Skip to main content

mlua_swarm/core/
state.rs

1//! `EngineState` — the single `Mutex`-guarded state object — plus the
2//! supporting types.
3//!
4//! `EngineState` holds every mutable piece of engine flow state (task
5//! table, session table, prompts, token records, worker handles, resume
6//! table, per-task notifiers, resources, per-attempt output events, and the
7//! event log tail). It sits on the Domain side of the Data / Domain split
8//! and is unchanged by the Data-plane (`output_store` module) refactor.
9
10use crate::types::{CapToken, Role, SessionId, StepId};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use std::collections::HashMap;
14use std::sync::Arc;
15use tokio::sync::{broadcast, Notify};
16
17// ─── Resume / Task ─────────────────────────────────────────────────────────
18
19/// Opaque handle identifying one `query_senior` suspend/`resume` cycle.
20/// Stored on `TaskState.suspended_on` and as the key of
21/// `EngineState.pending_resumes`.
22#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
23pub struct ResumeKey(pub String);
24
25impl ResumeKey {
26    /// Generate a random key (`RK-<12 hex bytes>`).
27    ///
28    /// The prefix moved from `R-` to `RK-` in issue #14: `R-` is reserved
29    /// for [`crate::types::RunId`], and sharing it would let a resume key
30    /// pass a run-id prefix check.
31    pub fn new() -> Self {
32        Self(format!("RK-{}", crate::types::uid_hex(12)))
33    }
34
35    /// Deterministic key for a Senior-escalation suspend on `task_id`
36    /// (`RK-senior-<task_id>`), so repeated escalations on the same task
37    /// are addressable without extra bookkeeping.
38    pub fn for_senior(task_id: &StepId) -> Self {
39        Self(format!("RK-senior-{}", task_id))
40    }
41}
42
43impl Default for ResumeKey {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49/// Lifecycle state of a task. `Pending` is the only non-terminal,
50/// non-`Suspended` state before the first `dispatch_attempt_with`;
51/// `Pass` / `Blocked` / `Cancelled` are terminal.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum TaskStatus {
55    /// Created via `start_task`, not yet dispatched.
56    Pending,
57    /// A `dispatch_attempt_with` call is in flight for this task.
58    Running,
59    /// Suspended awaiting a `query_senior`/`resume` round-trip.
60    Suspended,
61    /// The last attempt completed with `ok = true`.
62    Pass,
63    /// The last attempt completed with `ok = false` (or dispatch failed).
64    Blocked,
65    /// Cancelled via `cancel_task`.
66    Cancelled,
67}
68
69/// Static task definition supplied to `start_task`: which agent runs it
70/// and the initial prompt/directive value.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct TaskSpec {
73    /// Name of the agent that should execute this task.
74    pub agent: String,
75    /// Prompt/directive value seeded for attempt 1. Passed through
76    /// verbatim from the evaluated `Step.in` (issue #18 — no premature
77    /// `Value → String` coercion at this layer). Consumers that need a
78    /// rendered `String` (the `EngineState.prompts` table feeding the
79    /// Worker HTTP path, and the WS `Spawn.directive` reminder text)
80    /// render it at their own late boundary; strings pass through
81    /// verbatim, anything else is serde-stringified.
82    pub initial_directive: Value,
83    /// GH #21 Phase 2 — the Step tier's resolved context bundle, threaded
84    /// through from `EngineDispatcher::dispatch`'s `$step_meta` envelope
85    /// resolution (`None` when the dispatched `Step.in` carried no
86    /// envelope — pre-#21-Phase-2 Blueprints unaffected). Re-read from
87    /// the spec on EVERY `Engine::dispatch_attempt_with` attempt (not
88    /// cached once), so retries and Run-rekicks all carry it; inserted
89    /// into `Ctx.meta.runtime[STEP_CTX_KEY]`
90    /// (`crate::core::agent_context::STEP_CTX_KEY`), consumed by
91    /// `crate::middleware::agent_context::AgentContextMiddleware`.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub step_ctx: Option<Value>,
94}
95
96/// The full mutable record of one task: its static `spec`, current
97/// `status`, attempt counter, and bookkeeping timestamps. Cloned out of
98/// `EngineState` on every read (e.g. by `read_task_state` / `poll_task`).
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct TaskState {
101    /// Unique task identifier (assigned by `start_task`).
102    pub id: StepId,
103    /// The static spec this task was created from.
104    pub spec: TaskSpec,
105    /// Current lifecycle status.
106    pub status: TaskStatus,
107    /// 1-based counter, bumped by `Engine::dispatch_attempt_with` each
108    /// time this task is dispatched.
109    pub attempt: u32,
110    /// Set while `status == Suspended`; the key needed to `resume` it.
111    pub suspended_on: Option<ResumeKey>,
112    /// Most recent result value posted via `post_result` or produced by a
113    /// completed attempt.
114    pub last_result: Option<Value>,
115    /// Unix timestamp (seconds) when the task was created.
116    pub created_at: u64,
117    /// Unix timestamp (seconds) of the last state mutation.
118    pub updated_at: u64,
119    /// Recursive swarm depth. The root (an Operator calling
120    /// `start_task`) is 0; a child spawned by a Worker calling
121    /// `start_task` is its parent's `depth + 1`. Exceeding
122    /// `EngineCfg.max_spawn_depth` raises `SpawnDepthExceeded`.
123    #[serde(default)]
124    pub spawn_depth: u32,
125}
126
127impl TaskState {
128    /// Construct a new `Pending` task with `attempt = 0` and
129    /// `spawn_depth = 0`; `created_at`/`updated_at` are set to now.
130    pub fn new(id: StepId, spec: TaskSpec) -> Self {
131        let now = crate::types::now_unix();
132        Self {
133            id,
134            spec,
135            status: TaskStatus::Pending,
136            attempt: 0,
137            suspended_on: None,
138            last_result: None,
139            created_at: now,
140            updated_at: now,
141            spawn_depth: 0,
142        }
143    }
144}
145
146/// Result of a `dispatch_attempt_with` call (or the conceptual outcome of
147/// a task attempt more broadly).
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub enum DispatchOutcome {
150    /// The attempt completed with `ok = true`; carries the result value.
151    Pass(Value),
152    /// The attempt completed with `ok = false`, or dispatch itself failed;
153    /// carries the result/error value.
154    Blocked(Value),
155    /// The task suspended (e.g. via `query_senior`) before completing;
156    /// carries the key needed to `resume` it.
157    Suspended(ResumeKey),
158    /// The task was cancelled before completing.
159    Cancelled,
160    /// The attempt did not complete within the allotted time.
161    Timeout,
162}
163
164// ─── Session ───────────────────────────────────────────────────────────────
165
166/// Persisted record of one attached Operator session: identity, role,
167/// heartbeat bookkeeping, owned tasks, and the `OperatorKind` cascade
168/// inputs plus registry IDs used to rebuild `OperatorInfo` on dispatch
169/// (see `Engine::resolve_operator_info`).
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct OperatorSession {
172    /// Unique session identifier (distinct from the token nonce).
173    pub id: SessionId,
174    /// Caller-supplied name identifying the Operator (not necessarily
175    /// unique across sessions).
176    pub operator_id: String,
177    /// Role the session's token was minted with.
178    pub role: Role,
179    /// Unix timestamp (seconds) when the session was attached.
180    pub attached_at: u64,
181    /// Unix timestamp (seconds) of the last heartbeat/attach touch.
182    pub last_seen: u64,
183    /// Whether the session is currently considered live. Flipped to
184    /// `false` by `detach` or by `start_detach_loop` on a heartbeat miss.
185    pub attached: bool,
186    /// Task IDs started by this session (via `start_task` while this
187    /// session's token was current).
188    pub owned_task_ids: Vec<StepId>,
189    /// Fingerprint (`CapToken::fingerprint`, SHA-256 of the nonce) of the
190    /// `CapToken` this session was attached with; used to look sessions up
191    /// by token in `with_state` closures. Holds the fingerprint rather
192    /// than the nonce so the session table carries no secret material
193    /// (issue #14).
194    pub token_fp: String,
195    /// The Operator's `kind`, plus IDs of
196    /// the `SeniorBridge` / `SpawnHook` registered on the engine's
197    /// `BridgeRegistry`. Persisted (all `String`; no `Arc<dyn ...>`). At
198    /// `dispatch_attempt` time the engine looks these up in the registry
199    /// and builds an `OperatorInfo` to inject into `Ctx`.
200    ///
201    /// # 4-tier `OperatorKind` cascade — "Runtime Global" tier
202    ///
203    /// This field is the literal value passed to `Engine::attach_with_ids`'s
204    /// `kind` parameter, and is fed to `crate::core::ctx::collapse_operator_kind`
205    /// as the `runtime_global` tier verbatim: `Some(_)` is always an
206    /// explicit Runtime Global request that outranks both BP tiers — even
207    /// `Some(OperatorKind::Automate)` — and `None` means "not requested",
208    /// letting the BP-level tiers (`bp_agent_kinds` / `bp_global_kind`) take
209    /// over. `#[serde(default)]` keeps existing persisted sessions (from
210    /// before this field existed / was `Option`) deserializing as `None`.
211    /// See `crate::core::ctx::collapse_operator_kind` for the full cascade +
212    /// rationale.
213    #[serde(default)]
214    pub operator_kind: Option<crate::core::ctx::OperatorKind>,
215    /// "Runtime Agent-level" tier (highest priority) of the `OperatorKind`
216    /// cascade — per-agent override supplied at task-launch time via
217    /// `TaskLaunchInput.operator_kind_overrides` / `TaskApplicationInput
218    /// .operator_kind_overrides`. Keyed by `AgentDef.name`.
219    #[serde(default)]
220    pub runtime_agent_kinds: HashMap<String, crate::core::ctx::OperatorKind>,
221    /// "BP Agent-level" tier of the `OperatorKind` cascade — baked at
222    /// `TaskLaunchService::launch` time from `Blueprint.operators[].kind`,
223    /// resolved per-agent via `AgentDef.spec.operator_ref`. Keyed by
224    /// `AgentDef.name` (not `OperatorDef.name`).
225    #[serde(default)]
226    pub bp_agent_kinds: HashMap<String, crate::core::ctx::OperatorKind>,
227    /// "BP Global" tier of the `OperatorKind` cascade — baked at
228    /// `TaskLaunchService::launch` time from `Blueprint.default_operator_kind`.
229    #[serde(default)]
230    pub bp_global_kind: Option<crate::core::ctx::OperatorKind>,
231    /// ID of the `Arc<dyn SeniorBridge>` registered on the engine's
232    /// `BridgeRegistry`, if any; resolved back into `OperatorInfo.senior_bridge`.
233    #[serde(default)]
234    pub bridge_id: Option<String>,
235    /// ID of the `Arc<dyn SpawnHook>` registered on the engine's
236    /// `BridgeRegistry`, if any; resolved back into `OperatorInfo.spawn_hook`.
237    #[serde(default)]
238    pub hook_id: Option<String>,
239    /// ID of the `Arc<dyn Operator>` registered on the `OperatorRegistry`.
240    /// Used by `OperatorDelegateMiddleware` when `kind = MainAi` /
241    /// `Composite` and `operator_id` is `Some`: it delegates the entire
242    /// spawn to `operator.execute`.
243    #[serde(default)]
244    pub operator_backend_id: Option<String>,
245}
246
247// ─── Token record (= server-side counter holder) ──────────────────────────
248
249/// Server-side counter/state holder paired 1:1 with a minted `CapToken`
250/// (keyed by nonce in `EngineState.tokens`). Tracks remaining uses,
251/// revocation, and — for Worker tokens — the task the token is bound to.
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct CapTokenRecord {
254    /// The token this record backs.
255    pub token: CapToken,
256    /// Remaining number of verb-consuming calls. `None` means unlimited
257    /// (session-style tokens); `Some(0)` makes `consume` fail.
258    pub uses_left: Option<u32>, // None = unlimited (session)
259    /// When `true`, `consume` always fails regardless of `uses_left`.
260    pub revoked: bool,
261    /// The task this Worker token is bound to (set when minted via
262    /// `dispatch_attempt`). Used on two axes:
263    ///   1. **Depth tracking.** When a Worker calls `start_task` to spawn a
264    ///      child, the child receives this task's `spawn_depth + 1`.
265    ///   2. **Ownership gate.** When a Worker calls a state-touch verb
266    ///      (`fetch_prompt` / `post_result` / `read_task_state` /
267    ///      `cancel_task` / `poll_task`), the argument's `task_id` must
268    ///      match this value. `start_task`
269    ///      and `dispatch_attempt` are exempt — recursive swarming must
270    ///      stay open, and depth is capped by `max_spawn_depth`.
271    ///
272    ///      Operator tokens (minted at attach time) leave this `None`, so
273    ///      they can touch any task.
274    #[serde(default)]
275    pub task_id: Option<StepId>,
276}
277
278impl CapTokenRecord {
279    /// Wrap a freshly minted `CapToken` with no bound task (`task_id =
280    /// None`) — the shape used for Operator/session tokens.
281    pub fn from_token(token: CapToken) -> Self {
282        Self {
283            uses_left: token.max_uses,
284            token,
285            revoked: false,
286            task_id: None,
287        }
288    }
289
290    /// Convenience constructor used when minting a Worker token — binds
291    /// the record to the target task.
292    pub fn from_worker_token(token: CapToken, task_id: StepId) -> Self {
293        Self {
294            uses_left: token.max_uses,
295            token,
296            revoked: false,
297            task_id: Some(task_id),
298        }
299    }
300
301    /// Consume one use. `None` (session token) always returns `Ok`;
302    /// `Some(0)` returns `Err`.
303    pub fn consume(&mut self) -> Result<(), CapTokenConsumeError> {
304        if self.revoked {
305            return Err(CapTokenConsumeError::Revoked);
306        }
307        match self.uses_left.as_mut() {
308            None => Ok(()),
309            Some(0) => Err(CapTokenConsumeError::Exhausted),
310            Some(n) => {
311                *n -= 1;
312                Ok(())
313            }
314        }
315    }
316}
317
318/// Why [`CapTokenRecord::consume`] refused to spend a use.
319#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
320pub enum CapTokenConsumeError {
321    /// The record was explicitly revoked (`revoked = true`); revocation
322    /// is permanent and independent of `uses_left`.
323    #[error("token revoked")]
324    Revoked,
325    /// The record's `uses_left` budget (`Some(0)`) is spent.
326    #[error("token uses exhausted")]
327    Exhausted,
328}
329
330// ─── Event ─────────────────────────────────────────────────────────────────
331
332/// Engine lifecycle event. Every event is both appended to
333/// `EngineState.event_log_tail` (in-process ring buffer) and broadcast on
334/// `Engine::event_tx` for live subscribers.
335#[derive(Debug, Clone, Serialize, Deserialize)]
336#[serde(tag = "kind", rename_all = "snake_case")]
337pub enum Event {
338    /// A session was attached (`attach` / `attach_with` / `attach_with_ids`).
339    SessionAttached {
340        /// The newly attached session.
341        session_id: SessionId,
342        /// Role its token was minted with.
343        role: Role,
344    },
345    /// A session was detached (`detach`, or a heartbeat-miss timeout).
346    SessionDetached {
347        /// The session that was detached.
348        session_id: SessionId,
349    },
350    /// A new task was created via `start_task`.
351    TaskCreated {
352        /// The newly created task.
353        task_id: StepId,
354    },
355    /// An attempt began dispatching (not currently emitted by
356    /// `dispatch_attempt_with`; reserved for future use).
357    TaskAttemptStarted {
358        /// The task being dispatched.
359        task_id: StepId,
360        /// The attempt number.
361        attempt: u32,
362    },
363    /// An attempt finished, Pass or Blocked, with the resulting value.
364    TaskAttemptCompleted {
365        /// The task whose attempt completed.
366        task_id: StepId,
367        /// The attempt number that completed.
368        attempt: u32,
369        /// The result value produced by the attempt.
370        result: Value,
371    },
372    /// The task attempt completed with `ok = true`.
373    TaskPass {
374        /// The task that passed.
375        task_id: StepId,
376        /// The result value.
377        result: Value,
378    },
379    /// The task attempt completed with `ok = false`.
380    TaskBlocked {
381        /// The task that was blocked.
382        task_id: StepId,
383        /// The result/error value.
384        result: Value,
385    },
386    /// A worker appended an `OutputEvent` via `submit_output`.
387    WorkerOutput {
388        /// The task the output belongs to.
389        task_id: StepId,
390        /// The attempt the output belongs to.
391        attempt: u32,
392        /// The appended output event.
393        event: crate::worker::output::OutputEvent,
394    },
395    /// The task suspended pending a `resume` for `key`.
396    TaskSuspended {
397        /// The suspended task.
398        task_id: StepId,
399        /// The key needed to `resume` it.
400        key: ResumeKey,
401    },
402    /// The task resumed after `resume(key, ..)` was called.
403    TaskResumed {
404        /// The resumed task.
405        task_id: StepId,
406        /// The key that was resumed.
407        key: ResumeKey,
408    },
409    /// The task was cancelled via `cancel_task`.
410    TaskCancelled {
411        /// The cancelled task.
412        task_id: StepId,
413    },
414    /// `query_senior` was called, asking `question` on behalf of `task_id`.
415    SeniorQueried {
416        /// The task that triggered the query.
417        task_id: StepId,
418        /// The question posed to the Senior.
419        question: Value,
420    },
421    /// A Senior's `answer` was stored via `resume`.
422    SeniorAnswered {
423        /// The task the answer applies to.
424        task_id: StepId,
425        /// The Senior's answer.
426        answer: Value,
427    },
428}
429
430/// Receiver half of the engine-wide `Event` broadcast channel, obtained
431/// via `Engine::subscribe`.
432pub type EventStream = broadcast::Receiver<Event>;
433
434// ─── Resume pending (= Notify-based wait + stored answer) ─────────────────
435
436/// Entry for a task suspended via `query_senior`, waiting to be resumed.
437///
438/// The `Notify` + `answer: Option<Value>` form (rather than a oneshot
439/// channel) is deliberate: the answer stays inside `EngineState` even if
440/// the caller (an Operator) **detaches and reattaches**, so it can pull
441/// the answer out via `await_resume` after reattach.
442#[derive(Debug, Clone)]
443pub struct ResumePending {
444    /// Wakes any `await_resume` waiter once `answer` is set.
445    pub notify: Arc<Notify>,
446    /// The stored answer, once `resume` has been called for this key.
447    pub answer: Option<Value>,
448}
449
450impl ResumePending {
451    /// Create an unanswered pending entry (fresh `Notify`, `answer = None`).
452    pub fn new() -> Self {
453        Self {
454            notify: Arc::new(Notify::new()),
455            answer: None,
456        }
457    }
458}
459
460impl Default for ResumePending {
461    fn default() -> Self {
462        Self::new()
463    }
464}
465
466// ─── EngineState (= the locked thing) ──────────────────────────────────────
467
468/// One `(task_id, attempt)` entry of [`EngineState::agent_ctx`] — the
469/// materialized [`crate::core::agent_context::AgentContextView`] (Contract
470/// C, GH #20) and the effective [`mlua_swarm_schema::ContextPolicy`]
471/// `AgentContextMiddleware` already applied to it (`projection-adapter`
472/// ST5), folded into one struct (GH #23) so the two values — written
473/// together at the same single insert site — can no longer drift apart.
474#[derive(Debug, Clone, Default)]
475pub struct AgentCtxEntry {
476    /// The materialized view; the Worker axis's read-back source
477    /// (`Engine::fetch_worker_payload{,_trusted}` / `Engine::agent_context_for`).
478    pub view: crate::core::agent_context::AgentContextView,
479    /// The resolved policy already applied to `view`; read back by
480    /// `Engine::context_policy_for` so a consumer can filter a *different*
481    /// step's pointer list against this key's policy without re-deriving it
482    /// from the Blueprint.
483    pub policy: mlua_swarm_schema::ContextPolicy,
484}
485
486/// The single `Mutex`-guarded blob of engine flow state, accessed only
487/// through `Engine::with_state` (see the R1-R4 discipline documented
488/// there).
489#[derive(Debug)]
490pub struct EngineState {
491    /// All known tasks, keyed by `StepId`.
492    pub tasks: HashMap<StepId, TaskState>,
493    /// All attached/detached sessions, keyed by `SessionId`.
494    pub sessions: HashMap<SessionId, OperatorSession>,
495    /// Per-`(task_id, attempt)` prompt/directive value, seeded from
496    /// `TaskSpec.initial_directive` and fetched via `fetch_prompt`. Held
497    /// as `serde_json::Value` end-to-end (issue #18): the render down to
498    /// `String` (strings verbatim, anything else serde-stringified)
499    /// happens only at the two consumer boundaries — the Worker HTTP
500    /// path (`Engine::fetch_worker_payload*` → `WorkerPayload.prompt:
501    /// String`) and the WS Spawn frame text render
502    /// (`operator_ws::session::default_spawn_directive_with_task_directive`).
503    /// Engine-internal storage and `Engine::fetch_prompt` keep the
504    /// `Value` end-to-end, so `Step.in` `Object` / `Array` seeds are not
505    /// prematurely flattened.
506    pub prompts: HashMap<(StepId, u32), Value>,
507    /// Per-attempt `system_prompt`: `AgentDef.profile.system_prompt` is
508    /// baked at compile time, rendered inside `OperatorSpawner::spawn`,
509    /// and stashed here for the SubAgent to fetch alongside its prompt via
510    /// `HTTP /v1/worker/prompt`. The value is `Option<String>` so a missing
511    /// profile can be distinguished: an absent key means "not yet baked",
512    /// while `Some(None)` means "baked and profile is explicitly absent".
513    pub systems: HashMap<(StepId, u32), Option<String>>,
514    /// Per-attempt materialized [`crate::core::agent_context::AgentContextView`]
515    /// (Contract C, GH #20) paired with the effective
516    /// [`mlua_swarm_schema::ContextPolicy`] `AgentContextMiddleware` already
517    /// applied to that same view — the Worker axis's read-back source.
518    /// Written by `crate::middleware::agent_context::AgentContextMiddleware`
519    /// (innermost spawner layer) at dispatch time via one insert (GH #23:
520    /// folded from two separately-keyed maps — see below); read by
521    /// `Engine::fetch_worker_payload{,_trusted}` / `Engine::agent_context_for`
522    /// (the [`AgentCtxEntry::view`] half, threaded into `WorkerPayload.context`)
523    /// and `Engine::context_policy_for` (the [`AgentCtxEntry::policy`] half,
524    /// read by `crates/mlua-swarm-server/src/worker.rs`'s `GET
525    /// /v1/worker/prompt` handler to filter `WorkerPayload.context.steps` via
526    /// `ContextPolicy::allows_step` without re-deriving the policy from the
527    /// Blueprint at fetch time). Keyed the same way as `prompts` / `systems`
528    /// — `Ctx` itself is not stored, so the entry has to be snapshotted here
529    /// to still be servable at fetch time. An absent entry (pre-ST5 spawns,
530    /// or a spawner stack that never layered `AgentContextMiddleware`) means
531    /// no materialized view and a pass-all policy
532    /// (`mlua_swarm_schema::ContextPolicy::default()`) for that key.
533    ///
534    /// GH #23: this map used to be two separately-keyed maps
535    /// (`agent_contexts: HashMap<_, AgentContextView>` /
536    /// `context_policies: HashMap<_, ContextPolicy>`) sharing a key and kept
537    /// in sync only by convention (the single insert site in
538    /// `AgentContextMiddleware` writing both in the same `with_state` call).
539    /// Folding both values into one map of an [`AgentCtxEntry`] struct makes
540    /// that pairing structural — the two values can no longer drift apart
541    /// key-by-key. TODO(GH #23): this map (nor `prompts` / `systems`) still
542    /// has no removal path — entries accumulate for the process lifetime; a
543    /// long-running server needs a task-completion sweep (tracked
544    /// separately from this fold).
545    pub agent_ctx: HashMap<(StepId, u32), AgentCtxEntry>,
546    /// GH #23: per-dispatch snapshot of the Blueprint-wide
547    /// [`crate::core::step_naming::StepNaming`] addressing-space table —
548    /// built once by `blueprint::compiler::Compiler::compile` and stashed
549    /// here, per `StepId`, by `crate::blueprint::EngineDispatcher::dispatch`
550    /// (its single insert site; the same `Arc` is shared across every
551    /// Step dispatched from the same Blueprint launch). `Engine::step_naming_for`
552    /// reads it back so later consumers (GH #23 subtask-2/3 —
553    /// `ContextPolicy.allows_step`, `StepPointer`/`StepSummary` assembly,
554    /// the REST `:step` resolver, `FileProjectionAdapter`) do not have to
555    /// re-derive the table from the Blueprint at read time. An absent
556    /// entry means the dispatcher was never given a `StepNaming` (e.g. a
557    /// direct `EngineDispatcher::with_spawner` caller that skipped
558    /// `with_step_naming`) — callers fall back to the pre-GH-#23 runtime
559    /// union rule in that case.
560    pub step_namings: HashMap<StepId, Arc<crate::core::step_naming::StepNaming>>,
561    /// GH #27 (follow-up to #23): per-dispatch snapshot of the
562    /// Blueprint-wide [`crate::core::projection_placement::ProjectionPlacement`]
563    /// resolver — built once by `blueprint::compiler::Compiler::compile`
564    /// and stashed here, per `StepId`, by
565    /// `crate::blueprint::EngineDispatcher::dispatch` (the same insert
566    /// site, and the same "construct once, read many" contract, as
567    /// [`Self::step_namings`]). `Engine::projection_placement_for` reads
568    /// it back so every one of the 3 materialize call sites (submit-time
569    /// sink, server read-back, spawn-time pointer) resolves the SAME root
570    /// preference / directory template. An absent entry means the
571    /// dispatcher was never given a `ProjectionPlacement` (e.g. a direct
572    /// `EngineDispatcher::with_spawner` caller that skipped
573    /// `with_projection_placement`) — callers fall back to
574    /// `ProjectionPlacement::default()` (byte-compat with the pre-#27
575    /// hardcoded layout) in that case.
576    pub projection_placements:
577        HashMap<StepId, Arc<crate::core::projection_placement::ProjectionPlacement>>,
578    /// All minted `CapToken` records, keyed by token fingerprint
579    /// (`CapToken::fingerprint` = SHA-256 of the nonce; issue #14 — the
580    /// key is loggable, the nonce is not).
581    pub tokens: HashMap<String, CapTokenRecord>, // key = token fingerprint
582    /// Short worker handle (`wh-XXXXXXXX`, 12 chars) → token-fingerprint
583    /// lookup map. Resolves the `worker_handle` field a SubAgent receives
584    /// with its prompt. There is no signature verification: `task_id` is
585    /// resolved by a plain `HashMap` lookup — deliberately thin for the
586    /// local running over WebSocket, and adopted specifically to remove
587    /// the base64 copy-paste failure mode.
588    pub worker_handles: HashMap<String, String>,
589    /// Outstanding `query_senior` suspensions awaiting `resume`.
590    pub pending_resumes: HashMap<ResumeKey, ResumePending>,
591    /// Per-task notifier — `notify_waiters` fires on every task-status
592    /// change. Used by `poll_task` on the caller side, and by callers that
593    /// need to `await` again after detach/reattach.
594    pub task_notifies: HashMap<StepId, Arc<Notify>>,
595    /// Arbitrary named resources set via `set_resource` and read via
596    /// `fetch_data`.
597    pub resources: HashMap<String, Value>,
598    /// Per-attempt output-event log. The `SpawnerAdapter` appends via
599    /// `submit_output`; the dispatch path pulls the terminal
600    /// `OutputEvent::Final` off the tail and decides Pass / Blocked.
601    pub output_store: HashMap<(StepId, u32), Vec<crate::worker::output::OutputEvent>>,
602    /// Bounded in-process tail of recent `Event`s (most recent last),
603    /// trimmed to `event_log_max` by `push_event`.
604    pub event_log_tail: Vec<Event>,
605    /// Maximum length of `event_log_tail` before older entries are
606    /// dropped.
607    pub event_log_max: usize,
608}
609
610impl EngineState {
611    /// Construct an empty `EngineState` with `event_log_max = 1024`.
612    pub fn new() -> Self {
613        Self {
614            tasks: HashMap::new(),
615            sessions: HashMap::new(),
616            prompts: HashMap::new(),
617            systems: HashMap::new(),
618            agent_ctx: HashMap::new(),
619            step_namings: HashMap::new(),
620            projection_placements: HashMap::new(),
621            tokens: HashMap::new(),
622            worker_handles: HashMap::new(),
623            pending_resumes: HashMap::new(),
624            task_notifies: HashMap::new(),
625            resources: HashMap::new(),
626            output_store: HashMap::new(),
627            event_log_tail: Vec::new(),
628            event_log_max: 1024,
629        }
630    }
631
632    /// Ensure a per-task `Notify` exists; return the existing one if any.
633    pub fn ensure_task_notify(&mut self, task_id: &StepId) -> Arc<Notify> {
634        self.task_notifies
635            .entry(task_id.clone())
636            .or_insert_with(|| Arc::new(Notify::new()))
637            .clone()
638    }
639
640    /// Append `ev` to `event_log_tail`, trimming the oldest entries once
641    /// `event_log_max` is exceeded.
642    pub fn push_event(&mut self, ev: Event) {
643        self.event_log_tail.push(ev);
644        if self.event_log_tail.len() > self.event_log_max {
645            let overflow = self.event_log_tail.len() - self.event_log_max;
646            self.event_log_tail.drain(..overflow);
647        }
648    }
649}
650
651impl Default for EngineState {
652    fn default() -> Self {
653        Self::new()
654    }
655}