Skip to main content

glass/browser/session/
authoring.rs

1//! Human-readable workflow authoring and deterministic compilation.
2//!
3//! YAML is an authoring format only. The runtime consumes the existing
4//! [`super::WorkflowDefinition`] contract after this module parses, validates,
5//! and analyzes the source.
6
7use 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/// Source syntax accepted by the authoring compiler.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "lowercase")]
24pub enum WorkflowAuthoringFormat {
25    Yaml,
26    Json,
27}
28
29/// Stable diagnostic severity used by static analysis and compilation tools.
30#[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/// One source-linked authoring or analysis diagnostic.
39#[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/// Compiled authoring output. `canonical_json` is the only artifact consumed
54/// by the workflow runtime; source text is retained by the caller if needed.
55#[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/// Bounded failure returned when source cannot be converted into the runtime
67/// workflow contract.
68#[derive(Debug, Clone, Serialize)]
69#[serde(rename_all = "camelCase")]
70pub struct WorkflowCompileError {
71    pub format: WorkflowAuthoringFormat,
72    pub diagnostics: Vec<WorkflowDiagnostic>,
73}
74
75/// Redacted, browser-free execution preview for review and CI output.
76#[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/// One redacted preview entry. Values, selectors, URLs, and expressions are
88/// intentionally omitted; the preview describes execution shape only.
89#[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/// Risk attached to a workflow diff change.
103#[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/// Kind of deterministic workflow change.
112#[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/// One reviewable workflow migration change. It contains no action values.
122#[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/// Stable, value-free diff between two validated workflow definitions.
133#[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/// Explicit semantic evidence accepted by the offline recorder importer.
144#[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/// Versioned envelope for offline semantic recording input.
158#[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
179/// Compile YAML or canonical JSON into the validated workflow contract.
180pub 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
288/// Compile YAML workflow source.
289pub fn compile_workflow_yaml(
290    source: &str,
291) -> Result<WorkflowAuthoringDocument, WorkflowCompileError> {
292    compile_workflow(source, WorkflowAuthoringFormat::Yaml)
293}
294
295/// Compile JSON workflow source through the same canonical path as YAML.
296pub fn compile_workflow_json(
297    source: &str,
298) -> Result<WorkflowAuthoringDocument, WorkflowCompileError> {
299    compile_workflow(source, WorkflowAuthoringFormat::Json)
300}
301
302/// Render a validated workflow definition into deterministic YAML.
303pub 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
336/// Run deterministic safety and maintainability checks without a browser.
337pub 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
463/// Build a deterministic preview without resolving inputs or starting a
464/// browser. The output is safe to print in CI logs because action values are
465/// omitted and only input names are retained.
466pub 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
528/// Compare two validated workflow definitions without exposing their values.
529pub 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
689/// Turn explicit semantic resolution evidence into a reviewable draft.
690pub 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}