1use crate::jsonschema;
11use serde::{Deserialize, Serialize};
12use serde_json::{Map, Value, json};
13use std::collections::{BTreeMap, BTreeSet};
14
15pub const DIALECT: u32 = 3;
17pub const MAX_STEPS: usize = 512;
19pub const MAX_NESTING: usize = 4;
20pub const MAX_BATCH_PARALLEL: u64 = 8;
21pub const DEFAULT_FAN_OUT: u64 = 4;
27pub const MAX_ITERATIONS: u64 = 10_000;
28pub const MAX_ID_LEN: usize = 64;
29
30#[derive(Debug, Clone, Copy)]
32pub struct KindInfo {
33 pub name: &'static str,
34 pub start: bool,
36 pub fields: &'static [&'static str],
38 pub required: &'static [&'static str],
40 pub implemented: bool,
43 pub nested: bool,
45}
46
47const fn k(
48 name: &'static str,
49 start: bool,
50 fields: &'static [&'static str],
51 required: &'static [&'static str],
52 implemented: bool,
53 nested: bool,
54) -> KindInfo {
55 KindInfo {
56 name,
57 start,
58 fields,
59 required,
60 implemented,
61 nested,
62 }
63}
64
65pub const KINDS: &[KindInfo] = &[
68 k("once", true, &["policy", "inputs"], &[], true, false),
70 k("manual", true, &["inputs"], &[], true, false),
71 k(
72 "loop",
73 true,
74 &[
75 "interval",
76 "delay",
77 "until",
78 "max_iterations",
79 "backoff",
80 "inputs",
81 ],
82 &[],
83 true,
84 false,
85 ),
86 k(
87 "schedule",
88 true,
89 &["cron", "every", "tz", "jitter", "catch_up", "at", "inputs"],
90 &[],
91 true,
92 false,
93 ),
94 k(
95 "subscribe",
96 true,
97 &[
98 "server",
99 "uri",
100 "debounce_ms",
101 "coalesce",
102 "filter",
103 "deliver",
104 "on_no_listener",
105 "inputs",
106 ],
107 &["server", "uri"],
108 true,
109 false,
110 ),
111 k(
112 "signal",
113 true,
114 &["name", "filter", "deliver", "inputs"],
115 &["name"],
116 true,
117 false,
118 ),
119 k(
120 "event",
121 true,
122 &["on", "filter", "inputs"],
123 &["on"],
124 true,
125 false,
126 ),
127 k(
128 "a2a",
129 true,
130 &["command", "roles", "inputs"],
131 &[],
132 true,
133 false,
134 ),
135 k(
136 "webhook",
137 true,
138 &[
139 "path",
140 "methods",
141 "auth",
142 "parallelism",
143 "on_overflow",
144 "idempotency",
145 "respond",
146 "filter",
147 "inputs",
148 ],
149 &["path"],
150 true,
151 false,
152 ),
153 k(
155 "switch",
156 false,
157 &["on", "cases", "default"],
158 &["on", "cases"],
159 true,
160 false,
161 ),
162 k(
163 "parallel",
164 false,
165 &["branches", "on_error"],
166 &["branches"],
167 true,
168 true,
169 ),
170 k(
171 "foreach",
172 false,
173 &["over", "body", "batch", "collect", "on_error", "as"],
174 &["over", "body"],
175 true,
176 true,
177 ),
178 k(
179 "batch",
180 false,
181 &[
182 "over", "body", "by", "size", "parallel", "rate", "collect", "on_error",
183 ],
184 &["over", "body"],
185 true,
186 true,
187 ),
188 k(
189 "iterate",
190 false,
191 &["body", "while", "until", "max_iterations", "collect"],
192 &["body"],
193 true,
194 true,
195 ),
196 k(
197 "race",
198 false,
199 &["branches", "timeout", "min_success"],
200 &["branches"],
201 true,
202 true,
203 ),
204 k(
205 "join",
206 false,
207 &["handles", "timeout", "min", "partials"],
208 &["handles"],
209 true,
210 false,
211 ),
212 k("subgraph", false, &["body"], &["body"], true, true),
213 k(
214 "workflow",
215 false,
216 &["name", "inputs", "mode", "start", "version", "cascade"],
217 &["name"],
218 true,
219 false,
220 ),
221 k(
222 "wait",
223 false,
224 &[
225 "on",
226 "server",
227 "uri",
228 "condition",
229 "signal",
230 "run",
231 "subagent",
232 "conversation",
233 "webhook",
234 "timeout",
235 ],
236 &["on"],
237 true,
238 false,
239 ),
240 k("sleep", false, &["duration"], &["duration"], true, false),
241 k(
242 "assert",
243 false,
244 &["condition", "message"],
245 &["condition"],
246 true,
247 false,
248 ),
249 k("fail", false, &["message", "code"], &[], true, false),
250 k("noop", false, &[], &[], true, false),
251 k("checkpoint", false, &["name"], &[], true, false),
252 k(
253 "finish",
254 false,
255 &["status", "output", "reason"],
256 &[],
257 true,
258 false,
259 ),
260 k(
262 "assign",
263 false,
264 &["value", "writes", "mode"],
265 &["value"],
266 true,
267 false,
268 ),
269 k(
270 "transform",
271 false,
272 &["value", "writes", "mode"],
273 &["value"],
274 true,
275 false,
276 ),
277 k(
278 "map",
279 false,
280 &["over", "expr", "as"],
281 &["over", "expr"],
282 true,
283 false,
284 ),
285 k(
286 "filter",
287 false,
288 &["over", "expr", "as"],
289 &["over", "expr"],
290 true,
291 false,
292 ),
293 k(
294 "reduce",
295 false,
296 &["over", "expr", "initial", "as", "acc"],
297 &["over", "expr"],
298 true,
299 false,
300 ),
301 k(
302 "sort",
303 false,
304 &["over", "by", "order"],
305 &["over"],
306 true,
307 false,
308 ),
309 k("dedupe", false, &["over", "by"], &["over"], true, false),
310 k(
311 "chunk",
312 false,
313 &["value", "by", "size", "overlap"],
314 &["value", "size"],
315 true,
316 false,
317 ),
318 k("template", false, &["text", "value"], &[], true, false),
319 k("parse", false, &["text", "format"], &["text"], true, false),
320 k(
321 "validate",
322 false,
323 &["value", "schema"],
324 &["value", "schema"],
325 true,
326 false,
327 ),
328 k("memory.get", false, &["key"], &["key"], true, false),
329 k(
330 "memory.set",
331 false,
332 &["key", "value", "ttl"],
333 &["key", "value"],
334 true,
335 false,
336 ),
337 k("memory.list", false, &["prefix", "limit"], &[], true, false),
338 k("memory.delete", false, &["key"], &["key"], true, false),
339 k(
340 "artifact.create",
341 false,
342 &["name", "mime", "content", "from_step", "sensitive"],
343 &["name"],
344 true,
345 false,
346 ),
347 k("artifact.get", false, &["id"], &["id"], true, false),
348 k("artifact.delete", false, &["id"], &["id"], true, false),
349 k(
350 "knowledge.search",
351 false,
352 &["query", "top_k", "filters"],
353 &["query"],
354 true,
355 false,
356 ),
357 k("knowledge.get", false, &["id", "uri"], &[], true, false),
358 k(
359 "search.query",
360 false,
361 &["query", "kind", "limit", "freshness"],
362 &["query"],
363 true,
364 false,
365 ),
366 k(
367 "search.fetch",
368 false,
369 &["url", "max_bytes"],
370 &["url"],
371 true,
372 false,
373 ),
374 k(
376 "mcp.tool",
377 false,
378 &["server", "tool", "args"],
379 &["server", "tool"],
380 true,
381 false,
382 ),
383 k(
384 "mcp.resource",
385 false,
386 &[
387 "server",
388 "op",
389 "uri",
390 "name",
391 "arguments",
392 "reference",
393 "argument",
394 ],
395 &["server", "op"],
396 true,
397 false,
398 ),
399 k("tool", false, &["name", "args"], &["name"], true, false),
400 k(
401 "http",
402 false,
403 &[
404 "method",
405 "url",
406 "headers",
407 "query",
408 "body",
409 "json",
410 "timeout",
411 "expect",
412 "allow_private",
413 "sign",
414 ],
415 &["url"],
416 true,
417 false,
418 ),
419 k(
420 "a2a.send",
421 false,
422 &["to", "parts", "context", "timeout"],
423 &["to"],
424 true,
425 false,
426 ),
427 k(
428 "a2a.delegate",
429 false,
430 &["peer", "objective", "output_contract", "timeout"],
431 &["peer", "objective"],
432 true,
433 false,
434 ),
435 k(
436 "a2a.wait",
437 false,
438 &["conversation", "timeout"],
439 &[],
440 true,
441 false,
442 ),
443 k(
444 "workflow.signal",
445 false,
446 &["name", "payload", "run"],
447 &["name"],
448 true,
449 false,
450 ),
451 k(
452 "workflow.wait",
453 false,
454 &["run", "timeout"],
455 &["run"],
456 true,
457 false,
458 ),
459 k(
460 "workflow.cancel",
461 false,
462 &["run", "reason"],
463 &["run"],
464 true,
465 false,
466 ),
467 k(
468 "emit",
469 false,
470 &["note", "audit", "metric", "value"],
471 &[],
472 true,
473 false,
474 ),
475 k(
477 "think",
478 false,
479 &[
480 "prompt",
481 "output_schema",
482 "reads",
483 "check",
484 "retries",
485 "skills",
486 "system",
487 ],
488 &["prompt"],
489 true,
490 false,
491 ),
492 k(
493 "classify",
494 false,
495 &["input", "classes", "prompt", "skills"],
496 &["input", "classes"],
497 true,
498 false,
499 ),
500 k(
501 "extract",
502 false,
503 &["input", "output_schema", "prompt", "skills"],
504 &["input", "output_schema"],
505 true,
506 false,
507 ),
508 k(
509 "summarize",
510 false,
511 &["input", "length", "prompt", "skills"],
512 &["input"],
513 true,
514 false,
515 ),
516 k(
517 "judge",
518 false,
519 &["input", "rubric", "prompt", "skills"],
520 &["input", "rubric"],
521 true,
522 false,
523 ),
524 k(
525 "route",
526 false,
527 &["input", "choices", "prompt", "skills"],
528 &["input", "choices"],
529 true,
530 false,
531 ),
532 k(
533 "agent",
534 false,
535 &[
536 "instruction",
537 "output_contract",
538 "output_schema",
539 "tools",
540 "servers",
541 "limits",
542 "context",
543 "skills",
544 "system",
545 ],
546 &["instruction"],
547 true,
548 false,
549 ),
550 k(
551 "subagent",
552 false,
553 &[
554 "instruction",
555 "mode",
556 "workflow",
557 "tools",
558 "servers",
559 "limits",
560 "context",
561 "output_contract",
562 "output_schema",
563 "skills",
564 ],
565 &["instruction"],
566 true,
567 false,
568 ),
569 k(
570 "human",
571 false,
572 &["question", "schema", "to", "timeout", "reply_uri"],
573 &["question"],
574 true,
575 false,
576 ),
577];
578
579pub const COMMON_FIELDS: &[&str] = &[
581 "kind",
582 "depends_on",
583 "when",
584 "retry",
585 "timeout",
586 "on_error",
587 "idempotent",
588 "on_replay",
589 "output_schema",
590 "cache",
591 "budget",
592 "skills",
593 "otel",
594 "description",
595];
596
597pub fn kind_info(name: &str) -> Option<&'static KindInfo> {
598 KINDS.iter().find(|k| k.name == name)
599}
600
601pub fn implemented_kinds() -> Vec<&'static str> {
603 KINDS
604 .iter()
605 .filter(|k| k.implemented)
606 .map(|k| k.name)
607 .collect()
608}
609
610#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
612#[serde(rename_all = "snake_case")]
613pub enum OnError {
614 #[default]
615 Fail,
616 Continue,
617 Goto(String),
618}
619
620impl OnError {
621 fn parse(v: &Value) -> Result<OnError, String> {
622 match v.as_str() {
623 Some("fail") => Ok(OnError::Fail),
624 Some("continue") => Ok(OnError::Continue),
625 Some(s) if s.starts_with("goto:") => {
626 let t = s["goto:".len()..].trim();
627 if t.is_empty() {
628 Err("on_error goto: needs a step id".into())
629 } else {
630 Ok(OnError::Goto(t.to_string()))
631 }
632 }
633 _ => Err("on_error must be fail | continue | goto:<step>".into()),
634 }
635 }
636}
637
638#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
639#[serde(rename_all = "snake_case")]
640pub enum OnReplay {
641 #[default]
642 Retry,
643 Skip,
644 Fail,
645}
646
647#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
648pub struct Retry {
649 #[serde(default)]
650 pub max: u32,
651 #[serde(default)]
653 pub backoff_ms: u64,
654}
655
656#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
662pub struct Body {
663 pub steps: BTreeMap<String, Step>,
664}
665
666impl Body {
667 pub fn topo_order(&self) -> Vec<String> {
669 let mut out = Vec::new();
670 let mut done: BTreeSet<String> = BTreeSet::new();
671 let mut progress = true;
672 while progress && out.len() < self.steps.len() {
673 progress = false;
674 for (id, s) in &self.steps {
675 if !done.contains(id) && s.depends_on.iter().all(|d| done.contains(d)) {
676 done.insert(id.clone());
677 out.push(id.clone());
678 progress = true;
679 }
680 }
681 }
682 out
683 }
684 pub fn sinks(&self) -> Vec<String> {
686 self.steps
687 .keys()
688 .filter(|id| {
689 !self
690 .steps
691 .values()
692 .any(|s| s.depends_on.iter().any(|d| d == *id))
693 })
694 .cloned()
695 .collect()
696 }
697}
698
699#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
701pub struct Step {
702 pub id: String,
703 pub kind: String,
704 #[serde(default)]
705 pub depends_on: Vec<String>,
706 #[serde(default, skip_serializing_if = "Option::is_none")]
707 pub when: Option<String>,
708 #[serde(default, skip_serializing_if = "Option::is_none")]
709 pub retry: Option<Retry>,
710 #[serde(default, skip_serializing_if = "Option::is_none")]
711 pub timeout_ms: Option<u64>,
712 #[serde(default)]
713 pub on_error: OnError,
714 #[serde(default)]
715 pub idempotent: bool,
716 #[serde(default)]
717 pub on_replay: OnReplay,
718 #[serde(default, skip_serializing_if = "Option::is_none")]
719 pub output_schema: Option<Value>,
720 #[serde(default, skip_serializing_if = "Option::is_none")]
721 pub cache: Option<Value>,
722 #[serde(default, skip_serializing_if = "Option::is_none")]
723 pub budget: Option<u64>,
724 #[serde(default, skip_serializing_if = "Vec::is_empty")]
725 pub skills: Vec<String>,
726 #[serde(default, skip_serializing_if = "Option::is_none")]
727 pub description: Option<String>,
728 #[serde(default)]
730 pub spec: Map<String, Value>,
731 #[serde(default, skip_serializing_if = "Option::is_none")]
733 pub body: Option<Body>,
734 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
736 pub branches: BTreeMap<String, Body>,
737}
738
739impl Step {
740 pub fn info(&self) -> Option<&'static KindInfo> {
741 kind_info(&self.kind)
742 }
743 pub fn is_start(&self) -> bool {
744 self.info().is_some_and(|k| k.start)
745 }
746 pub fn field(&self, name: &str) -> Option<&Value> {
748 self.spec.get(name)
749 }
750 pub fn field_str(&self, name: &str) -> Option<&str> {
751 self.spec.get(name).and_then(Value::as_str)
752 }
753}
754
755#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
756#[serde(rename_all = "snake_case")]
757pub enum OnOverflow {
758 #[default]
759 Queue,
760 Drop,
761 Replace,
762}
763
764#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
765pub struct Concurrency {
766 pub max_runs: u32,
767 pub on_overflow: OnOverflow,
768}
769
770impl Default for Concurrency {
771 fn default() -> Self {
772 Concurrency {
773 max_runs: 4,
774 on_overflow: OnOverflow::Queue,
775 }
776 }
777}
778
779#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
780pub struct WorkflowLimits {
781 #[serde(default, skip_serializing_if = "Option::is_none")]
782 pub steps: Option<u32>,
783 #[serde(default, skip_serializing_if = "Option::is_none")]
784 pub tokens: Option<u64>,
785 #[serde(default, skip_serializing_if = "Option::is_none")]
786 pub deadline_ms: Option<u64>,
787 #[serde(default, skip_serializing_if = "Option::is_none")]
788 pub budget: Option<Value>,
789}
790
791#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
793pub struct StateDecl {
794 #[serde(default, skip_serializing_if = "Option::is_none")]
796 pub schema: Option<Value>,
797 #[serde(default, skip_serializing_if = "Option::is_none")]
799 pub reducer: Option<String>,
800}
801
802#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
804pub struct Workflow {
805 pub name: String,
806 pub version: u32,
807 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
815 pub state: BTreeMap<String, StateDecl>,
816 #[serde(default, skip_serializing_if = "Option::is_none")]
817 pub description: Option<String>,
818 #[serde(default = "default_true")]
819 pub armed: bool,
820 #[serde(default, skip_serializing_if = "Option::is_none")]
821 pub inputs_schema: Option<Value>,
822 #[serde(default)]
823 pub concurrency: Concurrency,
824 #[serde(default)]
825 pub limits: WorkflowLimits,
826 #[serde(default, skip_serializing_if = "Option::is_none")]
827 pub outputs_schema: Option<Value>,
828 pub steps: BTreeMap<String, Step>,
829 pub hash: String,
831 pub definition: Value,
833}
834
835fn default_true() -> bool {
836 true
837}
838
839impl Workflow {
840 pub fn start_steps(&self) -> Vec<&Step> {
841 self.steps.values().filter(|s| s.is_start()).collect()
842 }
843 pub fn step(&self, id: &str) -> Option<&Step> {
844 self.steps.get(id)
845 }
846 pub fn dependents(&self, id: &str) -> Vec<&Step> {
848 self.steps
849 .values()
850 .filter(|s| s.depends_on.iter().any(|d| d == id))
851 .collect()
852 }
853 pub fn is_long_lived(&self) -> bool {
855 self.start_steps().iter().any(|s| {
856 matches!(
857 s.kind.as_str(),
858 "loop" | "schedule" | "subscribe" | "signal" | "event" | "a2a"
859 )
860 })
861 }
862 pub fn topo_order(&self) -> Vec<String> {
864 let mut out = Vec::new();
865 let mut done: BTreeSet<String> = BTreeSet::new();
866 let mut progress = true;
867 while progress && out.len() < self.steps.len() {
868 progress = false;
869 for (id, s) in &self.steps {
870 if done.contains(id) {
871 continue;
872 }
873 if s.depends_on.iter().all(|d| done.contains(d)) {
874 done.insert(id.clone());
875 out.push(id.clone());
876 progress = true;
877 }
878 }
879 }
880 out
881 }
882}
883
884fn json_kind(v: &Value) -> &'static str {
886 match v {
887 Value::Null => "null",
888 Value::Bool(_) => "a boolean",
889 Value::Number(_) => "a number",
890 Value::String(_) => "a string",
891 Value::Array(_) => "a list",
892 Value::Object(_) => "an object",
893 }
894}
895
896pub fn parse_workflow(doc: &Value) -> Result<Workflow, Vec<String>> {
898 let mut errs = Vec::new();
899 let Some(obj) = doc.as_object() else {
900 return Err(vec!["a workflow must be an object".into()]);
901 };
902 const TOP: &[&str] = &[
903 "name",
904 "version",
905 "description",
906 "armed",
907 "inputs",
908 "concurrency",
909 "limits",
910 "outputs",
911 "state",
912 "steps",
913 "file",
914 "uri",
915 ];
916 for key in obj.keys() {
917 if !TOP.contains(&key.as_str()) {
918 errs.push(format!("unknown workflow field {key:?}"));
919 }
920 }
921 let name = obj
922 .get("name")
923 .and_then(Value::as_str)
924 .unwrap_or("")
925 .trim()
926 .to_string();
927 if !valid_id(&name) {
928 errs.push(format!(
929 "workflow name {name:?} must match [a-zA-Z_][a-zA-Z0-9_-]{{0,63}}"
930 ));
931 }
932 let version = obj
933 .get("version")
934 .and_then(Value::as_u64)
935 .unwrap_or(DIALECT as u64) as u32;
936 if version != DIALECT {
937 errs.push(format!(
938 "workflow {name:?}: version {version} is not dialect 3 (dialect 1/2 documents are refused — see docs/workflows.md §migration)"
939 ));
940 }
941 if obj.contains_key("start") || obj.contains_key("nodes") {
942 errs.push(format!("workflow {name:?}: `start`/`nodes` are dialect 1/2 — use `steps` with start nodes (docs/workflows.md §migration)"));
943 }
944 let armed = obj.get("armed").and_then(Value::as_bool).unwrap_or(true);
945 let inputs_schema = match obj.get("inputs") {
946 None => None,
947 Some(v) => {
948 let schema = v.get("schema").cloned().or_else(|| {
949 v.as_object()
950 .filter(|m| m.contains_key("type") || m.contains_key("properties"))
951 .map(|_| v.clone())
952 });
953 match schema {
954 Some(s) => {
955 if let Err(e) = jsonschema::check_schema(&s) {
956 errs.push(format!(
957 "workflow {name:?}: inputs.schema: {}",
958 e.join("; ")
959 ));
960 }
961 Some(s)
962 }
963 None => {
964 errs.push(format!("workflow {name:?}: inputs must be {{schema: …}}"));
965 None
966 }
967 }
968 }
969 };
970 let outputs_schema = obj.get("outputs").and_then(|v| v.get("schema").cloned());
971 if let Some(s) = &outputs_schema
972 && let Err(e) = jsonschema::check_schema(s)
973 {
974 errs.push(format!(
975 "workflow {name:?}: outputs.schema: {}",
976 e.join("; ")
977 ));
978 }
979 let concurrency = match obj.get("concurrency") {
980 None => Concurrency::default(),
981 Some(v) => Concurrency {
982 max_runs: v
983 .get("max_runs")
984 .and_then(Value::as_u64)
985 .unwrap_or(4)
986 .clamp(1, 1024) as u32,
987 on_overflow: match v.get("on_overflow").and_then(Value::as_str) {
988 None | Some("queue") => OnOverflow::Queue,
989 Some("drop") => OnOverflow::Drop,
990 Some("replace") => OnOverflow::Replace,
991 Some(o) => {
992 errs.push(format!("workflow {name:?}: concurrency.on_overflow {o:?} must be queue|drop|replace"));
993 OnOverflow::Queue
994 }
995 },
996 },
997 };
998 let limits = match obj.get("limits") {
999 None => WorkflowLimits::default(),
1000 Some(v) => WorkflowLimits {
1001 steps: v.get("steps").and_then(Value::as_u64).map(|x| x as u32),
1002 tokens: v.get("tokens").and_then(Value::as_u64),
1003 deadline_ms: match v.get("deadline") {
1004 None => None,
1005 Some(d) => match duration_ms(d) {
1006 Ok(ms) => Some(ms),
1007 Err(e) => {
1008 errs.push(format!("workflow {name:?}: limits.deadline: {e}"));
1009 None
1010 }
1011 },
1012 },
1013 budget: v.get("budget").cloned(),
1014 },
1015 };
1016 let mut steps: BTreeMap<String, Step> = BTreeMap::new();
1018 match obj.get("steps").and_then(Value::as_object) {
1019 None => errs.push(format!(
1020 "workflow {name:?}: `steps` (an object of steps) is required"
1021 )),
1022 Some(map) => {
1023 if map.len() > MAX_STEPS {
1024 errs.push(format!(
1025 "workflow {name:?}: {} steps exceed the cap of {MAX_STEPS}",
1026 map.len()
1027 ));
1028 }
1029 for (id, sv) in map {
1030 if let Some(step) = parse_step(&name, id, sv, 0, &mut errs) {
1031 steps.insert(id.clone(), step);
1032 }
1033 }
1034 }
1035 }
1036 if !errs.is_empty() {
1037 return Err(errs);
1038 }
1039 let mut state: BTreeMap<String, StateDecl> = BTreeMap::new();
1041 if let Some(decls) = obj.get("state") {
1042 match decls.as_object() {
1043 None => errs.push("state must be an object of {key: {schema, reducer}}".into()),
1044 Some(map) => {
1045 for (key, decl) in map {
1046 let Some(d) = decl.as_object() else {
1047 errs.push(format!("state {key:?}: must be an object"));
1048 continue;
1049 };
1050 for f in d.keys() {
1051 if !matches!(f.as_str(), "schema" | "reducer") {
1052 errs.push(format!(
1053 "state {key:?}: unknown field {f:?} (allowed: schema, reducer)"
1054 ));
1055 }
1056 }
1057 let schema = d.get("schema").cloned();
1058 if let Some(sc) = &schema
1059 && let Err(e) = jsonschema::check_schema(sc)
1060 {
1061 errs.push(format!("state {key:?}: schema: {}", e.join("; ")));
1062 }
1063 let reducer = d.get("reducer").and_then(Value::as_str).map(str::to_string);
1064 if let Some(r) = &reducer
1065 && !matches!(r.as_str(), "overwrite" | "append" | "merge" | "union")
1066 {
1067 errs.push(format!(
1068 "state {key:?}: reducer {r:?} must be overwrite|append|merge|union"
1069 ));
1070 }
1071 state.insert(key.clone(), StateDecl { schema, reducer });
1072 }
1073 }
1074 }
1075 }
1076 let mut wf = Workflow {
1077 state,
1078 name,
1079 version,
1080 description: obj
1081 .get("description")
1082 .and_then(Value::as_str)
1083 .map(str::to_string),
1084 armed,
1085 inputs_schema,
1086 concurrency,
1087 limits,
1088 outputs_schema,
1089 steps,
1090 hash: String::new(),
1091 definition: doc.clone(),
1092 };
1093 validate_graph(&wf, &mut errs);
1094 if !errs.is_empty() {
1095 return Err(errs);
1096 }
1097 wf.hash = crate::sha::sha256_hex(canonical(doc).as_bytes());
1098 Ok(wf)
1099}
1100
1101fn parse_step(
1102 wf: &str,
1103 id: &str,
1104 sv: &Value,
1105 depth: usize,
1106 errs: &mut Vec<String>,
1107) -> Option<Step> {
1108 let at = format!("workflow {wf:?} step {id:?}");
1109 if !valid_id(id) {
1110 errs.push(format!(
1111 "{at}: id must match [a-zA-Z_][a-zA-Z0-9_-]{{0,63}}"
1112 ));
1113 }
1114 let Some(o) = sv.as_object() else {
1115 errs.push(format!("{at}: must be an object"));
1116 return None;
1117 };
1118 let kind = match o.get("kind").and_then(Value::as_str) {
1119 Some(k) => k.to_string(),
1120 None => {
1121 errs.push(format!("{at}: `kind` is required"));
1122 return None;
1123 }
1124 };
1125 let Some(info) = kind_info(&kind) else {
1126 errs.push(format!(
1127 "{at}: unknown kind {kind:?} (see the RFC 0027 §5 catalogue)"
1128 ));
1129 return None;
1130 };
1131 let mut spec = Map::new();
1133 for (key, v) in o {
1134 if info.fields.contains(&key.as_str()) {
1144 spec.insert(key.clone(), v.clone());
1145 } else if COMMON_FIELDS.contains(&key.as_str()) {
1146 continue;
1147 } else {
1148 errs.push(format!(
1149 "{at}: unknown field {key:?} for kind {kind:?} (allowed: {})",
1150 info.fields.join(", ")
1151 ));
1152 }
1153 }
1154 for req in info.required {
1155 if !spec.contains_key(*req) {
1156 errs.push(format!("{at}: kind {kind:?} requires field {req:?}"));
1157 }
1158 }
1159 if !info.implemented {
1160 errs.push(format!("{at}: kind {kind:?} is not available in this build yet (it lands with the P4 engine); implemented kinds: {}", implemented_kinds().join(", ")));
1161 }
1162 let mut body: Option<Body> = None;
1164 let mut branches: BTreeMap<String, Body> = BTreeMap::new();
1165 if info.nested {
1166 if depth + 1 > MAX_NESTING {
1167 errs.push(format!("{at}: nesting exceeds {MAX_NESTING}"));
1168 }
1169 if matches!(kind.as_str(), "parallel" | "race") {
1170 match spec.get("branches").and_then(Value::as_object) {
1171 Some(bm) if !bm.is_empty() => {
1172 for (bname, bv) in bm {
1173 if !valid_id(bname) {
1174 errs.push(format!("{at}: branch name {bname:?} must match [a-zA-Z_][a-zA-Z0-9_-]{{0,63}}"));
1175 }
1176 if let Some(b) =
1177 parse_body(&format!("{wf}/{id}/{bname}"), bv, depth + 1, errs)
1178 {
1179 branches.insert(bname.clone(), b);
1180 }
1181 }
1182 }
1183 _ => errs.push(format!(
1184 "{at}: branches must be a non-empty object of {{steps: {{…}}}} bodies"
1185 )),
1186 }
1187 } else {
1188 match spec.get("body") {
1189 Some(bv) => body = parse_body(&format!("{wf}/{id}"), bv, depth + 1, errs),
1190 None => errs.push(format!("{at}: body is required")),
1191 }
1192 }
1193 }
1194 let depends_on: Vec<String> = match o.get("depends_on") {
1195 None => Vec::new(),
1196 Some(Value::Array(a)) => a
1197 .iter()
1198 .filter_map(Value::as_str)
1199 .map(str::to_string)
1200 .collect(),
1201 Some(Value::String(s)) => vec![s.clone()],
1202 Some(_) => {
1203 errs.push(format!("{at}: depends_on must be a list of step ids"));
1204 Vec::new()
1205 }
1206 };
1207 if info.start && !depends_on.is_empty() {
1208 errs.push(format!("{at}: a start node cannot depend on other steps"));
1209 }
1210 let when = o.get("when").and_then(Value::as_str).map(str::to_string);
1211 if let Some(w) = &when {
1212 let expr = w.trim().trim_start_matches("CEL:").trim();
1213 if let Err(e) = crate::cel::compile_check(expr) {
1214 errs.push(format!("{at}: when: {e}"));
1215 }
1216 }
1217 let retry = o.get("retry").map(|r| Retry {
1218 max: r.get("max").and_then(Value::as_u64).unwrap_or(0).min(20) as u32,
1219 backoff_ms: match r.get("backoff") {
1220 None => 0,
1221 Some(b) => duration_ms(b).unwrap_or_else(|e| {
1222 errs.push(format!("{at}: retry.backoff: {e}"));
1223 0
1224 }),
1225 },
1226 });
1227 let timeout_ms = match o.get("timeout") {
1228 None => None,
1229 Some(t) => match duration_ms(t) {
1230 Ok(ms) => Some(ms),
1231 Err(e) => {
1232 errs.push(format!("{at}: timeout: {e}"));
1233 None
1234 }
1235 },
1236 };
1237 let on_error = match o.get("on_error") {
1238 None => OnError::Fail,
1239 Some(v) => OnError::parse(v).unwrap_or_else(|e| {
1240 errs.push(format!("{at}: {e}"));
1241 OnError::Fail
1242 }),
1243 };
1244 let on_replay = match o.get("on_replay").and_then(Value::as_str) {
1245 None | Some("retry") => OnReplay::Retry,
1246 Some("skip") => OnReplay::Skip,
1247 Some("fail") => OnReplay::Fail,
1248 Some(x) => {
1249 errs.push(format!("{at}: on_replay {x:?} must be retry|skip|fail"));
1250 OnReplay::Retry
1251 }
1252 };
1253 let output_schema = o.get("output_schema").cloned();
1254 if let Some(s) = &output_schema
1255 && let Err(e) = jsonschema::check_schema(s)
1256 {
1257 errs.push(format!("{at}: output_schema: {}", e.join("; ")));
1258 }
1259 match kind.as_str() {
1261 "switch" => {
1269 if let Some(cases) = spec.get("cases").and_then(Value::as_object) {
1270 for (case, target) in cases {
1271 if !target.is_string() {
1272 errs.push(format!(
1273 "{at}: switch case {case:?} must name ONE step as a string \
1274 (got {}); write `{case}: some_step`, not a list",
1275 json_kind(target)
1276 ));
1277 }
1278 }
1279 }
1280 if let Some(d) = spec.get("default")
1281 && !d.is_string()
1282 {
1283 errs.push(format!(
1284 "{at}: switch default must name ONE step as a string (got {}); \
1285 write `default: some_step`, not a list",
1286 json_kind(d)
1287 ));
1288 }
1289 }
1290 "foreach" | "batch" | "iterate" | "parallel" | "race" | "subgraph"
1295 if spec.contains_key("collect") =>
1296 {
1297 if let Some(m) = spec
1298 .get("collect")
1299 .and_then(|c| c.get("mode"))
1300 .and_then(Value::as_str)
1301 && !matches!(m, "overwrite" | "append" | "merge" | "union")
1302 {
1303 errs.push(format!(
1304 "{at}: collect.mode {m:?} must be overwrite|append|merge|union"
1305 ));
1306 }
1307 }
1308 "human" => {
1313 for f in ["to", "reply_uri"] {
1314 if spec.contains_key(f) {
1315 errs.push(format!(
1316 "{at}: human.{f} is not implemented — a gate is answered over A2A by \
1317 whoever holds the task; remove it (see docs/node-registry.md)"
1318 ));
1319 }
1320 }
1321 }
1322 "finish" => {
1323 if let Some(st) = spec.get("status").and_then(Value::as_str)
1324 && !matches!(st, "completed" | "failed" | "refused" | "cancelled")
1325 {
1326 errs.push(format!(
1327 "{at}: finish.status must be completed|failed|refused|cancelled"
1328 ));
1329 }
1330 }
1331 "sleep" => {
1332 if let Some(d) = spec.get("duration")
1333 && let Err(e) = duration_ms(d)
1334 {
1335 errs.push(format!("{at}: sleep.duration: {e}"));
1336 }
1337 }
1338 "assert" => {
1339 if let Some(c) = spec.get("condition").and_then(Value::as_str)
1340 && let Err(e) =
1341 crate::cel::compile_check(c.trim().trim_start_matches("CEL:").trim())
1342 {
1343 errs.push(format!("{at}: assert.condition: {e}"));
1344 }
1345 }
1346 "think" | "agent" => {
1347 if let Some(s) = spec.get("output_schema")
1348 && let Err(e) = jsonschema::check_schema(s)
1349 {
1350 errs.push(format!("{at}: output_schema: {}", e.join("; ")));
1351 }
1352 }
1353 "validate" => {
1354 if let Some(s) = spec.get("schema")
1355 && let Err(e) = jsonschema::check_schema(s)
1356 {
1357 errs.push(format!("{at}: schema: {}", e.join("; ")));
1358 }
1359 }
1360 "assign" | "transform" => {
1361 if let Some(m) = spec.get("mode").and_then(Value::as_str)
1362 && !matches!(m, "overwrite" | "append" | "merge" | "union")
1363 {
1364 errs.push(format!("{at}: mode must be overwrite|append|merge|union"));
1365 }
1366 }
1367 _ => {}
1368 }
1369 for (key, v) in &spec {
1371 if let Some(s) = v.as_str()
1372 && let Some(expr) = s.trim().strip_prefix("CEL:")
1373 && let Err(e) = crate::cel::compile_check(expr.trim())
1374 {
1375 errs.push(format!("{at}: {key}: {e}"));
1376 }
1377 }
1378 Some(Step {
1379 id: id.to_string(),
1380 kind,
1381 depends_on,
1382 when,
1383 retry,
1384 timeout_ms,
1385 on_error,
1386 idempotent: o
1387 .get("idempotent")
1388 .and_then(Value::as_bool)
1389 .unwrap_or(false),
1390 on_replay,
1391 output_schema,
1392 cache: o.get("cache").cloned(),
1393 budget: o.get("budget").and_then(Value::as_u64),
1394 skills: o
1395 .get("skills")
1396 .and_then(Value::as_array)
1397 .map(|a| {
1398 a.iter()
1399 .filter_map(Value::as_str)
1400 .map(str::to_string)
1401 .collect()
1402 })
1403 .unwrap_or_default(),
1404 description: o
1405 .get("description")
1406 .and_then(Value::as_str)
1407 .map(str::to_string),
1408 spec,
1409 body,
1410 branches,
1411 })
1412}
1413
1414fn parse_body(at: &str, bv: &Value, depth: usize, errs: &mut Vec<String>) -> Option<Body> {
1416 let Some(bs) = bv.get("steps").and_then(Value::as_object) else {
1417 errs.push(format!("{at}: body must be {{steps: {{…}}}}"));
1418 return None;
1419 };
1420 if bs.is_empty() {
1421 errs.push(format!("{at}: body has no steps"));
1422 return None;
1423 }
1424 let mut steps = BTreeMap::new();
1425 for (bid, sv) in bs {
1426 if let Some(step) = parse_step(at, bid, sv, depth, errs) {
1427 if step.is_start() {
1428 errs.push(format!(
1429 "{at} step {bid:?}: a start node cannot be inside a body"
1430 ));
1431 }
1432 if step.kind == "finish" {
1433 errs.push(format!("{at} step {bid:?}: `finish` cannot be inside a body (a body's sinks are its result)"));
1434 }
1435 steps.insert(bid.clone(), step);
1436 }
1437 }
1438 let body = Body { steps };
1439 for s in body.steps.values() {
1440 for d in &s.depends_on {
1441 if !body.steps.contains_key(d) {
1442 errs.push(format!(
1443 "{at} step {:?}: depends_on names {d:?}, which is not a sibling in the body",
1444 s.id
1445 ));
1446 }
1447 }
1448 if let OnError::Goto(t) = &s.on_error
1449 && !body.steps.contains_key(t)
1450 {
1451 errs.push(format!(
1452 "{at} step {:?}: on_error goto {t:?} is not a sibling in the body",
1453 s.id
1454 ));
1455 }
1456 }
1457 if body.topo_order().len() != body.steps.len() {
1458 errs.push(format!("{at}: cycle inside the body"));
1459 }
1460 Some(body)
1461}
1462
1463fn validate_declared_state(wf: &Workflow, errs: &mut Vec<String>) {
1473 for s in wf.steps.values() {
1474 if !matches!(s.kind.as_str(), "assign" | "transform") {
1475 continue;
1476 }
1477 let key = s
1478 .spec
1479 .get("writes")
1480 .and_then(Value::as_str)
1481 .unwrap_or(s.id.as_str());
1482 let Some(decl) = wf.state.get(key) else {
1483 continue;
1484 };
1485 if let Some(want) = &decl.reducer {
1489 let mode = s
1490 .spec
1491 .get("mode")
1492 .and_then(Value::as_str)
1493 .unwrap_or("overwrite");
1494 if mode != want {
1495 errs.push(format!(
1496 "workflow {:?} step {:?}: writes {key:?} with mode {mode:?}, but state \
1497 declares reducer {want:?}",
1498 wf.name, s.id
1499 ));
1500 }
1501 }
1502 }
1503}
1504
1505fn validate_concurrent_writes(wf: &Workflow, errs: &mut Vec<String>) {
1506 use std::collections::BTreeMap;
1507 let mut writers: BTreeMap<&str, Vec<(&str, &str)>> = BTreeMap::new();
1508 for s in wf.steps.values() {
1509 if !matches!(s.kind.as_str(), "assign" | "transform") {
1510 continue;
1511 }
1512 let key = s
1513 .spec
1514 .get("writes")
1515 .and_then(Value::as_str)
1516 .unwrap_or(s.id.as_str());
1517 let mode = s
1518 .spec
1519 .get("mode")
1520 .and_then(Value::as_str)
1521 .unwrap_or("overwrite");
1522 writers.entry(key).or_default().push((s.id.as_str(), mode));
1523 }
1524 for (key, ws) in writers {
1525 if ws.len() < 2 {
1526 continue;
1527 }
1528 for (i, (a, ma)) in ws.iter().enumerate() {
1530 for (b, mb) in ws.iter().skip(i + 1) {
1531 if reachable(wf, a, b) || reachable(wf, b, a) {
1532 continue;
1533 }
1534 if exclusive_by_switch(wf, a, b) {
1539 continue;
1540 }
1541 if wf
1545 .state
1546 .get(key)
1547 .and_then(|d| d.reducer.as_deref())
1548 .is_some()
1549 {
1550 continue;
1551 }
1552 if *ma == "overwrite" || *mb == "overwrite" {
1555 errs.push(format!(
1556 "workflow {:?}: steps {a:?} and {b:?} can run concurrently and both \
1557 write {key:?} (modes {ma}/{mb}) — the surviving value would depend on \
1558 completion order; order them with depends_on, or use append/merge",
1559 wf.name
1560 ));
1561 }
1562 }
1563 }
1564 }
1565}
1566
1567fn exclusive_by_switch(wf: &Workflow, a: &str, b: &str) -> bool {
1569 for s in wf.steps.values() {
1570 if s.kind != "switch" {
1571 continue;
1572 }
1573 let mut arms: Vec<&str> = s
1574 .spec
1575 .get("cases")
1576 .and_then(Value::as_object)
1577 .map(|c| c.values().filter_map(Value::as_str).collect())
1578 .unwrap_or_default();
1579 if let Some(d) = s.spec.get("default").and_then(Value::as_str) {
1580 arms.push(d);
1581 }
1582 let on_arm = |x: &str| arms.iter().any(|arm| *arm == x || reachable(wf, arm, x));
1585 if on_arm(a) && on_arm(b) {
1586 return true;
1587 }
1588 }
1589 false
1590}
1591
1592fn reachable(wf: &Workflow, from: &str, to: &str) -> bool {
1594 let mut seen = std::collections::BTreeSet::new();
1595 let mut stack = vec![to];
1596 while let Some(cur) = stack.pop() {
1598 if cur == from {
1599 return true;
1600 }
1601 if !seen.insert(cur.to_string()) {
1602 continue;
1603 }
1604 if let Some(s) = wf.steps.get(cur) {
1605 for d in &s.depends_on {
1606 stack.push(d.as_str());
1607 }
1608 }
1609 }
1610 false
1611}
1612
1613fn validate_human_in_concurrent_bodies(wf: &Workflow, errs: &mut Vec<String>) {
1622 fn walk(wf_name: &str, owner: &str, body: &Body, errs: &mut Vec<String>) {
1623 for s in body.steps.values() {
1624 if s.kind == "human" {
1625 errs.push(format!(
1626 "workflow {wf_name:?} step {:?}: a `human` gate inside {owner:?} is not \
1627 supported — only one gate can be live per run, so a second item would \
1628 wait forever. Gate before or after the fan-out instead.",
1629 s.id
1630 ));
1631 }
1632 for nested in s.body.iter().chain(s.branches.values()) {
1633 walk(wf_name, owner, nested, errs);
1634 }
1635 }
1636 }
1637 for s in wf.steps.values() {
1638 if !matches!(s.kind.as_str(), "foreach" | "batch" | "parallel" | "race") {
1639 continue;
1640 }
1641 for body in s.body.iter().chain(s.branches.values()) {
1642 walk(&wf.name, &s.id, body, errs);
1643 }
1644 }
1645}
1646
1647fn validate_graph(wf: &Workflow, errs: &mut Vec<String>) {
1648 validate_human_in_concurrent_bodies(wf, errs);
1649 validate_declared_state(wf, errs);
1650 validate_concurrent_writes(wf, errs);
1651 let name = &wf.name;
1652 let starts: Vec<&Step> = wf.start_steps();
1653 if starts.is_empty() {
1654 errs.push(format!("workflow {name:?}: at least one start node is required (once|manual|loop|schedule|subscribe|signal|event|a2a)"));
1655 }
1656 for s in wf.steps.values() {
1658 for d in &s.depends_on {
1659 if !wf.steps.contains_key(d) {
1660 errs.push(format!(
1661 "workflow {name:?} step {:?}: depends_on names unknown step {d:?}",
1662 s.id
1663 ));
1664 }
1665 if d == &s.id {
1666 errs.push(format!(
1667 "workflow {name:?} step {:?}: depends on itself",
1668 s.id
1669 ));
1670 }
1671 }
1672 if let OnError::Goto(t) = &s.on_error
1673 && !wf.steps.contains_key(t)
1674 {
1675 errs.push(format!(
1676 "workflow {name:?} step {:?}: on_error goto names unknown step {t:?}",
1677 s.id
1678 ));
1679 }
1680 }
1681 for s in wf.steps.values() {
1683 if !s.is_start() && s.depends_on.is_empty() {
1684 errs.push(format!("workflow {name:?} step {:?}: a non-start step must depend on something (unreachable root)", s.id));
1685 }
1686 }
1687 let order = wf.topo_order();
1689 if order.len() != wf.steps.len() {
1690 let stuck: Vec<&String> = wf.steps.keys().filter(|k| !order.contains(k)).collect();
1691 errs.push(format!("workflow {name:?}: cycle among steps {stuck:?}"));
1692 }
1693 let mut reachable: BTreeSet<String> = starts.iter().map(|s| s.id.clone()).collect();
1694 let mut changed = true;
1695 while changed {
1696 changed = false;
1697 for s in wf.steps.values() {
1698 if !reachable.contains(&s.id)
1699 && !s.depends_on.is_empty()
1700 && s.depends_on.iter().any(|d| reachable.contains(d))
1701 {
1702 reachable.insert(s.id.clone());
1703 changed = true;
1704 }
1705 }
1706 }
1707 for s in wf.steps.values() {
1708 if !reachable.contains(&s.id) {
1709 errs.push(format!(
1710 "workflow {name:?} step {:?}: not reachable from any start node",
1711 s.id
1712 ));
1713 }
1714 }
1715 if !wf.steps.values().any(|s| s.kind == "finish") {
1716 errs.push(format!("workflow {name:?}: a `finish` step is required"));
1717 }
1718}
1719
1720pub fn valid_id(s: &str) -> bool {
1722 let mut chars = s.chars();
1723 match chars.next() {
1724 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
1725 _ => return false,
1726 }
1727 s.len() <= MAX_ID_LEN && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1728}
1729
1730pub const RAW_FIELDS: &[(&str, &str)] = &[
1733 ("assert", "condition"),
1734 ("map", "expr"),
1735 ("filter", "expr"),
1736 ("reduce", "expr"),
1737 ("iterate", "while"),
1738 ("iterate", "until"),
1739 ("iterate", "body"),
1740 ("foreach", "body"),
1741 ("batch", "body"),
1742 ("subgraph", "body"),
1743 ("parallel", "branches"),
1744 ("race", "branches"),
1745 ("subscribe", "filter"),
1746 ("signal", "filter"),
1747 ("event", "filter"),
1748 ("wait", "condition"),
1749 ("think", "check"),
1750 ("switch", "cases"),
1751 ("await", "condition"),
1752];
1753
1754pub fn is_raw_field(kind: &str, field: &str) -> bool {
1755 RAW_FIELDS.iter().any(|(k, f)| *k == kind && *f == field)
1756}
1757
1758pub fn duration_ms_opt(v: &Value) -> Option<u64> {
1760 duration_ms(v).ok()
1761}
1762
1763pub fn duration_ms(v: &Value) -> Result<u64, String> {
1765 match v {
1766 Value::Number(n) => n
1767 .as_u64()
1768 .map(|s| s * 1000)
1769 .ok_or_else(|| "duration must be a non-negative number of seconds".into()),
1770 Value::String(s) => crate::config::parse_duration(s).map(|d| d.as_millis() as u64),
1771 Value::Object(o) => o
1772 .get("ms")
1773 .and_then(Value::as_u64)
1774 .ok_or_else(|| "duration object must be {ms: n}".into()),
1775 _ => Err("duration must be a string like 30s or a number of seconds".into()),
1776 }
1777}
1778
1779pub fn canonical(v: &Value) -> String {
1781 v.to_string()
1782}
1783
1784pub fn workflow_schema() -> Value {
1786 let kinds: Vec<&str> = KINDS.iter().map(|k| k.name).collect();
1787 json!({
1788 "$schema": "https://json-schema.org/draft/2020-12/schema",
1789 "$id": "https://agentd.dev/schemas/workflow-3.json",
1790 "title": "agentd workflow (dialect 3)",
1791 "type": "object",
1792 "required": ["name", "steps"],
1793 "properties": {
1794 "name": {"type": "string", "pattern": "^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$"},
1795 "version": {"const": 3},
1796 "description": {"type": "string"},
1797 "armed": {"type": "boolean", "default": true},
1798 "inputs": {"type": "object", "properties": {"schema": {"type": "object"}}},
1799 "outputs": {"type": "object", "properties": {"schema": {"type": "object"}}},
1800 "state": {"type": "object", "additionalProperties": {"type": "object",
1801 "additionalProperties": false,
1802 "properties": {
1803 "schema": {"type": "object", "description": "a JSON Schema every write to this key must satisfy"},
1804 "reducer": {"enum": ["overwrite", "append", "merge", "union"],
1805 "description": "how concurrent writes to this key combine; declaring it makes concurrency a policy rather than a race"}}},
1806 "description": "declared run variables — {key: {schema, reducer}}"},
1807 "concurrency": {"type": "object", "properties": {"max_runs": {"type": "integer", "minimum": 1}, "on_overflow": {"enum": ["queue", "drop", "replace"]}}},
1808 "limits": {"type": "object", "properties": {"steps": {"type": "integer"}, "tokens": {"type": "integer"}, "deadline": {"type": "string"}, "budget": {"type": "object"}}},
1809 "steps": {"type": "object", "additionalProperties": {"$ref": "#/$defs/step"}, "minProperties": 1}
1810 },
1811 "$defs": {
1812 "step": {
1813 "type": "object",
1814 "required": ["kind"],
1815 "properties": {
1816 "kind": {"enum": kinds},
1817 "depends_on": {"type": "array", "items": {"type": "string"}},
1818 "when": {"type": "string"},
1819 "retry": {"type": "object", "properties": {"max": {"type": "integer"}, "backoff": {"type": "string"}}},
1820 "timeout": {"type": "string"},
1821 "on_error": {"type": "string"},
1822 "idempotent": {"type": "boolean"},
1823 "on_replay": {"enum": ["retry", "skip", "fail"]},
1824 "output_schema": {"type": "object"},
1825 "cache": {"type": "object"},
1826 "budget": {"type": "integer"},
1827 "skills": {"type": "array", "items": {"type": "string"}},
1828 "otel": {"type": "object"},
1829 "description": {"type": "string"}
1830 }
1831 },
1832 "kinds": KINDS.iter().map(|k| (k.name.to_string(), json!({"start": k.start, "fields": k.fields, "required": k.required, "implemented": k.implemented}))).collect::<BTreeMap<_, _>>()
1833 }
1834 })
1835}
1836
1837#[cfg(test)]
1838mod tests {
1839 #[test]
1847 fn a_kind_that_declares_output_schema_receives_it() {
1848 let doc = serde_json::json!({
1849 "name": "w",
1850 "steps": {
1851 "go": {"kind": "manual"},
1852 "e": {"kind": "extract", "depends_on": ["go"], "input": "x",
1853 "output_schema": {"type": "object"}},
1854 "t": {"kind": "think", "depends_on": ["e"], "prompt": "p",
1855 "output_schema": {"type": "object"}},
1856 "fin": {"kind": "finish", "depends_on": ["t"], "status": "completed"}
1857 }
1858 });
1859 let wf = parse_workflow(&doc)
1860 .unwrap_or_else(|e| panic!("extract must validate with an output_schema: {e:?}"));
1861 for id in ["e", "t"] {
1863 let step = wf.steps.get(id).unwrap_or_else(|| panic!("step {id}"));
1864 assert!(
1865 step.field("output_schema").is_some(),
1866 "{id}: the kind's own output_schema must reach the node spec"
1867 );
1868 }
1869 }
1870
1871 use super::*;
1872
1873 fn wf(doc: Value) -> Result<Workflow, Vec<String>> {
1874 parse_workflow(&doc)
1875 }
1876
1877 #[test]
1878 fn the_sugar_workflow_parses_hashes_and_orders() {
1879 let w = wf(json!({
1880 "name": "main", "version": 3,
1881 "steps": {
1882 "start": {"kind": "once"},
1883 "work": {"kind": "agent", "depends_on": ["start"], "instruction": "{{env.instruction}}"},
1884 "done": {"kind": "finish", "depends_on": ["work"], "status": "completed", "output": "{{steps.work.output}}"}
1885 }
1886 }))
1887 .unwrap();
1888 assert_eq!(w.start_steps().len(), 1);
1889 assert_eq!(w.topo_order(), vec!["start", "work", "done"]);
1890 assert_eq!(w.hash.len(), 64);
1891 assert!(!w.is_long_lived());
1892 assert!(w.armed);
1893 assert_eq!(
1894 w.step("work").unwrap().field_str("instruction"),
1895 Some("{{env.instruction}}")
1896 );
1897 let w2 = wf(w.definition.clone()).unwrap();
1899 assert_eq!(w2.hash, w.hash);
1900 let mut d = w.definition.clone();
1901 d["steps"]["work"]["instruction"] = json!("other");
1902 assert_ne!(wf(d).unwrap().hash, w.hash);
1903 }
1904
1905 #[cfg(feature = "cel")]
1907 #[test]
1908 fn validation_catches_the_rfc_0027_section_8_failures() {
1909 let e = wf(json!({"name": "bad name", "start": "x", "steps": {
1911 "a": {"kind": "agent", "instruction": "x"},
1912 "b": {"kind": "tool", "name": "memory.get", "depends_on": ["a"], "bogus": 1},
1913 "c": {"kind": "foreach", "over": "{{x}}", "body": {"steps": {"i": {"kind": "noop", "depends_on": ["q"]}, "bad id": {"kind": "noop"}}}, "depends_on": ["b"]},
1914 "d": {"kind": "nope", "depends_on": ["a"]},
1915 "e": {"kind": "sleep", "duration": "5 parsecs", "depends_on": ["a"], "when": "CEL: 1 +"},
1916 "s": {"kind": "once", "depends_on": ["a"]}
1917 }}))
1918 .unwrap_err();
1919 let joined = e.join("\n");
1920 for needle in [
1921 "workflow name \"bad name\"",
1922 "`start`/`nodes` are dialect 1/2",
1923 "unknown field \"bogus\"",
1924 "unknown kind \"nope\"",
1925 "sleep.duration",
1926 "when: CEL parse",
1927 "a start node cannot depend on other steps",
1928 "step \"bad id\": id must match",
1929 ] {
1930 assert!(joined.contains(needle), "missing {needle:?} in:\n{joined}");
1931 }
1932 let e = wf(json!({"name": "g", "steps": {
1934 "s": {"kind": "once"},
1935 "b": {"kind": "noop", "depends_on": ["s", "zz"]},
1936 "e": {"kind": "sleep", "duration": "1s", "depends_on": ["s"], "on_error": "goto:nowhere"},
1937 "loop1": {"kind": "noop", "depends_on": ["loop2"]},
1938 "loop2": {"kind": "noop", "depends_on": ["loop1"]},
1939 "f": {"kind": "finish", "depends_on": ["b"]}
1940 }}))
1941 .unwrap_err();
1942 let joined = e.join("\n");
1943 for needle in [
1944 "depends_on names unknown step \"zz\"",
1945 "on_error goto names unknown step \"nowhere\"",
1946 "cycle among steps",
1947 "not reachable from any start node",
1948 ] {
1949 assert!(joined.contains(needle), "missing {needle:?} in:\n{joined}");
1950 }
1951 let e = wf(json!({"name": "w", "steps": {
1953 "a": {"kind": "noop"},
1954 "b": {"kind": "noop", "depends_on": ["c"]},
1955 "c": {"kind": "noop", "depends_on": ["b"]}
1956 }}))
1957 .unwrap_err();
1958 let joined = e.join("\n");
1959 assert!(joined.contains("at least one start node"), "{joined}");
1960 assert!(joined.contains("unreachable root"), "{joined}");
1961 assert!(joined.contains("cycle among steps"), "{joined}");
1962 assert!(joined.contains("`finish` step is required"), "{joined}");
1963 let e = wf(json!({"name": "w", "version": 2, "steps": {"s": {"kind": "once"}, "f": {"kind": "finish", "depends_on": ["s"]}}})).unwrap_err();
1965 assert!(e[0].contains("not dialect 3"));
1966 let ok = wf(json!({"name": "w", "inputs": {"schema": {"type": "object"}}, "concurrency": {"max_runs": 2, "on_overflow": "drop"}, "limits": {"deadline": "10m", "steps": 50}, "steps": {
1968 "s": {"kind": "manual"},
1969 "t": {"kind": "mcp.tool", "server": "fs", "tool": "read", "args": {"path": "/x"}, "depends_on": ["s"], "retry": {"max": 2, "backoff": "1s"}, "timeout": "30s", "on_error": "continue"},
1970 "v": {"kind": "assign", "value": {"a": 1}, "writes": "x", "depends_on": ["t"], "when": "CEL: true"},
1971 "th": {"kind": "think", "prompt": "p", "output_schema": {"type": "object"}, "depends_on": ["v"]},
1972 "z": {"kind": "sleep", "duration": "1s", "depends_on": ["th"]},
1973 "f": {"kind": "finish", "depends_on": ["z"], "status": "completed", "output": "{{vars.x}}"}
1974 }}))
1975 .unwrap();
1976 assert_eq!(ok.concurrency.on_overflow, OnOverflow::Drop);
1977 assert_eq!(ok.limits.deadline_ms, Some(600_000));
1978 assert_eq!(
1979 ok.step("t").unwrap().retry.as_ref().unwrap().backoff_ms,
1980 1000
1981 );
1982 assert_eq!(ok.step("t").unwrap().on_error, OnError::Continue);
1983 assert_eq!(ok.step("t").unwrap().timeout_ms, Some(30_000));
1984 assert!(implemented_kinds().contains(&"agent"));
1985 assert!(workflow_schema()["$defs"]["kinds"]["a2a.send"]["implemented"] == json!(true));
1986 assert!(workflow_schema()["$defs"]["kinds"]["foreach"]["implemented"] == json!(true));
1987 }
1988}