Skip to main content

mlua_swarm/store/run/
mod.rs

1//! `RunStore` — persistence for `Run` records (one kick of a `Task`).
2//!
3//! Part of the issue #13 ID-hierarchy reconciliation: Blueprint -> Task ->
4//! Run -> Step -> Attempt. A [`RunId`](crate::types::RunId) is minted
5//! server-side each time a [`crate::store::task::TaskRecord`] is kicked; it
6//! carries a lightweight trace of the steps dispatched during that kick
7//! ([`StepEntry`]) for observability, plus its own outcome status
8//! independent of the owning Task's coarser status. A single Task can have
9//! N `Run`s over its lifetime (`list_by_task`).
10//!
11//! Current scope:
12//!
13//! - [`InMemoryRunStore`] — process-volatile default.
14//! - [`SqliteRunStore`] — file-backed persistence via `rusqlite-isle`.
15//!   `step_entries` is a JSON column, not normalized into its own table —
16//!   this is a trace/observability artifact, not something queried
17//!   relationally.
18//! - Other persistent backends (Git / mini-app / …) are future carries.
19
20use crate::blueprint::BindingDigest;
21use crate::store::replay::{ReplayCursor, ReplayStore};
22use crate::types::{RunId, StepId, TaskId};
23use async_trait::async_trait;
24use serde::{Deserialize, Serialize};
25use std::collections::HashMap;
26use std::sync::{Arc, Mutex};
27use thiserror::Error;
28
29pub mod inmemory;
30pub mod sqlite;
31pub use inmemory::InMemoryRunStore;
32pub use sqlite::SqliteRunStore;
33
34// ──────────────────────────────────────────────────────────────────────────
35// RunStatus / StepEntry / RunRecord
36// ──────────────────────────────────────────────────────────────────────────
37
38/// Lifecycle status of a [`RunRecord`] — the outcome of one specific kick
39/// of a Task.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
41#[serde(rename_all = "snake_case")]
42pub enum RunStatus {
43    /// Minted, not yet dispatched.
44    Pending,
45    /// Steps are currently being dispatched for this Run.
46    Running,
47    /// The Run completed successfully.
48    Done,
49    /// The Run failed.
50    Failed,
51    /// The Run was still `Running` when the server process restarted
52    /// (issue #35 ST2 boot-time recovery sweep). Terminal — in-flight
53    /// `EngineState` is process-local and unrecoverable; this variant
54    /// records the fact without attempting to reconstruct or resume it.
55    Interrupted,
56    /// A cancel request landed on the Run (via `POST /v1/runs/:id/cancel`
57    /// / `mse_cancel` / `swarm_cancel`). Terminal — the current wiring
58    /// records the intent + trace event; live in-flight abort of the
59    /// still-dispatching flow remains a v3 carry, so a Run that reaches
60    /// its Ok outcome after this marker keeps its terminal `result_ref`,
61    /// but the Cancelled marker itself is observable via
62    /// `swarm_status.cancel_requested` and `core.cancel_requested` on
63    /// the trace stream.
64    Cancelled,
65}
66
67/// One worker-reported degradation entry — a worker fell back to a
68/// substitute behavior instead of failing outright (e.g. a tool call errored
69/// and the worker used a cached/default value). Independent channel from
70/// [`StepEntry`]/`result_ref`: degradations never flow through step OUTPUT
71/// or the fold path (GH #32; sibling of the GH #34 audit sidecar — both
72/// keep observational signal off the BP-chain value). Reported via `POST
73/// /v1/worker/degradation`; the server injects `step_ref`/`attempt`/`at`
74/// before persisting.
75#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
76pub struct DegradationEntry {
77    /// The tool (or capability) the worker attempted to use.
78    pub tool: String,
79    /// The error that triggered the fallback, in the worker's own words.
80    pub error: String,
81    /// What the worker substituted instead of failing.
82    pub fallback: String,
83    /// Optional free-form context from the worker.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub note: Option<String>,
86    /// The Blueprint step ref (`Step.ref`) this degradation was reported
87    /// under, if known. Server-injected metadata, not worker-supplied.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub step_ref: Option<String>,
90    /// The attempt number this degradation was reported under, if known.
91    /// Server-injected metadata, not worker-supplied.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub attempt: Option<u32>,
94    /// Unix epoch seconds — when this entry was recorded. Server-injected.
95    pub at: u64,
96}
97
98/// One entry in a Run's step trace — appended as the engine dispatches
99/// (and finishes) each step. Purely observational: no field here is
100/// consulted for flow control.
101///
102/// The per-step stats extension (started/completed timestamps, duration,
103/// token usage, model, worker kind, variant-specific `adapter_data`) is
104/// additive: every field is `Option` + `#[serde(default)]` so rows
105/// written before the extension deserialize unchanged, and a dispatch
106/// where no boundary reported stats still appends a valid entry. The
107/// entry stays **write-once** — in-flight visibility belongs to the
108/// sibling [`crate::store::trace::TraceEvent`] stream, never to
109/// in-place updates here.
110#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
111pub struct StepEntry {
112    /// The step this entry traces.
113    #[schemars(with = "String")]
114    pub step_id: StepId,
115    /// The Blueprint step ref (`Step.ref`) that was dispatched, if known.
116    pub step_ref: Option<String>,
117    /// Free-form status label for this step at the time the entry was
118    /// recorded (e.g. `"dispatched"`, `"passed"`, `"blocked"`).
119    pub status: Option<String>,
120    /// Immutable Runner/Agent/Context snapshot digest used for this step.
121    /// `None` for rows created before BoundAgent launch wiring.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub binding_digest: Option<BindingDigest>,
124    /// Unix epoch seconds — when this entry was recorded.
125    pub at: u64,
126    /// The attempt number the stats below describe (the LAST attempt the
127    /// dispatch ran), when a worker boundary reported one.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub attempt: Option<u32>,
130    /// Unix epoch milliseconds — when the dispatcher began this step.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub started_at_ms: Option<i64>,
133    /// Unix epoch milliseconds — when the step reached its outcome.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub completed_at_ms: Option<i64>,
136    /// Wall-clock dispatch duration in milliseconds (dispatcher-measured,
137    /// worker-kind independent).
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub duration_ms: Option<u64>,
140    /// Worker kind label (`"agent_block"` / `"subprocess"` / `"operator"`
141    /// / …) as reported by the worker boundary.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub worker_kind: Option<String>,
144    /// The model that served the attempt, when known.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub model: Option<String>,
147    /// Normalized token usage, when a worker boundary reported one.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub usage: Option<crate::store::trace::TokenUsage>,
150    /// Number of LLM turns the attempt ran, when reported.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub num_turns: Option<u32>,
153    /// Worker-kind-specific raw payload (size-capped, engine-opaque).
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub adapter_data: Option<serde_json::Value>,
156}
157
158impl StepEntry {
159    /// Construct an entry with only the pre-stats fields set — the shape
160    /// every pre-extension writer produced. Stats fields default to
161    /// `None`; the dispatcher's fold fills them when available.
162    pub fn basic(
163        step_id: StepId,
164        step_ref: Option<String>,
165        status: Option<String>,
166        binding_digest: Option<BindingDigest>,
167        at: u64,
168    ) -> Self {
169        Self {
170            step_id,
171            step_ref,
172            status,
173            binding_digest,
174            at,
175            attempt: None,
176            started_at_ms: None,
177            completed_at_ms: None,
178            duration_ms: None,
179            worker_kind: None,
180            model: None,
181            usage: None,
182            num_turns: None,
183            adapter_data: None,
184        }
185    }
186
187    /// Fold a boundary-reported [`crate::store::trace::WorkerStats`]
188    /// into this entry (the dispatcher's outcome-time fold). `None`
189    /// fields in `stats` leave the entry untouched; `adapter_data` is
190    /// size-capped via [`crate::store::trace::cap_payload`].
191    pub fn with_worker_stats(mut self, stats: crate::store::trace::WorkerStats) -> Self {
192        self.worker_kind = stats.worker_kind.or(self.worker_kind);
193        self.model = stats.model.or(self.model);
194        self.usage = stats.usage.or(self.usage);
195        self.num_turns = stats.num_turns.or(self.num_turns);
196        self.adapter_data = stats
197            .adapter_data
198            .map(crate::store::trace::cap_payload)
199            .or(self.adapter_data);
200        self
201    }
202}
203
204/// One persisted `Run` row — one kick of a [`crate::store::task::TaskRecord`].
205#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
206pub struct RunRecord {
207    /// Run identifier.
208    #[schemars(with = "String")]
209    pub id: RunId,
210    /// The Task this Run was kicked from.
211    #[schemars(with = "String")]
212    pub task_id: TaskId,
213    /// Current lifecycle status.
214    pub status: RunStatus,
215    /// Trace of dispatched steps, in append order.
216    pub step_entries: Vec<StepEntry>,
217    /// Worker-reported degradations, in append order (GH #32). Independent
218    /// channel from [`Self::step_entries`]/[`Self::result_ref`] — see
219    /// [`DegradationEntry`]'s doc for the invariant. `[]` (the default) =
220    /// no degradations reported — every pre-#32 Run is unaffected.
221    #[serde(default, skip_serializing_if = "Vec::is_empty")]
222    pub degradations: Vec<DegradationEntry>,
223    /// Operator session id bound to this Run, if any (WS operator
224    /// correlation).
225    pub operator_sid: Option<String>,
226    /// The Run's terminal result payload, set once by
227    /// [`RunStore::set_result`]. `None` while the Run is in flight.
228    #[schemars(with = "Option<serde_json::Value>")]
229    pub result_ref: Option<serde_json::Value>,
230    /// Opaque JSON snapshot of the launch input this Run was kicked with
231    /// (blueprint / init_ctx / operator injection / ttl / …). The server
232    /// serializes its own launch-input struct into this string at Run
233    /// creation time so an `Interrupted` Run can be resumed under the SAME
234    /// `run_id` without re-deriving the input from a since-stale request
235    /// body. The store treats it as an opaque blob — the schema is owned by
236    /// the caller (the server crate). `None` = no snapshot recorded (older
237    /// rows predating resume support, or a caller that never opts in); such
238    /// a Run cannot be resumed. Additive with `#[serde(default)]` so
239    /// pre-existing serialized rows deserialize unchanged.
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub input_json: Option<String>,
242    /// Unix epoch seconds — creation time.
243    pub created_at: u64,
244    /// Unix epoch seconds — last update time.
245    pub updated_at: u64,
246}
247
248/// Filter/paging parameters for [`RunStore::list`] — the `GET /v1/runs`
249/// collection query. All filters AND together; results are newest-first
250/// (`created_at` descending, ties broken by insertion order where the
251/// backend tracks one).
252#[derive(Debug, Clone, Default)]
253pub struct RunListFilter {
254    /// Only Runs kicked from this Task.
255    pub task_id: Option<TaskId>,
256    /// Only Runs currently in this status.
257    pub status: Option<RunStatus>,
258    /// Page size cap. `None` = no cap.
259    pub limit: Option<usize>,
260    /// Skip the first N matching rows (after ordering).
261    pub offset: Option<usize>,
262}
263
264/// Errors surfaced by a [`RunStore`] implementation.
265#[derive(Debug, Error)]
266pub enum RunStoreError {
267    /// No Run exists for the given id.
268    #[error("run not found: {0}")]
269    NotFound(RunId),
270
271    /// `create` was called with an id that is already stored.
272    #[error("run already exists: {0}")]
273    Duplicate(RunId),
274
275    /// Backend-specific failure not covered by the other variants.
276    #[error("other: {0}")]
277    Other(String),
278}
279
280/// The provenance of a Run snapshot's `bound_agents` array.
281///
282/// Persisted as the [`BOUND_AGENTS_ORIGIN_KEY`] sibling of `bound_agents`
283/// inside the opaque [`RunRecord::input_json`] blob. This is Run-store
284/// metadata, **not** a schema-crate Blueprint wire type: it never enters
285/// [`crate::blueprint::BoundAgent`], `BoundAgentDigestInput`, or any digest
286/// computation. It lives here beside [`RunContext`] — rather than in
287/// `crate::service::task_launch` — because both the domain launch service
288/// (which writes it) and the server crate's bindings-explain handler (which
289/// reads it) consume it, and both already depend on this module; parking it
290/// in the service module would force the server crate to reach into a
291/// service-private type.
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
293#[serde(rename_all = "snake_case")]
294pub enum SnapshotOrigin {
295    /// `bound_agents` were resolved and pinned at the Run's initial launch —
296    /// the binding identity is the launch-time pin.
297    Launch,
298    /// `bound_agents` were backfilled from the current Blueprint when a
299    /// pre-binding-snapshot Run was resumed or reran. The binding identity
300    /// carries no launch-time pin guarantee.
301    ResumeBackfill,
302}
303
304/// JSON key the [`SnapshotOrigin`] is persisted under, beside `bound_agents`,
305/// in [`RunRecord::input_json`].
306pub const BOUND_AGENTS_ORIGIN_KEY: &str = "bound_agents_origin";
307
308impl SnapshotOrigin {
309    /// Read the origin marker from a decoded launch snapshot. An absent (or
310    /// unparseable) [`BOUND_AGENTS_ORIGIN_KEY`] maps to
311    /// [`SnapshotOrigin::ResumeBackfill`] — the safe side: a snapshot whose
312    /// `bound_agents` were persisted before this marker existed cannot prove
313    /// they were pinned at launch, so it must not be reported as a launch pin
314    /// and (on the replay axis) must not have binding digests mixed into its
315    /// replay keys. Only test artifacts hit this case in practice — the
316    /// strict-binding series is unreleased, so no real snapshot predates the
317    /// marker.
318    pub fn from_snapshot(snapshot: &serde_json::Value) -> Self {
319        snapshot
320            .get(BOUND_AGENTS_ORIGIN_KEY)
321            .and_then(|v| serde_json::from_value::<SnapshotOrigin>(v.clone()).ok())
322            .unwrap_or(SnapshotOrigin::ResumeBackfill)
323    }
324}
325
326/// GH #76 error surface: single-slot breadcrumb the dispatcher writes when a step
327/// aborts the flow (currently: [`crate::core::state::DispatchOutcome::Blocked`]),
328/// so the surrounding [`crate::service::task_launch::TaskLaunchService::launch`]
329/// `map_err` closure can lift `failed_step` + `verdict_value` off the eval
330/// boundary into the structured [`crate::service::task_launch::TaskLaunchError::FlowEval`]
331/// variant. Sibling to `step_entries` (append-only per-step trace) — this
332/// slot is last-write-wins because only ONE aborting step matters for the
333/// eval's terminal error envelope, and flow-ir stops dispatching further
334/// steps after `EvalError::DispatcherError`.
335#[derive(Debug, Clone)]
336pub struct LastFailure {
337    /// The `StepId` (dispatch-time tid) the dispatcher assigned to the
338    /// aborting step.
339    pub step_id: StepId,
340    /// The Blueprint `Step.ref` that dispatched the aborting step, if
341    /// known (dispatcher fills this from its own `ref_` param — never `None`
342    /// on the current write path, but modeled `Option` because the
343    /// `LastFailure` shape is a public read surface and future breadcrumb
344    /// writers may not have a ref in hand).
345    pub step_ref: Option<String>,
346    /// The verdict value the aborting step carried
347    /// (e.g. `DispatchOutcome::Blocked(v)`'s `v`, cloned by the dispatcher
348    /// before mapping the outcome to `EvalError::DispatcherError`).
349    pub verdict_value: serde_json::Value,
350}
351
352/// Pairs a [`RunId`] with the [`RunStore`] used to persist its trace.
353///
354/// Threaded from the server entry points (`POST /v1/tasks`, `POST
355/// /v1/tasks/:id/runs`) down through `TaskApplication::handle_with_run` /
356/// `TaskLaunchService::launch` / `EngineDispatcher` (issue #13 run_id
357/// propagation) so every step the dispatcher runs can be appended to
358/// `RunRecord.step_entries` and the run's id exposed to workers via
359/// `Ctx.meta.runtime["run_id"]`. Kept as a distinct type — rather than a
360/// new field on `TaskApplicationInput` — so the pre-existing exhaustive
361/// struct literal in `mlua-swarm-cli`'s MCP adapter (`TaskApplicationInput
362/// { .. }`, no `run_ctx`) keeps compiling unchanged: callers that don't
363/// care about run tracing keep calling `TaskApplication::handle` /
364/// `TaskLaunchService::launch`, which pass `None` through internally.
365#[derive(Clone)]
366pub struct RunContext {
367    /// The Run this dispatch's steps should be traced into.
368    pub run_id: RunId,
369    /// Where to append [`StepEntry`] rows as steps are dispatched.
370    pub run_store: Arc<dyn RunStore>,
371    /// Optional [`ReplayStore`] the engine will append a Ctx-snapshot +
372    /// step-output row to after every completed step (see
373    /// [`crate::store::replay`] for the primitive). `None` (the default)
374    /// disables logging entirely — pre-replay callers keep their behavior
375    /// byte-for-byte.
376    pub replay_store: Option<Arc<dyn ReplayStore>>,
377    /// Optional [`ReplayCursor`] the engine consults BEFORE dispatching
378    /// each step. When present and the cursor has a matching row for
379    /// `(step_ref, input_hash, occurrence)`, the engine returns the
380    /// stored `DispatchOutcome::Pass(value)` verbatim and skips the
381    /// Adapter spawn — this is the replay-hit path. `None` (the default)
382    /// disables replay entirely.
383    pub replay_cursor: Option<Arc<Mutex<ReplayCursor>>>,
384    /// Run-pinned replay identity component, keyed by logical agent name.
385    pub binding_digests: Arc<HashMap<String, BindingDigest>>,
386    /// Whether this dispatch is a resume / rerun-from of an existing Run
387    /// rather than an initial launch. `false` (the default) marks an initial
388    /// launch. Set to `true` ONLY by the server's resume and rerun-from
389    /// handlers — it is the sole, explicit signal that decides a backfilled
390    /// snapshot's [`SnapshotOrigin`] (never inferred from replay-cursor or
391    /// step-entry state, whose wiring is free to change).
392    pub resume: bool,
393    /// GH #76 error surface: shared single-slot breadcrumb the dispatcher writes when
394    /// a step aborts the flow (`DispatchOutcome::Blocked` → `EvalError`).
395    /// Read by the enclosing [`crate::service::task_launch::TaskLaunchService::launch`]
396    /// `map_err` closure to populate the structured
397    /// [`crate::service::task_launch::TaskLaunchError::FlowEval`] variant's
398    /// `failed_step` / `verdict_value` fields. `None` (the default) means
399    /// no aborting step was recorded — either the run succeeded, or an
400    /// error path fired that does not go through the dispatcher's Blocked
401    /// arm (e.g. `EvalError` raised by flow-ir itself before dispatch).
402    /// Behind `std::sync::Mutex` to match the `replay_cursor` sibling
403    /// (same crate-level convention — dispatcher writes are short critical
404    /// sections, no `.await` held across).
405    pub last_failure: Arc<Mutex<Option<LastFailure>>>,
406    /// Optional [`crate::store::trace::TraceHandle`] bound to this Run —
407    /// the write port for the per-Run [`crate::store::trace::TraceEvent`]
408    /// stream. When present the dispatcher appends `core.*` events
409    /// around every step and registers the handle with the engine
410    /// (`Engine::trace_handle`) so middlewares/workers can append their
411    /// own kinds. `None` (the default) disables the trace rail entirely
412    /// — pre-trace callers keep their behavior byte-for-byte.
413    pub trace: Option<crate::store::trace::TraceHandle>,
414}
415
416impl RunContext {
417    /// Construct a `RunContext` with just the RunStore wired — the same
418    /// shape all pre-replay callers use (`replay_store` / `replay_cursor`
419    /// both `None`). Preserved as a convenience so a caller that never
420    /// opts into replay can keep constructing `RunContext` positionally.
421    pub fn new(run_id: RunId, run_store: Arc<dyn RunStore>) -> Self {
422        Self {
423            run_id,
424            run_store,
425            replay_store: None,
426            replay_cursor: None,
427            binding_digests: Arc::new(HashMap::new()),
428            resume: false,
429            last_failure: Arc::new(Mutex::new(None)),
430            trace: None,
431        }
432    }
433
434    /// Builder-style setter: attach a
435    /// [`crate::store::trace::TraceHandle`] so the dispatcher appends
436    /// `core.*` trace events around every step and exposes the handle
437    /// to middlewares/workers via the engine.
438    pub fn with_trace(mut self, trace: crate::store::trace::TraceHandle) -> Self {
439        self.trace = Some(trace);
440        self
441    }
442
443    /// GH #76 error surface: write the aborting-step breadcrumb (last-write-wins).
444    /// Called by [`crate::blueprint::EngineDispatcher::dispatch`]'s Blocked
445    /// arm BEFORE it maps the outcome to `EvalError::DispatcherError`.
446    /// Silently succeeds if the mutex is poisoned — this is an
447    /// observability breadcrumb, not a load-bearing invariant, and a
448    /// poisoned mutex here must never prevent the primary abort error
449    /// from propagating (same fail-open convention as the sibling
450    /// `append_step_entry` warn-and-swallow at
451    /// `EngineDispatcher::dispatch`).
452    pub fn set_last_failure(&self, failure: LastFailure) {
453        if let Ok(mut slot) = self.last_failure.lock() {
454            *slot = Some(failure);
455        }
456    }
457
458    /// GH #76 error surface: reconstruct a partial-ctx snapshot from the step-entry
459    /// trace persisted so far — the in-tree substitute for a full
460    /// `storage.snapshot()` from flow-ir (upstream carry).
461    ///
462    /// Shape: `{ "steps": { "<step_id>": { "step_ref": ..., "status": ...,
463    /// "binding_digest": ..., "at": ... } } }` — a JSON object keyed by
464    /// each dispatched `StepId` with its recorded [`StepEntry`] metadata.
465    /// This is metadata-level, NOT value-level (no `StepEntry` carries the
466    /// step's actual OUTPUT value; that requires upstream mlua-flow-ir
467    /// support to expose `storage.snapshot()` on error). Consumers who
468    /// need value-level partial ctx must wait for the upstream carry —
469    /// see the FlowEval `partial_ctx` field rustdoc.
470    ///
471    /// Returns `Value::Null` if the store lookup fails (e.g. the row was
472    /// deleted between dispatch and error surfacing) — the caller's
473    /// `partial_ctx: Option<Value>` field wraps this so `Null` is
474    /// distinguishable from "no snapshot attempt at all".
475    pub async fn snapshot_partial_ctx(&self) -> serde_json::Value {
476        let record = match self.run_store.get(&self.run_id).await {
477            Ok(r) => r,
478            Err(_) => return serde_json::Value::Null,
479        };
480        let mut steps = serde_json::Map::new();
481        for entry in &record.step_entries {
482            let mut fields = serde_json::Map::new();
483            if let Some(ref_) = &entry.step_ref {
484                fields.insert(
485                    "step_ref".to_string(),
486                    serde_json::Value::String(ref_.clone()),
487                );
488            }
489            if let Some(status) = &entry.status {
490                fields.insert(
491                    "status".to_string(),
492                    serde_json::Value::String(status.clone()),
493                );
494            }
495            if let Some(digest) = &entry.binding_digest {
496                fields.insert(
497                    "binding_digest".to_string(),
498                    serde_json::Value::String(digest.to_string()),
499                );
500            }
501            fields.insert("at".to_string(), serde_json::Value::Number(entry.at.into()));
502            steps.insert(entry.step_id.to_string(), serde_json::Value::Object(fields));
503        }
504        let mut out = serde_json::Map::new();
505        out.insert("steps".to_string(), serde_json::Value::Object(steps));
506        serde_json::Value::Object(out)
507    }
508
509    /// Builder-style setter: attach a [`ReplayStore`] to log every
510    /// completed step's Ctx snapshot + output into.
511    pub fn with_replay_store(mut self, store: Arc<dyn ReplayStore>) -> Self {
512        self.replay_store = Some(store);
513        self
514    }
515
516    /// Builder-style setter: attach a [`ReplayCursor`] the dispatcher
517    /// consults for a hit before dispatching each step.
518    pub fn with_replay_cursor(mut self, cursor: Arc<Mutex<ReplayCursor>>) -> Self {
519        self.replay_cursor = Some(cursor);
520        self
521    }
522
523    /// Attach immutable binding digests so replay keys distinguish the same
524    /// step/input executed under different Runner/Agent/Context snapshots.
525    pub fn with_binding_digests(mut self, digests: HashMap<String, BindingDigest>) -> Self {
526        self.binding_digests = Arc::new(digests);
527        self
528    }
529
530    /// Builder-style setter: mark this dispatch as a resume / rerun-from of
531    /// an existing Run (see [`Self::resume`]). Called only by the server's
532    /// resume and rerun-from handlers; every other construction site leaves
533    /// the default `false` (initial launch).
534    pub fn with_resume(mut self) -> Self {
535        self.resume = true;
536        self
537    }
538}
539
540impl std::fmt::Debug for RunContext {
541    // `dyn RunStore` carries no `Debug` bound (backend implementations
542    // shouldn't be forced to derive it just to satisfy this struct's
543    // `Debug`); render `run_store` as its `name()` instead, same idiom as
544    // `WorkerInvocation`'s manual `Debug` for its `Arc<dyn OutputSink>`
545    // field.
546    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
547        f.debug_struct("RunContext")
548            .field("run_id", &self.run_id)
549            .field("run_store", &self.run_store.name())
550            .field(
551                "replay_store",
552                &self.replay_store.as_ref().map(|s| s.name()),
553            )
554            .field("replay_cursor", &self.replay_cursor.is_some())
555            .field("binding_digests", &self.binding_digests.len())
556            .field("resume", &self.resume)
557            .field(
558                "last_failure",
559                &self.last_failure.lock().ok().and_then(|slot| slot.clone()),
560            )
561            .field("trace", &self.trace.is_some())
562            .finish()
563    }
564}
565
566// ──────────────────────────────────────────────────────────────────────────
567// RunStore trait
568// ──────────────────────────────────────────────────────────────────────────
569
570/// Persistence interface for `Run` records — one kick of a Task, in the
571/// issue #13 ID hierarchy.
572#[async_trait]
573pub trait RunStore: Send + Sync {
574    /// Backend name — for diagnostics/logging.
575    fn name(&self) -> &str;
576
577    /// Create a new Run row. Returns `Duplicate` if `record.id` is already
578    /// stored.
579    async fn create(&self, record: RunRecord) -> Result<(), RunStoreError>;
580
581    /// Fetch a Run by id.
582    async fn get(&self, id: &RunId) -> Result<RunRecord, RunStoreError>;
583
584    /// List every Run kicked from `task_id`, ascending by `created_at`
585    /// (oldest kick first).
586    async fn list_by_task(&self, task_id: &TaskId) -> Result<Vec<RunRecord>, RunStoreError>;
587
588    /// Append one step-trace entry to a Run's `step_entries`, bumping
589    /// `updated_at` to now.
590    async fn append_step_entry(&self, id: &RunId, entry: StepEntry) -> Result<(), RunStoreError>;
591
592    /// Append one worker-reported degradation to a Run's `degradations`
593    /// (GH #32), bumping `updated_at` to now. Independent of
594    /// [`Self::append_step_entry`] — degradations never flow through step
595    /// OUTPUT/fold.
596    async fn append_degradation(
597        &self,
598        id: &RunId,
599        entry: DegradationEntry,
600    ) -> Result<(), RunStoreError>;
601
602    /// Update a Run's status, bumping `updated_at` to now.
603    async fn update_status(&self, id: &RunId, status: RunStatus) -> Result<(), RunStoreError>;
604
605    /// Atomically transition a Run's status from `from` to `to`, bumping
606    /// `updated_at` to now — the compare-and-set primitive the resume path
607    /// (`POST /v1/runs/:id/resume`) uses to guard against a double resume
608    /// racing the same `Interrupted` Run into `Running` twice.
609    ///
610    /// Returns `Ok(true)` when a row with this `id` AND current status
611    /// `from` was found and flipped to `to`; `Ok(false)` when the row's
612    /// current status was not `from` (a concurrent transition already won,
613    /// or the Run is absent). Never a hard error for the status-mismatch /
614    /// absent case — the boolean is the caller's race signal.
615    async fn try_transition(
616        &self,
617        id: &RunId,
618        from: RunStatus,
619        to: RunStatus,
620    ) -> Result<bool, RunStoreError>;
621
622    /// Set a Run's terminal `result_ref`, bumping `updated_at` to now.
623    async fn set_result(
624        &self,
625        id: &RunId,
626        result_ref: serde_json::Value,
627    ) -> Result<(), RunStoreError>;
628
629    /// Replace the opaque launch snapshot after pre-dispatch binding has
630    /// enriched it (for example with immutable `bound_agents`).
631    async fn set_input_json(&self, id: &RunId, input_json: String) -> Result<(), RunStoreError>;
632
633    /// List every Run currently `Running` (issue #35 ST2 boot sweep +
634    /// ST4 occupancy check reuse this). No ordering guarantee.
635    async fn list_running(&self) -> Result<Vec<RunRecord>, RunStoreError>;
636
637    /// List Runs matching `filter`, newest-first (`created_at`
638    /// descending) — the `GET /v1/runs` collection read.
639    async fn list(&self, filter: &RunListFilter) -> Result<Vec<RunRecord>, RunStoreError>;
640
641    /// Delete a Run row (the `DELETE /v1/runs/:id` retention operation).
642    /// The caller is responsible for pruning the sibling trace stream
643    /// ([`crate::store::trace::RunTraceStore::delete_run`]) — the two
644    /// stores are deliberately uncoupled at the trait level.
645    async fn delete(&self, id: &RunId) -> Result<(), RunStoreError>;
646}
647
648// ──────────────────────────────────────────────────────────────────────────
649// Shared inner state used by the InMemory backend.
650// ──────────────────────────────────────────────────────────────────────────
651
652#[derive(Default)]
653pub(crate) struct Inner {
654    /// Insertion order — used as a stable tie-break under `list_by_task()`.
655    pub(crate) order: Vec<RunId>,
656    pub(crate) records: HashMap<RunId, RunRecord>,
657}
658
659pub(crate) type SharedInner = Mutex<Inner>;