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 self.finish_step(
1423 run_id,
1424 step_id,
1425 StepStatus::Done,
1426 Some(json!({"id": id, "seq": seq, "stream": stream,
1427 "subject": subject})),
1428 None,
1429 0,
1430 );
1431 }
1432 Err(e) => {
1433 self.finish_step(run_id, step_id, StepStatus::Failed, None, Some(e), 0)
1434 }
1435 }
1436 return;
1437 }
1438 if let Some(n) = spec.get("note").and_then(Value::as_str) {
1439 let text = format!("run {run_id}: {n}");
1440 self.note_root(text);
1441 }
1442 if let Some(a) = spec.get("audit") {
1443 self.log.info(
1444 "audit.emit",
1445 json!({"run": run_id, "step": step_id, "audit": a}),
1446 );
1447 }
1448 self.finish_step(
1449 run_id,
1450 step_id,
1451 StepStatus::Done,
1452 spec.get("value").cloned().or(Some(Value::Null)),
1453 None,
1454 0,
1455 );
1456 }
1457 "finish" => {
1458 let status = match spec
1459 .get("status")
1460 .and_then(Value::as_str)
1461 .unwrap_or("completed")
1462 {
1463 "completed" => RunStatus::Completed,
1464 "refused" => RunStatus::Refused,
1465 "cancelled" => RunStatus::Cancelled,
1466 _ => RunStatus::Failed,
1467 };
1468 let output = spec.get("output").cloned();
1469 if matches!(status, RunStatus::Completed)
1476 && let Some(schema) = self
1477 .definition_for_run(run_id)
1478 .and_then(|wf| wf.outputs_schema.clone())
1479 {
1480 let value = output.clone().unwrap_or(Value::Null);
1481 if let Err(errs) = crate::jsonschema::validate(&schema, &value) {
1482 self.finish_step_pub(
1483 run_id,
1484 step_id,
1485 StepStatus::Failed,
1486 None,
1487 Some(format!(
1488 "finish: output does not match the workflow's declared \
1489 outputs.schema: {}",
1490 errs.join("; ")
1491 )),
1492 0,
1493 );
1494 return;
1495 }
1496 }
1497 let reason = spec
1498 .get("reason")
1499 .and_then(Value::as_str)
1500 .map(str::to_string);
1501 self.runs.get_mut(run_id).expect("present").end_step(
1502 step_id,
1503 StepStatus::Done,
1504 output.clone(),
1505 None,
1506 );
1507 self.runs
1508 .get_mut(run_id)
1509 .expect("present")
1510 .finish(status, output, reason);
1511 self.on_run_terminal(run_id);
1512 }
1513 "sleep" => {
1514 let ms = spec
1515 .get("duration")
1516 .map(crate::engine::model::duration_ms)
1517 .unwrap_or(Ok(0))
1518 .unwrap_or(0);
1519 match self.timers.arm(
1520 &self.durable,
1521 now_ms() + ms,
1522 json!({"kind": "step", "run": run_id, "step": step_id}),
1523 json!({"slept_ms": ms}),
1524 ) {
1525 Ok(id) => {
1526 self.runs.get_mut(run_id).expect("present").suspend_step(
1527 step_id,
1528 json!({"kind": "sleep", "timer": id, "deadline_ms": now_ms() + ms}),
1529 );
1530 self.checkpoint(false);
1531 }
1532 Err(e) => self.finish_step(
1533 run_id,
1534 step_id,
1535 StepStatus::Failed,
1536 None,
1537 Some(format!("sleep: {e}")),
1538 0,
1539 ),
1540 }
1541 }
1542 "tool" => {
1543 let name = spec
1544 .get("name")
1545 .and_then(Value::as_str)
1546 .unwrap_or("")
1547 .to_string();
1548 let args = spec.get("args").cloned().unwrap_or(json!({}));
1549 self.step_tool_call(run_id, step_id, &step_caller, &name, args);
1550 }
1551 "http" => self.step_http(run_id, step_id, &spec),
1552 k if k.starts_with("memory.")
1553 || k.starts_with("artifact.")
1554 || k.starts_with("knowledge.")
1555 || k.starts_with("search.") =>
1556 {
1557 let mut args = spec.clone();
1558 args.retain(|_, v| !v.is_null());
1560 self.step_tool_call(run_id, step_id, &step_caller, k, Value::Object(args));
1561 }
1562 "mcp.tool" => {
1563 let server = spec
1564 .get("server")
1565 .and_then(Value::as_str)
1566 .unwrap_or("")
1567 .to_string();
1568 let tool = spec
1569 .get("tool")
1570 .and_then(Value::as_str)
1571 .unwrap_or("")
1572 .to_string();
1573 let args = spec.get("args").cloned().unwrap_or(json!({}));
1574 if let Err(e) = self.service_rate_take(&server) {
1578 self.finish_step(run_id, step_id, StepStatus::Failed, None, Some(e), 0);
1579 return;
1580 }
1581 let Some(client) = self.mcp.get(&server).cloned() else {
1582 self.finish_step(
1583 run_id,
1584 step_id,
1585 StepStatus::Failed,
1586 None,
1587 Some(format!("mcp server {server:?} is not connected")),
1588 0,
1589 );
1590 return;
1591 };
1592 let key = spec
1602 .get("idempotency")
1603 .and_then(|i| i.get("value"))
1604 .and_then(Value::as_str)
1605 .map(str::to_string)
1606 .unwrap_or_else(|| crate::engine::run::idempotency_key(run_id, step_id));
1607 let meta = json!({"agent/idempotency_key": key, "agent/attempt": attempt, "agent/instance": self.instance, "agent/run": run_id});
1608 let timeout = step
1609 .timeout_ms
1610 .map(std::time::Duration::from_millis)
1611 .unwrap_or(
1612 self.settings
1613 .limits
1614 .step_timeout
1615 .map(|d| d.0)
1616 .unwrap_or(std::time::Duration::from_secs(600)),
1617 );
1618 let tx = self.events_tx.clone();
1619 let (r, s) = (run_id.to_string(), step_id.to_string());
1620 self.executing
1621 .insert(format!("{run_id}/{step_id}"), std::time::Instant::now());
1622 std::thread::Builder::new()
1623 .name(format!("step:{server}.{tool}"))
1624 .spawn(move || {
1625 let (output, is_error, error) = match client.call_tool_with_meta_within(
1626 &tool,
1627 Some(args),
1628 meta,
1629 timeout,
1630 ) {
1631 Ok(res) => {
1632 let v = super::worker::tool_result_value(&res);
1633 if res.is_error() {
1634 (v.clone(), true, Some(res.text()))
1635 } else {
1636 (v, false, None)
1637 }
1638 }
1639 Err(e) => (Value::Null, true, Some(format!("transport error: {e}"))),
1640 };
1641 let _ = tx.send(super::events::Event::StepDone {
1642 run: r,
1643 step: s,
1644 output,
1645 is_error,
1646 error,
1647 tokens: 0,
1648 });
1649 })
1650 .ok();
1651 }
1652 "agent" | "think" => self.step_turn(run_id, step_id, &step, &spec, &data),
1653 "foreach" | "batch" | "iterate" | "parallel" | "race" | "subgraph" => {
1654 self.nested_start(run_id, step_id, &step, &spec)
1655 }
1656 "wait" | "join" | "workflow" | "message" | "workflow.signal" | "workflow.wait"
1657 | "workflow.cancel" | "subagent" | "human" | "mcp.resource" | "a2a.delegate"
1658 | "a2a.send" | "a2a.wait" | "classify" | "extract" | "summarize" | "judge"
1659 | "route" => {
1660 self.execute_orchestration_step(run_id, step_id, &step, &spec, &data, &step_caller)
1661 }
1662 "switch" => {
1663 let on = spec.get("on").cloned().unwrap_or(Value::Null);
1664 let key = match &on {
1665 Value::String(x) => x.clone(),
1666 other => other.to_string(),
1667 };
1668 let cases = step
1669 .field("cases")
1670 .and_then(Value::as_object)
1671 .cloned()
1672 .unwrap_or_default();
1673 let target = cases
1674 .get(&key)
1675 .and_then(Value::as_str)
1676 .map(str::to_string)
1677 .or_else(|| step.field_str("default").map(str::to_string));
1678 match target {
1679 Some(t) => {
1680 let scope_prefix = step_id
1683 .rsplit_once('.')
1684 .map(|(p, _)| format!("{p}."))
1685 .unwrap_or_default();
1686 let mut skipped = Vec::new();
1687 let mut others: Vec<String> = cases
1690 .values()
1691 .filter_map(Value::as_str)
1692 .map(str::to_string)
1693 .collect();
1694 if let Some(d) = step.field_str("default") {
1695 others.push(d.to_string());
1696 }
1697 if let Some(run) = self.runs.get_mut(run_id) {
1698 for tid in others {
1699 if tid == t {
1700 continue;
1701 }
1702 let sid = format!("{scope_prefix}{tid}");
1703 if let Some(st) = run.steps.get_mut(&sid)
1704 && st.status == StepStatus::Pending
1705 {
1706 st.status = StepStatus::Pruned;
1709 skipped.push(sid);
1710 }
1711 }
1712 let sid = format!("{scope_prefix}{t}");
1713 if let Some(st) = run.steps.get_mut(&sid) {
1714 st.status = StepStatus::Pending;
1715 st.forced = true;
1716 }
1717 }
1718 self.finish_step(
1719 run_id,
1720 step_id,
1721 StepStatus::Done,
1722 Some(json!({"case": key, "goto": t, "skipped": skipped})),
1723 None,
1724 0,
1725 );
1726 }
1727 None if step.field_str("on_no_match") == Some("skip") => {
1731 let scope_prefix = step_id
1732 .rsplit_once('.')
1733 .map(|(p, _)| format!("{p}."))
1734 .unwrap_or_default();
1735 let mut skipped = Vec::new();
1736 let others: Vec<String> = cases
1737 .values()
1738 .filter_map(Value::as_str)
1739 .map(str::to_string)
1740 .collect();
1741 if let Some(run) = self.runs.get_mut(run_id) {
1742 for tid in others {
1743 let sid = format!("{scope_prefix}{tid}");
1744 if let Some(st) = run.steps.get_mut(&sid)
1745 && st.status == StepStatus::Pending
1746 {
1747 st.status = StepStatus::Pruned;
1748 skipped.push(sid);
1749 }
1750 }
1751 }
1752 self.finish_step(
1753 run_id,
1754 step_id,
1755 StepStatus::Done,
1756 Some(json!({"case": key, "matched": false, "skipped": skipped})),
1757 None,
1758 0,
1759 );
1760 }
1761 None => self.finish_step(
1762 run_id,
1763 step_id,
1764 StepStatus::Failed,
1765 Some(json!({"case": key})),
1766 Some(format!("switch: no case for {key:?} and no default")),
1767 0,
1768 ),
1769 }
1770 }
1771 "map" | "filter" | "reduce" | "sort" | "dedupe" | "chunk" | "parse" => {
1772 let out = match step.kind.as_str() {
1773 "map" => crate::engine::data::map(
1774 spec.get("over").unwrap_or(&Value::Null),
1775 step.field_str("expr").unwrap_or(""),
1776 step.field_str("as").unwrap_or("item"),
1777 &data,
1778 ),
1779 "filter" => crate::engine::data::filter(
1780 spec.get("over").unwrap_or(&Value::Null),
1781 step.field_str("expr").unwrap_or(""),
1782 step.field_str("as").unwrap_or("item"),
1783 &data,
1784 ),
1785 "reduce" => crate::engine::data::reduce(
1786 spec.get("over").unwrap_or(&Value::Null),
1787 step.field_str("expr").unwrap_or(""),
1788 spec.get("initial").cloned().unwrap_or(Value::Null),
1789 step.field_str("as").unwrap_or("item"),
1790 step.field_str("acc").unwrap_or("acc"),
1791 &data,
1792 ),
1793 "sort" => crate::engine::data::sort(
1794 spec.get("over").unwrap_or(&Value::Null),
1795 spec.get("by").and_then(Value::as_str),
1796 spec.get("order").and_then(Value::as_str),
1797 ),
1798 "dedupe" => crate::engine::data::dedupe(
1799 spec.get("over").unwrap_or(&Value::Null),
1800 spec.get("by").and_then(Value::as_str),
1801 ),
1802 "chunk" => crate::engine::data::chunk(
1803 spec.get("value").unwrap_or(&Value::Null),
1804 spec.get("by").and_then(Value::as_str),
1805 spec.get("size").and_then(Value::as_u64).unwrap_or(0) as usize,
1806 spec.get("overlap").and_then(Value::as_u64).unwrap_or(0) as usize,
1807 ),
1808 _ => crate::engine::data::parse(
1809 spec.get("text").and_then(Value::as_str).unwrap_or(""),
1810 spec.get("format").and_then(Value::as_str),
1811 ),
1812 };
1813 match out {
1814 Ok(v) => self.finish_step(run_id, step_id, StepStatus::Done, Some(v), None, 0),
1815 Err(e) => {
1816 self.finish_step(run_id, step_id, StepStatus::Failed, None, Some(e), 0)
1817 }
1818 }
1819 }
1820 other => self.finish_step(
1821 run_id,
1822 step_id,
1823 StepStatus::Failed,
1824 None,
1825 Some(format!(
1826 "step kind {other:?} is not executable in this build"
1827 )),
1828 0,
1829 ),
1830 }
1831 }
1832
1833 fn step_tool_call(
1835 &mut self,
1836 run_id: &str,
1837 step_id: &str,
1838 caller: &ToolCaller,
1839 name: &str,
1840 args: Value,
1841 ) {
1842 match self.execute_tool(caller, name, args) {
1843 ToolOutcome::Ready(v, is_error) => {
1844 let err = is_error.then(|| match &v {
1845 Value::String(s) => s.clone(),
1846 o => o.to_string(),
1847 });
1848 self.finish_step(
1849 run_id,
1850 step_id,
1851 if is_error {
1852 StepStatus::Failed
1853 } else {
1854 StepStatus::Done
1855 },
1856 Some(v),
1857 err,
1858 0,
1859 );
1860 }
1861 ToolOutcome::Deferred(kind) => {
1862 let wait = match &kind {
1863 PendingKind::Timer { id } => json!({"kind": "timer", "timer": id}),
1864 PendingKind::Subagent { handle } => {
1865 json!({"kind": "subagent", "handle": handle})
1866 }
1867 PendingKind::Think { .. } => json!({"kind": "think"}),
1868 PendingKind::Run { run, .. } => json!({"kind": "run", "run": run}),
1869 PendingKind::Await {
1870 condition,
1871 deadline_ms,
1872 } => {
1873 json!({"kind": "await", "condition": condition, "deadline_ms": deadline_ms})
1874 }
1875 PendingKind::Human {
1876 task, deadline_ms, ..
1877 } => {
1878 json!({"kind": "human", "task": task, "deadline_ms": deadline_ms})
1879 }
1880 };
1881 self.runs
1882 .get_mut(run_id)
1883 .expect("present")
1884 .suspend_step(step_id, wait);
1885 if !matches!(kind, PendingKind::Timer { .. }) {
1886 self.push_pending(super::reactor::PendingTool {
1887 target: Target::Step(run_id.to_string(), step_id.to_string()),
1888 name: name.to_string(),
1889 kind,
1890 started_ms: now_ms(),
1891 });
1892 }
1893 self.checkpoint(false);
1894 }
1895 ToolOutcome::Executing => {
1896 self.executing
1897 .insert(format!("{run_id}/{step_id}"), std::time::Instant::now());
1898 }
1899 }
1900 }
1901
1902 pub(crate) fn step_turn_pub(
1903 &mut self,
1904 run_id: &str,
1905 step_id: &str,
1906 step: &Step,
1907 spec: &Map<String, Value>,
1908 data: &template::Data,
1909 ) {
1910 self.step_turn(run_id, step_id, step, spec, data)
1911 }
1912
1913 fn step_turn(
1915 &mut self,
1916 run_id: &str,
1917 step_id: &str,
1918 step: &Step,
1919 spec: &Map<String, Value>,
1920 data: &template::Data,
1921 ) {
1922 let is_think = step.kind == "think";
1923 let prompt = if is_think {
1924 spec.get("prompt").and_then(Value::as_str).unwrap_or("")
1925 } else {
1926 spec.get("instruction")
1927 .and_then(Value::as_str)
1928 .unwrap_or("")
1929 }
1930 .to_string();
1931 let output_schema = spec.get("output_schema").cloned();
1932 let mut messages = Vec::new();
1933 if let Some(reads) = spec.get("reads").and_then(Value::as_array) {
1935 for path in reads.iter().filter_map(Value::as_str) {
1936 if let Some(v) = template::lookup(path, data) {
1937 messages.push(Msg::system(format!("{path} = {v}")));
1938 }
1939 }
1940 }
1941 let step_template: Option<String> = spec
1946 .get("context")
1947 .and_then(Value::as_object)
1948 .and_then(|o| o.get("template"))
1949 .and_then(Value::as_str)
1950 .map(str::to_string);
1951 let seed_list = spec.get("context").and_then(Value::as_array).or_else(|| {
1952 spec.get("context")
1953 .and_then(Value::as_object)
1954 .and_then(|o| o.get("seed"))
1955 .and_then(Value::as_array)
1956 });
1957 if let Some(seed) = seed_list {
1958 for m in seed {
1959 match (m["role"].as_str(), m["content"].as_str()) {
1960 (Some("system"), Some(c)) => messages.push(Msg::system(c)),
1961 (Some("assistant"), Some(c)) => {
1962 messages.push(Msg::assistant(Some(c.to_string()), vec![]))
1963 }
1964 (_, Some(c)) => messages.push(Msg::user(c, None)),
1965 _ => {}
1966 }
1967 }
1968 }
1969 let mut user = prompt.clone();
1970 if let Some(c) = spec.get("output_contract").and_then(Value::as_str) {
1971 user.push_str(&format!("\n\nOutput contract:\n{c}"));
1972 }
1973 if let Some(s) = &output_schema {
1974 user.push_str(&format!(
1975 "\n\nReply with ONLY one JSON object matching this JSON Schema:\n{s}"
1976 ));
1977 }
1978 messages.push(Msg::user(user, None));
1979 let skill_bodies: Vec<String> = step
1981 .skills
1982 .iter()
1983 .chain(
1984 spec.get("skills")
1985 .and_then(Value::as_array)
1986 .map(|a| {
1987 a.iter()
1988 .filter_map(Value::as_str)
1989 .map(str::to_string)
1990 .collect::<Vec<_>>()
1991 })
1992 .unwrap_or_default()
1993 .iter(),
1994 )
1995 .filter_map(|name| {
1996 let mcp = self.mcp.clone();
1997 let resolver = move |server: &str| -> Option<
1998 std::sync::Arc<dyn crate::context::skills::SkillServer>,
1999 > {
2000 mcp.get(server).map(|c| {
2001 c.clone() as std::sync::Arc<dyn crate::context::skills::SkillServer>
2002 })
2003 };
2004 self.skills
2005 .load(name, None, &resolver)
2006 .ok()
2007 .map(|b| format!("### Skill: {}\n{}", b.name, b.body))
2008 })
2009 .collect();
2010 let extra = if skill_bodies.is_empty() {
2011 None
2012 } else {
2013 Some(format!(
2014 "Loaded skills — follow these instructions when relevant:\n{}",
2015 skill_bodies.join("\n\n")
2016 ))
2017 };
2018 let system = match spec.get("system").and_then(Value::as_str) {
2019 Some(s) => s.to_string(),
2020 None if is_think => format!(
2021 "You are the reasoning module of {}. Reply with {}. No tools are available.",
2022 self.instance,
2023 if output_schema.is_some() {
2024 "ONLY one JSON object matching the schema"
2025 } else {
2026 "your conclusion"
2027 }
2028 ),
2029 None => self.system_prompt_named(None, extra.as_deref(), step_template.as_deref()),
2030 };
2031 let (tools, internal, routes) = if is_think {
2032 (Vec::new(), Vec::new(), BTreeMap::new())
2033 } else {
2034 let allow: Option<Vec<String>> = spec.get("tools").and_then(Value::as_array).map(|a| {
2035 a.iter()
2036 .filter_map(Value::as_str)
2037 .map(str::to_string)
2038 .collect()
2039 });
2040 self.tool_plan(&Caller::Workflow, allow.as_deref())
2041 };
2042 let servers: Vec<String> = match spec.get("servers").and_then(Value::as_array) {
2043 Some(a) => a
2044 .iter()
2045 .filter_map(Value::as_str)
2046 .map(str::to_string)
2047 .collect(),
2048 None => routes
2049 .values()
2050 .map(|(s, _)| s.clone())
2051 .collect::<std::collections::BTreeSet<_>>()
2052 .into_iter()
2053 .collect(),
2054 };
2055 let est: u64 = messages.iter().map(Msg::est_tokens).sum::<u64>()
2058 + crate::context::tokens::estimate(&system)
2059 + 4096;
2060 let scopes = self.run_scopes(run_id);
2061 let reservation = match self.governor.admit(est, &scopes, now_ms()) {
2062 Admission::Ok { reservation, model } => {
2063 if let Some(m) = model {
2064 self.log.info(
2065 "budget.degraded",
2066 json!({"run": run_id, "step": step_id, "model": m}),
2067 );
2068 }
2069 Some(reservation)
2070 }
2071 Admission::Wait { until_ms, reason } => {
2072 self.log.info(
2073 "budget.wait",
2074 json!({"run": run_id, "step": step_id, "until_ms": until_ms, "reason": reason}),
2075 );
2076 crate::state::kill_point("budget.waiting");
2077 match self.timers.arm(
2078 &self.durable,
2079 until_ms,
2080 json!({"kind": "step_budget", "run": run_id, "step": step_id}),
2081 Value::Null,
2082 ) {
2083 Ok(id) => {
2084 self.runs.get_mut(run_id).expect("present").suspend_step(step_id, json!({"kind": "waiting_budget", "timer": id, "until_ms": until_ms, "reason": reason}));
2085 self.checkpoint(false);
2086 }
2087 Err(e) => self.finish_step(
2088 run_id,
2089 step_id,
2090 StepStatus::Failed,
2091 None,
2092 Some(format!("budget wait: {e}")),
2093 0,
2094 ),
2095 }
2096 return;
2097 }
2098 Admission::Refuse { reason } | Admission::Fail { reason } => {
2099 self.finish_step(run_id, step_id, StepStatus::Failed, None, Some(reason), 0);
2100 return;
2101 }
2102 };
2103 let node_model = spec
2108 .get("model")
2109 .and_then(Value::as_str)
2110 .map(str::to_string);
2111 let limits = spec.get("limits").cloned().unwrap_or(json!({}));
2112 let max_steps = limits
2113 .get("steps")
2114 .and_then(Value::as_u64)
2115 .map(|s| s as u32)
2116 .unwrap_or(self.settings.limits.run.steps());
2117 let max_tokens = step
2118 .budget
2119 .or_else(|| limits.get("tokens").and_then(Value::as_u64))
2120 .unwrap_or(self.settings.limits.run.tokens());
2121 let deadline_ms = step.timeout_ms.unwrap_or(
2122 self.settings
2123 .limits
2124 .step_timeout
2125 .map(|d| d.0.as_millis() as u64)
2126 .unwrap_or(600_000),
2127 );
2128 let spec = TurnSpec {
2129 kind: if is_think {
2130 TurnKind::Think
2131 } else {
2132 TurnKind::Agent
2133 },
2134 system,
2135 messages,
2136 tools,
2137 internal,
2138 mcp_routes: routes,
2139 output_schema,
2140 max_rounds: if is_think { 3 } else { 0 },
2141 budget_admission: self.governor.is_active(),
2142 idempotency_prefix: format!("{}/{run_id}/{step_id}", self.instance),
2143 tool_meta: Some(
2144 json!({"agent/run": run_id, "agent/step": step_id, "agent/instance": self.instance}),
2145 ),
2146 temperature: None,
2147 max_tokens_per_call: 0,
2148 turn_id: format!(
2149 "{run_id}/{step_id}#{}",
2150 self.runs
2151 .get(run_id)
2152 .and_then(|r| r.step(step_id))
2153 .map(|s| s.attempt)
2154 .unwrap_or(1)
2155 ),
2156 };
2157 let launch = super::turns::TurnLaunch {
2158 spec,
2159 kind: ChildKind::StepTurn {
2160 run: run_id.to_string(),
2161 step: step_id.to_string(),
2162 reservation,
2163 },
2164 servers,
2165 max_steps,
2166 max_tokens,
2167 deadline_ms,
2168 agent_path: format!("run/{run_id}/{step_id}"),
2169 model: node_model,
2170 };
2171 match self.spawn_turn(launch) {
2172 Ok(node) => {
2173 if let Some(st) = self
2174 .runs
2175 .get_mut(run_id)
2176 .and_then(|r| r.steps.get_mut(step_id))
2177 {
2178 st.worker = Some(node.0.to_string());
2179 }
2180 self.log.info(
2181 "step.turn.spawn",
2182 json!({"run": run_id, "step": step_id, "node": node.0}),
2183 );
2184 }
2185 Err(e) => {
2186 if let Some(r) = reservation {
2187 self.governor.release(r);
2188 }
2189 self.finish_step(
2190 run_id,
2191 step_id,
2192 StepStatus::Failed,
2193 None,
2194 Some(format!("spawn: {e}")),
2195 0,
2196 );
2197 }
2198 }
2199 }
2200
2201 fn run_scopes(&mut self, run_id: &str) -> Vec<String> {
2203 let mut scopes = self.principal_scopes(
2207 self.runs
2208 .get(run_id)
2209 .and_then(|r| r.principal.clone())
2210 .as_deref(),
2211 );
2212 let Some(wf) = self.definition_for_run(run_id) else {
2213 return scopes;
2214 };
2215 if let Some(b) = wf
2216 .limits
2217 .budget
2218 .as_ref()
2219 .and_then(|b| serde_json::from_value::<crate::config::v2::Budget>(b.clone()).ok())
2220 {
2221 let key = format!("run:{run_id}");
2222 self.governor.ensure_scope(&key, &b);
2223 scopes.push(key);
2224 }
2225 scopes
2226 }
2227
2228 pub(crate) fn on_step_done(
2232 &mut self,
2233 run_id: &str,
2234 step_id: &str,
2235 output: Value,
2236 is_error: bool,
2237 error: Option<String>,
2238 tokens: u64,
2239 ) {
2240 self.executing.remove(&format!("{run_id}/{step_id}"));
2241 self.finish_step(
2242 run_id,
2243 step_id,
2244 if is_error {
2245 StepStatus::Failed
2246 } else {
2247 StepStatus::Done
2248 },
2249 Some(output),
2250 error,
2251 tokens,
2252 );
2253 }
2254
2255 pub(crate) fn on_step_turn_done(&mut self, run_id: &str, step_id: &str, turn: TurnResult) {
2257 let tokens = turn.usage.total();
2258 if turn.status == "completed" {
2259 let output = turn
2260 .value
2261 .clone()
2262 .or_else(|| turn.finish.as_ref().and_then(|f| f.get("output").cloned()))
2263 .or_else(|| turn.text.clone().map(Value::String))
2264 .unwrap_or(Value::Null);
2265 let failed = turn
2267 .finish
2268 .as_ref()
2269 .and_then(|f| f.get("status"))
2270 .and_then(Value::as_str)
2271 .is_some_and(|s| s != "completed");
2272 if failed {
2273 let reason = turn
2274 .finish
2275 .as_ref()
2276 .and_then(|f| f.get("reason"))
2277 .and_then(Value::as_str)
2278 .unwrap_or("agent finished with a non-completed status")
2279 .to_string();
2280 self.finish_step(
2281 run_id,
2282 step_id,
2283 StepStatus::Failed,
2284 Some(output),
2285 Some(reason),
2286 tokens,
2287 );
2288 } else {
2289 self.finish_step(
2290 run_id,
2291 step_id,
2292 StepStatus::Done,
2293 Some(output),
2294 None,
2295 tokens,
2296 );
2297 }
2298 } else {
2299 let status = if turn.status == "deadline" {
2300 StepStatus::Timeout
2301 } else {
2302 StepStatus::Failed
2303 };
2304 self.finish_step(
2305 run_id,
2306 step_id,
2307 status,
2308 turn.value
2309 .clone()
2310 .or_else(|| turn.text.clone().map(Value::String)),
2311 Some(format!(
2312 "turn {}{}",
2313 turn.status,
2314 turn.error
2315 .as_deref()
2316 .map(|e| format!(": {e}"))
2317 .unwrap_or_default()
2318 )),
2319 tokens,
2320 );
2321 }
2322 }
2323
2324 pub(crate) fn on_step_timer(
2326 &mut self,
2327 run_id: &str,
2328 step_id: &str,
2329 budget: bool,
2330 payload: &Value,
2331 ) {
2332 if budget {
2333 if let Some(st) = self
2334 .runs
2335 .get_mut(run_id)
2336 .and_then(|r| r.steps.get_mut(step_id))
2337 {
2338 st.status = StepStatus::Pending;
2339 st.wait = None;
2340 }
2341 if let Some(r) = self.runs.get_mut(run_id) {
2342 r.touch();
2343 }
2344 return;
2345 }
2346 self.finish_step(
2347 run_id,
2348 step_id,
2349 StepStatus::Done,
2350 Some(payload.clone()),
2351 None,
2352 0,
2353 );
2354 }
2355
2356 pub(crate) fn finish_step_pub(
2358 &mut self,
2359 run_id: &str,
2360 step_id: &str,
2361 status: StepStatus,
2362 output: Option<Value>,
2363 error: Option<String>,
2364 tokens: u64,
2365 ) {
2366 self.finish_step(run_id, step_id, status, output, error, tokens)
2367 }
2368
2369 fn finish_step(
2370 &mut self,
2371 run_id: &str,
2372 step_id: &str,
2373 status: StepStatus,
2374 output: Option<Value>,
2375 error: Option<String>,
2376 tokens: u64,
2377 ) {
2378 self.resched = true;
2381 let Some(wf) = self
2382 .runs
2383 .get(run_id)
2384 .and_then(|_| self.definition_for_run(run_id))
2385 else {
2386 return;
2387 };
2388 let Some((step, scope)) = self
2389 .runs
2390 .get(run_id)
2391 .and_then(|r| self.resolve_step(&wf, r, step_id))
2392 else {
2393 return;
2394 };
2395 {
2396 let run = self.runs.get_mut(run_id).expect("present");
2397 if run.status.is_terminal() {
2398 return; }
2400 run.tokens += tokens;
2401 }
2402 crate::obs::metrics::record_step(match status {
2403 StepStatus::Done => "done",
2404 StepStatus::Failed => "failed",
2405 _ => "other",
2406 });
2407 let output = match output {
2412 Some(v) if !v.is_null() => {
2413 let cap = self.settings.limits.inline_max_bytes.unwrap_or(65_536) as usize;
2414 if v.to_string().len() > cap {
2415 match self.artifacts.create(
2416 &self.durable,
2417 super::artifacts::NewArtifact {
2418 name: &format!("{run_id}/{step_id}/output.json"),
2419 mime: Some("application/json"),
2420 content: v.clone(),
2421 created_by: Some("engine"),
2422 sensitive: false,
2423 owner: Some(run_id),
2424 },
2425 ) {
2426 Ok(meta) => {
2427 self.log.info("step.output.artifact", json!({"run": run_id, "step": step_id, "artifact": meta["id"], "size": meta["size"]}));
2428 Some(json!({"$artifact": meta["id"], "size": meta["size"]}))
2429 }
2430 Err(e) => {
2431 self.log.warn(
2432 "step.output.artifact_fail",
2433 json!({"run": run_id, "step": step_id, "err": e}),
2434 );
2435 Some(v)
2436 }
2437 }
2438 } else {
2439 Some(v)
2440 }
2441 }
2442 other => other,
2443 };
2444 let (status, error) = match (&status, &step.output_schema, &output) {
2447 (StepStatus::Done, Some(schema), Some(out)) => {
2448 match crate::jsonschema::validate(schema, out) {
2449 Ok(()) => (status, error),
2450 Err(e) => (
2451 StepStatus::Failed,
2452 Some(format!(
2453 "output does not match output_schema: {}",
2454 crate::jsonschema::explain(&e)
2455 )),
2456 ),
2457 }
2458 }
2459 _ => (status, error),
2460 };
2461 let attempt = self
2462 .runs
2463 .get(run_id)
2464 .and_then(|r| r.step(step_id))
2465 .map(|s| s.attempt)
2466 .unwrap_or(1);
2467 self.log.info("step.done", json!({"run": run_id, "step": step_id, "status": status, "attempt": attempt, "tokens": tokens, "err": error}));
2468 if matches!(
2472 step.kind.as_str(),
2473 "http" | "mcp.tool" | "a2a.send" | "a2a.delegate"
2474 ) && let Some(cfg) = self.effective_breaker(&step)
2475 && !matches!(status, StepStatus::Failed | StepStatus::Timeout if error
2476 .as_deref()
2477 .is_some_and(|e| e.starts_with(super::breaker::OPEN_ERR)))
2478 && matches!(
2479 status,
2480 StepStatus::Done | StepStatus::Failed | StepStatus::Timeout
2481 )
2482 {
2483 let workflow = self
2484 .runs
2485 .get(run_id)
2486 .map(|r| r.workflow.clone())
2487 .unwrap_or_default();
2488 let key = super::breaker::key(&workflow, step_id);
2489 let mut st = self
2490 .durable
2491 .manifest()
2492 .breakers
2493 .get(&key)
2494 .cloned()
2495 .unwrap_or_else(|| json!({}));
2496 let t = super::breaker::record(&mut st, cfg, status == StepStatus::Done, now_ms());
2497 self.durable.manifest_update(|m| {
2498 m.breakers.insert(key.clone(), st);
2499 });
2500 match t {
2501 super::breaker::Transition::Opened { fails } => self.log.warn(
2502 "breaker.open",
2503 json!({"breaker": key, "consecutive_failures": fails,
2504 "cooldown": format!("{}ms", cfg.cooldown_ms)}),
2505 ),
2506 super::breaker::Transition::Reopened => self.log.warn(
2507 "breaker.reopen",
2508 json!({"breaker": key, "probe_failed": true}),
2509 ),
2510 super::breaker::Transition::Closed => {
2511 self.log.info("breaker.closed", json!({"breaker": key}))
2512 }
2513 super::breaker::Transition::None => {}
2514 }
2515 }
2516 #[cfg(feature = "a2a")]
2517 self.feed_push(
2518 "step",
2519 crate::runtime::a2a_server::FeedVis::Operator,
2520 json!({"run": run_id, "step": step_id, "phase": "done",
2521 "status": crate::runtime::nested::StatusLabel::as_label(&status), "attempt": attempt, "tokens": tokens,
2522 "err": error.as_deref().map(|e| {
2525 e.chars().take(2048).collect::<String>()
2526 })}),
2527 );
2528 if matches!(status, StepStatus::Failed | StepStatus::Timeout) {
2529 if let Some(retry) = &step.retry
2531 && attempt <= retry.max
2532 {
2533 let base = retry
2540 .backoff_ms
2541 .saturating_mul(1u64 << (attempt.saturating_sub(1)).min(10));
2542 let backoff = if base == 0 {
2543 0
2544 } else {
2545 let mut h: u64 = 1469598103934665603;
2546 for b in run_id.bytes().chain(step_id.bytes()).chain([attempt as u8]) {
2547 h ^= b as u64;
2548 h = h.wrapping_mul(1099511628211);
2549 }
2550 let spread = (base / 5).max(1);
2552 base.saturating_sub(spread) + (h % (spread * 2 + 1))
2553 };
2554 self.log.info("step.retry", json!({"run": run_id, "step": step_id, "attempt": attempt, "backoff_ms": backoff}));
2555 if backoff == 0 {
2556 if let Some(st) = self
2557 .runs
2558 .get_mut(run_id)
2559 .and_then(|r| r.steps.get_mut(step_id))
2560 {
2561 st.status = StepStatus::Pending;
2562 st.error = error;
2563 }
2564 } else {
2565 match self.timers.arm(
2566 &self.durable,
2567 now_ms() + backoff,
2568 json!({"kind": "step_budget", "run": run_id, "step": step_id}),
2569 Value::Null,
2570 ) {
2571 Ok(id) => {
2572 self.runs.get_mut(run_id).expect("present").suspend_step(
2573 step_id,
2574 json!({"kind": "retry_backoff", "timer": id, "error": error}),
2575 );
2576 }
2577 Err(_) => {
2578 if let Some(st) = self
2579 .runs
2580 .get_mut(run_id)
2581 .and_then(|r| r.steps.get_mut(step_id))
2582 {
2583 st.status = StepStatus::Pending;
2584 }
2585 }
2586 }
2587 }
2588 self.checkpoint(false);
2589 return;
2590 }
2591 let err_text = error.clone().unwrap_or_else(|| "failed".into());
2592 self.runs
2593 .get_mut(run_id)
2594 .expect("present")
2595 .end_step(step_id, status, output, error);
2596 if status == StepStatus::Timeout
2604 && let Some(t) = step.field_str("on_timeout")
2605 {
2606 let target = match &scope {
2607 Some(sc) => super::nested::scoped_id(&sc.parent, t),
2608 None => t.to_string(),
2609 };
2610 if let Some(st) = self
2611 .runs
2612 .get_mut(run_id)
2613 .and_then(|r| r.steps.get_mut(&target))
2614 {
2615 st.status = StepStatus::Pending;
2616 st.forced = true;
2617 }
2618 self.log.info(
2619 "step.timeout_routed",
2620 json!({"run": run_id, "step": step_id, "to": t}),
2621 );
2622 crate::state::kill_point("step.before_done");
2623 self.checkpoint(false);
2624 if scope.is_some() {
2625 self.on_scoped_step_done(run_id, step_id);
2626 }
2627 return;
2628 }
2629 if let Some(sc) = &scope {
2630 match &step.on_error {
2633 OnError::Continue => {
2634 if let Some(st) = self
2635 .runs
2636 .get_mut(run_id)
2637 .and_then(|r| r.steps.get_mut(step_id))
2638 {
2639 st.status = StepStatus::Done;
2640 st.error = Some(err_text.clone());
2641 if st.output.is_none() {
2642 st.output = Some(json!({"error": err_text}));
2643 }
2644 }
2645 }
2646 OnError::Goto(t) => {
2647 let sid = super::nested::scoped_id(&sc.parent, t);
2648 if let Some(st) = self
2649 .runs
2650 .get_mut(run_id)
2651 .and_then(|r| r.steps.get_mut(&sid))
2652 {
2653 st.status = StepStatus::Pending;
2654 st.forced = true;
2655 }
2656 }
2657 OnError::Fail => {}
2658 }
2659 crate::state::kill_point("step.before_done");
2660 if !crate::engine::model::pure_data_kind(&step.kind) {
2661 self.checkpoint(false);
2662 }
2663 self.on_scoped_step_done(run_id, step_id);
2664 return;
2665 }
2666 let routed = run::route_failure(
2667 &wf,
2668 self.runs.get_mut(run_id).expect("present"),
2669 &step,
2670 &err_text,
2671 );
2672 match routed {
2673 Ok(next) => {
2674 if !next.is_empty() {
2675 self.log.info(
2676 "step.goto",
2677 json!({"run": run_id, "from": step_id, "to": next}),
2678 );
2679 }
2680 }
2681 Err(reason) => {
2682 self.cancel_children_of_run(run_id, "run failed");
2683 self.runs.get_mut(run_id).expect("present").finish(
2684 RunStatus::Failed,
2685 None,
2686 Some(reason),
2687 );
2688 self.on_run_terminal(run_id);
2689 return;
2690 }
2691 }
2692 if let OnError::Continue = step.on_error {
2693 }
2695 } else {
2696 if step.cache.is_some()
2698 && status == StepStatus::Done
2699 && let Some(key) = self
2700 .runs
2701 .get(run_id)
2702 .and_then(|r| r.steps.get(step_id))
2703 .and_then(|st| st.cache_key.clone())
2704 && let Some(out) = &output
2705 {
2706 self.cache_store(&key, out);
2707 }
2708 self.runs
2709 .get_mut(run_id)
2710 .expect("present")
2711 .end_step(step_id, status, output, error);
2712 }
2713 crate::state::kill_point("step.before_done");
2714 let terminal_now = self
2717 .runs
2718 .get(run_id)
2719 .is_some_and(|r| r.status.is_terminal());
2720 if terminal_now || !crate::engine::model::pure_data_kind(&step.kind) {
2721 self.checkpoint(false);
2722 }
2723 if scope.is_some() {
2724 self.on_scoped_step_done(run_id, step_id);
2725 }
2726 }
2727
2728 fn evict_terminal_runs(&mut self) {
2738 let policy = &self.settings.store.retention.runs;
2739 let keep_last = policy.keep_last;
2740 let ttl_ms = policy.ttl.as_ref().map(|d| d.0.as_millis() as u64);
2741 if keep_last.is_none() && ttl_ms.is_none() {
2742 return;
2743 }
2744 let now = now_ms();
2745 let mut terminal: Vec<(String, u64)> = self
2747 .runs
2748 .values()
2749 .filter(|r| r.status.is_terminal())
2750 .map(|r| (r.id.clone(), r.finished.unwrap_or(0)))
2751 .collect();
2752 terminal.sort_by_key(|(_, finished)| std::cmp::Reverse(*finished));
2753
2754 let mut drop: Vec<String> = Vec::new();
2755 for (i, (id, finished)) in terminal.iter().enumerate() {
2756 let over_count = keep_last.is_some_and(|k| i >= k as usize);
2757 let over_age = ttl_ms.is_some_and(|t| now.saturating_sub(*finished) > t);
2758 if over_count || over_age {
2759 drop.push(id.clone());
2760 }
2761 }
2762 for id in drop {
2763 let was_durable = self.runs.get(&id).is_none_or(|r| r.durable);
2765 self.runs.remove(&id);
2766 if was_durable && let Err(e) = self.durable.delete(crate::state::Kind::Run, &id) {
2767 self.log
2768 .warn("run.evict.fail", json!({"run": id, "err": e.to_string()}));
2769 continue;
2770 }
2771 self.log.info("run.evicted", json!({"run": id}));
2772 }
2773 }
2774
2775 pub(crate) fn on_run_terminal(&mut self, run_id: &str) {
2776 let Some(run) = self.runs.get(run_id) else {
2777 return;
2778 };
2779 let (status, output, error, workflow) = (
2780 run.status,
2781 run.output.clone(),
2782 run.error.clone(),
2783 run.workflow.clone(),
2784 );
2785 #[cfg(feature = "a2a")]
2786 let a2a_task = run.task.clone();
2787 let evict_after = true;
2793 #[cfg(feature = "a2a")]
2795 self.webhook_sync_reply(run_id);
2796 if let Some(parent) = self.runs.get(run_id).and_then(|r| r.parent.clone())
2798 && let (Some(pr), Some(ps)) = (
2799 parent["run"].as_str().map(str::to_string),
2800 parent["step"].as_str().map(str::to_string),
2801 )
2802 && self
2803 .runs
2804 .get(&pr)
2805 .and_then(|r| r.steps.get(&ps))
2806 .is_some_and(|st| {
2807 st.status == StepStatus::Suspended
2808 && st.wait.as_ref().is_some_and(|w| w["kind"] == "child_run")
2809 })
2810 {
2811 self.finish_step_pub(
2812 &pr,
2813 &ps,
2814 if status == RunStatus::Completed {
2815 StepStatus::Done
2816 } else {
2817 StepStatus::Failed
2818 },
2819 Some(json!({"run": run_id, "status": status, "output": output, "error": error})),
2820 (status != RunStatus::Completed).then(|| {
2821 error
2822 .clone()
2823 .unwrap_or_else(|| format!("child run {}", status.as_str()))
2824 }),
2825 0,
2826 );
2827 }
2828 self.counters.runs_finished += 1;
2829 crate::obs::metrics::record_run(match status {
2830 RunStatus::Completed => crate::obs::metrics::RunOutcome::Completed,
2831 RunStatus::Cancelled => crate::obs::metrics::RunOutcome::Killed,
2832 _ => crate::obs::metrics::RunOutcome::Failed,
2833 });
2834 crate::obs::metrics::record_run_status(status.as_str());
2835 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 }}));
2836 self.governor.drop_scope(&format!("run:{run_id}"));
2837 self.retire_sweep();
2838 if let Some(hash) = self.runs.get(run_id).map(|r| r.workflow_hash.clone())
2842 && !self
2843 .runs
2844 .values()
2845 .any(|r| !r.status.is_terminal() && r.workflow_hash == hash)
2846 {
2847 let _ = self.durable.delete(
2848 crate::state::Kind::Memory,
2849 &format!("{}{hash}", super::retire::PIN_PREFIX),
2850 );
2851 self.pin_written.remove(&hash);
2852 }
2853 let waiting: Vec<Target> = self
2855 .pending
2856 .iter()
2857 .filter(|p| matches!(&p.kind, PendingKind::Run { run, .. } if run == run_id))
2858 .map(|p| p.target.clone())
2859 .collect();
2860 self.pending
2861 .retain(|p| !matches!(&p.kind, PendingKind::Run { run, .. } if run == run_id));
2862 for t in waiting {
2863 self.reply(
2864 &t,
2865 json!({"run": run_id, "status": status, "output": output, "error": error}),
2866 false,
2867 );
2868 }
2869 let ok = status == RunStatus::Completed;
2871 let note = error
2872 .clone()
2873 .or_else(|| output.as_ref().map(|o| o.to_string()))
2874 .unwrap_or_default();
2875 self.settle_plan_bindings(
2876 &crate::context::plan::Binding::Run {
2877 id: run_id.to_string(),
2878 },
2879 ok,
2880 ¬e,
2881 );
2882 let wake = self.settings.agent.wake_on();
2883 let notify = match self.settings.agent.on_workflow_finished {
2884 crate::config::v2::OnWorkflowFinished::Ignore => false,
2885 _ => {
2886 ok && wake.contains(&crate::config::v2::WakeEvent::WorkflowFinished)
2887 || !ok && wake.contains(&crate::config::v2::WakeEvent::WorkflowFailed)
2888 }
2889 };
2890 if notify && !self.job_shape {
2891 let short = if note.chars().count() > 400 {
2892 format!("{}…", note.chars().take(400).collect::<String>())
2893 } else {
2894 note.clone()
2895 };
2896 let line = format!(
2897 "workflow {workflow} run {run_id} {}: {short}",
2898 status.as_str()
2899 );
2900 match self.settings.agent.on_workflow_finished {
2901 crate::config::v2::OnWorkflowFinished::Think => {
2907 let depth = self.runs.get(run_id).map(|r| r.msg_depth).unwrap_or(0) + 1;
2908 let cap = self.settings.limits.message_depth();
2909 if depth > cap {
2910 self.log.warn(
2911 "message.too_deep",
2912 json!({"run": run_id, "reason": "on_workflow_finished",
2913 "depth": depth, "max": cap}),
2914 );
2915 self.note_root(line);
2916 } else {
2917 let principal = self.runs.get(run_id).and_then(|r| r.principal.clone());
2918 if let Err(e) = self.accept_event(
2919 kinds::A2A_MESSAGE,
2920 principal,
2921 json!({"text": line.clone(), "context_id": crate::context::ROOT,
2922 "msg_depth": depth}),
2923 ) {
2924 self.log
2925 .warn("workflow.think.fail", json!({"run": run_id, "err": e}));
2926 self.note_root(line);
2927 }
2928 }
2929 }
2930 _ => self.note_root(line),
2931 }
2932 }
2933 if let Some((wf, node, spec, kind)) = self.run_start_spec(run_id)
2936 && kind == "loop"
2937 {
2938 self.on_loop_run_finished(
2939 &wf,
2940 &node,
2941 &spec,
2942 ok,
2943 &output.clone().unwrap_or(Value::Null),
2944 );
2945 }
2946 if let Some(ev) = super::starts::run_event(status) {
2947 self.fire_event_starts(
2948 ev,
2949 &json!({"run": run_id, "workflow": workflow, "status": status.as_str()}),
2950 );
2951 }
2952 #[cfg(feature = "a2a")]
2954 if let Some(tid) = &a2a_task {
2955 self.a2a_task_for_run(tid, status.as_str(), output.as_ref(), error.as_deref());
2956 }
2957 self.checkpoint(false);
2958 if evict_after {
2959 self.evict_terminal_runs();
2960 }
2961 }
2962
2963 pub(crate) fn cancel_run(&mut self, run_id: &str, reason: &str) {
2965 let kids: Vec<String> = self
2968 .runs
2969 .values()
2970 .filter(|r| {
2971 !r.status.is_terminal()
2972 && r.parent.as_ref().is_some_and(|p| {
2973 p["run"].as_str() == Some(run_id) && p["cascade"].as_bool().unwrap_or(true)
2974 })
2975 })
2976 .map(|r| r.id.clone())
2977 .collect();
2978 for k in kids {
2979 self.cancel_run(&k, "parent run cancelled");
2980 }
2981 self.cancel_children_of_run(run_id, reason);
2982 let timers = self.timers.owned_by(|o| o["run"].as_str() == Some(run_id));
2983 for t in timers {
2984 let _ = self.timers.disarm(&self.durable, &t);
2985 }
2986 self.pending
2987 .retain(|p| !matches!(&p.target, Target::Step(r, _) if r == run_id));
2988 if let Some(r) = self.runs.get_mut(run_id)
2989 && !r.status.is_terminal()
2990 {
2991 r.finish(RunStatus::Cancelled, None, Some(reason.to_string()));
2992 self.on_run_terminal(run_id);
2993 }
2994 }
2995
2996 fn cancel_children_of_run(&mut self, run_id: &str, reason: &str) {
2997 let nodes: Vec<_> = self
2998 .children
2999 .iter()
3000 .filter(|(_, c)| matches!(&c.kind, ChildKind::StepTurn { run, .. } if run == run_id))
3001 .map(|(n, _)| *n)
3002 .collect();
3003 for n in nodes {
3004 self.children.cancel(n, reason);
3005 }
3006 }
3007
3008 pub(crate) fn workflow_tool(
3011 &mut self,
3012 caller: &ToolCaller,
3013 name: &str,
3014 args: Value,
3015 ) -> ToolOutcome {
3016 let err = |e: String| ToolOutcome::Ready(Value::String(e), true);
3017 match name {
3018 "workflow.run" => {
3019 let wname = args["name"].as_str().unwrap_or("").to_string();
3020 let Some(w) = self.workflows.get(&wname) else {
3021 return err(format!("no such workflow {wname:?}"));
3022 };
3023 if let Some(cause) = self
3024 .pressure
3025 .refusal(w.priority == crate::engine::model::Priority::Low)
3026 {
3027 return err(format!(
3028 "workflow.run refused: {cause}; retry when it clears"
3029 ));
3030 }
3031 let start = match args.get("start").and_then(Value::as_str) {
3032 Some(s) => match w.step(s) {
3033 Some(st) if st.is_start() => s.to_string(),
3034 _ => return err(format!("workflow {wname:?} has no start node {s:?}")),
3035 },
3036 None => match default_start(w) {
3037 Some(s) => s,
3038 None => return err(format!("workflow {wname:?} has no start node")),
3039 },
3040 };
3041 let wait = args.get("wait").and_then(Value::as_bool).unwrap_or(false);
3042 let timeout_ms = args
3043 .get("timeout")
3044 .and_then(Value::as_str)
3045 .and_then(|t| crate::config::parse_duration(t).ok())
3046 .map(|d| d.as_millis() as u64)
3047 .unwrap_or(3_600_000);
3048 let request = match (caller.node, &caller.run, &caller.step) {
3049 (Some(n), _, _) => {
3050 json!({"node": n.0, "req": caller.req, "wait": wait, "timeout_ms": timeout_ms})
3051 }
3052 (None, Some(r), Some(s)) => {
3053 json!({"run": r, "step": s, "wait": wait, "timeout_ms": timeout_ms})
3054 }
3055 _ => Value::Null,
3056 };
3057 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});
3058 match self.accept_event(kinds::WORKFLOW_RUN, caller.principal.clone(), payload) {
3059 Ok(_) => {
3060 if let Some(ev) = self.inbox_queue.pop_back() {
3062 let done = self.on_start_event(&ev);
3063 if done {
3064 self.inbox_done(&ev.id);
3065 }
3066 }
3067 ToolOutcome::Executing
3071 }
3072 Err(e) => err(e),
3073 }
3074 }
3075 "workflow.list" => ToolOutcome::Ready(
3076 json!({"workflows": self.workflows.values().map(|w| json!({
3077 "name": w.name, "description": w.description, "armed": w.armed, "hash": w.hash,
3078 "starts": w.start_steps().iter().map(|s| json!({"node": s.id, "kind": s.kind})).collect::<Vec<_>>(),
3079 "runs": self.runs.values().filter(|r| r.workflow == w.name).map(|r| json!({"id": r.id, "status": r.status})).collect::<Vec<_>>(),
3080 })).collect::<Vec<_>>()}),
3081 false,
3082 ),
3083 "workflow.status" => {
3084 let runs: Vec<Value> = match (
3085 args.get("run").and_then(Value::as_str),
3086 args.get("name").and_then(Value::as_str),
3087 ) {
3088 (Some(id), _) => self
3089 .runs
3090 .get(id)
3091 .map(|r| vec![run_detail(r)])
3092 .unwrap_or_default(),
3093 (None, Some(n)) => self
3094 .runs
3095 .values()
3096 .filter(|r| r.workflow == n)
3097 .map(RunState::summary)
3098 .collect(),
3099 _ => self.runs.values().map(RunState::summary).collect(),
3100 };
3101 ToolOutcome::Ready(json!({"runs": runs}), false)
3102 }
3103 "workflow.cancel" => {
3104 let id = args["run"].as_str().unwrap_or("").to_string();
3105 if !self.runs.contains_key(&id) {
3106 return err(format!("no such run {id:?}"));
3107 }
3108 let reason = args
3109 .get("reason")
3110 .and_then(Value::as_str)
3111 .unwrap_or("cancelled by request")
3112 .to_string();
3113 self.cancel_run(&id, &reason);
3114 ToolOutcome::Ready(
3115 json!({"ok": true, "status": self.runs.get(&id).map(|r| r.status.as_str()).unwrap_or("cancelled")}),
3116 false,
3117 )
3118 }
3119 "workflow.wait" => {
3120 let id = args["run"].as_str().unwrap_or("").to_string();
3121 let timeout_ms = args
3122 .get("timeout")
3123 .and_then(Value::as_str)
3124 .and_then(|t| crate::config::parse_duration(t).ok())
3125 .map(|d| d.as_millis() as u64)
3126 .unwrap_or(3_600_000);
3127 match self.runs.get(&id) {
3128 None => err(format!("no such run {id:?}")),
3129 Some(r) if r.status.is_terminal() => ToolOutcome::Ready(
3130 json!({"run": id, "status": r.status, "output": r.output, "error": r.error}),
3131 false,
3132 ),
3133 Some(_) => ToolOutcome::Deferred(PendingKind::Run {
3134 run: id,
3135 deadline_ms: now_ms() + timeout_ms,
3136 }),
3137 }
3138 }
3139 "workflow.pause" | "workflow.resume" => {
3140 let pause = name == "workflow.pause";
3141 if pause
3147 && let Some(id) = args.get("run").and_then(Value::as_str)
3148 && let Some(step) = args.get("before_step").and_then(Value::as_str)
3149 {
3150 let known = self
3151 .definition_for_run(id)
3152 .is_some_and(|wf| wf.steps.contains_key(step));
3153 if !known {
3154 return err(format!(
3155 "before_step {step:?} is not a step of this run's workflow"
3156 ));
3157 }
3158 match self.runs.get_mut(id) {
3159 None => return err(format!("no such run {id:?}")),
3160 Some(r) => {
3161 r.break_before = Some(step.to_string());
3162 r.dirty = true;
3163 }
3164 }
3165 self.log
3166 .info("run.breakpoint", json!({"run": id, "before_step": step}));
3167 return ToolOutcome::Ready(json!({"run": id, "break_before": step}), false);
3168 }
3169 if let Some(id) = args.get("run").and_then(Value::as_str) {
3170 match self.runs.get_mut(id) {
3171 None => return err(format!("no such run {id:?}")),
3172 Some(r) if r.status.is_terminal() => {
3173 return err(format!("run {id:?} is already {}", r.status.as_str()));
3174 }
3175 Some(r) => {
3176 r.status = if pause {
3177 RunStatus::Paused
3178 } else {
3179 RunStatus::Running
3180 };
3181 r.touch();
3182 }
3183 }
3184 return ToolOutcome::Ready(json!({"ok": true}), false);
3185 }
3186 if let Some(n) = args.get("name").and_then(Value::as_str) {
3187 match self.workflows.get_mut(n) {
3188 None => return err(format!("no such workflow {n:?}")),
3189 Some(w) => std::sync::Arc::make_mut(w).armed = !pause,
3193 }
3194 if !pause {
3195 self.arm_workflows();
3196 }
3197 return ToolOutcome::Ready(json!({"ok": true}), false);
3198 }
3199 err(format!("{name}: give run or name"))
3200 }
3201 "workflow.create" | "workflow.update" => {
3202 if let Some(e) = self.workflows_locked(name) {
3207 return e;
3208 }
3209 let def = args["definition"].clone();
3210 match parse_workflow(&def) {
3211 Err(e) => err(format!("{name}: {}", e.join("; "))),
3212 Ok(w) if w.tool.is_some() => err(format!(
3213 "{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 {:?})",
3214 w.name
3215 )),
3216 Ok(mut w) => {
3217 self.fill_durable_default(&mut w);
3218 if name == "workflow.create" && self.workflows.contains_key(&w.name) {
3219 return err(format!(
3220 "workflow {:?} exists (use workflow.update)",
3221 w.name
3222 ));
3223 }
3224 if name == "workflow.update" && !self.workflows.contains_key(&w.name) {
3225 return err(format!(
3226 "workflow {:?} does not exist (use workflow.create)",
3227 w.name
3228 ));
3229 }
3230 let (wname, hash) = (w.name.clone(), w.hash.clone());
3231 let rec = crate::context::memory::Record {
3233 value: def,
3234 ts: now_ms(),
3235 ttl_ms: None,
3236 by: Some(caller.label_pub()),
3237 };
3238 if let Err(e) = self.durable.put(
3239 Kind::Memory,
3240 &format!("{WORKFLOW_DEF_PREFIX}{wname}"),
3241 serde_json::to_value(&rec).unwrap_or(Value::Null),
3242 None,
3243 ) {
3244 return err(format!("{name}: store: {e}"));
3245 }
3246 let arm = args.get("arm").and_then(Value::as_bool).unwrap_or(true);
3247 let mut w = w;
3248 w.armed = arm;
3249 self.workflows.insert(wname.clone(), std::sync::Arc::new(w));
3250 self.log.info(
3251 "workflow.defined",
3252 json!({"name": wname, "hash": &hash[..12], "op": name}),
3253 );
3254 if arm {
3255 self.arm_workflows();
3256 }
3257 ToolOutcome::Ready(
3258 json!({"name": wname, "hash": hash, "armed": arm}),
3259 false,
3260 )
3261 }
3262 }
3263 }
3264 "workflow.delete" => {
3265 if let Some(e) = self.workflows_locked(name) {
3266 return e;
3267 }
3268 let wname = args["name"].as_str().unwrap_or("").to_string();
3269 let Some(wf) = self.workflows.remove(&wname) else {
3270 return err(format!("no such workflow {wname:?}"));
3271 };
3272 let _ = self
3273 .durable
3274 .delete(Kind::Memory, &format!("{WORKFLOW_DEF_PREFIX}{wname}"));
3275 self.retire_workflow(&wf, "deleted");
3280 self.log.info("workflow.deleted", json!({"name": wname}));
3281 ToolOutcome::Ready(json!({"ok": true}), false)
3282 }
3283 "workflow.signal" => {
3284 let sname = args["name"].as_str().unwrap_or("").to_string();
3285 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()}));
3286 ToolOutcome::Ready(
3290 json!({"delivered": 0, "note": "signal recorded; waits and signal start nodes are woken when the loop drains the inbox"}),
3291 false,
3292 )
3293 }
3294 _ => err(format!("unknown workflow tool {name}")),
3295 }
3296 }
3297}
3298
3299impl ToolCaller {
3300 pub(crate) fn label_pub(&self) -> String {
3301 if let Some(s) = &self.subagent {
3302 return format!("subagent:{s}");
3303 }
3304 if let (Some(r), Some(s)) = (&self.run, &self.step) {
3305 return format!("step:{r}/{s}");
3306 }
3307 format!(
3308 "ctx:{}",
3309 self.ctx.as_deref().unwrap_or(crate::context::ROOT)
3310 )
3311 }
3312}
3313
3314fn default_start(w: &Workflow) -> Option<String> {
3316 let starts = w.start_steps();
3317 starts
3318 .iter()
3319 .find(|s| s.kind == "manual")
3320 .or_else(|| starts.first())
3321 .map(|s| s.id.clone())
3322}
3323
3324fn node_kind<'a>(w: &'a Workflow, node: &str) -> Option<&'a str> {
3325 w.step(node).map(|s| s.kind.as_str())
3326}
3327
3328fn run_detail(r: &RunState) -> Value {
3329 let mut v = r.summary();
3330 v["step_states"] = json!(r.steps);
3331 v["vars"] = Value::Object(r.vars.clone());
3332 v
3333}
3334
3335fn collect_memory_keys(v: &Value, out: &mut Vec<String>) {
3337 match v {
3338 Value::String(s) => {
3339 let mut rest = s.as_str();
3340 while let Some(i) = rest.find("memory.") {
3341 let after = &rest[i + "memory.".len()..];
3342 let key: String = after
3343 .chars()
3344 .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '/' | ':'))
3345 .collect();
3346 if !key.is_empty() && !out.contains(&key) {
3347 out.push(key.clone());
3348 }
3349 rest = &after[key.len().min(after.len())..];
3350 }
3351 }
3352 Value::Array(a) => a.iter().for_each(|x| collect_memory_keys(x, out)),
3353 Value::Object(o) => o.values().for_each(|x| collect_memory_keys(x, out)),
3354 _ => {}
3355 }
3356}
3357
3358fn expand_dir(dir: &str, pattern: &str) -> Result<Vec<String>, String> {
3370 let root = std::path::Path::new(dir);
3371 if !root.is_dir() {
3372 return Err(format!("not a directory ({})", root.display()));
3373 }
3374 let pats: Vec<&str> = pattern
3375 .split(',')
3376 .map(str::trim)
3377 .filter(|p| !p.is_empty())
3378 .collect();
3379 let recursive = pats.iter().any(|p| p.contains("**"));
3380 let mut out = Vec::new();
3381 let mut stack = vec![root.to_path_buf()];
3382 while let Some(d) = stack.pop() {
3383 let rd = std::fs::read_dir(&d).map_err(|e| e.to_string())?;
3384 for ent in rd.flatten() {
3385 let path = ent.path();
3386 if path.is_dir() {
3387 if recursive {
3388 stack.push(path);
3389 }
3390 continue;
3391 }
3392 let rel = path.strip_prefix(root).unwrap_or(&path);
3393 let rels = rel.to_string_lossy();
3394 if pats.iter().any(|p| glob_match(p, &rels)) {
3395 out.push(path.to_string_lossy().into_owned());
3396 }
3397 }
3398 }
3399 out.sort();
3400 Ok(out)
3401}
3402
3403fn glob_match(pat: &str, text: &str) -> bool {
3408 if let Some(rest) = pat.strip_prefix("**/")
3411 && glob_match(rest, text)
3412 {
3413 return true;
3414 }
3415 let (p, t): (Vec<char>, Vec<char>) = (pat.chars().collect(), text.chars().collect());
3416 fn go(p: &[char], t: &[char]) -> bool {
3417 match p.first() {
3418 None => t.is_empty(),
3419 Some('*') => {
3420 let doubled = p.get(1) == Some(&'*');
3421 let rest = if doubled { &p[2..] } else { &p[1..] };
3422 let mut i = 0;
3424 loop {
3425 if go(rest, &t[i..]) {
3426 return true;
3427 }
3428 if i >= t.len() {
3429 return false;
3430 }
3431 if !doubled && t[i] == '/' {
3432 return false;
3433 }
3434 i += 1;
3435 }
3436 }
3437 Some('?') if !t.is_empty() => go(&p[1..], &t[1..]),
3438 Some(c) if t.first() == Some(c) => go(&p[1..], &t[1..]),
3439 _ => false,
3440 }
3441 }
3442 go(&p, &t)
3443}
3444
3445#[cfg(test)]
3446mod glob_tests {
3447 use super::glob_match;
3448
3449 #[test]
3450 fn a_single_star_stays_inside_one_segment_and_double_crosses() {
3451 assert!(glob_match("*.yaml", "nightly.yaml"));
3453 assert!(
3454 !glob_match("*.yaml", "team/nightly.yaml"),
3455 "* must not cross /"
3456 );
3457 assert!(glob_match("**/*.yaml", "team/nightly.yaml"));
3458 assert!(glob_match("**/*.yaml", "a/b/c/deep.yaml"));
3459 assert!(glob_match("**/*.yaml", "nightly.yaml"));
3462
3463 assert!(glob_match("flows/*.json", "flows/a.json"));
3464 assert!(!glob_match("flows/*.json", "flows/a.yaml"));
3465 assert!(!glob_match("*.yaml", "yaml"), "the dot is literal");
3466 assert!(glob_match("?.yaml", "a.yaml"));
3467 assert!(!glob_match("?.yaml", "ab.yaml"));
3468 assert!(glob_match("nightly.yaml", "nightly.yaml"));
3470 assert!(!glob_match("nightly.yaml", "nightly.yml"));
3471 }
3472}