Skip to main content

agentd/runtime/
starts.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! **Start nodes** are a workflow's triggers: beyond `once`
3//! and `manual`, the long-lived start kinds fire runs while the instance lives
4//! — `loop` (re-run on completion, `interval`/`until`/`max_iterations`/
5//! `backoff`), `schedule` (cron / `every`, `catch_up`), `subscribe` (an MCP
6//! resource update, notify-then-read, `debounce`/`coalesce`/`filter`/`window`),
7//! `signal` (a named signal), `event` (an internal lifecycle event), and `a2a`
8//! (a principal's message routed here). A `subscribe` start has no `claim` or
9//! `shard`: agentd holds no lease and partitions no work — exactly-one-owner
10//! across a fleet belongs to whatever the work comes FROM, because only that
11//! can hand an item to somebody else when a holder dies (see
12//! `docs/scaling.md`). Start-node state (last fired, iteration, missed,
13//! next deadline, debounce) is durable in the manifest, so a restart resumes
14//! the schedule rather than restarting it.
15
16use super::events::kinds;
17use super::reactor::Runtime;
18use crate::engine::model::Step;
19use crate::engine::run::RunStatus;
20use crate::state::now_ms;
21use serde_json::{Map, Value, json};
22
23/// `(workflow, node, kind, spec)` — a start node identity + its config.
24type StartSpec = (String, String, String, Map<String, Value>);
25
26impl Runtime {
27    /// The manifest key for a start node's state.
28    fn start_key(workflow: &str, node: &str) -> String {
29        format!("{workflow}.{node}")
30    }
31
32    /// Read a start node's durable state.
33    pub(crate) fn start_state_pub(&self, workflow: &str, node: &str) -> Value {
34        self.start_state(workflow, node)
35    }
36    pub(crate) fn set_start_state_pub(&mut self, workflow: &str, node: &str, state: Value) {
37        self.set_start_state(workflow, node, state)
38    }
39
40    fn start_state(&self, workflow: &str, node: &str) -> Value {
41        self.durable
42            .manifest()
43            .starts
44            .get(&Self::start_key(workflow, node))
45            .cloned()
46            .unwrap_or(json!({}))
47    }
48
49    /// Update a start node's durable state.
50    fn set_start_state(&mut self, workflow: &str, node: &str, state: Value) {
51        let key = Self::start_key(workflow, node);
52        self.durable.manifest_update(|m| {
53            m.starts.insert(key, state);
54        });
55    }
56
57    /// Arm the long-lived start nodes at boot/restore (called by `arm_workflows`
58    /// after the `once` handling). Schedules the first deadline for `loop`/
59    /// `schedule` and subscribes `subscribe` resources.
60    pub(crate) fn arm_long_lived_starts(&mut self) {
61        // Boot's last pass over restored state before the loop starts ticking —
62        // and the only one that is NOT also a hot-reload path, so the timer
63        // repair (a run restored with a suspended step whose timer is gone)
64        // rides here rather than re-running on every reload.
65        self.repair_orphaned_timer_waits();
66        let specs: Vec<StartSpec> = self
67            .workflows
68            .values()
69            .filter(|w| w.armed)
70            .flat_map(|w| {
71                w.start_steps()
72                    .into_iter()
73                    .map(|s| (w.name.clone(), s.id.clone(), s.kind.clone(), s.spec.clone()))
74                    .collect::<Vec<_>>()
75            })
76            .collect();
77        for (workflow, node, kind, spec) in specs {
78            match kind.as_str() {
79                "schedule" => self.arm_schedule(&workflow, &node, &spec),
80                "loop" => {
81                    // A loop fires its first run immediately unless one is live.
82                    let live = self
83                        .runs
84                        .values()
85                        .any(|r| r.workflow == workflow && !r.status.is_terminal());
86                    let iteration = self.start_state(&workflow, &node)["iteration"]
87                        .as_u64()
88                        .unwrap_or(0);
89                    let max = spec.get("max_iterations").and_then(Value::as_u64);
90                    if !live && max.is_none_or(|m| iteration < m) {
91                        let delay = spec
92                            .get("delay")
93                            .and_then(Value::as_str)
94                            .and_then(|d| crate::config::parse_duration(d).ok());
95                        match delay {
96                            Some(d) if !d.is_zero() => self.set_start_state(&workflow, &node, json!({"iteration": iteration, "next_ms": now_ms() + d.as_millis() as u64})),
97                            _ => self.fire_start(&workflow, &node, &spec, json!({"iteration": iteration}), "loop"),
98                        }
99                    }
100                }
101                "subscribe" => self.arm_subscribe(&workflow, &node, &spec),
102                _ => {}
103            }
104        }
105    }
106
107    fn arm_schedule(&mut self, workflow: &str, node: &str, spec: &Map<String, Value>) {
108        let st = self.start_state(workflow, node);
109        let at_fired = st["at_fired"].as_bool().unwrap_or(false);
110        let next = self.next_schedule_ms(spec, now_ms(), at_fired);
111        if let Some(next) = next {
112            let mut st = st;
113            st["next_ms"] = json!(next);
114            self.set_start_state(workflow, node, st);
115            self.log.info(
116                "start.schedule.armed",
117                json!({"workflow": workflow, "node": node, "next_ms": next}),
118            );
119        } else if at_fired {
120            // The one-shot `at` was consumed in an earlier process lifetime:
121            // there is nothing to arm, and nothing wrong either.
122            self.log.info(
123                "start.schedule.done",
124                json!({"workflow": workflow, "node": node, "note": "one-shot `at` already fired"}),
125            );
126        } else {
127            self.log.warn(
128                "start.schedule.invalid",
129                json!({"workflow": workflow, "node": node, "note": "no cron/every"}),
130            );
131        }
132    }
133
134    /// The next fire time (ms) for a `schedule` start node. `at_fired` is the
135    /// durable "the one-shot `at` has already gone off" flag: once set, `at` is
136    /// out of the running and only a recurrence (`every`/`cron`) can arm again.
137    fn next_schedule_ms(
138        &self,
139        spec: &Map<String, Value>,
140        after_ms: u64,
141        at_fired: bool,
142    ) -> Option<u64> {
143        if let Some(every) = spec
144            .get("every")
145            .and_then(Value::as_str)
146            .and_then(|e| crate::config::parse_duration(e).ok())
147        {
148            return Some(after_ms + every.as_millis() as u64);
149        }
150        if let Some(at) = spec
151            .get("at")
152            .and_then(Value::as_str)
153            .filter(|_| !at_fired)
154            .and_then(|a| crate::config::parse_duration(a).ok())
155        {
156            // `at` (a one-shot delay) — fire once after the delay, then never
157            // again: it is consumed by its own firing (see `poll_starts`).
158            return Some(now_ms() + at.as_millis() as u64);
159        }
160        #[cfg(feature = "cron")]
161        if let Some(cron) = spec.get("cron").and_then(Value::as_str) {
162            return crate::triggers::timer::CronExpr::parse(cron)
163                .ok()
164                .and_then(|c| c.next_after(after_ms / 1000))
165                .map(|s| s * 1000);
166        }
167        None
168    }
169
170    fn arm_subscribe(&mut self, workflow: &str, node: &str, spec: &Map<String, Value>) {
171        let server = spec.get("server").and_then(Value::as_str).unwrap_or("");
172        let uri = spec.get("uri").and_then(Value::as_str).unwrap_or("");
173        match self.mcp.get(server) {
174            Some(c) => match c.subscribe(uri) {
175                Ok(()) => self.log.info(
176                    "start.subscribe.armed",
177                    json!({"workflow": workflow, "node": node, "server": server, "uri": uri}),
178                ),
179                Err(e) => self.log.warn(
180                    "start.subscribe.fail",
181                    json!({"workflow": workflow, "node": node, "err": e.to_string()}),
182                ),
183            },
184            None => self.log.warn(
185                "start.subscribe.no_server",
186                json!({"workflow": workflow, "node": node, "server": server}),
187            ),
188        }
189    }
190
191    /// Every tick: fire due `schedule`/`loop` starts and flush debounced
192    /// `subscribe` firings.
193    pub(crate) fn poll_starts(&mut self) {
194        let now = now_ms();
195        let due: Vec<StartSpec> = self
196            .workflows
197            .values()
198            .filter(|w| w.armed)
199            .flat_map(|w| {
200                w.start_steps()
201                    .into_iter()
202                    .filter(|s| matches!(s.kind.as_str(), "schedule" | "loop" | "subscribe"))
203                    .map(|s| (w.name.clone(), s.id.clone(), s.kind.clone(), s.spec.clone()))
204                    .collect::<Vec<_>>()
205            })
206            .collect();
207        for (workflow, node, kind, spec) in due {
208            let st = self.start_state(&workflow, &node);
209            match kind.as_str() {
210                "schedule" => {
211                    if let Some(next) = st["next_ms"].as_u64()
212                        && now >= next
213                    {
214                        // A one-shot `at:` is CONSUMED by this firing. The flag
215                        // is durable in the start state (not an in-memory one)
216                        // because a restart re-arms from that state: without it
217                        // every tick past the instant would re-arm `now + at`,
218                        // and a workflow the operator asked to run once at
219                        // 03:00 would run continuously from 03:00 on. A `cron`
220                        // alongside `at` still takes over from here — `at` is
221                        // then just the first occurrence.
222                        let at_fired =
223                            st["at_fired"].as_bool().unwrap_or(false) || spec.contains_key("at");
224                        self.fire_start(
225                            &workflow,
226                            &node,
227                            &spec,
228                            json!({"scheduled_for": next}),
229                            "schedule",
230                        );
231                        // Arm the following occurrence (catch_up: one — fire once, skip missed).
232                        let following = self.next_schedule_ms(&spec, now, at_fired);
233                        let mut st = self.start_state(&workflow, &node);
234                        match following {
235                            Some(n) => st["next_ms"] = json!(n),
236                            None => {
237                                st.as_object_mut().map(|o| o.remove("next_ms"));
238                            }
239                        }
240                        if at_fired {
241                            st["at_fired"] = json!(true);
242                        }
243                        st["last_fired"] = json!(now);
244                        self.set_start_state(&workflow, &node, st);
245                    }
246                }
247                "loop" => {
248                    let live = self
249                        .runs
250                        .values()
251                        .any(|r| r.workflow == workflow && !r.status.is_terminal());
252                    if !live
253                        && let Some(next) = st["next_ms"].as_u64()
254                        && now >= next
255                    {
256                        // Consume the armed deadline: only on_loop_run_finished
257                        // re-arms (after the `until`/`max` check).
258                        let mut st2 = self.start_state(&workflow, &node);
259                        st2.as_object_mut().map(|o| o.remove("next_ms"));
260                        self.set_start_state(&workflow, &node, st2);
261                        let iteration = st["iteration"].as_u64().unwrap_or(0);
262                        let max = spec.get("max_iterations").and_then(Value::as_u64);
263                        if max.is_none_or(|m| iteration < m) {
264                            self.fire_start(
265                                &workflow,
266                                &node,
267                                &spec,
268                                json!({"iteration": iteration}),
269                                "loop",
270                            );
271                        }
272                    }
273                }
274                "subscribe" => {
275                    // A debounced firing whose window elapsed.
276                    if let Some(fire_at) = st["debounce_until"].as_u64()
277                        && now >= fire_at
278                    {
279                        let mut payload = st["pending_payload"].clone();
280                        // The sample ring may have grown since the payload was
281                        // coalesced — deliver the ring as of NOW, not as of the
282                        // update that armed the debounce.
283                        if payload.get("window").is_some() {
284                            payload["window"] = st["window"].clone();
285                        }
286                        let mut st2 = self.start_state(&workflow, &node);
287                        st2.as_object_mut().map(|o| {
288                            o.remove("debounce_until");
289                            o.remove("pending_payload")
290                        });
291                        self.set_start_state(&workflow, &node, st2);
292                        self.fire_start(&workflow, &node, &spec, payload, "subscribe");
293                    }
294                }
295                _ => {}
296            }
297        }
298    }
299
300    /// A `loop`/`schedule`/`subscribe` start fires: `deliver: run` (default)
301    /// accepts a durable start event; `deliver: wait` would resolve a `wait`
302    /// step (P4b). Applies the per-start `inputs` mapping.
303    pub(crate) fn fire_start(
304        &mut self,
305        workflow: &str,
306        node: &str,
307        spec: &Map<String, Value>,
308        payload: Value,
309        kind: &str,
310    ) {
311        self.fire_start_run(workflow, node, spec, payload, kind, None);
312    }
313
314    /// Like [`Runtime::fire_start`], with an optional pre-generated `run_id` (so a
315    /// caller — e.g. a `respond: sync` webhook — can link the run before it starts).
316    pub(crate) fn fire_start_run(
317        &mut self,
318        workflow: &str,
319        node: &str,
320        spec: &Map<String, Value>,
321        payload: Value,
322        kind: &str,
323        run_id: Option<&str>,
324    ) {
325        // Admission gate: a fired start under pressure is SKIPPED — logged with
326        // its cause, so a schedule that quietly stopped firing while the disk
327        // filled is a story the log tells, not a mystery. In-flight runs keep
328        // draining; that is the point of shedding here rather than dying at the
329        // next checkpoint.
330        let low = self
331            .workflows
332            .get(workflow)
333            .is_some_and(|w| w.priority == crate::engine::model::Priority::Low);
334        if let Some(cause) = self.pressure.refusal(low) {
335            self.log.warn(
336                "start.shed",
337                json!({"workflow": workflow, "node": node, "kind": kind, "cause": cause}),
338            );
339            return;
340        }
341        let inputs = match spec.get("inputs") {
342            Some(mapping) => {
343                let mut data = crate::engine::template::Data::new();
344                data.insert("payload".into(), payload.clone());
345                data.insert(
346                    "env".into(),
347                    json!({"instance": self.instance, "ts": now_ms()}),
348                );
349                match crate::engine::template::render(mapping, &data) {
350                    Ok(v) => v,
351                    Err(e) => {
352                        // Fail closed, loudly: an inputs mapping that will not
353                        // render cancels the firing rather than starting the
354                        // run with silently-empty inputs, so a typo in the
355                        // mapping surfaces as one line here instead of as a
356                        // mystery three steps later.
357                        self.log.warn(
358                            "start.inputs.invalid",
359                            json!({"workflow": workflow, "node": node, "kind": kind, "err": e}),
360                        );
361                        return;
362                    }
363                }
364            }
365            None => json!({}),
366        };
367        self.log.info(
368            "start.fired",
369            json!({"workflow": workflow, "node": node, "kind": kind}),
370        );
371        let mut st = self.start_state(workflow, node);
372        st["last_fired"] = json!(now_ms());
373        if kind == "loop" {
374            st["iteration"] = json!(st["iteration"].as_u64().unwrap_or(0) + 1);
375        }
376        self.set_start_state(workflow, node, st);
377        let mut ev =
378            json!({"workflow": workflow, "node": node, "payload": payload, "inputs": inputs});
379        // The logical thing this run is ABOUT, rendered here because this is
380        // the one moment the trigger payload exists and the run does not yet.
381        // A key that will not render is left absent rather than guessed: a run
382        // silently sharing the empty key with every other run is the opposite
383        // of per-entity serialization.
384        if let Some(tpl) = self.workflows.get(workflow).and_then(|w| w.key.clone()) {
385            let mut data = crate::engine::template::Data::new();
386            data.insert("payload".into(), ev["payload"].clone());
387            data.insert("inputs".into(), ev["inputs"].clone());
388            match crate::engine::template::render_str(&tpl, &data) {
389                Ok(Value::String(k)) if !k.trim().is_empty() => ev["key"] = json!(k),
390                Ok(other) if !other.is_null() => ev["key"] = json!(other.to_string()),
391                Ok(_) => self.log.warn(
392                    "start.key.empty",
393                    json!({"workflow": workflow, "node": node, "template": tpl}),
394                ),
395                Err(e) => self.log.warn(
396                    "start.key.invalid",
397                    json!({"workflow": workflow, "node": node, "err": e}),
398                ),
399            }
400        }
401        // An A2A message carries its conversation and its tracking TASK; the
402        // run must link to both (the task completes with the run's outcome —
403        // that is what a peer's `a2a.delegate {command}` blocks on).
404        // Fields the TRIGGER payload carries that belong on the run itself.
405        // `msg_depth` is here because a run started by a delivered message
406        // continues that chain; a trigger that carries none starts at zero.
407        for k in ["conversation", "task", "msg_depth"] {
408            if let Some(v) = ev["payload"].get(k).filter(|v| !v.is_null()).cloned() {
409                ev[k] = v;
410            }
411        }
412        if let Some(rid) = run_id {
413            ev["run_id"] = json!(rid);
414        }
415        // A trigger firing is work done on SOMEBODY's behalf, even when nobody
416        // typed anything: a schedule, a webhook, a stream. Passing no principal
417        // dropped the attribution chain at its very first hop, which made
418        // "every effect names the human or the schedule that caused it" false
419        // by construction. `identity.autonomous_as` names the actor instead —
420        // one that shows up in the audit line, the MCP `_meta` and the budget
421        // scope like any other.
422        //
423        // An inbound A2A message is the exception: it already carries the
424        // principal who sent it, and that is who the work is for.
425        let acting = ev
426            .get("payload")
427            .and_then(|p| p.get("principal"))
428            .and_then(Value::as_str)
429            .map(str::to_string)
430            .unwrap_or_else(|| self.settings.identity.autonomous_id().to_string());
431        let _ = self.accept_event(kinds::START_FIRED, Some(acting), ev);
432    }
433
434    /// A `loop`'s run finished: re-arm the next iteration (interval / backoff /
435    /// `until`).
436    pub(crate) fn on_loop_run_finished(
437        &mut self,
438        workflow: &str,
439        node: &str,
440        spec: &Map<String, Value>,
441        ok: bool,
442        last_output: &Value,
443    ) {
444        // `until` (CEL over the last outcome) stops the loop.
445        if let Some(until) = spec.get("until").and_then(Value::as_str) {
446            let mut data = crate::engine::template::Data::new();
447            data.insert("outcome".into(), json!({"ok": ok, "output": last_output}));
448            data.insert("last".into(), last_output.clone());
449            let vars: Vec<(&str, &Value)> = data.iter().map(|(k, v)| (k.as_str(), v)).collect();
450            if crate::cel::eval_bool(until.trim().trim_start_matches("CEL:").trim(), &vars)
451                == Ok(true)
452            {
453                self.log.info(
454                    "start.loop.stopped",
455                    json!({"workflow": workflow, "node": node, "reason": "until"}),
456                );
457                let mut st = self.start_state(workflow, node);
458                st.as_object_mut().map(|o| o.remove("next_ms"));
459                self.set_start_state(workflow, node, st);
460                return;
461            }
462        }
463        let st = self.start_state(workflow, node);
464        let iteration = st["iteration"].as_u64().unwrap_or(0);
465        if let Some(max) = spec.get("max_iterations").and_then(Value::as_u64)
466            && iteration >= max
467        {
468            self.log.info(
469                "start.loop.stopped",
470                json!({"workflow": workflow, "node": node, "reason": "max_iterations"}),
471            );
472            let mut st = self.start_state(workflow, node);
473            st.as_object_mut().map(|o| o.remove("next_ms"));
474            self.set_start_state(workflow, node, st);
475            return;
476        }
477        // interval / backoff on failure.
478        let interval = spec
479            .get("interval")
480            .and_then(Value::as_str)
481            .and_then(|i| crate::config::parse_duration(i).ok())
482            .map(|d| d.as_millis() as u64)
483            .unwrap_or(0);
484        let delay = if !ok {
485            spec.get("backoff")
486                .and_then(|b| b.get("initial"))
487                .and_then(Value::as_str)
488                .and_then(|i| crate::config::parse_duration(i).ok())
489                .map(|d| d.as_millis() as u64)
490                .unwrap_or(interval)
491        } else {
492            interval
493        };
494        let mut st = self.start_state(workflow, node);
495        st["next_ms"] = json!(now_ms() + delay);
496        self.set_start_state(workflow, node, st);
497    }
498
499    /// A subscribed resource updated: debounce/coalesce/filter, then fire a run.
500    pub(crate) fn on_subscribe_resource(&mut self, server: &str, uri: &str) {
501        let matches: Vec<(String, String, Map<String, Value>)> = self
502            .workflows
503            .values()
504            .filter(|w| w.armed)
505            .flat_map(|w| {
506                w.start_steps()
507                    .into_iter()
508                    .filter(|s| {
509                        s.kind == "subscribe"
510                            && s.field_str("server") == Some(server)
511                            && s.field_str("uri") == Some(uri)
512                    })
513                    .map(|s| (w.name.clone(), s.id.clone(), s.spec.clone()))
514                    .collect::<Vec<_>>()
515            })
516            .collect();
517        if matches.is_empty() {
518            return;
519        }
520        // Notify-then-read, OFF the loop (same shape as `on_resource_updated`):
521        // the read is a network round trip bounded only by the MCP server's
522        // patience, and subscriptions are the reactivity hot path — an inline
523        // read here handed a slow server the whole daemon per update. The read
524        // thread reports back as an event; the filter/window/debounce state
525        // machine below runs on the loop when it lands.
526        let Some(client) = self.mcp.get(server).cloned() else {
527            return; // the server went away between the notification and here
528        };
529        let tx = self.events_tx.clone();
530        let (srv, u) = (server.to_string(), uri.to_string());
531        std::thread::Builder::new()
532            .name(format!("mcp.subscribe:{server}"))
533            .spawn(move || {
534                let content = client.read_resource(&u).ok().map(|r| {
535                    let t = r.text();
536                    serde_json::from_str::<Value>(&t).unwrap_or(Value::String(t))
537                });
538                let _ = tx.send(super::events::Event::SubscribeRead {
539                    server: srv,
540                    uri: u,
541                    content,
542                });
543            })
544            .ok();
545    }
546
547    /// The loop half of a `subscribe` update: the off-loop read landed; apply
548    /// filter → window ring → debounce/fire per matching start node.
549    pub(crate) fn on_subscribe_read(&mut self, server: &str, uri: &str, content: Option<Value>) {
550        let matches: Vec<(String, String, Map<String, Value>)> = self
551            .workflows
552            .values()
553            .filter(|w| w.armed)
554            .flat_map(|w| {
555                w.start_steps()
556                    .into_iter()
557                    .filter(|s| {
558                        s.kind == "subscribe"
559                            && s.field_str("server") == Some(server)
560                            && s.field_str("uri") == Some(uri)
561                    })
562                    .map(|s| (w.name.clone(), s.id.clone(), s.spec.clone()))
563                    .collect::<Vec<_>>()
564            })
565            .collect();
566        for (workflow, node, spec) in matches {
567            // filter (CEL over the read).
568            if let Some(filter) = spec.get("filter").and_then(Value::as_str) {
569                let mut data = crate::engine::template::Data::new();
570                data.insert("content".into(), content.clone().unwrap_or(Value::Null));
571                let vars: Vec<(&str, &Value)> = data.iter().map(|(k, v)| (k.as_str(), v)).collect();
572                if crate::cel::eval_bool(filter.trim().trim_start_matches("CEL:").trim(), &vars)
573                    != Ok(true)
574                {
575                    continue;
576                }
577            }
578            // `window: {samples: N}`: keep a ring of the last N read values in
579            // the durable start-state, so the fired run sees the trend, not
580            // just the reading that happened to fire it. The ring accrues on
581            // every (filter-passing) update — including updates a debounce
582            // window coalesces away, which is the point: coalescing drops
583            // FIRINGS, the window keeps the SAMPLES.
584            let window_n = spec
585                .get("window")
586                .and_then(|w| w.get("samples"))
587                .and_then(Value::as_u64)
588                .map(|n| n as usize);
589            if let Some(n) = window_n {
590                let mut st = self.start_state(&workflow, &node);
591                let mut ring: Vec<Value> = st["window"].as_array().cloned().unwrap_or_default();
592                ring.push(content.clone().unwrap_or(Value::Null));
593                if ring.len() > n {
594                    let drop = ring.len() - n;
595                    ring.drain(..drop);
596                }
597                st["window"] = Value::Array(ring);
598                self.set_start_state(&workflow, &node, st);
599            }
600            let mut payload = json!({"server": server, "uri": uri, "content": content});
601            if window_n.is_some() {
602                payload["window"] = self.start_state(&workflow, &node)["window"]
603                    .as_array()
604                    .cloned()
605                    .map(Value::Array)
606                    .unwrap_or_else(|| json!([]));
607            }
608            let debounce = spec.get("debounce_ms").and_then(Value::as_u64).unwrap_or(0);
609            if debounce > 0 {
610                // Coalesce: newest payload wins; fire when the window elapses.
611                let mut st = self.start_state(&workflow, &node);
612                st["debounce_until"] = json!(now_ms() + debounce);
613                st["pending_payload"] = payload;
614                self.set_start_state(&workflow, &node, st);
615            } else {
616                self.fire_start(&workflow, &node, &spec, payload, "subscribe");
617            }
618        }
619    }
620
621    /// Fire `signal` start nodes for a named signal. Returns how many fired.
622    pub(crate) fn fire_signal_starts(
623        &mut self,
624        name: &str,
625        payload: &Value,
626        _broadcast: bool,
627    ) -> u64 {
628        let matches: Vec<(String, String, Map<String, Value>)> = self
629            .workflows
630            .values()
631            .filter(|w| w.armed)
632            .flat_map(|w| {
633                w.start_steps()
634                    .into_iter()
635                    .filter(|s| s.kind == "signal" && s.field_str("name") == Some(name))
636                    .map(|s| (w.name.clone(), s.id.clone(), s.spec.clone()))
637                    .collect::<Vec<_>>()
638            })
639            .collect();
640        let mut fired = 0;
641        for (workflow, node, spec) in matches {
642            if let Some(filter) = spec.get("filter").and_then(Value::as_str) {
643                let mut data = crate::engine::template::Data::new();
644                data.insert("payload".into(), payload.clone());
645                let vars: Vec<(&str, &Value)> = data.iter().map(|(k, v)| (k.as_str(), v)).collect();
646                if crate::cel::eval_bool(filter.trim().trim_start_matches("CEL:").trim(), &vars)
647                    != Ok(true)
648                {
649                    continue;
650                }
651            }
652            self.fire_start(
653                &workflow,
654                &node,
655                &spec,
656                json!({"signal": name, "payload": payload}),
657                "signal",
658            );
659            fired += 1;
660        }
661        fired
662    }
663
664    /// Fire `event` start nodes for an internal lifecycle event.
665    /// Push a deferred tool wait — and let the runtime NOTICE a human gate
666    /// opening: `human.asked` fires as an internal event, so a workflow
667    /// (`{kind: event, on: human.asked}`) can escalate it out-of-band — mail
668    /// the approver, ring a phone — instead of hoping someone is watching a
669    /// terminal.
670    pub(crate) fn push_pending(&mut self, p: super::reactor::PendingTool) {
671        if let super::reactor::PendingKind::Human {
672            task,
673            question,
674            deadline_ms,
675            ..
676        } = &p.kind
677        {
678            let payload = serde_json::json!({
679                "task": task, "question": question, "deadline_ms": deadline_ms,
680            });
681            self.fire_event_starts("human.asked", &payload);
682        }
683        self.pending.push(p);
684    }
685
686    pub(crate) fn fire_event_starts(&mut self, event: &str, payload: &Value) {
687        let matches: Vec<(String, String, Map<String, Value>)> = self
688            .workflows
689            .values()
690            .filter(|w| w.armed)
691            .flat_map(|w| {
692                w.start_steps()
693                    .into_iter()
694                    .filter(|s| s.kind == "event" && s.field_str("on") == Some(event))
695                    .map(|s| (w.name.clone(), s.id.clone(), s.spec.clone()))
696                    .collect::<Vec<_>>()
697            })
698            .collect();
699        for (workflow, node, spec) in matches {
700            // Self-trigger suppression: a watcher on `workflow.finished` with
701            // no (or a too-loose) filter must not fire on ITS OWN completions
702            // — that is an infinite loop of runs, not a reaction. An event
703            // about workflow W never fires W's own event start.
704            if payload.get("workflow").and_then(Value::as_str) == Some(workflow.as_str()) {
705                continue;
706            }
707            if let Some(filter) = spec.get("filter").and_then(Value::as_str) {
708                let mut data = crate::engine::template::Data::new();
709                data.insert("payload".into(), payload.clone());
710                let vars: Vec<(&str, &Value)> = data.iter().map(|(k, v)| (k.as_str(), v)).collect();
711                if crate::cel::eval_bool(filter.trim().trim_start_matches("CEL:").trim(), &vars)
712                    != Ok(true)
713                {
714                    continue;
715                }
716            }
717            self.fire_start(
718                &workflow,
719                &node,
720                &spec,
721                json!({"event": event, "payload": payload}),
722                "event",
723            );
724        }
725    }
726
727    /// The start-node spec of a run's `run.start` node (for loop re-arming).
728    pub(crate) fn run_start_spec(
729        &self,
730        run_id: &str,
731    ) -> Option<(String, String, Map<String, Value>, String)> {
732        let run = self.runs.get(run_id)?;
733        let w = self.workflows.get(&run.workflow)?;
734        let s: &Step = w.step(&run.start.node)?;
735        Some((
736            run.workflow.clone(),
737            run.start.node.clone(),
738            s.spec.clone(),
739            s.kind.clone(),
740        ))
741    }
742}
743
744/// Whether a status is a success for `event on: workflow.finished|failed`.
745pub fn run_event(status: RunStatus) -> Option<&'static str> {
746    match status {
747        RunStatus::Completed => Some("workflow.finished"),
748        RunStatus::Failed | RunStatus::Stalled | RunStatus::Cancelled | RunStatus::Refused => {
749            Some("workflow.failed")
750        }
751        _ => None,
752    }
753}