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}