Skip to main content

agentd/engine/
model.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The **dialect-3 workflow model** (RFC 0027 §2–§5, §8): a named DAG of steps
3//! beginning at start nodes, parsed from a JSON/YAML document with a strict
4//! per-kind field check (unknown fields are refused — the RFC 0021 §4.1 typo
5//! shield carried over), validated for acyclicity, reachability, `finish`
6//! reachability, dependency existence, schema well-formedness, CEL
7//! compilation and the caps. The node catalogue is one table ([`KINDS`]) —
8//! the validator, the executor and `--workflow-schema` all read it.
9
10use crate::jsonschema;
11use serde::{Deserialize, Serialize};
12use serde_json::{Map, Value, json};
13use std::collections::{BTreeMap, BTreeSet};
14
15/// The dialect this model speaks.
16pub const DIALECT: u32 = 3;
17/// Caps (RFC 0027 §8).
18pub const MAX_STEPS: usize = 512;
19pub const MAX_NESTING: usize = 4;
20pub const MAX_BATCH_PARALLEL: u64 = 8;
21pub const MAX_ITERATIONS: u64 = 10_000;
22pub const MAX_ID_LEN: usize = 64;
23
24/// A step kind's metadata.
25#[derive(Debug, Clone, Copy)]
26pub struct KindInfo {
27    pub name: &'static str,
28    /// A start node (a trigger).
29    pub start: bool,
30    /// Kind-specific fields (besides the cross-cutting ones).
31    pub fields: &'static [&'static str],
32    /// Required kind-specific fields.
33    pub required: &'static [&'static str],
34    /// Executable in this build (`false` = parses/validates but the run
35    /// engine refuses it: it lands in a later phase).
36    pub implemented: bool,
37    /// Has a nested body sub-DAG (`body: {steps: …}`) / branches.
38    pub nested: bool,
39}
40
41const fn k(
42    name: &'static str,
43    start: bool,
44    fields: &'static [&'static str],
45    required: &'static [&'static str],
46    implemented: bool,
47    nested: bool,
48) -> KindInfo {
49    KindInfo {
50        name,
51        start,
52        fields,
53        required,
54        implemented,
55        nested,
56    }
57}
58
59/// The node catalogue (RFC 0027 §4–§5). `implemented` marks the P3 executor
60/// subset; the rest arrives with the P4 engine.
61pub const KINDS: &[KindInfo] = &[
62    // ---- start nodes ----
63    k("once", true, &["policy", "inputs"], &[], true, false),
64    k("manual", true, &["inputs"], &[], true, false),
65    k(
66        "loop",
67        true,
68        &[
69            "interval",
70            "delay",
71            "until",
72            "max_iterations",
73            "backoff",
74            "inputs",
75        ],
76        &[],
77        true,
78        false,
79    ),
80    k(
81        "schedule",
82        true,
83        &["cron", "every", "tz", "jitter", "catch_up", "at", "inputs"],
84        &[],
85        true,
86        false,
87    ),
88    k(
89        "subscribe",
90        true,
91        &[
92            "server",
93            "uri",
94            "debounce_ms",
95            "coalesce",
96            "filter",
97            "deliver",
98            "on_no_listener",
99            "inputs",
100        ],
101        &["server", "uri"],
102        true,
103        false,
104    ),
105    k(
106        "signal",
107        true,
108        &["name", "filter", "deliver", "inputs"],
109        &["name"],
110        true,
111        false,
112    ),
113    k(
114        "event",
115        true,
116        &["on", "filter", "inputs"],
117        &["on"],
118        true,
119        false,
120    ),
121    k(
122        "a2a",
123        true,
124        &["command", "roles", "inputs"],
125        &[],
126        false,
127        false,
128    ),
129    k(
130        "webhook",
131        true,
132        &[
133            "path",
134            "methods",
135            "auth",
136            "parallelism",
137            "on_overflow",
138            "idempotency",
139            "respond",
140            "filter",
141            "inputs",
142        ],
143        &["path"],
144        true,
145        false,
146    ),
147    // ---- control ----
148    k(
149        "switch",
150        false,
151        &["on", "cases", "default"],
152        &["on", "cases"],
153        true,
154        false,
155    ),
156    k(
157        "parallel",
158        false,
159        &["branches", "on_error"],
160        &["branches"],
161        true,
162        true,
163    ),
164    k(
165        "foreach",
166        false,
167        &["over", "body", "batch", "collect", "on_error", "as"],
168        &["over", "body"],
169        true,
170        true,
171    ),
172    k(
173        "batch",
174        false,
175        &[
176            "over", "body", "by", "size", "parallel", "rate", "collect", "on_error",
177        ],
178        &["over", "body"],
179        true,
180        true,
181    ),
182    k(
183        "iterate",
184        false,
185        &["body", "while", "until", "max_iterations", "collect"],
186        &["body"],
187        true,
188        true,
189    ),
190    k(
191        "race",
192        false,
193        &["branches", "timeout", "min_success"],
194        &["branches"],
195        true,
196        true,
197    ),
198    k(
199        "join",
200        false,
201        &["handles", "timeout", "min", "partials"],
202        &["handles"],
203        true,
204        false,
205    ),
206    k("subgraph", false, &["body"], &["body"], true, true),
207    k(
208        "workflow",
209        false,
210        &["name", "inputs", "mode", "start", "version", "cascade"],
211        &["name"],
212        true,
213        false,
214    ),
215    k(
216        "wait",
217        false,
218        &[
219            "on",
220            "server",
221            "uri",
222            "condition",
223            "signal",
224            "run",
225            "subagent",
226            "conversation",
227            "webhook",
228            "timeout",
229        ],
230        &["on"],
231        true,
232        false,
233    ),
234    k("sleep", false, &["duration"], &["duration"], true, false),
235    k(
236        "assert",
237        false,
238        &["condition", "message"],
239        &["condition"],
240        true,
241        false,
242    ),
243    k("fail", false, &["message", "code"], &[], true, false),
244    k("noop", false, &[], &[], true, false),
245    k("checkpoint", false, &["name"], &[], true, false),
246    k(
247        "finish",
248        false,
249        &["status", "output", "reason"],
250        &[],
251        true,
252        false,
253    ),
254    // ---- data ----
255    k(
256        "assign",
257        false,
258        &["value", "writes", "mode"],
259        &["value"],
260        true,
261        false,
262    ),
263    k(
264        "transform",
265        false,
266        &["value", "writes", "mode"],
267        &["value"],
268        true,
269        false,
270    ),
271    k(
272        "map",
273        false,
274        &["over", "expr", "as"],
275        &["over", "expr"],
276        true,
277        false,
278    ),
279    k(
280        "filter",
281        false,
282        &["over", "expr", "as"],
283        &["over", "expr"],
284        true,
285        false,
286    ),
287    k(
288        "reduce",
289        false,
290        &["over", "expr", "initial", "as", "acc"],
291        &["over", "expr"],
292        true,
293        false,
294    ),
295    k(
296        "sort",
297        false,
298        &["over", "by", "order"],
299        &["over"],
300        true,
301        false,
302    ),
303    k("dedupe", false, &["over", "by"], &["over"], true, false),
304    k(
305        "chunk",
306        false,
307        &["value", "by", "size", "overlap"],
308        &["value", "size"],
309        true,
310        false,
311    ),
312    k("template", false, &["text", "value"], &[], true, false),
313    k("parse", false, &["text", "format"], &["text"], true, false),
314    k(
315        "validate",
316        false,
317        &["value", "schema"],
318        &["value", "schema"],
319        true,
320        false,
321    ),
322    k("memory.get", false, &["key"], &["key"], true, false),
323    k(
324        "memory.set",
325        false,
326        &["key", "value", "ttl"],
327        &["key", "value"],
328        true,
329        false,
330    ),
331    k("memory.list", false, &["prefix", "limit"], &[], true, false),
332    k("memory.delete", false, &["key"], &["key"], true, false),
333    k(
334        "artifact.create",
335        false,
336        &["name", "mime", "content", "from_step", "sensitive"],
337        &["name"],
338        true,
339        false,
340    ),
341    k("artifact.get", false, &["id"], &["id"], true, false),
342    k("artifact.delete", false, &["id"], &["id"], true, false),
343    k(
344        "knowledge.search",
345        false,
346        &["query", "top_k", "filters"],
347        &["query"],
348        true,
349        false,
350    ),
351    k("knowledge.get", false, &["id", "uri"], &[], true, false),
352    k(
353        "search.query",
354        false,
355        &["query", "kind", "limit", "freshness"],
356        &["query"],
357        true,
358        false,
359    ),
360    k(
361        "search.fetch",
362        false,
363        &["url", "max_bytes"],
364        &["url"],
365        true,
366        false,
367    ),
368    // ---- integration ----
369    k(
370        "mcp.tool",
371        false,
372        &["server", "tool", "args"],
373        &["server", "tool"],
374        true,
375        false,
376    ),
377    k(
378        "mcp.resource",
379        false,
380        &[
381            "server",
382            "op",
383            "uri",
384            "name",
385            "arguments",
386            "reference",
387            "argument",
388        ],
389        &["server", "op"],
390        true,
391        false,
392    ),
393    k("tool", false, &["name", "args"], &["name"], true, false),
394    k(
395        "http",
396        false,
397        &[
398            "method",
399            "url",
400            "headers",
401            "query",
402            "body",
403            "json",
404            "timeout",
405            "expect",
406            "allow_private",
407            "sign",
408        ],
409        &["url"],
410        true,
411        false,
412    ),
413    k(
414        "a2a.send",
415        false,
416        &["to", "parts", "context"],
417        &["to"],
418        false,
419        false,
420    ),
421    k(
422        "a2a.delegate",
423        false,
424        &["peer", "objective", "output_contract", "timeout"],
425        &["peer", "objective"],
426        true,
427        false,
428    ),
429    k(
430        "a2a.wait",
431        false,
432        &["conversation", "timeout"],
433        &[],
434        false,
435        false,
436    ),
437    k(
438        "workflow.signal",
439        false,
440        &["name", "payload", "run"],
441        &["name"],
442        true,
443        false,
444    ),
445    k(
446        "workflow.wait",
447        false,
448        &["run", "timeout"],
449        &["run"],
450        true,
451        false,
452    ),
453    k(
454        "workflow.cancel",
455        false,
456        &["run", "reason"],
457        &["run"],
458        true,
459        false,
460    ),
461    k(
462        "emit",
463        false,
464        &["note", "audit", "metric", "value"],
465        &[],
466        true,
467        false,
468    ),
469    // ---- intelligence & agents ----
470    k(
471        "think",
472        false,
473        &[
474            "prompt",
475            "output_schema",
476            "reads",
477            "check",
478            "retries",
479            "skills",
480            "system",
481        ],
482        &["prompt"],
483        true,
484        false,
485    ),
486    k(
487        "classify",
488        false,
489        &["input", "classes", "prompt", "skills"],
490        &["input", "classes"],
491        true,
492        false,
493    ),
494    k(
495        "extract",
496        false,
497        &["input", "output_schema", "prompt", "skills"],
498        &["input", "output_schema"],
499        true,
500        false,
501    ),
502    k(
503        "summarize",
504        false,
505        &["input", "length", "prompt", "skills"],
506        &["input"],
507        true,
508        false,
509    ),
510    k(
511        "judge",
512        false,
513        &["input", "rubric", "prompt", "skills"],
514        &["input", "rubric"],
515        true,
516        false,
517    ),
518    k(
519        "route",
520        false,
521        &["input", "choices", "prompt", "skills"],
522        &["input", "choices"],
523        true,
524        false,
525    ),
526    k(
527        "agent",
528        false,
529        &[
530            "instruction",
531            "output_contract",
532            "output_schema",
533            "tools",
534            "servers",
535            "limits",
536            "context",
537            "skills",
538            "system",
539        ],
540        &["instruction"],
541        true,
542        false,
543    ),
544    k(
545        "subagent",
546        false,
547        &[
548            "instruction",
549            "mode",
550            "workflow",
551            "tools",
552            "servers",
553            "limits",
554            "context",
555            "output_contract",
556            "output_schema",
557            "skills",
558        ],
559        &["instruction"],
560        true,
561        false,
562    ),
563    k(
564        "human",
565        false,
566        &["question", "schema", "to", "timeout", "reply_uri"],
567        &["question"],
568        true,
569        false,
570    ),
571];
572
573/// Cross-cutting fields every step may carry (RFC 0027 §5).
574pub const COMMON_FIELDS: &[&str] = &[
575    "kind",
576    "depends_on",
577    "when",
578    "retry",
579    "timeout",
580    "on_error",
581    "idempotent",
582    "on_replay",
583    "output_schema",
584    "cache",
585    "budget",
586    "skills",
587    "otel",
588    "description",
589];
590
591pub fn kind_info(name: &str) -> Option<&'static KindInfo> {
592    KINDS.iter().find(|k| k.name == name)
593}
594
595/// The kinds implemented by this build's engine.
596pub fn implemented_kinds() -> Vec<&'static str> {
597    KINDS
598        .iter()
599        .filter(|k| k.implemented)
600        .map(|k| k.name)
601        .collect()
602}
603
604/// `on_error` policy.
605#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
606#[serde(rename_all = "snake_case")]
607pub enum OnError {
608    #[default]
609    Fail,
610    Continue,
611    Goto(String),
612}
613
614impl OnError {
615    fn parse(v: &Value) -> Result<OnError, String> {
616        match v.as_str() {
617            Some("fail") => Ok(OnError::Fail),
618            Some("continue") => Ok(OnError::Continue),
619            Some(s) if s.starts_with("goto:") => {
620                let t = s["goto:".len()..].trim();
621                if t.is_empty() {
622                    Err("on_error goto: needs a step id".into())
623                } else {
624                    Ok(OnError::Goto(t.to_string()))
625                }
626            }
627            _ => Err("on_error must be fail | continue | goto:<step>".into()),
628        }
629    }
630}
631
632#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
633#[serde(rename_all = "snake_case")]
634pub enum OnReplay {
635    #[default]
636    Retry,
637    Skip,
638    Fail,
639}
640
641#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
642pub struct Retry {
643    #[serde(default)]
644    pub max: u32,
645    /// Backoff between attempts (ms), doubling; 0 = none.
646    #[serde(default)]
647    pub backoff_ms: u64,
648}
649
650/// A nested sub-DAG: the body of `foreach`/`batch`/`iterate`/`subgraph`, or one
651/// branch of `parallel`/`race`. Body steps depend only on siblings; steps with
652/// no dependencies are the entry points; steps nothing depends on are the
653/// **sinks** whose outputs form the body's result (one sink ⇒ its output; many
654/// ⇒ an object keyed by step id).
655#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
656pub struct Body {
657    pub steps: BTreeMap<String, Step>,
658}
659
660impl Body {
661    /// Deterministic dependency order.
662    pub fn topo_order(&self) -> Vec<String> {
663        let mut out = Vec::new();
664        let mut done: BTreeSet<String> = BTreeSet::new();
665        let mut progress = true;
666        while progress && out.len() < self.steps.len() {
667            progress = false;
668            for (id, s) in &self.steps {
669                if !done.contains(id) && s.depends_on.iter().all(|d| done.contains(d)) {
670                    done.insert(id.clone());
671                    out.push(id.clone());
672                    progress = true;
673                }
674            }
675        }
676        out
677    }
678    /// Steps nothing else depends on.
679    pub fn sinks(&self) -> Vec<String> {
680        self.steps
681            .keys()
682            .filter(|id| {
683                !self
684                    .steps
685                    .values()
686                    .any(|s| s.depends_on.iter().any(|d| d == *id))
687            })
688            .cloned()
689            .collect()
690    }
691}
692
693/// One step (the cross-cutting fields typed; kind fields in `spec`).
694#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
695pub struct Step {
696    pub id: String,
697    pub kind: String,
698    #[serde(default)]
699    pub depends_on: Vec<String>,
700    #[serde(default, skip_serializing_if = "Option::is_none")]
701    pub when: Option<String>,
702    #[serde(default, skip_serializing_if = "Option::is_none")]
703    pub retry: Option<Retry>,
704    #[serde(default, skip_serializing_if = "Option::is_none")]
705    pub timeout_ms: Option<u64>,
706    #[serde(default)]
707    pub on_error: OnError,
708    #[serde(default)]
709    pub idempotent: bool,
710    #[serde(default)]
711    pub on_replay: OnReplay,
712    #[serde(default, skip_serializing_if = "Option::is_none")]
713    pub output_schema: Option<Value>,
714    #[serde(default, skip_serializing_if = "Option::is_none")]
715    pub cache: Option<Value>,
716    #[serde(default, skip_serializing_if = "Option::is_none")]
717    pub budget: Option<u64>,
718    #[serde(default, skip_serializing_if = "Vec::is_empty")]
719    pub skills: Vec<String>,
720    #[serde(default, skip_serializing_if = "Option::is_none")]
721    pub description: Option<String>,
722    /// The kind-specific fields, verbatim.
723    #[serde(default)]
724    pub spec: Map<String, Value>,
725    /// The parsed nested body (`foreach`/`batch`/`iterate`/`subgraph`).
726    #[serde(default, skip_serializing_if = "Option::is_none")]
727    pub body: Option<Body>,
728    /// The parsed branches (`parallel`/`race`).
729    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
730    pub branches: BTreeMap<String, Body>,
731}
732
733impl Step {
734    pub fn info(&self) -> Option<&'static KindInfo> {
735        kind_info(&self.kind)
736    }
737    pub fn is_start(&self) -> bool {
738        self.info().is_some_and(|k| k.start)
739    }
740    /// A kind-specific field.
741    pub fn field(&self, name: &str) -> Option<&Value> {
742        self.spec.get(name)
743    }
744    pub fn field_str(&self, name: &str) -> Option<&str> {
745        self.spec.get(name).and_then(Value::as_str)
746    }
747}
748
749#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
750#[serde(rename_all = "snake_case")]
751pub enum OnOverflow {
752    #[default]
753    Queue,
754    Drop,
755    Replace,
756}
757
758#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
759pub struct Concurrency {
760    pub max_runs: u32,
761    pub on_overflow: OnOverflow,
762}
763
764impl Default for Concurrency {
765    fn default() -> Self {
766        Concurrency {
767            max_runs: 4,
768            on_overflow: OnOverflow::Queue,
769        }
770    }
771}
772
773#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
774pub struct WorkflowLimits {
775    #[serde(default, skip_serializing_if = "Option::is_none")]
776    pub steps: Option<u32>,
777    #[serde(default, skip_serializing_if = "Option::is_none")]
778    pub tokens: Option<u64>,
779    #[serde(default, skip_serializing_if = "Option::is_none")]
780    pub deadline_ms: Option<u64>,
781    #[serde(default, skip_serializing_if = "Option::is_none")]
782    pub budget: Option<Value>,
783}
784
785/// A parsed, validated workflow.
786#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
787pub struct Workflow {
788    pub name: String,
789    pub version: u32,
790    #[serde(default, skip_serializing_if = "Option::is_none")]
791    pub description: Option<String>,
792    #[serde(default = "default_true")]
793    pub armed: bool,
794    #[serde(default, skip_serializing_if = "Option::is_none")]
795    pub inputs_schema: Option<Value>,
796    #[serde(default)]
797    pub concurrency: Concurrency,
798    #[serde(default)]
799    pub limits: WorkflowLimits,
800    #[serde(default, skip_serializing_if = "Option::is_none")]
801    pub outputs_schema: Option<Value>,
802    pub steps: BTreeMap<String, Step>,
803    /// SHA-256 of the canonical definition (RFC 0027 §9).
804    pub hash: String,
805    /// The definition as given (canonical JSON), for `workflow.list`/hash.
806    pub definition: Value,
807}
808
809fn default_true() -> bool {
810    true
811}
812
813impl Workflow {
814    pub fn start_steps(&self) -> Vec<&Step> {
815        self.steps.values().filter(|s| s.is_start()).collect()
816    }
817    pub fn step(&self, id: &str) -> Option<&Step> {
818        self.steps.get(id)
819    }
820    /// The steps that depend on `id`.
821    pub fn dependents(&self, id: &str) -> Vec<&Step> {
822        self.steps
823            .values()
824            .filter(|s| s.depends_on.iter().any(|d| d == id))
825            .collect()
826    }
827    /// The start nodes considered long-lived (RFC 0030 §5 durability rule).
828    pub fn is_long_lived(&self) -> bool {
829        self.start_steps().iter().any(|s| {
830            matches!(
831                s.kind.as_str(),
832                "loop" | "schedule" | "subscribe" | "signal" | "event" | "a2a"
833            )
834        })
835    }
836    /// The step ids in a deterministic topological order (deps first).
837    pub fn topo_order(&self) -> Vec<String> {
838        let mut out = Vec::new();
839        let mut done: BTreeSet<String> = BTreeSet::new();
840        let mut progress = true;
841        while progress && out.len() < self.steps.len() {
842            progress = false;
843            for (id, s) in &self.steps {
844                if done.contains(id) {
845                    continue;
846                }
847                if s.depends_on.iter().all(|d| done.contains(d)) {
848                    done.insert(id.clone());
849                    out.push(id.clone());
850                    progress = true;
851                }
852            }
853        }
854        out
855    }
856}
857
858/// Parse + validate a dialect-3 document. Errors name every problem.
859pub fn parse_workflow(doc: &Value) -> Result<Workflow, Vec<String>> {
860    let mut errs = Vec::new();
861    let Some(obj) = doc.as_object() else {
862        return Err(vec!["a workflow must be an object".into()]);
863    };
864    const TOP: &[&str] = &[
865        "name",
866        "version",
867        "description",
868        "armed",
869        "inputs",
870        "concurrency",
871        "limits",
872        "outputs",
873        "steps",
874        "file",
875        "uri",
876    ];
877    for key in obj.keys() {
878        if !TOP.contains(&key.as_str()) {
879            errs.push(format!("unknown workflow field {key:?}"));
880        }
881    }
882    let name = obj
883        .get("name")
884        .and_then(Value::as_str)
885        .unwrap_or("")
886        .trim()
887        .to_string();
888    if !valid_id(&name) {
889        errs.push(format!(
890            "workflow name {name:?} must match [a-zA-Z_][a-zA-Z0-9_-]{{0,63}}"
891        ));
892    }
893    let version = obj
894        .get("version")
895        .and_then(Value::as_u64)
896        .unwrap_or(DIALECT as u64) as u32;
897    if version != DIALECT {
898        errs.push(format!(
899            "workflow {name:?}: version {version} is not dialect 3 (dialect 1/2 documents are refused — see docs/workflows.md §migration)"
900        ));
901    }
902    if obj.contains_key("start") || obj.contains_key("nodes") {
903        errs.push(format!("workflow {name:?}: `start`/`nodes` are dialect 1/2 — use `steps` with start nodes (docs/workflows.md §migration)"));
904    }
905    let armed = obj.get("armed").and_then(Value::as_bool).unwrap_or(true);
906    let inputs_schema = match obj.get("inputs") {
907        None => None,
908        Some(v) => {
909            let schema = v.get("schema").cloned().or_else(|| {
910                v.as_object()
911                    .filter(|m| m.contains_key("type") || m.contains_key("properties"))
912                    .map(|_| v.clone())
913            });
914            match schema {
915                Some(s) => {
916                    if let Err(e) = jsonschema::check_schema(&s) {
917                        errs.push(format!(
918                            "workflow {name:?}: inputs.schema: {}",
919                            e.join("; ")
920                        ));
921                    }
922                    Some(s)
923                }
924                None => {
925                    errs.push(format!("workflow {name:?}: inputs must be {{schema: …}}"));
926                    None
927                }
928            }
929        }
930    };
931    let outputs_schema = obj.get("outputs").and_then(|v| v.get("schema").cloned());
932    if let Some(s) = &outputs_schema
933        && let Err(e) = jsonschema::check_schema(s)
934    {
935        errs.push(format!(
936            "workflow {name:?}: outputs.schema: {}",
937            e.join("; ")
938        ));
939    }
940    let concurrency = match obj.get("concurrency") {
941        None => Concurrency::default(),
942        Some(v) => Concurrency {
943            max_runs: v
944                .get("max_runs")
945                .and_then(Value::as_u64)
946                .unwrap_or(4)
947                .clamp(1, 1024) as u32,
948            on_overflow: match v.get("on_overflow").and_then(Value::as_str) {
949                None | Some("queue") => OnOverflow::Queue,
950                Some("drop") => OnOverflow::Drop,
951                Some("replace") => OnOverflow::Replace,
952                Some(o) => {
953                    errs.push(format!("workflow {name:?}: concurrency.on_overflow {o:?} must be queue|drop|replace"));
954                    OnOverflow::Queue
955                }
956            },
957        },
958    };
959    let limits = match obj.get("limits") {
960        None => WorkflowLimits::default(),
961        Some(v) => WorkflowLimits {
962            steps: v.get("steps").and_then(Value::as_u64).map(|x| x as u32),
963            tokens: v.get("tokens").and_then(Value::as_u64),
964            deadline_ms: match v.get("deadline") {
965                None => None,
966                Some(d) => match duration_ms(d) {
967                    Ok(ms) => Some(ms),
968                    Err(e) => {
969                        errs.push(format!("workflow {name:?}: limits.deadline: {e}"));
970                        None
971                    }
972                },
973            },
974            budget: v.get("budget").cloned(),
975        },
976    };
977    // Steps.
978    let mut steps: BTreeMap<String, Step> = BTreeMap::new();
979    match obj.get("steps").and_then(Value::as_object) {
980        None => errs.push(format!(
981            "workflow {name:?}: `steps` (an object of steps) is required"
982        )),
983        Some(map) => {
984            if map.len() > MAX_STEPS {
985                errs.push(format!(
986                    "workflow {name:?}: {} steps exceed the cap of {MAX_STEPS}",
987                    map.len()
988                ));
989            }
990            for (id, sv) in map {
991                if let Some(step) = parse_step(&name, id, sv, 0, &mut errs) {
992                    steps.insert(id.clone(), step);
993                }
994            }
995        }
996    }
997    if !errs.is_empty() {
998        return Err(errs);
999    }
1000    let mut wf = Workflow {
1001        name,
1002        version,
1003        description: obj
1004            .get("description")
1005            .and_then(Value::as_str)
1006            .map(str::to_string),
1007        armed,
1008        inputs_schema,
1009        concurrency,
1010        limits,
1011        outputs_schema,
1012        steps,
1013        hash: String::new(),
1014        definition: doc.clone(),
1015    };
1016    validate_graph(&wf, &mut errs);
1017    if !errs.is_empty() {
1018        return Err(errs);
1019    }
1020    wf.hash = crate::sha::sha256_hex(canonical(doc).as_bytes());
1021    Ok(wf)
1022}
1023
1024fn parse_step(
1025    wf: &str,
1026    id: &str,
1027    sv: &Value,
1028    depth: usize,
1029    errs: &mut Vec<String>,
1030) -> Option<Step> {
1031    let at = format!("workflow {wf:?} step {id:?}");
1032    if !valid_id(id) {
1033        errs.push(format!(
1034            "{at}: id must match [a-zA-Z_][a-zA-Z0-9_-]{{0,63}}"
1035        ));
1036    }
1037    let Some(o) = sv.as_object() else {
1038        errs.push(format!("{at}: must be an object"));
1039        return None;
1040    };
1041    let kind = match o.get("kind").and_then(Value::as_str) {
1042        Some(k) => k.to_string(),
1043        None => {
1044            errs.push(format!("{at}: `kind` is required"));
1045            return None;
1046        }
1047    };
1048    let Some(info) = kind_info(&kind) else {
1049        errs.push(format!(
1050            "{at}: unknown kind {kind:?} (see the RFC 0027 §5 catalogue)"
1051        ));
1052        return None;
1053    };
1054    // Strict fields.
1055    let mut spec = Map::new();
1056    for (key, v) in o {
1057        if COMMON_FIELDS.contains(&key.as_str()) {
1058            continue;
1059        }
1060        if info.fields.contains(&key.as_str()) {
1061            spec.insert(key.clone(), v.clone());
1062        } else {
1063            errs.push(format!(
1064                "{at}: unknown field {key:?} for kind {kind:?} (allowed: {})",
1065                info.fields.join(", ")
1066            ));
1067        }
1068    }
1069    for req in info.required {
1070        if !spec.contains_key(*req) {
1071            errs.push(format!("{at}: kind {kind:?} requires field {req:?}"));
1072        }
1073    }
1074    if !info.implemented {
1075        errs.push(format!("{at}: kind {kind:?} is not available in this build yet (it lands with the P4 engine); implemented kinds: {}", implemented_kinds().join(", ")));
1076    }
1077    // Nested bodies / branches: parsed into typed sub-DAGs and validated.
1078    let mut body: Option<Body> = None;
1079    let mut branches: BTreeMap<String, Body> = BTreeMap::new();
1080    if info.nested {
1081        if depth + 1 > MAX_NESTING {
1082            errs.push(format!("{at}: nesting exceeds {MAX_NESTING}"));
1083        }
1084        if matches!(kind.as_str(), "parallel" | "race") {
1085            match spec.get("branches").and_then(Value::as_object) {
1086                Some(bm) if !bm.is_empty() => {
1087                    for (bname, bv) in bm {
1088                        if !valid_id(bname) {
1089                            errs.push(format!("{at}: branch name {bname:?} must match [a-zA-Z_][a-zA-Z0-9_-]{{0,63}}"));
1090                        }
1091                        if let Some(b) =
1092                            parse_body(&format!("{wf}/{id}/{bname}"), bv, depth + 1, errs)
1093                        {
1094                            branches.insert(bname.clone(), b);
1095                        }
1096                    }
1097                }
1098                _ => errs.push(format!(
1099                    "{at}: branches must be a non-empty object of {{steps: {{…}}}} bodies"
1100                )),
1101            }
1102        } else {
1103            match spec.get("body") {
1104                Some(bv) => body = parse_body(&format!("{wf}/{id}"), bv, depth + 1, errs),
1105                None => errs.push(format!("{at}: body is required")),
1106            }
1107        }
1108    }
1109    let depends_on: Vec<String> = match o.get("depends_on") {
1110        None => Vec::new(),
1111        Some(Value::Array(a)) => a
1112            .iter()
1113            .filter_map(Value::as_str)
1114            .map(str::to_string)
1115            .collect(),
1116        Some(Value::String(s)) => vec![s.clone()],
1117        Some(_) => {
1118            errs.push(format!("{at}: depends_on must be a list of step ids"));
1119            Vec::new()
1120        }
1121    };
1122    if info.start && !depends_on.is_empty() {
1123        errs.push(format!("{at}: a start node cannot depend on other steps"));
1124    }
1125    let when = o.get("when").and_then(Value::as_str).map(str::to_string);
1126    if let Some(w) = &when {
1127        let expr = w.trim().trim_start_matches("CEL:").trim();
1128        if let Err(e) = crate::cel::compile_check(expr) {
1129            errs.push(format!("{at}: when: {e}"));
1130        }
1131    }
1132    let retry = o.get("retry").map(|r| Retry {
1133        max: r.get("max").and_then(Value::as_u64).unwrap_or(0).min(20) as u32,
1134        backoff_ms: match r.get("backoff") {
1135            None => 0,
1136            Some(b) => duration_ms(b).unwrap_or_else(|e| {
1137                errs.push(format!("{at}: retry.backoff: {e}"));
1138                0
1139            }),
1140        },
1141    });
1142    let timeout_ms = match o.get("timeout") {
1143        None => None,
1144        Some(t) => match duration_ms(t) {
1145            Ok(ms) => Some(ms),
1146            Err(e) => {
1147                errs.push(format!("{at}: timeout: {e}"));
1148                None
1149            }
1150        },
1151    };
1152    let on_error = match o.get("on_error") {
1153        None => OnError::Fail,
1154        Some(v) => OnError::parse(v).unwrap_or_else(|e| {
1155            errs.push(format!("{at}: {e}"));
1156            OnError::Fail
1157        }),
1158    };
1159    let on_replay = match o.get("on_replay").and_then(Value::as_str) {
1160        None | Some("retry") => OnReplay::Retry,
1161        Some("skip") => OnReplay::Skip,
1162        Some("fail") => OnReplay::Fail,
1163        Some(x) => {
1164            errs.push(format!("{at}: on_replay {x:?} must be retry|skip|fail"));
1165            OnReplay::Retry
1166        }
1167    };
1168    let output_schema = o.get("output_schema").cloned();
1169    if let Some(s) = &output_schema
1170        && let Err(e) = jsonschema::check_schema(s)
1171    {
1172        errs.push(format!("{at}: output_schema: {}", e.join("; ")));
1173    }
1174    // Kind-specific sanity.
1175    match kind.as_str() {
1176        "finish" => {
1177            if let Some(st) = spec.get("status").and_then(Value::as_str)
1178                && !matches!(st, "completed" | "failed" | "refused" | "cancelled")
1179            {
1180                errs.push(format!(
1181                    "{at}: finish.status must be completed|failed|refused|cancelled"
1182                ));
1183            }
1184        }
1185        "sleep" => {
1186            if let Some(d) = spec.get("duration")
1187                && let Err(e) = duration_ms(d)
1188            {
1189                errs.push(format!("{at}: sleep.duration: {e}"));
1190            }
1191        }
1192        "assert" => {
1193            if let Some(c) = spec.get("condition").and_then(Value::as_str)
1194                && let Err(e) =
1195                    crate::cel::compile_check(c.trim().trim_start_matches("CEL:").trim())
1196            {
1197                errs.push(format!("{at}: assert.condition: {e}"));
1198            }
1199        }
1200        "think" | "agent" => {
1201            if let Some(s) = spec.get("output_schema")
1202                && let Err(e) = jsonschema::check_schema(s)
1203            {
1204                errs.push(format!("{at}: output_schema: {}", e.join("; ")));
1205            }
1206        }
1207        "validate" => {
1208            if let Some(s) = spec.get("schema")
1209                && let Err(e) = jsonschema::check_schema(s)
1210            {
1211                errs.push(format!("{at}: schema: {}", e.join("; ")));
1212            }
1213        }
1214        "assign" | "transform" => {
1215            if let Some(m) = spec.get("mode").and_then(Value::as_str)
1216                && !matches!(m, "overwrite" | "append" | "merge" | "union")
1217            {
1218                errs.push(format!("{at}: mode must be overwrite|append|merge|union"));
1219            }
1220        }
1221        _ => {}
1222    }
1223    // Any `CEL:` valued field compiles.
1224    for (key, v) in &spec {
1225        if let Some(s) = v.as_str()
1226            && let Some(expr) = s.trim().strip_prefix("CEL:")
1227            && let Err(e) = crate::cel::compile_check(expr.trim())
1228        {
1229            errs.push(format!("{at}: {key}: {e}"));
1230        }
1231    }
1232    Some(Step {
1233        id: id.to_string(),
1234        kind,
1235        depends_on,
1236        when,
1237        retry,
1238        timeout_ms,
1239        on_error,
1240        idempotent: o
1241            .get("idempotent")
1242            .and_then(Value::as_bool)
1243            .unwrap_or(false),
1244        on_replay,
1245        output_schema,
1246        cache: o.get("cache").cloned(),
1247        budget: o.get("budget").and_then(Value::as_u64),
1248        skills: o
1249            .get("skills")
1250            .and_then(Value::as_array)
1251            .map(|a| {
1252                a.iter()
1253                    .filter_map(Value::as_str)
1254                    .map(str::to_string)
1255                    .collect()
1256            })
1257            .unwrap_or_default(),
1258        description: o
1259            .get("description")
1260            .and_then(Value::as_str)
1261            .map(str::to_string),
1262        spec,
1263        body,
1264        branches,
1265    })
1266}
1267
1268/// Parse + validate a nested body `{steps: {…}}`.
1269fn parse_body(at: &str, bv: &Value, depth: usize, errs: &mut Vec<String>) -> Option<Body> {
1270    let Some(bs) = bv.get("steps").and_then(Value::as_object) else {
1271        errs.push(format!("{at}: body must be {{steps: {{…}}}}"));
1272        return None;
1273    };
1274    if bs.is_empty() {
1275        errs.push(format!("{at}: body has no steps"));
1276        return None;
1277    }
1278    let mut steps = BTreeMap::new();
1279    for (bid, sv) in bs {
1280        if let Some(step) = parse_step(at, bid, sv, depth, errs) {
1281            if step.is_start() {
1282                errs.push(format!(
1283                    "{at} step {bid:?}: a start node cannot be inside a body"
1284                ));
1285            }
1286            if step.kind == "finish" {
1287                errs.push(format!("{at} step {bid:?}: `finish` cannot be inside a body (a body's sinks are its result)"));
1288            }
1289            steps.insert(bid.clone(), step);
1290        }
1291    }
1292    let body = Body { steps };
1293    for s in body.steps.values() {
1294        for d in &s.depends_on {
1295            if !body.steps.contains_key(d) {
1296                errs.push(format!(
1297                    "{at} step {:?}: depends_on names {d:?}, which is not a sibling in the body",
1298                    s.id
1299                ));
1300            }
1301        }
1302        if let OnError::Goto(t) = &s.on_error
1303            && !body.steps.contains_key(t)
1304        {
1305            errs.push(format!(
1306                "{at} step {:?}: on_error goto {t:?} is not a sibling in the body",
1307                s.id
1308            ));
1309        }
1310    }
1311    if body.topo_order().len() != body.steps.len() {
1312        errs.push(format!("{at}: cycle inside the body"));
1313    }
1314    Some(body)
1315}
1316
1317/// Graph-level validation (RFC 0027 §8).
1318fn validate_graph(wf: &Workflow, errs: &mut Vec<String>) {
1319    let name = &wf.name;
1320    let starts: Vec<&Step> = wf.start_steps();
1321    if starts.is_empty() {
1322        errs.push(format!("workflow {name:?}: at least one start node is required (once|manual|loop|schedule|subscribe|signal|event|a2a)"));
1323    }
1324    // Dependencies + goto targets exist.
1325    for s in wf.steps.values() {
1326        for d in &s.depends_on {
1327            if !wf.steps.contains_key(d) {
1328                errs.push(format!(
1329                    "workflow {name:?} step {:?}: depends_on names unknown step {d:?}",
1330                    s.id
1331                ));
1332            }
1333            if d == &s.id {
1334                errs.push(format!(
1335                    "workflow {name:?} step {:?}: depends on itself",
1336                    s.id
1337                ));
1338            }
1339        }
1340        if let OnError::Goto(t) = &s.on_error
1341            && !wf.steps.contains_key(t)
1342        {
1343            errs.push(format!(
1344                "workflow {name:?} step {:?}: on_error goto names unknown step {t:?}",
1345                s.id
1346            ));
1347        }
1348    }
1349    // A non-start step with no dependencies is an unreachable root.
1350    for s in wf.steps.values() {
1351        if !s.is_start() && s.depends_on.is_empty() {
1352            errs.push(format!("workflow {name:?} step {:?}: a non-start step must depend on something (unreachable root)", s.id));
1353        }
1354    }
1355    // Acyclic (Kahn) + reachability from a start node.
1356    let order = wf.topo_order();
1357    if order.len() != wf.steps.len() {
1358        let stuck: Vec<&String> = wf.steps.keys().filter(|k| !order.contains(k)).collect();
1359        errs.push(format!("workflow {name:?}: cycle among steps {stuck:?}"));
1360    }
1361    let mut reachable: BTreeSet<String> = starts.iter().map(|s| s.id.clone()).collect();
1362    let mut changed = true;
1363    while changed {
1364        changed = false;
1365        for s in wf.steps.values() {
1366            if !reachable.contains(&s.id)
1367                && !s.depends_on.is_empty()
1368                && s.depends_on.iter().any(|d| reachable.contains(d))
1369            {
1370                reachable.insert(s.id.clone());
1371                changed = true;
1372            }
1373        }
1374    }
1375    for s in wf.steps.values() {
1376        if !reachable.contains(&s.id) {
1377            errs.push(format!(
1378                "workflow {name:?} step {:?}: not reachable from any start node",
1379                s.id
1380            ));
1381        }
1382    }
1383    if !wf.steps.values().any(|s| s.kind == "finish") {
1384        errs.push(format!("workflow {name:?}: a `finish` step is required"));
1385    }
1386}
1387
1388/// `[a-zA-Z_][a-zA-Z0-9_-]{0,63}`.
1389pub fn valid_id(s: &str) -> bool {
1390    let mut chars = s.chars();
1391    match chars.next() {
1392        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
1393        _ => return false,
1394    }
1395    s.len() <= MAX_ID_LEN && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1396}
1397
1398/// Fields never rendered as templates before execution (expressions the step
1399/// evaluates itself, and nested definitions).
1400pub const RAW_FIELDS: &[(&str, &str)] = &[
1401    ("assert", "condition"),
1402    ("map", "expr"),
1403    ("filter", "expr"),
1404    ("reduce", "expr"),
1405    ("iterate", "while"),
1406    ("iterate", "until"),
1407    ("iterate", "body"),
1408    ("foreach", "body"),
1409    ("batch", "body"),
1410    ("subgraph", "body"),
1411    ("parallel", "branches"),
1412    ("race", "branches"),
1413    ("subscribe", "filter"),
1414    ("signal", "filter"),
1415    ("event", "filter"),
1416    ("wait", "condition"),
1417    ("think", "check"),
1418    ("switch", "cases"),
1419    ("await", "condition"),
1420];
1421
1422pub fn is_raw_field(kind: &str, field: &str) -> bool {
1423    RAW_FIELDS.iter().any(|(k, f)| *k == kind && *f == field)
1424}
1425
1426/// `Some(ms)` for a duration field, `None` when absent/invalid.
1427pub fn duration_ms_opt(v: &Value) -> Option<u64> {
1428    duration_ms(v).ok()
1429}
1430
1431/// A duration field: `"30s"`, `"5m"`, bare seconds, or ms as `{"ms": n}`.
1432pub fn duration_ms(v: &Value) -> Result<u64, String> {
1433    match v {
1434        Value::Number(n) => n
1435            .as_u64()
1436            .map(|s| s * 1000)
1437            .ok_or_else(|| "duration must be a non-negative number of seconds".into()),
1438        Value::String(s) => crate::config::parse_duration(s).map(|d| d.as_millis() as u64),
1439        Value::Object(o) => o
1440            .get("ms")
1441            .and_then(Value::as_u64)
1442            .ok_or_else(|| "duration object must be {ms: n}".into()),
1443        _ => Err("duration must be a string like 30s or a number of seconds".into()),
1444    }
1445}
1446
1447/// Canonical JSON (sorted keys — serde_json's Map is a BTreeMap here) for hashing.
1448pub fn canonical(v: &Value) -> String {
1449    v.to_string()
1450}
1451
1452/// The dialect-3 JSON Schema (`--workflow-schema`).
1453pub fn workflow_schema() -> Value {
1454    let kinds: Vec<&str> = KINDS.iter().map(|k| k.name).collect();
1455    json!({
1456        "$schema": "https://json-schema.org/draft/2020-12/schema",
1457        "$id": "https://agentd.dev/schemas/workflow-3.json",
1458        "title": "agentd workflow (dialect 3)",
1459        "type": "object",
1460        "required": ["name", "steps"],
1461        "properties": {
1462            "name": {"type": "string", "pattern": "^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$"},
1463            "version": {"const": 3},
1464            "description": {"type": "string"},
1465            "armed": {"type": "boolean", "default": true},
1466            "inputs": {"type": "object", "properties": {"schema": {"type": "object"}}},
1467            "outputs": {"type": "object", "properties": {"schema": {"type": "object"}}},
1468            "concurrency": {"type": "object", "properties": {"max_runs": {"type": "integer", "minimum": 1}, "on_overflow": {"enum": ["queue", "drop", "replace"]}}},
1469            "limits": {"type": "object", "properties": {"steps": {"type": "integer"}, "tokens": {"type": "integer"}, "deadline": {"type": "string"}, "budget": {"type": "object"}}},
1470            "steps": {"type": "object", "additionalProperties": {"$ref": "#/$defs/step"}, "minProperties": 1}
1471        },
1472        "$defs": {
1473            "step": {
1474                "type": "object",
1475                "required": ["kind"],
1476                "properties": {
1477                    "kind": {"enum": kinds},
1478                    "depends_on": {"type": "array", "items": {"type": "string"}},
1479                    "when": {"type": "string"},
1480                    "retry": {"type": "object", "properties": {"max": {"type": "integer"}, "backoff": {"type": "string"}}},
1481                    "timeout": {"type": "string"},
1482                    "on_error": {"type": "string"},
1483                    "idempotent": {"type": "boolean"},
1484                    "on_replay": {"enum": ["retry", "skip", "fail"]},
1485                    "output_schema": {"type": "object"},
1486                    "cache": {"type": "object"},
1487                    "budget": {"type": "integer"},
1488                    "skills": {"type": "array", "items": {"type": "string"}},
1489                    "otel": {"type": "object"},
1490                    "description": {"type": "string"}
1491                }
1492            },
1493            "kinds": KINDS.iter().map(|k| (k.name.to_string(), json!({"start": k.start, "fields": k.fields, "required": k.required, "implemented": k.implemented}))).collect::<BTreeMap<_, _>>()
1494        }
1495    })
1496}
1497
1498#[cfg(test)]
1499mod tests {
1500    use super::*;
1501
1502    fn wf(doc: Value) -> Result<Workflow, Vec<String>> {
1503        parse_workflow(&doc)
1504    }
1505
1506    #[test]
1507    fn the_sugar_workflow_parses_hashes_and_orders() {
1508        let w = wf(json!({
1509            "name": "main", "version": 3,
1510            "steps": {
1511                "start": {"kind": "once"},
1512                "work": {"kind": "agent", "depends_on": ["start"], "instruction": "{{env.instruction}}"},
1513                "done": {"kind": "finish", "depends_on": ["work"], "status": "completed", "output": "{{steps.work.output}}"}
1514            }
1515        }))
1516        .unwrap();
1517        assert_eq!(w.start_steps().len(), 1);
1518        assert_eq!(w.topo_order(), vec!["start", "work", "done"]);
1519        assert_eq!(w.hash.len(), 64);
1520        assert!(!w.is_long_lived());
1521        assert!(w.armed);
1522        assert_eq!(
1523            w.step("work").unwrap().field_str("instruction"),
1524            Some("{{env.instruction}}")
1525        );
1526        // Same definition, same hash; a changed one differs.
1527        let w2 = wf(w.definition.clone()).unwrap();
1528        assert_eq!(w2.hash, w.hash);
1529        let mut d = w.definition.clone();
1530        d["steps"]["work"]["instruction"] = json!("other");
1531        assert_ne!(wf(d).unwrap().hash, w.hash);
1532    }
1533
1534    // Asserts a `when: CEL parse` diagnostic, so it needs the `cel` feature.
1535    #[cfg(feature = "cel")]
1536    #[test]
1537    fn validation_catches_the_rfc_0027_section_8_failures() {
1538        // Parse-level failures (reported together, before graph checks).
1539        let e = wf(json!({"name": "bad name", "start": "x", "steps": {
1540            "a": {"kind": "agent", "instruction": "x"},
1541            "b": {"kind": "tool", "name": "memory.get", "depends_on": ["a"], "bogus": 1},
1542            "c": {"kind": "foreach", "over": "{{x}}", "body": {"steps": {"i": {"kind": "noop", "depends_on": ["q"]}, "bad id": {"kind": "noop"}}}, "depends_on": ["b"]},
1543            "d": {"kind": "nope", "depends_on": ["a"]},
1544            "e": {"kind": "sleep", "duration": "5 parsecs", "depends_on": ["a"], "when": "CEL: 1 +"},
1545            "s": {"kind": "once", "depends_on": ["a"]}
1546        }}))
1547        .unwrap_err();
1548        let joined = e.join("\n");
1549        for needle in [
1550            "workflow name \"bad name\"",
1551            "`start`/`nodes` are dialect 1/2",
1552            "unknown field \"bogus\"",
1553            "unknown kind \"nope\"",
1554            "sleep.duration",
1555            "when: CEL parse",
1556            "a start node cannot depend on other steps",
1557            "step \"bad id\": id must match",
1558        ] {
1559            assert!(joined.contains(needle), "missing {needle:?} in:\n{joined}");
1560        }
1561        // Graph-level failures.
1562        let e = wf(json!({"name": "g", "steps": {
1563            "s": {"kind": "once"},
1564            "b": {"kind": "noop", "depends_on": ["s", "zz"]},
1565            "e": {"kind": "sleep", "duration": "1s", "depends_on": ["s"], "on_error": "goto:nowhere"},
1566            "loop1": {"kind": "noop", "depends_on": ["loop2"]},
1567            "loop2": {"kind": "noop", "depends_on": ["loop1"]},
1568            "f": {"kind": "finish", "depends_on": ["b"]}
1569        }}))
1570        .unwrap_err();
1571        let joined = e.join("\n");
1572        for needle in [
1573            "depends_on names unknown step \"zz\"",
1574            "on_error goto names unknown step \"nowhere\"",
1575            "cycle among steps",
1576            "not reachable from any start node",
1577        ] {
1578            assert!(joined.contains(needle), "missing {needle:?} in:\n{joined}");
1579        }
1580        // Structural: no start, unreachable, cycle, no finish.
1581        let e = wf(json!({"name": "w", "steps": {
1582            "a": {"kind": "noop"},
1583            "b": {"kind": "noop", "depends_on": ["c"]},
1584            "c": {"kind": "noop", "depends_on": ["b"]}
1585        }}))
1586        .unwrap_err();
1587        let joined = e.join("\n");
1588        assert!(joined.contains("at least one start node"), "{joined}");
1589        assert!(joined.contains("unreachable root"), "{joined}");
1590        assert!(joined.contains("cycle among steps"), "{joined}");
1591        assert!(joined.contains("`finish` step is required"), "{joined}");
1592        // Version.
1593        let e = wf(json!({"name": "w", "version": 2, "steps": {"s": {"kind": "once"}, "f": {"kind": "finish", "depends_on": ["s"]}}})).unwrap_err();
1594        assert!(e[0].contains("not dialect 3"));
1595        // Happy path with every implemented kind referenced.
1596        let ok = wf(json!({"name": "w", "inputs": {"schema": {"type": "object"}}, "concurrency": {"max_runs": 2, "on_overflow": "drop"}, "limits": {"deadline": "10m", "steps": 50}, "steps": {
1597            "s": {"kind": "manual"},
1598            "t": {"kind": "mcp.tool", "server": "fs", "tool": "read", "args": {"path": "/x"}, "depends_on": ["s"], "retry": {"max": 2, "backoff": "1s"}, "timeout": "30s", "on_error": "continue"},
1599            "v": {"kind": "assign", "value": {"a": 1}, "writes": "x", "depends_on": ["t"], "when": "CEL: true"},
1600            "th": {"kind": "think", "prompt": "p", "output_schema": {"type": "object"}, "depends_on": ["v"]},
1601            "z": {"kind": "sleep", "duration": "1s", "depends_on": ["th"]},
1602            "f": {"kind": "finish", "depends_on": ["z"], "status": "completed", "output": "{{vars.x}}"}
1603        }}))
1604        .unwrap();
1605        assert_eq!(ok.concurrency.on_overflow, OnOverflow::Drop);
1606        assert_eq!(ok.limits.deadline_ms, Some(600_000));
1607        assert_eq!(
1608            ok.step("t").unwrap().retry.as_ref().unwrap().backoff_ms,
1609            1000
1610        );
1611        assert_eq!(ok.step("t").unwrap().on_error, OnError::Continue);
1612        assert_eq!(ok.step("t").unwrap().timeout_ms, Some(30_000));
1613        assert!(implemented_kinds().contains(&"agent"));
1614        assert!(workflow_schema()["$defs"]["kinds"]["a2a.send"]["implemented"] == json!(false));
1615        assert!(workflow_schema()["$defs"]["kinds"]["foreach"]["implemented"] == json!(true));
1616    }
1617}