Skip to main content

orbit_runtime/
engine.rs

1//! The execution engine: owns persistence, in-memory projections of active
2//! executions, the worker registry and task dispatch.
3//!
4//! Every state change goes through [`Engine::commit`], which assigns sequence
5//! numbers, persists the events transactionally and only then updates memory.
6
7use crate::decide::{decide, invocation};
8use crate::metrics::Metrics;
9use crate::model::*;
10use crate::store::{ExecutionSummary, PutDefinition, Store, WorkerRecord};
11use orbit_ir::{WorkflowIr, WORKFLOW_CONTRACT_PREFIX};
12use serde::{Deserialize, Serialize};
13use serde_json::{json, Value};
14use std::collections::{BTreeMap, HashMap, HashSet};
15use std::sync::Arc;
16
17pub type Clock = Box<dyn Fn() -> u64 + Send>;
18
19pub fn system_clock() -> Clock {
20    Box::new(|| {
21        std::time::SystemTime::now()
22            .duration_since(std::time::UNIX_EPOCH)
23            .map(|d| d.as_millis() as u64)
24            .unwrap_or(0)
25    })
26}
27
28#[derive(Debug, Clone)]
29pub struct EngineConfig {
30    /// A worker that has not polled or heartbeated for this long is considered lost.
31    pub session_timeout_ms: u64,
32    pub heartbeat_interval_ms: u64,
33    /// Execute `orbit.http.request` steps inside the runtime.
34    pub http_adapter: bool,
35    /// Resource limits (all optional; `None` means unlimited).
36    pub limits: Limits,
37}
38
39/// Server-wide resource limits.
40#[derive(Debug, Clone, Default, PartialEq)]
41pub struct Limits {
42    /// Running executions at once; further starts get 429 `RESOURCE_EXHAUSTED`.
43    pub max_active_executions: Option<usize>,
44    /// Elements one `for` step may fan out to; larger lists fail the step with
45    /// `LIMIT_EXCEEDED`.
46    pub max_fan_out: Option<usize>,
47    /// History events per execution; past it the execution is failed with
48    /// `LIMIT_EXCEEDED` (compensation and handlers still run).
49    pub max_history_events: Option<u64>,
50    /// Serialized size of an execution input, a task result or a signal
51    /// payload; larger ones get 413 `PAYLOAD_TOO_LARGE`.
52    pub max_payload_bytes: Option<usize>,
53}
54
55impl Default for EngineConfig {
56    fn default() -> Self {
57        EngineConfig {
58            session_timeout_ms: 30_000,
59            heartbeat_interval_ms: 5_000,
60            http_adapter: false,
61            limits: Limits::default(),
62        }
63    }
64}
65
66#[derive(Debug, Clone, PartialEq, Serialize)]
67pub struct EngineError {
68    pub code: &'static str,
69    pub message: String,
70    #[serde(skip)]
71    pub status: u16,
72}
73
74impl EngineError {
75    pub fn new(status: u16, code: &'static str, message: impl Into<String>) -> Self {
76        EngineError {
77            code,
78            message: message.into(),
79            status,
80        }
81    }
82    fn not_found(what: &str) -> Self {
83        Self::new(404, "NOT_FOUND", format!("{what} not found"))
84    }
85    fn storage(e: crate::store::StoreError) -> Self {
86        Self::new(500, "STORAGE_ERROR", e.to_string())
87    }
88}
89
90impl std::fmt::Display for EngineError {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        write!(f, "{}: {}", self.code, self.message)
93    }
94}
95
96pub type Result<T> = std::result::Result<T, EngineError>;
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct ContractAnnouncement {
100    pub task_name: String,
101    pub contract_version: String,
102}
103
104#[derive(Debug, Clone, Deserialize)]
105pub struct RegisterWorker {
106    pub worker_id: String,
107    pub runtime: String,
108    #[serde(default)]
109    pub sdk: Option<String>,
110    pub contracts: Vec<ContractAnnouncement>,
111}
112
113#[derive(Debug, Clone, Serialize)]
114pub struct Registration {
115    pub accepted: bool,
116    pub worker_session_id: String,
117    pub heartbeat_interval_ms: u64,
118    pub session_timeout_ms: u64,
119    pub warnings: Vec<String>,
120}
121
122#[derive(Debug, Clone)]
123struct Session {
124    worker_id: String,
125    runtime: String,
126    contracts: HashSet<(String, String)>,
127    last_seen_ms: u64,
128    inflight: HashSet<String>,
129}
130
131/// A task handed to a worker (RFC-0001 §8.3 `ExecuteTaskRequest`).
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
133pub struct TaskAssignment {
134    pub token: String,
135    pub execution_id: String,
136    pub workflow: String,
137    pub workflow_version: String,
138    pub step_id: String,
139    pub task_name: String,
140    pub contract_version: String,
141    pub input: Value,
142    pub idempotency_key: String,
143    pub attempt: u32,
144    pub redelivery: bool,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub deadline_ms: Option<u64>,
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub timeout_ms: Option<u64>,
149}
150
151#[derive(Debug, Clone, Serialize)]
152pub struct ResultAck {
153    pub accepted: bool,
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub reason: Option<String>,
156}
157
158#[derive(Debug, Clone, Serialize)]
159pub struct HeartbeatAck {
160    pub accepted: bool,
161    /// Tokens the worker should stop working on (cooperative cancellation).
162    pub cancel: Vec<String>,
163}
164
165#[derive(Debug, Clone, Serialize)]
166pub struct WorkerInfo {
167    #[serde(flatten)]
168    pub record: WorkerRecord,
169    pub online: bool,
170    pub sessions: usize,
171    pub inflight: usize,
172}
173
174#[derive(Debug, Clone, Serialize)]
175pub struct StartResult {
176    pub execution_id: String,
177    pub created: bool,
178}
179
180const BUILTIN_SESSION_PREFIX: &str = "builtin:";
181const CHILD_SESSION_PREFIX: &str = "child:";
182
183/// Deterministic id of the child execution started by `unit` of `parent`, so
184/// that starting it again after a crash is a no-op.
185pub fn child_execution_id(parent: &str, unit: &str) -> String {
186    let unit = unit
187        .replace("compensate:", "compensate.")
188        .replace('[', ".")
189        .replace(']', "");
190    format!("{parent}.{unit}")
191}
192
193pub struct Engine {
194    store: Store,
195    clock: Clock,
196    pub config: EngineConfig,
197    instance: String,
198    started_at_ms: u64,
199    by_version: BTreeMap<(String, String), Arc<WorkflowIr>>,
200    by_hash: HashMap<String, Arc<WorkflowIr>>,
201    active: BTreeMap<String, ExecutionState>,
202    tokens: HashMap<String, (String, String)>,
203    /// Which worker each task token was issued to (for per-worker authorization).
204    token_workers: HashMap<String, String>,
205    sessions: HashMap<String, Session>,
206    /// Built-in tasks dispatched to the runtime itself, waiting to be run.
207    builtin_jobs: Vec<TaskAssignment>,
208    pub metrics: Metrics,
209    pub changed: Arc<tokio::sync::Notify>,
210    telemetry: Option<crate::telemetry::Exporter>,
211}
212
213fn semver_key(v: &str) -> (Vec<u64>, String) {
214    let core = v.split(['-', '+']).next().unwrap_or("");
215    (
216        core.split('.').map(|p| p.parse().unwrap_or(0)).collect(),
217        v.to_string(),
218    )
219}
220
221impl Engine {
222    /// Open the engine and recover every non-terminal execution from history.
223    pub fn open(store: Store, config: EngineConfig, clock: Clock) -> Result<Engine> {
224        let now = clock();
225        let mut engine = Engine {
226            store,
227            clock,
228            config,
229            instance: uuid::Uuid::new_v4().to_string(),
230            started_at_ms: now,
231            by_version: BTreeMap::new(),
232            by_hash: HashMap::new(),
233            active: BTreeMap::new(),
234            tokens: HashMap::new(),
235            token_workers: HashMap::new(),
236            sessions: HashMap::new(),
237            builtin_jobs: vec![],
238            metrics: Metrics::default(),
239            changed: Arc::new(tokio::sync::Notify::new()),
240            telemetry: None,
241        };
242        for ir in engine.store.definitions().map_err(EngineError::storage)? {
243            engine.cache_definition(ir);
244        }
245        engine.recover()?;
246        Ok(engine)
247    }
248
249    pub fn now(&self) -> u64 {
250        (self.clock)()
251    }
252
253    fn cache_definition(&mut self, ir: WorkflowIr) -> Arc<WorkflowIr> {
254        let ir = Arc::new(ir);
255        self.by_version
256            .insert((ir.name.clone(), ir.version.clone()), ir.clone());
257        self.by_hash.insert(ir.definition_hash.clone(), ir.clone());
258        ir
259    }
260
261    /// RFC-0001 §9.9 crash recovery: load active executions, rebuild state
262    /// from history, re-index in-flight work and resume. Attempts that were
263    /// in flight are reconciled by `check_liveness` once their worker fails to
264    /// report back within the session timeout (outcome treated as unknown,
265    /// never as failed).
266    fn recover(&mut self) -> Result<()> {
267        let ids = self
268            .store
269            .active_execution_ids()
270            .map_err(EngineError::storage)?;
271        let mut in_flight = 0;
272        for id in &ids {
273            let history = self.store.history(id).map_err(EngineError::storage)?;
274            for h in &history {
275                if let Event::StepDispatched {
276                    token, worker_id, ..
277                } = &h.event
278                {
279                    self.token_workers.insert(token.clone(), worker_id.clone());
280                }
281            }
282            let state = ExecutionState::replay(id, &history);
283            for (key, u) in &state.units {
284                for (t, _) in &u.tokens {
285                    self.tokens.insert(t.clone(), (id.clone(), key.clone()));
286                }
287                if u.status() == UnitStatus::Dispatched {
288                    in_flight += 1;
289                }
290            }
291            self.active.insert(id.clone(), state);
292        }
293        if !ids.is_empty() {
294            tracing::info!(
295                executions = ids.len(),
296                in_flight,
297                "recovered active executions from history"
298            );
299        }
300        for id in ids {
301            self.advance(&id)?;
302        }
303        Ok(())
304    }
305
306    // ------------------------------------------------------------ definitions
307
308    pub fn register_definition(&mut self, ir: WorkflowIr) -> Result<bool> {
309        ir.validate()
310            .map_err(|e| EngineError::new(400, "INVALID_IR", e.0))?;
311        if ir.definition_hash.is_empty() {
312            return Err(EngineError::new(
313                400,
314                "INVALID_IR",
315                "definition_hash is required",
316            ));
317        }
318        // A parent pins the exact child definition it was compiled against.
319        for c in ir.contracts.values() {
320            if let Some(hash) = &c.workflow_hash {
321                if !self.by_hash.contains_key(hash) {
322                    return Err(EngineError::new(
323                        400,
324                        "CHILD_NOT_REGISTERED",
325                        format!(
326                            "{}@{} starts child workflow {}@{} ({hash}), which is not registered; register the child first (`orbit deploy` does this automatically)",
327                            ir.name,
328                            ir.version,
329                            c.name.trim_start_matches(WORKFLOW_CONTRACT_PREFIX),
330                            c.version
331                        ),
332                    ));
333                }
334            }
335        }
336        let now = self.now();
337        match self.store.put_definition(&ir, now).map_err(EngineError::storage)? {
338            PutDefinition::Created => {
339                tracing::info!(workflow = %ir.name, version = %ir.version, hash = %ir.definition_hash, "registered workflow definition");
340                self.cache_definition(ir);
341                Ok(true)
342            }
343            PutDefinition::Unchanged => Ok(false),
344            PutDefinition::Conflict { existing_hash } => Err(EngineError::new(
345                409,
346                "DEFINITION_CONFLICT",
347                format!(
348                    "{}@{} is already registered with a different definition ({existing_hash}); workflow definitions are immutable, bump the version",
349                    ir.name, ir.version
350                ),
351            )),
352        }
353    }
354
355    pub fn definitions(&self) -> Vec<Arc<WorkflowIr>> {
356        self.by_version.values().cloned().collect()
357    }
358
359    pub fn definition(&self, name: &str, version: Option<&str>) -> Option<Arc<WorkflowIr>> {
360        match version {
361            Some(v) => self
362                .by_version
363                .get(&(name.to_string(), v.to_string()))
364                .cloned(),
365            None => self
366                .by_version
367                .iter()
368                .filter(|((n, _), _)| n == name)
369                .max_by_key(|((_, v), _)| semver_key(v))
370                .map(|(_, d)| d.clone()),
371        }
372    }
373
374    // ------------------------------------------------------------- executions
375
376    pub fn start(
377        &mut self,
378        workflow: &str,
379        version: Option<&str>,
380        input: Value,
381        execution_id: Option<String>,
382    ) -> Result<StartResult> {
383        let def = self.definition(workflow, version).ok_or_else(|| {
384            EngineError::new(
385                404,
386                "WORKFLOW_NOT_FOUND",
387                match version {
388                    Some(v) => format!("workflow {workflow}@{v} is not registered"),
389                    None => format!("workflow {workflow} is not registered"),
390                },
391            )
392        })?;
393        self.start_with(def, input, execution_id, None)
394    }
395
396    /// Start an execution of a specific definition. Child workflows pass their
397    /// parent link; their input was already validated against the child's
398    /// contract by the parent, so it is checked leniently.
399    fn start_with(
400        &mut self,
401        def: Arc<WorkflowIr>,
402        mut input: Value,
403        execution_id: Option<String>,
404        parent: Option<ParentRef>,
405    ) -> Result<StartResult> {
406        if input.is_null() {
407            input = json!({});
408        }
409        def.input.apply_defaults(&mut input);
410        self.check_payload("execution input", &input)?;
411        def.input.validate(&input, parent.is_none()).map_err(|e| {
412            EngineError::new(
413                400,
414                "INVALID_INPUT",
415                format!("workflow input is invalid at {e}"),
416            )
417        })?;
418        let id = match execution_id {
419            Some(id) => {
420                if id.is_empty()
421                    || id.len() > 200
422                    || !id
423                        .chars()
424                        .all(|c| c.is_ascii_alphanumeric() || "-_.:".contains(c))
425                {
426                    return Err(EngineError::new(
427                        400,
428                        "INVALID_EXECUTION_ID",
429                        "execution ids may contain letters, digits and - _ . :",
430                    ));
431                }
432                // Idempotent start: the same id never starts a second execution.
433                if self.active.contains_key(&id)
434                    || self
435                        .store
436                        .execution_exists(&id)
437                        .map_err(EngineError::storage)?
438                {
439                    let existing = self.execution(&id)?;
440                    if existing.definition_hash != def.definition_hash {
441                        return Err(EngineError::new(
442                            409,
443                            "EXECUTION_EXISTS",
444                            format!("execution `{id}` already exists for a different workflow"),
445                        ));
446                    }
447                    return Ok(StartResult {
448                        execution_id: id,
449                        created: false,
450                    });
451                }
452                id
453            }
454            None => uuid::Uuid::new_v4().to_string(),
455        };
456        if parent.is_none() {
457            if let Some(max) = self.config.limits.max_active_executions {
458                if self.active.len() >= max {
459                    return Err(EngineError::new(
460                        429,
461                        "RESOURCE_EXHAUSTED",
462                        format!("{max} executions are already running (--max-active-executions)"),
463                    ));
464                }
465            }
466        }
467        self.active.insert(id.clone(), ExecutionState::new(&id));
468        let created = Event::WorkflowCreated {
469            workflow: def.name.clone(),
470            version: def.version.clone(),
471            definition_hash: def.definition_hash.clone(),
472            input,
473            parent,
474        };
475        if let Err(e) = self.commit(&id, vec![created, Event::WorkflowStarted {}]) {
476            self.active.remove(&id);
477            return Err(e);
478        }
479        tracing::info!(execution_id = %id, workflow = %def.name, version = %def.version, "execution started");
480        self.advance(&id)?;
481        Ok(StartResult {
482            execution_id: id,
483            created: true,
484        })
485    }
486
487    pub fn cancel(&mut self, id: &str, reason: &str) -> Result<ExecutionState> {
488        let state = self.execution(id)?;
489        if state.status.is_terminal() {
490            return Err(EngineError::new(
491                409,
492                "EXECUTION_FINISHED",
493                format!("execution is already {}", state.status.as_str()),
494            ));
495        }
496        if state.abort.is_none() {
497            self.commit(
498                id,
499                vec![Event::CancelRequested {
500                    reason: reason.to_string(),
501                }],
502            )?;
503            self.advance(id)?;
504        }
505        self.execution(id)
506    }
507
508    /// Send a typed signal to a running execution. The payload is validated
509    /// against the workflow's `signal` declaration (defaults applied) and
510    /// buffered until an `await` step takes it. Returns the history sequence
511    /// number of the recorded signal.
512    pub fn signal(&mut self, id: &str, name: &str, mut payload: Value) -> Result<u64> {
513        let state = self.execution(id)?;
514        if state.status.is_terminal() {
515            return Err(EngineError::new(
516                409,
517                "EXECUTION_FINISHED",
518                format!("execution is already {}", state.status.as_str()),
519            ));
520        }
521        let def = self
522            .by_hash
523            .get(&state.definition_hash)
524            .cloned()
525            .ok_or_else(|| EngineError::not_found("workflow definition"))?;
526        let Some(ty) = def.signals.get(name) else {
527            let declared: Vec<&str> = def.signals.keys().map(String::as_str).collect();
528            return Err(EngineError::new(
529                404,
530                "UNKNOWN_SIGNAL",
531                if declared.is_empty() {
532                    format!("{}@{} declares no signals", def.name, def.version)
533                } else {
534                    format!(
535                        "{}@{} has no signal `{name}`; declared: {}",
536                        def.name,
537                        def.version,
538                        declared.join(", ")
539                    )
540                },
541            ));
542        };
543        self.check_payload("signal payload", &payload)?;
544        ty.apply_defaults(&mut payload);
545        if let Err(e) = ty.validate(&payload, false) {
546            return Err(EngineError::new(
547                422,
548                "SIGNAL_PAYLOAD_INVALID",
549                format!("payload for signal `{name}` is invalid at {e}"),
550            ));
551        }
552        let seq = state.last_seq + 1;
553        self.commit(
554            id,
555            vec![Event::SignalReceived {
556                name: name.to_string(),
557                payload,
558            }],
559        )?;
560        tracing::info!(execution_id = %id, signal = %name, "signal received");
561        self.advance(id)?;
562        Ok(seq)
563    }
564
565    /// Evaluate a declared query against the current state of an execution.
566    /// Steps that have not completed read as `null`.
567    pub fn query(&self, id: &str, name: &str) -> Result<Value> {
568        let state = self.execution(id)?;
569        let def = self
570            .by_hash
571            .get(&state.definition_hash)
572            .cloned()
573            .ok_or_else(|| EngineError::not_found("workflow definition"))?;
574        let Some(q) = def.queries.get(name) else {
575            let declared: Vec<&str> = def.queries.keys().map(String::as_str).collect();
576            return Err(EngineError::new(
577                404,
578                "UNKNOWN_QUERY",
579                if declared.is_empty() {
580                    format!("{}@{} declares no queries", def.name, def.version)
581                } else {
582                    format!(
583                        "{}@{} has no query `{name}`; declared: {}",
584                        def.name,
585                        def.version,
586                        declared.join(", ")
587                    )
588                },
589            ));
590        };
591        struct QueryScope<'a>(crate::decide::StateScope<'a>, Value);
592        impl orbit_ir::Scope for QueryScope<'_> {
593            fn input(&self) -> &Value {
594                self.0.input()
595            }
596            fn step_output(&self, step: &str) -> Option<&Value> {
597                self.0.step_output(step).or(Some(&self.1))
598            }
599            fn execution_field(&self, field: &str) -> Option<Value> {
600                self.0.execution_field(field)
601            }
602        }
603        let scope = QueryScope(crate::decide::StateScope::new(&state), Value::Null);
604        orbit_ir::eval(&q.expr, &scope).map_err(|e| {
605            EngineError::new(
606                422,
607                "QUERY_FAILED",
608                format!("evaluating query `{name}`: {e}"),
609            )
610        })
611    }
612
613    /// Reject a payload larger than `--max-payload-bytes`.
614    fn check_payload(&self, what: &str, value: &Value) -> Result<()> {
615        if let Some(max) = self.config.limits.max_payload_bytes {
616            let size = serde_json::to_vec(value).map(|v| v.len()).unwrap_or(0);
617            if size > max {
618                return Err(EngineError::new(
619                    413,
620                    "PAYLOAD_TOO_LARGE",
621                    format!("{what} is {size} bytes; the limit is {max} (--max-payload-bytes)"),
622                ));
623            }
624        }
625        Ok(())
626    }
627
628    pub fn execution(&self, id: &str) -> Result<ExecutionState> {
629        if let Some(s) = self.active.get(id) {
630            return Ok(s.clone());
631        }
632        let history = self.store.history(id).map_err(EngineError::storage)?;
633        if history.is_empty() {
634            return Err(EngineError::not_found("execution"));
635        }
636        Ok(ExecutionState::replay(id, &history))
637    }
638
639    pub fn history(&self, id: &str) -> Result<Vec<HistoryEvent>> {
640        let h = self.store.history(id).map_err(EngineError::storage)?;
641        if h.is_empty() {
642            return Err(EngineError::not_found("execution"));
643        }
644        Ok(h)
645    }
646
647    pub fn list_executions(
648        &self,
649        status: Option<&str>,
650        workflow: Option<&str>,
651        limit: u32,
652    ) -> Result<Vec<ExecutionSummary>> {
653        self.store
654            .list_executions(status, workflow, limit.clamp(1, 1000))
655            .map_err(EngineError::storage)
656    }
657
658    pub fn active_count(&self) -> usize {
659        self.active.len()
660    }
661
662    // ----------------------------------------------------------------- commit
663
664    /// Persist events for one execution, then update the in-memory state.
665    fn commit(&mut self, id: &str, events: Vec<Event>) -> Result<()> {
666        if events.is_empty() {
667            return Ok(());
668        }
669        let now = self.now();
670        let mut state = self
671            .active
672            .get(id)
673            .cloned()
674            .ok_or_else(|| EngineError::not_found("active execution"))?;
675        let mut history = Vec::with_capacity(events.len());
676        for event in events {
677            let h = HistoryEvent {
678                seq: state.last_seq + 1,
679                timestamp_ms: now,
680                event,
681            };
682            apply(&mut state, &h);
683            history.push(h);
684        }
685        self.store
686            .append(&state, &history)
687            .map_err(EngineError::storage)?;
688        // Export spans only once the events are durable.
689        if let Some(t) = &self.telemetry {
690            let ctx = self.trace_context(&state);
691            t.export(crate::telemetry::spans_for(&state, &ctx, &history));
692        }
693        for h in &history {
694            self.metrics.observe(&h.event);
695            if let Event::StepDispatched {
696                token,
697                step,
698                worker_id,
699                ..
700            } = &h.event
701            {
702                self.tokens
703                    .insert(token.clone(), (id.to_string(), step.clone()));
704                self.token_workers.insert(token.clone(), worker_id.clone());
705            }
706            tracing::debug!(execution_id = %id, seq = h.seq, event = h.event.name(), step = h.event.step().unwrap_or(""), "event");
707            match &h.event {
708                Event::WorkflowCompleted { .. } => {
709                    tracing::info!(execution_id = %id, "execution completed")
710                }
711                Event::WorkflowFailed {
712                    error,
713                    compensation,
714                } => {
715                    tracing::warn!(execution_id = %id, code = %error.code, error = %error.message, compensation = ?compensation, "execution failed")
716                }
717                Event::WorkflowCancelled { .. } => {
718                    tracing::info!(execution_id = %id, "execution cancelled")
719                }
720                Event::WorkflowTimedOut { .. } => {
721                    tracing::warn!(execution_id = %id, "execution timed out")
722                }
723                Event::StepFailed { step, error } => {
724                    tracing::warn!(execution_id = %id, step = %step, code = %error.code, error = %error.message, "step failed")
725                }
726                Event::RetryScheduled {
727                    step,
728                    next_attempt,
729                    delay_ms,
730                    ..
731                } => {
732                    tracing::info!(execution_id = %id, step = %step, attempt = next_attempt, delay_ms, "retry scheduled")
733                }
734                _ => {}
735            }
736        }
737        if state.status.is_terminal() {
738            for u in state.units.values() {
739                for (t, _) in &u.tokens {
740                    self.tokens.remove(t);
741                    self.token_workers.remove(t);
742                }
743            }
744            self.active.remove(id);
745        } else {
746            self.active.insert(id.to_string(), state);
747        }
748        self.changed.notify_waiters();
749        Ok(())
750    }
751
752    /// Run the decider to a fixpoint and commit the resulting events as one batch.
753    fn advance(&mut self, id: &str) -> Result<()> {
754        let Some(state) = self.active.get(id) else {
755            return Ok(());
756        };
757        let Some(def) = self.by_hash.get(&state.definition_hash).cloned() else {
758            tracing::error!(execution_id = %id, hash = %state.definition_hash, "definition missing for execution");
759            return Ok(());
760        };
761        let now = self.now();
762        let mut scratch = state.clone();
763        let mut batch = vec![];
764        let limits = self.config.limits.clone();
765        for _ in 0..10_000 {
766            let mut events = decide(&scratch, &def, now);
767            if events.is_empty() {
768                break;
769            }
770            enforce_limits(&limits, &scratch, &mut events);
771            for e in events {
772                let seq = scratch.last_seq + 1;
773                apply(
774                    &mut scratch,
775                    &HistoryEvent {
776                        seq,
777                        timestamp_ms: now,
778                        event: e.clone(),
779                    },
780                );
781                batch.push(e);
782            }
783        }
784        self.commit(id, batch)
785    }
786
787    /// Periodic work: timers, retries, deadlines, worker liveness, built-ins.
788    pub fn tick(&mut self) -> Result<Vec<TaskAssignment>> {
789        self.check_liveness()?;
790        let ids: Vec<String> = self.active.keys().cloned().collect();
791        for id in &ids {
792            self.advance(id)?;
793        }
794        self.dispatch_builtins()?;
795        self.dispatch_children()?;
796        self.reconcile_children()?;
797        Ok(std::mem::take(&mut self.builtin_jobs))
798    }
799
800    /// Dispatched attempts whose worker session is gone become uncertain and
801    /// are redelivered with the same idempotency key (at-least-once delivery).
802    fn check_liveness(&mut self) -> Result<()> {
803        let now = self.now();
804        let timeout = self.config.session_timeout_ms;
805        self.sessions
806            .retain(|_, s| now < s.last_seen_ms + timeout * 4);
807        let mut lost: Vec<(String, String, u32, String)> = vec![];
808        for (id, state) in &self.active {
809            for (key, u) in &state.units {
810                if u.status() != UnitStatus::Dispatched {
811                    continue;
812                }
813                let session = u.worker_session.clone().unwrap_or_default();
814                let alive = if session.starts_with(CHILD_SESSION_PREFIX) {
815                    // Child workflows are durable executions, not processes.
816                    true
817                } else if let Some(inst) = session.strip_prefix(BUILTIN_SESSION_PREFIX) {
818                    inst == self.instance
819                } else {
820                    self.sessions
821                        .get(&session)
822                        .is_some_and(|s| now < s.last_seen_ms + timeout)
823                };
824                let since = u.dispatched_at_ms.unwrap_or(0).max(self.started_at_ms);
825                if !alive && now >= since + timeout {
826                    let reason = if session.starts_with(BUILTIN_SESSION_PREFIX) {
827                        "runtime restarted while the built-in task was running".to_string()
828                    } else {
829                        format!(
830                            "worker `{}` stopped responding",
831                            u.worker_id.clone().unwrap_or_default()
832                        )
833                    };
834                    lost.push((id.clone(), key.clone(), u.attempt, reason));
835                }
836            }
837        }
838        for (id, key, attempt, reason) in lost {
839            tracing::warn!(execution_id = %id, step = %key, attempt, reason = %reason, "attempt outcome uncertain; redelivering");
840            self.commit(
841                &id,
842                vec![Event::StepAttemptUncertain {
843                    step: key,
844                    attempt,
845                    reason,
846                }],
847            )?;
848            self.advance(&id)?;
849        }
850        Ok(())
851    }
852
853    // ---------------------------------------------------------------- workers
854
855    pub fn register_worker(&mut self, req: RegisterWorker) -> Result<Registration> {
856        if req.worker_id.trim().is_empty() {
857            return Err(EngineError::new(
858                400,
859                "INVALID_REQUEST",
860                "worker_id is required",
861            ));
862        }
863        if req.contracts.is_empty() {
864            return Err(EngineError::new(
865                400,
866                "INVALID_REQUEST",
867                "a worker must announce at least one task contract",
868            ));
869        }
870        let now = self.now();
871        let mut warnings = vec![];
872        for c in &req.contracts {
873            if c.task_name.starts_with(WORKFLOW_CONTRACT_PREFIX) {
874                return Err(EngineError::new(
875                    400,
876                    "RESERVED_TASK",
877                    format!(
878                        "`{}` names a child workflow, not a worker task",
879                        c.task_name
880                    ),
881                ));
882            }
883            if c.task_name.starts_with("orbit.") {
884                return Err(EngineError::new(
885                    400,
886                    "RESERVED_TASK",
887                    format!("`{}` is a built-in task", c.task_name),
888                ));
889            }
890            let mut versions: Vec<&str> = self
891                .by_version
892                .values()
893                .filter_map(|d| d.contracts.get(&c.task_name).map(|k| k.version.as_str()))
894                .collect();
895            versions.sort();
896            versions.dedup();
897            if !versions.is_empty() && !versions.contains(&c.contract_version.as_str()) {
898                warnings.push(format!(
899                    "`{}` is announced at version {} but registered workflows use {}; this worker will not receive those tasks",
900                    c.task_name,
901                    c.contract_version,
902                    versions.join(", ")
903                ));
904            }
905        }
906        let session_id = uuid::Uuid::new_v4().to_string();
907        self.sessions.insert(
908            session_id.clone(),
909            Session {
910                worker_id: req.worker_id.clone(),
911                runtime: req.runtime.to_lowercase(),
912                contracts: req
913                    .contracts
914                    .iter()
915                    .map(|c| (c.task_name.clone(), c.contract_version.clone()))
916                    .collect(),
917                last_seen_ms: now,
918                inflight: HashSet::new(),
919            },
920        );
921        self.store
922            .upsert_worker(&WorkerRecord {
923                worker_id: req.worker_id.clone(),
924                runtime: req.runtime.to_lowercase(),
925                sdk: req.sdk.clone(),
926                contracts: serde_json::to_value(&req.contracts).unwrap_or_default(),
927                last_seen_ms: now,
928                registered_at_ms: now,
929            })
930            .map_err(EngineError::storage)?;
931        tracing::info!(worker_id = %req.worker_id, runtime = %req.runtime, tasks = req.contracts.len(), "worker registered");
932        Ok(Registration {
933            accepted: true,
934            worker_session_id: session_id,
935            heartbeat_interval_ms: self.config.heartbeat_interval_ms,
936            session_timeout_ms: self.config.session_timeout_ms,
937            warnings,
938        })
939    }
940
941    fn touch(&mut self, session_id: &str) -> Result<()> {
942        let now = self.now();
943        let s = self.sessions.get_mut(session_id).ok_or_else(|| {
944            EngineError::new(
945                404,
946                "UNKNOWN_SESSION",
947                "unknown or expired worker session; register again",
948            )
949        })?;
950        s.last_seen_ms = now;
951        let wid = s.worker_id.clone();
952        let _ = self.store.touch_worker(&wid, now);
953        Ok(())
954    }
955
956    pub fn heartbeat(&mut self, session_id: &str) -> Result<HeartbeatAck> {
957        self.touch(session_id)?;
958        let inflight: Vec<String> = self.sessions[session_id].inflight.iter().cloned().collect();
959        let mut cancel = vec![];
960        for token in inflight {
961            let live = self.tokens.get(&token).and_then(|(id, key)| {
962                let st = self.active.get(id)?;
963                let u = st.units.get(key)?;
964                Some(
965                    u.status() == UnitStatus::Dispatched
966                        && u.token.as_deref() == Some(token.as_str())
967                        && (st.abort.is_none() || crate::model::is_cleanup_key(key)),
968                )
969            });
970            if live != Some(true) {
971                cancel.push(token.clone());
972                self.sessions
973                    .get_mut(session_id)
974                    .unwrap()
975                    .inflight
976                    .remove(&token);
977            }
978        }
979        Ok(HeartbeatAck {
980            accepted: true,
981            cancel,
982        })
983    }
984
985    pub fn unregister_worker(&mut self, session_id: &str) -> Result<()> {
986        let s = self
987            .sessions
988            .remove(session_id)
989            .ok_or_else(|| EngineError::new(404, "UNKNOWN_SESSION", "unknown worker session"))?;
990        tracing::info!(worker_id = %s.worker_id, "worker unregistered");
991        // In-flight work of a departing worker is redelivered immediately.
992        let mut lost = vec![];
993        for token in s.inflight {
994            if let Some((id, key)) = self.tokens.get(&token) {
995                if let Some(u) = self.active.get(id).and_then(|st| st.units.get(key)) {
996                    if u.status() == UnitStatus::Dispatched
997                        && u.token.as_deref() == Some(token.as_str())
998                    {
999                        lost.push((id.clone(), key.clone(), u.attempt));
1000                    }
1001                }
1002            }
1003        }
1004        for (id, key, attempt) in lost {
1005            self.commit(
1006                &id,
1007                vec![Event::StepAttemptUncertain {
1008                    step: key,
1009                    attempt,
1010                    reason: "worker unregistered".into(),
1011                }],
1012            )?;
1013            self.advance(&id)?;
1014        }
1015        Ok(())
1016    }
1017
1018    pub fn workers(&self) -> Result<Vec<WorkerInfo>> {
1019        let now = self.now();
1020        let records = self.store.workers().map_err(EngineError::storage)?;
1021        Ok(records
1022            .into_iter()
1023            .map(|record| {
1024                let live: Vec<&Session> = self
1025                    .sessions
1026                    .values()
1027                    .filter(|s| {
1028                        s.worker_id == record.worker_id
1029                            && now < s.last_seen_ms + self.config.session_timeout_ms
1030                    })
1031                    .collect();
1032                WorkerInfo {
1033                    online: !live.is_empty(),
1034                    sessions: live.len(),
1035                    inflight: live.iter().map(|s| s.inflight.len()).sum(),
1036                    record,
1037                }
1038            })
1039            .collect())
1040    }
1041
1042    /// Hand the oldest matching queued task to a worker, if any.
1043    pub fn poll(&mut self, session_id: &str) -> Result<Option<TaskAssignment>> {
1044        self.touch(session_id)?;
1045        let session = self.sessions[session_id].clone();
1046        let mut best: Option<(u64, String, String)> = None;
1047        for (id, state) in &self.active {
1048            let Some(def) = self.by_hash.get(&state.definition_hash) else {
1049                continue;
1050            };
1051            for (key, u) in &state.units {
1052                if u.status() != UnitStatus::Scheduled {
1053                    continue;
1054                }
1055                let Some(inv) = invocation(def, key) else {
1056                    continue;
1057                };
1058                if !session
1059                    .contracts
1060                    .contains(&(inv.task.clone(), inv.contract_version.clone()))
1061                {
1062                    continue;
1063                }
1064                if let Some(lang) = &inv.language {
1065                    if !lang.eq_ignore_ascii_case(&session.runtime) && lang != "any" {
1066                        continue;
1067                    }
1068                }
1069                let at = u.scheduled_at_ms.unwrap_or(0);
1070                if best.as_ref().is_none_or(|(b, _, _)| at < *b) {
1071                    best = Some((at, id.clone(), key.clone()));
1072                }
1073            }
1074        }
1075        let Some((_, id, key)) = best else {
1076            return Ok(None);
1077        };
1078        let assignment = self.dispatch(&id, &key, session_id, &session.worker_id)?;
1079        if let Some(s) = self.sessions.get_mut(session_id) {
1080            s.inflight.insert(assignment.token.clone());
1081        }
1082        Ok(Some(assignment))
1083    }
1084
1085    fn dispatch(
1086        &mut self,
1087        id: &str,
1088        key: &str,
1089        session_id: &str,
1090        worker_id: &str,
1091    ) -> Result<TaskAssignment> {
1092        let now = self.now();
1093        let state = &self.active[id];
1094        let def = self.by_hash[&state.definition_hash].clone();
1095        let inv = invocation(&def, key)
1096            .expect("unit has an invocation")
1097            .clone();
1098        let u = state.units[key].clone();
1099        let token = uuid::Uuid::new_v4().to_string();
1100        let deadline = inv.timeout_ms.map(|t| now + t);
1101        let assignment = TaskAssignment {
1102            token: token.clone(),
1103            execution_id: id.to_string(),
1104            workflow: def.name.clone(),
1105            workflow_version: def.version.clone(),
1106            step_id: key.to_string(),
1107            task_name: inv.task.clone(),
1108            contract_version: inv.contract_version.clone(),
1109            input: u.input.clone().unwrap_or(Value::Null),
1110            idempotency_key: u.idempotency_key.clone().unwrap_or_default(),
1111            attempt: u.attempt,
1112            redelivery: u.redelivery,
1113            deadline_ms: deadline,
1114            timeout_ms: inv.timeout_ms,
1115        };
1116        self.commit(
1117            id,
1118            vec![Event::StepDispatched {
1119                step: key.to_string(),
1120                attempt: u.attempt,
1121                token,
1122                worker_id: worker_id.to_string(),
1123                worker_session: session_id.to_string(),
1124                deadline_ms: deadline,
1125            }],
1126        )?;
1127        Ok(assignment)
1128    }
1129
1130    /// Start child executions for scheduled `workflow` steps.
1131    fn dispatch_children(&mut self) -> Result<()> {
1132        let mut todo = vec![];
1133        for (id, state) in &self.active {
1134            let Some(def) = self.by_hash.get(&state.definition_hash) else {
1135                continue;
1136            };
1137            for (key, u) in &state.units {
1138                if u.status() != UnitStatus::Scheduled {
1139                    continue;
1140                }
1141                if let Some(inv) = invocation(def, key) {
1142                    if let Some(hash) = def
1143                        .contracts
1144                        .get(&inv.task)
1145                        .and_then(|c| c.workflow_hash.clone())
1146                    {
1147                        todo.push((
1148                            id.clone(),
1149                            key.clone(),
1150                            u.attempt,
1151                            hash,
1152                            u.input.clone().unwrap_or(Value::Null),
1153                        ));
1154                    }
1155                }
1156            }
1157        }
1158        for (id, key, attempt, hash, input) in todo {
1159            let child_id = child_execution_id(&id, &key);
1160            let failure = |code: &'static str, message: String| Event::StepAttemptFailed {
1161                step: key.clone(),
1162                attempt,
1163                error: TaskError::new(code, message, false),
1164            };
1165            let Some(child_def) = self.by_hash.get(&hash).cloned() else {
1166                self.commit(
1167                    &id,
1168                    vec![failure(
1169                        "CHILD_NOT_REGISTERED",
1170                        format!("child definition {hash} is not registered"),
1171                    )],
1172                )?;
1173                self.advance(&id)?;
1174                continue;
1175            };
1176            let parent = ParentRef {
1177                execution_id: id.clone(),
1178                step: key.clone(),
1179            };
1180            if let Err(e) = self.start_with(child_def, input, Some(child_id.clone()), Some(parent))
1181            {
1182                self.commit(&id, vec![failure("CHILD_START_FAILED", e.message)])?;
1183                self.advance(&id)?;
1184                continue;
1185            }
1186            if self
1187                .active
1188                .get(&id)
1189                .and_then(|s| s.units.get(&key))
1190                .map(|u| u.status())
1191                == Some(UnitStatus::Scheduled)
1192            {
1193                self.dispatch(
1194                    &id,
1195                    &key,
1196                    &format!("{CHILD_SESSION_PREFIX}{child_id}"),
1197                    "orbit-runtime",
1198                )?;
1199            }
1200        }
1201        Ok(())
1202    }
1203
1204    /// Deliver finished children's results to their parents, and cancel
1205    /// children whose parent is aborting or no longer waits for them. Runs
1206    /// every tick, so it also repairs anything a crash interrupted.
1207    fn reconcile_children(&mut self) -> Result<()> {
1208        let mut finished = vec![];
1209        let mut cancel = vec![];
1210        for state in self.active.values() {
1211            for u in state.units.values() {
1212                let Some(child) = u
1213                    .worker_session
1214                    .as_deref()
1215                    .and_then(|s| s.strip_prefix(CHILD_SESSION_PREFIX))
1216                else {
1217                    continue;
1218                };
1219                match self.active.get(child) {
1220                    Some(c) => {
1221                        let parent_gone = u.status() != UnitStatus::Dispatched;
1222                        if c.abort.is_none() && (state.abort.is_some() || parent_gone) {
1223                            cancel.push(child.to_string());
1224                        }
1225                    }
1226                    None if u.status() == UnitStatus::Dispatched => {
1227                        finished.push((child.to_string(), u.token.clone().unwrap_or_default()));
1228                    }
1229                    None => {}
1230                }
1231            }
1232        }
1233        for child in cancel {
1234            tracing::info!(execution_id = %child, "cancelling child workflow because its parent no longer needs it");
1235            self.cancel(&child, "parent workflow aborted")?;
1236        }
1237        for (child, token) in finished {
1238            let state = match self.execution(&child) {
1239                Ok(s) => s,
1240                Err(_) => {
1241                    self.fail(
1242                        &token,
1243                        TaskError::new(
1244                            "CHILD_MISSING",
1245                            format!("child execution `{child}` not found"),
1246                            false,
1247                        ),
1248                    )?;
1249                    continue;
1250                }
1251            };
1252            let name = state.workflow.clone();
1253            match state.status {
1254                WorkflowStatus::Completed => {
1255                    self.complete(&token, state.output.unwrap_or(Value::Null))?;
1256                }
1257                WorkflowStatus::Failed => {
1258                    let e = state
1259                        .error
1260                        .unwrap_or_else(|| TaskError::new("UNKNOWN", "failed", false));
1261                    let mut err = TaskError::new(
1262                        &e.code,
1263                        format!("child workflow `{name}` failed: {}", e.message),
1264                        false,
1265                    );
1266                    err.details = Some(json!({ "child_execution_id": child }));
1267                    self.fail(&token, err)?;
1268                }
1269                WorkflowStatus::Cancelled => {
1270                    self.fail(
1271                        &token,
1272                        TaskError::new(
1273                            "CHILD_CANCELLED",
1274                            format!("child workflow `{name}` was cancelled"),
1275                            false,
1276                        ),
1277                    )?;
1278                }
1279                WorkflowStatus::TimedOut => {
1280                    self.fail(
1281                        &token,
1282                        TaskError::new(
1283                            "CHILD_TIMED_OUT",
1284                            format!("child workflow `{name}` timed out"),
1285                            false,
1286                        ),
1287                    )?;
1288                }
1289                _ => {}
1290            }
1291        }
1292        Ok(())
1293    }
1294
1295    fn dispatch_builtins(&mut self) -> Result<()> {
1296        let mut todo = vec![];
1297        for (id, state) in &self.active {
1298            let Some(def) = self.by_hash.get(&state.definition_hash) else {
1299                continue;
1300            };
1301            for (key, u) in &state.units {
1302                if u.status() != UnitStatus::Scheduled {
1303                    continue;
1304                }
1305                if let Some(inv) = invocation(def, key) {
1306                    if def.contracts.get(&inv.task).is_some_and(|c| c.builtin) {
1307                        todo.push((id.clone(), key.clone(), u.attempt));
1308                    }
1309                }
1310            }
1311        }
1312        for (id, key, attempt) in todo {
1313            if !self.config.http_adapter {
1314                self.commit(
1315                    &id,
1316                    vec![Event::StepAttemptFailed {
1317                        step: key,
1318                        attempt,
1319                        error: TaskError::new(codes::ADAPTER_DISABLED, "the HTTP adapter is disabled; start the server with --enable-http-adapter", false),
1320                    }],
1321                )?;
1322                self.advance(&id)?;
1323                continue;
1324            }
1325            let session = format!("{BUILTIN_SESSION_PREFIX}{}", self.instance);
1326            let job = self.dispatch(&id, &key, &session, "orbit-runtime")?;
1327            self.builtin_jobs.push(job);
1328        }
1329        Ok(())
1330    }
1331
1332    /// Record a successful task result.
1333    pub fn complete(&mut self, token: &str, mut output: Value) -> Result<ResultAck> {
1334        let (id, key, unit, def) = match self.resolve_token(token)? {
1335            Ok(x) => x,
1336            Err(ack) => return Ok(ack),
1337        };
1338        let attempt = unit
1339            .tokens
1340            .iter()
1341            .find(|(t, _)| t == token)
1342            .map(|(_, a)| *a)
1343            .unwrap_or(unit.attempt);
1344        if output.is_null() {
1345            output = json!({});
1346        }
1347        self.check_payload("task result", &output)?;
1348        let inv = invocation(&def, &key).cloned();
1349        let validation = inv
1350            .as_ref()
1351            .and_then(|i| def.contracts.get(&i.task))
1352            .map(|c| {
1353                c.output.apply_defaults(&mut output);
1354                c.output.validate(&output, false)
1355            })
1356            .unwrap_or(Ok(()));
1357        let event = match validation {
1358            Ok(()) => Event::StepSucceeded {
1359                step: key.clone(),
1360                attempt,
1361                output,
1362            },
1363            Err(e) => Event::StepAttemptFailed {
1364                step: key.clone(),
1365                attempt: unit.attempt,
1366                error: TaskError::new(
1367                    codes::OUTPUT_CONTRACT_VIOLATION,
1368                    format!(
1369                        "worker output for `{}` does not match its contract at {e}",
1370                        inv.map(|i| i.task).unwrap_or_default()
1371                    ),
1372                    false,
1373                ),
1374            },
1375        };
1376        let accepted = matches!(event, Event::StepSucceeded { .. });
1377        self.commit(&id, vec![event])?;
1378        self.advance(&id)?;
1379        Ok(ResultAck {
1380            accepted,
1381            reason: (!accepted).then(|| "output violates the task contract".into()),
1382        })
1383    }
1384
1385    /// Record a failed attempt.
1386    pub fn fail(&mut self, token: &str, error: TaskError) -> Result<ResultAck> {
1387        let (id, key, unit, _) = match self.resolve_token(token)? {
1388            Ok(x) => x,
1389            Err(ack) => return Ok(ack),
1390        };
1391        // Failures only count for the attempt currently in flight.
1392        if unit.token.as_deref() != Some(token) {
1393            self.commit(
1394                &id,
1395                vec![Event::StaleResultIgnored {
1396                    step: key,
1397                    token: token.into(),
1398                    outcome: "failure".into(),
1399                }],
1400            )?;
1401            return Ok(ResultAck {
1402                accepted: false,
1403                reason: Some("stale attempt".into()),
1404            });
1405        }
1406        self.commit(
1407            &id,
1408            vec![Event::StepAttemptFailed {
1409                step: key,
1410                attempt: unit.attempt,
1411                error,
1412            }],
1413        )?;
1414        self.advance(&id)?;
1415        Ok(ResultAck {
1416            accepted: true,
1417            reason: None,
1418        })
1419    }
1420
1421    #[allow(clippy::type_complexity)]
1422    fn resolve_token(
1423        &mut self,
1424        token: &str,
1425    ) -> Result<std::result::Result<(String, String, UnitState, Arc<WorkflowIr>), ResultAck>> {
1426        let Some((id, key)) = self.tokens.get(token).cloned() else {
1427            return Ok(Err(ResultAck {
1428                accepted: false,
1429                reason: Some("unknown token (execution finished or token invalid)".into()),
1430            }));
1431        };
1432        let state = &self.active[&id];
1433        let unit = state.units.get(&key).cloned().unwrap_or_default();
1434        let def = self.by_hash[&state.definition_hash].clone();
1435        if let Some(session) = &unit.worker_session {
1436            if let Some(s) = self.sessions.get_mut(session) {
1437                s.inflight.remove(token);
1438                s.last_seen_ms = (self.clock)();
1439            }
1440        }
1441        // A result for any attempt of an unresolved unit is accepted: the work
1442        // really happened (reconciliation of uncertain/timed-out attempts).
1443        let unresolved = matches!(
1444            unit.status(),
1445            UnitStatus::Dispatched
1446                | UnitStatus::Scheduled
1447                | UnitStatus::RetryWait
1448                | UnitStatus::Uncertain
1449                | UnitStatus::AttemptFailed
1450        );
1451        if !unresolved {
1452            self.commit(
1453                &id,
1454                vec![Event::StaleResultIgnored {
1455                    step: key,
1456                    token: token.into(),
1457                    outcome: "duplicate".into(),
1458                }],
1459            )?;
1460            return Ok(Err(ResultAck {
1461                accepted: false,
1462                reason: Some("duplicate result; step already resolved".into()),
1463            }));
1464        }
1465        Ok(Ok((id, key, unit, def)))
1466    }
1467
1468    pub fn store_stats(&self) -> Result<Vec<(String, u64)>> {
1469        self.store.count_by_status().map_err(EngineError::storage)
1470    }
1471
1472    /// Export OpenTelemetry traces of executions (see [`crate::telemetry`]).
1473    pub fn enable_telemetry(&mut self, config: crate::telemetry::TelemetryConfig) {
1474        tracing::info!(endpoint = %config.endpoint, service = %config.service_name, "exporting OpenTelemetry traces");
1475        self.telemetry = Some(crate::telemetry::Exporter::start(config));
1476    }
1477
1478    /// Trace of an execution: its own, or (for a child workflow) the trace of
1479    /// its top-level ancestor, nested under the parent step's span.
1480    fn trace_context(&self, state: &ExecutionState) -> crate::telemetry::TraceContext {
1481        use crate::telemetry::{trace_id, unit_span_id, TraceContext};
1482        let Some(parent) = &state.parent else {
1483            return TraceContext {
1484                trace_id: trace_id(&state.id),
1485                parent_span_id: None,
1486            };
1487        };
1488        let mut root = parent.execution_id.clone();
1489        for _ in 0..64 {
1490            let next = match self.active.get(&root) {
1491                Some(s) => s.parent.clone(),
1492                None => self.execution(&root).ok().and_then(|s| s.parent),
1493            };
1494            match next {
1495                Some(p) => root = p.execution_id,
1496                None => break,
1497            }
1498        }
1499        TraceContext {
1500            trace_id: trace_id(&root),
1501            // Child workflow steps are dispatched exactly once.
1502            parent_span_id: Some(unit_span_id(&parent.execution_id, &parent.step, 1)),
1503        }
1504    }
1505
1506    /// The worker id that owns a session, if the session exists.
1507    pub fn session_worker(&self, session_id: &str) -> Option<&str> {
1508        self.sessions.get(session_id).map(|s| s.worker_id.as_str())
1509    }
1510
1511    /// The worker id a task token was issued to, if the token is known.
1512    pub fn token_worker(&self, token: &str) -> Option<&str> {
1513        self.token_workers.get(token).map(String::as_str)
1514    }
1515
1516    pub fn session_count(&self) -> usize {
1517        self.sessions.len()
1518    }
1519
1520    /// Load definitions registered in the store since this engine opened
1521    /// (another server registered them). Used by cluster standbys, whose
1522    /// engine only reads. Returns how many were new.
1523    pub fn refresh_definitions(&mut self) -> Result<usize> {
1524        let mut added = 0;
1525        for ir in self.store.definitions().map_err(EngineError::storage)? {
1526            if !self.by_hash.contains_key(&ir.definition_hash) {
1527                self.cache_definition(ir);
1528                added += 1;
1529            }
1530        }
1531        Ok(added)
1532    }
1533}
1534
1535/// Apply fan-out and history limits to the decider's next events (the decider
1536/// itself stays a pure function of the history and the definition).
1537fn enforce_limits(limits: &Limits, state: &ExecutionState, events: &mut Vec<Event>) {
1538    if let Some(max) = limits.max_fan_out {
1539        for e in events.iter_mut() {
1540            if let Event::StepFanOut { step, items } = e {
1541                if items.len() > max {
1542                    *e = Event::StepFailed {
1543                        step: step.clone(),
1544                        error: TaskError::new(
1545                            "LIMIT_EXCEEDED",
1546                            format!(
1547                                "`for` over {} elements exceeds the fan-out limit of {max} (--max-fan-out)",
1548                                items.len()
1549                            ),
1550                            false,
1551                        ),
1552                    };
1553                }
1554            }
1555        }
1556    }
1557    if let Some(max) = limits.max_history_events {
1558        let would_be = state.last_seq + events.len() as u64;
1559        if state.abort.is_none() && would_be > max && !state.status.is_terminal() {
1560            // Stop scheduling new work; compensation and handlers still run.
1561            events.clear();
1562            events.push(Event::LimitExceeded {
1563                limit: "max_history_events".into(),
1564                message: format!(
1565                    "the execution reached {max} history events (--max-history-events)"
1566                ),
1567            });
1568        }
1569    }
1570}