1use super::children::ChildKind;
10use super::events::kinds;
11use super::reactor::{PendingKind, Runtime, Target};
12use super::tools::{ToolCaller, ToolOutcome};
13use crate::config::v2::substitute_config_vars;
14use crate::context::Msg;
15use crate::engine::model::{OnError, Step, Workflow, parse_workflow};
16use crate::engine::run::{
17 self, Next, RunState, RunStatus, Start, StepStatus, env_view, render_spec,
18};
19use crate::engine::template;
20use crate::governor::Admission;
21use crate::registry::Caller;
22use crate::state::{InboxEvent, Kind, now_ms, ulid};
23use crate::subagent::protocol::{TurnKind, TurnResult, TurnSpec};
24use serde_json::{Map, Value, json};
25use std::collections::BTreeMap;
26
27const WORKFLOW_DEF_PREFIX: &str = "_workflows/";
29
30impl Runtime {
31 pub(crate) fn effective_breaker(
39 &self,
40 step: &crate::engine::model::Step,
41 ) -> Option<super::breaker::Config> {
42 if let Some(cfg) = super::breaker::Config::of(step.spec.get("breaker")) {
43 return Some(cfg);
44 }
45 if step.kind != "mcp.tool" {
46 return None;
47 }
48 let server = step.field_str("server")?;
49 let svc = self
50 .settings
51 .mcp
52 .servers
53 .iter()
54 .find(|s| s.name == server)
55 .and_then(|s| s.service.as_ref())?;
56 super::breaker::Config::of(self.settings.services.get(svc)?.breaker.as_ref())
57 }
58
59 pub(crate) fn fill_durable_default(&self, w: &mut crate::engine::model::Workflow) {
63 if w.durable.is_none() {
64 w.durable = Some(self.work_durable_default());
65 }
66 }
67
68 pub(crate) fn load_workflows(&mut self) -> Result<(), Vec<String>> {
73 let mut errs = Vec::new();
74 let mut docs: Vec<Value> = Vec::new();
78 for doc in self.settings.workflows.clone() {
79 let mut doc = doc;
87 if doc.get("steps").is_none() {
88 substitute_config_vars(&mut doc, &self.settings.vars, "workflow entry", &mut errs);
89 }
90 match doc.get("dir").and_then(Value::as_str) {
91 None => docs.push(doc),
92 Some(dir) => {
93 let pattern = doc
94 .get("glob")
95 .and_then(Value::as_str)
96 .unwrap_or("*.yaml,*.yml,*.json");
97 match expand_dir(dir, pattern) {
98 Ok(paths) if paths.is_empty() => {
99 errs.push(format!("workflow dir {dir}: no file matched {pattern:?}"));
102 }
103 Ok(paths) => {
104 for path in paths {
105 let mut d = json!({"file": path});
106 if let Some(a) = doc.get("armed") {
107 d["armed"] = a.clone();
108 }
109 docs.push(d);
110 }
111 }
112 Err(e) => errs.push(format!("workflow dir {dir}: {e}")),
113 }
114 }
115 }
116 }
117 for doc in docs {
118 let mut doc = doc;
127 if doc.get("steps").is_none() {
132 substitute_config_vars(&mut doc, &self.settings.vars, "workflow entry", &mut errs);
133 }
134 let resolved = match (
135 doc.get("file").and_then(Value::as_str),
136 doc.get("uri").and_then(Value::as_str),
137 ) {
138 (Some(path), _) => match std::fs::read_to_string(path)
139 .map_err(|e| e.to_string())
140 .and_then(|t| {
141 crate::config::file::parse_document(
142 &t,
143 crate::config::file::Format::detect(
144 Some(std::path::Path::new(path)),
145 &t,
146 ),
147 )
148 }) {
149 Ok(mut d) => {
150 if d.get("name").is_none()
151 && let Some(n) = doc.get("name")
152 {
153 d["name"] = n.clone();
154 }
155 d
156 }
157 Err(e) => {
158 errs.push(format!("workflow file {path}: {e}"));
159 continue;
160 }
161 },
162 (None, None) if doc.get("url").is_some() => {
168 let url = doc["url"].as_str().unwrap_or_default().to_string();
169 match self.fetch_workflow_url(&doc, &url) {
170 Ok(mut d) => {
171 if d.get("name").is_none()
172 && let Some(n) = doc.get("name")
173 {
174 d["name"] = n.clone();
175 }
176 d
177 }
178 Err(e) => {
179 errs.push(format!("workflow url {url}: {e}"));
180 continue;
181 }
182 }
183 }
184 (None, Some(uri)) => match self.read_resource_any(uri) {
185 Ok(text) => match crate::config::file::parse_document(
186 &text,
187 crate::config::file::Format::detect(Some(std::path::Path::new(uri)), &text),
188 ) {
189 Ok(mut d) => {
190 if d.get("name").is_none()
191 && let Some(n) = doc.get("name")
192 {
193 d["name"] = n.clone();
194 }
195 d
196 }
197 Err(e) => {
198 errs.push(format!("workflow uri {uri}: {e}"));
199 continue;
200 }
201 },
202 Err(e) => {
203 errs.push(format!("workflow uri {uri}: {e}"));
204 continue;
205 }
206 },
207 _ => doc.clone(),
208 };
209 let mut resolved = resolved;
210 substitute_config_vars(&mut resolved, &self.settings.vars, "workflow", &mut errs);
211 match parse_workflow(&resolved) {
212 Ok(mut w) => {
213 self.fill_durable_default(&mut w);
214 self.log.info("workflow.loaded", json!({"name": w.name, "hash": &w.hash[..12], "steps": w.steps.len(), "durable": w.durable, "starts": w.start_steps().iter().map(|s| s.kind.clone()).collect::<Vec<_>>()}));
215 self.workflows
216 .insert(w.name.clone(), std::sync::Arc::new(w));
217 }
218 Err(e) => errs.extend(e),
219 }
220 }
221 if let Ok(list) = self.durable.list(Kind::Memory) {
223 for ks in list {
224 let Some((_, id)) = crate::store::parse_key(
225 self.durable.prefix(),
226 self.durable.instance(),
227 &ks.key,
228 ) else {
229 continue;
230 };
231 if let Some(name) = id.strip_prefix(WORKFLOW_DEF_PREFIX)
232 && !self.workflows.contains_key(name)
233 && let Ok(Some(env)) = self.durable.get(Kind::Memory, id)
234 && let Some(def) = env.state.get("value")
235 {
236 match parse_workflow(def) {
237 Ok(mut w) => {
238 self.fill_durable_default(&mut w);
239 self.log.info(
240 "workflow.loaded",
241 json!({"name": w.name, "source": "store"}),
242 );
243 self.workflows
244 .insert(w.name.clone(), std::sync::Arc::new(w));
245 }
246 Err(e) => self.log.warn(
247 "workflow.stored.invalid",
248 json!({"name": name, "errors": e}),
249 ),
250 }
251 }
252 }
253 }
254 for w in self.workflows.values() {
256 for s in w.steps.values() {
257 match s.kind.as_str() {
258 "tool" => {
259 if let Some(n) = s.field_str("name")
260 && !self.registry.allowed(&Caller::Workflow, n)
261 {
262 errs.push(format!("workflow {:?} step {:?}: tool {n:?} is unknown, disabled or not granted to workflows", w.name, s.id));
263 }
264 }
265 "mcp.tool" => {
266 if let Some(srv) = s.field_str("server")
267 && !self.mcp.contains_key(srv)
268 {
269 errs.push(format!(
270 "workflow {:?} step {:?}: mcp server {srv:?} is not connected",
271 w.name, s.id
272 ));
273 }
274 }
275 k if (k.starts_with("memory.")
276 || k.starts_with("artifact.")
277 || k.starts_with("knowledge.")
278 || k.starts_with("search."))
279 && !self.registry.allowed(&Caller::Workflow, k) =>
280 {
281 errs.push(format!("workflow {:?} step {:?}: {k} is unavailable (map it with tools.overrides or configure its server)", w.name, s.id));
282 }
283 _ => {}
284 }
285 }
286 }
287 for w in self.workflows.values() {
292 if w.durable == Some(false) {
293 continue;
294 }
295 for st in w.steps.values() {
296 if st.kind == "workflow"
297 && st.field_str("mode").unwrap_or("sync") != "detached"
298 && let Some(target) = st.field_str("name")
299 && self
300 .workflows
301 .get(target)
302 .is_some_and(|t| t.durable == Some(false))
303 {
304 self.log.warn(
305 "workflow.durability_mix",
306 json!({"workflow": w.name, "step": st.id, "child": target,
307 "note": "a durable parent waits on a non-durable child; after a restart the wait fails (the child run does not survive)"}),
308 );
309 }
310 }
311 }
312 for w in self.workflows.values() {
315 for st in w.steps.values() {
316 if matches!(st.kind.as_str(), "emit" | "stream")
317 && let Some(name) = st.field_str("stream")
318 && !self.settings.streams.contains_key(name)
319 {
320 errs.push(format!(
321 "workflow {:?} step {:?}: stream {name:?} is not declared under `streams:`",
322 w.name, st.id
323 ));
324 }
325 }
326 }
327 if errs.is_empty() { Ok(()) } else { Err(errs) }
328 }
329
330 fn fetch_workflow_url(&self, doc: &Value, url: &str) -> Result<Value, String> {
343 let headers: Vec<(String, String)> = doc
344 .get("headers")
345 .and_then(Value::as_object)
346 .map(|m| {
347 m.iter()
348 .map(|(k, v)| (k.clone(), v.as_str().unwrap_or_default().to_string()))
349 .collect()
350 })
351 .unwrap_or_default();
352 let headers =
353 crate::mcp::auth::resolve_headers(&headers).map_err(|e| format!("headers: {e}"))?;
354 let timeout = doc
355 .get("timeout")
356 .and_then(Value::as_str)
357 .and_then(|t| crate::config::parse_duration(t).ok())
358 .unwrap_or(std::time::Duration::from_secs(20));
359 let allow_private = doc
360 .get("allow_private")
361 .and_then(Value::as_bool)
362 .unwrap_or(false);
363 let text = crate::runtime::http_node::fetch_text(url, &headers, timeout, allow_private)?;
364 crate::config::file::parse_document(
365 &text,
366 crate::config::file::Format::detect(Some(std::path::Path::new(url)), &text),
367 )
368 }
369
370 pub(crate) fn read_resource_any(&self, uri: &str) -> Result<String, String> {
372 if let Some(rest) = uri.strip_prefix("mcp://") {
373 let (server, res) = rest
374 .split_once('/')
375 .ok_or("mcp:// uri needs <server>/<resource-uri>")?;
376 let c = self
377 .mcp
378 .get(server)
379 .ok_or_else(|| format!("mcp server {server:?} is not connected"))?;
380 return c
381 .read_resource(res)
382 .map(|r| r.text())
383 .map_err(|e| e.to_string());
384 }
385 let mut last = String::from("no connected server serves it");
386 for c in self.mcp.values() {
387 match c.read_resource(uri) {
388 Ok(r) => return Ok(r.text()),
389 Err(e) => last = e.to_string(),
390 }
391 }
392 Err(last)
393 }
394
395 pub(crate) fn arm_workflows(&mut self) {
400 let names: Vec<String> = self.workflows.keys().cloned().collect();
401 for name in names {
402 let Some(w) = self.workflows.get(&name) else {
403 continue;
404 };
405 if !w.armed {
406 continue;
407 }
408 let starts: Vec<(String, String, Map<String, Value>)> = w
409 .start_steps()
410 .iter()
411 .map(|s| (s.id.clone(), s.kind.clone(), s.spec.clone()))
412 .collect();
413 for (id, kind, spec) in starts {
414 match kind.as_str() {
415 "once" => {
416 let policy = spec
417 .get("policy")
418 .and_then(Value::as_str)
419 .unwrap_or("ensure");
420 let live = self
421 .runs
422 .values()
423 .any(|r| r.workflow == name && !r.status.is_terminal());
424 let ever = self
425 .runs
426 .values()
427 .any(|r| r.workflow == name && r.start.node == id);
428 let pending = self.inbox_queue.iter().any(|e| {
430 e.kind == kinds::START_FIRED
431 && e.payload["workflow"] == name.as_str()
432 && e.payload["node"] == id.as_str()
433 });
434 if policy == "ensure" && (live || ever || pending) {
435 self.log.info("start.once.skipped", json!({"workflow": name, "node": id, "live": live, "pending": pending}));
436 continue;
437 }
438 let inputs = spec.get("inputs").cloned().unwrap_or(json!({}));
439 let acting = self.settings.identity.autonomous_id().to_string();
442 let _ = self.accept_event(kinds::START_FIRED, Some(acting), json!({"workflow": name, "node": id, "payload": {"fired_at": now_ms()}, "inputs": inputs}));
443 }
444 "manual" => {}
445 _ => {}
447 }
448 }
449 }
450 }
451
452 pub(crate) fn on_start_event(&mut self, ev: &InboxEvent) -> bool {
454 let name = ev.payload["workflow"].as_str().unwrap_or("").to_string();
455 let Some(w) = self.workflows.get(&name).cloned() else {
456 self.log.warn(
457 "start.unknown_workflow",
458 json!({"inbox_event": ev.id, "workflow": name}),
459 );
460 return true;
461 };
462 let node = ev.payload["node"]
463 .as_str()
464 .map(str::to_string)
465 .unwrap_or_else(|| default_start(&w).unwrap_or_default());
466 let this_key = ev.payload["key"].as_str();
478 let keyed = w.concurrency.scope == crate::engine::model::ConcurrencyScope::Key
479 && this_key.is_some();
480 let live = self
481 .runs
482 .values()
483 .filter(|r| r.workflow == name && !r.status.is_terminal())
484 .filter(|r| !keyed || r.key.as_deref() == this_key)
485 .count() as u32;
486 let global_live = self
487 .runs
488 .values()
489 .filter(|r| !r.status.is_terminal())
490 .count() as u32;
491 if live >= w.concurrency.max_runs
492 || global_live >= self.settings.limits.max_runs.unwrap_or(8)
493 {
494 match w.concurrency.on_overflow {
495 crate::engine::model::OnOverflow::Queue => {
496 self.inbox_queue.push_back(ev.clone());
503 return false;
504 }
505 crate::engine::model::OnOverflow::Drop => {
506 self.log.warn(
507 "run.dropped",
508 json!({"workflow": name, "reason": "concurrency"}),
509 );
510 return true;
511 }
512 crate::engine::model::OnOverflow::Replace => {
513 if let Some(oldest) = self
514 .runs
515 .values()
516 .filter(|r| r.workflow == name && !r.status.is_terminal())
517 .min_by_key(|r| r.created)
518 .map(|r| r.id.clone())
519 {
520 self.cancel_run(&oldest, "replaced by a newer run");
521 }
522 }
523 }
524 }
525 let inputs = ev.payload.get("inputs").cloned().unwrap_or(json!({}));
527 if let Some(schema) = &w.inputs_schema
528 && let Err(e) = crate::jsonschema::validate(schema, &inputs)
529 {
530 self.log
531 .warn("run.inputs.invalid", json!({"workflow": name, "errors": e}));
532 return true;
533 }
534 let run_id = ev
536 .payload
537 .get("run_id")
538 .and_then(Value::as_str)
539 .map(str::to_string)
540 .unwrap_or_else(|| format!("{}-{}", name, ulid::new()));
541 self.ensure_pin(&w);
544 let mut run = RunState::new(
545 &run_id,
546 &w,
547 Start {
548 node: node.clone(),
549 payload: ev.payload.get("payload").cloned().unwrap_or(Value::Null),
550 ts: now_ms(),
551 },
552 inputs,
553 );
554 run.principal = ev.principal.clone();
555 run.parent = ev.payload.get("parent").cloned().filter(|p| !p.is_null());
556 run.conversation = ev
557 .payload
558 .get("conversation")
559 .and_then(Value::as_str)
560 .map(str::to_string);
561 run.task = ev
562 .payload
563 .get("task")
564 .and_then(Value::as_str)
565 .map(str::to_string);
566 run.msg_depth = ev.payload["msg_depth"].as_u64().unwrap_or(0) as u32;
570 run.key = ev.payload["key"].as_str().map(str::to_string);
571 if run.durable
575 && let Err(e) = self.durable.put(
576 Kind::Run,
577 &run_id,
578 serde_json::to_value(&run).unwrap_or(Value::Null),
579 Some(w.hash.clone()),
580 )
581 {
582 self.log.error(
583 "run.create.fail",
584 json!({"workflow": name, "err": e.to_string()}),
585 );
586 self.inbox_queue.push_back(ev.clone());
591 return false;
592 }
593 run.dirty = false;
594 self.log.info(
597 "run.start",
598 json!({"run": run_id, "workflow": name, "node": node, "inbox_event": ev.id,
599 "acting_for": run.principal, "key": run.key}),
600 );
601 self.counters.runs_started += 1;
602 crate::obs::metrics::record_run_started();
603 if node_kind(&w, &node) == Some("once") && self.job_shape {
604 self.job_runs.push(run_id.clone());
605 }
606 if ev.kind == kinds::WORKFLOW_RUN
608 && let Some(req) = ev.payload.get("request").and_then(Value::as_object)
609 {
610 let target = match (
611 req.get("node").and_then(Value::as_u64),
612 req.get("req").and_then(Value::as_u64),
613 req.get("run").and_then(Value::as_str),
614 req.get("step").and_then(Value::as_str),
615 ) {
616 (Some(n), Some(r), _, _) => {
617 Some(Target::Child(crate::supervisor::tree::NodeId(n), r))
618 }
619 (None, None, Some(r), Some(s)) => Some(Target::Step(r.to_string(), s.to_string())),
620 _ => None,
621 };
622 if let Some(t) = target {
623 if req.get("wait").and_then(Value::as_bool).unwrap_or(false) {
624 let deadline = now_ms()
625 + req
626 .get("timeout_ms")
627 .and_then(Value::as_u64)
628 .unwrap_or(3_600_000);
629 self.push_pending(super::reactor::PendingTool {
630 target: t,
631 name: "workflow.run".into(),
632 kind: PendingKind::Run {
633 run: run_id.clone(),
634 deadline_ms: deadline,
635 },
636 started_ms: now_ms(),
637 });
638 } else {
639 self.reply(
640 &t,
641 json!({"run": run_id, "status": "running", "workflow": name}),
642 false,
643 );
644 }
645 }
646 }
647 self.runs.insert(run_id, run);
648 true
649 }
650
651 pub(crate) fn schedule_runs(&mut self) {
655 if self.paused {
656 return; }
658 let mut ids: Vec<(std::cmp::Reverse<crate::engine::model::Priority>, String)> = self
662 .runs
663 .iter()
664 .filter(|(_, r)| !r.status.is_terminal() && r.status != RunStatus::Paused)
665 .map(|(id, r)| {
666 let pr = self
667 .workflows
668 .get(&r.workflow)
669 .map(|w| w.priority)
670 .unwrap_or_default();
671 (std::cmp::Reverse(pr), id.clone())
672 })
673 .collect();
674 ids.sort_by_key(|a| a.0);
675 for (_, id) in ids {
676 self.schedule_run(&id);
677 }
678 }
679
680 pub(crate) fn definition_for_run_ref(&self, workflow: &str, hash: &str) -> Option<&Workflow> {
683 if let Some(w) = self.workflows.get(workflow)
684 && w.hash == hash
685 {
686 return Some(w.as_ref());
687 }
688 self.pinned.get(hash).map(|w| w.as_ref())
689 }
690
691 pub(crate) fn definition_for_run(&self, run_id: &str) -> Option<std::sync::Arc<Workflow>> {
697 let run = self.runs.get(run_id)?;
698 if let Some(w) = self.workflows.get(&run.workflow)
699 && w.hash == run.workflow_hash
700 {
701 return Some(w.clone());
705 }
706 self.pinned.get(&run.workflow_hash).cloned()
707 }
708
709 fn schedule_run(&mut self, run_id: &str) {
710 let Some(wf) = self.definition_for_run(run_id) else {
711 let (name, hash) = self
714 .runs
715 .get(run_id)
716 .map(|r| (r.workflow.clone(), r.workflow_hash.clone()))
717 .unwrap_or_default();
718 let reason = if self.workflows.contains_key(&name) {
719 format!(
720 "workflow {name:?} definition changed (run pinned to hash {}); resume_policy refuse",
721 &hash[..hash.len().min(12)]
722 )
723 } else {
724 format!("workflow {name:?} definition is gone")
725 };
726 self.log
727 .warn("run.refused", json!({"run": run_id, "reason": reason}));
728 if let Some(r) = self.runs.get_mut(run_id) {
729 r.finish(RunStatus::Refused, None, Some(reason));
730 }
731 self.on_run_terminal(run_id);
732 return;
733 };
734 if let Some(r) = self.runs.get(run_id)
735 && run::deadline_passed(r)
736 {
737 self.log.warn("run.deadline", json!({"run": run_id}));
738 self.cancel_children_of_run(run_id, "run deadline");
739 self.runs.get_mut(run_id).expect("present").finish(
740 RunStatus::Failed,
741 None,
742 Some("deadline exceeded".into()),
743 );
744 self.on_run_terminal(run_id);
745 return;
746 }
747 let step_cap = wf.limits.steps.or(self.settings.limits.run.steps);
753 let token_cap = wf.limits.tokens.or(self.settings.limits.run.tokens);
754 if let Some(cap) = step_cap
755 && self.runs.get(run_id).is_some_and(|r| r.steps_run >= cap)
756 {
757 self.runs.get_mut(run_id).expect("present").finish(
758 RunStatus::Failed,
759 None,
760 Some(format!("exhausted steps: limit {cap}")),
761 );
762 self.on_run_terminal(run_id);
763 return;
764 }
765 if let Some(cap) = token_cap
766 && self.runs.get(run_id).is_some_and(|r| r.tokens >= cap)
767 {
768 self.runs.get_mut(run_id).expect("present").finish(
769 RunStatus::Failed,
770 None,
771 Some(format!("exhausted tokens: limits.tokens = {cap}")),
772 );
773 self.on_run_terminal(run_id);
774 return;
775 }
776 let data = self.run_data(run_id);
777 let next = {
778 let run = self.runs.get_mut(run_id).expect("present");
779 run::schedule(&wf, run, &data)
780 };
781 let nested: Vec<String> = self
784 .runs
785 .get(run_id)
786 .map(|r| {
787 r.steps
788 .iter()
789 .filter(|(_, st)| {
790 st.status == StepStatus::Running
791 && st.wait.as_ref().is_some_and(|w| {
792 matches!(
793 w["kind"].as_str(),
794 Some("foreach")
795 | Some("batch")
796 | Some("iterate")
797 | Some("parallel")
798 | Some("race")
799 | Some("subgraph")
800 )
801 })
802 })
803 .map(|(id, _)| id.clone())
804 .collect()
805 })
806 .unwrap_or_default();
807 for id in nested {
808 self.nested_advance(run_id, &id);
809 }
810 let shutdown_capable = self.draining
816 && wf
817 .start_steps()
818 .iter()
819 .any(|s| s.kind == "event" && s.field_str("on") == Some("lifecycle.shutdown"));
820 match next {
821 Ok(Next::Ready(steps)) => {
822 for s in steps {
823 if self.draining && !shutdown_capable {
824 return;
825 }
826 self.execute_step(run_id, &s);
827 }
828 }
829 Ok(Next::Waiting) | Ok(Next::Terminal) => {}
830 Ok(Next::Stalled) => {
831 let culprit = self.first_failed_step(run_id);
836 let why = match &culprit {
837 Some((sid, err)) => format!(
838 "no ready step and no finish reached — step {sid:?} failed first: {err}"
839 ),
840 None => "no ready step and no finish reached".to_string(),
841 };
842 self.log.warn(
843 "run.stalled",
844 json!({"run": run_id, "blocked_by": culprit.as_ref().map(|(s, _)| s)}),
845 );
846 let status = if culprit.is_some() {
849 RunStatus::Failed
850 } else {
851 RunStatus::Stalled
852 };
853 self.runs
854 .get_mut(run_id)
855 .expect("present")
856 .finish(status, None, Some(why));
857 self.on_run_terminal(run_id);
858 }
859 Err(e) => {
860 self.runs.get_mut(run_id).expect("present").finish(
861 RunStatus::Failed,
862 None,
863 Some(e),
864 );
865 self.on_run_terminal(run_id);
866 }
867 }
868 }
869
870 fn workflows_locked(&self, tool: &str) -> Option<super::tools::ToolOutcome> {
877 if !self.settings.security.workflows.immutable {
878 return None;
879 }
880 self.log.warn("workflow.locked", json!({"tool": tool}));
881 Some(super::tools::ToolOutcome::Ready(
882 json!({"error": format!(
883 "{tool}: workflow definitions are immutable \
884 (security.workflows.immutable) — edit the config, file or \
885 directory they load from and reload"
886 )}),
887 true,
888 ))
889 }
890
891 fn first_failed_step(&self, run_id: &str) -> Option<(String, String)> {
897 let run = self.runs.get(run_id)?;
898 run.steps
899 .iter()
900 .filter(|(_, st)| {
901 matches!(
902 st.status,
903 StepStatus::Failed | StepStatus::Timeout | StepStatus::Cancelled
904 )
905 })
906 .min_by_key(|(_, st)| st.finished.unwrap_or(u64::MAX))
907 .map(|(id, st)| {
908 (
909 id.clone(),
910 st.error
911 .clone()
912 .unwrap_or_else(|| "no error recorded".into()),
913 )
914 })
915 }
916
917 pub(crate) fn run_data(&mut self, run_id: &str) -> template::Data {
921 let env = env_view(
922 &self.instance,
923 run_id,
924 Some(&self.instruction.text),
925 self.settings.agent.prompt.as_deref(),
926 );
927 let mut memory = Map::new();
932 let hash = self
933 .runs
934 .get(run_id)
935 .map(|r| r.workflow_hash.clone())
936 .unwrap_or_default();
937 if !hash.is_empty() {
938 if !self.memory_keys.contains_key(&hash) {
939 let mut keys: Vec<String> = Vec::new();
940 if let Some(wf) = self.definition_for_run(run_id) {
941 for s in wf.steps.values() {
942 for (_, v) in &s.spec {
943 collect_memory_keys(v, &mut keys);
944 }
945 if let Some(w) = &s.when {
946 collect_memory_keys(&Value::String(w.clone()), &mut keys);
947 }
948 }
949 }
950 self.memory_keys.insert(hash.clone(), keys);
951 }
952 for k in self.memory_keys.get(&hash).cloned().unwrap_or_default() {
953 if let Ok(v) = self.memory.get(&self.durable, &k)
954 && v["found"] == json!(true)
955 {
956 memory.insert(k, v["value"].clone());
957 }
958 }
959 }
960 let mut data = self
961 .runs
962 .get(run_id)
963 .map(|r| r.data(env, Value::Object(memory)))
964 .unwrap_or_default();
965 for key in ["steps", "vars", "inputs"] {
969 if let Some(v) = data.get_mut(key) {
970 self.deref_artifacts(v);
971 }
972 }
973 data
974 }
975
976 pub(crate) fn deref_artifacts(&self, v: &mut Value) {
978 match v {
979 Value::Object(o) => {
980 if let Some(id) = o.get("$artifact").and_then(Value::as_str) {
981 if let Some(a) = self.artifacts.get(id) {
982 *v = a.content.clone();
983 }
984 return;
985 }
986 for x in o.values_mut() {
987 self.deref_artifacts(x);
988 }
989 }
990 Value::Array(a) => {
991 for x in a.iter_mut() {
992 self.deref_artifacts(x);
993 }
994 }
995 _ => {}
996 }
997 }
998
999 pub(crate) fn execute_step_pub(&mut self, run_id: &str, step_id: &str) {
1003 self.execute_step(run_id, step_id)
1004 }
1005
1006 fn execute_step(&mut self, run_id: &str, step_id: &str) {
1007 if self.runs.get(run_id).is_none_or(|r| r.status.is_terminal()) {
1008 return;
1009 }
1010 let Some(wf) = self
1011 .runs
1012 .get(run_id)
1013 .and_then(|_| self.definition_for_run(run_id))
1014 else {
1015 return;
1016 };
1017 let Some((step, scope)) = self
1018 .runs
1019 .get(run_id)
1020 .and_then(|r| self.resolve_step(&wf, r, step_id))
1021 else {
1022 return;
1023 };
1024 if matches!(
1030 step.kind.as_str(),
1031 "http" | "mcp.tool" | "a2a.send" | "a2a.delegate"
1032 ) && let Some(rate) = step.spec.get("rate").and_then(Value::as_str)
1033 && let Ok((burst, secs)) = crate::supervisor::tree::parse_rate(rate)
1034 {
1035 let workflow = self
1036 .runs
1037 .get(run_id)
1038 .map(|r| r.workflow.clone())
1039 .unwrap_or_default();
1040 let key = super::breaker::key(&workflow, step_id);
1041 let (bucket, window_s, b) = self.step_rates.entry(key.clone()).or_insert_with(|| {
1042 (
1043 crate::supervisor::tree::TokenBucket::new(burst, burst as f64 / secs),
1044 secs,
1045 burst,
1046 )
1047 });
1048 if !bucket.try_take() {
1049 let wait = ((*window_s * 1000.0) / (*b).max(1) as f64).max(20.0) as u64;
1052 match self.timers.arm(
1053 &self.durable,
1054 now_ms() + wait,
1055 json!({"kind": "step_budget", "run": run_id, "step": step_id}),
1056 Value::Null,
1057 ) {
1058 Ok(id) => {
1059 self.log.info(
1060 "step.rate_wait",
1061 json!({"run": run_id, "step": step_id, "rate": rate, "wait_ms": wait}),
1062 );
1063 self.runs
1064 .get_mut(run_id)
1065 .expect("present")
1066 .suspend_step(step_id, json!({"kind": "rate_wait", "timer": id}));
1067 self.checkpoint(false);
1068 return;
1069 }
1070 Err(e) => {
1071 self.log.warn(
1074 "step.rate_wait_fail",
1075 json!({"run": run_id, "step": step_id, "err": e.to_string()}),
1076 );
1077 }
1078 }
1079 }
1080 }
1081 let attempt = self
1082 .runs
1083 .get_mut(run_id)
1084 .expect("present")
1085 .begin_step(step_id);
1086 if self
1090 .runs
1091 .get(run_id)
1092 .and_then(|r| r.break_before.as_deref())
1093 == Some(step_id)
1094 {
1095 if let Some(r) = self.runs.get_mut(run_id) {
1096 r.status = RunStatus::Paused;
1097 r.break_before = None;
1098 r.dirty = true;
1099 r.steps.entry(step_id.to_string()).or_default().status = StepStatus::Pending;
1100 }
1101 self.log
1102 .info("run.paused", json!({"run": run_id, "before_step": step_id}));
1103 self.checkpoint(true);
1104 return;
1105 }
1106 crate::state::kill_point("step.running");
1113 if !crate::engine::model::pure_data_kind(&step.kind) {
1114 self.checkpoint(false);
1115 }
1116 self.log.info(
1117 "step.start",
1118 json!({"run": run_id, "step": step_id, "kind": step.kind, "attempt": attempt}),
1119 );
1120 #[cfg(feature = "a2a")]
1126 self.feed_push(
1127 "step",
1128 crate::runtime::a2a_server::FeedVis::Operator,
1129 json!({"run": run_id, "step": step_id, "kind": step.kind,
1130 "phase": "start", "attempt": attempt}),
1131 );
1132 let mut data = match &scope {
1133 Some(sc) => self.scoped_data(run_id, sc),
1134 None => self.run_data(run_id),
1135 };
1136 if let Some(env) = data.get_mut("env") {
1142 env["step"] = json!(step_id);
1143 env["attempt"] = json!(attempt);
1144 env["idempotency_key"] = json!(crate::engine::run::idempotency_key(run_id, step_id));
1145 }
1146 let spec = match render_spec(&step, &data) {
1147 Ok(s) => s,
1148 Err(e) => {
1149 self.finish_step(run_id, step_id, StepStatus::Failed, None, Some(e), 0);
1150 return;
1151 }
1152 };
1153 let step_caller = ToolCaller {
1154 run: Some(run_id.to_string()),
1155 step: Some(step_id.to_string()),
1156 req: attempt as u64,
1157 principal: self.runs.get(run_id).and_then(|r| r.principal.clone()),
1158 ctx: self.runs.get(run_id).and_then(|r| r.conversation.clone()),
1159 msg_depth: self.runs.get(run_id).map(|r| r.msg_depth).unwrap_or(0),
1160 ..Default::default()
1161 };
1162 let cache_key = match self.cache_lookup(&step, &spec, &data) {
1164 Some((_key, Some(hit))) => {
1165 self.log
1166 .info("step.cache_hit", json!({"run": run_id, "step": step_id}));
1167 self.finish_step(run_id, step_id, StepStatus::Done, Some(hit), None, 0);
1168 return;
1169 }
1170 Some((key, None)) => Some(key),
1171 None => None,
1172 };
1173 if let Some(k) = cache_key
1174 && let Some(st) = self
1175 .runs
1176 .get_mut(run_id)
1177 .and_then(|r| r.steps.get_mut(step_id))
1178 {
1179 st.cache_key = Some(k);
1180 }
1181 if matches!(
1187 step.kind.as_str(),
1188 "http" | "mcp.tool" | "a2a.send" | "a2a.delegate"
1189 ) && let Some(cfg) = self.effective_breaker(&step)
1190 {
1191 let workflow = self
1192 .runs
1193 .get(run_id)
1194 .map(|r| r.workflow.clone())
1195 .unwrap_or_default();
1196 let key = super::breaker::key(&workflow, step_id);
1197 let mut st = self
1198 .durable
1199 .manifest()
1200 .breakers
1201 .get(&key)
1202 .cloned()
1203 .unwrap_or_else(|| json!({}));
1204 match super::breaker::gate(&mut st, cfg, now_ms()) {
1205 super::breaker::Gate::Proceed => {}
1206 super::breaker::Gate::Probe => {
1207 self.durable.manifest_update(|m| {
1210 m.breakers.insert(key.clone(), st);
1211 });
1212 self.log
1213 .info("breaker.probe", json!({"breaker": key, "run": run_id}));
1214 }
1215 super::breaker::Gate::FastFail { retry_in_ms } => {
1216 self.finish_step(
1217 run_id,
1218 step_id,
1219 StepStatus::Failed,
1220 None,
1221 Some(format!(
1222 "{} — failing fast; next probe in {}ms",
1223 super::breaker::OPEN_ERR,
1224 retry_in_ms
1225 )),
1226 0,
1227 );
1228 return;
1229 }
1230 }
1231 }
1232 match step.kind.as_str() {
1233 "checkpoint" => {
1234 self.checkpoint(true);
1240 self.finish_step(
1241 run_id,
1242 step_id,
1243 StepStatus::Done,
1244 Some(Value::Null),
1245 None,
1246 0,
1247 );
1248 }
1249 "noop" => self.finish_step(
1250 run_id,
1251 step_id,
1252 StepStatus::Done,
1253 Some(Value::Null),
1254 None,
1255 0,
1256 ),
1257 "assign" | "transform" => {
1258 let value = spec.get("value").cloned().unwrap_or(Value::Null);
1259 let key = spec
1260 .get("writes")
1261 .and_then(Value::as_str)
1262 .unwrap_or(step_id)
1263 .to_string();
1264 if let Some(schema) = self
1269 .definition_for_run(run_id)
1270 .and_then(|wf| wf.state.get(&key).and_then(|d| d.schema.clone()))
1271 && let Err(errs) = crate::jsonschema::validate(&schema, &value)
1272 {
1273 self.finish_step_pub(
1274 run_id,
1275 step_id,
1276 StepStatus::Failed,
1277 None,
1278 Some(format!(
1279 "assign: value does not match the schema declared for state \
1280 {key:?}: {}",
1281 errs.join("; ")
1282 )),
1283 0,
1284 );
1285 return;
1286 }
1287 let mode = spec
1288 .get("mode")
1289 .and_then(Value::as_str)
1290 .unwrap_or("overwrite")
1291 .to_string();
1292 self.runs
1293 .get_mut(run_id)
1294 .expect("present")
1295 .write_var(&key, value.clone(), &mode);
1296 self.finish_step(run_id, step_id, StepStatus::Done, Some(value), None, 0);
1297 }
1298 "template" => {
1299 let out = spec
1300 .get("text")
1301 .cloned()
1302 .or_else(|| spec.get("value").cloned())
1303 .unwrap_or(Value::String(String::new()));
1304 self.finish_step(run_id, step_id, StepStatus::Done, Some(out), None, 0);
1305 }
1306 "validate" => {
1307 let value = spec.get("value").cloned().unwrap_or(Value::Null);
1308 let schema = spec.get("schema").cloned().unwrap_or(json!({}));
1309 match crate::jsonschema::validate(&schema, &value) {
1310 Ok(()) => {
1311 self.finish_step(run_id, step_id, StepStatus::Done, Some(value), None, 0)
1312 }
1313 Err(e) => self.finish_step(
1314 run_id,
1315 step_id,
1316 StepStatus::Failed,
1317 Some(value),
1318 Some(format!(
1319 "validation failed: {}",
1320 crate::jsonschema::explain(&e)
1321 )),
1322 0,
1323 ),
1324 }
1325 }
1326 "assert" => {
1327 let cond = step
1328 .field_str("condition")
1329 .unwrap_or("false")
1330 .trim()
1331 .trim_start_matches("CEL:")
1332 .trim()
1333 .to_string();
1334 let vars: Vec<(&str, &Value)> = data.iter().map(|(k, v)| (k.as_str(), v)).collect();
1335 match crate::cel::eval_bool(&cond, &vars) {
1336 Ok(true) => self.finish_step(
1337 run_id,
1338 step_id,
1339 StepStatus::Done,
1340 Some(json!(true)),
1341 None,
1342 0,
1343 ),
1344 Ok(false) => self.finish_step(
1345 run_id,
1346 step_id,
1347 StepStatus::Failed,
1348 Some(json!(false)),
1349 Some(
1350 spec.get("message")
1351 .and_then(Value::as_str)
1352 .map(str::to_string)
1353 .unwrap_or_else(|| format!("assertion failed: {cond}")),
1354 ),
1355 0,
1356 ),
1357 Err(e) => self.finish_step(
1358 run_id,
1359 step_id,
1360 StepStatus::Failed,
1361 None,
1362 Some(format!("assert: {e}")),
1363 0,
1364 ),
1365 }
1366 }
1367 "fail" => {
1368 let msg = spec
1369 .get("message")
1370 .and_then(Value::as_str)
1371 .unwrap_or("deliberate failure")
1372 .to_string();
1373 self.finish_step(
1374 run_id,
1375 step_id,
1376 StepStatus::Failed,
1377 spec.get("code").cloned(),
1378 Some(msg),
1379 0,
1380 );
1381 }
1382 "emit" => {
1383 if let Some(stream) = spec.get("stream").and_then(Value::as_str) {
1387 let stream = stream.to_string();
1388 let subject = spec
1389 .get("subject")
1390 .and_then(Value::as_str)
1391 .unwrap_or("")
1392 .to_string();
1393 let correlation = spec
1394 .get("correlation")
1395 .and_then(Value::as_str)
1396 .map(str::to_string);
1397 let data = spec.get("data").cloned().unwrap_or(Value::Null);
1398 let id = crate::engine::run::idempotency_key(run_id, step_id);
1403 let source = self
1404 .runs
1405 .get(run_id)
1406 .map(|r| r.workflow.clone())
1407 .unwrap_or_default();
1408 match self.append_event(
1409 &stream,
1410 &subject,
1411 correlation.as_deref(),
1412 data,
1413 &id,
1414 &source,
1415 ) {
1416 Ok(seq) => {
1417 self.log.info(
1418 "stream.emit",
1419 json!({"run": run_id, "step": step_id, "stream": stream,
1420 "subject": subject, "seq": seq}),
1421 );
1422 let fwd = crate::runtime::streams::Forwarded {
1430 stream: &stream,
1431 subject: &subject,
1432 id: &id,
1433 seq,
1434 run_id,
1435 step_id,
1436 };
1437 if let Some(url) = spec
1438 .get("forward")
1439 .and_then(|f| f.get("webhook"))
1440 .and_then(Value::as_str)
1441 {
1442 let allow_private = spec
1446 .get("forward")
1447 .and_then(|f| f.get("allow_private"))
1448 .and_then(Value::as_bool)
1449 .unwrap_or(false);
1450 self.forward_event(url, &fwd, allow_private);
1451 }
1452 #[cfg(feature = "a2a")]
1453 if let Some(peer) = spec
1454 .get("forward")
1455 .and_then(|f| f.get("peer"))
1456 .and_then(Value::as_str)
1457 {
1458 let data = spec.get("data").cloned().unwrap_or(Value::Null);
1459 self.forward_event_peer(peer, &fwd, &data);
1460 }
1461 self.finish_step(
1462 run_id,
1463 step_id,
1464 StepStatus::Done,
1465 Some(json!({"id": id, "seq": seq, "stream": stream,
1466 "subject": subject})),
1467 None,
1468 0,
1469 );
1470 }
1471 Err(e) => {
1472 self.finish_step(run_id, step_id, StepStatus::Failed, None, Some(e), 0)
1473 }
1474 }
1475 return;
1476 }
1477 if let Some(n) = spec.get("note").and_then(Value::as_str) {
1478 let text = format!("run {run_id}: {n}");
1479 self.note_root(text);
1480 }
1481 if let Some(a) = spec.get("audit") {
1482 self.log.info(
1483 "audit.emit",
1484 json!({"run": run_id, "step": step_id, "audit": a}),
1485 );
1486 }
1487 self.finish_step(
1488 run_id,
1489 step_id,
1490 StepStatus::Done,
1491 spec.get("value").cloned().or(Some(Value::Null)),
1492 None,
1493 0,
1494 );
1495 }
1496 "finish" => {
1497 let status = match spec
1498 .get("status")
1499 .and_then(Value::as_str)
1500 .unwrap_or("completed")
1501 {
1502 "completed" => RunStatus::Completed,
1503 "refused" => RunStatus::Refused,
1504 "cancelled" => RunStatus::Cancelled,
1505 _ => RunStatus::Failed,
1506 };
1507 let output = spec.get("output").cloned();
1508 if matches!(status, RunStatus::Completed)
1515 && let Some(schema) = self
1516 .definition_for_run(run_id)
1517 .and_then(|wf| wf.outputs_schema.clone())
1518 {
1519 let value = output.clone().unwrap_or(Value::Null);
1520 if let Err(errs) = crate::jsonschema::validate(&schema, &value) {
1521 self.finish_step_pub(
1522 run_id,
1523 step_id,
1524 StepStatus::Failed,
1525 None,
1526 Some(format!(
1527 "finish: output does not match the workflow's declared \
1528 outputs.schema: {}",
1529 errs.join("; ")
1530 )),
1531 0,
1532 );
1533 return;
1534 }
1535 }
1536 let reason = spec
1537 .get("reason")
1538 .and_then(Value::as_str)
1539 .map(str::to_string);
1540 self.runs.get_mut(run_id).expect("present").end_step(
1541 step_id,
1542 StepStatus::Done,
1543 output.clone(),
1544 None,
1545 );
1546 self.runs
1547 .get_mut(run_id)
1548 .expect("present")
1549 .finish(status, output, reason);
1550 self.on_run_terminal(run_id);
1551 }
1552 "sleep" => {
1553 let ms = spec
1554 .get("duration")
1555 .map(crate::engine::model::duration_ms)
1556 .unwrap_or(Ok(0))
1557 .unwrap_or(0);
1558 match self.timers.arm(
1559 &self.durable,
1560 now_ms() + ms,
1561 json!({"kind": "step", "run": run_id, "step": step_id}),
1562 json!({"slept_ms": ms}),
1563 ) {
1564 Ok(id) => {
1565 self.runs.get_mut(run_id).expect("present").suspend_step(
1566 step_id,
1567 json!({"kind": "sleep", "timer": id, "deadline_ms": now_ms() + ms}),
1568 );
1569 self.checkpoint(false);
1570 }
1571 Err(e) => self.finish_step(
1572 run_id,
1573 step_id,
1574 StepStatus::Failed,
1575 None,
1576 Some(format!("sleep: {e}")),
1577 0,
1578 ),
1579 }
1580 }
1581 "tool" => {
1582 let name = spec
1583 .get("name")
1584 .and_then(Value::as_str)
1585 .unwrap_or("")
1586 .to_string();
1587 let args = spec.get("args").cloned().unwrap_or(json!({}));
1588 self.step_tool_call(run_id, step_id, &step_caller, &name, args);
1589 }
1590 "http" => self.step_http(run_id, step_id, &spec),
1591 k if k.starts_with("memory.")
1592 || k.starts_with("artifact.")
1593 || k.starts_with("knowledge.")
1594 || k.starts_with("search.") =>
1595 {
1596 let mut args = spec.clone();
1597 args.retain(|_, v| !v.is_null());
1599 self.step_tool_call(run_id, step_id, &step_caller, k, Value::Object(args));
1600 }
1601 "mcp.tool" => {
1602 let server = spec
1603 .get("server")
1604 .and_then(Value::as_str)
1605 .unwrap_or("")
1606 .to_string();
1607 let tool = spec
1608 .get("tool")
1609 .and_then(Value::as_str)
1610 .unwrap_or("")
1611 .to_string();
1612 let args = spec.get("args").cloned().unwrap_or(json!({}));
1613 if let Err(e) = self.service_rate_take(&server) {
1617 self.finish_step(run_id, step_id, StepStatus::Failed, None, Some(e), 0);
1618 return;
1619 }
1620 let Some(client) = self.mcp.get(&server).cloned() else {
1621 self.finish_step(
1622 run_id,
1623 step_id,
1624 StepStatus::Failed,
1625 None,
1626 Some(format!("mcp server {server:?} is not connected")),
1627 0,
1628 );
1629 return;
1630 };
1631 let key = spec
1641 .get("idempotency")
1642 .and_then(|i| i.get("value"))
1643 .and_then(Value::as_str)
1644 .map(str::to_string)
1645 .unwrap_or_else(|| crate::engine::run::idempotency_key(run_id, step_id));
1646 let meta = json!({"agent/idempotency_key": key, "agent/attempt": attempt, "agent/instance": self.instance, "agent/run": run_id});
1647 let timeout = step
1648 .timeout_ms
1649 .map(std::time::Duration::from_millis)
1650 .unwrap_or(
1651 self.settings
1652 .limits
1653 .step_timeout
1654 .map(|d| d.0)
1655 .unwrap_or(std::time::Duration::from_secs(600)),
1656 );
1657 let tx = self.events_tx.clone();
1658 let (r, s) = (run_id.to_string(), step_id.to_string());
1659 self.executing
1660 .insert(format!("{run_id}/{step_id}"), std::time::Instant::now());
1661 std::thread::Builder::new()
1662 .name(format!("step:{server}.{tool}"))
1663 .spawn(move || {
1664 let (output, is_error, error) = match client.call_tool_with_meta_within(
1665 &tool,
1666 Some(args),
1667 meta,
1668 timeout,
1669 ) {
1670 Ok(res) => {
1671 let v = super::worker::tool_result_value(&res);
1672 if res.is_error() {
1673 (v.clone(), true, Some(res.text()))
1674 } else {
1675 (v, false, None)
1676 }
1677 }
1678 Err(e) => (Value::Null, true, Some(format!("transport error: {e}"))),
1679 };
1680 let _ = tx.send(super::events::Event::StepDone {
1681 run: r,
1682 step: s,
1683 output,
1684 is_error,
1685 error,
1686 tokens: 0,
1687 });
1688 })
1689 .ok();
1690 }
1691 "agent" | "think" => self.step_turn(run_id, step_id, &step, &spec, &data),
1692 "foreach" | "batch" | "iterate" | "parallel" | "race" | "subgraph" => {
1693 self.nested_start(run_id, step_id, &step, &spec)
1694 }
1695 "wait" | "join" | "workflow" | "message" | "workflow.signal" | "workflow.wait"
1696 | "workflow.cancel" | "subagent" | "human" | "mcp.resource" | "a2a.delegate"
1697 | "a2a.send" | "a2a.wait" | "classify" | "extract" | "summarize" | "judge"
1698 | "route" => {
1699 self.execute_orchestration_step(run_id, step_id, &step, &spec, &data, &step_caller)
1700 }
1701 "switch" => {
1702 let on = spec.get("on").cloned().unwrap_or(Value::Null);
1703 let key = match &on {
1704 Value::String(x) => x.clone(),
1705 other => other.to_string(),
1706 };
1707 let cases = step
1708 .field("cases")
1709 .and_then(Value::as_object)
1710 .cloned()
1711 .unwrap_or_default();
1712 let target = cases
1713 .get(&key)
1714 .and_then(Value::as_str)
1715 .map(str::to_string)
1716 .or_else(|| step.field_str("default").map(str::to_string));
1717 match target {
1718 Some(t) => {
1719 let scope_prefix = step_id
1722 .rsplit_once('.')
1723 .map(|(p, _)| format!("{p}."))
1724 .unwrap_or_default();
1725 let mut skipped = Vec::new();
1726 let mut others: Vec<String> = cases
1729 .values()
1730 .filter_map(Value::as_str)
1731 .map(str::to_string)
1732 .collect();
1733 if let Some(d) = step.field_str("default") {
1734 others.push(d.to_string());
1735 }
1736 if let Some(run) = self.runs.get_mut(run_id) {
1737 for tid in others {
1738 if tid == t {
1739 continue;
1740 }
1741 let sid = format!("{scope_prefix}{tid}");
1742 if let Some(st) = run.steps.get_mut(&sid)
1743 && st.status == StepStatus::Pending
1744 {
1745 st.status = StepStatus::Pruned;
1748 skipped.push(sid);
1749 }
1750 }
1751 let sid = format!("{scope_prefix}{t}");
1752 if let Some(st) = run.steps.get_mut(&sid) {
1753 st.status = StepStatus::Pending;
1754 st.forced = true;
1755 }
1756 }
1757 self.finish_step(
1758 run_id,
1759 step_id,
1760 StepStatus::Done,
1761 Some(json!({"case": key, "goto": t, "skipped": skipped})),
1762 None,
1763 0,
1764 );
1765 }
1766 None if step.field_str("on_no_match") == Some("skip") => {
1770 let scope_prefix = step_id
1771 .rsplit_once('.')
1772 .map(|(p, _)| format!("{p}."))
1773 .unwrap_or_default();
1774 let mut skipped = Vec::new();
1775 let others: Vec<String> = cases
1776 .values()
1777 .filter_map(Value::as_str)
1778 .map(str::to_string)
1779 .collect();
1780 if let Some(run) = self.runs.get_mut(run_id) {
1781 for tid in others {
1782 let sid = format!("{scope_prefix}{tid}");
1783 if let Some(st) = run.steps.get_mut(&sid)
1784 && st.status == StepStatus::Pending
1785 {
1786 st.status = StepStatus::Pruned;
1787 skipped.push(sid);
1788 }
1789 }
1790 }
1791 self.finish_step(
1792 run_id,
1793 step_id,
1794 StepStatus::Done,
1795 Some(json!({"case": key, "matched": false, "skipped": skipped})),
1796 None,
1797 0,
1798 );
1799 }
1800 None => self.finish_step(
1801 run_id,
1802 step_id,
1803 StepStatus::Failed,
1804 Some(json!({"case": key})),
1805 Some(format!("switch: no case for {key:?} and no default")),
1806 0,
1807 ),
1808 }
1809 }
1810 "map" | "filter" | "reduce" | "sort" | "dedupe" | "chunk" | "parse" => {
1811 let out = match step.kind.as_str() {
1812 "map" => crate::engine::data::map(
1813 spec.get("over").unwrap_or(&Value::Null),
1814 step.field_str("expr").unwrap_or(""),
1815 step.field_str("as").unwrap_or("item"),
1816 &data,
1817 ),
1818 "filter" => crate::engine::data::filter(
1819 spec.get("over").unwrap_or(&Value::Null),
1820 step.field_str("expr").unwrap_or(""),
1821 step.field_str("as").unwrap_or("item"),
1822 &data,
1823 ),
1824 "reduce" => crate::engine::data::reduce(
1825 spec.get("over").unwrap_or(&Value::Null),
1826 step.field_str("expr").unwrap_or(""),
1827 spec.get("initial").cloned().unwrap_or(Value::Null),
1828 step.field_str("as").unwrap_or("item"),
1829 step.field_str("acc").unwrap_or("acc"),
1830 &data,
1831 ),
1832 "sort" => crate::engine::data::sort(
1833 spec.get("over").unwrap_or(&Value::Null),
1834 spec.get("by").and_then(Value::as_str),
1835 spec.get("order").and_then(Value::as_str),
1836 ),
1837 "dedupe" => crate::engine::data::dedupe(
1838 spec.get("over").unwrap_or(&Value::Null),
1839 spec.get("by").and_then(Value::as_str),
1840 ),
1841 "chunk" => crate::engine::data::chunk(
1842 spec.get("value").unwrap_or(&Value::Null),
1843 spec.get("by").and_then(Value::as_str),
1844 spec.get("size").and_then(Value::as_u64).unwrap_or(0) as usize,
1845 spec.get("overlap").and_then(Value::as_u64).unwrap_or(0) as usize,
1846 ),
1847 _ => crate::engine::data::parse(
1848 spec.get("text").and_then(Value::as_str).unwrap_or(""),
1849 spec.get("format").and_then(Value::as_str),
1850 ),
1851 };
1852 match out {
1853 Ok(v) => self.finish_step(run_id, step_id, StepStatus::Done, Some(v), None, 0),
1854 Err(e) => {
1855 self.finish_step(run_id, step_id, StepStatus::Failed, None, Some(e), 0)
1856 }
1857 }
1858 }
1859 other => self.finish_step(
1860 run_id,
1861 step_id,
1862 StepStatus::Failed,
1863 None,
1864 Some(format!(
1865 "step kind {other:?} is not executable in this build"
1866 )),
1867 0,
1868 ),
1869 }
1870 }
1871
1872 fn step_tool_call(
1874 &mut self,
1875 run_id: &str,
1876 step_id: &str,
1877 caller: &ToolCaller,
1878 name: &str,
1879 args: Value,
1880 ) {
1881 match self.execute_tool(caller, name, args) {
1882 ToolOutcome::Ready(v, is_error) => {
1883 let err = is_error.then(|| match &v {
1884 Value::String(s) => s.clone(),
1885 o => o.to_string(),
1886 });
1887 self.finish_step(
1888 run_id,
1889 step_id,
1890 if is_error {
1891 StepStatus::Failed
1892 } else {
1893 StepStatus::Done
1894 },
1895 Some(v),
1896 err,
1897 0,
1898 );
1899 }
1900 ToolOutcome::Deferred(kind) => {
1901 let wait = match &kind {
1902 PendingKind::Timer { id } => json!({"kind": "timer", "timer": id}),
1903 PendingKind::Subagent { handle } => {
1904 json!({"kind": "subagent", "handle": handle})
1905 }
1906 PendingKind::Think { .. } => json!({"kind": "think"}),
1907 PendingKind::Run { run, .. } => json!({"kind": "run", "run": run}),
1908 PendingKind::Await {
1909 condition,
1910 deadline_ms,
1911 } => {
1912 json!({"kind": "await", "condition": condition, "deadline_ms": deadline_ms})
1913 }
1914 PendingKind::Human {
1915 task, deadline_ms, ..
1916 } => {
1917 json!({"kind": "human", "task": task, "deadline_ms": deadline_ms})
1918 }
1919 };
1920 self.runs
1921 .get_mut(run_id)
1922 .expect("present")
1923 .suspend_step(step_id, wait);
1924 if !matches!(kind, PendingKind::Timer { .. }) {
1925 self.push_pending(super::reactor::PendingTool {
1926 target: Target::Step(run_id.to_string(), step_id.to_string()),
1927 name: name.to_string(),
1928 kind,
1929 started_ms: now_ms(),
1930 });
1931 }
1932 self.checkpoint(false);
1933 }
1934 ToolOutcome::Executing => {
1935 self.executing
1936 .insert(format!("{run_id}/{step_id}"), std::time::Instant::now());
1937 }
1938 }
1939 }
1940
1941 pub(crate) fn step_turn_pub(
1942 &mut self,
1943 run_id: &str,
1944 step_id: &str,
1945 step: &Step,
1946 spec: &Map<String, Value>,
1947 data: &template::Data,
1948 ) {
1949 self.step_turn(run_id, step_id, step, spec, data)
1950 }
1951
1952 fn step_turn(
1954 &mut self,
1955 run_id: &str,
1956 step_id: &str,
1957 step: &Step,
1958 spec: &Map<String, Value>,
1959 data: &template::Data,
1960 ) {
1961 let is_think = step.kind == "think";
1962 let prompt = if is_think {
1963 spec.get("prompt").and_then(Value::as_str).unwrap_or("")
1964 } else {
1965 spec.get("instruction")
1966 .and_then(Value::as_str)
1967 .unwrap_or("")
1968 }
1969 .to_string();
1970 let output_schema = spec.get("output_schema").cloned();
1971 let mut messages = Vec::new();
1972 if let Some(reads) = spec.get("reads").and_then(Value::as_array) {
1974 for path in reads.iter().filter_map(Value::as_str) {
1975 if let Some(v) = template::lookup(path, data) {
1976 messages.push(Msg::system(format!("{path} = {v}")));
1977 }
1978 }
1979 }
1980 let step_template: Option<String> = spec
1985 .get("context")
1986 .and_then(Value::as_object)
1987 .and_then(|o| o.get("template"))
1988 .and_then(Value::as_str)
1989 .map(str::to_string);
1990 let seed_list = spec.get("context").and_then(Value::as_array).or_else(|| {
1991 spec.get("context")
1992 .and_then(Value::as_object)
1993 .and_then(|o| o.get("seed"))
1994 .and_then(Value::as_array)
1995 });
1996 if let Some(seed) = seed_list {
1997 for m in seed {
1998 match (m["role"].as_str(), m["content"].as_str()) {
1999 (Some("system"), Some(c)) => messages.push(Msg::system(c)),
2000 (Some("assistant"), Some(c)) => {
2001 messages.push(Msg::assistant(Some(c.to_string()), vec![]))
2002 }
2003 (_, Some(c)) => messages.push(Msg::user(c, None)),
2004 _ => {}
2005 }
2006 }
2007 }
2008 let mut user = prompt.clone();
2009 if let Some(c) = spec.get("output_contract").and_then(Value::as_str) {
2010 user.push_str(&format!("\n\nOutput contract:\n{c}"));
2011 }
2012 if let Some(s) = &output_schema {
2013 user.push_str(&format!(
2014 "\n\nReply with ONLY one JSON object matching this JSON Schema:\n{s}"
2015 ));
2016 }
2017 messages.push(Msg::user(user, None));
2018 let skill_bodies: Vec<String> = step
2020 .skills
2021 .iter()
2022 .chain(
2023 spec.get("skills")
2024 .and_then(Value::as_array)
2025 .map(|a| {
2026 a.iter()
2027 .filter_map(Value::as_str)
2028 .map(str::to_string)
2029 .collect::<Vec<_>>()
2030 })
2031 .unwrap_or_default()
2032 .iter(),
2033 )
2034 .filter_map(|name| {
2035 let mcp = self.mcp.clone();
2036 let resolver = move |server: &str| -> Option<
2037 std::sync::Arc<dyn crate::context::skills::SkillServer>,
2038 > {
2039 mcp.get(server).map(|c| {
2040 c.clone() as std::sync::Arc<dyn crate::context::skills::SkillServer>
2041 })
2042 };
2043 self.skills
2044 .load(name, None, &resolver)
2045 .ok()
2046 .map(|b| format!("### Skill: {}\n{}", b.name, b.body))
2047 })
2048 .collect();
2049 let extra = if skill_bodies.is_empty() {
2050 None
2051 } else {
2052 Some(format!(
2053 "Loaded skills — follow these instructions when relevant:\n{}",
2054 skill_bodies.join("\n\n")
2055 ))
2056 };
2057 let system = match spec.get("system").and_then(Value::as_str) {
2058 Some(s) => s.to_string(),
2059 None if is_think => format!(
2060 "You are the reasoning module of {}. Reply with {}. No tools are available.",
2061 self.instance,
2062 if output_schema.is_some() {
2063 "ONLY one JSON object matching the schema"
2064 } else {
2065 "your conclusion"
2066 }
2067 ),
2068 None => self.system_prompt_named(None, extra.as_deref(), step_template.as_deref()),
2069 };
2070 let (tools, internal, routes) = if is_think {
2071 (Vec::new(), Vec::new(), BTreeMap::new())
2072 } else {
2073 let allow: Option<Vec<String>> = spec.get("tools").and_then(Value::as_array).map(|a| {
2074 a.iter()
2075 .filter_map(Value::as_str)
2076 .map(str::to_string)
2077 .collect()
2078 });
2079 self.tool_plan(&Caller::Workflow, allow.as_deref())
2080 };
2081 let servers: Vec<String> = match spec.get("servers").and_then(Value::as_array) {
2082 Some(a) => a
2083 .iter()
2084 .filter_map(Value::as_str)
2085 .map(str::to_string)
2086 .collect(),
2087 None => routes
2088 .values()
2089 .map(|(s, _)| s.clone())
2090 .collect::<std::collections::BTreeSet<_>>()
2091 .into_iter()
2092 .collect(),
2093 };
2094 let est: u64 = messages.iter().map(Msg::est_tokens).sum::<u64>()
2097 + crate::context::tokens::estimate(&system)
2098 + 4096;
2099 let scopes = self.run_scopes(run_id);
2100 let reservation = match self.governor.admit(est, &scopes, now_ms()) {
2101 Admission::Ok { reservation, model } => {
2102 if let Some(m) = model {
2103 self.log.info(
2104 "budget.degraded",
2105 json!({"run": run_id, "step": step_id, "model": m}),
2106 );
2107 }
2108 Some(reservation)
2109 }
2110 Admission::Wait { until_ms, reason } => {
2111 self.log.info(
2112 "budget.wait",
2113 json!({"run": run_id, "step": step_id, "until_ms": until_ms, "reason": reason}),
2114 );
2115 crate::state::kill_point("budget.waiting");
2116 match self.timers.arm(
2117 &self.durable,
2118 until_ms,
2119 json!({"kind": "step_budget", "run": run_id, "step": step_id}),
2120 Value::Null,
2121 ) {
2122 Ok(id) => {
2123 self.runs.get_mut(run_id).expect("present").suspend_step(step_id, json!({"kind": "waiting_budget", "timer": id, "until_ms": until_ms, "reason": reason}));
2124 self.checkpoint(false);
2125 }
2126 Err(e) => self.finish_step(
2127 run_id,
2128 step_id,
2129 StepStatus::Failed,
2130 None,
2131 Some(format!("budget wait: {e}")),
2132 0,
2133 ),
2134 }
2135 return;
2136 }
2137 Admission::Refuse { reason } | Admission::Fail { reason } => {
2138 self.finish_step(run_id, step_id, StepStatus::Failed, None, Some(reason), 0);
2139 return;
2140 }
2141 };
2142 let node_model = spec
2147 .get("model")
2148 .and_then(Value::as_str)
2149 .map(str::to_string);
2150 let limits = spec.get("limits").cloned().unwrap_or(json!({}));
2151 let max_steps = limits
2152 .get("steps")
2153 .and_then(Value::as_u64)
2154 .map(|s| s as u32)
2155 .unwrap_or(self.settings.limits.run.steps());
2156 let max_tokens = step
2157 .budget
2158 .or_else(|| limits.get("tokens").and_then(Value::as_u64))
2159 .unwrap_or(self.settings.limits.run.tokens());
2160 let deadline_ms = step.timeout_ms.unwrap_or(
2161 self.settings
2162 .limits
2163 .step_timeout
2164 .map(|d| d.0.as_millis() as u64)
2165 .unwrap_or(600_000),
2166 );
2167 let spec = TurnSpec {
2168 kind: if is_think {
2169 TurnKind::Think
2170 } else {
2171 TurnKind::Agent
2172 },
2173 system,
2174 messages,
2175 tools,
2176 internal,
2177 mcp_routes: routes,
2178 output_schema,
2179 max_rounds: if is_think { 3 } else { 0 },
2180 budget_admission: self.governor.is_active(),
2181 idempotency_prefix: format!("{}/{run_id}/{step_id}", self.instance),
2182 tool_meta: Some(
2183 json!({"agent/run": run_id, "agent/step": step_id, "agent/instance": self.instance}),
2184 ),
2185 temperature: None,
2186 max_tokens_per_call: 0,
2187 turn_id: format!(
2188 "{run_id}/{step_id}#{}",
2189 self.runs
2190 .get(run_id)
2191 .and_then(|r| r.step(step_id))
2192 .map(|s| s.attempt)
2193 .unwrap_or(1)
2194 ),
2195 };
2196 let launch = super::turns::TurnLaunch {
2197 spec,
2198 kind: ChildKind::StepTurn {
2199 run: run_id.to_string(),
2200 step: step_id.to_string(),
2201 reservation,
2202 },
2203 servers,
2204 max_steps,
2205 max_tokens,
2206 deadline_ms,
2207 agent_path: format!("run/{run_id}/{step_id}"),
2208 model: node_model,
2209 };
2210 match self.spawn_turn(launch) {
2211 Ok(node) => {
2212 if let Some(st) = self
2213 .runs
2214 .get_mut(run_id)
2215 .and_then(|r| r.steps.get_mut(step_id))
2216 {
2217 st.worker = Some(node.0.to_string());
2218 }
2219 self.log.info(
2220 "step.turn.spawn",
2221 json!({"run": run_id, "step": step_id, "node": node.0}),
2222 );
2223 }
2224 Err(e) => {
2225 if let Some(r) = reservation {
2226 self.governor.release(r);
2227 }
2228 self.finish_step(
2229 run_id,
2230 step_id,
2231 StepStatus::Failed,
2232 None,
2233 Some(format!("spawn: {e}")),
2234 0,
2235 );
2236 }
2237 }
2238 }
2239
2240 fn run_scopes(&mut self, run_id: &str) -> Vec<String> {
2242 let mut scopes = self.principal_scopes(
2246 self.runs
2247 .get(run_id)
2248 .and_then(|r| r.principal.clone())
2249 .as_deref(),
2250 );
2251 let Some(wf) = self.definition_for_run(run_id) else {
2252 return scopes;
2253 };
2254 if let Some(b) = wf
2255 .limits
2256 .budget
2257 .as_ref()
2258 .and_then(|b| serde_json::from_value::<crate::config::v2::Budget>(b.clone()).ok())
2259 {
2260 let key = format!("run:{run_id}");
2261 self.governor.ensure_scope(&key, &b);
2262 scopes.push(key);
2263 }
2264 scopes
2265 }
2266
2267 pub(crate) fn on_step_done(
2271 &mut self,
2272 run_id: &str,
2273 step_id: &str,
2274 output: Value,
2275 is_error: bool,
2276 error: Option<String>,
2277 tokens: u64,
2278 ) {
2279 self.executing.remove(&format!("{run_id}/{step_id}"));
2280 self.finish_step(
2281 run_id,
2282 step_id,
2283 if is_error {
2284 StepStatus::Failed
2285 } else {
2286 StepStatus::Done
2287 },
2288 Some(output),
2289 error,
2290 tokens,
2291 );
2292 }
2293
2294 pub(crate) fn on_step_turn_done(&mut self, run_id: &str, step_id: &str, turn: TurnResult) {
2296 let tokens = turn.usage.total();
2297 if turn.status == "completed" {
2298 let output = turn
2299 .value
2300 .clone()
2301 .or_else(|| turn.finish.as_ref().and_then(|f| f.get("output").cloned()))
2302 .or_else(|| turn.text.clone().map(Value::String))
2303 .unwrap_or(Value::Null);
2304 let failed = turn
2306 .finish
2307 .as_ref()
2308 .and_then(|f| f.get("status"))
2309 .and_then(Value::as_str)
2310 .is_some_and(|s| s != "completed");
2311 if failed {
2312 let reason = turn
2313 .finish
2314 .as_ref()
2315 .and_then(|f| f.get("reason"))
2316 .and_then(Value::as_str)
2317 .unwrap_or("agent finished with a non-completed status")
2318 .to_string();
2319 self.finish_step(
2320 run_id,
2321 step_id,
2322 StepStatus::Failed,
2323 Some(output),
2324 Some(reason),
2325 tokens,
2326 );
2327 } else {
2328 self.finish_step(
2329 run_id,
2330 step_id,
2331 StepStatus::Done,
2332 Some(output),
2333 None,
2334 tokens,
2335 );
2336 }
2337 } else {
2338 let status = if turn.status == "deadline" {
2339 StepStatus::Timeout
2340 } else {
2341 StepStatus::Failed
2342 };
2343 self.finish_step(
2344 run_id,
2345 step_id,
2346 status,
2347 turn.value
2348 .clone()
2349 .or_else(|| turn.text.clone().map(Value::String)),
2350 Some(format!(
2351 "turn {}{}",
2352 turn.status,
2353 turn.error
2354 .as_deref()
2355 .map(|e| format!(": {e}"))
2356 .unwrap_or_default()
2357 )),
2358 tokens,
2359 );
2360 }
2361 }
2362
2363 pub(crate) fn on_step_timer(
2365 &mut self,
2366 run_id: &str,
2367 step_id: &str,
2368 budget: bool,
2369 payload: &Value,
2370 ) {
2371 if budget {
2372 if let Some(st) = self
2373 .runs
2374 .get_mut(run_id)
2375 .and_then(|r| r.steps.get_mut(step_id))
2376 {
2377 st.status = StepStatus::Pending;
2378 st.wait = None;
2379 }
2380 if let Some(r) = self.runs.get_mut(run_id) {
2381 r.touch();
2382 }
2383 return;
2384 }
2385 self.finish_step(
2386 run_id,
2387 step_id,
2388 StepStatus::Done,
2389 Some(payload.clone()),
2390 None,
2391 0,
2392 );
2393 }
2394
2395 pub(crate) fn finish_step_pub(
2397 &mut self,
2398 run_id: &str,
2399 step_id: &str,
2400 status: StepStatus,
2401 output: Option<Value>,
2402 error: Option<String>,
2403 tokens: u64,
2404 ) {
2405 self.finish_step(run_id, step_id, status, output, error, tokens)
2406 }
2407
2408 fn finish_step(
2409 &mut self,
2410 run_id: &str,
2411 step_id: &str,
2412 status: StepStatus,
2413 output: Option<Value>,
2414 error: Option<String>,
2415 tokens: u64,
2416 ) {
2417 self.resched = true;
2420 let Some(wf) = self
2421 .runs
2422 .get(run_id)
2423 .and_then(|_| self.definition_for_run(run_id))
2424 else {
2425 return;
2426 };
2427 let Some((step, scope)) = self
2428 .runs
2429 .get(run_id)
2430 .and_then(|r| self.resolve_step(&wf, r, step_id))
2431 else {
2432 return;
2433 };
2434 {
2435 let run = self.runs.get_mut(run_id).expect("present");
2436 if run.status.is_terminal() {
2437 return; }
2439 run.tokens += tokens;
2440 }
2441 crate::obs::metrics::record_step(match status {
2442 StepStatus::Done => "done",
2443 StepStatus::Failed => "failed",
2444 _ => "other",
2445 });
2446 let output = match output {
2451 Some(v) if !v.is_null() => {
2452 let cap = self.settings.limits.inline_max_bytes.unwrap_or(65_536) as usize;
2453 if v.to_string().len() > cap {
2454 match self.artifacts.create(
2455 &self.durable,
2456 super::artifacts::NewArtifact {
2457 name: &format!("{run_id}/{step_id}/output.json"),
2458 mime: Some("application/json"),
2459 content: v.clone(),
2460 created_by: Some("engine"),
2461 sensitive: false,
2462 owner: Some(run_id),
2463 },
2464 ) {
2465 Ok(meta) => {
2466 self.log.info("step.output.artifact", json!({"run": run_id, "step": step_id, "artifact": meta["id"], "size": meta["size"]}));
2467 Some(json!({"$artifact": meta["id"], "size": meta["size"]}))
2468 }
2469 Err(e) => {
2470 self.log.warn(
2471 "step.output.artifact_fail",
2472 json!({"run": run_id, "step": step_id, "err": e}),
2473 );
2474 Some(v)
2475 }
2476 }
2477 } else {
2478 Some(v)
2479 }
2480 }
2481 other => other,
2482 };
2483 let (status, error) = match (&status, &step.output_schema, &output) {
2486 (StepStatus::Done, Some(schema), Some(out)) => {
2487 match crate::jsonschema::validate(schema, out) {
2488 Ok(()) => (status, error),
2489 Err(e) => (
2490 StepStatus::Failed,
2491 Some(format!(
2492 "output does not match output_schema: {}",
2493 crate::jsonschema::explain(&e)
2494 )),
2495 ),
2496 }
2497 }
2498 _ => (status, error),
2499 };
2500 let attempt = self
2501 .runs
2502 .get(run_id)
2503 .and_then(|r| r.step(step_id))
2504 .map(|s| s.attempt)
2505 .unwrap_or(1);
2506 self.log.info("step.done", json!({"run": run_id, "step": step_id, "status": status, "attempt": attempt, "tokens": tokens, "err": error}));
2507 if matches!(
2511 step.kind.as_str(),
2512 "http" | "mcp.tool" | "a2a.send" | "a2a.delegate"
2513 ) && let Some(cfg) = self.effective_breaker(&step)
2514 && !matches!(status, StepStatus::Failed | StepStatus::Timeout if error
2515 .as_deref()
2516 .is_some_and(|e| e.starts_with(super::breaker::OPEN_ERR)))
2517 && matches!(
2518 status,
2519 StepStatus::Done | StepStatus::Failed | StepStatus::Timeout
2520 )
2521 {
2522 let workflow = self
2523 .runs
2524 .get(run_id)
2525 .map(|r| r.workflow.clone())
2526 .unwrap_or_default();
2527 let key = super::breaker::key(&workflow, step_id);
2528 let mut st = self
2529 .durable
2530 .manifest()
2531 .breakers
2532 .get(&key)
2533 .cloned()
2534 .unwrap_or_else(|| json!({}));
2535 let t = super::breaker::record(&mut st, cfg, status == StepStatus::Done, now_ms());
2536 self.durable.manifest_update(|m| {
2537 m.breakers.insert(key.clone(), st);
2538 });
2539 match t {
2540 super::breaker::Transition::Opened { fails } => self.log.warn(
2541 "breaker.open",
2542 json!({"breaker": key, "consecutive_failures": fails,
2543 "cooldown": format!("{}ms", cfg.cooldown_ms)}),
2544 ),
2545 super::breaker::Transition::Reopened => self.log.warn(
2546 "breaker.reopen",
2547 json!({"breaker": key, "probe_failed": true}),
2548 ),
2549 super::breaker::Transition::Closed => {
2550 self.log.info("breaker.closed", json!({"breaker": key}))
2551 }
2552 super::breaker::Transition::None => {}
2553 }
2554 }
2555 #[cfg(feature = "a2a")]
2556 self.feed_push(
2557 "step",
2558 crate::runtime::a2a_server::FeedVis::Operator,
2559 json!({"run": run_id, "step": step_id, "phase": "done",
2560 "status": crate::runtime::nested::StatusLabel::as_label(&status), "attempt": attempt, "tokens": tokens,
2561 "err": error.as_deref().map(|e| {
2564 e.chars().take(2048).collect::<String>()
2565 })}),
2566 );
2567 if matches!(status, StepStatus::Failed | StepStatus::Timeout) {
2568 if let Some(retry) = &step.retry
2570 && attempt <= retry.max
2571 {
2572 let base = retry
2579 .backoff_ms
2580 .saturating_mul(1u64 << (attempt.saturating_sub(1)).min(10));
2581 let backoff = if base == 0 {
2582 0
2583 } else {
2584 let mut h: u64 = 1469598103934665603;
2585 for b in run_id.bytes().chain(step_id.bytes()).chain([attempt as u8]) {
2586 h ^= b as u64;
2587 h = h.wrapping_mul(1099511628211);
2588 }
2589 let spread = (base / 5).max(1);
2591 base.saturating_sub(spread) + (h % (spread * 2 + 1))
2592 };
2593 self.log.info("step.retry", json!({"run": run_id, "step": step_id, "attempt": attempt, "backoff_ms": backoff}));
2594 if backoff == 0 {
2595 if let Some(st) = self
2596 .runs
2597 .get_mut(run_id)
2598 .and_then(|r| r.steps.get_mut(step_id))
2599 {
2600 st.status = StepStatus::Pending;
2601 st.error = error;
2602 }
2603 } else {
2604 match self.timers.arm(
2605 &self.durable,
2606 now_ms() + backoff,
2607 json!({"kind": "step_budget", "run": run_id, "step": step_id}),
2608 Value::Null,
2609 ) {
2610 Ok(id) => {
2611 self.runs.get_mut(run_id).expect("present").suspend_step(
2612 step_id,
2613 json!({"kind": "retry_backoff", "timer": id, "error": error}),
2614 );
2615 }
2616 Err(_) => {
2617 if let Some(st) = self
2618 .runs
2619 .get_mut(run_id)
2620 .and_then(|r| r.steps.get_mut(step_id))
2621 {
2622 st.status = StepStatus::Pending;
2623 }
2624 }
2625 }
2626 }
2627 self.checkpoint(false);
2628 return;
2629 }
2630 let err_text = error.clone().unwrap_or_else(|| "failed".into());
2631 self.runs
2632 .get_mut(run_id)
2633 .expect("present")
2634 .end_step(step_id, status, output, error);
2635 if status == StepStatus::Timeout
2643 && let Some(t) = step.field_str("on_timeout")
2644 {
2645 let target = match &scope {
2646 Some(sc) => super::nested::scoped_id(&sc.parent, t),
2647 None => t.to_string(),
2648 };
2649 if let Some(st) = self
2650 .runs
2651 .get_mut(run_id)
2652 .and_then(|r| r.steps.get_mut(&target))
2653 {
2654 st.status = StepStatus::Pending;
2655 st.forced = true;
2656 }
2657 self.log.info(
2658 "step.timeout_routed",
2659 json!({"run": run_id, "step": step_id, "to": t}),
2660 );
2661 crate::state::kill_point("step.before_done");
2662 self.checkpoint(false);
2663 if scope.is_some() {
2664 self.on_scoped_step_done(run_id, step_id);
2665 }
2666 return;
2667 }
2668 if let Some(sc) = &scope {
2669 match &step.on_error {
2672 OnError::Continue => {
2673 if let Some(st) = self
2674 .runs
2675 .get_mut(run_id)
2676 .and_then(|r| r.steps.get_mut(step_id))
2677 {
2678 st.status = StepStatus::Done;
2679 st.error = Some(err_text.clone());
2680 if st.output.is_none() {
2681 st.output = Some(json!({"error": err_text}));
2682 }
2683 }
2684 }
2685 OnError::Goto(t) => {
2686 let sid = super::nested::scoped_id(&sc.parent, t);
2687 if let Some(st) = self
2688 .runs
2689 .get_mut(run_id)
2690 .and_then(|r| r.steps.get_mut(&sid))
2691 {
2692 st.status = StepStatus::Pending;
2693 st.forced = true;
2694 }
2695 }
2696 OnError::Fail => {}
2697 }
2698 crate::state::kill_point("step.before_done");
2699 if !crate::engine::model::pure_data_kind(&step.kind) {
2700 self.checkpoint(false);
2701 }
2702 self.on_scoped_step_done(run_id, step_id);
2703 return;
2704 }
2705 let routed = run::route_failure(
2706 &wf,
2707 self.runs.get_mut(run_id).expect("present"),
2708 &step,
2709 &err_text,
2710 );
2711 match routed {
2712 Ok(next) => {
2713 if !next.is_empty() {
2714 self.log.info(
2715 "step.goto",
2716 json!({"run": run_id, "from": step_id, "to": next}),
2717 );
2718 }
2719 }
2720 Err(reason) => {
2721 self.cancel_children_of_run(run_id, "run failed");
2722 self.runs.get_mut(run_id).expect("present").finish(
2723 RunStatus::Failed,
2724 None,
2725 Some(reason),
2726 );
2727 self.on_run_terminal(run_id);
2728 return;
2729 }
2730 }
2731 if let OnError::Continue = step.on_error {
2732 }
2734 } else {
2735 if step.cache.is_some()
2737 && status == StepStatus::Done
2738 && let Some(key) = self
2739 .runs
2740 .get(run_id)
2741 .and_then(|r| r.steps.get(step_id))
2742 .and_then(|st| st.cache_key.clone())
2743 && let Some(out) = &output
2744 {
2745 self.cache_store(&key, out);
2746 }
2747 self.runs
2748 .get_mut(run_id)
2749 .expect("present")
2750 .end_step(step_id, status, output, error);
2751 }
2752 crate::state::kill_point("step.before_done");
2753 let terminal_now = self
2756 .runs
2757 .get(run_id)
2758 .is_some_and(|r| r.status.is_terminal());
2759 if terminal_now || !crate::engine::model::pure_data_kind(&step.kind) {
2760 self.checkpoint(false);
2761 }
2762 if scope.is_some() {
2763 self.on_scoped_step_done(run_id, step_id);
2764 }
2765 }
2766
2767 fn evict_terminal_runs(&mut self) {
2777 let policy = &self.settings.store.retention.runs;
2778 let keep_last = policy.keep_last;
2779 let ttl_ms = policy.ttl.as_ref().map(|d| d.0.as_millis() as u64);
2780 if keep_last.is_none() && ttl_ms.is_none() {
2781 return;
2782 }
2783 let now = now_ms();
2784 let mut terminal: Vec<(String, u64)> = self
2786 .runs
2787 .values()
2788 .filter(|r| r.status.is_terminal())
2789 .map(|r| (r.id.clone(), r.finished.unwrap_or(0)))
2790 .collect();
2791 terminal.sort_by_key(|(_, finished)| std::cmp::Reverse(*finished));
2792
2793 let mut drop: Vec<String> = Vec::new();
2794 for (i, (id, finished)) in terminal.iter().enumerate() {
2795 let over_count = keep_last.is_some_and(|k| i >= k as usize);
2796 let over_age = ttl_ms.is_some_and(|t| now.saturating_sub(*finished) > t);
2797 if over_count || over_age {
2798 drop.push(id.clone());
2799 }
2800 }
2801 for id in drop {
2802 let was_durable = self.runs.get(&id).is_none_or(|r| r.durable);
2804 self.runs.remove(&id);
2805 if was_durable && let Err(e) = self.durable.delete(crate::state::Kind::Run, &id) {
2806 self.log
2807 .warn("run.evict.fail", json!({"run": id, "err": e.to_string()}));
2808 continue;
2809 }
2810 self.log.info("run.evicted", json!({"run": id}));
2811 }
2812 }
2813
2814 pub(crate) fn on_run_terminal(&mut self, run_id: &str) {
2815 let Some(run) = self.runs.get(run_id) else {
2816 return;
2817 };
2818 let (status, output, error, workflow) = (
2819 run.status,
2820 run.output.clone(),
2821 run.error.clone(),
2822 run.workflow.clone(),
2823 );
2824 #[cfg(feature = "a2a")]
2825 let a2a_task = run.task.clone();
2826 let evict_after = true;
2832 #[cfg(feature = "a2a")]
2834 self.webhook_sync_reply(run_id);
2835 if let Some(parent) = self.runs.get(run_id).and_then(|r| r.parent.clone())
2837 && let (Some(pr), Some(ps)) = (
2838 parent["run"].as_str().map(str::to_string),
2839 parent["step"].as_str().map(str::to_string),
2840 )
2841 && self
2842 .runs
2843 .get(&pr)
2844 .and_then(|r| r.steps.get(&ps))
2845 .is_some_and(|st| {
2846 st.status == StepStatus::Suspended
2847 && st.wait.as_ref().is_some_and(|w| w["kind"] == "child_run")
2848 })
2849 {
2850 self.finish_step_pub(
2851 &pr,
2852 &ps,
2853 if status == RunStatus::Completed {
2854 StepStatus::Done
2855 } else {
2856 StepStatus::Failed
2857 },
2858 Some(json!({"run": run_id, "status": status, "output": output, "error": error})),
2859 (status != RunStatus::Completed).then(|| {
2860 error
2861 .clone()
2862 .unwrap_or_else(|| format!("child run {}", status.as_str()))
2863 }),
2864 0,
2865 );
2866 }
2867 self.counters.runs_finished += 1;
2868 crate::obs::metrics::record_run(match status {
2869 RunStatus::Completed => crate::obs::metrics::RunOutcome::Completed,
2870 RunStatus::Cancelled => crate::obs::metrics::RunOutcome::Killed,
2871 _ => crate::obs::metrics::RunOutcome::Failed,
2872 });
2873 crate::obs::metrics::record_run_status(status.as_str());
2874 self.log.info("run.done", json!({"run": run_id, "workflow": workflow, "status": status, "err": error, "output": if self.log.content_capture() { output.clone().unwrap_or(Value::Null) } else { Value::Null }}));
2875 self.governor.drop_scope(&format!("run:{run_id}"));
2876 self.retire_sweep();
2877 if let Some(hash) = self.runs.get(run_id).map(|r| r.workflow_hash.clone())
2881 && !self
2882 .runs
2883 .values()
2884 .any(|r| !r.status.is_terminal() && r.workflow_hash == hash)
2885 {
2886 let _ = self.durable.delete(
2887 crate::state::Kind::Memory,
2888 &format!("{}{hash}", super::retire::PIN_PREFIX),
2889 );
2890 self.pin_written.remove(&hash);
2891 }
2892 let waiting: Vec<Target> = self
2894 .pending
2895 .iter()
2896 .filter(|p| matches!(&p.kind, PendingKind::Run { run, .. } if run == run_id))
2897 .map(|p| p.target.clone())
2898 .collect();
2899 self.pending
2900 .retain(|p| !matches!(&p.kind, PendingKind::Run { run, .. } if run == run_id));
2901 for t in waiting {
2902 self.reply(
2903 &t,
2904 json!({"run": run_id, "status": status, "output": output, "error": error}),
2905 false,
2906 );
2907 }
2908 let ok = status == RunStatus::Completed;
2910 let note = error
2911 .clone()
2912 .or_else(|| output.as_ref().map(|o| o.to_string()))
2913 .unwrap_or_default();
2914 self.settle_plan_bindings(
2915 &crate::context::plan::Binding::Run {
2916 id: run_id.to_string(),
2917 },
2918 ok,
2919 ¬e,
2920 );
2921 let wake = self.settings.agent.wake_on();
2922 let notify = match self.settings.agent.on_workflow_finished {
2923 crate::config::v2::OnWorkflowFinished::Ignore => false,
2924 _ => {
2925 ok && wake.contains(&crate::config::v2::WakeEvent::WorkflowFinished)
2926 || !ok && wake.contains(&crate::config::v2::WakeEvent::WorkflowFailed)
2927 }
2928 };
2929 if notify && !self.job_shape {
2930 let short = if note.chars().count() > 400 {
2931 format!("{}…", note.chars().take(400).collect::<String>())
2932 } else {
2933 note.clone()
2934 };
2935 let line = format!(
2936 "workflow {workflow} run {run_id} {}: {short}",
2937 status.as_str()
2938 );
2939 match self.settings.agent.on_workflow_finished {
2940 crate::config::v2::OnWorkflowFinished::Think => {
2946 let depth = self.runs.get(run_id).map(|r| r.msg_depth).unwrap_or(0) + 1;
2947 let cap = self.settings.limits.message_depth();
2948 if depth > cap {
2949 self.log.warn(
2950 "message.too_deep",
2951 json!({"run": run_id, "reason": "on_workflow_finished",
2952 "depth": depth, "max": cap}),
2953 );
2954 self.note_root(line);
2955 } else {
2956 let principal = self.runs.get(run_id).and_then(|r| r.principal.clone());
2957 if let Err(e) = self.accept_event(
2958 kinds::A2A_MESSAGE,
2959 principal,
2960 json!({"text": line.clone(), "context_id": crate::context::ROOT,
2961 "msg_depth": depth}),
2962 ) {
2963 self.log
2964 .warn("workflow.think.fail", json!({"run": run_id, "err": e}));
2965 self.note_root(line);
2966 }
2967 }
2968 }
2969 _ => self.note_root(line),
2970 }
2971 }
2972 if let Some((wf, node, spec, kind)) = self.run_start_spec(run_id)
2975 && kind == "loop"
2976 {
2977 self.on_loop_run_finished(
2978 &wf,
2979 &node,
2980 &spec,
2981 ok,
2982 &output.clone().unwrap_or(Value::Null),
2983 );
2984 }
2985 if let Some(ev) = super::starts::run_event(status) {
2986 self.fire_event_starts(
2987 ev,
2988 &json!({"run": run_id, "workflow": workflow, "status": status.as_str()}),
2989 );
2990 }
2991 #[cfg(feature = "a2a")]
2993 if let Some(tid) = &a2a_task {
2994 self.a2a_task_for_run(tid, status.as_str(), output.as_ref(), error.as_deref());
2995 }
2996 self.checkpoint(false);
2997 if evict_after {
2998 self.evict_terminal_runs();
2999 }
3000 }
3001
3002 pub(crate) fn cancel_run(&mut self, run_id: &str, reason: &str) {
3004 let kids: Vec<String> = self
3007 .runs
3008 .values()
3009 .filter(|r| {
3010 !r.status.is_terminal()
3011 && r.parent.as_ref().is_some_and(|p| {
3012 p["run"].as_str() == Some(run_id) && p["cascade"].as_bool().unwrap_or(true)
3013 })
3014 })
3015 .map(|r| r.id.clone())
3016 .collect();
3017 for k in kids {
3018 self.cancel_run(&k, "parent run cancelled");
3019 }
3020 self.cancel_children_of_run(run_id, reason);
3021 let timers = self.timers.owned_by(|o| o["run"].as_str() == Some(run_id));
3022 for t in timers {
3023 let _ = self.timers.disarm(&self.durable, &t);
3024 }
3025 self.pending
3026 .retain(|p| !matches!(&p.target, Target::Step(r, _) if r == run_id));
3027 if let Some(r) = self.runs.get_mut(run_id)
3028 && !r.status.is_terminal()
3029 {
3030 r.finish(RunStatus::Cancelled, None, Some(reason.to_string()));
3031 self.on_run_terminal(run_id);
3032 }
3033 }
3034
3035 fn cancel_children_of_run(&mut self, run_id: &str, reason: &str) {
3036 let nodes: Vec<_> = self
3037 .children
3038 .iter()
3039 .filter(|(_, c)| matches!(&c.kind, ChildKind::StepTurn { run, .. } if run == run_id))
3040 .map(|(n, _)| *n)
3041 .collect();
3042 for n in nodes {
3043 self.children.cancel(n, reason);
3044 }
3045 }
3046
3047 pub(crate) fn workflow_tool(
3050 &mut self,
3051 caller: &ToolCaller,
3052 name: &str,
3053 args: Value,
3054 ) -> ToolOutcome {
3055 let err = |e: String| ToolOutcome::Ready(Value::String(e), true);
3056 match name {
3057 "workflow.run" => {
3058 let wname = args["name"].as_str().unwrap_or("").to_string();
3059 let Some(w) = self.workflows.get(&wname) else {
3060 return err(format!("no such workflow {wname:?}"));
3061 };
3062 if let Some(cause) = self
3063 .pressure
3064 .refusal(w.priority == crate::engine::model::Priority::Low)
3065 {
3066 return err(format!(
3067 "workflow.run refused: {cause}; retry when it clears"
3068 ));
3069 }
3070 let start = match args.get("start").and_then(Value::as_str) {
3071 Some(s) => match w.step(s) {
3072 Some(st) if st.is_start() => s.to_string(),
3073 _ => return err(format!("workflow {wname:?} has no start node {s:?}")),
3074 },
3075 None => match default_start(w) {
3076 Some(s) => s,
3077 None => return err(format!("workflow {wname:?} has no start node")),
3078 },
3079 };
3080 let wait = args.get("wait").and_then(Value::as_bool).unwrap_or(false);
3081 let timeout_ms = args
3082 .get("timeout")
3083 .and_then(Value::as_str)
3084 .and_then(|t| crate::config::parse_duration(t).ok())
3085 .map(|d| d.as_millis() as u64)
3086 .unwrap_or(3_600_000);
3087 let request = match (caller.node, &caller.run, &caller.step) {
3088 (Some(n), _, _) => {
3089 json!({"node": n.0, "req": caller.req, "wait": wait, "timeout_ms": timeout_ms})
3090 }
3091 (None, Some(r), Some(s)) => {
3092 json!({"run": r, "step": s, "wait": wait, "timeout_ms": timeout_ms})
3093 }
3094 _ => Value::Null,
3095 };
3096 let payload = json!({"workflow": wname, "node": start, "payload": {"requested_by": caller.label_pub()}, "inputs": args.get("inputs").cloned().unwrap_or(json!({})), "request": request, "conversation": caller.ctx, "msg_depth": caller.msg_depth});
3097 match self.accept_event(kinds::WORKFLOW_RUN, caller.principal.clone(), payload) {
3098 Ok(_) => {
3099 if let Some(ev) = self.inbox_queue.pop_back() {
3101 let done = self.on_start_event(&ev);
3102 if done {
3103 self.inbox_done(&ev.id);
3104 }
3105 }
3106 ToolOutcome::Executing
3110 }
3111 Err(e) => err(e),
3112 }
3113 }
3114 "workflow.list" => ToolOutcome::Ready(
3115 json!({"workflows": self.workflows.values().map(|w| json!({
3116 "name": w.name, "description": w.description, "armed": w.armed, "hash": w.hash,
3117 "starts": w.start_steps().iter().map(|s| json!({"node": s.id, "kind": s.kind})).collect::<Vec<_>>(),
3118 "runs": self.runs.values().filter(|r| r.workflow == w.name).map(|r| json!({"id": r.id, "status": r.status})).collect::<Vec<_>>(),
3119 })).collect::<Vec<_>>()}),
3120 false,
3121 ),
3122 "workflow.status" => {
3123 let runs: Vec<Value> = match (
3124 args.get("run").and_then(Value::as_str),
3125 args.get("name").and_then(Value::as_str),
3126 ) {
3127 (Some(id), _) => self
3128 .runs
3129 .get(id)
3130 .map(|r| vec![run_detail(r)])
3131 .unwrap_or_default(),
3132 (None, Some(n)) => self
3133 .runs
3134 .values()
3135 .filter(|r| r.workflow == n)
3136 .map(RunState::summary)
3137 .collect(),
3138 _ => self.runs.values().map(RunState::summary).collect(),
3139 };
3140 ToolOutcome::Ready(json!({"runs": runs}), false)
3141 }
3142 "workflow.cancel" => {
3143 let id = args["run"].as_str().unwrap_or("").to_string();
3144 if !self.runs.contains_key(&id) {
3145 return err(format!("no such run {id:?}"));
3146 }
3147 let reason = args
3148 .get("reason")
3149 .and_then(Value::as_str)
3150 .unwrap_or("cancelled by request")
3151 .to_string();
3152 self.cancel_run(&id, &reason);
3153 ToolOutcome::Ready(
3154 json!({"ok": true, "status": self.runs.get(&id).map(|r| r.status.as_str()).unwrap_or("cancelled")}),
3155 false,
3156 )
3157 }
3158 "workflow.wait" => {
3159 let id = args["run"].as_str().unwrap_or("").to_string();
3160 let timeout_ms = args
3161 .get("timeout")
3162 .and_then(Value::as_str)
3163 .and_then(|t| crate::config::parse_duration(t).ok())
3164 .map(|d| d.as_millis() as u64)
3165 .unwrap_or(3_600_000);
3166 match self.runs.get(&id) {
3167 None => err(format!("no such run {id:?}")),
3168 Some(r) if r.status.is_terminal() => ToolOutcome::Ready(
3169 json!({"run": id, "status": r.status, "output": r.output, "error": r.error}),
3170 false,
3171 ),
3172 Some(_) => ToolOutcome::Deferred(PendingKind::Run {
3173 run: id,
3174 deadline_ms: now_ms() + timeout_ms,
3175 }),
3176 }
3177 }
3178 "workflow.pause" | "workflow.resume" => {
3179 let pause = name == "workflow.pause";
3180 if pause
3186 && let Some(id) = args.get("run").and_then(Value::as_str)
3187 && let Some(step) = args.get("before_step").and_then(Value::as_str)
3188 {
3189 let known = self
3190 .definition_for_run(id)
3191 .is_some_and(|wf| wf.steps.contains_key(step));
3192 if !known {
3193 return err(format!(
3194 "before_step {step:?} is not a step of this run's workflow"
3195 ));
3196 }
3197 match self.runs.get_mut(id) {
3198 None => return err(format!("no such run {id:?}")),
3199 Some(r) => {
3200 r.break_before = Some(step.to_string());
3201 r.dirty = true;
3202 }
3203 }
3204 self.log
3205 .info("run.breakpoint", json!({"run": id, "before_step": step}));
3206 return ToolOutcome::Ready(json!({"run": id, "break_before": step}), false);
3207 }
3208 if let Some(id) = args.get("run").and_then(Value::as_str) {
3209 match self.runs.get_mut(id) {
3210 None => return err(format!("no such run {id:?}")),
3211 Some(r) if r.status.is_terminal() => {
3212 return err(format!("run {id:?} is already {}", r.status.as_str()));
3213 }
3214 Some(r) => {
3215 r.status = if pause {
3216 RunStatus::Paused
3217 } else {
3218 RunStatus::Running
3219 };
3220 r.touch();
3221 }
3222 }
3223 return ToolOutcome::Ready(json!({"ok": true}), false);
3224 }
3225 if let Some(n) = args.get("name").and_then(Value::as_str) {
3226 match self.workflows.get_mut(n) {
3227 None => return err(format!("no such workflow {n:?}")),
3228 Some(w) => std::sync::Arc::make_mut(w).armed = !pause,
3232 }
3233 if !pause {
3234 self.arm_workflows();
3235 }
3236 return ToolOutcome::Ready(json!({"ok": true}), false);
3237 }
3238 err(format!("{name}: give run or name"))
3239 }
3240 "workflow.create" | "workflow.update" => {
3241 if let Some(e) = self.workflows_locked(name) {
3246 return e;
3247 }
3248 let def = args["definition"].clone();
3249 match parse_workflow(&def) {
3250 Err(e) => err(format!("{name}: {}", e.join("; "))),
3251 Ok(w) if w.tool.is_some() => err(format!(
3252 "{name}: a `tool:` block may only be declared in the startup config. The tool registry is built once and validated fail-closed; minting or shadowing a tool name at runtime would put no operator in the loop. (workflow {:?})",
3253 w.name
3254 )),
3255 Ok(mut w) => {
3256 self.fill_durable_default(&mut w);
3257 if name == "workflow.create" && self.workflows.contains_key(&w.name) {
3258 return err(format!(
3259 "workflow {:?} exists (use workflow.update)",
3260 w.name
3261 ));
3262 }
3263 if name == "workflow.update" && !self.workflows.contains_key(&w.name) {
3264 return err(format!(
3265 "workflow {:?} does not exist (use workflow.create)",
3266 w.name
3267 ));
3268 }
3269 let (wname, hash) = (w.name.clone(), w.hash.clone());
3270 let rec = crate::context::memory::Record {
3272 value: def,
3273 ts: now_ms(),
3274 ttl_ms: None,
3275 by: Some(caller.label_pub()),
3276 };
3277 if let Err(e) = self.durable.put(
3278 Kind::Memory,
3279 &format!("{WORKFLOW_DEF_PREFIX}{wname}"),
3280 serde_json::to_value(&rec).unwrap_or(Value::Null),
3281 None,
3282 ) {
3283 return err(format!("{name}: store: {e}"));
3284 }
3285 let arm = args.get("arm").and_then(Value::as_bool).unwrap_or(true);
3286 let mut w = w;
3287 w.armed = arm;
3288 self.workflows.insert(wname.clone(), std::sync::Arc::new(w));
3289 self.log.info(
3290 "workflow.defined",
3291 json!({"name": wname, "hash": &hash[..12], "op": name}),
3292 );
3293 if arm {
3294 self.arm_workflows();
3295 }
3296 ToolOutcome::Ready(
3297 json!({"name": wname, "hash": hash, "armed": arm}),
3298 false,
3299 )
3300 }
3301 }
3302 }
3303 "workflow.delete" => {
3304 if let Some(e) = self.workflows_locked(name) {
3305 return e;
3306 }
3307 let wname = args["name"].as_str().unwrap_or("").to_string();
3308 let Some(wf) = self.workflows.remove(&wname) else {
3309 return err(format!("no such workflow {wname:?}"));
3310 };
3311 let _ = self
3312 .durable
3313 .delete(Kind::Memory, &format!("{WORKFLOW_DEF_PREFIX}{wname}"));
3314 self.retire_workflow(&wf, "deleted");
3319 self.log.info("workflow.deleted", json!({"name": wname}));
3320 ToolOutcome::Ready(json!({"ok": true}), false)
3321 }
3322 "workflow.signal" => {
3323 let sname = args["name"].as_str().unwrap_or("").to_string();
3324 let _ = self.accept_event(kinds::SIGNAL, caller.principal.clone(), json!({"name": sname, "payload": args.get("payload").cloned().unwrap_or(Value::Null), "run": args.get("run"), "from": caller.label_pub()}));
3325 ToolOutcome::Ready(
3329 json!({"delivered": 0, "note": "signal recorded; waits and signal start nodes are woken when the loop drains the inbox"}),
3330 false,
3331 )
3332 }
3333 _ => err(format!("unknown workflow tool {name}")),
3334 }
3335 }
3336}
3337
3338impl ToolCaller {
3339 pub(crate) fn label_pub(&self) -> String {
3340 if let Some(s) = &self.subagent {
3341 return format!("subagent:{s}");
3342 }
3343 if let (Some(r), Some(s)) = (&self.run, &self.step) {
3344 return format!("step:{r}/{s}");
3345 }
3346 format!(
3347 "ctx:{}",
3348 self.ctx.as_deref().unwrap_or(crate::context::ROOT)
3349 )
3350 }
3351}
3352
3353fn default_start(w: &Workflow) -> Option<String> {
3355 let starts = w.start_steps();
3356 starts
3357 .iter()
3358 .find(|s| s.kind == "manual")
3359 .or_else(|| starts.first())
3360 .map(|s| s.id.clone())
3361}
3362
3363fn node_kind<'a>(w: &'a Workflow, node: &str) -> Option<&'a str> {
3364 w.step(node).map(|s| s.kind.as_str())
3365}
3366
3367fn run_detail(r: &RunState) -> Value {
3368 let mut v = r.summary();
3369 v["step_states"] = json!(r.steps);
3370 v["vars"] = Value::Object(r.vars.clone());
3371 v
3372}
3373
3374fn collect_memory_keys(v: &Value, out: &mut Vec<String>) {
3376 match v {
3377 Value::String(s) => {
3378 let mut rest = s.as_str();
3379 while let Some(i) = rest.find("memory.") {
3380 let after = &rest[i + "memory.".len()..];
3381 let key: String = after
3382 .chars()
3383 .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '/' | ':'))
3384 .collect();
3385 if !key.is_empty() && !out.contains(&key) {
3386 out.push(key.clone());
3387 }
3388 rest = &after[key.len().min(after.len())..];
3389 }
3390 }
3391 Value::Array(a) => a.iter().for_each(|x| collect_memory_keys(x, out)),
3392 Value::Object(o) => o.values().for_each(|x| collect_memory_keys(x, out)),
3393 _ => {}
3394 }
3395}
3396
3397fn expand_dir(dir: &str, pattern: &str) -> Result<Vec<String>, String> {
3409 let root = std::path::Path::new(dir);
3410 if !root.is_dir() {
3411 return Err(format!("not a directory ({})", root.display()));
3412 }
3413 let pats: Vec<&str> = pattern
3414 .split(',')
3415 .map(str::trim)
3416 .filter(|p| !p.is_empty())
3417 .collect();
3418 let recursive = pats.iter().any(|p| p.contains("**"));
3419 let mut out = Vec::new();
3420 let mut stack = vec![root.to_path_buf()];
3421 while let Some(d) = stack.pop() {
3422 let rd = std::fs::read_dir(&d).map_err(|e| e.to_string())?;
3423 for ent in rd.flatten() {
3424 let path = ent.path();
3425 if path.is_dir() {
3426 if recursive {
3427 stack.push(path);
3428 }
3429 continue;
3430 }
3431 let rel = path.strip_prefix(root).unwrap_or(&path);
3432 let rels = rel.to_string_lossy();
3433 if pats.iter().any(|p| glob_match(p, &rels)) {
3434 out.push(path.to_string_lossy().into_owned());
3435 }
3436 }
3437 }
3438 out.sort();
3439 Ok(out)
3440}
3441
3442fn glob_match(pat: &str, text: &str) -> bool {
3447 if let Some(rest) = pat.strip_prefix("**/")
3450 && glob_match(rest, text)
3451 {
3452 return true;
3453 }
3454 let (p, t): (Vec<char>, Vec<char>) = (pat.chars().collect(), text.chars().collect());
3455 fn go(p: &[char], t: &[char]) -> bool {
3456 match p.first() {
3457 None => t.is_empty(),
3458 Some('*') => {
3459 let doubled = p.get(1) == Some(&'*');
3460 let rest = if doubled { &p[2..] } else { &p[1..] };
3461 let mut i = 0;
3463 loop {
3464 if go(rest, &t[i..]) {
3465 return true;
3466 }
3467 if i >= t.len() {
3468 return false;
3469 }
3470 if !doubled && t[i] == '/' {
3471 return false;
3472 }
3473 i += 1;
3474 }
3475 }
3476 Some('?') if !t.is_empty() => go(&p[1..], &t[1..]),
3477 Some(c) if t.first() == Some(c) => go(&p[1..], &t[1..]),
3478 _ => false,
3479 }
3480 }
3481 go(&p, &t)
3482}
3483
3484#[cfg(test)]
3485mod glob_tests {
3486 use super::glob_match;
3487
3488 #[test]
3489 fn a_single_star_stays_inside_one_segment_and_double_crosses() {
3490 assert!(glob_match("*.yaml", "nightly.yaml"));
3492 assert!(
3493 !glob_match("*.yaml", "team/nightly.yaml"),
3494 "* must not cross /"
3495 );
3496 assert!(glob_match("**/*.yaml", "team/nightly.yaml"));
3497 assert!(glob_match("**/*.yaml", "a/b/c/deep.yaml"));
3498 assert!(glob_match("**/*.yaml", "nightly.yaml"));
3501
3502 assert!(glob_match("flows/*.json", "flows/a.json"));
3503 assert!(!glob_match("flows/*.json", "flows/a.yaml"));
3504 assert!(!glob_match("*.yaml", "yaml"), "the dot is literal");
3505 assert!(glob_match("?.yaml", "a.yaml"));
3506 assert!(!glob_match("?.yaml", "ab.yaml"));
3507 assert!(glob_match("nightly.yaml", "nightly.yaml"));
3509 assert!(!glob_match("nightly.yaml", "nightly.yml"));
3510 }
3511}