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 MAX_ITERATIONS: u64 = 10_000;
22pub const MAX_ID_LEN: usize = 64;
23
24#[derive(Debug, Clone, Copy)]
26pub struct KindInfo {
27 pub name: &'static str,
28 pub start: bool,
30 pub fields: &'static [&'static str],
32 pub required: &'static [&'static str],
34 pub implemented: bool,
37 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
59pub const KINDS: &[KindInfo] = &[
62 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 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 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 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 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
573pub 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
595pub 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#[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 #[serde(default)]
647 pub backoff_ms: u64,
648}
649
650#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
656pub struct Body {
657 pub steps: BTreeMap<String, Step>,
658}
659
660impl Body {
661 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 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#[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 #[serde(default)]
724 pub spec: Map<String, Value>,
725 #[serde(default, skip_serializing_if = "Option::is_none")]
727 pub body: Option<Body>,
728 #[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 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#[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 pub hash: String,
805 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 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 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 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
858pub 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 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 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 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 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 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
1268fn 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
1317fn 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 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 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 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
1388pub 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
1398pub 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
1426pub fn duration_ms_opt(v: &Value) -> Option<u64> {
1428 duration_ms(v).ok()
1429}
1430
1431pub 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
1447pub fn canonical(v: &Value) -> String {
1449 v.to_string()
1450}
1451
1452pub 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 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 #[cfg(feature = "cel")]
1536 #[test]
1537 fn validation_catches_the_rfc_0027_section_8_failures() {
1538 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 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 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 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 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}