1use crate::jsonschema;
18use serde::{Deserialize, Serialize};
19use serde_json::{Map, Value, json};
20use std::collections::{BTreeMap, BTreeSet};
21
22pub const DIALECT: u32 = 3;
24pub const MAX_STEPS: usize = 512;
27pub const MAX_NESTING: usize = 4;
28pub const MAX_BATCH_PARALLEL: u64 = 8;
29pub const DEFAULT_FAN_OUT: u64 = 4;
36pub const MAX_ITERATIONS: u64 = 10_000;
37pub const MAX_ID_LEN: usize = 64;
38
39#[derive(Debug, Clone, Copy)]
41pub struct KindInfo {
42 pub name: &'static str,
43 pub start: bool,
45 pub fields: &'static [&'static str],
47 pub required: &'static [&'static str],
49 pub implemented: bool,
53 pub nested: bool,
55}
56
57fn check_into(spec: &Map<String, Value>, at: &str, errs: &mut Vec<String>) {
64 let Some(into) = spec.get("into") else {
65 return;
66 };
67 let ok = into.as_object().is_some_and(|o| {
71 o.keys().all(|k| k == "stream" || k == "subject")
72 && o.get("stream")
73 .and_then(Value::as_str)
74 .is_some_and(|s| !s.is_empty())
75 && o.get("subject")
76 .and_then(Value::as_str)
77 .is_some_and(|s| !s.is_empty())
78 });
79 if !ok {
80 errs.push(format!(
81 "{at}: into takes {{stream: <name>, subject: <subject>}}"
82 ));
83 }
84}
85
86const fn k(
87 name: &'static str,
88 start: bool,
89 fields: &'static [&'static str],
90 required: &'static [&'static str],
91 implemented: bool,
92 nested: bool,
93) -> KindInfo {
94 KindInfo {
95 name,
96 start,
97 fields,
98 required,
99 implemented,
100 nested,
101 }
102}
103
104pub const KINDS: &[KindInfo] = &[
109 k("once", true, &["policy", "inputs"], &[], true, false),
111 k("manual", true, &["inputs"], &[], true, false),
112 k(
113 "loop",
114 true,
115 &[
116 "interval",
117 "delay",
118 "until",
119 "max_iterations",
120 "backoff",
121 "inputs",
122 ],
123 &[],
124 true,
125 false,
126 ),
127 k(
128 "schedule",
129 true,
130 &["cron", "every", "tz", "jitter", "catch_up", "at", "inputs"],
131 &[],
132 true,
133 false,
134 ),
135 k(
136 "subscribe",
137 true,
138 &[
139 "server",
140 "uri",
141 "debounce_ms",
142 "coalesce",
143 "filter",
144 "deliver",
145 "on_no_listener",
146 "window",
147 "inputs",
148 ],
149 &["server", "uri"],
150 true,
151 false,
152 ),
153 k(
154 "stream",
155 true,
156 &[
157 "stream", "subject", "filter", "from", "rate", "batch", "inputs",
158 ],
159 &["stream"],
160 true,
161 false,
162 ),
163 k(
164 "correlate",
165 true,
166 &[
167 "stream",
168 "on",
169 "by",
170 "window",
171 "on_incomplete",
172 "filter",
173 "max_pending",
174 "inputs",
175 ],
176 &["stream", "on"],
177 true,
178 false,
179 ),
180 k(
181 "signal",
182 true,
183 &["name", "filter", "deliver", "inputs"],
184 &["name"],
185 true,
186 false,
187 ),
188 k(
189 "event",
190 true,
191 &["on", "filter", "inputs"],
192 &["on"],
193 true,
194 false,
195 ),
196 k(
197 "a2a",
198 true,
199 &["command", "roles", "inputs", "schema", "into"],
200 &[],
201 true,
202 false,
203 ),
204 k(
205 "webhook",
206 true,
207 &[
208 "path",
209 "methods",
210 "auth",
211 "parallelism",
212 "on_overflow",
213 "rate",
214 "idempotency",
215 "respond",
216 "filter",
217 "inputs",
218 "signal",
219 "into",
220 ],
221 &["path"],
222 true,
223 false,
224 ),
225 k(
227 "switch",
228 false,
229 &["on", "cases", "default", "on_no_match"],
230 &["on", "cases"],
231 true,
232 false,
233 ),
234 k(
235 "parallel",
236 false,
237 &["branches", "on_error"],
238 &["branches"],
239 true,
240 true,
241 ),
242 k(
243 "foreach",
244 false,
245 &["over", "body", "batch", "collect", "on_error", "as"],
246 &["over", "body"],
247 true,
248 true,
249 ),
250 k(
251 "batch",
252 false,
253 &[
254 "over", "body", "by", "size", "parallel", "rate", "collect", "on_error",
255 ],
256 &["over", "body"],
257 true,
258 true,
259 ),
260 k(
261 "iterate",
262 false,
263 &["body", "while", "until", "max_iterations", "collect"],
264 &["body"],
265 true,
266 true,
267 ),
268 k(
269 "race",
270 false,
271 &["branches", "timeout", "min_success"],
272 &["branches"],
273 true,
274 true,
275 ),
276 k(
277 "join",
278 false,
279 &["handles", "timeout", "min", "partials"],
280 &["handles"],
281 true,
282 false,
283 ),
284 k("subgraph", false, &["body"], &["body"], true, true),
285 k(
286 "workflow",
287 false,
288 &["name", "inputs", "mode", "start", "version", "cascade"],
289 &["name"],
290 true,
291 false,
292 ),
293 k(
294 "wait",
295 false,
296 &[
297 "on",
298 "server",
299 "uri",
300 "condition",
301 "signal",
302 "run",
303 "subagent",
304 "conversation",
305 "webhook",
306 "stream",
307 "subject",
308 "match",
309 "timeout",
310 "on_timeout",
311 ],
312 &["on"],
313 true,
314 false,
315 ),
316 k("sleep", false, &["duration"], &["duration"], true, false),
317 k(
318 "assert",
319 false,
320 &["condition", "message"],
321 &["condition"],
322 true,
323 false,
324 ),
325 k("fail", false, &["message", "code"], &[], true, false),
326 k("noop", false, &[], &[], true, false),
327 k("checkpoint", false, &["name"], &[], true, false),
328 k(
329 "finish",
330 false,
331 &["status", "output", "reason"],
332 &[],
333 true,
334 false,
335 ),
336 k(
338 "assign",
339 false,
340 &["value", "writes", "mode"],
341 &["value"],
342 true,
343 false,
344 ),
345 k(
346 "transform",
347 false,
348 &["value", "writes", "mode"],
349 &["value"],
350 true,
351 false,
352 ),
353 k(
354 "map",
355 false,
356 &["over", "expr", "as"],
357 &["over", "expr"],
358 true,
359 false,
360 ),
361 k(
362 "filter",
363 false,
364 &["over", "expr", "as"],
365 &["over", "expr"],
366 true,
367 false,
368 ),
369 k(
370 "reduce",
371 false,
372 &["over", "expr", "initial", "as", "acc"],
373 &["over", "expr"],
374 true,
375 false,
376 ),
377 k(
378 "sort",
379 false,
380 &["over", "by", "order"],
381 &["over"],
382 true,
383 false,
384 ),
385 k("dedupe", false, &["over", "by"], &["over"], true, false),
386 k(
387 "chunk",
388 false,
389 &["value", "by", "size", "overlap"],
390 &["value", "size"],
391 true,
392 false,
393 ),
394 k("template", false, &["text", "value"], &[], true, false),
395 k("parse", false, &["text", "format"], &["text"], true, false),
396 k(
397 "validate",
398 false,
399 &["value", "schema"],
400 &["value", "schema"],
401 true,
402 false,
403 ),
404 k("memory.get", false, &["key"], &["key"], true, false),
405 k(
406 "memory.set",
407 false,
408 &["key", "value", "ttl"],
409 &["key", "value"],
410 true,
411 false,
412 ),
413 k("memory.list", false, &["prefix", "limit"], &[], true, false),
414 k(
415 "memory.push",
416 false,
417 &["key", "value"],
418 &["key", "value"],
419 true,
420 false,
421 ),
422 k("memory.shift", false, &["key"], &["key"], true, false),
423 k("memory.pop", false, &["key"], &["key"], true, false),
424 k("memory.delete", false, &["key"], &["key"], true, false),
425 k(
426 "artifact.create",
427 false,
428 &["name", "mime", "content", "from_step", "sensitive"],
429 &["name"],
430 true,
431 false,
432 ),
433 k("artifact.get", false, &["id"], &["id"], true, false),
434 k("artifact.delete", false, &["id"], &["id"], true, false),
435 k(
436 "knowledge.search",
437 false,
438 &["query", "top_k", "filters"],
439 &["query"],
440 true,
441 false,
442 ),
443 k("knowledge.get", false, &["id", "uri"], &[], true, false),
444 k(
445 "search.query",
446 false,
447 &["query", "kind", "limit", "freshness"],
448 &["query"],
449 true,
450 false,
451 ),
452 k(
453 "search.fetch",
454 false,
455 &["url", "max_bytes"],
456 &["url"],
457 true,
458 false,
459 ),
460 k(
462 "mcp.tool",
463 false,
464 &["server", "tool", "args", "idempotency", "breaker", "rate"],
465 &["server", "tool"],
466 true,
467 false,
468 ),
469 k(
470 "mcp.resource",
471 false,
472 &[
473 "server",
474 "op",
475 "uri",
476 "name",
477 "arguments",
478 "reference",
479 "argument",
480 ],
481 &["server", "op"],
482 true,
483 false,
484 ),
485 k("tool", false, &["name", "args"], &["name"], true, false),
486 k(
487 "http",
488 false,
489 &[
490 "method",
491 "url",
492 "headers",
493 "query",
494 "body",
495 "json",
496 "timeout",
497 "expect",
498 "allow_private",
499 "sign",
500 "idempotency",
501 "breaker",
502 "rate",
503 ],
504 &["url"],
505 true,
506 false,
507 ),
508 k(
509 "a2a.send",
510 false,
511 &[
512 "to",
513 "parts",
514 "command",
515 "args",
516 "context",
517 "timeout",
518 "idempotency",
519 "breaker",
520 "rate",
521 ],
522 &["to"],
523 true,
524 false,
525 ),
526 k(
527 "a2a.delegate",
528 false,
529 &[
530 "peer",
531 "objective",
532 "command",
533 "args",
534 "output_contract",
535 "timeout",
536 "idempotency",
537 "breaker",
538 "rate",
539 ],
540 &["peer"],
541 true,
542 false,
543 ),
544 k(
545 "a2a.wait",
546 false,
547 &["conversation", "timeout"],
548 &[],
549 true,
550 false,
551 ),
552 k(
557 "message",
558 false,
559 &["to", "text", "parts", "wait", "timeout", "on_timeout"],
560 &["to"],
561 true,
562 false,
563 ),
564 k(
565 "workflow.signal",
566 false,
567 &["name", "payload", "run"],
568 &["name"],
569 true,
570 false,
571 ),
572 k(
573 "workflow.wait",
574 false,
575 &["run", "timeout"],
576 &["run"],
577 true,
578 false,
579 ),
580 k(
581 "workflow.cancel",
582 false,
583 &["run", "reason"],
584 &["run"],
585 true,
586 false,
587 ),
588 k(
589 "emit",
590 false,
591 &[
592 "note",
593 "audit",
594 "metric",
595 "value",
596 "stream",
597 "subject",
598 "data",
599 "correlation",
600 "forward",
601 ],
602 &[],
603 true,
604 false,
605 ),
606 k(
608 "think",
609 false,
610 &[
611 "prompt",
612 "output_schema",
613 "reads",
614 "check",
615 "retries",
616 "skills",
617 "system",
618 "model",
619 ],
620 &["prompt"],
621 true,
622 false,
623 ),
624 k(
625 "classify",
626 false,
627 &["input", "classes", "prompt", "skills", "model"],
628 &["input", "classes"],
629 true,
630 false,
631 ),
632 k(
633 "extract",
634 false,
635 &["input", "output_schema", "prompt", "skills", "model"],
636 &["input", "output_schema"],
637 true,
638 false,
639 ),
640 k(
641 "summarize",
642 false,
643 &["input", "length", "prompt", "skills", "model"],
644 &["input"],
645 true,
646 false,
647 ),
648 k(
649 "judge",
650 false,
651 &["input", "rubric", "prompt", "skills", "model"],
652 &["input", "rubric"],
653 true,
654 false,
655 ),
656 k(
657 "route",
658 false,
659 &["input", "choices", "prompt", "skills", "model"],
660 &["input", "choices"],
661 true,
662 false,
663 ),
664 k(
665 "agent",
666 false,
667 &[
668 "instruction",
669 "output_contract",
670 "output_schema",
671 "tools",
672 "servers",
673 "limits",
674 "context",
675 "skills",
676 "system",
677 "model",
678 ],
679 &["instruction"],
680 true,
681 false,
682 ),
683 k(
688 "subagent",
689 false,
690 &[
691 "instruction",
692 "template",
693 "params",
694 "mode",
695 "tools",
696 "servers",
697 "limits",
698 "priority",
699 "context",
700 "output_contract",
701 "output_schema",
702 "skills",
703 "durable",
704 ],
705 &[],
706 true,
707 false,
708 ),
709 k(
710 "human",
711 false,
712 &["question", "schema", "to", "timeout", "reply_uri"],
713 &["question"],
714 true,
715 false,
716 ),
717];
718
719pub const COMMON_FIELDS: &[&str] = &[
723 "kind",
724 "depends_on",
725 "when",
726 "retry",
727 "timeout",
728 "on_error",
729 "idempotent",
730 "on_replay",
731 "output_schema",
732 "cache",
733 "budget",
734 "skills",
735 "otel",
736 "description",
737];
738
739pub fn pure_data_kind(kind: &str) -> bool {
748 matches!(
749 kind,
750 "assign"
751 | "map"
752 | "filter"
753 | "reduce"
754 | "sort"
755 | "dedupe"
756 | "chunk"
757 | "parse"
758 | "switch"
759 | "noop"
760 | "assert"
761 | "validate"
762 )
763}
764
765pub fn kind_info(name: &str) -> Option<&'static KindInfo> {
766 KINDS.iter().find(|k| k.name == name)
767}
768
769pub fn implemented_kinds() -> Vec<&'static str> {
771 KINDS
772 .iter()
773 .filter(|k| k.implemented)
774 .map(|k| k.name)
775 .collect()
776}
777
778#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
780#[serde(rename_all = "snake_case")]
781pub enum OnError {
782 #[default]
783 Fail,
784 Continue,
785 Goto(String),
786}
787
788impl OnError {
789 fn parse(v: &Value) -> Result<OnError, String> {
790 match v.as_str() {
791 Some("fail") => Ok(OnError::Fail),
792 Some("continue") => Ok(OnError::Continue),
793 Some(s) if s.starts_with("goto:") => {
794 let t = s["goto:".len()..].trim();
795 if t.is_empty() {
796 Err("on_error goto: needs a step id".into())
797 } else {
798 Ok(OnError::Goto(t.to_string()))
799 }
800 }
801 _ => Err("on_error must be fail | continue | goto:<step>".into()),
802 }
803 }
804}
805
806#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
807#[serde(rename_all = "snake_case")]
808pub enum OnReplay {
809 #[default]
810 Retry,
811 Skip,
812 Fail,
813}
814
815#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
816pub struct Retry {
817 #[serde(default)]
818 pub max: u32,
819 #[serde(default)]
821 pub backoff_ms: u64,
822}
823
824#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
830pub struct Body {
831 pub steps: BTreeMap<String, Step>,
832}
833
834impl Body {
835 pub fn topo_order(&self) -> Vec<String> {
837 let mut out = Vec::new();
838 let mut done: BTreeSet<String> = BTreeSet::new();
839 let mut progress = true;
840 while progress && out.len() < self.steps.len() {
841 progress = false;
842 for (id, s) in &self.steps {
843 if !done.contains(id) && s.depends_on.iter().all(|d| done.contains(d)) {
844 done.insert(id.clone());
845 out.push(id.clone());
846 progress = true;
847 }
848 }
849 }
850 out
851 }
852 pub fn sinks(&self) -> Vec<String> {
854 self.steps
855 .keys()
856 .filter(|id| {
857 !self
858 .steps
859 .values()
860 .any(|s| s.depends_on.iter().any(|d| d == *id))
861 })
862 .cloned()
863 .collect()
864 }
865}
866
867#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
869pub struct Step {
870 pub id: String,
871 pub kind: String,
872 #[serde(default)]
873 pub depends_on: Vec<String>,
874 #[serde(default, skip_serializing_if = "Option::is_none")]
875 pub when: Option<String>,
876 #[serde(default, skip_serializing_if = "Option::is_none")]
877 pub retry: Option<Retry>,
878 #[serde(default, skip_serializing_if = "Option::is_none")]
879 pub timeout_ms: Option<u64>,
880 #[serde(default)]
881 pub on_error: OnError,
882 #[serde(default)]
883 pub idempotent: bool,
884 #[serde(default)]
885 pub on_replay: OnReplay,
886 #[serde(default, skip_serializing_if = "Option::is_none")]
887 pub output_schema: Option<Value>,
888 #[serde(default, skip_serializing_if = "Option::is_none")]
889 pub cache: Option<Value>,
890 #[serde(default, skip_serializing_if = "Option::is_none")]
891 pub budget: Option<u64>,
892 #[serde(default, skip_serializing_if = "Vec::is_empty")]
893 pub skills: Vec<String>,
894 #[serde(default, skip_serializing_if = "Option::is_none")]
895 pub description: Option<String>,
896 #[serde(default)]
898 pub spec: Map<String, Value>,
899 #[serde(default, skip_serializing_if = "Option::is_none")]
901 pub body: Option<Body>,
902 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
904 pub branches: BTreeMap<String, Body>,
905}
906
907impl Step {
908 pub fn info(&self) -> Option<&'static KindInfo> {
909 kind_info(&self.kind)
910 }
911 pub fn is_start(&self) -> bool {
912 self.info().is_some_and(|k| k.start)
913 }
914 pub fn field(&self, name: &str) -> Option<&Value> {
916 self.spec.get(name)
917 }
918 pub fn field_str(&self, name: &str) -> Option<&str> {
919 self.spec.get(name).and_then(Value::as_str)
920 }
921}
922
923#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
924#[serde(rename_all = "snake_case")]
925pub enum OnOverflow {
926 #[default]
927 Queue,
928 Drop,
929 Replace,
930}
931
932#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
933pub struct Concurrency {
934 pub max_runs: u32,
935 pub on_overflow: OnOverflow,
936 #[serde(default)]
946 pub scope: ConcurrencyScope,
947}
948
949#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
950#[serde(rename_all = "lowercase")]
951pub enum ConcurrencyScope {
952 #[default]
953 Workflow,
954 Key,
955}
956
957impl Default for Concurrency {
958 fn default() -> Self {
959 Concurrency {
960 max_runs: 4,
961 on_overflow: OnOverflow::Queue,
962 scope: ConcurrencyScope::Workflow,
963 }
964 }
965}
966
967#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
968pub struct WorkflowLimits {
969 #[serde(default, skip_serializing_if = "Option::is_none")]
970 pub steps: Option<u32>,
971 #[serde(default, skip_serializing_if = "Option::is_none")]
972 pub tokens: Option<u64>,
973 #[serde(default, skip_serializing_if = "Option::is_none")]
974 pub deadline_ms: Option<u64>,
975 #[serde(default, skip_serializing_if = "Option::is_none")]
976 pub budget: Option<Value>,
977}
978
979#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
981pub struct WorkflowTool {
982 pub name: String,
984 #[serde(default)]
987 pub mode: WorkflowToolMode,
988 #[serde(default)]
990 pub grant: WorkflowToolGrant,
991}
992
993#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
994#[serde(rename_all = "lowercase")]
995pub enum WorkflowToolMode {
996 #[default]
997 Sync,
998 Async,
999}
1000
1001#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1002pub struct WorkflowToolGrant {
1003 pub root: bool,
1004 pub workflows: bool,
1005 pub subagents: bool,
1006 pub user: bool,
1007 pub agent: bool,
1008}
1009
1010impl Default for WorkflowToolGrant {
1011 fn default() -> Self {
1012 WorkflowToolGrant {
1016 root: true,
1017 workflows: true,
1018 subagents: false,
1019 user: false,
1020 agent: false,
1021 }
1022 }
1023}
1024
1025#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1027pub struct StateDecl {
1028 #[serde(default, skip_serializing_if = "Option::is_none")]
1030 pub schema: Option<Value>,
1031 #[serde(default, skip_serializing_if = "Option::is_none")]
1033 pub reducer: Option<String>,
1034}
1035
1036#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1038pub struct Workflow {
1039 pub name: String,
1040 pub version: u32,
1041 #[serde(default)]
1046 pub priority: Priority,
1047 #[serde(default)]
1049 pub unload: Unload,
1050 #[serde(default, skip_serializing_if = "Option::is_none")]
1056 pub durable: Option<bool>,
1057 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1065 pub state: BTreeMap<String, StateDecl>,
1066 #[serde(default, skip_serializing_if = "Option::is_none")]
1067 pub description: Option<String>,
1068 #[serde(default = "default_true")]
1069 pub armed: bool,
1070 #[serde(default, skip_serializing_if = "Option::is_none")]
1071 pub inputs_schema: Option<Value>,
1072 #[serde(default)]
1073 pub concurrency: Concurrency,
1074 #[serde(default, skip_serializing_if = "Option::is_none")]
1083 pub key: Option<String>,
1084 #[serde(default, skip_serializing_if = "Option::is_none")]
1095 pub tool: Option<WorkflowTool>,
1096 #[serde(default)]
1097 pub limits: WorkflowLimits,
1098 #[serde(default, skip_serializing_if = "Option::is_none")]
1099 pub outputs_schema: Option<Value>,
1100 pub steps: BTreeMap<String, Step>,
1101 pub hash: String,
1105 pub definition: Value,
1107}
1108
1109fn default_true() -> bool {
1110 true
1111}
1112
1113#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1120#[serde(rename_all = "lowercase")]
1121pub enum UnloadPolicy {
1122 #[default]
1125 Drain,
1126 Cancel,
1128 Detach,
1130}
1131
1132impl UnloadPolicy {
1133 pub fn as_str(self) -> &'static str {
1134 match self {
1135 UnloadPolicy::Drain => "drain",
1136 UnloadPolicy::Cancel => "cancel",
1137 UnloadPolicy::Detach => "detach",
1138 }
1139 }
1140}
1141
1142#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
1144pub struct Unload {
1145 #[serde(default)]
1146 pub policy: UnloadPolicy,
1147 #[serde(default, skip_serializing_if = "Option::is_none")]
1149 pub timeout_ms: Option<u64>,
1150}
1151
1152#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1155#[serde(rename_all = "lowercase")]
1156pub enum Priority {
1157 Low,
1158 #[default]
1159 Normal,
1160 High,
1161}
1162
1163impl Priority {
1164 pub fn as_str(self) -> &'static str {
1165 match self {
1166 Priority::Low => "low",
1167 Priority::Normal => "normal",
1168 Priority::High => "high",
1169 }
1170 }
1171 pub fn from_spec(v: Option<&Value>) -> Result<Priority, String> {
1173 match v.and_then(Value::as_str) {
1174 None if v.is_none() => Ok(Priority::Normal),
1175 Some("low") => Ok(Priority::Low),
1176 Some("normal") => Ok(Priority::Normal),
1177 Some("high") => Ok(Priority::High),
1178 other => Err(format!(
1179 "priority must be low|normal|high, got {:?}",
1180 other
1181 .map(str::to_string)
1182 .unwrap_or_else(|| v.map(|x| x.to_string()).unwrap_or_default())
1183 )),
1184 }
1185 }
1186 pub fn nice(self) -> Option<i32> {
1190 match self {
1191 Priority::Low => Some(10),
1192 Priority::Normal => None,
1193 Priority::High => Some(-5),
1194 }
1195 }
1196}
1197
1198impl Workflow {
1199 pub fn start_steps(&self) -> Vec<&Step> {
1200 self.steps.values().filter(|s| s.is_start()).collect()
1201 }
1202 pub fn step(&self, id: &str) -> Option<&Step> {
1203 self.steps.get(id)
1204 }
1205 pub fn dependents(&self, id: &str) -> Vec<&Step> {
1207 self.steps
1208 .values()
1209 .filter(|s| s.depends_on.iter().any(|d| d == id))
1210 .collect()
1211 }
1212 pub fn is_long_lived(&self) -> bool {
1220 self.start_steps()
1221 .iter()
1222 .any(|s| is_long_lived_start(&s.kind))
1223 }
1224 pub fn topo_order(&self) -> Vec<String> {
1226 let mut out = Vec::new();
1227 let mut done: BTreeSet<String> = BTreeSet::new();
1228 let mut progress = true;
1229 while progress && out.len() < self.steps.len() {
1230 progress = false;
1231 for (id, s) in &self.steps {
1232 if done.contains(id) {
1233 continue;
1234 }
1235 if s.depends_on.iter().all(|d| done.contains(d)) {
1236 done.insert(id.clone());
1237 out.push(id.clone());
1238 progress = true;
1239 }
1240 }
1241 }
1242 out
1243 }
1244}
1245
1246fn json_kind(v: &Value) -> &'static str {
1248 match v {
1249 Value::Null => "null",
1250 Value::Bool(_) => "a boolean",
1251 Value::Number(_) => "a number",
1252 Value::String(_) => "a string",
1253 Value::Array(_) => "a list",
1254 Value::Object(_) => "an object",
1255 }
1256}
1257
1258pub const TOP: &[&str] = &[
1262 "name",
1263 "version",
1264 "description",
1265 "armed",
1266 "inputs",
1267 "concurrency",
1268 "limits",
1269 "outputs",
1270 "state",
1271 "steps",
1272 "file",
1273 "uri",
1274 "priority",
1275 "unload",
1276 "durable",
1277 "key",
1278 "tool",
1279];
1280
1281pub const ONE_SHOT_STARTS: &[&str] = &["once", "manual"];
1294
1295pub fn start_kinds() -> Vec<&'static str> {
1298 KINDS.iter().filter(|k| k.start).map(|k| k.name).collect()
1299}
1300
1301pub fn is_long_lived_start(kind: &str) -> bool {
1310 KINDS.iter().any(|k| k.start && k.name == kind) && !ONE_SHOT_STARTS.contains(&kind)
1311}
1312
1313pub fn parse_workflow(doc: &Value) -> Result<Workflow, Vec<String>> {
1315 let mut errs = Vec::new();
1316 let Some(obj) = doc.as_object() else {
1317 return Err(vec!["a workflow must be an object".into()]);
1318 };
1319 for key in obj.keys() {
1320 if !TOP.contains(&key.as_str()) {
1321 errs.push(format!("unknown workflow field {key:?}"));
1322 }
1323 }
1324 let name = obj
1325 .get("name")
1326 .and_then(Value::as_str)
1327 .unwrap_or("")
1328 .trim()
1329 .to_string();
1330 if !valid_id(&name) {
1331 errs.push(format!(
1332 "workflow name {name:?} must match [a-zA-Z_][a-zA-Z0-9_-]{{0,63}}"
1333 ));
1334 }
1335 let version = obj
1336 .get("version")
1337 .and_then(Value::as_u64)
1338 .unwrap_or(DIALECT as u64) as u32;
1339 if version != DIALECT {
1340 errs.push(format!(
1341 "workflow {name:?}: version {version} is not dialect 3 (dialect 1/2 documents are refused — see docs/workflows.md §migration)"
1342 ));
1343 }
1344 if obj.contains_key("start") || obj.contains_key("nodes") {
1345 errs.push(format!("workflow {name:?}: `start`/`nodes` are dialect 1/2 — use `steps` with start nodes (docs/workflows.md §migration)"));
1346 }
1347 let armed = obj.get("armed").and_then(Value::as_bool).unwrap_or(true);
1348 let priority = match Priority::from_spec(obj.get("priority")) {
1349 Ok(p) => p,
1350 Err(e) => {
1351 errs.push(format!("workflow {name:?}: {e}"));
1352 Priority::Normal
1353 }
1354 };
1355 let unload = match obj.get("unload") {
1356 None => Unload::default(),
1357 Some(u) => {
1358 let policy = match u.get("policy").and_then(Value::as_str) {
1359 None | Some("drain") => UnloadPolicy::Drain,
1360 Some("cancel") => UnloadPolicy::Cancel,
1361 Some("detach") => UnloadPolicy::Detach,
1362 Some(o) => {
1363 errs.push(format!(
1364 "workflow {name:?}: unload.policy {o:?} must be drain|cancel|detach"
1365 ));
1366 UnloadPolicy::Drain
1367 }
1368 };
1369 let timeout_ms = match u.get("timeout") {
1370 None => None,
1371 Some(t) => match t.as_str().map(crate::config::parse_duration) {
1372 Some(Ok(d)) => Some(d.as_millis() as u64),
1373 _ => {
1374 errs.push(format!(
1375 "workflow {name:?}: unload.timeout must be a duration (\"60s\")"
1376 ));
1377 None
1378 }
1379 },
1380 };
1381 if let Some(o) = u.as_object()
1382 && o.keys()
1383 .any(|k| !matches!(k.as_str(), "policy" | "timeout"))
1384 {
1385 errs.push(format!(
1386 "workflow {name:?}: unload takes {{policy, timeout}}"
1387 ));
1388 }
1389 Unload { policy, timeout_ms }
1390 }
1391 };
1392 let inputs_schema = match obj.get("inputs") {
1393 None => None,
1394 Some(v) => {
1395 let schema = v.get("schema").cloned().or_else(|| {
1396 v.as_object()
1397 .filter(|m| m.contains_key("type") || m.contains_key("properties"))
1398 .map(|_| v.clone())
1399 });
1400 match schema {
1401 Some(s) => {
1402 if let Err(e) = jsonschema::check_schema(&s) {
1403 errs.push(format!(
1404 "workflow {name:?}: inputs.schema: {}",
1405 e.join("; ")
1406 ));
1407 }
1408 Some(s)
1409 }
1410 None => {
1411 errs.push(format!("workflow {name:?}: inputs must be {{schema: …}}"));
1412 None
1413 }
1414 }
1415 }
1416 };
1417 let outputs_schema = obj.get("outputs").and_then(|v| v.get("schema").cloned());
1418 if let Some(s) = &outputs_schema
1419 && let Err(e) = jsonschema::check_schema(s)
1420 {
1421 errs.push(format!(
1422 "workflow {name:?}: outputs.schema: {}",
1423 e.join("; ")
1424 ));
1425 }
1426 let concurrency = match obj.get("concurrency") {
1427 None => Concurrency::default(),
1428 Some(v) => Concurrency {
1429 max_runs: v
1430 .get("max_runs")
1431 .and_then(Value::as_u64)
1432 .unwrap_or(4)
1433 .clamp(1, 1024) as u32,
1434 on_overflow: match v.get("on_overflow").and_then(Value::as_str) {
1435 None | Some("queue") => OnOverflow::Queue,
1436 Some("drop") => OnOverflow::Drop,
1437 Some("replace") => OnOverflow::Replace,
1438 Some(o) => {
1439 errs.push(format!("workflow {name:?}: concurrency.on_overflow {o:?} must be queue|drop|replace"));
1440 OnOverflow::Queue
1441 }
1442 },
1443 scope: match v.get("scope").and_then(Value::as_str) {
1444 None | Some("workflow") => ConcurrencyScope::Workflow,
1445 Some("key") => ConcurrencyScope::Key,
1446 Some(o) => {
1447 errs.push(format!(
1448 "workflow {name:?}: concurrency.scope {o:?} must be workflow|key"
1449 ));
1450 ConcurrencyScope::Workflow
1451 }
1452 },
1453 },
1454 };
1455 let key = obj
1459 .get("key")
1460 .and_then(Value::as_str)
1461 .map(str::to_string)
1462 .filter(|k| !k.trim().is_empty());
1463 let tool = match obj.get("tool") {
1464 None => None,
1465 Some(v) => {
1466 let tname = v
1467 .get("name")
1468 .and_then(Value::as_str)
1469 .unwrap_or("")
1470 .trim()
1471 .to_string();
1472 if tname.is_empty() {
1473 errs.push(format!("workflow {name:?}: tool.name is required"));
1474 } else if crate::registry::internal::contracts()
1475 .iter()
1476 .any(|c| c.name == tname)
1477 {
1478 errs.push(format!(
1482 "workflow {name:?}: tool.name {tname:?} shadows an internal contract"
1483 ));
1484 }
1485 let mode = match v.get("mode").and_then(Value::as_str) {
1486 None | Some("sync") => WorkflowToolMode::Sync,
1487 Some("async") => WorkflowToolMode::Async,
1488 Some(o) => {
1489 errs.push(format!(
1490 "workflow {name:?}: tool.mode {o:?} must be sync|async"
1491 ));
1492 WorkflowToolMode::Sync
1493 }
1494 };
1495 let g = v.get("grant");
1496 let flag = |k: &str, dflt: bool| {
1497 g.and_then(|g| g.get(k))
1498 .and_then(Value::as_bool)
1499 .unwrap_or(dflt)
1500 };
1501 let dflt = WorkflowToolGrant::default();
1502 Some(WorkflowTool {
1503 name: tname,
1504 mode,
1505 grant: WorkflowToolGrant {
1506 root: flag("root", dflt.root),
1507 workflows: flag("workflows", dflt.workflows),
1508 subagents: flag("subagents", dflt.subagents),
1509 user: flag("user", dflt.user),
1510 agent: flag("agent", dflt.agent),
1511 },
1512 })
1513 }
1514 };
1515 if concurrency.scope == ConcurrencyScope::Key && key.is_none() {
1516 errs.push(format!(
1517 "workflow {name:?}: concurrency.scope: key needs a `key:` template naming what a run is about"
1518 ));
1519 }
1520 let limits = match obj.get("limits") {
1521 None => WorkflowLimits::default(),
1522 Some(v) => WorkflowLimits {
1523 steps: v.get("steps").and_then(Value::as_u64).map(|x| x as u32),
1524 tokens: v.get("tokens").and_then(Value::as_u64),
1525 deadline_ms: match v.get("deadline") {
1526 None => None,
1527 Some(d) => match duration_ms(d) {
1528 Ok(ms) => Some(ms),
1529 Err(e) => {
1530 errs.push(format!("workflow {name:?}: limits.deadline: {e}"));
1531 None
1532 }
1533 },
1534 },
1535 budget: v.get("budget").cloned(),
1536 },
1537 };
1538 let mut steps: BTreeMap<String, Step> = BTreeMap::new();
1540 match obj.get("steps").and_then(Value::as_object) {
1541 None => errs.push(format!(
1542 "workflow {name:?}: `steps` (an object of steps) is required"
1543 )),
1544 Some(map) => {
1545 if map.len() > MAX_STEPS {
1546 errs.push(format!(
1547 "workflow {name:?}: {} steps exceed the cap of {MAX_STEPS}",
1548 map.len()
1549 ));
1550 }
1551 for (id, sv) in map {
1552 if let Some(step) = parse_step(&name, id, sv, 0, &mut errs) {
1553 steps.insert(id.clone(), step);
1554 }
1555 }
1556 }
1557 }
1558 if !errs.is_empty() {
1559 return Err(errs);
1560 }
1561 let mut state: BTreeMap<String, StateDecl> = BTreeMap::new();
1563 if let Some(decls) = obj.get("state") {
1564 match decls.as_object() {
1565 None => errs.push("state must be an object of {key: {schema, reducer}}".into()),
1566 Some(map) => {
1567 for (key, decl) in map {
1568 let Some(d) = decl.as_object() else {
1569 errs.push(format!("state {key:?}: must be an object"));
1570 continue;
1571 };
1572 for f in d.keys() {
1573 if !matches!(f.as_str(), "schema" | "reducer") {
1574 errs.push(format!(
1575 "state {key:?}: unknown field {f:?} (allowed: schema, reducer)"
1576 ));
1577 }
1578 }
1579 let schema = d.get("schema").cloned();
1580 if let Some(sc) = &schema
1581 && let Err(e) = jsonschema::check_schema(sc)
1582 {
1583 errs.push(format!("state {key:?}: schema: {}", e.join("; ")));
1584 }
1585 let reducer = d.get("reducer").and_then(Value::as_str).map(str::to_string);
1586 if let Some(r) = &reducer
1587 && !matches!(r.as_str(), "overwrite" | "append" | "merge" | "union")
1588 {
1589 errs.push(format!(
1590 "state {key:?}: reducer {r:?} must be overwrite|append|merge|union"
1591 ));
1592 }
1593 state.insert(key.clone(), StateDecl { schema, reducer });
1594 }
1595 }
1596 }
1597 }
1598 let durable = match obj.get("durable") {
1599 None => None,
1600 Some(Value::Bool(b)) => Some(*b),
1601 Some(other) => {
1602 errs.push(format!("workflow durable must be a boolean (got {other})"));
1603 None
1604 }
1605 };
1606 let mut wf = Workflow {
1607 state,
1608 name,
1609 version,
1610 priority,
1611 unload,
1612 durable,
1613 description: obj
1614 .get("description")
1615 .and_then(Value::as_str)
1616 .map(str::to_string),
1617 armed,
1618 inputs_schema,
1619 concurrency,
1620 key,
1621 tool,
1622 limits,
1623 outputs_schema,
1624 steps,
1625 hash: String::new(),
1626 definition: doc.clone(),
1627 };
1628 validate_graph(&wf, &mut errs);
1629 if !errs.is_empty() {
1630 return Err(errs);
1631 }
1632 wf.hash = crate::sha::sha256_hex(canonical(doc).as_bytes());
1633 Ok(wf)
1634}
1635
1636fn parse_step(
1637 wf: &str,
1638 id: &str,
1639 sv: &Value,
1640 depth: usize,
1641 errs: &mut Vec<String>,
1642) -> Option<Step> {
1643 let at = format!("workflow {wf:?} step {id:?}");
1644 if !valid_id(id) {
1645 errs.push(format!(
1646 "{at}: id must match [a-zA-Z_][a-zA-Z0-9_-]{{0,63}}"
1647 ));
1648 }
1649 let Some(o) = sv.as_object() else {
1650 errs.push(format!("{at}: must be an object"));
1651 return None;
1652 };
1653 let kind = match o.get("kind").and_then(Value::as_str) {
1654 Some(k) => k.to_string(),
1655 None => {
1656 errs.push(format!("{at}: `kind` is required"));
1657 return None;
1658 }
1659 };
1660 let Some(info) = kind_info(&kind) else {
1661 errs.push(format!(
1662 "{at}: unknown kind {kind:?} (run `agentd --workflow-schema` for the kind catalogue)"
1663 ));
1664 return None;
1665 };
1666 let mut spec = Map::new();
1668 for (key, v) in o {
1669 if info.fields.contains(&key.as_str()) {
1679 spec.insert(key.clone(), v.clone());
1680 } else if COMMON_FIELDS.contains(&key.as_str()) {
1681 continue;
1682 } else {
1683 errs.push(format!(
1684 "{at}: unknown field {key:?} for kind {kind:?} (allowed: {})",
1685 info.fields.join(", ")
1686 ));
1687 }
1688 }
1689 for req in info.required {
1690 if !spec.contains_key(*req) {
1691 errs.push(format!("{at}: kind {kind:?} requires field {req:?}"));
1692 }
1693 }
1694 if !info.implemented {
1695 errs.push(format!(
1696 "{at}: kind {kind:?} is not available in this build; implemented kinds: {}",
1697 implemented_kinds().join(", ")
1698 ));
1699 }
1700 let mut body: Option<Body> = None;
1702 let mut branches: BTreeMap<String, Body> = BTreeMap::new();
1703 if info.nested {
1704 if depth + 1 > MAX_NESTING {
1705 errs.push(format!("{at}: nesting exceeds {MAX_NESTING}"));
1706 }
1707 if matches!(kind.as_str(), "parallel" | "race") {
1708 match spec.get("branches").and_then(Value::as_object) {
1709 Some(bm) if !bm.is_empty() => {
1710 for (bname, bv) in bm {
1711 if !valid_id(bname) {
1712 errs.push(format!("{at}: branch name {bname:?} must match [a-zA-Z_][a-zA-Z0-9_-]{{0,63}}"));
1713 }
1714 if let Some(b) =
1715 parse_body(&format!("{wf}/{id}/{bname}"), bv, depth + 1, errs)
1716 {
1717 branches.insert(bname.clone(), b);
1718 }
1719 }
1720 }
1721 _ => errs.push(format!(
1722 "{at}: branches must be a non-empty object of {{steps: {{…}}}} bodies"
1723 )),
1724 }
1725 } else {
1726 match spec.get("body") {
1727 Some(bv) => body = parse_body(&format!("{wf}/{id}"), bv, depth + 1, errs),
1728 None => errs.push(format!("{at}: body is required")),
1729 }
1730 }
1731 }
1732 let depends_on: Vec<String> = match o.get("depends_on") {
1733 None => Vec::new(),
1734 Some(Value::Array(a)) => a
1735 .iter()
1736 .filter_map(Value::as_str)
1737 .map(str::to_string)
1738 .collect(),
1739 Some(Value::String(s)) => vec![s.clone()],
1740 Some(_) => {
1741 errs.push(format!("{at}: depends_on must be a list of step ids"));
1742 Vec::new()
1743 }
1744 };
1745 if info.start && !depends_on.is_empty() {
1746 errs.push(format!("{at}: a start node cannot depend on other steps"));
1747 }
1748 let when = o.get("when").and_then(Value::as_str).map(str::to_string);
1749 if let Some(w) = &when {
1750 let expr = w.trim().trim_start_matches("CEL:").trim();
1751 if let Err(e) = crate::cel::compile_check(expr) {
1752 errs.push(format!("{at}: when: {e}"));
1753 }
1754 }
1755 let retry = o.get("retry").map(|r| Retry {
1756 max: r.get("max").and_then(Value::as_u64).unwrap_or(0).min(20) as u32,
1757 backoff_ms: match r.get("backoff") {
1758 None => 0,
1759 Some(b) => duration_ms(b).unwrap_or_else(|e| {
1760 errs.push(format!("{at}: retry.backoff: {e}"));
1761 0
1762 }),
1763 },
1764 });
1765 let timeout_ms = match o.get("timeout") {
1766 None => None,
1767 Some(t) => match duration_ms(t) {
1768 Ok(ms) => Some(ms),
1769 Err(e) => {
1770 errs.push(format!("{at}: timeout: {e}"));
1771 None
1772 }
1773 },
1774 };
1775 let on_error = match o.get("on_error") {
1776 None => OnError::Fail,
1777 Some(v) => OnError::parse(v).unwrap_or_else(|e| {
1778 errs.push(format!("{at}: {e}"));
1779 OnError::Fail
1780 }),
1781 };
1782 let on_replay = match o.get("on_replay").and_then(Value::as_str) {
1783 None | Some("retry") => OnReplay::Retry,
1784 Some("skip") => OnReplay::Skip,
1785 Some("fail") => OnReplay::Fail,
1786 Some(x) => {
1787 errs.push(format!("{at}: on_replay {x:?} must be retry|skip|fail"));
1788 OnReplay::Retry
1789 }
1790 };
1791 let output_schema = o.get("output_schema").cloned();
1792 if let Some(s) = &output_schema
1793 && let Err(e) = jsonschema::check_schema(s)
1794 {
1795 errs.push(format!("{at}: output_schema: {}", e.join("; ")));
1796 }
1797 if let Some(idem) = spec.get("idempotency") {
1802 match kind.as_str() {
1803 "http" => {
1804 let ok = idem.as_object().is_some_and(|o| {
1805 let hdr = o.get("header").map(|v| v.is_string());
1806 let qry = o.get("query").map(|v| v.is_string());
1807 let val = o.get("value").is_none_or(|v| v.is_string());
1808 let known = o
1809 .keys()
1810 .all(|k| matches!(k.as_str(), "header" | "query" | "value"));
1811 known && val && matches!((hdr, qry), (Some(true), None) | (None, Some(true)))
1812 });
1813 if !ok {
1814 errs.push(format!(
1815 "{at}: http idempotency takes {{header: NAME}} or {{query: NAME}} \
1816 (exactly one), with an optional string value"
1817 ));
1818 }
1819 }
1820 "mcp.tool" | "a2a.send" | "a2a.delegate" => {
1821 let ok = idem.is_boolean()
1822 || idem.as_object().is_some_and(|o| {
1823 o.keys().all(|k| k == "value")
1824 && o.get("value").is_none_or(|v| v.is_string())
1825 });
1826 if !ok {
1827 errs.push(format!(
1828 "{at}: idempotency takes true or {{value: \"…\"}} on this kind"
1829 ));
1830 }
1831 }
1832 _ => {}
1833 }
1834 }
1835 if let Some(b) = spec.get("breaker") {
1839 if !matches!(
1840 kind.as_str(),
1841 "http" | "mcp.tool" | "a2a.send" | "a2a.delegate"
1842 ) {
1843 errs.push(format!(
1844 "{at}: breaker applies to remote-effect kinds (http, mcp.tool, a2a.send, a2a.delegate)"
1845 ));
1846 } else {
1847 let ok = b.as_object().is_some_and(|o| {
1848 o.keys()
1849 .all(|k| matches!(k.as_str(), "failures" | "cooldown"))
1850 && o.get("failures")
1851 .and_then(Value::as_u64)
1852 .is_some_and(|n| n >= 1)
1853 && o.get("cooldown")
1854 .and_then(Value::as_str)
1855 .is_some_and(|d| crate::config::parse_duration(d).is_ok())
1856 });
1857 if !ok {
1858 errs.push(format!(
1859 "{at}: breaker takes {{failures: N>=1, cooldown: \"60s\"}}"
1860 ));
1861 }
1862 }
1863 }
1864 if let Some(r) = spec.get("rate")
1868 && matches!(
1869 kind.as_str(),
1870 "http" | "mcp.tool" | "a2a.send" | "a2a.delegate"
1871 )
1872 {
1873 let ok = r
1874 .as_str()
1875 .is_some_and(|r| crate::supervisor::tree::parse_rate(r).is_ok());
1876 if !ok {
1877 errs.push(format!(
1878 "{at}: rate must be \"<burst>/<per>s\" (e.g. \"10/1s\")"
1879 ));
1880 }
1881 }
1882 match kind.as_str() {
1884 "webhook" => {
1888 if let Some(r) = spec.get("rate") {
1889 let ok = r.as_str().is_some_and(|r| {
1890 r.split_once('/').is_some_and(|(b, p)| {
1891 let per = p.trim();
1892 let per = per
1893 .strip_suffix('s')
1894 .or_else(|| per.strip_suffix("sec"))
1895 .unwrap_or(per);
1896 b.trim().parse::<u32>().is_ok_and(|b| b > 0)
1897 && per.trim().parse::<f64>().is_ok_and(|s| s > 0.0)
1898 })
1899 });
1900 if !ok {
1901 errs.push(format!(
1902 "{at}: rate must be \"<burst>/<per>s\" (e.g. \"20/1s\")"
1903 ));
1904 }
1905 }
1906 check_into(&spec, &at, errs);
1907 if spec.get("into").is_some()
1912 && spec.get("respond").and_then(Value::as_str) == Some("sync")
1913 {
1914 errs.push(format!(
1915 "{at}: `respond: sync` cannot be combined with `into` — an appended \
1916 event fires no run to wait for"
1917 ));
1918 }
1919 }
1920 "a2a" => check_into(&spec, &at, errs),
1922 "a2a.delegate" => {
1924 if spec.get("objective").is_none() && spec.get("command").is_none() {
1925 errs.push(format!(
1926 "{at}: needs `objective` (prose) or `command` (typed)"
1927 ));
1928 }
1929 if spec.get("args").is_some() && spec.get("command").is_none() {
1930 errs.push(format!("{at}: `args` needs `command`"));
1931 }
1932 }
1933 "a2a.send" => {
1934 if spec.get("args").is_some() && spec.get("command").is_none() {
1935 errs.push(format!("{at}: `args` needs `command`"));
1936 }
1937 }
1938 "subagent" => {
1942 match (spec.get("instruction").is_some(), spec.get("template").is_some()) {
1943 (false, false) => errs.push(format!(
1944 "{at}: needs `instruction` (freeform) or `template` (a subagents.templates entry)"
1945 )),
1946 (true, true) => errs.push(format!(
1947 "{at}: `instruction` and `template` are mutually exclusive"
1948 )),
1949 _ => {}
1950 }
1951 if spec.get("params").is_some() && spec.get("template").is_none() {
1952 errs.push(format!("{at}: `params` needs `template`"));
1953 }
1954 for k in ["tools", "servers"] {
1955 if spec.get(k).is_some() && spec.get("template").is_some() {
1956 errs.push(format!(
1957 "{at}: `{k}` may not be combined with `template` — the template defines the grant"
1958 ));
1959 }
1960 }
1961 }
1962 "emit" => {
1967 if spec.get("stream").is_some() != spec.get("subject").is_some() {
1968 errs.push(format!(
1969 "{at}: a stream emit needs both `stream` and `subject`"
1970 ));
1971 }
1972 if let Some(f) = spec.get("forward") {
1977 if spec.get("stream").is_none() {
1978 errs.push(format!(
1979 "{at}: `forward` needs `stream` — it pushes the appended event, and a \
1980 non-stream emit appends nothing"
1981 ));
1982 }
1983 let has_webhook = f.get("webhook").is_some();
1987 let has_peer = f.get("peer").is_some();
1988 let ok = f.as_object().is_some_and(|o| {
1989 o.keys()
1990 .all(|k| k == "webhook" || k == "peer" || k == "allow_private")
1991 && has_webhook != has_peer
1992 && o.get("webhook").is_none_or(|u| {
1993 u.as_str().is_some_and(|u| {
1994 u.starts_with("http://") || u.starts_with("https://")
1995 })
1996 })
1997 && o.get("peer")
1998 .is_none_or(|p| p.as_str().is_some_and(|p| !p.is_empty()))
1999 && o.get("allow_private").is_none_or(Value::is_boolean)
2000 });
2001 if !ok {
2002 errs.push(format!(
2003 "{at}: forward takes {{webhook: <http(s) URL>, allow_private?: bool}} \
2004 or {{peer: <a2a.peers name>}} — one destination, not both"
2005 ));
2006 }
2007 }
2008 }
2009 "stream" => {
2011 if let Some(f) = spec.get("from")
2012 && !matches!(f.as_str(), Some("new") | Some("earliest"))
2013 {
2014 errs.push(format!("{at}: from must be \"new\" or \"earliest\""));
2015 }
2016 if let Some(b) = spec.get("batch") {
2023 let ok = b.as_object().is_some_and(|o| {
2024 o.keys().all(|k| k == "size" || k == "window")
2025 && o.get("size")
2026 .and_then(Value::as_u64)
2027 .is_some_and(|n| (2..=1000).contains(&n))
2028 && o.get("window").is_none_or(|w| {
2029 w.as_str()
2030 .is_some_and(|d| crate::config::parse_duration(d).is_ok())
2031 })
2032 });
2033 if !ok {
2034 errs.push(format!(
2035 "{at}: batch takes {{size: 2..=1000, window?: <duration>}}"
2036 ));
2037 }
2038 }
2039 if spec.get("batch").is_some() && spec.get("rate").is_some() {
2040 errs.push(format!(
2041 "{at}: `batch` and `rate` both pace consumption and compose confusingly — \
2042 `rate` paces one run per event, `batch` makes one run per group; pick one"
2043 ));
2044 }
2045 }
2046 "correlate" => {
2051 match spec.get("on").and_then(Value::as_array) {
2052 Some(subjects) if subjects.len() >= 2 => {
2053 if !subjects.iter().all(|v| v.is_string()) {
2054 errs.push(format!("{at}: `on` must be a list of subject patterns"));
2055 }
2056 }
2057 Some(_) => errs.push(format!(
2058 "{at}: `on` needs at least two subjects — joining one subject with \
2059 itself is a `stream` start"
2060 )),
2061 None => errs.push(format!("{at}: `on` must be a list of subject patterns")),
2062 }
2063 if let Some(t) = spec.get("on_incomplete").and_then(Value::as_str)
2068 && !matches!(t, "fire_partial" | "discard")
2069 {
2070 errs.push(format!(
2071 "{at}: on_incomplete must be \"fire_partial\" or \"discard\""
2072 ));
2073 }
2074 match spec.get("window") {
2078 Some(w) => {
2079 if w.as_str()
2080 .is_none_or(|d| crate::config::parse_duration(d).is_err())
2081 {
2082 errs.push(format!(
2083 "{at}: window must be a duration (e.g. \"24h\") — it bounds how long \
2084 a half-collected join is kept"
2085 ));
2086 }
2087 }
2088 None => errs.push(format!(
2089 "{at}: `window` is required — it bounds how long a half-collected join is \
2090 kept in durable state"
2091 )),
2092 }
2093 if let Some(n) = spec.get("max_pending")
2094 && n.as_u64().is_none_or(|n| !(1..=100_000).contains(&n))
2095 {
2096 errs.push(format!("{at}: max_pending takes 1..=100000"));
2097 }
2098 }
2099 "subscribe" => {
2105 if let Some(w) = spec.get("window") {
2106 let ok = w.as_object().is_some_and(|o| {
2107 o.keys().all(|k| k == "samples")
2108 && o.get("samples")
2109 .and_then(Value::as_u64)
2110 .is_some_and(|n| (1..=256).contains(&n))
2111 });
2112 if !ok {
2113 errs.push(format!("{at}: window takes {{samples: 1..=256}}"));
2114 }
2115 }
2116 }
2117 "switch" => {
2125 if let Some(cases) = spec.get("cases").and_then(Value::as_object) {
2126 for (case, target) in cases {
2127 if !target.is_string() {
2128 errs.push(format!(
2129 "{at}: switch case {case:?} must name ONE step as a string \
2130 (got {}); write `{case}: some_step`, not a list",
2131 json_kind(target)
2132 ));
2133 }
2134 }
2135 }
2136 if let Some(m) = spec.get("on_no_match")
2137 && !matches!(m.as_str(), Some("skip") | Some("fail"))
2138 {
2139 errs.push(format!("{at}: on_no_match must be \"skip\" or \"fail\""));
2140 }
2141 if let Some(d) = spec.get("default")
2142 && !d.is_string()
2143 {
2144 errs.push(format!(
2145 "{at}: switch default must name ONE step as a string (got {}); \
2146 write `default: some_step`, not a list",
2147 json_kind(d)
2148 ));
2149 }
2150 }
2151 "foreach" | "batch" | "iterate" | "parallel" | "race" | "subgraph"
2156 if spec.contains_key("collect") =>
2157 {
2158 if let Some(m) = spec
2159 .get("collect")
2160 .and_then(|c| c.get("mode"))
2161 .and_then(Value::as_str)
2162 && !matches!(m, "overwrite" | "append" | "merge" | "union")
2163 {
2164 errs.push(format!(
2165 "{at}: collect.mode {m:?} must be overwrite|append|merge|union"
2166 ));
2167 }
2168 }
2169 "human" => {
2174 if spec.contains_key("reply_uri") {
2175 errs.push(format!(
2176 "{at}: human.reply_uri is not implemented — a gate is answered over A2A; \
2177 use `to` to name who must answer (see docs/node-registry.md)"
2178 ));
2179 }
2180 if let Some(v) = spec.get("to")
2183 && let Err(e) = crate::a2a::principals::Addressee::parse(v)
2184 {
2185 errs.push(format!("{at}: human.to: {e}"));
2186 }
2187 }
2188 "finish" => {
2189 if let Some(st) = spec.get("status").and_then(Value::as_str)
2190 && !matches!(st, "completed" | "failed" | "refused" | "cancelled")
2191 {
2192 errs.push(format!(
2193 "{at}: finish.status must be completed|failed|refused|cancelled"
2194 ));
2195 }
2196 }
2197 "sleep" => {
2198 if let Some(d) = spec.get("duration")
2199 && let Err(e) = duration_ms(d)
2200 {
2201 errs.push(format!("{at}: sleep.duration: {e}"));
2202 }
2203 }
2204 "assert" => {
2205 if let Some(c) = spec.get("condition").and_then(Value::as_str)
2206 && let Err(e) =
2207 crate::cel::compile_check(c.trim().trim_start_matches("CEL:").trim())
2208 {
2209 errs.push(format!("{at}: assert.condition: {e}"));
2210 }
2211 }
2212 "think" | "agent" => {
2213 if let Some(s) = spec.get("output_schema")
2214 && let Err(e) = jsonschema::check_schema(s)
2215 {
2216 errs.push(format!("{at}: output_schema: {}", e.join("; ")));
2217 }
2218 }
2219 "validate" => {
2220 if let Some(s) = spec.get("schema")
2221 && let Err(e) = jsonschema::check_schema(s)
2222 {
2223 errs.push(format!("{at}: schema: {}", e.join("; ")));
2224 }
2225 }
2226 "assign" | "transform" => {
2227 if let Some(m) = spec.get("mode").and_then(Value::as_str)
2228 && !matches!(m, "overwrite" | "append" | "merge" | "union")
2229 {
2230 errs.push(format!("{at}: mode must be overwrite|append|merge|union"));
2231 }
2232 }
2233 _ => {}
2234 }
2235 for (key, v) in &spec {
2237 if let Some(s) = v.as_str()
2238 && let Some(expr) = s.trim().strip_prefix("CEL:")
2239 && let Err(e) = crate::cel::compile_check(expr.trim())
2240 {
2241 errs.push(format!("{at}: {key}: {e}"));
2242 }
2243 }
2244 Some(Step {
2245 id: id.to_string(),
2246 kind,
2247 depends_on,
2248 when,
2249 retry,
2250 timeout_ms,
2251 on_error,
2252 idempotent: o
2253 .get("idempotent")
2254 .and_then(Value::as_bool)
2255 .unwrap_or(false),
2256 on_replay,
2257 output_schema,
2258 cache: o.get("cache").cloned(),
2259 budget: o.get("budget").and_then(Value::as_u64),
2260 skills: o
2261 .get("skills")
2262 .and_then(Value::as_array)
2263 .map(|a| {
2264 a.iter()
2265 .filter_map(Value::as_str)
2266 .map(str::to_string)
2267 .collect()
2268 })
2269 .unwrap_or_default(),
2270 description: o
2271 .get("description")
2272 .and_then(Value::as_str)
2273 .map(str::to_string),
2274 spec,
2275 body,
2276 branches,
2277 })
2278}
2279
2280fn parse_body(at: &str, bv: &Value, depth: usize, errs: &mut Vec<String>) -> Option<Body> {
2282 let Some(bs) = bv.get("steps").and_then(Value::as_object) else {
2283 errs.push(format!("{at}: body must be {{steps: {{…}}}}"));
2284 return None;
2285 };
2286 if bs.is_empty() {
2287 errs.push(format!("{at}: body has no steps"));
2288 return None;
2289 }
2290 let mut steps = BTreeMap::new();
2291 for (bid, sv) in bs {
2292 if let Some(step) = parse_step(at, bid, sv, depth, errs) {
2293 if step.is_start() {
2294 errs.push(format!(
2295 "{at} step {bid:?}: a start node cannot be inside a body"
2296 ));
2297 }
2298 if step.kind == "finish" {
2299 errs.push(format!("{at} step {bid:?}: `finish` cannot be inside a body (a body's sinks are its result)"));
2300 }
2301 steps.insert(bid.clone(), step);
2302 }
2303 }
2304 let body = Body { steps };
2305 for s in body.steps.values() {
2306 for d in &s.depends_on {
2307 if !body.steps.contains_key(d) {
2308 errs.push(format!(
2309 "{at} step {:?}: depends_on names {d:?}, which is not a sibling in the body",
2310 s.id
2311 ));
2312 }
2313 }
2314 if let OnError::Goto(t) = &s.on_error
2315 && !body.steps.contains_key(t)
2316 {
2317 errs.push(format!(
2318 "{at} step {:?}: on_error goto {t:?} is not a sibling in the body",
2319 s.id
2320 ));
2321 }
2322 }
2323 if body.topo_order().len() != body.steps.len() {
2324 errs.push(format!("{at}: cycle inside the body"));
2325 }
2326 Some(body)
2327}
2328
2329fn validate_declared_state(wf: &Workflow, errs: &mut Vec<String>) {
2341 for s in wf.steps.values() {
2342 if !matches!(s.kind.as_str(), "assign" | "transform") {
2343 continue;
2344 }
2345 let key = s
2346 .spec
2347 .get("writes")
2348 .and_then(Value::as_str)
2349 .unwrap_or(s.id.as_str());
2350 let Some(decl) = wf.state.get(key) else {
2351 continue;
2352 };
2353 if let Some(want) = &decl.reducer {
2357 let mode = s
2358 .spec
2359 .get("mode")
2360 .and_then(Value::as_str)
2361 .unwrap_or("overwrite");
2362 if mode != want {
2363 errs.push(format!(
2364 "workflow {:?} step {:?}: writes {key:?} with mode {mode:?}, but state \
2365 declares reducer {want:?}",
2366 wf.name, s.id
2367 ));
2368 }
2369 }
2370 }
2371}
2372
2373fn validate_concurrent_writes(wf: &Workflow, errs: &mut Vec<String>) {
2374 use std::collections::BTreeMap;
2375 let mut writers: BTreeMap<&str, Vec<(&str, &str)>> = BTreeMap::new();
2376 for s in wf.steps.values() {
2377 if !matches!(s.kind.as_str(), "assign" | "transform") {
2378 continue;
2379 }
2380 let key = s
2381 .spec
2382 .get("writes")
2383 .and_then(Value::as_str)
2384 .unwrap_or(s.id.as_str());
2385 let mode = s
2386 .spec
2387 .get("mode")
2388 .and_then(Value::as_str)
2389 .unwrap_or("overwrite");
2390 writers.entry(key).or_default().push((s.id.as_str(), mode));
2391 }
2392 for (key, ws) in writers {
2393 if ws.len() < 2 {
2394 continue;
2395 }
2396 for (i, (a, ma)) in ws.iter().enumerate() {
2398 for (b, mb) in ws.iter().skip(i + 1) {
2399 if reachable(wf, a, b) || reachable(wf, b, a) {
2400 continue;
2401 }
2402 if exclusive_by_switch(wf, a, b) {
2407 continue;
2408 }
2409 if wf
2413 .state
2414 .get(key)
2415 .and_then(|d| d.reducer.as_deref())
2416 .is_some()
2417 {
2418 continue;
2419 }
2420 if *ma == "overwrite" || *mb == "overwrite" {
2423 errs.push(format!(
2424 "workflow {:?}: steps {a:?} and {b:?} can run concurrently and both \
2425 write {key:?} (modes {ma}/{mb}) — the surviving value would depend on \
2426 completion order; order them with depends_on, or use append/merge",
2427 wf.name
2428 ));
2429 }
2430 }
2431 }
2432 }
2433}
2434
2435fn exclusive_by_switch(wf: &Workflow, a: &str, b: &str) -> bool {
2437 for s in wf.steps.values() {
2438 if s.kind != "switch" {
2439 continue;
2440 }
2441 let mut arms: Vec<&str> = s
2442 .spec
2443 .get("cases")
2444 .and_then(Value::as_object)
2445 .map(|c| c.values().filter_map(Value::as_str).collect())
2446 .unwrap_or_default();
2447 if let Some(d) = s.spec.get("default").and_then(Value::as_str) {
2448 arms.push(d);
2449 }
2450 let on_arm = |x: &str| arms.iter().any(|arm| *arm == x || reachable(wf, arm, x));
2453 if on_arm(a) && on_arm(b) {
2454 return true;
2455 }
2456 }
2457 false
2458}
2459
2460fn reachable(wf: &Workflow, from: &str, to: &str) -> bool {
2462 let mut seen = std::collections::BTreeSet::new();
2463 let mut stack = vec![to];
2464 while let Some(cur) = stack.pop() {
2466 if cur == from {
2467 return true;
2468 }
2469 if !seen.insert(cur.to_string()) {
2470 continue;
2471 }
2472 if let Some(s) = wf.steps.get(cur) {
2473 for d in &s.depends_on {
2474 stack.push(d.as_str());
2475 }
2476 }
2477 }
2478 false
2479}
2480
2481fn validate_human_in_concurrent_bodies(wf: &Workflow, errs: &mut Vec<String>) {
2490 fn walk(wf_name: &str, owner: &str, body: &Body, errs: &mut Vec<String>) {
2491 for s in body.steps.values() {
2492 if s.kind == "human" {
2493 errs.push(format!(
2494 "workflow {wf_name:?} step {:?}: a `human` gate inside {owner:?} is not \
2495 supported — only one gate can be live per run, so a second item would \
2496 wait forever. Gate before or after the fan-out instead.",
2497 s.id
2498 ));
2499 }
2500 for nested in s.body.iter().chain(s.branches.values()) {
2501 walk(wf_name, owner, nested, errs);
2502 }
2503 }
2504 }
2505 for s in wf.steps.values() {
2506 if !matches!(s.kind.as_str(), "foreach" | "batch" | "parallel" | "race") {
2507 continue;
2508 }
2509 for body in s.body.iter().chain(s.branches.values()) {
2510 walk(&wf.name, &s.id, body, errs);
2511 }
2512 }
2513}
2514
2515fn validate_graph(wf: &Workflow, errs: &mut Vec<String>) {
2516 validate_human_in_concurrent_bodies(wf, errs);
2517 validate_declared_state(wf, errs);
2518 validate_concurrent_writes(wf, errs);
2519 let name = &wf.name;
2520 let starts: Vec<&Step> = wf.start_steps();
2521 if starts.is_empty() {
2522 errs.push(format!("workflow {name:?}: at least one start node is required (once|manual|loop|schedule|subscribe|signal|event|a2a)"));
2523 }
2524 for s in wf.steps.values() {
2526 for d in &s.depends_on {
2527 if !wf.steps.contains_key(d) {
2528 errs.push(format!(
2529 "workflow {name:?} step {:?}: depends_on names unknown step {d:?}",
2530 s.id
2531 ));
2532 }
2533 if d == &s.id {
2534 errs.push(format!(
2535 "workflow {name:?} step {:?}: depends on itself",
2536 s.id
2537 ));
2538 }
2539 }
2540 if let OnError::Goto(t) = &s.on_error
2541 && !wf.steps.contains_key(t)
2542 {
2543 errs.push(format!(
2544 "workflow {name:?} step {:?}: on_error goto names unknown step {t:?}",
2545 s.id
2546 ));
2547 }
2548 if let Some(t) = s.field_str("on_timeout")
2549 && !wf.steps.contains_key(t)
2550 {
2551 errs.push(format!(
2552 "workflow {name:?} step {:?}: on_timeout names unknown step {t:?}",
2553 s.id
2554 ));
2555 }
2556 }
2557 let timeout_targets: BTreeSet<String> = wf
2562 .steps
2563 .values()
2564 .filter_map(|s| s.field_str("on_timeout").map(str::to_string))
2565 .collect();
2566 for s in wf.steps.values() {
2568 if !s.is_start() && s.depends_on.is_empty() && !timeout_targets.contains(&s.id) {
2569 errs.push(format!("workflow {name:?} step {:?}: a non-start step must depend on something (unreachable root)", s.id));
2570 }
2571 }
2572 let order = wf.topo_order();
2574 if order.len() != wf.steps.len() {
2575 let stuck: Vec<&String> = wf.steps.keys().filter(|k| !order.contains(k)).collect();
2576 errs.push(format!("workflow {name:?}: cycle among steps {stuck:?}"));
2577 }
2578 let mut reachable: BTreeSet<String> = starts.iter().map(|s| s.id.clone()).collect();
2579 let mut changed = true;
2580 while changed {
2581 changed = false;
2582 for s in wf.steps.values() {
2583 if !reachable.contains(&s.id)
2584 && !s.depends_on.is_empty()
2585 && s.depends_on.iter().any(|d| reachable.contains(d))
2586 {
2587 reachable.insert(s.id.clone());
2588 changed = true;
2589 }
2590 if reachable.contains(&s.id)
2592 && let Some(t) = s.field_str("on_timeout")
2593 && !reachable.contains(t)
2594 {
2595 reachable.insert(t.to_string());
2596 changed = true;
2597 }
2598 }
2599 }
2600 for s in wf.steps.values() {
2601 if !reachable.contains(&s.id) {
2602 errs.push(format!(
2603 "workflow {name:?} step {:?}: not reachable from any start node",
2604 s.id
2605 ));
2606 }
2607 }
2608 let all_starts_append = {
2613 let starts: Vec<&Step> = wf.steps.values().filter(|s| s.is_start()).collect();
2614 !starts.is_empty() && starts.iter().all(|s| s.spec.get("into").is_some())
2615 };
2616 if !all_starts_append && !wf.steps.values().any(|s| s.kind == "finish") {
2617 errs.push(format!("workflow {name:?}: a `finish` step is required"));
2618 }
2619}
2620
2621pub fn valid_id(s: &str) -> bool {
2623 let mut chars = s.chars();
2624 match chars.next() {
2625 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
2626 _ => return false,
2627 }
2628 s.len() <= MAX_ID_LEN && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
2629}
2630
2631pub const RAW_FIELDS: &[(&str, &str)] = &[
2634 ("assert", "condition"),
2635 ("validate", "schema"),
2636 ("map", "expr"),
2637 ("filter", "expr"),
2638 ("reduce", "expr"),
2639 ("iterate", "while"),
2640 ("iterate", "until"),
2641 ("iterate", "body"),
2642 ("foreach", "body"),
2643 ("batch", "body"),
2644 ("subgraph", "body"),
2645 ("parallel", "branches"),
2646 ("race", "branches"),
2647 ("subscribe", "filter"),
2648 ("signal", "filter"),
2649 ("event", "filter"),
2650 ("wait", "condition"),
2651 ("wait", "match"),
2655 ("think", "check"),
2656 ("switch", "cases"),
2657 ("await", "condition"),
2658];
2659
2660pub fn is_raw_field(kind: &str, field: &str) -> bool {
2661 RAW_FIELDS.iter().any(|(k, f)| *k == kind && *f == field)
2662}
2663
2664pub fn duration_ms_opt(v: &Value) -> Option<u64> {
2666 duration_ms(v).ok()
2667}
2668
2669pub fn duration_ms(v: &Value) -> Result<u64, String> {
2671 match v {
2672 Value::Number(n) => n
2673 .as_u64()
2674 .map(|s| s * 1000)
2675 .ok_or_else(|| "duration must be a non-negative number of seconds".into()),
2676 Value::String(s) => crate::config::parse_duration(s).map(|d| d.as_millis() as u64),
2677 Value::Object(o) => o
2678 .get("ms")
2679 .and_then(Value::as_u64)
2680 .ok_or_else(|| "duration object must be {ms: n}".into()),
2681 _ => Err("duration must be a string like 30s or a number of seconds".into()),
2682 }
2683}
2684
2685pub fn canonical(v: &Value) -> String {
2687 v.to_string()
2688}
2689
2690pub fn workflow_schema() -> Value {
2694 let kinds: Vec<&str> = KINDS.iter().map(|k| k.name).collect();
2695 json!({
2696 "$schema": "https://json-schema.org/draft/2020-12/schema",
2697 "$id": "https://agentd.dev/schema/workflow-3.json",
2700 "title": "agentd workflow",
2701 "type": "object",
2702 "required": ["name", "steps"],
2703 "properties": {
2704 "name": {"type": "string", "pattern": "^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$"},
2705 "version": {"const": 3},
2706 "description": {"type": "string"},
2707 "armed": {"type": "boolean", "default": true},
2708 "durable": {"type": "boolean", "description": "false = runs are memory-only (no checkpoints, gone after a restart) — the fast path for recomputable work; absent = the store.durability.work default (durable)"},
2709 "priority": {"enum": ["low", "normal", "high"], "description": "contention weight: `low` sheds one pressure level earlier and is scheduled last; a tiebreak under scarcity, not a reservation"},
2710 "unload": {"type": "object", "additionalProperties": false, "description": "what happens to LIVE runs when this definition is retired (removed, replaced or deleted)", "properties": {
2711 "policy": {"enum": ["drain", "cancel", "detach"], "description": "drain (default) lets them finish"},
2712 "timeout": {"type": "string", "description": "how long a drain may take"}}},
2713 "file": {"type": "string", "description": "load the document from a path on disk instead of inline"},
2714 "uri": {"type": "string", "description": "load the document from an MCP resource instead of inline"},
2715 "inputs": {"type": "object", "properties": {"schema": {"type": "object"}}},
2716 "outputs": {"type": "object", "properties": {"schema": {"type": "object"}}},
2717 "state": {"type": "object", "additionalProperties": {"type": "object",
2718 "additionalProperties": false,
2719 "properties": {
2720 "schema": {"type": "object", "description": "a JSON Schema every write to this key must satisfy"},
2721 "reducer": {"enum": ["overwrite", "append", "merge", "union"],
2722 "description": "how concurrent writes to this key combine; declaring it makes concurrency a policy rather than a race"}}},
2723 "description": "declared run variables — {key: {schema, reducer}}"},
2724 "concurrency": {"type": "object", "properties": {"max_runs": {"type": "integer", "minimum": 1}, "on_overflow": {"enum": ["queue", "drop", "replace"]}, "scope": {"enum": ["workflow", "key"], "description": "what max_runs counts: every run of this workflow (default), or every run about the same `key` — the difference between a queue and a per-entity lock"}}},
2725 "key": {"type": "string", "description": "the logical thing a run is ABOUT, rendered from the trigger payload (e.g. \"{{payload.account_id}}\"); required by concurrency.scope: key"},
2726 "tool": {"type": "object", "required": ["name"], "additionalProperties": false, "description": "register this workflow as a first-class tool — a callable procedure with retry, breaker, idempotency and a human gate INSIDE one apparent call. Startup config only; tags are DERIVED from what the steps reach.", "properties": {
2727 "name": {"type": "string", "description": "the tool name callers see; may not shadow an internal contract"},
2728 "mode": {"enum": ["sync", "async"], "description": "sync parks the caller on the run and returns its output; async returns a handle"},
2729 "grant": {"type": "object", "additionalProperties": false, "properties": {
2730 "root": {"type": "boolean"}, "workflows": {"type": "boolean"}, "subagents": {"type": "boolean"},
2731 "user": {"type": "boolean"}, "agent": {"type": "boolean"}}}}},
2732 "limits": {"type": "object", "properties": {"steps": {"type": "integer"}, "tokens": {"type": "integer"}, "deadline": {"type": "string"}, "budget": {"type": "object"}}},
2733 "steps": {"type": "object", "additionalProperties": {"$ref": "#/$defs/step"}, "minProperties": 1}
2734 },
2735 "$defs": {
2736 "step": {
2737 "type": "object",
2738 "required": ["kind"],
2739 "properties": {
2740 "kind": {"enum": kinds},
2741 "depends_on": {"type": "array", "items": {"type": "string"}},
2742 "when": {"type": "string"},
2743 "retry": {"type": "object", "properties": {"max": {"type": "integer"}, "backoff": {"type": "string"}}},
2744 "timeout": {"type": "string"},
2745 "on_error": {"type": "string"},
2746 "idempotent": {"type": "boolean"},
2747 "on_replay": {"enum": ["retry", "skip", "fail"]},
2748 "output_schema": {"type": "object"},
2749 "cache": {"type": "object"},
2750 "budget": {"type": "integer"},
2751 "skills": {"type": "array", "items": {"type": "string"}},
2752 "otel": {"type": "object"},
2753 "description": {"type": "string"}
2754 }
2755 },
2756 "kinds": KINDS.iter().map(|k| (k.name.to_string(), json!({"start": k.start, "fields": k.fields, "required": k.required, "implemented": k.implemented}))).collect::<BTreeMap<_, _>>()
2757 }
2758 })
2759}
2760
2761#[cfg(test)]
2762mod tests {
2763 #[test]
2771 fn a_kind_that_declares_output_schema_receives_it() {
2772 let doc = serde_json::json!({
2773 "name": "w",
2774 "steps": {
2775 "go": {"kind": "manual"},
2776 "e": {"kind": "extract", "depends_on": ["go"], "input": "x",
2777 "output_schema": {"type": "object"}},
2778 "t": {"kind": "think", "depends_on": ["e"], "prompt": "p",
2779 "output_schema": {"type": "object"}},
2780 "fin": {"kind": "finish", "depends_on": ["t"], "status": "completed"}
2781 }
2782 });
2783 let wf = parse_workflow(&doc)
2784 .unwrap_or_else(|e| panic!("extract must validate with an output_schema: {e:?}"));
2785 for id in ["e", "t"] {
2787 let step = wf.steps.get(id).unwrap_or_else(|| panic!("step {id}"));
2788 assert!(
2789 step.field("output_schema").is_some(),
2790 "{id}: the kind's own output_schema must reach the node spec"
2791 );
2792 }
2793 }
2794
2795 use super::*;
2796
2797 fn wf(doc: Value) -> Result<Workflow, Vec<String>> {
2798 parse_workflow(&doc)
2799 }
2800
2801 #[test]
2809 fn the_workflow_schema_accepts_exactly_what_the_parser_does() {
2810 let schema = workflow_schema();
2811 let declared: std::collections::BTreeSet<&str> = schema["properties"]
2812 .as_object()
2813 .expect("properties")
2814 .keys()
2815 .map(String::as_str)
2816 .collect();
2817 let parsed: std::collections::BTreeSet<&str> = TOP.iter().copied().collect();
2818 assert_eq!(
2819 parsed.difference(&declared).collect::<Vec<_>>(),
2820 Vec::<&&str>::new(),
2821 "the parser accepts fields the schema does not declare — an editor would flag valid documents"
2822 );
2823 assert_eq!(
2824 declared.difference(&parsed).collect::<Vec<_>>(),
2825 Vec::<&&str>::new(),
2826 "the schema declares fields the parser refuses — completion would suggest fields that fail at load"
2827 );
2828 }
2829
2830 #[test]
2835 fn a_human_gates_addressee_is_checked_at_load() {
2836 let gate = |to: Value| {
2837 wf(json!({"name": "w", "steps": {
2838 "s": {"kind": "manual"},
2839 "g": {"kind": "human", "question": "ok?", "to": to, "depends_on": ["s"]},
2840 "f": {"kind": "finish", "depends_on": ["g"]}}}))
2841 };
2842 assert!(gate(json!("*@finance.example")).is_ok());
2843 assert!(gate(json!({"role": "user", "labels": {"team": "finance"}})).is_ok());
2844 for bad in [
2846 json!(""),
2847 json!({}),
2848 json!({"role": "anonymous"}),
2849 json!({"role": "auditor"}),
2850 json!({"rolle": "user"}),
2851 json!(7),
2852 ] {
2853 let e = gate(bad.clone()).unwrap_err();
2854 assert!(
2855 e.iter().any(|m| m.contains("human.to")),
2856 "{bad} should be refused at load, got {e:?}"
2857 );
2858 }
2859 let e = wf(json!({"name": "w", "steps": {
2861 "s": {"kind": "manual"},
2862 "g": {"kind": "human", "question": "ok?", "reply_uri": "https://x", "depends_on": ["s"]},
2863 "f": {"kind": "finish", "depends_on": ["g"]}}}))
2864 .unwrap_err();
2865 assert!(e.iter().any(|m| m.contains("reply_uri")), "{e:?}");
2866 }
2867
2868 #[test]
2869 fn workflow_priority_parses_and_rejects_junk() {
2870 let w = wf(json!({"name": "w", "priority": "low", "steps": {
2871 "s": {"kind": "once"}, "f": {"kind": "finish", "depends_on": ["s"]}}}))
2872 .unwrap();
2873 assert_eq!(w.priority, Priority::Low);
2874 let w = wf(json!({"name": "w", "steps": {
2875 "s": {"kind": "once"}, "f": {"kind": "finish", "depends_on": ["s"]}}}))
2876 .unwrap();
2877 assert_eq!(w.priority, Priority::Normal, "default");
2878 let e = wf(json!({"name": "w", "priority": "urgent", "steps": {
2879 "s": {"kind": "once"}, "f": {"kind": "finish", "depends_on": ["s"]}}}))
2880 .unwrap_err();
2881 assert!(e.iter().any(|m| m.contains("low|normal|high")), "{e:?}");
2882 assert!(Priority::High > Priority::Normal && Priority::Normal > Priority::Low);
2884 }
2885
2886 #[test]
2887 fn breaker_validates_shape_and_kind_family() {
2888 let ok = wf(json!({"name": "w", "steps": {
2889 "s": {"kind": "once"},
2890 "c": {"kind": "http", "depends_on": ["s"], "url": "https://api.example",
2891 "breaker": {"failures": 5, "cooldown": "60s"}},
2892 "f": {"kind": "finish", "depends_on": ["c"]},
2893 }}));
2894 assert!(ok.is_ok(), "{ok:?}");
2895 for bad in [
2896 json!({"failures": 0, "cooldown": "60s"}),
2897 json!({"failures": 5}),
2898 json!({"cooldown": "60s"}),
2899 json!({"failures": 5, "cooldown": "sometimes"}),
2900 json!({"failures": 5, "cooldown": "60s", "extra": 1}),
2901 ] {
2902 let e = wf(json!({"name": "w", "steps": {
2903 "s": {"kind": "once"},
2904 "c": {"kind": "http", "depends_on": ["s"], "url": "https://x", "breaker": bad},
2905 "f": {"kind": "finish", "depends_on": ["c"]},
2906 }}))
2907 .unwrap_err();
2908 assert!(e.iter().any(|m| m.contains("breaker takes")), "{e:?}");
2909 }
2910 let e = wf(json!({"name": "w", "steps": {
2912 "s": {"kind": "once"},
2913 "a": {"kind": "assign", "depends_on": ["s"], "value": 1,
2914 "breaker": {"failures": 5, "cooldown": "60s"}},
2915 "f": {"kind": "finish", "depends_on": ["a"]},
2916 }}))
2917 .unwrap_err();
2918 assert!(
2919 e.iter()
2920 .any(|m| m.contains("unknown field") || m.contains("remote-effect")),
2921 "{e:?}"
2922 );
2923 }
2924
2925 #[test]
2926 fn webhook_rate_and_subscribe_window_validate_their_shapes() {
2927 let ok = wf(json!({"name": "w", "steps": {
2929 "h": {"kind": "webhook", "path": "/x", "rate": "20/1s"},
2930 "s": {"kind": "subscribe", "server": "m", "uri": "u://v", "window": {"samples": 64}},
2931 "f": {"kind": "finish", "depends_on": ["h", "s"]},
2932 }}));
2933 assert!(ok.is_ok(), "{ok:?}");
2934 for bad in ["fast", "0/1s", "5/0s", "5"] {
2936 let e = wf(json!({"name": "w", "steps": {
2937 "h": {"kind": "webhook", "path": "/x", "rate": bad},
2938 "f": {"kind": "finish", "depends_on": ["h"]},
2939 }}))
2940 .unwrap_err();
2941 assert!(
2942 e.iter().any(|m| m.contains("rate must be")),
2943 "rate {bad:?}: {e:?}"
2944 );
2945 }
2946 for bad in [
2948 json!(64),
2949 json!({"samples": 0}),
2950 json!({"samples": 300}),
2951 json!({"samples": 4, "mean": true}),
2952 ] {
2953 let e = wf(json!({"name": "w", "steps": {
2954 "s": {"kind": "subscribe", "server": "m", "uri": "u://v", "window": bad},
2955 "f": {"kind": "finish", "depends_on": ["s"]},
2956 }}))
2957 .unwrap_err();
2958 assert!(
2959 e.iter().any(|m| m.contains("window takes")),
2960 "window {bad:?}: {e:?}"
2961 );
2962 }
2963 }
2964
2965 #[test]
2966 fn the_sugar_workflow_parses_hashes_and_orders() {
2967 let w = wf(json!({
2968 "name": "main", "version": 3,
2969 "steps": {
2970 "start": {"kind": "once"},
2971 "work": {"kind": "agent", "depends_on": ["start"], "instruction": "{{env.instruction}}"},
2972 "done": {"kind": "finish", "depends_on": ["work"], "status": "completed", "output": "{{steps.work.output}}"}
2973 }
2974 }))
2975 .unwrap();
2976 assert_eq!(w.start_steps().len(), 1);
2977 assert_eq!(w.topo_order(), vec!["start", "work", "done"]);
2978 assert_eq!(w.hash.len(), 64);
2979 assert!(!w.is_long_lived());
2980 assert!(w.armed);
2981 assert_eq!(
2982 w.step("work").unwrap().field_str("instruction"),
2983 Some("{{env.instruction}}")
2984 );
2985 let w2 = wf(w.definition.clone()).unwrap();
2987 assert_eq!(w2.hash, w.hash);
2988 let mut d = w.definition.clone();
2989 d["steps"]["work"]["instruction"] = json!("other");
2990 assert_ne!(wf(d).unwrap().hash, w.hash);
2991 }
2992
2993 #[test]
3002 fn every_start_kind_is_classified_and_only_once_manual_are_short() {
3003 let starts = start_kinds();
3004 assert_eq!(starts.len(), 11, "start kinds: {starts:?}");
3005 for k in &starts {
3006 assert_eq!(
3007 is_long_lived_start(k),
3008 !ONE_SHOT_STARTS.contains(k),
3009 "{k} classified inconsistently"
3010 );
3011 }
3012 assert!(is_long_lived_start("webhook"), "a listener keeps us alive");
3014 assert!(is_long_lived_start("stream"), "a consumer keeps us alive");
3015 assert!(is_long_lived_start("correlate"), "a join keeps us alive");
3018 assert!(!is_long_lived_start("once"));
3019 assert!(!is_long_lived_start("manual"));
3020 assert!(!is_long_lived_start("wait"));
3022 assert!(!is_long_lived_start("nonsense"));
3023 }
3024
3025 #[cfg(feature = "cel")]
3027 #[test]
3028 fn validation_catches_the_parse_and_graph_level_failures() {
3029 let e = wf(json!({"name": "bad name", "start": "x", "steps": {
3031 "a": {"kind": "agent", "instruction": "x"},
3032 "b": {"kind": "tool", "name": "memory.get", "depends_on": ["a"], "bogus": 1},
3033 "c": {"kind": "foreach", "over": "{{x}}", "body": {"steps": {"i": {"kind": "noop", "depends_on": ["q"]}, "bad id": {"kind": "noop"}}}, "depends_on": ["b"]},
3034 "d": {"kind": "nope", "depends_on": ["a"]},
3035 "e": {"kind": "sleep", "duration": "5 parsecs", "depends_on": ["a"], "when": "CEL: 1 +"},
3036 "s": {"kind": "once", "depends_on": ["a"]}
3037 }}))
3038 .unwrap_err();
3039 let joined = e.join("\n");
3040 for needle in [
3041 "workflow name \"bad name\"",
3042 "`start`/`nodes` are dialect 1/2",
3043 "unknown field \"bogus\"",
3044 "unknown kind \"nope\"",
3045 "sleep.duration",
3046 "when: CEL parse",
3047 "a start node cannot depend on other steps",
3048 "step \"bad id\": id must match",
3049 ] {
3050 assert!(joined.contains(needle), "missing {needle:?} in:\n{joined}");
3051 }
3052 let e = wf(json!({"name": "g", "steps": {
3054 "s": {"kind": "once"},
3055 "b": {"kind": "noop", "depends_on": ["s", "zz"]},
3056 "e": {"kind": "sleep", "duration": "1s", "depends_on": ["s"], "on_error": "goto:nowhere"},
3057 "loop1": {"kind": "noop", "depends_on": ["loop2"]},
3058 "loop2": {"kind": "noop", "depends_on": ["loop1"]},
3059 "f": {"kind": "finish", "depends_on": ["b"]}
3060 }}))
3061 .unwrap_err();
3062 let joined = e.join("\n");
3063 for needle in [
3064 "depends_on names unknown step \"zz\"",
3065 "on_error goto names unknown step \"nowhere\"",
3066 "cycle among steps",
3067 "not reachable from any start node",
3068 ] {
3069 assert!(joined.contains(needle), "missing {needle:?} in:\n{joined}");
3070 }
3071 let e = wf(json!({"name": "w", "steps": {
3073 "a": {"kind": "noop"},
3074 "b": {"kind": "noop", "depends_on": ["c"]},
3075 "c": {"kind": "noop", "depends_on": ["b"]}
3076 }}))
3077 .unwrap_err();
3078 let joined = e.join("\n");
3079 assert!(joined.contains("at least one start node"), "{joined}");
3080 assert!(joined.contains("unreachable root"), "{joined}");
3081 assert!(joined.contains("cycle among steps"), "{joined}");
3082 assert!(joined.contains("`finish` step is required"), "{joined}");
3083 let e = wf(json!({"name": "w", "version": 2, "steps": {"s": {"kind": "once"}, "f": {"kind": "finish", "depends_on": ["s"]}}})).unwrap_err();
3085 assert!(e[0].contains("not dialect 3"));
3086 let ok = wf(json!({"name": "w", "inputs": {"schema": {"type": "object"}}, "concurrency": {"max_runs": 2, "on_overflow": "drop"}, "limits": {"deadline": "10m", "steps": 50}, "steps": {
3088 "s": {"kind": "manual"},
3089 "t": {"kind": "mcp.tool", "server": "fs", "tool": "read", "args": {"path": "/x"}, "depends_on": ["s"], "retry": {"max": 2, "backoff": "1s"}, "timeout": "30s", "on_error": "continue"},
3090 "v": {"kind": "assign", "value": {"a": 1}, "writes": "x", "depends_on": ["t"], "when": "CEL: true"},
3091 "th": {"kind": "think", "prompt": "p", "output_schema": {"type": "object"}, "depends_on": ["v"]},
3092 "z": {"kind": "sleep", "duration": "1s", "depends_on": ["th"]},
3093 "f": {"kind": "finish", "depends_on": ["z"], "status": "completed", "output": "{{vars.x}}"}
3094 }}))
3095 .unwrap();
3096 assert_eq!(ok.concurrency.on_overflow, OnOverflow::Drop);
3097 assert_eq!(ok.limits.deadline_ms, Some(600_000));
3098 assert_eq!(
3099 ok.step("t").unwrap().retry.as_ref().unwrap().backoff_ms,
3100 1000
3101 );
3102 assert_eq!(ok.step("t").unwrap().on_error, OnError::Continue);
3103 assert_eq!(ok.step("t").unwrap().timeout_ms, Some(30_000));
3104 assert!(implemented_kinds().contains(&"agent"));
3105 assert!(workflow_schema()["$defs"]["kinds"]["a2a.send"]["implemented"] == json!(true));
3106 assert!(workflow_schema()["$defs"]["kinds"]["foreach"]["implemented"] == json!(true));
3107 }
3108}