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}
57
58/// One worker-reported degradation entry — a worker fell back to a
59/// substitute behavior instead of failing outright (e.g. a tool call errored
60/// and the worker used a cached/default value). Independent channel from
61/// [`StepEntry`]/`result_ref`: degradations never flow through step OUTPUT
62/// or the fold path (GH #32; sibling of the GH #34 audit sidecar — both
63/// keep observational signal off the BP-chain value). Reported via `POST
64/// /v1/worker/degradation`; the server injects `step_ref`/`attempt`/`at`
65/// before persisting.
66#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
67pub struct DegradationEntry {
68    /// The tool (or capability) the worker attempted to use.
69    pub tool: String,
70    /// The error that triggered the fallback, in the worker's own words.
71    pub error: String,
72    /// What the worker substituted instead of failing.
73    pub fallback: String,
74    /// Optional free-form context from the worker.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub note: Option<String>,
77    /// The Blueprint step ref (`Step.ref`) this degradation was reported
78    /// under, if known. Server-injected metadata, not worker-supplied.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub step_ref: Option<String>,
81    /// The attempt number this degradation was reported under, if known.
82    /// Server-injected metadata, not worker-supplied.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub attempt: Option<u32>,
85    /// Unix epoch seconds — when this entry was recorded. Server-injected.
86    pub at: u64,
87}
88
89/// One entry in a Run's step trace — appended as the engine dispatches
90/// (and finishes) each step. Purely observational: no field here is
91/// consulted for flow control.
92#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
93pub struct StepEntry {
94    /// The step this entry traces.
95    #[schemars(with = "String")]
96    pub step_id: StepId,
97    /// The Blueprint step ref (`Step.ref`) that was dispatched, if known.
98    pub step_ref: Option<String>,
99    /// Free-form status label for this step at the time the entry was
100    /// recorded (e.g. `"dispatched"`, `"passed"`, `"blocked"`).
101    pub status: Option<String>,
102    /// Immutable Runner/Agent/Context snapshot digest used for this step.
103    /// `None` for rows created before BoundAgent launch wiring.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub binding_digest: Option<BindingDigest>,
106    /// Unix epoch seconds — when this entry was recorded.
107    pub at: u64,
108}
109
110/// One persisted `Run` row — one kick of a [`crate::store::task::TaskRecord`].
111#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
112pub struct RunRecord {
113    /// Run identifier.
114    #[schemars(with = "String")]
115    pub id: RunId,
116    /// The Task this Run was kicked from.
117    #[schemars(with = "String")]
118    pub task_id: TaskId,
119    /// Current lifecycle status.
120    pub status: RunStatus,
121    /// Trace of dispatched steps, in append order.
122    pub step_entries: Vec<StepEntry>,
123    /// Worker-reported degradations, in append order (GH #32). Independent
124    /// channel from [`Self::step_entries`]/[`Self::result_ref`] — see
125    /// [`DegradationEntry`]'s doc for the invariant. `[]` (the default) =
126    /// no degradations reported — every pre-#32 Run is unaffected.
127    #[serde(default, skip_serializing_if = "Vec::is_empty")]
128    pub degradations: Vec<DegradationEntry>,
129    /// Operator session id bound to this Run, if any (WS operator
130    /// correlation).
131    pub operator_sid: Option<String>,
132    /// The Run's terminal result payload, set once by
133    /// [`RunStore::set_result`]. `None` while the Run is in flight.
134    #[schemars(with = "Option<serde_json::Value>")]
135    pub result_ref: Option<serde_json::Value>,
136    /// Opaque JSON snapshot of the launch input this Run was kicked with
137    /// (blueprint / init_ctx / operator injection / ttl / …). The server
138    /// serializes its own launch-input struct into this string at Run
139    /// creation time so an `Interrupted` Run can be resumed under the SAME
140    /// `run_id` without re-deriving the input from a since-stale request
141    /// body. The store treats it as an opaque blob — the schema is owned by
142    /// the caller (the server crate). `None` = no snapshot recorded (older
143    /// rows predating resume support, or a caller that never opts in); such
144    /// a Run cannot be resumed. Additive with `#[serde(default)]` so
145    /// pre-existing serialized rows deserialize unchanged.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub input_json: Option<String>,
148    /// Unix epoch seconds — creation time.
149    pub created_at: u64,
150    /// Unix epoch seconds — last update time.
151    pub updated_at: u64,
152}
153
154/// Errors surfaced by a [`RunStore`] implementation.
155#[derive(Debug, Error)]
156pub enum RunStoreError {
157    /// No Run exists for the given id.
158    #[error("run not found: {0}")]
159    NotFound(RunId),
160
161    /// `create` was called with an id that is already stored.
162    #[error("run already exists: {0}")]
163    Duplicate(RunId),
164
165    /// Backend-specific failure not covered by the other variants.
166    #[error("other: {0}")]
167    Other(String),
168}
169
170/// The provenance of a Run snapshot's `bound_agents` array.
171///
172/// Persisted as the [`BOUND_AGENTS_ORIGIN_KEY`] sibling of `bound_agents`
173/// inside the opaque [`RunRecord::input_json`] blob. This is Run-store
174/// metadata, **not** a schema-crate Blueprint wire type: it never enters
175/// [`crate::blueprint::BoundAgent`], `BoundAgentDigestInput`, or any digest
176/// computation. It lives here beside [`RunContext`] — rather than in
177/// `crate::service::task_launch` — because both the domain launch service
178/// (which writes it) and the server crate's bindings-explain handler (which
179/// reads it) consume it, and both already depend on this module; parking it
180/// in the service module would force the server crate to reach into a
181/// service-private type.
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
183#[serde(rename_all = "snake_case")]
184pub enum SnapshotOrigin {
185    /// `bound_agents` were resolved and pinned at the Run's initial launch —
186    /// the binding identity is the launch-time pin.
187    Launch,
188    /// `bound_agents` were backfilled from the current Blueprint when a
189    /// pre-binding-snapshot Run was resumed or reran. The binding identity
190    /// carries no launch-time pin guarantee.
191    ResumeBackfill,
192}
193
194/// JSON key the [`SnapshotOrigin`] is persisted under, beside `bound_agents`,
195/// in [`RunRecord::input_json`].
196pub const BOUND_AGENTS_ORIGIN_KEY: &str = "bound_agents_origin";
197
198impl SnapshotOrigin {
199    /// Read the origin marker from a decoded launch snapshot. An absent (or
200    /// unparseable) [`BOUND_AGENTS_ORIGIN_KEY`] maps to
201    /// [`SnapshotOrigin::ResumeBackfill`] — the safe side: a snapshot whose
202    /// `bound_agents` were persisted before this marker existed cannot prove
203    /// they were pinned at launch, so it must not be reported as a launch pin
204    /// and (on the replay axis) must not have binding digests mixed into its
205    /// replay keys. Only test artifacts hit this case in practice — the
206    /// strict-binding series is unreleased, so no real snapshot predates the
207    /// marker.
208    pub fn from_snapshot(snapshot: &serde_json::Value) -> Self {
209        snapshot
210            .get(BOUND_AGENTS_ORIGIN_KEY)
211            .and_then(|v| serde_json::from_value::<SnapshotOrigin>(v.clone()).ok())
212            .unwrap_or(SnapshotOrigin::ResumeBackfill)
213    }
214}
215
216/// Pairs a [`RunId`] with the [`RunStore`] used to persist its trace.
217///
218/// Threaded from the server entry points (`POST /v1/tasks`, `POST
219/// /v1/tasks/:id/runs`) down through `TaskApplication::handle_with_run` /
220/// `TaskLaunchService::launch` / `EngineDispatcher` (issue #13 run_id
221/// propagation) so every step the dispatcher runs can be appended to
222/// `RunRecord.step_entries` and the run's id exposed to workers via
223/// `Ctx.meta.runtime["run_id"]`. Kept as a distinct type — rather than a
224/// new field on `TaskApplicationInput` — so the pre-existing exhaustive
225/// struct literal in `mlua-swarm-cli`'s MCP adapter (`TaskApplicationInput
226/// { .. }`, no `run_ctx`) keeps compiling unchanged: callers that don't
227/// care about run tracing keep calling `TaskApplication::handle` /
228/// `TaskLaunchService::launch`, which pass `None` through internally.
229#[derive(Clone)]
230pub struct RunContext {
231    /// The Run this dispatch's steps should be traced into.
232    pub run_id: RunId,
233    /// Where to append [`StepEntry`] rows as steps are dispatched.
234    pub run_store: Arc<dyn RunStore>,
235    /// Optional [`ReplayStore`] the engine will append a Ctx-snapshot +
236    /// step-output row to after every completed step (see
237    /// [`crate::store::replay`] for the primitive). `None` (the default)
238    /// disables logging entirely — pre-replay callers keep their behavior
239    /// byte-for-byte.
240    pub replay_store: Option<Arc<dyn ReplayStore>>,
241    /// Optional [`ReplayCursor`] the engine consults BEFORE dispatching
242    /// each step. When present and the cursor has a matching row for
243    /// `(step_ref, input_hash, occurrence)`, the engine returns the
244    /// stored `DispatchOutcome::Pass(value)` verbatim and skips the
245    /// Adapter spawn — this is the replay-hit path. `None` (the default)
246    /// disables replay entirely.
247    pub replay_cursor: Option<Arc<Mutex<ReplayCursor>>>,
248    /// Run-pinned replay identity component, keyed by logical agent name.
249    pub binding_digests: Arc<HashMap<String, BindingDigest>>,
250    /// Whether this dispatch is a resume / rerun-from of an existing Run
251    /// rather than an initial launch. `false` (the default) marks an initial
252    /// launch. Set to `true` ONLY by the server's resume and rerun-from
253    /// handlers — it is the sole, explicit signal that decides a backfilled
254    /// snapshot's [`SnapshotOrigin`] (never inferred from replay-cursor or
255    /// step-entry state, whose wiring is free to change).
256    pub resume: bool,
257}
258
259impl RunContext {
260    /// Construct a `RunContext` with just the RunStore wired — the same
261    /// shape all pre-replay callers use (`replay_store` / `replay_cursor`
262    /// both `None`). Preserved as a convenience so a caller that never
263    /// opts into replay can keep constructing `RunContext` positionally.
264    pub fn new(run_id: RunId, run_store: Arc<dyn RunStore>) -> Self {
265        Self {
266            run_id,
267            run_store,
268            replay_store: None,
269            replay_cursor: None,
270            binding_digests: Arc::new(HashMap::new()),
271            resume: false,
272        }
273    }
274
275    /// Builder-style setter: attach a [`ReplayStore`] to log every
276    /// completed step's Ctx snapshot + output into.
277    pub fn with_replay_store(mut self, store: Arc<dyn ReplayStore>) -> Self {
278        self.replay_store = Some(store);
279        self
280    }
281
282    /// Builder-style setter: attach a [`ReplayCursor`] the dispatcher
283    /// consults for a hit before dispatching each step.
284    pub fn with_replay_cursor(mut self, cursor: Arc<Mutex<ReplayCursor>>) -> Self {
285        self.replay_cursor = Some(cursor);
286        self
287    }
288
289    /// Attach immutable binding digests so replay keys distinguish the same
290    /// step/input executed under different Runner/Agent/Context snapshots.
291    pub fn with_binding_digests(mut self, digests: HashMap<String, BindingDigest>) -> Self {
292        self.binding_digests = Arc::new(digests);
293        self
294    }
295
296    /// Builder-style setter: mark this dispatch as a resume / rerun-from of
297    /// an existing Run (see [`Self::resume`]). Called only by the server's
298    /// resume and rerun-from handlers; every other construction site leaves
299    /// the default `false` (initial launch).
300    pub fn with_resume(mut self) -> Self {
301        self.resume = true;
302        self
303    }
304}
305
306impl std::fmt::Debug for RunContext {
307    // `dyn RunStore` carries no `Debug` bound (backend implementations
308    // shouldn't be forced to derive it just to satisfy this struct's
309    // `Debug`); render `run_store` as its `name()` instead, same idiom as
310    // `WorkerInvocation`'s manual `Debug` for its `Arc<dyn OutputSink>`
311    // field.
312    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
313        f.debug_struct("RunContext")
314            .field("run_id", &self.run_id)
315            .field("run_store", &self.run_store.name())
316            .field(
317                "replay_store",
318                &self.replay_store.as_ref().map(|s| s.name()),
319            )
320            .field("replay_cursor", &self.replay_cursor.is_some())
321            .field("binding_digests", &self.binding_digests.len())
322            .field("resume", &self.resume)
323            .finish()
324    }
325}
326
327// ──────────────────────────────────────────────────────────────────────────
328// RunStore trait
329// ──────────────────────────────────────────────────────────────────────────
330
331/// Persistence interface for `Run` records — one kick of a Task, in the
332/// issue #13 ID hierarchy.
333#[async_trait]
334pub trait RunStore: Send + Sync {
335    /// Backend name — for diagnostics/logging.
336    fn name(&self) -> &str;
337
338    /// Create a new Run row. Returns `Duplicate` if `record.id` is already
339    /// stored.
340    async fn create(&self, record: RunRecord) -> Result<(), RunStoreError>;
341
342    /// Fetch a Run by id.
343    async fn get(&self, id: &RunId) -> Result<RunRecord, RunStoreError>;
344
345    /// List every Run kicked from `task_id`, ascending by `created_at`
346    /// (oldest kick first).
347    async fn list_by_task(&self, task_id: &TaskId) -> Result<Vec<RunRecord>, RunStoreError>;
348
349    /// Append one step-trace entry to a Run's `step_entries`, bumping
350    /// `updated_at` to now.
351    async fn append_step_entry(&self, id: &RunId, entry: StepEntry) -> Result<(), RunStoreError>;
352
353    /// Append one worker-reported degradation to a Run's `degradations`
354    /// (GH #32), bumping `updated_at` to now. Independent of
355    /// [`Self::append_step_entry`] — degradations never flow through step
356    /// OUTPUT/fold.
357    async fn append_degradation(
358        &self,
359        id: &RunId,
360        entry: DegradationEntry,
361    ) -> Result<(), RunStoreError>;
362
363    /// Update a Run's status, bumping `updated_at` to now.
364    async fn update_status(&self, id: &RunId, status: RunStatus) -> Result<(), RunStoreError>;
365
366    /// Atomically transition a Run's status from `from` to `to`, bumping
367    /// `updated_at` to now — the compare-and-set primitive the resume path
368    /// (`POST /v1/runs/:id/resume`) uses to guard against a double resume
369    /// racing the same `Interrupted` Run into `Running` twice.
370    ///
371    /// Returns `Ok(true)` when a row with this `id` AND current status
372    /// `from` was found and flipped to `to`; `Ok(false)` when the row's
373    /// current status was not `from` (a concurrent transition already won,
374    /// or the Run is absent). Never a hard error for the status-mismatch /
375    /// absent case — the boolean is the caller's race signal.
376    async fn try_transition(
377        &self,
378        id: &RunId,
379        from: RunStatus,
380        to: RunStatus,
381    ) -> Result<bool, RunStoreError>;
382
383    /// Set a Run's terminal `result_ref`, bumping `updated_at` to now.
384    async fn set_result(
385        &self,
386        id: &RunId,
387        result_ref: serde_json::Value,
388    ) -> Result<(), RunStoreError>;
389
390    /// Replace the opaque launch snapshot after pre-dispatch binding has
391    /// enriched it (for example with immutable `bound_agents`).
392    async fn set_input_json(&self, id: &RunId, input_json: String) -> Result<(), RunStoreError>;
393
394    /// List every Run currently `Running` (issue #35 ST2 boot sweep +
395    /// ST4 occupancy check reuse this). No ordering guarantee.
396    async fn list_running(&self) -> Result<Vec<RunRecord>, RunStoreError>;
397}
398
399// ──────────────────────────────────────────────────────────────────────────
400// Shared inner state used by the InMemory backend.
401// ──────────────────────────────────────────────────────────────────────────
402
403#[derive(Default)]
404pub(crate) struct Inner {
405    /// Insertion order — used as a stable tie-break under `list_by_task()`.
406    pub(crate) order: Vec<RunId>,
407    pub(crate) records: HashMap<RunId, RunRecord>,
408}
409
410pub(crate) type SharedInner = Mutex<Inner>;