1use super::{
8 BatchStep, SemanticIntentAction, WorkflowDefinition, WorkflowDraft, WorkflowTransactionClass,
9 WorkflowValidationError,
10};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use sha2::{Digest, Sha256};
14use std::collections::{BTreeMap, BTreeSet};
15use std::fmt;
16
17pub const WORKFLOW_AUTHORING_SCHEMA_VERSION: u32 = 1;
18const MAX_SOURCE_BYTES: usize = 256 * 1024;
19const MAX_DIAGNOSTIC_MESSAGE_BYTES: usize = 512;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "lowercase")]
24pub enum WorkflowAuthoringFormat {
25 Yaml,
26 Json,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
31#[serde(rename_all = "lowercase")]
32pub enum WorkflowDiagnosticSeverity {
33 Advisory,
34 Warning,
35 Error,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase", deny_unknown_fields)]
41pub struct WorkflowDiagnostic {
42 pub code: String,
43 pub severity: WorkflowDiagnosticSeverity,
44 pub message: String,
45 pub path: String,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub line: Option<usize>,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub column: Option<usize>,
50 pub remediation: String,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
56#[serde(rename_all = "camelCase", deny_unknown_fields)]
57pub struct WorkflowAuthoringDocument {
58 pub schema_version: u32,
59 pub source_format: WorkflowAuthoringFormat,
60 pub source_hash: String,
61 pub definition: WorkflowDefinition,
62 pub canonical_json: String,
63 pub diagnostics: Vec<WorkflowDiagnostic>,
64}
65
66#[derive(Debug, Clone, Serialize)]
69#[serde(rename_all = "camelCase")]
70pub struct WorkflowCompileError {
71 pub format: WorkflowAuthoringFormat,
72 pub diagnostics: Vec<WorkflowDiagnostic>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase", deny_unknown_fields)]
78pub struct WorkflowPreview {
79 pub schema_version: u32,
80 pub name: String,
81 pub workflow_version: String,
82 pub input_names: Vec<String>,
83 pub steps: Vec<WorkflowPreviewStep>,
84 pub has_terminal_condition: bool,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase", deny_unknown_fields)]
91pub struct WorkflowPreviewStep {
92 pub id: String,
93 pub action: String,
94 pub intent: bool,
95 pub transaction: WorkflowTransactionClass,
96 pub has_postcondition: bool,
97 pub max_retries: u32,
98 pub repeat: u32,
99 pub input_names: Vec<String>,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum WorkflowDiffRisk {
106 Advisory,
107 Warning,
108 Breaking,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114pub enum WorkflowDiffChangeKind {
115 Added,
116 Removed,
117 Changed,
118 Reordered,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123#[serde(rename_all = "camelCase", deny_unknown_fields)]
124pub struct WorkflowDiffChange {
125 pub kind: WorkflowDiffChangeKind,
126 pub path: String,
127 pub risk: WorkflowDiffRisk,
128 pub summary: String,
129 pub guidance: String,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
134#[serde(rename_all = "camelCase", deny_unknown_fields)]
135pub struct WorkflowDiff {
136 pub schema_version: u32,
137 pub before_hash: String,
138 pub after_hash: String,
139 pub breaking: bool,
140 pub changes: Vec<WorkflowDiffChange>,
141}
142
143#[derive(Debug, Clone, Deserialize)]
145#[serde(rename_all = "camelCase", deny_unknown_fields)]
146pub struct WorkflowRecordingEvent {
147 pub id: String,
148 pub request: super::SemanticIntentRequest,
149 pub result: super::SemanticIntentResult,
150 #[serde(default)]
151 pub input_name: Option<String>,
152 pub transaction: WorkflowTransactionClass,
153 #[serde(default)]
154 pub expect: Option<super::VerificationPredicate>,
155}
156
157#[derive(Debug, Clone, Deserialize)]
159#[serde(rename_all = "camelCase", deny_unknown_fields)]
160pub struct WorkflowRecordingSession {
161 pub schema_version: u32,
162 pub name: String,
163 pub workflow_version: String,
164 pub events: Vec<WorkflowRecordingEvent>,
165}
166
167impl fmt::Display for WorkflowCompileError {
168 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
169 if let Some(diagnostic) = self.diagnostics.first() {
170 write!(formatter, "{}: {}", diagnostic.code, diagnostic.message)
171 } else {
172 formatter.write_str("workflow source compilation failed")
173 }
174 }
175}
176
177impl std::error::Error for WorkflowCompileError {}
178
179pub fn compile_workflow(
181 source: &str,
182 format: WorkflowAuthoringFormat,
183) -> Result<WorkflowAuthoringDocument, WorkflowCompileError> {
184 if source.is_empty() || source.len() > MAX_SOURCE_BYTES {
185 return Err(compile_error(
186 format,
187 diagnostic(
188 "authoring.source_size",
189 WorkflowDiagnosticSeverity::Error,
190 "source is empty or exceeds the 256 KiB authoring limit",
191 "$",
192 None,
193 None,
194 "Provide a non-empty workflow smaller than 256 KiB.",
195 ),
196 ));
197 }
198 let value = match format {
199 WorkflowAuthoringFormat::Yaml => serde_yaml::from_str::<serde_yaml::Value>(source)
200 .map_err(|error| {
201 let location = error.location();
202 compile_error(
203 format,
204 diagnostic(
205 "authoring.parse",
206 WorkflowDiagnosticSeverity::Error,
207 "invalid YAML workflow source",
208 "$",
209 location.as_ref().map(|location| location.line()),
210 location.as_ref().map(|location| location.column()),
211 "Fix the YAML syntax at the reported location.",
212 ),
213 )
214 })
215 .and_then(|value| {
216 serde_json::to_value(value).map_err(|_| {
217 compile_error(
218 format,
219 diagnostic(
220 "authoring.parse",
221 WorkflowDiagnosticSeverity::Error,
222 "YAML value could not be represented as JSON",
223 "$",
224 None,
225 None,
226 "Use JSON-compatible scalar, array, and object values.",
227 ),
228 )
229 })
230 })?,
231 WorkflowAuthoringFormat::Json => {
232 serde_json::from_str::<Value>(source).map_err(|error| {
233 compile_error(
234 format,
235 diagnostic(
236 "authoring.parse",
237 WorkflowDiagnosticSeverity::Error,
238 "invalid JSON workflow source",
239 "$",
240 Some(error.line()),
241 Some(error.column()),
242 "Fix the JSON syntax at the reported location.",
243 ),
244 )
245 })?
246 }
247 };
248 let mut definition = WorkflowDefinition::from_value(value).map_err(|error| {
249 compile_error(
250 format,
251 diagnostic(
252 "workflow.validation",
253 WorkflowDiagnosticSeverity::Error,
254 error.reason,
255 error.path,
256 None,
257 None,
258 "Correct the workflow field named by the diagnostic path.",
259 ),
260 )
261 })?;
262 let mut diagnostics = infer_sensitive_inputs(&mut definition);
263 let canonical_json = definition.to_canonical_json().map_err(|error| {
264 compile_error(
265 format,
266 diagnostic(
267 "workflow.canonicalization",
268 WorkflowDiagnosticSeverity::Error,
269 error.to_string(),
270 "$",
271 None,
272 None,
273 "Correct the workflow definition before compiling it.",
274 ),
275 )
276 })?;
277 diagnostics.extend(analyze_workflow(&definition));
278 Ok(WorkflowAuthoringDocument {
279 schema_version: WORKFLOW_AUTHORING_SCHEMA_VERSION,
280 source_format: format,
281 source_hash: source_hash(source),
282 definition,
283 canonical_json,
284 diagnostics,
285 })
286}
287
288pub fn compile_workflow_yaml(
290 source: &str,
291) -> Result<WorkflowAuthoringDocument, WorkflowCompileError> {
292 compile_workflow(source, WorkflowAuthoringFormat::Yaml)
293}
294
295pub fn compile_workflow_json(
297 source: &str,
298) -> Result<WorkflowAuthoringDocument, WorkflowCompileError> {
299 compile_workflow(source, WorkflowAuthoringFormat::Json)
300}
301
302pub fn format_workflow_yaml(
304 definition: &WorkflowDefinition,
305) -> Result<String, WorkflowCompileError> {
306 definition.validate().map_err(|error| {
307 compile_error(
308 WorkflowAuthoringFormat::Yaml,
309 diagnostic(
310 "workflow.validation",
311 WorkflowDiagnosticSeverity::Error,
312 error.reason,
313 error.path,
314 None,
315 None,
316 "Correct the workflow before formatting it.",
317 ),
318 )
319 })?;
320 serde_yaml::to_string(definition).map_err(|error| {
321 compile_error(
322 WorkflowAuthoringFormat::Yaml,
323 diagnostic(
324 "authoring.format",
325 WorkflowDiagnosticSeverity::Error,
326 "workflow could not be rendered as YAML",
327 "$",
328 None,
329 None,
330 error.to_string(),
331 ),
332 )
333 })
334}
335
336pub fn analyze_workflow(definition: &WorkflowDefinition) -> Vec<WorkflowDiagnostic> {
338 let mut diagnostics = Vec::new();
339 let declared_inputs = definition.inputs.keys().cloned().collect::<BTreeSet<_>>();
340 if let Ok(value) = serde_json::to_value(definition) {
341 collect_input_references(&value, "$", &declared_inputs, &mut diagnostics);
342 }
343 for (index, step) in definition.steps.iter().enumerate() {
344 let path = format!("steps[{index}]");
345 let mutating = step.intent.as_ref().map_or_else(
346 || batch_step_is_mutating(&step.action),
347 |intent| intent_action_is_mutating(intent.action),
348 );
349 if mutating && step.expect.is_none() {
350 diagnostics.push(diagnostic(
351 "workflow.missing_postcondition",
352 WorkflowDiagnosticSeverity::Warning,
353 "mutating step has no explicit postcondition",
354 format!("{path}.expect"),
355 None,
356 None,
357 "Add a bounded expect predicate or review the warning explicitly.",
358 ));
359 }
360 if mutating && step.transaction == WorkflowTransactionClass::Unknown {
361 diagnostics.push(diagnostic(
362 "workflow.unknown_transaction",
363 WorkflowDiagnosticSeverity::Warning,
364 "mutating step has unknown retry and duplicate-effect behavior",
365 format!("{path}.transaction"),
366 None,
367 None,
368 "Classify the step as readOnly, idempotent, conditionallyIdempotent, or nonIdempotent.",
369 ));
370 }
371 if step.max_retries > 0 && !step.transaction.permits_pre_dispatch_retry() {
372 diagnostics.push(diagnostic(
373 "workflow.unsafe_retry",
374 WorkflowDiagnosticSeverity::Error,
375 "step requests retries that its transaction class cannot prove safe",
376 format!("{path}.maxRetries"),
377 None,
378 None,
379 "Set maxRetries to zero or choose a retry-safe transaction class with an effect marker.",
380 ));
381 }
382 if step.transaction == WorkflowTransactionClass::NonIdempotent
383 && step.idempotency_key.is_none()
384 {
385 diagnostics.push(diagnostic(
386 "workflow.non_idempotent_without_marker",
387 WorkflowDiagnosticSeverity::Error,
388 "non-idempotent step has no duplicate-effect marker",
389 format!("{path}.idempotencyKey"),
390 None,
391 None,
392 "Add an idempotency key or classify the step differently after review.",
393 ));
394 }
395 match &step.action {
396 BatchStep::Type { text, .. } if !contains_input_placeholder(text) => {
397 diagnostics.push(diagnostic(
398 "workflow.literal_input_value",
399 WorkflowDiagnosticSeverity::Error,
400 "type step contains a literal value instead of an input placeholder",
401 format!("{path}.text"),
402 None,
403 None,
404 "Declare an input and use ${inputs.name}; provide the value at execution time.",
405 ));
406 }
407 BatchStep::Select { value, .. } if !contains_input_placeholder(value) => {
408 diagnostics.push(diagnostic(
409 "workflow.literal_input_value",
410 WorkflowDiagnosticSeverity::Error,
411 "select step contains a literal value instead of an input placeholder",
412 format!("{path}.value"),
413 None,
414 None,
415 "Declare an input and use ${inputs.name}; provide the value at execution time.",
416 ));
417 }
418 _ => {}
419 }
420 if let Some(target) = workflow_target(&step.action)
421 && let Some(reason) = fragile_target_reason(target)
422 {
423 diagnostics.push(diagnostic(
424 "workflow.fragile_selector",
425 WorkflowDiagnosticSeverity::Warning,
426 format!("workflow target uses a {reason} locator"),
427 format!("{path}.target"),
428 None,
429 None,
430 "Prefer a semantic role/name target or a reviewed semantic intent.",
431 ));
432 }
433 if let Some(intent) = &step.intent
434 && matches!(
435 intent.action,
436 SemanticIntentAction::Type | SemanticIntentAction::Select
437 )
438 && !intent
439 .value
440 .as_deref()
441 .is_some_and(contains_input_placeholder)
442 {
443 diagnostics.push(diagnostic(
444 "workflow.literal_input_value",
445 WorkflowDiagnosticSeverity::Error,
446 "value-bearing intent contains a literal value instead of an input placeholder",
447 format!("{path}.intent.value"),
448 None,
449 None,
450 "Declare an input and use ${inputs.name}; provide the value at execution time.",
451 ));
452 }
453 }
454 diagnostics.sort_by(|left, right| {
455 left.path
456 .cmp(&right.path)
457 .then(left.code.cmp(&right.code))
458 .then(left.severity.cmp(&right.severity))
459 });
460 diagnostics
461}
462
463pub fn preview_workflow(
467 definition: &WorkflowDefinition,
468) -> Result<WorkflowPreview, WorkflowCompileError> {
469 definition.validate().map_err(|error| {
470 compile_error(
471 WorkflowAuthoringFormat::Json,
472 diagnostic(
473 "workflow.validation",
474 WorkflowDiagnosticSeverity::Error,
475 error.reason,
476 error.path,
477 None,
478 None,
479 "Correct the workflow before previewing it.",
480 ),
481 )
482 })?;
483 let input_names = definition.inputs.keys().cloned().collect::<Vec<_>>();
484 let steps = definition
485 .steps
486 .iter()
487 .map(|step| {
488 let serialized = serde_json::to_value(step).unwrap_or(Value::Null);
489 let input_names = input_names_in_value(&serialized);
490 let action = step
491 .intent
492 .as_ref()
493 .map(|intent| {
494 serde_json::to_value(intent.action)
495 .ok()
496 .and_then(|value| value.as_str().map(ToOwned::to_owned))
497 .unwrap_or_else(|| "intent".into())
498 })
499 .or_else(|| {
500 serialized
501 .get("action")
502 .and_then(Value::as_str)
503 .map(ToOwned::to_owned)
504 })
505 .unwrap_or_else(|| "unknown".into());
506 WorkflowPreviewStep {
507 id: step.id.clone(),
508 action,
509 intent: step.intent.is_some(),
510 transaction: step.transaction,
511 has_postcondition: step.expect.is_some(),
512 max_retries: step.max_retries,
513 repeat: step.repeat,
514 input_names,
515 }
516 })
517 .collect();
518 Ok(WorkflowPreview {
519 schema_version: definition.schema_version,
520 name: definition.name.clone(),
521 workflow_version: definition.workflow_version.clone(),
522 input_names,
523 steps,
524 has_terminal_condition: true,
525 })
526}
527
528pub fn diff_workflows(
530 before: &WorkflowDefinition,
531 after: &WorkflowDefinition,
532) -> Result<WorkflowDiff, WorkflowCompileError> {
533 for (label, definition) in [("before", before), ("after", after)] {
534 definition.validate().map_err(|error| {
535 compile_error(
536 WorkflowAuthoringFormat::Json,
537 diagnostic(
538 "workflow.validation",
539 WorkflowDiagnosticSeverity::Error,
540 error.reason,
541 format!("{label}.{}", error.path),
542 None,
543 None,
544 "Correct both workflow definitions before diffing them.",
545 ),
546 )
547 })?;
548 }
549
550 let before_json = before.to_canonical_json().map_err(|error| {
551 compile_error(
552 WorkflowAuthoringFormat::Json,
553 diagnostic(
554 "workflow.canonicalization",
555 WorkflowDiagnosticSeverity::Error,
556 error.to_string(),
557 "before",
558 None,
559 None,
560 "Correct the earlier workflow definition.",
561 ),
562 )
563 })?;
564 let after_json = after.to_canonical_json().map_err(|error| {
565 compile_error(
566 WorkflowAuthoringFormat::Json,
567 diagnostic(
568 "workflow.canonicalization",
569 WorkflowDiagnosticSeverity::Error,
570 error.to_string(),
571 "after",
572 None,
573 None,
574 "Correct the later workflow definition.",
575 ),
576 )
577 })?;
578 let mut changes = Vec::new();
579 let mut before_steps = BTreeMap::new();
580 let mut after_steps = BTreeMap::new();
581 for (index, step) in before.steps.iter().enumerate() {
582 before_steps.insert(step.id.as_str(), (index, step));
583 }
584 for (index, step) in after.steps.iter().enumerate() {
585 after_steps.insert(step.id.as_str(), (index, step));
586 }
587 let step_ids = before_steps
588 .keys()
589 .chain(after_steps.keys())
590 .copied()
591 .collect::<BTreeSet<_>>();
592 for id in step_ids {
593 match (before_steps.get(id), after_steps.get(id)) {
594 (None, Some(_)) => changes.push(diff_change(
595 WorkflowDiffChangeKind::Added,
596 format!("steps.{id}"),
597 WorkflowDiffRisk::Warning,
598 "step added",
599 "Review its effect, transaction class, and postcondition before approval.",
600 )),
601 (Some(_), None) => changes.push(diff_change(
602 WorkflowDiffChangeKind::Removed,
603 format!("steps.{id}"),
604 WorkflowDiffRisk::Breaking,
605 "step removed",
606 "Confirm that the workflow no longer requires this operation and update downstream expectations.",
607 )),
608 (Some((before_index, before_step)), Some((after_index, after_step))) => {
609 if before_index != after_index {
610 changes.push(diff_change(
611 WorkflowDiffChangeKind::Reordered,
612 format!("steps.{id}"),
613 WorkflowDiffRisk::Warning,
614 "step order changed",
615 "Review dependencies and revision-sensitive effects across the reordered steps.",
616 ));
617 }
618 let before_value = serde_json::to_value(before_step).unwrap_or(Value::Null);
619 let after_value = serde_json::to_value(after_step).unwrap_or(Value::Null);
620 if before_value != after_value {
621 let fields = changed_object_fields(&before_value, &after_value);
622 let breaking = fields.iter().any(|field| {
623 matches!(
624 field.as_str(),
625 "action"
626 | "intent"
627 | "expect"
628 | "transaction"
629 | "idempotencyKey"
630 | "maxRetries"
631 | "repeat"
632 )
633 });
634 let summary = if fields.is_empty() {
635 "step definition changed".to_string()
636 } else {
637 format!("step definition changed: {}", fields.join(", "))
638 };
639 changes.push(diff_change(
640 WorkflowDiffChangeKind::Changed,
641 format!("steps.{id}"),
642 if breaking {
643 WorkflowDiffRisk::Breaking
644 } else {
645 WorkflowDiffRisk::Warning
646 },
647 &summary,
648 "Review the changed action, effect classification, retry policy, and postcondition before approval.",
649 ));
650 }
651 }
652 (None, None) => unreachable!("step ID union contains an impossible empty entry"),
653 }
654 }
655 if before.inputs != after.inputs {
656 changes.push(diff_change(
657 WorkflowDiffChangeKind::Changed,
658 "inputs".into(),
659 WorkflowDiffRisk::Breaking,
660 "input declarations changed",
661 "Review requiredness, type, maximum length, and sensitivity before migrating callers.",
662 ));
663 }
664 if before.terminal_condition != after.terminal_condition {
665 changes.push(diff_change(
666 WorkflowDiffChangeKind::Changed,
667 "terminalCondition".into(),
668 WorkflowDiffRisk::Breaking,
669 "terminal condition changed",
670 "Re-run completion tests and confirm the new condition proves the intended outcome.",
671 ));
672 }
673 changes.sort_by(|left, right| {
674 left.path
675 .cmp(&right.path)
676 .then((left.kind as u8).cmp(&(right.kind as u8)))
677 });
678 Ok(WorkflowDiff {
679 schema_version: WORKFLOW_AUTHORING_SCHEMA_VERSION,
680 before_hash: source_hash(&before_json),
681 after_hash: source_hash(&after_json),
682 breaking: changes
683 .iter()
684 .any(|change| change.risk == WorkflowDiffRisk::Breaking),
685 changes,
686 })
687}
688
689pub fn record_semantic_events(
691 session: WorkflowRecordingSession,
692) -> Result<WorkflowDraft, WorkflowValidationError> {
693 if session.schema_version != WORKFLOW_AUTHORING_SCHEMA_VERSION {
694 return Err(WorkflowValidationError::new(
695 "schemaVersion",
696 format!(
697 "unsupported recording schema version {}; expected {}",
698 session.schema_version, WORKFLOW_AUTHORING_SCHEMA_VERSION
699 ),
700 ));
701 }
702 let mut recorder = super::WorkflowRecorder::new(session.name, session.workflow_version);
703 for event in session.events {
704 recorder.record_semantic_intent(
705 event.id,
706 &event.request,
707 &event.result,
708 event.input_name,
709 event.transaction,
710 event.expect,
711 )?;
712 }
713 Ok(recorder.draft().clone())
714}
715
716fn diff_change(
717 kind: WorkflowDiffChangeKind,
718 path: String,
719 risk: WorkflowDiffRisk,
720 summary: &str,
721 guidance: &str,
722) -> WorkflowDiffChange {
723 WorkflowDiffChange {
724 kind,
725 path,
726 risk,
727 summary: summary.into(),
728 guidance: guidance.into(),
729 }
730}
731
732fn changed_object_fields(before: &Value, after: &Value) -> Vec<String> {
733 let (Some(before), Some(after)) = (before.as_object(), after.as_object()) else {
734 return Vec::new();
735 };
736 before
737 .keys()
738 .chain(after.keys())
739 .filter(|key| before.get(*key) != after.get(*key))
740 .cloned()
741 .collect::<BTreeSet<_>>()
742 .into_iter()
743 .collect()
744}
745
746fn infer_sensitive_inputs(definition: &mut WorkflowDefinition) -> Vec<WorkflowDiagnostic> {
747 let mut diagnostics = Vec::new();
748 for (name, input) in &mut definition.inputs {
749 if !looks_sensitive_input_name(name) {
750 continue;
751 }
752 match input.sensitive {
753 None => {
754 input.sensitive = Some(true);
755 diagnostics.push(diagnostic(
756 "workflow.sensitive_input_inferred",
757 WorkflowDiagnosticSeverity::Advisory,
758 "input marked sensitive from its name",
759 format!("inputs.{name}.sensitive"),
760 None,
761 None,
762 "Keep the value outside the workflow source and provide it at execution time.",
763 ));
764 }
765 Some(false) => diagnostics.push(diagnostic(
766 "workflow.sensitive_input_denied",
767 WorkflowDiagnosticSeverity::Error,
768 "input name suggests sensitive data but sensitive was explicitly disabled",
769 format!("inputs.{name}.sensitive"),
770 None,
771 None,
772 "Set sensitive: true or rename the input after reviewing its data class.",
773 )),
774 Some(true) => {}
775 }
776 }
777 diagnostics
778}
779
780fn looks_sensitive_input_name(name: &str) -> bool {
781 let normalized = name.to_ascii_lowercase();
782 [
783 "password", "passwd", "secret", "token", "api_key", "apikey", "cookie",
784 ]
785 .iter()
786 .any(|term| normalized.contains(term))
787}
788
789fn contains_input_placeholder(value: &str) -> bool {
790 value.contains("${inputs.")
791}
792
793fn input_names_in_value(value: &Value) -> Vec<String> {
794 let mut names = BTreeSet::new();
795 collect_input_names(value, &mut names);
796 names.into_iter().collect()
797}
798
799fn collect_input_names(value: &Value, names: &mut BTreeSet<String>) {
800 match value {
801 Value::String(text) => {
802 let marker = "${inputs.";
803 let mut remainder = text.as_str();
804 while let Some(start) = remainder.find(marker) {
805 let placeholder = &remainder[start..];
806 let Some(end) = placeholder.find('}') else {
807 break;
808 };
809 let name = &placeholder[marker.len()..end];
810 if !name.is_empty() && !name.contains(['{', '}', '$']) {
811 names.insert(name.to_string());
812 }
813 remainder = &placeholder[end + 1..];
814 }
815 }
816 Value::Array(items) => {
817 for item in items {
818 collect_input_names(item, names);
819 }
820 }
821 Value::Object(fields) => {
822 for item in fields.values() {
823 collect_input_names(item, names);
824 }
825 }
826 Value::Null | Value::Bool(_) | Value::Number(_) => {}
827 }
828}
829
830fn collect_input_references(
831 value: &Value,
832 path: &str,
833 declared_inputs: &BTreeSet<String>,
834 diagnostics: &mut Vec<WorkflowDiagnostic>,
835) {
836 match value {
837 Value::String(text) => {
838 let marker = "${inputs.";
839 let mut remainder = text.as_str();
840 while let Some(start) = remainder.find(marker) {
841 let placeholder = &remainder[start..];
842 let Some(end) = placeholder.find('}') else {
843 diagnostics.push(diagnostic(
844 "workflow.invalid_input_reference",
845 WorkflowDiagnosticSeverity::Error,
846 "input placeholder is missing a closing brace",
847 path,
848 None,
849 None,
850 "Use a complete ${inputs.name} placeholder.",
851 ));
852 break;
853 };
854 let name = &placeholder[marker.len()..end];
855 if name.is_empty() || name.contains(['{', '}', '$']) {
856 diagnostics.push(diagnostic(
857 "workflow.invalid_input_reference",
858 WorkflowDiagnosticSeverity::Error,
859 "input placeholder name is invalid",
860 path,
861 None,
862 None,
863 "Use a simple declared input name inside ${inputs.name}.",
864 ));
865 } else if !declared_inputs.contains(name) {
866 diagnostics.push(diagnostic(
867 "workflow.undefined_input",
868 WorkflowDiagnosticSeverity::Error,
869 "workflow references an input that is not declared",
870 path,
871 None,
872 None,
873 "Declare the input or remove the placeholder before execution.",
874 ));
875 }
876 remainder = &placeholder[end + 1..];
877 }
878 }
879 Value::Array(items) => {
880 for (index, item) in items.iter().enumerate() {
881 collect_input_references(
882 item,
883 &format!("{path}[{index}]"),
884 declared_inputs,
885 diagnostics,
886 );
887 }
888 }
889 Value::Object(fields) => {
890 for (name, item) in fields {
891 collect_input_references(
892 item,
893 &format!("{path}.{name}"),
894 declared_inputs,
895 diagnostics,
896 );
897 }
898 }
899 Value::Null | Value::Bool(_) | Value::Number(_) => {}
900 }
901}
902
903fn batch_step_is_mutating(step: &BatchStep) -> bool {
904 matches!(
905 step,
906 BatchStep::Navigate { .. }
907 | BatchStep::Click { .. }
908 | BatchStep::Type { .. }
909 | BatchStep::Check { .. }
910 | BatchStep::Uncheck { .. }
911 | BatchStep::Select { .. }
912 | BatchStep::Clear { .. }
913 | BatchStep::Scroll { .. }
914 | BatchStep::Evaluate { .. }
915 | BatchStep::AcceptDialog
916 | BatchStep::DismissDialog
917 )
918}
919
920fn intent_action_is_mutating(action: SemanticIntentAction) -> bool {
921 !matches!(
922 action,
923 SemanticIntentAction::Inspect | SemanticIntentAction::Extract
924 )
925}
926
927fn workflow_target(action: &BatchStep) -> Option<&str> {
928 match action {
929 BatchStep::Click { target }
930 | BatchStep::Check { target }
931 | BatchStep::Uncheck { target }
932 | BatchStep::Select { target, .. }
933 | BatchStep::Clear { target } => Some(target),
934 BatchStep::Type { target, .. } => target.as_deref(),
935 _ => None,
936 }
937}
938
939fn fragile_target_reason(target: &str) -> Option<&'static str> {
940 let target = target.trim();
941 if target.starts_with("css=") {
942 Some("CSS selector")
943 } else if target.starts_with("ordinal=") {
944 Some("ordinal")
945 } else if target.starts_with("ref=") || target.contains(" | ref=") {
946 Some("revision-scoped reference")
947 } else if target.starts_with("x=") || target.starts_with("y=") {
948 Some("coordinate")
949 } else {
950 None
951 }
952}
953
954fn source_hash(source: &str) -> String {
955 let digest = Sha256::digest(source.as_bytes());
956 format!("sha256:{digest:x}")
957}
958
959fn compile_error(
960 format: WorkflowAuthoringFormat,
961 diagnostic: WorkflowDiagnostic,
962) -> WorkflowCompileError {
963 WorkflowCompileError {
964 format,
965 diagnostics: vec![diagnostic],
966 }
967}
968
969fn diagnostic(
970 code: impl Into<String>,
971 severity: WorkflowDiagnosticSeverity,
972 message: impl Into<String>,
973 path: impl Into<String>,
974 line: Option<usize>,
975 column: Option<usize>,
976 remediation: impl Into<String>,
977) -> WorkflowDiagnostic {
978 WorkflowDiagnostic {
979 code: code.into(),
980 severity,
981 message: bound_text(&message.into(), MAX_DIAGNOSTIC_MESSAGE_BYTES),
982 path: path.into(),
983 line,
984 column,
985 remediation: bound_text(&remediation.into(), MAX_DIAGNOSTIC_MESSAGE_BYTES),
986 }
987}
988
989fn bound_text(value: &str, maximum: usize) -> String {
990 if value.len() <= maximum {
991 return value.to_string();
992 }
993 let mut end = maximum.saturating_sub(15);
994 while end > 0 && !value.is_char_boundary(end) {
995 end -= 1;
996 }
997 format!("{}…[truncated]", &value[..end])
998}
999
1000#[cfg(test)]
1001mod tests {
1002 use super::*;
1003
1004 const YAML: &str = r#"
1005schemaVersion: 1
1006name: docs-search
1007workflowVersion: 1.0.0
1008inputs: {}
1009budgets:
1010 maxSteps: 1
1011 maxDurationMs: 30000
1012 maxRetries: 0
1013 maxExtractedBytes: 4096
1014steps:
1015 - id: search
1016 action: navigate
1017 url: https://example.test/docs
1018 transaction: read_only
1019 expect:
1020 urlEquals: https://example.test/docs
1021terminalCondition:
1022 urlEquals: https://example.test/docs
1023outputs: {}
1024"#;
1025
1026 #[test]
1027 fn yaml_compiles_to_runtime_contract_and_stable_hash() {
1028 let document = compile_workflow_yaml(YAML).unwrap();
1029 assert_eq!(document.schema_version, 1);
1030 assert_eq!(document.definition.name, "docs-search");
1031 assert!(document.canonical_json.contains("schemaVersion"));
1032 assert_eq!(document.source_hash, source_hash(YAML));
1033 }
1034
1035 #[test]
1036 fn yaml_and_json_share_canonical_semantics() {
1037 let yaml = compile_workflow_yaml(YAML).unwrap();
1038 let json = compile_workflow_json(&yaml.canonical_json).unwrap();
1039 assert_eq!(yaml.canonical_json, json.canonical_json);
1040 }
1041
1042 #[test]
1043 fn parser_reports_location_without_echoing_source() {
1044 let error = compile_workflow_yaml("schemaVersion: [").unwrap_err();
1045 let diagnostic = &error.diagnostics[0];
1046 assert_eq!(diagnostic.code, "authoring.parse");
1047 assert!(diagnostic.line.is_some());
1048 assert!(!diagnostic.message.contains("schemaVersion: ["));
1049 }
1050
1051 #[test]
1052 fn analyzer_reports_mutation_and_sensitive_input_findings() {
1053 let source = YAML
1054 .replace(
1055 "inputs: {}",
1056 "inputs:\n password:\n type: string\n required: true",
1057 )
1058 .replace(
1059 " expect:\n urlEquals: https://example.test/docs\n",
1060 "",
1061 );
1062 let document = compile_workflow_yaml(&source).unwrap();
1063 assert!(
1064 document
1065 .diagnostics
1066 .iter()
1067 .any(|diagnostic| diagnostic.code == "workflow.missing_postcondition")
1068 );
1069 assert!(
1070 document
1071 .diagnostics
1072 .iter()
1073 .any(|diagnostic| diagnostic.code == "workflow.sensitive_input_inferred")
1074 );
1075 assert_eq!(document.definition.inputs["password"].sensitive, Some(true));
1076 }
1077
1078 #[test]
1079 fn analyzer_reports_undefined_inputs_and_literal_values() {
1080 let source = r#"
1081schemaVersion: 1
1082name: input-check
1083workflowVersion: 1.0.0
1084inputs: {}
1085budgets:
1086 maxSteps: 1
1087 maxDurationMs: 30000
1088 maxRetries: 0
1089 maxExtractedBytes: 4096
1090steps:
1091 - id: type
1092 action: type
1093 text: literal-secret
1094 target: "role=textbox;name=Search"
1095 transaction: non_idempotent
1096 idempotencyKey: type-once
1097 expect:
1098 textContains: "${inputs.missing}"
1099terminalCondition:
1100 textContains: done
1101outputs: {}
1102"#;
1103 let document = compile_workflow_yaml(source).unwrap();
1104 assert!(
1105 document
1106 .diagnostics
1107 .iter()
1108 .any(|diagnostic| diagnostic.code == "workflow.undefined_input")
1109 );
1110 assert!(
1111 document
1112 .diagnostics
1113 .iter()
1114 .any(|diagnostic| diagnostic.code == "workflow.literal_input_value")
1115 );
1116 }
1117
1118 #[test]
1119 fn analyzer_flags_fragile_locator_targets() {
1120 let source = YAML.replace(
1121 " action: navigate\n url: https://example.test/docs\n transaction: read_only",
1122 " action: click\n target: css=.submit\n transaction: idempotent",
1123 );
1124 let document = compile_workflow_yaml(&source).unwrap();
1125 assert!(
1126 document
1127 .diagnostics
1128 .iter()
1129 .any(|diagnostic| diagnostic.code == "workflow.fragile_selector")
1130 );
1131 }
1132
1133 #[test]
1134 fn formatter_rejects_invalid_definition() {
1135 let mut document = compile_workflow_yaml(YAML).unwrap();
1136 document.definition.steps.clear();
1137 let error = format_workflow_yaml(&document.definition).unwrap_err();
1138 assert_eq!(error.diagnostics[0].code, "workflow.validation");
1139 }
1140
1141 #[test]
1142 fn preview_omits_values_and_lists_input_shape() {
1143 let source = YAML.replace("inputs: {}", "inputs:\n query:\n type: string");
1144 let document = compile_workflow_yaml(&source).unwrap();
1145 let preview = preview_workflow(&document.definition).unwrap();
1146 assert_eq!(preview.input_names, vec!["query"]);
1147 assert_eq!(preview.steps[0].action, "navigate");
1148 assert!(preview.steps[0].has_postcondition);
1149 let serialized = serde_json::to_string(&preview).unwrap();
1150 assert!(!serialized.contains("example.test/docs"));
1151 assert!(!serialized.contains("${inputs.query}"));
1152 }
1153
1154 #[test]
1155 fn diff_is_stable_and_redacts_step_values() {
1156 let before = compile_workflow_yaml(YAML).unwrap();
1157 let after = compile_workflow_yaml(
1158 &YAML.replace("transaction: read_only", "transaction: idempotent"),
1159 )
1160 .unwrap();
1161 let diff = diff_workflows(&before.definition, &after.definition).unwrap();
1162 assert!(diff.breaking);
1163 assert_eq!(diff.changes.len(), 1);
1164 assert_eq!(diff.changes[0].path, "steps.search");
1165 assert_eq!(diff.changes[0].kind, WorkflowDiffChangeKind::Changed);
1166 let serialized = serde_json::to_string(&diff).unwrap();
1167 assert!(!serialized.contains("example.test/docs"));
1168 assert_ne!(diff.before_hash, diff.after_hash);
1169 }
1170
1171 #[test]
1172 fn recording_session_is_versioned_and_stays_a_draft() {
1173 let draft = record_semantic_events(WorkflowRecordingSession {
1174 schema_version: WORKFLOW_AUTHORING_SCHEMA_VERSION,
1175 name: "empty-recording".into(),
1176 workflow_version: "1.0.0".into(),
1177 events: Vec::new(),
1178 })
1179 .unwrap();
1180 assert_eq!(draft.name, "empty-recording");
1181 assert!(draft.steps.is_empty());
1182 let error = record_semantic_events(WorkflowRecordingSession {
1183 schema_version: 99,
1184 name: "bad".into(),
1185 workflow_version: "1.0.0".into(),
1186 events: Vec::new(),
1187 })
1188 .unwrap_err();
1189 assert_eq!(error.path, "schemaVersion");
1190 }
1191}