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
57const fn k(
58 name: &'static str,
59 start: bool,
60 fields: &'static [&'static str],
61 required: &'static [&'static str],
62 implemented: bool,
63 nested: bool,
64) -> KindInfo {
65 KindInfo {
66 name,
67 start,
68 fields,
69 required,
70 implemented,
71 nested,
72 }
73}
74
75pub const KINDS: &[KindInfo] = &[
80 k("once", true, &["policy", "inputs"], &[], true, false),
82 k("manual", true, &["inputs"], &[], true, false),
83 k(
84 "loop",
85 true,
86 &[
87 "interval",
88 "delay",
89 "until",
90 "max_iterations",
91 "backoff",
92 "inputs",
93 ],
94 &[],
95 true,
96 false,
97 ),
98 k(
99 "schedule",
100 true,
101 &["cron", "every", "tz", "jitter", "catch_up", "at", "inputs"],
102 &[],
103 true,
104 false,
105 ),
106 k(
107 "subscribe",
108 true,
109 &[
110 "server",
111 "uri",
112 "debounce_ms",
113 "coalesce",
114 "filter",
115 "deliver",
116 "on_no_listener",
117 "window",
118 "inputs",
119 ],
120 &["server", "uri"],
121 true,
122 false,
123 ),
124 k(
125 "stream",
126 true,
127 &["stream", "subject", "filter", "from", "rate", "inputs"],
128 &["stream"],
129 true,
130 false,
131 ),
132 k(
133 "signal",
134 true,
135 &["name", "filter", "deliver", "inputs"],
136 &["name"],
137 true,
138 false,
139 ),
140 k(
141 "event",
142 true,
143 &["on", "filter", "inputs"],
144 &["on"],
145 true,
146 false,
147 ),
148 k(
149 "a2a",
150 true,
151 &["command", "roles", "inputs", "schema"],
152 &[],
153 true,
154 false,
155 ),
156 k(
157 "webhook",
158 true,
159 &[
160 "path",
161 "methods",
162 "auth",
163 "parallelism",
164 "on_overflow",
165 "rate",
166 "idempotency",
167 "respond",
168 "filter",
169 "inputs",
170 "signal",
171 ],
172 &["path"],
173 true,
174 false,
175 ),
176 k(
178 "switch",
179 false,
180 &["on", "cases", "default", "on_no_match"],
181 &["on", "cases"],
182 true,
183 false,
184 ),
185 k(
186 "parallel",
187 false,
188 &["branches", "on_error"],
189 &["branches"],
190 true,
191 true,
192 ),
193 k(
194 "foreach",
195 false,
196 &["over", "body", "batch", "collect", "on_error", "as"],
197 &["over", "body"],
198 true,
199 true,
200 ),
201 k(
202 "batch",
203 false,
204 &[
205 "over", "body", "by", "size", "parallel", "rate", "collect", "on_error",
206 ],
207 &["over", "body"],
208 true,
209 true,
210 ),
211 k(
212 "iterate",
213 false,
214 &["body", "while", "until", "max_iterations", "collect"],
215 &["body"],
216 true,
217 true,
218 ),
219 k(
220 "race",
221 false,
222 &["branches", "timeout", "min_success"],
223 &["branches"],
224 true,
225 true,
226 ),
227 k(
228 "join",
229 false,
230 &["handles", "timeout", "min", "partials"],
231 &["handles"],
232 true,
233 false,
234 ),
235 k("subgraph", false, &["body"], &["body"], true, true),
236 k(
237 "workflow",
238 false,
239 &["name", "inputs", "mode", "start", "version", "cascade"],
240 &["name"],
241 true,
242 false,
243 ),
244 k(
245 "wait",
246 false,
247 &[
248 "on",
249 "server",
250 "uri",
251 "condition",
252 "signal",
253 "run",
254 "subagent",
255 "conversation",
256 "webhook",
257 "stream",
258 "subject",
259 "match",
260 "timeout",
261 "on_timeout",
262 ],
263 &["on"],
264 true,
265 false,
266 ),
267 k("sleep", false, &["duration"], &["duration"], true, false),
268 k(
269 "assert",
270 false,
271 &["condition", "message"],
272 &["condition"],
273 true,
274 false,
275 ),
276 k("fail", false, &["message", "code"], &[], true, false),
277 k("noop", false, &[], &[], true, false),
278 k("checkpoint", false, &["name"], &[], true, false),
279 k(
280 "finish",
281 false,
282 &["status", "output", "reason"],
283 &[],
284 true,
285 false,
286 ),
287 k(
289 "assign",
290 false,
291 &["value", "writes", "mode"],
292 &["value"],
293 true,
294 false,
295 ),
296 k(
297 "transform",
298 false,
299 &["value", "writes", "mode"],
300 &["value"],
301 true,
302 false,
303 ),
304 k(
305 "map",
306 false,
307 &["over", "expr", "as"],
308 &["over", "expr"],
309 true,
310 false,
311 ),
312 k(
313 "filter",
314 false,
315 &["over", "expr", "as"],
316 &["over", "expr"],
317 true,
318 false,
319 ),
320 k(
321 "reduce",
322 false,
323 &["over", "expr", "initial", "as", "acc"],
324 &["over", "expr"],
325 true,
326 false,
327 ),
328 k(
329 "sort",
330 false,
331 &["over", "by", "order"],
332 &["over"],
333 true,
334 false,
335 ),
336 k("dedupe", false, &["over", "by"], &["over"], true, false),
337 k(
338 "chunk",
339 false,
340 &["value", "by", "size", "overlap"],
341 &["value", "size"],
342 true,
343 false,
344 ),
345 k("template", false, &["text", "value"], &[], true, false),
346 k("parse", false, &["text", "format"], &["text"], true, false),
347 k(
348 "validate",
349 false,
350 &["value", "schema"],
351 &["value", "schema"],
352 true,
353 false,
354 ),
355 k("memory.get", false, &["key"], &["key"], true, false),
356 k(
357 "memory.set",
358 false,
359 &["key", "value", "ttl"],
360 &["key", "value"],
361 true,
362 false,
363 ),
364 k("memory.list", false, &["prefix", "limit"], &[], true, false),
365 k(
366 "memory.push",
367 false,
368 &["key", "value"],
369 &["key", "value"],
370 true,
371 false,
372 ),
373 k("memory.shift", false, &["key"], &["key"], true, false),
374 k("memory.pop", false, &["key"], &["key"], true, false),
375 k("memory.delete", false, &["key"], &["key"], true, false),
376 k(
377 "artifact.create",
378 false,
379 &["name", "mime", "content", "from_step", "sensitive"],
380 &["name"],
381 true,
382 false,
383 ),
384 k("artifact.get", false, &["id"], &["id"], true, false),
385 k("artifact.delete", false, &["id"], &["id"], true, false),
386 k(
387 "knowledge.search",
388 false,
389 &["query", "top_k", "filters"],
390 &["query"],
391 true,
392 false,
393 ),
394 k("knowledge.get", false, &["id", "uri"], &[], true, false),
395 k(
396 "search.query",
397 false,
398 &["query", "kind", "limit", "freshness"],
399 &["query"],
400 true,
401 false,
402 ),
403 k(
404 "search.fetch",
405 false,
406 &["url", "max_bytes"],
407 &["url"],
408 true,
409 false,
410 ),
411 k(
413 "mcp.tool",
414 false,
415 &["server", "tool", "args", "idempotency", "breaker", "rate"],
416 &["server", "tool"],
417 true,
418 false,
419 ),
420 k(
421 "mcp.resource",
422 false,
423 &[
424 "server",
425 "op",
426 "uri",
427 "name",
428 "arguments",
429 "reference",
430 "argument",
431 ],
432 &["server", "op"],
433 true,
434 false,
435 ),
436 k("tool", false, &["name", "args"], &["name"], true, false),
437 k(
438 "http",
439 false,
440 &[
441 "method",
442 "url",
443 "headers",
444 "query",
445 "body",
446 "json",
447 "timeout",
448 "expect",
449 "allow_private",
450 "sign",
451 "idempotency",
452 "breaker",
453 "rate",
454 ],
455 &["url"],
456 true,
457 false,
458 ),
459 k(
460 "a2a.send",
461 false,
462 &[
463 "to",
464 "parts",
465 "command",
466 "args",
467 "context",
468 "timeout",
469 "idempotency",
470 "breaker",
471 "rate",
472 ],
473 &["to"],
474 true,
475 false,
476 ),
477 k(
478 "a2a.delegate",
479 false,
480 &[
481 "peer",
482 "objective",
483 "command",
484 "args",
485 "output_contract",
486 "timeout",
487 "idempotency",
488 "breaker",
489 "rate",
490 ],
491 &["peer"],
492 true,
493 false,
494 ),
495 k(
496 "a2a.wait",
497 false,
498 &["conversation", "timeout"],
499 &[],
500 true,
501 false,
502 ),
503 k(
508 "message",
509 false,
510 &["to", "text", "parts", "wait", "timeout", "on_timeout"],
511 &["to"],
512 true,
513 false,
514 ),
515 k(
516 "workflow.signal",
517 false,
518 &["name", "payload", "run"],
519 &["name"],
520 true,
521 false,
522 ),
523 k(
524 "workflow.wait",
525 false,
526 &["run", "timeout"],
527 &["run"],
528 true,
529 false,
530 ),
531 k(
532 "workflow.cancel",
533 false,
534 &["run", "reason"],
535 &["run"],
536 true,
537 false,
538 ),
539 k(
540 "emit",
541 false,
542 &[
543 "note",
544 "audit",
545 "metric",
546 "value",
547 "stream",
548 "subject",
549 "data",
550 "correlation",
551 ],
552 &[],
553 true,
554 false,
555 ),
556 k(
558 "think",
559 false,
560 &[
561 "prompt",
562 "output_schema",
563 "reads",
564 "check",
565 "retries",
566 "skills",
567 "system",
568 "model",
569 ],
570 &["prompt"],
571 true,
572 false,
573 ),
574 k(
575 "classify",
576 false,
577 &["input", "classes", "prompt", "skills", "model"],
578 &["input", "classes"],
579 true,
580 false,
581 ),
582 k(
583 "extract",
584 false,
585 &["input", "output_schema", "prompt", "skills", "model"],
586 &["input", "output_schema"],
587 true,
588 false,
589 ),
590 k(
591 "summarize",
592 false,
593 &["input", "length", "prompt", "skills", "model"],
594 &["input"],
595 true,
596 false,
597 ),
598 k(
599 "judge",
600 false,
601 &["input", "rubric", "prompt", "skills", "model"],
602 &["input", "rubric"],
603 true,
604 false,
605 ),
606 k(
607 "route",
608 false,
609 &["input", "choices", "prompt", "skills", "model"],
610 &["input", "choices"],
611 true,
612 false,
613 ),
614 k(
615 "agent",
616 false,
617 &[
618 "instruction",
619 "output_contract",
620 "output_schema",
621 "tools",
622 "servers",
623 "limits",
624 "context",
625 "skills",
626 "system",
627 "model",
628 ],
629 &["instruction"],
630 true,
631 false,
632 ),
633 k(
638 "subagent",
639 false,
640 &[
641 "instruction",
642 "template",
643 "params",
644 "mode",
645 "tools",
646 "servers",
647 "limits",
648 "priority",
649 "context",
650 "output_contract",
651 "output_schema",
652 "skills",
653 "durable",
654 ],
655 &[],
656 true,
657 false,
658 ),
659 k(
660 "human",
661 false,
662 &["question", "schema", "to", "timeout", "reply_uri"],
663 &["question"],
664 true,
665 false,
666 ),
667];
668
669pub const COMMON_FIELDS: &[&str] = &[
673 "kind",
674 "depends_on",
675 "when",
676 "retry",
677 "timeout",
678 "on_error",
679 "idempotent",
680 "on_replay",
681 "output_schema",
682 "cache",
683 "budget",
684 "skills",
685 "otel",
686 "description",
687];
688
689pub fn pure_data_kind(kind: &str) -> bool {
698 matches!(
699 kind,
700 "assign"
701 | "map"
702 | "filter"
703 | "reduce"
704 | "sort"
705 | "dedupe"
706 | "chunk"
707 | "parse"
708 | "switch"
709 | "noop"
710 | "assert"
711 | "validate"
712 )
713}
714
715pub fn kind_info(name: &str) -> Option<&'static KindInfo> {
716 KINDS.iter().find(|k| k.name == name)
717}
718
719pub fn implemented_kinds() -> Vec<&'static str> {
721 KINDS
722 .iter()
723 .filter(|k| k.implemented)
724 .map(|k| k.name)
725 .collect()
726}
727
728#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
730#[serde(rename_all = "snake_case")]
731pub enum OnError {
732 #[default]
733 Fail,
734 Continue,
735 Goto(String),
736}
737
738impl OnError {
739 fn parse(v: &Value) -> Result<OnError, String> {
740 match v.as_str() {
741 Some("fail") => Ok(OnError::Fail),
742 Some("continue") => Ok(OnError::Continue),
743 Some(s) if s.starts_with("goto:") => {
744 let t = s["goto:".len()..].trim();
745 if t.is_empty() {
746 Err("on_error goto: needs a step id".into())
747 } else {
748 Ok(OnError::Goto(t.to_string()))
749 }
750 }
751 _ => Err("on_error must be fail | continue | goto:<step>".into()),
752 }
753 }
754}
755
756#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
757#[serde(rename_all = "snake_case")]
758pub enum OnReplay {
759 #[default]
760 Retry,
761 Skip,
762 Fail,
763}
764
765#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
766pub struct Retry {
767 #[serde(default)]
768 pub max: u32,
769 #[serde(default)]
771 pub backoff_ms: u64,
772}
773
774#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
780pub struct Body {
781 pub steps: BTreeMap<String, Step>,
782}
783
784impl Body {
785 pub fn topo_order(&self) -> Vec<String> {
787 let mut out = Vec::new();
788 let mut done: BTreeSet<String> = BTreeSet::new();
789 let mut progress = true;
790 while progress && out.len() < self.steps.len() {
791 progress = false;
792 for (id, s) in &self.steps {
793 if !done.contains(id) && s.depends_on.iter().all(|d| done.contains(d)) {
794 done.insert(id.clone());
795 out.push(id.clone());
796 progress = true;
797 }
798 }
799 }
800 out
801 }
802 pub fn sinks(&self) -> Vec<String> {
804 self.steps
805 .keys()
806 .filter(|id| {
807 !self
808 .steps
809 .values()
810 .any(|s| s.depends_on.iter().any(|d| d == *id))
811 })
812 .cloned()
813 .collect()
814 }
815}
816
817#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
819pub struct Step {
820 pub id: String,
821 pub kind: String,
822 #[serde(default)]
823 pub depends_on: Vec<String>,
824 #[serde(default, skip_serializing_if = "Option::is_none")]
825 pub when: Option<String>,
826 #[serde(default, skip_serializing_if = "Option::is_none")]
827 pub retry: Option<Retry>,
828 #[serde(default, skip_serializing_if = "Option::is_none")]
829 pub timeout_ms: Option<u64>,
830 #[serde(default)]
831 pub on_error: OnError,
832 #[serde(default)]
833 pub idempotent: bool,
834 #[serde(default)]
835 pub on_replay: OnReplay,
836 #[serde(default, skip_serializing_if = "Option::is_none")]
837 pub output_schema: Option<Value>,
838 #[serde(default, skip_serializing_if = "Option::is_none")]
839 pub cache: Option<Value>,
840 #[serde(default, skip_serializing_if = "Option::is_none")]
841 pub budget: Option<u64>,
842 #[serde(default, skip_serializing_if = "Vec::is_empty")]
843 pub skills: Vec<String>,
844 #[serde(default, skip_serializing_if = "Option::is_none")]
845 pub description: Option<String>,
846 #[serde(default)]
848 pub spec: Map<String, Value>,
849 #[serde(default, skip_serializing_if = "Option::is_none")]
851 pub body: Option<Body>,
852 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
854 pub branches: BTreeMap<String, Body>,
855}
856
857impl Step {
858 pub fn info(&self) -> Option<&'static KindInfo> {
859 kind_info(&self.kind)
860 }
861 pub fn is_start(&self) -> bool {
862 self.info().is_some_and(|k| k.start)
863 }
864 pub fn field(&self, name: &str) -> Option<&Value> {
866 self.spec.get(name)
867 }
868 pub fn field_str(&self, name: &str) -> Option<&str> {
869 self.spec.get(name).and_then(Value::as_str)
870 }
871}
872
873#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
874#[serde(rename_all = "snake_case")]
875pub enum OnOverflow {
876 #[default]
877 Queue,
878 Drop,
879 Replace,
880}
881
882#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
883pub struct Concurrency {
884 pub max_runs: u32,
885 pub on_overflow: OnOverflow,
886 #[serde(default)]
896 pub scope: ConcurrencyScope,
897}
898
899#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
900#[serde(rename_all = "lowercase")]
901pub enum ConcurrencyScope {
902 #[default]
903 Workflow,
904 Key,
905}
906
907impl Default for Concurrency {
908 fn default() -> Self {
909 Concurrency {
910 max_runs: 4,
911 on_overflow: OnOverflow::Queue,
912 scope: ConcurrencyScope::Workflow,
913 }
914 }
915}
916
917#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
918pub struct WorkflowLimits {
919 #[serde(default, skip_serializing_if = "Option::is_none")]
920 pub steps: Option<u32>,
921 #[serde(default, skip_serializing_if = "Option::is_none")]
922 pub tokens: Option<u64>,
923 #[serde(default, skip_serializing_if = "Option::is_none")]
924 pub deadline_ms: Option<u64>,
925 #[serde(default, skip_serializing_if = "Option::is_none")]
926 pub budget: Option<Value>,
927}
928
929#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
931pub struct WorkflowTool {
932 pub name: String,
934 #[serde(default)]
937 pub mode: WorkflowToolMode,
938 #[serde(default)]
940 pub grant: WorkflowToolGrant,
941}
942
943#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
944#[serde(rename_all = "lowercase")]
945pub enum WorkflowToolMode {
946 #[default]
947 Sync,
948 Async,
949}
950
951#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
952pub struct WorkflowToolGrant {
953 pub root: bool,
954 pub workflows: bool,
955 pub subagents: bool,
956 pub user: bool,
957 pub agent: bool,
958}
959
960impl Default for WorkflowToolGrant {
961 fn default() -> Self {
962 WorkflowToolGrant {
966 root: true,
967 workflows: true,
968 subagents: false,
969 user: false,
970 agent: false,
971 }
972 }
973}
974
975#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
977pub struct StateDecl {
978 #[serde(default, skip_serializing_if = "Option::is_none")]
980 pub schema: Option<Value>,
981 #[serde(default, skip_serializing_if = "Option::is_none")]
983 pub reducer: Option<String>,
984}
985
986#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
988pub struct Workflow {
989 pub name: String,
990 pub version: u32,
991 #[serde(default)]
996 pub priority: Priority,
997 #[serde(default)]
999 pub unload: Unload,
1000 #[serde(default, skip_serializing_if = "Option::is_none")]
1006 pub durable: Option<bool>,
1007 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1015 pub state: BTreeMap<String, StateDecl>,
1016 #[serde(default, skip_serializing_if = "Option::is_none")]
1017 pub description: Option<String>,
1018 #[serde(default = "default_true")]
1019 pub armed: bool,
1020 #[serde(default, skip_serializing_if = "Option::is_none")]
1021 pub inputs_schema: Option<Value>,
1022 #[serde(default)]
1023 pub concurrency: Concurrency,
1024 #[serde(default, skip_serializing_if = "Option::is_none")]
1033 pub key: Option<String>,
1034 #[serde(default, skip_serializing_if = "Option::is_none")]
1045 pub tool: Option<WorkflowTool>,
1046 #[serde(default)]
1047 pub limits: WorkflowLimits,
1048 #[serde(default, skip_serializing_if = "Option::is_none")]
1049 pub outputs_schema: Option<Value>,
1050 pub steps: BTreeMap<String, Step>,
1051 pub hash: String,
1055 pub definition: Value,
1057}
1058
1059fn default_true() -> bool {
1060 true
1061}
1062
1063#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1070#[serde(rename_all = "lowercase")]
1071pub enum UnloadPolicy {
1072 #[default]
1075 Drain,
1076 Cancel,
1078 Detach,
1080}
1081
1082impl UnloadPolicy {
1083 pub fn as_str(self) -> &'static str {
1084 match self {
1085 UnloadPolicy::Drain => "drain",
1086 UnloadPolicy::Cancel => "cancel",
1087 UnloadPolicy::Detach => "detach",
1088 }
1089 }
1090}
1091
1092#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
1094pub struct Unload {
1095 #[serde(default)]
1096 pub policy: UnloadPolicy,
1097 #[serde(default, skip_serializing_if = "Option::is_none")]
1099 pub timeout_ms: Option<u64>,
1100}
1101
1102#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1105#[serde(rename_all = "lowercase")]
1106pub enum Priority {
1107 Low,
1108 #[default]
1109 Normal,
1110 High,
1111}
1112
1113impl Priority {
1114 pub fn as_str(self) -> &'static str {
1115 match self {
1116 Priority::Low => "low",
1117 Priority::Normal => "normal",
1118 Priority::High => "high",
1119 }
1120 }
1121 pub fn from_spec(v: Option<&Value>) -> Result<Priority, String> {
1123 match v.and_then(Value::as_str) {
1124 None if v.is_none() => Ok(Priority::Normal),
1125 Some("low") => Ok(Priority::Low),
1126 Some("normal") => Ok(Priority::Normal),
1127 Some("high") => Ok(Priority::High),
1128 other => Err(format!(
1129 "priority must be low|normal|high, got {:?}",
1130 other
1131 .map(str::to_string)
1132 .unwrap_or_else(|| v.map(|x| x.to_string()).unwrap_or_default())
1133 )),
1134 }
1135 }
1136 pub fn nice(self) -> Option<i32> {
1140 match self {
1141 Priority::Low => Some(10),
1142 Priority::Normal => None,
1143 Priority::High => Some(-5),
1144 }
1145 }
1146}
1147
1148impl Workflow {
1149 pub fn start_steps(&self) -> Vec<&Step> {
1150 self.steps.values().filter(|s| s.is_start()).collect()
1151 }
1152 pub fn step(&self, id: &str) -> Option<&Step> {
1153 self.steps.get(id)
1154 }
1155 pub fn dependents(&self, id: &str) -> Vec<&Step> {
1157 self.steps
1158 .values()
1159 .filter(|s| s.depends_on.iter().any(|d| d == id))
1160 .collect()
1161 }
1162 pub fn is_long_lived(&self) -> bool {
1170 self.start_steps()
1171 .iter()
1172 .any(|s| is_long_lived_start(&s.kind))
1173 }
1174 pub fn topo_order(&self) -> Vec<String> {
1176 let mut out = Vec::new();
1177 let mut done: BTreeSet<String> = BTreeSet::new();
1178 let mut progress = true;
1179 while progress && out.len() < self.steps.len() {
1180 progress = false;
1181 for (id, s) in &self.steps {
1182 if done.contains(id) {
1183 continue;
1184 }
1185 if s.depends_on.iter().all(|d| done.contains(d)) {
1186 done.insert(id.clone());
1187 out.push(id.clone());
1188 progress = true;
1189 }
1190 }
1191 }
1192 out
1193 }
1194}
1195
1196fn json_kind(v: &Value) -> &'static str {
1198 match v {
1199 Value::Null => "null",
1200 Value::Bool(_) => "a boolean",
1201 Value::Number(_) => "a number",
1202 Value::String(_) => "a string",
1203 Value::Array(_) => "a list",
1204 Value::Object(_) => "an object",
1205 }
1206}
1207
1208pub const TOP: &[&str] = &[
1212 "name",
1213 "version",
1214 "description",
1215 "armed",
1216 "inputs",
1217 "concurrency",
1218 "limits",
1219 "outputs",
1220 "state",
1221 "steps",
1222 "file",
1223 "uri",
1224 "priority",
1225 "unload",
1226 "durable",
1227 "key",
1228 "tool",
1229];
1230
1231pub const ONE_SHOT_STARTS: &[&str] = &["once", "manual"];
1244
1245pub fn start_kinds() -> Vec<&'static str> {
1248 KINDS.iter().filter(|k| k.start).map(|k| k.name).collect()
1249}
1250
1251pub fn is_long_lived_start(kind: &str) -> bool {
1260 KINDS.iter().any(|k| k.start && k.name == kind) && !ONE_SHOT_STARTS.contains(&kind)
1261}
1262
1263pub fn parse_workflow(doc: &Value) -> Result<Workflow, Vec<String>> {
1265 let mut errs = Vec::new();
1266 let Some(obj) = doc.as_object() else {
1267 return Err(vec!["a workflow must be an object".into()]);
1268 };
1269 for key in obj.keys() {
1270 if !TOP.contains(&key.as_str()) {
1271 errs.push(format!("unknown workflow field {key:?}"));
1272 }
1273 }
1274 let name = obj
1275 .get("name")
1276 .and_then(Value::as_str)
1277 .unwrap_or("")
1278 .trim()
1279 .to_string();
1280 if !valid_id(&name) {
1281 errs.push(format!(
1282 "workflow name {name:?} must match [a-zA-Z_][a-zA-Z0-9_-]{{0,63}}"
1283 ));
1284 }
1285 let version = obj
1286 .get("version")
1287 .and_then(Value::as_u64)
1288 .unwrap_or(DIALECT as u64) as u32;
1289 if version != DIALECT {
1290 errs.push(format!(
1291 "workflow {name:?}: version {version} is not dialect 3 (dialect 1/2 documents are refused — see docs/workflows.md §migration)"
1292 ));
1293 }
1294 if obj.contains_key("start") || obj.contains_key("nodes") {
1295 errs.push(format!("workflow {name:?}: `start`/`nodes` are dialect 1/2 — use `steps` with start nodes (docs/workflows.md §migration)"));
1296 }
1297 let armed = obj.get("armed").and_then(Value::as_bool).unwrap_or(true);
1298 let priority = match Priority::from_spec(obj.get("priority")) {
1299 Ok(p) => p,
1300 Err(e) => {
1301 errs.push(format!("workflow {name:?}: {e}"));
1302 Priority::Normal
1303 }
1304 };
1305 let unload = match obj.get("unload") {
1306 None => Unload::default(),
1307 Some(u) => {
1308 let policy = match u.get("policy").and_then(Value::as_str) {
1309 None | Some("drain") => UnloadPolicy::Drain,
1310 Some("cancel") => UnloadPolicy::Cancel,
1311 Some("detach") => UnloadPolicy::Detach,
1312 Some(o) => {
1313 errs.push(format!(
1314 "workflow {name:?}: unload.policy {o:?} must be drain|cancel|detach"
1315 ));
1316 UnloadPolicy::Drain
1317 }
1318 };
1319 let timeout_ms = match u.get("timeout") {
1320 None => None,
1321 Some(t) => match t.as_str().map(crate::config::parse_duration) {
1322 Some(Ok(d)) => Some(d.as_millis() as u64),
1323 _ => {
1324 errs.push(format!(
1325 "workflow {name:?}: unload.timeout must be a duration (\"60s\")"
1326 ));
1327 None
1328 }
1329 },
1330 };
1331 if let Some(o) = u.as_object()
1332 && o.keys()
1333 .any(|k| !matches!(k.as_str(), "policy" | "timeout"))
1334 {
1335 errs.push(format!(
1336 "workflow {name:?}: unload takes {{policy, timeout}}"
1337 ));
1338 }
1339 Unload { policy, timeout_ms }
1340 }
1341 };
1342 let inputs_schema = match obj.get("inputs") {
1343 None => None,
1344 Some(v) => {
1345 let schema = v.get("schema").cloned().or_else(|| {
1346 v.as_object()
1347 .filter(|m| m.contains_key("type") || m.contains_key("properties"))
1348 .map(|_| v.clone())
1349 });
1350 match schema {
1351 Some(s) => {
1352 if let Err(e) = jsonschema::check_schema(&s) {
1353 errs.push(format!(
1354 "workflow {name:?}: inputs.schema: {}",
1355 e.join("; ")
1356 ));
1357 }
1358 Some(s)
1359 }
1360 None => {
1361 errs.push(format!("workflow {name:?}: inputs must be {{schema: …}}"));
1362 None
1363 }
1364 }
1365 }
1366 };
1367 let outputs_schema = obj.get("outputs").and_then(|v| v.get("schema").cloned());
1368 if let Some(s) = &outputs_schema
1369 && let Err(e) = jsonschema::check_schema(s)
1370 {
1371 errs.push(format!(
1372 "workflow {name:?}: outputs.schema: {}",
1373 e.join("; ")
1374 ));
1375 }
1376 let concurrency = match obj.get("concurrency") {
1377 None => Concurrency::default(),
1378 Some(v) => Concurrency {
1379 max_runs: v
1380 .get("max_runs")
1381 .and_then(Value::as_u64)
1382 .unwrap_or(4)
1383 .clamp(1, 1024) as u32,
1384 on_overflow: match v.get("on_overflow").and_then(Value::as_str) {
1385 None | Some("queue") => OnOverflow::Queue,
1386 Some("drop") => OnOverflow::Drop,
1387 Some("replace") => OnOverflow::Replace,
1388 Some(o) => {
1389 errs.push(format!("workflow {name:?}: concurrency.on_overflow {o:?} must be queue|drop|replace"));
1390 OnOverflow::Queue
1391 }
1392 },
1393 scope: match v.get("scope").and_then(Value::as_str) {
1394 None | Some("workflow") => ConcurrencyScope::Workflow,
1395 Some("key") => ConcurrencyScope::Key,
1396 Some(o) => {
1397 errs.push(format!(
1398 "workflow {name:?}: concurrency.scope {o:?} must be workflow|key"
1399 ));
1400 ConcurrencyScope::Workflow
1401 }
1402 },
1403 },
1404 };
1405 let key = obj
1409 .get("key")
1410 .and_then(Value::as_str)
1411 .map(str::to_string)
1412 .filter(|k| !k.trim().is_empty());
1413 let tool = match obj.get("tool") {
1414 None => None,
1415 Some(v) => {
1416 let tname = v
1417 .get("name")
1418 .and_then(Value::as_str)
1419 .unwrap_or("")
1420 .trim()
1421 .to_string();
1422 if tname.is_empty() {
1423 errs.push(format!("workflow {name:?}: tool.name is required"));
1424 } else if crate::registry::internal::contracts()
1425 .iter()
1426 .any(|c| c.name == tname)
1427 {
1428 errs.push(format!(
1432 "workflow {name:?}: tool.name {tname:?} shadows an internal contract"
1433 ));
1434 }
1435 let mode = match v.get("mode").and_then(Value::as_str) {
1436 None | Some("sync") => WorkflowToolMode::Sync,
1437 Some("async") => WorkflowToolMode::Async,
1438 Some(o) => {
1439 errs.push(format!(
1440 "workflow {name:?}: tool.mode {o:?} must be sync|async"
1441 ));
1442 WorkflowToolMode::Sync
1443 }
1444 };
1445 let g = v.get("grant");
1446 let flag = |k: &str, dflt: bool| {
1447 g.and_then(|g| g.get(k))
1448 .and_then(Value::as_bool)
1449 .unwrap_or(dflt)
1450 };
1451 let dflt = WorkflowToolGrant::default();
1452 Some(WorkflowTool {
1453 name: tname,
1454 mode,
1455 grant: WorkflowToolGrant {
1456 root: flag("root", dflt.root),
1457 workflows: flag("workflows", dflt.workflows),
1458 subagents: flag("subagents", dflt.subagents),
1459 user: flag("user", dflt.user),
1460 agent: flag("agent", dflt.agent),
1461 },
1462 })
1463 }
1464 };
1465 if concurrency.scope == ConcurrencyScope::Key && key.is_none() {
1466 errs.push(format!(
1467 "workflow {name:?}: concurrency.scope: key needs a `key:` template naming what a run is about"
1468 ));
1469 }
1470 let limits = match obj.get("limits") {
1471 None => WorkflowLimits::default(),
1472 Some(v) => WorkflowLimits {
1473 steps: v.get("steps").and_then(Value::as_u64).map(|x| x as u32),
1474 tokens: v.get("tokens").and_then(Value::as_u64),
1475 deadline_ms: match v.get("deadline") {
1476 None => None,
1477 Some(d) => match duration_ms(d) {
1478 Ok(ms) => Some(ms),
1479 Err(e) => {
1480 errs.push(format!("workflow {name:?}: limits.deadline: {e}"));
1481 None
1482 }
1483 },
1484 },
1485 budget: v.get("budget").cloned(),
1486 },
1487 };
1488 let mut steps: BTreeMap<String, Step> = BTreeMap::new();
1490 match obj.get("steps").and_then(Value::as_object) {
1491 None => errs.push(format!(
1492 "workflow {name:?}: `steps` (an object of steps) is required"
1493 )),
1494 Some(map) => {
1495 if map.len() > MAX_STEPS {
1496 errs.push(format!(
1497 "workflow {name:?}: {} steps exceed the cap of {MAX_STEPS}",
1498 map.len()
1499 ));
1500 }
1501 for (id, sv) in map {
1502 if let Some(step) = parse_step(&name, id, sv, 0, &mut errs) {
1503 steps.insert(id.clone(), step);
1504 }
1505 }
1506 }
1507 }
1508 if !errs.is_empty() {
1509 return Err(errs);
1510 }
1511 let mut state: BTreeMap<String, StateDecl> = BTreeMap::new();
1513 if let Some(decls) = obj.get("state") {
1514 match decls.as_object() {
1515 None => errs.push("state must be an object of {key: {schema, reducer}}".into()),
1516 Some(map) => {
1517 for (key, decl) in map {
1518 let Some(d) = decl.as_object() else {
1519 errs.push(format!("state {key:?}: must be an object"));
1520 continue;
1521 };
1522 for f in d.keys() {
1523 if !matches!(f.as_str(), "schema" | "reducer") {
1524 errs.push(format!(
1525 "state {key:?}: unknown field {f:?} (allowed: schema, reducer)"
1526 ));
1527 }
1528 }
1529 let schema = d.get("schema").cloned();
1530 if let Some(sc) = &schema
1531 && let Err(e) = jsonschema::check_schema(sc)
1532 {
1533 errs.push(format!("state {key:?}: schema: {}", e.join("; ")));
1534 }
1535 let reducer = d.get("reducer").and_then(Value::as_str).map(str::to_string);
1536 if let Some(r) = &reducer
1537 && !matches!(r.as_str(), "overwrite" | "append" | "merge" | "union")
1538 {
1539 errs.push(format!(
1540 "state {key:?}: reducer {r:?} must be overwrite|append|merge|union"
1541 ));
1542 }
1543 state.insert(key.clone(), StateDecl { schema, reducer });
1544 }
1545 }
1546 }
1547 }
1548 let durable = match obj.get("durable") {
1549 None => None,
1550 Some(Value::Bool(b)) => Some(*b),
1551 Some(other) => {
1552 errs.push(format!("workflow durable must be a boolean (got {other})"));
1553 None
1554 }
1555 };
1556 let mut wf = Workflow {
1557 state,
1558 name,
1559 version,
1560 priority,
1561 unload,
1562 durable,
1563 description: obj
1564 .get("description")
1565 .and_then(Value::as_str)
1566 .map(str::to_string),
1567 armed,
1568 inputs_schema,
1569 concurrency,
1570 key,
1571 tool,
1572 limits,
1573 outputs_schema,
1574 steps,
1575 hash: String::new(),
1576 definition: doc.clone(),
1577 };
1578 validate_graph(&wf, &mut errs);
1579 if !errs.is_empty() {
1580 return Err(errs);
1581 }
1582 wf.hash = crate::sha::sha256_hex(canonical(doc).as_bytes());
1583 Ok(wf)
1584}
1585
1586fn parse_step(
1587 wf: &str,
1588 id: &str,
1589 sv: &Value,
1590 depth: usize,
1591 errs: &mut Vec<String>,
1592) -> Option<Step> {
1593 let at = format!("workflow {wf:?} step {id:?}");
1594 if !valid_id(id) {
1595 errs.push(format!(
1596 "{at}: id must match [a-zA-Z_][a-zA-Z0-9_-]{{0,63}}"
1597 ));
1598 }
1599 let Some(o) = sv.as_object() else {
1600 errs.push(format!("{at}: must be an object"));
1601 return None;
1602 };
1603 let kind = match o.get("kind").and_then(Value::as_str) {
1604 Some(k) => k.to_string(),
1605 None => {
1606 errs.push(format!("{at}: `kind` is required"));
1607 return None;
1608 }
1609 };
1610 let Some(info) = kind_info(&kind) else {
1611 errs.push(format!(
1612 "{at}: unknown kind {kind:?} (run `agentd --workflow-schema` for the kind catalogue)"
1613 ));
1614 return None;
1615 };
1616 let mut spec = Map::new();
1618 for (key, v) in o {
1619 if info.fields.contains(&key.as_str()) {
1629 spec.insert(key.clone(), v.clone());
1630 } else if COMMON_FIELDS.contains(&key.as_str()) {
1631 continue;
1632 } else {
1633 errs.push(format!(
1634 "{at}: unknown field {key:?} for kind {kind:?} (allowed: {})",
1635 info.fields.join(", ")
1636 ));
1637 }
1638 }
1639 for req in info.required {
1640 if !spec.contains_key(*req) {
1641 errs.push(format!("{at}: kind {kind:?} requires field {req:?}"));
1642 }
1643 }
1644 if !info.implemented {
1645 errs.push(format!(
1646 "{at}: kind {kind:?} is not available in this build; implemented kinds: {}",
1647 implemented_kinds().join(", ")
1648 ));
1649 }
1650 let mut body: Option<Body> = None;
1652 let mut branches: BTreeMap<String, Body> = BTreeMap::new();
1653 if info.nested {
1654 if depth + 1 > MAX_NESTING {
1655 errs.push(format!("{at}: nesting exceeds {MAX_NESTING}"));
1656 }
1657 if matches!(kind.as_str(), "parallel" | "race") {
1658 match spec.get("branches").and_then(Value::as_object) {
1659 Some(bm) if !bm.is_empty() => {
1660 for (bname, bv) in bm {
1661 if !valid_id(bname) {
1662 errs.push(format!("{at}: branch name {bname:?} must match [a-zA-Z_][a-zA-Z0-9_-]{{0,63}}"));
1663 }
1664 if let Some(b) =
1665 parse_body(&format!("{wf}/{id}/{bname}"), bv, depth + 1, errs)
1666 {
1667 branches.insert(bname.clone(), b);
1668 }
1669 }
1670 }
1671 _ => errs.push(format!(
1672 "{at}: branches must be a non-empty object of {{steps: {{…}}}} bodies"
1673 )),
1674 }
1675 } else {
1676 match spec.get("body") {
1677 Some(bv) => body = parse_body(&format!("{wf}/{id}"), bv, depth + 1, errs),
1678 None => errs.push(format!("{at}: body is required")),
1679 }
1680 }
1681 }
1682 let depends_on: Vec<String> = match o.get("depends_on") {
1683 None => Vec::new(),
1684 Some(Value::Array(a)) => a
1685 .iter()
1686 .filter_map(Value::as_str)
1687 .map(str::to_string)
1688 .collect(),
1689 Some(Value::String(s)) => vec![s.clone()],
1690 Some(_) => {
1691 errs.push(format!("{at}: depends_on must be a list of step ids"));
1692 Vec::new()
1693 }
1694 };
1695 if info.start && !depends_on.is_empty() {
1696 errs.push(format!("{at}: a start node cannot depend on other steps"));
1697 }
1698 let when = o.get("when").and_then(Value::as_str).map(str::to_string);
1699 if let Some(w) = &when {
1700 let expr = w.trim().trim_start_matches("CEL:").trim();
1701 if let Err(e) = crate::cel::compile_check(expr) {
1702 errs.push(format!("{at}: when: {e}"));
1703 }
1704 }
1705 let retry = o.get("retry").map(|r| Retry {
1706 max: r.get("max").and_then(Value::as_u64).unwrap_or(0).min(20) as u32,
1707 backoff_ms: match r.get("backoff") {
1708 None => 0,
1709 Some(b) => duration_ms(b).unwrap_or_else(|e| {
1710 errs.push(format!("{at}: retry.backoff: {e}"));
1711 0
1712 }),
1713 },
1714 });
1715 let timeout_ms = match o.get("timeout") {
1716 None => None,
1717 Some(t) => match duration_ms(t) {
1718 Ok(ms) => Some(ms),
1719 Err(e) => {
1720 errs.push(format!("{at}: timeout: {e}"));
1721 None
1722 }
1723 },
1724 };
1725 let on_error = match o.get("on_error") {
1726 None => OnError::Fail,
1727 Some(v) => OnError::parse(v).unwrap_or_else(|e| {
1728 errs.push(format!("{at}: {e}"));
1729 OnError::Fail
1730 }),
1731 };
1732 let on_replay = match o.get("on_replay").and_then(Value::as_str) {
1733 None | Some("retry") => OnReplay::Retry,
1734 Some("skip") => OnReplay::Skip,
1735 Some("fail") => OnReplay::Fail,
1736 Some(x) => {
1737 errs.push(format!("{at}: on_replay {x:?} must be retry|skip|fail"));
1738 OnReplay::Retry
1739 }
1740 };
1741 let output_schema = o.get("output_schema").cloned();
1742 if let Some(s) = &output_schema
1743 && let Err(e) = jsonschema::check_schema(s)
1744 {
1745 errs.push(format!("{at}: output_schema: {}", e.join("; ")));
1746 }
1747 if let Some(idem) = spec.get("idempotency") {
1752 match kind.as_str() {
1753 "http" => {
1754 let ok = idem.as_object().is_some_and(|o| {
1755 let hdr = o.get("header").map(|v| v.is_string());
1756 let qry = o.get("query").map(|v| v.is_string());
1757 let val = o.get("value").is_none_or(|v| v.is_string());
1758 let known = o
1759 .keys()
1760 .all(|k| matches!(k.as_str(), "header" | "query" | "value"));
1761 known && val && matches!((hdr, qry), (Some(true), None) | (None, Some(true)))
1762 });
1763 if !ok {
1764 errs.push(format!(
1765 "{at}: http idempotency takes {{header: NAME}} or {{query: NAME}} \
1766 (exactly one), with an optional string value"
1767 ));
1768 }
1769 }
1770 "mcp.tool" | "a2a.send" | "a2a.delegate" => {
1771 let ok = idem.is_boolean()
1772 || idem.as_object().is_some_and(|o| {
1773 o.keys().all(|k| k == "value")
1774 && o.get("value").is_none_or(|v| v.is_string())
1775 });
1776 if !ok {
1777 errs.push(format!(
1778 "{at}: idempotency takes true or {{value: \"…\"}} on this kind"
1779 ));
1780 }
1781 }
1782 _ => {}
1783 }
1784 }
1785 if let Some(b) = spec.get("breaker") {
1789 if !matches!(
1790 kind.as_str(),
1791 "http" | "mcp.tool" | "a2a.send" | "a2a.delegate"
1792 ) {
1793 errs.push(format!(
1794 "{at}: breaker applies to remote-effect kinds (http, mcp.tool, a2a.send, a2a.delegate)"
1795 ));
1796 } else {
1797 let ok = b.as_object().is_some_and(|o| {
1798 o.keys()
1799 .all(|k| matches!(k.as_str(), "failures" | "cooldown"))
1800 && o.get("failures")
1801 .and_then(Value::as_u64)
1802 .is_some_and(|n| n >= 1)
1803 && o.get("cooldown")
1804 .and_then(Value::as_str)
1805 .is_some_and(|d| crate::config::parse_duration(d).is_ok())
1806 });
1807 if !ok {
1808 errs.push(format!(
1809 "{at}: breaker takes {{failures: N>=1, cooldown: \"60s\"}}"
1810 ));
1811 }
1812 }
1813 }
1814 if let Some(r) = spec.get("rate")
1818 && matches!(
1819 kind.as_str(),
1820 "http" | "mcp.tool" | "a2a.send" | "a2a.delegate"
1821 )
1822 {
1823 let ok = r
1824 .as_str()
1825 .is_some_and(|r| crate::supervisor::tree::parse_rate(r).is_ok());
1826 if !ok {
1827 errs.push(format!(
1828 "{at}: rate must be \"<burst>/<per>s\" (e.g. \"10/1s\")"
1829 ));
1830 }
1831 }
1832 match kind.as_str() {
1834 "webhook" => {
1838 if let Some(r) = spec.get("rate") {
1839 let ok = r.as_str().is_some_and(|r| {
1840 r.split_once('/').is_some_and(|(b, p)| {
1841 let per = p.trim();
1842 let per = per
1843 .strip_suffix('s')
1844 .or_else(|| per.strip_suffix("sec"))
1845 .unwrap_or(per);
1846 b.trim().parse::<u32>().is_ok_and(|b| b > 0)
1847 && per.trim().parse::<f64>().is_ok_and(|s| s > 0.0)
1848 })
1849 });
1850 if !ok {
1851 errs.push(format!(
1852 "{at}: rate must be \"<burst>/<per>s\" (e.g. \"20/1s\")"
1853 ));
1854 }
1855 }
1856 }
1857 "a2a.delegate" => {
1859 if spec.get("objective").is_none() && spec.get("command").is_none() {
1860 errs.push(format!(
1861 "{at}: needs `objective` (prose) or `command` (typed)"
1862 ));
1863 }
1864 if spec.get("args").is_some() && spec.get("command").is_none() {
1865 errs.push(format!("{at}: `args` needs `command`"));
1866 }
1867 }
1868 "a2a.send" => {
1869 if spec.get("args").is_some() && spec.get("command").is_none() {
1870 errs.push(format!("{at}: `args` needs `command`"));
1871 }
1872 }
1873 "subagent" => {
1877 match (spec.get("instruction").is_some(), spec.get("template").is_some()) {
1878 (false, false) => errs.push(format!(
1879 "{at}: needs `instruction` (freeform) or `template` (a subagents.templates entry)"
1880 )),
1881 (true, true) => errs.push(format!(
1882 "{at}: `instruction` and `template` are mutually exclusive"
1883 )),
1884 _ => {}
1885 }
1886 if spec.get("params").is_some() && spec.get("template").is_none() {
1887 errs.push(format!("{at}: `params` needs `template`"));
1888 }
1889 for k in ["tools", "servers"] {
1890 if spec.get(k).is_some() && spec.get("template").is_some() {
1891 errs.push(format!(
1892 "{at}: `{k}` may not be combined with `template` — the template defines the grant"
1893 ));
1894 }
1895 }
1896 }
1897 "emit" => {
1902 if spec.get("stream").is_some() != spec.get("subject").is_some() {
1903 errs.push(format!(
1904 "{at}: a stream emit needs both `stream` and `subject`"
1905 ));
1906 }
1907 }
1908 "stream" => {
1910 if let Some(f) = spec.get("from")
1911 && !matches!(f.as_str(), Some("new") | Some("earliest"))
1912 {
1913 errs.push(format!("{at}: from must be \"new\" or \"earliest\""));
1914 }
1915 }
1916 "subscribe" => {
1922 if let Some(w) = spec.get("window") {
1923 let ok = w.as_object().is_some_and(|o| {
1924 o.keys().all(|k| k == "samples")
1925 && o.get("samples")
1926 .and_then(Value::as_u64)
1927 .is_some_and(|n| (1..=256).contains(&n))
1928 });
1929 if !ok {
1930 errs.push(format!("{at}: window takes {{samples: 1..=256}}"));
1931 }
1932 }
1933 }
1934 "switch" => {
1942 if let Some(cases) = spec.get("cases").and_then(Value::as_object) {
1943 for (case, target) in cases {
1944 if !target.is_string() {
1945 errs.push(format!(
1946 "{at}: switch case {case:?} must name ONE step as a string \
1947 (got {}); write `{case}: some_step`, not a list",
1948 json_kind(target)
1949 ));
1950 }
1951 }
1952 }
1953 if let Some(m) = spec.get("on_no_match")
1954 && !matches!(m.as_str(), Some("skip") | Some("fail"))
1955 {
1956 errs.push(format!("{at}: on_no_match must be \"skip\" or \"fail\""));
1957 }
1958 if let Some(d) = spec.get("default")
1959 && !d.is_string()
1960 {
1961 errs.push(format!(
1962 "{at}: switch default must name ONE step as a string (got {}); \
1963 write `default: some_step`, not a list",
1964 json_kind(d)
1965 ));
1966 }
1967 }
1968 "foreach" | "batch" | "iterate" | "parallel" | "race" | "subgraph"
1973 if spec.contains_key("collect") =>
1974 {
1975 if let Some(m) = spec
1976 .get("collect")
1977 .and_then(|c| c.get("mode"))
1978 .and_then(Value::as_str)
1979 && !matches!(m, "overwrite" | "append" | "merge" | "union")
1980 {
1981 errs.push(format!(
1982 "{at}: collect.mode {m:?} must be overwrite|append|merge|union"
1983 ));
1984 }
1985 }
1986 "human" => {
1991 if spec.contains_key("reply_uri") {
1992 errs.push(format!(
1993 "{at}: human.reply_uri is not implemented — a gate is answered over A2A; \
1994 use `to` to name who must answer (see docs/node-registry.md)"
1995 ));
1996 }
1997 if let Some(v) = spec.get("to")
2000 && let Err(e) = crate::a2a::principals::Addressee::parse(v)
2001 {
2002 errs.push(format!("{at}: human.to: {e}"));
2003 }
2004 }
2005 "finish" => {
2006 if let Some(st) = spec.get("status").and_then(Value::as_str)
2007 && !matches!(st, "completed" | "failed" | "refused" | "cancelled")
2008 {
2009 errs.push(format!(
2010 "{at}: finish.status must be completed|failed|refused|cancelled"
2011 ));
2012 }
2013 }
2014 "sleep" => {
2015 if let Some(d) = spec.get("duration")
2016 && let Err(e) = duration_ms(d)
2017 {
2018 errs.push(format!("{at}: sleep.duration: {e}"));
2019 }
2020 }
2021 "assert" => {
2022 if let Some(c) = spec.get("condition").and_then(Value::as_str)
2023 && let Err(e) =
2024 crate::cel::compile_check(c.trim().trim_start_matches("CEL:").trim())
2025 {
2026 errs.push(format!("{at}: assert.condition: {e}"));
2027 }
2028 }
2029 "think" | "agent" => {
2030 if let Some(s) = spec.get("output_schema")
2031 && let Err(e) = jsonschema::check_schema(s)
2032 {
2033 errs.push(format!("{at}: output_schema: {}", e.join("; ")));
2034 }
2035 }
2036 "validate" => {
2037 if let Some(s) = spec.get("schema")
2038 && let Err(e) = jsonschema::check_schema(s)
2039 {
2040 errs.push(format!("{at}: schema: {}", e.join("; ")));
2041 }
2042 }
2043 "assign" | "transform" => {
2044 if let Some(m) = spec.get("mode").and_then(Value::as_str)
2045 && !matches!(m, "overwrite" | "append" | "merge" | "union")
2046 {
2047 errs.push(format!("{at}: mode must be overwrite|append|merge|union"));
2048 }
2049 }
2050 _ => {}
2051 }
2052 for (key, v) in &spec {
2054 if let Some(s) = v.as_str()
2055 && let Some(expr) = s.trim().strip_prefix("CEL:")
2056 && let Err(e) = crate::cel::compile_check(expr.trim())
2057 {
2058 errs.push(format!("{at}: {key}: {e}"));
2059 }
2060 }
2061 Some(Step {
2062 id: id.to_string(),
2063 kind,
2064 depends_on,
2065 when,
2066 retry,
2067 timeout_ms,
2068 on_error,
2069 idempotent: o
2070 .get("idempotent")
2071 .and_then(Value::as_bool)
2072 .unwrap_or(false),
2073 on_replay,
2074 output_schema,
2075 cache: o.get("cache").cloned(),
2076 budget: o.get("budget").and_then(Value::as_u64),
2077 skills: o
2078 .get("skills")
2079 .and_then(Value::as_array)
2080 .map(|a| {
2081 a.iter()
2082 .filter_map(Value::as_str)
2083 .map(str::to_string)
2084 .collect()
2085 })
2086 .unwrap_or_default(),
2087 description: o
2088 .get("description")
2089 .and_then(Value::as_str)
2090 .map(str::to_string),
2091 spec,
2092 body,
2093 branches,
2094 })
2095}
2096
2097fn parse_body(at: &str, bv: &Value, depth: usize, errs: &mut Vec<String>) -> Option<Body> {
2099 let Some(bs) = bv.get("steps").and_then(Value::as_object) else {
2100 errs.push(format!("{at}: body must be {{steps: {{…}}}}"));
2101 return None;
2102 };
2103 if bs.is_empty() {
2104 errs.push(format!("{at}: body has no steps"));
2105 return None;
2106 }
2107 let mut steps = BTreeMap::new();
2108 for (bid, sv) in bs {
2109 if let Some(step) = parse_step(at, bid, sv, depth, errs) {
2110 if step.is_start() {
2111 errs.push(format!(
2112 "{at} step {bid:?}: a start node cannot be inside a body"
2113 ));
2114 }
2115 if step.kind == "finish" {
2116 errs.push(format!("{at} step {bid:?}: `finish` cannot be inside a body (a body's sinks are its result)"));
2117 }
2118 steps.insert(bid.clone(), step);
2119 }
2120 }
2121 let body = Body { steps };
2122 for s in body.steps.values() {
2123 for d in &s.depends_on {
2124 if !body.steps.contains_key(d) {
2125 errs.push(format!(
2126 "{at} step {:?}: depends_on names {d:?}, which is not a sibling in the body",
2127 s.id
2128 ));
2129 }
2130 }
2131 if let OnError::Goto(t) = &s.on_error
2132 && !body.steps.contains_key(t)
2133 {
2134 errs.push(format!(
2135 "{at} step {:?}: on_error goto {t:?} is not a sibling in the body",
2136 s.id
2137 ));
2138 }
2139 }
2140 if body.topo_order().len() != body.steps.len() {
2141 errs.push(format!("{at}: cycle inside the body"));
2142 }
2143 Some(body)
2144}
2145
2146fn validate_declared_state(wf: &Workflow, errs: &mut Vec<String>) {
2158 for s in wf.steps.values() {
2159 if !matches!(s.kind.as_str(), "assign" | "transform") {
2160 continue;
2161 }
2162 let key = s
2163 .spec
2164 .get("writes")
2165 .and_then(Value::as_str)
2166 .unwrap_or(s.id.as_str());
2167 let Some(decl) = wf.state.get(key) else {
2168 continue;
2169 };
2170 if let Some(want) = &decl.reducer {
2174 let mode = s
2175 .spec
2176 .get("mode")
2177 .and_then(Value::as_str)
2178 .unwrap_or("overwrite");
2179 if mode != want {
2180 errs.push(format!(
2181 "workflow {:?} step {:?}: writes {key:?} with mode {mode:?}, but state \
2182 declares reducer {want:?}",
2183 wf.name, s.id
2184 ));
2185 }
2186 }
2187 }
2188}
2189
2190fn validate_concurrent_writes(wf: &Workflow, errs: &mut Vec<String>) {
2191 use std::collections::BTreeMap;
2192 let mut writers: BTreeMap<&str, Vec<(&str, &str)>> = BTreeMap::new();
2193 for s in wf.steps.values() {
2194 if !matches!(s.kind.as_str(), "assign" | "transform") {
2195 continue;
2196 }
2197 let key = s
2198 .spec
2199 .get("writes")
2200 .and_then(Value::as_str)
2201 .unwrap_or(s.id.as_str());
2202 let mode = s
2203 .spec
2204 .get("mode")
2205 .and_then(Value::as_str)
2206 .unwrap_or("overwrite");
2207 writers.entry(key).or_default().push((s.id.as_str(), mode));
2208 }
2209 for (key, ws) in writers {
2210 if ws.len() < 2 {
2211 continue;
2212 }
2213 for (i, (a, ma)) in ws.iter().enumerate() {
2215 for (b, mb) in ws.iter().skip(i + 1) {
2216 if reachable(wf, a, b) || reachable(wf, b, a) {
2217 continue;
2218 }
2219 if exclusive_by_switch(wf, a, b) {
2224 continue;
2225 }
2226 if wf
2230 .state
2231 .get(key)
2232 .and_then(|d| d.reducer.as_deref())
2233 .is_some()
2234 {
2235 continue;
2236 }
2237 if *ma == "overwrite" || *mb == "overwrite" {
2240 errs.push(format!(
2241 "workflow {:?}: steps {a:?} and {b:?} can run concurrently and both \
2242 write {key:?} (modes {ma}/{mb}) — the surviving value would depend on \
2243 completion order; order them with depends_on, or use append/merge",
2244 wf.name
2245 ));
2246 }
2247 }
2248 }
2249 }
2250}
2251
2252fn exclusive_by_switch(wf: &Workflow, a: &str, b: &str) -> bool {
2254 for s in wf.steps.values() {
2255 if s.kind != "switch" {
2256 continue;
2257 }
2258 let mut arms: Vec<&str> = s
2259 .spec
2260 .get("cases")
2261 .and_then(Value::as_object)
2262 .map(|c| c.values().filter_map(Value::as_str).collect())
2263 .unwrap_or_default();
2264 if let Some(d) = s.spec.get("default").and_then(Value::as_str) {
2265 arms.push(d);
2266 }
2267 let on_arm = |x: &str| arms.iter().any(|arm| *arm == x || reachable(wf, arm, x));
2270 if on_arm(a) && on_arm(b) {
2271 return true;
2272 }
2273 }
2274 false
2275}
2276
2277fn reachable(wf: &Workflow, from: &str, to: &str) -> bool {
2279 let mut seen = std::collections::BTreeSet::new();
2280 let mut stack = vec![to];
2281 while let Some(cur) = stack.pop() {
2283 if cur == from {
2284 return true;
2285 }
2286 if !seen.insert(cur.to_string()) {
2287 continue;
2288 }
2289 if let Some(s) = wf.steps.get(cur) {
2290 for d in &s.depends_on {
2291 stack.push(d.as_str());
2292 }
2293 }
2294 }
2295 false
2296}
2297
2298fn validate_human_in_concurrent_bodies(wf: &Workflow, errs: &mut Vec<String>) {
2307 fn walk(wf_name: &str, owner: &str, body: &Body, errs: &mut Vec<String>) {
2308 for s in body.steps.values() {
2309 if s.kind == "human" {
2310 errs.push(format!(
2311 "workflow {wf_name:?} step {:?}: a `human` gate inside {owner:?} is not \
2312 supported — only one gate can be live per run, so a second item would \
2313 wait forever. Gate before or after the fan-out instead.",
2314 s.id
2315 ));
2316 }
2317 for nested in s.body.iter().chain(s.branches.values()) {
2318 walk(wf_name, owner, nested, errs);
2319 }
2320 }
2321 }
2322 for s in wf.steps.values() {
2323 if !matches!(s.kind.as_str(), "foreach" | "batch" | "parallel" | "race") {
2324 continue;
2325 }
2326 for body in s.body.iter().chain(s.branches.values()) {
2327 walk(&wf.name, &s.id, body, errs);
2328 }
2329 }
2330}
2331
2332fn validate_graph(wf: &Workflow, errs: &mut Vec<String>) {
2333 validate_human_in_concurrent_bodies(wf, errs);
2334 validate_declared_state(wf, errs);
2335 validate_concurrent_writes(wf, errs);
2336 let name = &wf.name;
2337 let starts: Vec<&Step> = wf.start_steps();
2338 if starts.is_empty() {
2339 errs.push(format!("workflow {name:?}: at least one start node is required (once|manual|loop|schedule|subscribe|signal|event|a2a)"));
2340 }
2341 for s in wf.steps.values() {
2343 for d in &s.depends_on {
2344 if !wf.steps.contains_key(d) {
2345 errs.push(format!(
2346 "workflow {name:?} step {:?}: depends_on names unknown step {d:?}",
2347 s.id
2348 ));
2349 }
2350 if d == &s.id {
2351 errs.push(format!(
2352 "workflow {name:?} step {:?}: depends on itself",
2353 s.id
2354 ));
2355 }
2356 }
2357 if let OnError::Goto(t) = &s.on_error
2358 && !wf.steps.contains_key(t)
2359 {
2360 errs.push(format!(
2361 "workflow {name:?} step {:?}: on_error goto names unknown step {t:?}",
2362 s.id
2363 ));
2364 }
2365 if let Some(t) = s.field_str("on_timeout")
2366 && !wf.steps.contains_key(t)
2367 {
2368 errs.push(format!(
2369 "workflow {name:?} step {:?}: on_timeout names unknown step {t:?}",
2370 s.id
2371 ));
2372 }
2373 }
2374 let timeout_targets: BTreeSet<String> = wf
2379 .steps
2380 .values()
2381 .filter_map(|s| s.field_str("on_timeout").map(str::to_string))
2382 .collect();
2383 for s in wf.steps.values() {
2385 if !s.is_start() && s.depends_on.is_empty() && !timeout_targets.contains(&s.id) {
2386 errs.push(format!("workflow {name:?} step {:?}: a non-start step must depend on something (unreachable root)", s.id));
2387 }
2388 }
2389 let order = wf.topo_order();
2391 if order.len() != wf.steps.len() {
2392 let stuck: Vec<&String> = wf.steps.keys().filter(|k| !order.contains(k)).collect();
2393 errs.push(format!("workflow {name:?}: cycle among steps {stuck:?}"));
2394 }
2395 let mut reachable: BTreeSet<String> = starts.iter().map(|s| s.id.clone()).collect();
2396 let mut changed = true;
2397 while changed {
2398 changed = false;
2399 for s in wf.steps.values() {
2400 if !reachable.contains(&s.id)
2401 && !s.depends_on.is_empty()
2402 && s.depends_on.iter().any(|d| reachable.contains(d))
2403 {
2404 reachable.insert(s.id.clone());
2405 changed = true;
2406 }
2407 if reachable.contains(&s.id)
2409 && let Some(t) = s.field_str("on_timeout")
2410 && !reachable.contains(t)
2411 {
2412 reachable.insert(t.to_string());
2413 changed = true;
2414 }
2415 }
2416 }
2417 for s in wf.steps.values() {
2418 if !reachable.contains(&s.id) {
2419 errs.push(format!(
2420 "workflow {name:?} step {:?}: not reachable from any start node",
2421 s.id
2422 ));
2423 }
2424 }
2425 if !wf.steps.values().any(|s| s.kind == "finish") {
2426 errs.push(format!("workflow {name:?}: a `finish` step is required"));
2427 }
2428}
2429
2430pub fn valid_id(s: &str) -> bool {
2432 let mut chars = s.chars();
2433 match chars.next() {
2434 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
2435 _ => return false,
2436 }
2437 s.len() <= MAX_ID_LEN && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
2438}
2439
2440pub const RAW_FIELDS: &[(&str, &str)] = &[
2443 ("assert", "condition"),
2444 ("validate", "schema"),
2445 ("map", "expr"),
2446 ("filter", "expr"),
2447 ("reduce", "expr"),
2448 ("iterate", "while"),
2449 ("iterate", "until"),
2450 ("iterate", "body"),
2451 ("foreach", "body"),
2452 ("batch", "body"),
2453 ("subgraph", "body"),
2454 ("parallel", "branches"),
2455 ("race", "branches"),
2456 ("subscribe", "filter"),
2457 ("signal", "filter"),
2458 ("event", "filter"),
2459 ("wait", "condition"),
2460 ("wait", "match"),
2464 ("think", "check"),
2465 ("switch", "cases"),
2466 ("await", "condition"),
2467];
2468
2469pub fn is_raw_field(kind: &str, field: &str) -> bool {
2470 RAW_FIELDS.iter().any(|(k, f)| *k == kind && *f == field)
2471}
2472
2473pub fn duration_ms_opt(v: &Value) -> Option<u64> {
2475 duration_ms(v).ok()
2476}
2477
2478pub fn duration_ms(v: &Value) -> Result<u64, String> {
2480 match v {
2481 Value::Number(n) => n
2482 .as_u64()
2483 .map(|s| s * 1000)
2484 .ok_or_else(|| "duration must be a non-negative number of seconds".into()),
2485 Value::String(s) => crate::config::parse_duration(s).map(|d| d.as_millis() as u64),
2486 Value::Object(o) => o
2487 .get("ms")
2488 .and_then(Value::as_u64)
2489 .ok_or_else(|| "duration object must be {ms: n}".into()),
2490 _ => Err("duration must be a string like 30s or a number of seconds".into()),
2491 }
2492}
2493
2494pub fn canonical(v: &Value) -> String {
2496 v.to_string()
2497}
2498
2499pub fn workflow_schema() -> Value {
2503 let kinds: Vec<&str> = KINDS.iter().map(|k| k.name).collect();
2504 json!({
2505 "$schema": "https://json-schema.org/draft/2020-12/schema",
2506 "$id": "https://agentd.dev/schema/workflow-3.json",
2509 "title": "agentd workflow",
2510 "type": "object",
2511 "required": ["name", "steps"],
2512 "properties": {
2513 "name": {"type": "string", "pattern": "^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$"},
2514 "version": {"const": 3},
2515 "description": {"type": "string"},
2516 "armed": {"type": "boolean", "default": true},
2517 "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)"},
2518 "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"},
2519 "unload": {"type": "object", "additionalProperties": false, "description": "what happens to LIVE runs when this definition is retired (removed, replaced or deleted)", "properties": {
2520 "policy": {"enum": ["drain", "cancel", "detach"], "description": "drain (default) lets them finish"},
2521 "timeout": {"type": "string", "description": "how long a drain may take"}}},
2522 "file": {"type": "string", "description": "load the document from a path on disk instead of inline"},
2523 "uri": {"type": "string", "description": "load the document from an MCP resource instead of inline"},
2524 "inputs": {"type": "object", "properties": {"schema": {"type": "object"}}},
2525 "outputs": {"type": "object", "properties": {"schema": {"type": "object"}}},
2526 "state": {"type": "object", "additionalProperties": {"type": "object",
2527 "additionalProperties": false,
2528 "properties": {
2529 "schema": {"type": "object", "description": "a JSON Schema every write to this key must satisfy"},
2530 "reducer": {"enum": ["overwrite", "append", "merge", "union"],
2531 "description": "how concurrent writes to this key combine; declaring it makes concurrency a policy rather than a race"}}},
2532 "description": "declared run variables — {key: {schema, reducer}}"},
2533 "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"}}},
2534 "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"},
2535 "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": {
2536 "name": {"type": "string", "description": "the tool name callers see; may not shadow an internal contract"},
2537 "mode": {"enum": ["sync", "async"], "description": "sync parks the caller on the run and returns its output; async returns a handle"},
2538 "grant": {"type": "object", "additionalProperties": false, "properties": {
2539 "root": {"type": "boolean"}, "workflows": {"type": "boolean"}, "subagents": {"type": "boolean"},
2540 "user": {"type": "boolean"}, "agent": {"type": "boolean"}}}}},
2541 "limits": {"type": "object", "properties": {"steps": {"type": "integer"}, "tokens": {"type": "integer"}, "deadline": {"type": "string"}, "budget": {"type": "object"}}},
2542 "steps": {"type": "object", "additionalProperties": {"$ref": "#/$defs/step"}, "minProperties": 1}
2543 },
2544 "$defs": {
2545 "step": {
2546 "type": "object",
2547 "required": ["kind"],
2548 "properties": {
2549 "kind": {"enum": kinds},
2550 "depends_on": {"type": "array", "items": {"type": "string"}},
2551 "when": {"type": "string"},
2552 "retry": {"type": "object", "properties": {"max": {"type": "integer"}, "backoff": {"type": "string"}}},
2553 "timeout": {"type": "string"},
2554 "on_error": {"type": "string"},
2555 "idempotent": {"type": "boolean"},
2556 "on_replay": {"enum": ["retry", "skip", "fail"]},
2557 "output_schema": {"type": "object"},
2558 "cache": {"type": "object"},
2559 "budget": {"type": "integer"},
2560 "skills": {"type": "array", "items": {"type": "string"}},
2561 "otel": {"type": "object"},
2562 "description": {"type": "string"}
2563 }
2564 },
2565 "kinds": KINDS.iter().map(|k| (k.name.to_string(), json!({"start": k.start, "fields": k.fields, "required": k.required, "implemented": k.implemented}))).collect::<BTreeMap<_, _>>()
2566 }
2567 })
2568}
2569
2570#[cfg(test)]
2571mod tests {
2572 #[test]
2580 fn a_kind_that_declares_output_schema_receives_it() {
2581 let doc = serde_json::json!({
2582 "name": "w",
2583 "steps": {
2584 "go": {"kind": "manual"},
2585 "e": {"kind": "extract", "depends_on": ["go"], "input": "x",
2586 "output_schema": {"type": "object"}},
2587 "t": {"kind": "think", "depends_on": ["e"], "prompt": "p",
2588 "output_schema": {"type": "object"}},
2589 "fin": {"kind": "finish", "depends_on": ["t"], "status": "completed"}
2590 }
2591 });
2592 let wf = parse_workflow(&doc)
2593 .unwrap_or_else(|e| panic!("extract must validate with an output_schema: {e:?}"));
2594 for id in ["e", "t"] {
2596 let step = wf.steps.get(id).unwrap_or_else(|| panic!("step {id}"));
2597 assert!(
2598 step.field("output_schema").is_some(),
2599 "{id}: the kind's own output_schema must reach the node spec"
2600 );
2601 }
2602 }
2603
2604 use super::*;
2605
2606 fn wf(doc: Value) -> Result<Workflow, Vec<String>> {
2607 parse_workflow(&doc)
2608 }
2609
2610 #[test]
2618 fn the_workflow_schema_accepts_exactly_what_the_parser_does() {
2619 let schema = workflow_schema();
2620 let declared: std::collections::BTreeSet<&str> = schema["properties"]
2621 .as_object()
2622 .expect("properties")
2623 .keys()
2624 .map(String::as_str)
2625 .collect();
2626 let parsed: std::collections::BTreeSet<&str> = TOP.iter().copied().collect();
2627 assert_eq!(
2628 parsed.difference(&declared).collect::<Vec<_>>(),
2629 Vec::<&&str>::new(),
2630 "the parser accepts fields the schema does not declare — an editor would flag valid documents"
2631 );
2632 assert_eq!(
2633 declared.difference(&parsed).collect::<Vec<_>>(),
2634 Vec::<&&str>::new(),
2635 "the schema declares fields the parser refuses — completion would suggest fields that fail at load"
2636 );
2637 }
2638
2639 #[test]
2644 fn a_human_gates_addressee_is_checked_at_load() {
2645 let gate = |to: Value| {
2646 wf(json!({"name": "w", "steps": {
2647 "s": {"kind": "manual"},
2648 "g": {"kind": "human", "question": "ok?", "to": to, "depends_on": ["s"]},
2649 "f": {"kind": "finish", "depends_on": ["g"]}}}))
2650 };
2651 assert!(gate(json!("*@finance.example")).is_ok());
2652 assert!(gate(json!({"role": "user", "labels": {"team": "finance"}})).is_ok());
2653 for bad in [
2655 json!(""),
2656 json!({}),
2657 json!({"role": "anonymous"}),
2658 json!({"role": "auditor"}),
2659 json!({"rolle": "user"}),
2660 json!(7),
2661 ] {
2662 let e = gate(bad.clone()).unwrap_err();
2663 assert!(
2664 e.iter().any(|m| m.contains("human.to")),
2665 "{bad} should be refused at load, got {e:?}"
2666 );
2667 }
2668 let e = wf(json!({"name": "w", "steps": {
2670 "s": {"kind": "manual"},
2671 "g": {"kind": "human", "question": "ok?", "reply_uri": "https://x", "depends_on": ["s"]},
2672 "f": {"kind": "finish", "depends_on": ["g"]}}}))
2673 .unwrap_err();
2674 assert!(e.iter().any(|m| m.contains("reply_uri")), "{e:?}");
2675 }
2676
2677 #[test]
2678 fn workflow_priority_parses_and_rejects_junk() {
2679 let w = wf(json!({"name": "w", "priority": "low", "steps": {
2680 "s": {"kind": "once"}, "f": {"kind": "finish", "depends_on": ["s"]}}}))
2681 .unwrap();
2682 assert_eq!(w.priority, Priority::Low);
2683 let w = wf(json!({"name": "w", "steps": {
2684 "s": {"kind": "once"}, "f": {"kind": "finish", "depends_on": ["s"]}}}))
2685 .unwrap();
2686 assert_eq!(w.priority, Priority::Normal, "default");
2687 let e = wf(json!({"name": "w", "priority": "urgent", "steps": {
2688 "s": {"kind": "once"}, "f": {"kind": "finish", "depends_on": ["s"]}}}))
2689 .unwrap_err();
2690 assert!(e.iter().any(|m| m.contains("low|normal|high")), "{e:?}");
2691 assert!(Priority::High > Priority::Normal && Priority::Normal > Priority::Low);
2693 }
2694
2695 #[test]
2696 fn breaker_validates_shape_and_kind_family() {
2697 let ok = wf(json!({"name": "w", "steps": {
2698 "s": {"kind": "once"},
2699 "c": {"kind": "http", "depends_on": ["s"], "url": "https://api.example",
2700 "breaker": {"failures": 5, "cooldown": "60s"}},
2701 "f": {"kind": "finish", "depends_on": ["c"]},
2702 }}));
2703 assert!(ok.is_ok(), "{ok:?}");
2704 for bad in [
2705 json!({"failures": 0, "cooldown": "60s"}),
2706 json!({"failures": 5}),
2707 json!({"cooldown": "60s"}),
2708 json!({"failures": 5, "cooldown": "sometimes"}),
2709 json!({"failures": 5, "cooldown": "60s", "extra": 1}),
2710 ] {
2711 let e = wf(json!({"name": "w", "steps": {
2712 "s": {"kind": "once"},
2713 "c": {"kind": "http", "depends_on": ["s"], "url": "https://x", "breaker": bad},
2714 "f": {"kind": "finish", "depends_on": ["c"]},
2715 }}))
2716 .unwrap_err();
2717 assert!(e.iter().any(|m| m.contains("breaker takes")), "{e:?}");
2718 }
2719 let e = wf(json!({"name": "w", "steps": {
2721 "s": {"kind": "once"},
2722 "a": {"kind": "assign", "depends_on": ["s"], "value": 1,
2723 "breaker": {"failures": 5, "cooldown": "60s"}},
2724 "f": {"kind": "finish", "depends_on": ["a"]},
2725 }}))
2726 .unwrap_err();
2727 assert!(
2728 e.iter()
2729 .any(|m| m.contains("unknown field") || m.contains("remote-effect")),
2730 "{e:?}"
2731 );
2732 }
2733
2734 #[test]
2735 fn webhook_rate_and_subscribe_window_validate_their_shapes() {
2736 let ok = wf(json!({"name": "w", "steps": {
2738 "h": {"kind": "webhook", "path": "/x", "rate": "20/1s"},
2739 "s": {"kind": "subscribe", "server": "m", "uri": "u://v", "window": {"samples": 64}},
2740 "f": {"kind": "finish", "depends_on": ["h", "s"]},
2741 }}));
2742 assert!(ok.is_ok(), "{ok:?}");
2743 for bad in ["fast", "0/1s", "5/0s", "5"] {
2745 let e = wf(json!({"name": "w", "steps": {
2746 "h": {"kind": "webhook", "path": "/x", "rate": bad},
2747 "f": {"kind": "finish", "depends_on": ["h"]},
2748 }}))
2749 .unwrap_err();
2750 assert!(
2751 e.iter().any(|m| m.contains("rate must be")),
2752 "rate {bad:?}: {e:?}"
2753 );
2754 }
2755 for bad in [
2757 json!(64),
2758 json!({"samples": 0}),
2759 json!({"samples": 300}),
2760 json!({"samples": 4, "mean": true}),
2761 ] {
2762 let e = wf(json!({"name": "w", "steps": {
2763 "s": {"kind": "subscribe", "server": "m", "uri": "u://v", "window": bad},
2764 "f": {"kind": "finish", "depends_on": ["s"]},
2765 }}))
2766 .unwrap_err();
2767 assert!(
2768 e.iter().any(|m| m.contains("window takes")),
2769 "window {bad:?}: {e:?}"
2770 );
2771 }
2772 }
2773
2774 #[test]
2775 fn the_sugar_workflow_parses_hashes_and_orders() {
2776 let w = wf(json!({
2777 "name": "main", "version": 3,
2778 "steps": {
2779 "start": {"kind": "once"},
2780 "work": {"kind": "agent", "depends_on": ["start"], "instruction": "{{env.instruction}}"},
2781 "done": {"kind": "finish", "depends_on": ["work"], "status": "completed", "output": "{{steps.work.output}}"}
2782 }
2783 }))
2784 .unwrap();
2785 assert_eq!(w.start_steps().len(), 1);
2786 assert_eq!(w.topo_order(), vec!["start", "work", "done"]);
2787 assert_eq!(w.hash.len(), 64);
2788 assert!(!w.is_long_lived());
2789 assert!(w.armed);
2790 assert_eq!(
2791 w.step("work").unwrap().field_str("instruction"),
2792 Some("{{env.instruction}}")
2793 );
2794 let w2 = wf(w.definition.clone()).unwrap();
2796 assert_eq!(w2.hash, w.hash);
2797 let mut d = w.definition.clone();
2798 d["steps"]["work"]["instruction"] = json!("other");
2799 assert_ne!(wf(d).unwrap().hash, w.hash);
2800 }
2801
2802 #[test]
2811 fn every_start_kind_is_classified_and_only_once_manual_are_short() {
2812 let starts = start_kinds();
2813 assert_eq!(starts.len(), 10, "start kinds: {starts:?}");
2814 for k in &starts {
2815 assert_eq!(
2816 is_long_lived_start(k),
2817 !ONE_SHOT_STARTS.contains(k),
2818 "{k} classified inconsistently"
2819 );
2820 }
2821 assert!(is_long_lived_start("webhook"), "a listener keeps us alive");
2823 assert!(is_long_lived_start("stream"), "a consumer keeps us alive");
2824 assert!(!is_long_lived_start("once"));
2825 assert!(!is_long_lived_start("manual"));
2826 assert!(!is_long_lived_start("wait"));
2828 assert!(!is_long_lived_start("nonsense"));
2829 }
2830
2831 #[cfg(feature = "cel")]
2833 #[test]
2834 fn validation_catches_the_parse_and_graph_level_failures() {
2835 let e = wf(json!({"name": "bad name", "start": "x", "steps": {
2837 "a": {"kind": "agent", "instruction": "x"},
2838 "b": {"kind": "tool", "name": "memory.get", "depends_on": ["a"], "bogus": 1},
2839 "c": {"kind": "foreach", "over": "{{x}}", "body": {"steps": {"i": {"kind": "noop", "depends_on": ["q"]}, "bad id": {"kind": "noop"}}}, "depends_on": ["b"]},
2840 "d": {"kind": "nope", "depends_on": ["a"]},
2841 "e": {"kind": "sleep", "duration": "5 parsecs", "depends_on": ["a"], "when": "CEL: 1 +"},
2842 "s": {"kind": "once", "depends_on": ["a"]}
2843 }}))
2844 .unwrap_err();
2845 let joined = e.join("\n");
2846 for needle in [
2847 "workflow name \"bad name\"",
2848 "`start`/`nodes` are dialect 1/2",
2849 "unknown field \"bogus\"",
2850 "unknown kind \"nope\"",
2851 "sleep.duration",
2852 "when: CEL parse",
2853 "a start node cannot depend on other steps",
2854 "step \"bad id\": id must match",
2855 ] {
2856 assert!(joined.contains(needle), "missing {needle:?} in:\n{joined}");
2857 }
2858 let e = wf(json!({"name": "g", "steps": {
2860 "s": {"kind": "once"},
2861 "b": {"kind": "noop", "depends_on": ["s", "zz"]},
2862 "e": {"kind": "sleep", "duration": "1s", "depends_on": ["s"], "on_error": "goto:nowhere"},
2863 "loop1": {"kind": "noop", "depends_on": ["loop2"]},
2864 "loop2": {"kind": "noop", "depends_on": ["loop1"]},
2865 "f": {"kind": "finish", "depends_on": ["b"]}
2866 }}))
2867 .unwrap_err();
2868 let joined = e.join("\n");
2869 for needle in [
2870 "depends_on names unknown step \"zz\"",
2871 "on_error goto names unknown step \"nowhere\"",
2872 "cycle among steps",
2873 "not reachable from any start node",
2874 ] {
2875 assert!(joined.contains(needle), "missing {needle:?} in:\n{joined}");
2876 }
2877 let e = wf(json!({"name": "w", "steps": {
2879 "a": {"kind": "noop"},
2880 "b": {"kind": "noop", "depends_on": ["c"]},
2881 "c": {"kind": "noop", "depends_on": ["b"]}
2882 }}))
2883 .unwrap_err();
2884 let joined = e.join("\n");
2885 assert!(joined.contains("at least one start node"), "{joined}");
2886 assert!(joined.contains("unreachable root"), "{joined}");
2887 assert!(joined.contains("cycle among steps"), "{joined}");
2888 assert!(joined.contains("`finish` step is required"), "{joined}");
2889 let e = wf(json!({"name": "w", "version": 2, "steps": {"s": {"kind": "once"}, "f": {"kind": "finish", "depends_on": ["s"]}}})).unwrap_err();
2891 assert!(e[0].contains("not dialect 3"));
2892 let ok = wf(json!({"name": "w", "inputs": {"schema": {"type": "object"}}, "concurrency": {"max_runs": 2, "on_overflow": "drop"}, "limits": {"deadline": "10m", "steps": 50}, "steps": {
2894 "s": {"kind": "manual"},
2895 "t": {"kind": "mcp.tool", "server": "fs", "tool": "read", "args": {"path": "/x"}, "depends_on": ["s"], "retry": {"max": 2, "backoff": "1s"}, "timeout": "30s", "on_error": "continue"},
2896 "v": {"kind": "assign", "value": {"a": 1}, "writes": "x", "depends_on": ["t"], "when": "CEL: true"},
2897 "th": {"kind": "think", "prompt": "p", "output_schema": {"type": "object"}, "depends_on": ["v"]},
2898 "z": {"kind": "sleep", "duration": "1s", "depends_on": ["th"]},
2899 "f": {"kind": "finish", "depends_on": ["z"], "status": "completed", "output": "{{vars.x}}"}
2900 }}))
2901 .unwrap();
2902 assert_eq!(ok.concurrency.on_overflow, OnOverflow::Drop);
2903 assert_eq!(ok.limits.deadline_ms, Some(600_000));
2904 assert_eq!(
2905 ok.step("t").unwrap().retry.as_ref().unwrap().backoff_ms,
2906 1000
2907 );
2908 assert_eq!(ok.step("t").unwrap().on_error, OnError::Continue);
2909 assert_eq!(ok.step("t").unwrap().timeout_ms, Some(30_000));
2910 assert!(implemented_kinds().contains(&"agent"));
2911 assert!(workflow_schema()["$defs"]["kinds"]["a2a.send"]["implemented"] == json!(true));
2912 assert!(workflow_schema()["$defs"]["kinds"]["foreach"]["implemented"] == json!(true));
2913 }
2914}