Skip to main content

agentd/runtime/
starts.rs

1// SPDX-License-Identifier: Apache-2.0
2//! **Start nodes** as the triggers (RFC 0027 §4, plan §3.6.6): 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`,
7//! `claim`/`shard` for exactly-one-owner in a cluster), `signal` (a named
8//! signal), `event` (an internal lifecycle event), and `a2a` (a principal's
9//! message routed here — P5). Start-node state (last fired, iteration, missed,
10//! next deadline, debounce) is durable in the manifest.
11
12use super::events::kinds;
13use super::reactor::Runtime;
14use crate::engine::model::Step;
15use crate::engine::run::RunStatus;
16use crate::state::now_ms;
17use serde_json::{Map, Value, json};
18
19/// `(workflow, node, kind, spec)` — a start node identity + its config.
20type StartSpec = (String, String, String, Map<String, Value>);
21
22impl Runtime {
23    /// The manifest key for a start node's state.
24    fn start_key(workflow: &str, node: &str) -> String {
25        format!("{workflow}.{node}")
26    }
27
28    /// Read a start node's durable state.
29    fn start_state(&self, workflow: &str, node: &str) -> Value {
30        self.durable
31            .manifest()
32            .starts
33            .get(&Self::start_key(workflow, node))
34            .cloned()
35            .unwrap_or(json!({}))
36    }
37
38    /// Update a start node's durable state.
39    fn set_start_state(&mut self, workflow: &str, node: &str, state: Value) {
40        let key = Self::start_key(workflow, node);
41        self.durable.manifest_update(|m| {
42            m.starts.insert(key, state);
43        });
44    }
45
46    /// Arm the long-lived start nodes at boot/restore (called by `arm_workflows`
47    /// after the `once` handling). Schedules the first deadline for `loop`/
48    /// `schedule` and subscribes `subscribe` resources.
49    pub(crate) fn arm_long_lived_starts(&mut self) {
50        // Boot's last pass over restored state before the loop starts ticking —
51        // and the only one that is NOT also a hot-reload path, so the timer
52        // repair (a run restored with a suspended step whose timer is gone)
53        // rides here rather than re-running on every reload.
54        self.repair_orphaned_timer_waits();
55        let specs: Vec<StartSpec> = self
56            .workflows
57            .values()
58            .filter(|w| w.armed)
59            .flat_map(|w| {
60                w.start_steps()
61                    .into_iter()
62                    .map(|s| (w.name.clone(), s.id.clone(), s.kind.clone(), s.spec.clone()))
63                    .collect::<Vec<_>>()
64            })
65            .collect();
66        for (workflow, node, kind, spec) in specs {
67            match kind.as_str() {
68                "schedule" => self.arm_schedule(&workflow, &node, &spec),
69                "loop" => {
70                    // A loop fires its first run immediately unless one is live.
71                    let live = self
72                        .runs
73                        .values()
74                        .any(|r| r.workflow == workflow && !r.status.is_terminal());
75                    let iteration = self.start_state(&workflow, &node)["iteration"]
76                        .as_u64()
77                        .unwrap_or(0);
78                    let max = spec.get("max_iterations").and_then(Value::as_u64);
79                    if !live && max.is_none_or(|m| iteration < m) {
80                        let delay = spec
81                            .get("delay")
82                            .and_then(Value::as_str)
83                            .and_then(|d| crate::config::parse_duration(d).ok());
84                        match delay {
85                            Some(d) if !d.is_zero() => self.set_start_state(&workflow, &node, json!({"iteration": iteration, "next_ms": now_ms() + d.as_millis() as u64})),
86                            _ => self.fire_start(&workflow, &node, &spec, json!({"iteration": iteration}), "loop"),
87                        }
88                    }
89                }
90                "subscribe" => self.arm_subscribe(&workflow, &node, &spec),
91                _ => {}
92            }
93        }
94    }
95
96    fn arm_schedule(&mut self, workflow: &str, node: &str, spec: &Map<String, Value>) {
97        let st = self.start_state(workflow, node);
98        let at_fired = st["at_fired"].as_bool().unwrap_or(false);
99        let next = self.next_schedule_ms(spec, now_ms(), at_fired);
100        if let Some(next) = next {
101            let mut st = st;
102            st["next_ms"] = json!(next);
103            self.set_start_state(workflow, node, st);
104            self.log.info(
105                "start.schedule.armed",
106                json!({"workflow": workflow, "node": node, "next_ms": next}),
107            );
108        } else if at_fired {
109            // The one-shot `at` was consumed in an earlier life: nothing to arm,
110            // and nothing wrong either.
111            self.log.info(
112                "start.schedule.done",
113                json!({"workflow": workflow, "node": node, "note": "one-shot `at` already fired"}),
114            );
115        } else {
116            self.log.warn(
117                "start.schedule.invalid",
118                json!({"workflow": workflow, "node": node, "note": "no cron/every"}),
119            );
120        }
121    }
122
123    /// The next fire time (ms) for a `schedule` start node. `at_fired` is the
124    /// durable "the one-shot `at` has already gone off" flag: once set, `at` is
125    /// out of the running and only a recurrence (`every`/`cron`) can arm again.
126    fn next_schedule_ms(
127        &self,
128        spec: &Map<String, Value>,
129        after_ms: u64,
130        at_fired: bool,
131    ) -> Option<u64> {
132        if let Some(every) = spec
133            .get("every")
134            .and_then(Value::as_str)
135            .and_then(|e| crate::config::parse_duration(e).ok())
136        {
137            return Some(after_ms + every.as_millis() as u64);
138        }
139        if let Some(at) = spec
140            .get("at")
141            .and_then(Value::as_str)
142            .filter(|_| !at_fired)
143            .and_then(|a| crate::config::parse_duration(a).ok())
144        {
145            // `at` (a one-shot delay) — fire once after the delay, then never
146            // again: it is consumed by its own firing (see `poll_starts`).
147            return Some(now_ms() + at.as_millis() as u64);
148        }
149        #[cfg(feature = "cron")]
150        if let Some(cron) = spec.get("cron").and_then(Value::as_str) {
151            return crate::triggers::timer::CronExpr::parse(cron)
152                .ok()
153                .and_then(|c| c.next_after(after_ms / 1000))
154                .map(|s| s * 1000);
155        }
156        None
157    }
158
159    fn arm_subscribe(&mut self, workflow: &str, node: &str, spec: &Map<String, Value>) {
160        let server = spec.get("server").and_then(Value::as_str).unwrap_or("");
161        let uri = spec.get("uri").and_then(Value::as_str).unwrap_or("");
162        match self.mcp.get(server) {
163            Some(c) => match c.subscribe(uri) {
164                Ok(()) => self.log.info(
165                    "start.subscribe.armed",
166                    json!({"workflow": workflow, "node": node, "server": server, "uri": uri}),
167                ),
168                Err(e) => self.log.warn(
169                    "start.subscribe.fail",
170                    json!({"workflow": workflow, "node": node, "err": e.to_string()}),
171                ),
172            },
173            None => self.log.warn(
174                "start.subscribe.no_server",
175                json!({"workflow": workflow, "node": node, "server": server}),
176            ),
177        }
178    }
179
180    /// Every tick: fire due `schedule`/`loop` starts and flush debounced
181    /// `subscribe` firings.
182    pub(crate) fn poll_starts(&mut self) {
183        let now = now_ms();
184        let due: Vec<StartSpec> = self
185            .workflows
186            .values()
187            .filter(|w| w.armed)
188            .flat_map(|w| {
189                w.start_steps()
190                    .into_iter()
191                    .filter(|s| matches!(s.kind.as_str(), "schedule" | "loop" | "subscribe"))
192                    .map(|s| (w.name.clone(), s.id.clone(), s.kind.clone(), s.spec.clone()))
193                    .collect::<Vec<_>>()
194            })
195            .collect();
196        for (workflow, node, kind, spec) in due {
197            let st = self.start_state(&workflow, &node);
198            match kind.as_str() {
199                "schedule" => {
200                    if let Some(next) = st["next_ms"].as_u64()
201                        && now >= next
202                    {
203                        // A one-shot `at:` is CONSUMED by this firing. The flag
204                        // is durable in the start state (not an in-memory one)
205                        // because a restart re-arms from that state: without it
206                        // every tick past the instant re-armed `now + at`, so a
207                        // workflow the operator asked to run once at 03:00 ran
208                        // continuously from 03:00 on. A `cron` alongside `at`
209                        // still takes over from here — `at` is then just the
210                        // first occurrence.
211                        let at_fired =
212                            st["at_fired"].as_bool().unwrap_or(false) || spec.contains_key("at");
213                        self.fire_start(
214                            &workflow,
215                            &node,
216                            &spec,
217                            json!({"scheduled_for": next}),
218                            "schedule",
219                        );
220                        // Arm the following occurrence (catch_up: one — fire once, skip missed).
221                        let following = self.next_schedule_ms(&spec, now, at_fired);
222                        let mut st = self.start_state(&workflow, &node);
223                        match following {
224                            Some(n) => st["next_ms"] = json!(n),
225                            None => {
226                                st.as_object_mut().map(|o| o.remove("next_ms"));
227                            }
228                        }
229                        if at_fired {
230                            st["at_fired"] = json!(true);
231                        }
232                        st["last_fired"] = json!(now);
233                        self.set_start_state(&workflow, &node, st);
234                    }
235                }
236                "loop" => {
237                    let live = self
238                        .runs
239                        .values()
240                        .any(|r| r.workflow == workflow && !r.status.is_terminal());
241                    if !live
242                        && let Some(next) = st["next_ms"].as_u64()
243                        && now >= next
244                    {
245                        // Consume the armed deadline: only on_loop_run_finished
246                        // re-arms (after the `until`/`max` check).
247                        let mut st2 = self.start_state(&workflow, &node);
248                        st2.as_object_mut().map(|o| o.remove("next_ms"));
249                        self.set_start_state(&workflow, &node, st2);
250                        let iteration = st["iteration"].as_u64().unwrap_or(0);
251                        let max = spec.get("max_iterations").and_then(Value::as_u64);
252                        if max.is_none_or(|m| iteration < m) {
253                            self.fire_start(
254                                &workflow,
255                                &node,
256                                &spec,
257                                json!({"iteration": iteration}),
258                                "loop",
259                            );
260                        }
261                    }
262                }
263                "subscribe" => {
264                    // A debounced firing whose window elapsed.
265                    if let Some(fire_at) = st["debounce_until"].as_u64()
266                        && now >= fire_at
267                    {
268                        let payload = st["pending_payload"].clone();
269                        let mut st2 = self.start_state(&workflow, &node);
270                        st2.as_object_mut().map(|o| {
271                            o.remove("debounce_until");
272                            o.remove("pending_payload")
273                        });
274                        self.set_start_state(&workflow, &node, st2);
275                        self.fire_start(&workflow, &node, &spec, payload, "subscribe");
276                    }
277                }
278                _ => {}
279            }
280        }
281    }
282
283    /// A `loop`/`schedule`/`subscribe` start fires: `deliver: run` (default)
284    /// accepts a durable start event; `deliver: wait` would resolve a `wait`
285    /// step (P4b). Applies the per-start `inputs` mapping.
286    pub(crate) fn fire_start(
287        &mut self,
288        workflow: &str,
289        node: &str,
290        spec: &Map<String, Value>,
291        payload: Value,
292        kind: &str,
293    ) {
294        self.fire_start_run(workflow, node, spec, payload, kind, None);
295    }
296
297    /// Like [`Runtime::fire_start`], with an optional pre-generated `run_id` (so a
298    /// caller — e.g. a `respond: sync` webhook — can link the run before it starts).
299    pub(crate) fn fire_start_run(
300        &mut self,
301        workflow: &str,
302        node: &str,
303        spec: &Map<String, Value>,
304        payload: Value,
305        kind: &str,
306        run_id: Option<&str>,
307    ) {
308        let inputs = match spec.get("inputs") {
309            Some(mapping) => {
310                let mut data = crate::engine::template::Data::new();
311                data.insert("payload".into(), payload.clone());
312                data.insert(
313                    "env".into(),
314                    json!({"instance": self.instance, "ts": now_ms()}),
315                );
316                crate::engine::template::render(mapping, &data).unwrap_or(json!({}))
317            }
318            None => json!({}),
319        };
320        self.log.info(
321            "start.fired",
322            json!({"workflow": workflow, "node": node, "kind": kind}),
323        );
324        let mut st = self.start_state(workflow, node);
325        st["last_fired"] = json!(now_ms());
326        if kind == "loop" {
327            st["iteration"] = json!(st["iteration"].as_u64().unwrap_or(0) + 1);
328        }
329        self.set_start_state(workflow, node, st);
330        let mut ev =
331            json!({"workflow": workflow, "node": node, "payload": payload, "inputs": inputs});
332        if let Some(rid) = run_id {
333            ev["run_id"] = json!(rid);
334        }
335        let _ = self.accept_event(kinds::START_FIRED, None, ev);
336    }
337
338    /// A `loop`'s run finished: re-arm the next iteration (interval / backoff /
339    /// `until`).
340    pub(crate) fn on_loop_run_finished(
341        &mut self,
342        workflow: &str,
343        node: &str,
344        spec: &Map<String, Value>,
345        ok: bool,
346        last_output: &Value,
347    ) {
348        // `until` (CEL over the last outcome) stops the loop.
349        if let Some(until) = spec.get("until").and_then(Value::as_str) {
350            let mut data = crate::engine::template::Data::new();
351            data.insert("outcome".into(), json!({"ok": ok, "output": last_output}));
352            data.insert("last".into(), last_output.clone());
353            let vars: Vec<(&str, &Value)> = data.iter().map(|(k, v)| (k.as_str(), v)).collect();
354            if crate::cel::eval_bool(until.trim().trim_start_matches("CEL:").trim(), &vars)
355                == Ok(true)
356            {
357                self.log.info(
358                    "start.loop.stopped",
359                    json!({"workflow": workflow, "node": node, "reason": "until"}),
360                );
361                let mut st = self.start_state(workflow, node);
362                st.as_object_mut().map(|o| o.remove("next_ms"));
363                self.set_start_state(workflow, node, st);
364                return;
365            }
366        }
367        let st = self.start_state(workflow, node);
368        let iteration = st["iteration"].as_u64().unwrap_or(0);
369        if let Some(max) = spec.get("max_iterations").and_then(Value::as_u64)
370            && iteration >= max
371        {
372            self.log.info(
373                "start.loop.stopped",
374                json!({"workflow": workflow, "node": node, "reason": "max_iterations"}),
375            );
376            let mut st = self.start_state(workflow, node);
377            st.as_object_mut().map(|o| o.remove("next_ms"));
378            self.set_start_state(workflow, node, st);
379            return;
380        }
381        // interval / backoff on failure.
382        let interval = spec
383            .get("interval")
384            .and_then(Value::as_str)
385            .and_then(|i| crate::config::parse_duration(i).ok())
386            .map(|d| d.as_millis() as u64)
387            .unwrap_or(0);
388        let delay = if !ok {
389            spec.get("backoff")
390                .and_then(|b| b.get("initial"))
391                .and_then(Value::as_str)
392                .and_then(|i| crate::config::parse_duration(i).ok())
393                .map(|d| d.as_millis() as u64)
394                .unwrap_or(interval)
395        } else {
396            interval
397        };
398        let mut st = self.start_state(workflow, node);
399        st["next_ms"] = json!(now_ms() + delay);
400        self.set_start_state(workflow, node, st);
401    }
402
403    /// A subscribed resource updated: debounce/coalesce/filter, then fire a run.
404    pub(crate) fn on_subscribe_resource(&mut self, server: &str, uri: &str) {
405        let matches: Vec<(String, String, Map<String, Value>)> = self
406            .workflows
407            .values()
408            .filter(|w| w.armed)
409            .flat_map(|w| {
410                w.start_steps()
411                    .into_iter()
412                    .filter(|s| {
413                        s.kind == "subscribe"
414                            && s.field_str("server") == Some(server)
415                            && s.field_str("uri") == Some(uri)
416                    })
417                    .map(|s| (w.name.clone(), s.id.clone(), s.spec.clone()))
418                    .collect::<Vec<_>>()
419            })
420            .collect();
421        if matches.is_empty() {
422            return;
423        }
424        // Notify-then-read.
425        let content = self
426            .mcp
427            .get(server)
428            .and_then(|c| c.read_resource(uri).ok())
429            .map(|r| {
430                let t = r.text();
431                serde_json::from_str::<Value>(&t).unwrap_or(Value::String(t))
432            });
433        for (workflow, node, spec) in matches {
434            // filter (CEL over the read).
435            if let Some(filter) = spec.get("filter").and_then(Value::as_str) {
436                let mut data = crate::engine::template::Data::new();
437                data.insert("content".into(), content.clone().unwrap_or(Value::Null));
438                let vars: Vec<(&str, &Value)> = data.iter().map(|(k, v)| (k.as_str(), v)).collect();
439                if crate::cel::eval_bool(filter.trim().trim_start_matches("CEL:").trim(), &vars)
440                    != Ok(true)
441                {
442                    continue;
443                }
444            }
445            let payload = json!({"server": server, "uri": uri, "content": content});
446            let debounce = spec.get("debounce_ms").and_then(Value::as_u64).unwrap_or(0);
447            if debounce > 0 {
448                // Coalesce: newest payload wins; fire when the window elapses.
449                let mut st = self.start_state(&workflow, &node);
450                st["debounce_until"] = json!(now_ms() + debounce);
451                st["pending_payload"] = payload;
452                self.set_start_state(&workflow, &node, st);
453            } else {
454                self.fire_start(&workflow, &node, &spec, payload, "subscribe");
455            }
456        }
457    }
458
459    /// Fire `signal` start nodes for a named signal. Returns how many fired.
460    pub(crate) fn fire_signal_starts(
461        &mut self,
462        name: &str,
463        payload: &Value,
464        _broadcast: bool,
465    ) -> u64 {
466        let matches: Vec<(String, String, Map<String, Value>)> = self
467            .workflows
468            .values()
469            .filter(|w| w.armed)
470            .flat_map(|w| {
471                w.start_steps()
472                    .into_iter()
473                    .filter(|s| s.kind == "signal" && s.field_str("name") == Some(name))
474                    .map(|s| (w.name.clone(), s.id.clone(), s.spec.clone()))
475                    .collect::<Vec<_>>()
476            })
477            .collect();
478        let mut fired = 0;
479        for (workflow, node, spec) in matches {
480            if let Some(filter) = spec.get("filter").and_then(Value::as_str) {
481                let mut data = crate::engine::template::Data::new();
482                data.insert("payload".into(), payload.clone());
483                let vars: Vec<(&str, &Value)> = data.iter().map(|(k, v)| (k.as_str(), v)).collect();
484                if crate::cel::eval_bool(filter.trim().trim_start_matches("CEL:").trim(), &vars)
485                    != Ok(true)
486                {
487                    continue;
488                }
489            }
490            self.fire_start(
491                &workflow,
492                &node,
493                &spec,
494                json!({"signal": name, "payload": payload}),
495                "signal",
496            );
497            fired += 1;
498        }
499        fired
500    }
501
502    /// Fire `event` start nodes for an internal lifecycle event.
503    pub(crate) fn fire_event_starts(&mut self, event: &str, payload: &Value) {
504        let matches: Vec<(String, String, Map<String, Value>)> = self
505            .workflows
506            .values()
507            .filter(|w| w.armed)
508            .flat_map(|w| {
509                w.start_steps()
510                    .into_iter()
511                    .filter(|s| s.kind == "event" && s.field_str("on") == Some(event))
512                    .map(|s| (w.name.clone(), s.id.clone(), s.spec.clone()))
513                    .collect::<Vec<_>>()
514            })
515            .collect();
516        for (workflow, node, spec) in matches {
517            if let Some(filter) = spec.get("filter").and_then(Value::as_str) {
518                let mut data = crate::engine::template::Data::new();
519                data.insert("payload".into(), payload.clone());
520                let vars: Vec<(&str, &Value)> = data.iter().map(|(k, v)| (k.as_str(), v)).collect();
521                if crate::cel::eval_bool(filter.trim().trim_start_matches("CEL:").trim(), &vars)
522                    != Ok(true)
523                {
524                    continue;
525                }
526            }
527            self.fire_start(
528                &workflow,
529                &node,
530                &spec,
531                json!({"event": event, "payload": payload}),
532                "event",
533            );
534        }
535    }
536
537    /// The start-node spec of a run's `run.start` node (for loop re-arming).
538    pub(crate) fn run_start_spec(
539        &self,
540        run_id: &str,
541    ) -> Option<(String, String, Map<String, Value>, String)> {
542        let run = self.runs.get(run_id)?;
543        let w = self.workflows.get(&run.workflow)?;
544        let s: &Step = w.step(&run.start.node)?;
545        Some((
546            run.workflow.clone(),
547            run.start.node.clone(),
548            s.spec.clone(),
549            s.kind.clone(),
550        ))
551    }
552}
553
554/// Whether a status is a success for `event on: workflow.finished|failed`.
555pub fn run_event(status: RunStatus) -> Option<&'static str> {
556    match status {
557        RunStatus::Completed => Some("workflow.finished"),
558        RunStatus::Failed | RunStatus::Stalled | RunStatus::Cancelled | RunStatus::Refused => {
559            Some("workflow.failed")
560        }
561        _ => None,
562    }
563}