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::store::replay::{ReplayCursor, ReplayStore};
21use crate::types::{RunId, StepId, TaskId};
22use async_trait::async_trait;
23use serde::{Deserialize, Serialize};
24use std::collections::HashMap;
25use std::sync::{Arc, Mutex};
26use thiserror::Error;
27
28pub mod inmemory;
29pub mod sqlite;
30pub use inmemory::InMemoryRunStore;
31pub use sqlite::SqliteRunStore;
32
33// ──────────────────────────────────────────────────────────────────────────
34// RunStatus / StepEntry / RunRecord
35// ──────────────────────────────────────────────────────────────────────────
36
37/// Lifecycle status of a [`RunRecord`] — the outcome of one specific kick
38/// of a Task.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
40#[serde(rename_all = "snake_case")]
41pub enum RunStatus {
42    /// Minted, not yet dispatched.
43    Pending,
44    /// Steps are currently being dispatched for this Run.
45    Running,
46    /// The Run completed successfully.
47    Done,
48    /// The Run failed.
49    Failed,
50    /// The Run was still `Running` when the server process restarted
51    /// (issue #35 ST2 boot-time recovery sweep). Terminal — in-flight
52    /// `EngineState` is process-local and unrecoverable; this variant
53    /// records the fact without attempting to reconstruct or resume it.
54    Interrupted,
55}
56
57/// One worker-reported degradation entry — a worker fell back to a
58/// substitute behavior instead of failing outright (e.g. a tool call errored
59/// and the worker used a cached/default value). Independent channel from
60/// [`StepEntry`]/`result_ref`: degradations never flow through step OUTPUT
61/// or the fold path (GH #32; sibling of the GH #34 audit sidecar — both
62/// keep observational signal off the BP-chain value). Reported via `POST
63/// /v1/worker/degradation`; the server injects `step_ref`/`attempt`/`at`
64/// before persisting.
65#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
66pub struct DegradationEntry {
67    /// The tool (or capability) the worker attempted to use.
68    pub tool: String,
69    /// The error that triggered the fallback, in the worker's own words.
70    pub error: String,
71    /// What the worker substituted instead of failing.
72    pub fallback: String,
73    /// Optional free-form context from the worker.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub note: Option<String>,
76    /// The Blueprint step ref (`Step.ref`) this degradation was reported
77    /// under, if known. Server-injected metadata, not worker-supplied.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub step_ref: Option<String>,
80    /// The attempt number this degradation was reported under, if known.
81    /// Server-injected metadata, not worker-supplied.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub attempt: Option<u32>,
84    /// Unix epoch seconds — when this entry was recorded. Server-injected.
85    pub at: u64,
86}
87
88/// One entry in a Run's step trace — appended as the engine dispatches
89/// (and finishes) each step. Purely observational: no field here is
90/// consulted for flow control.
91#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
92pub struct StepEntry {
93    /// The step this entry traces.
94    #[schemars(with = "String")]
95    pub step_id: StepId,
96    /// The Blueprint step ref (`Step.ref`) that was dispatched, if known.
97    pub step_ref: Option<String>,
98    /// Free-form status label for this step at the time the entry was
99    /// recorded (e.g. `"dispatched"`, `"passed"`, `"blocked"`).
100    pub status: Option<String>,
101    /// Unix epoch seconds — when this entry was recorded.
102    pub at: u64,
103}
104
105/// One persisted `Run` row — one kick of a [`crate::store::task::TaskRecord`].
106#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
107pub struct RunRecord {
108    /// Run identifier.
109    #[schemars(with = "String")]
110    pub id: RunId,
111    /// The Task this Run was kicked from.
112    #[schemars(with = "String")]
113    pub task_id: TaskId,
114    /// Current lifecycle status.
115    pub status: RunStatus,
116    /// Trace of dispatched steps, in append order.
117    pub step_entries: Vec<StepEntry>,
118    /// Worker-reported degradations, in append order (GH #32). Independent
119    /// channel from [`Self::step_entries`]/[`Self::result_ref`] — see
120    /// [`DegradationEntry`]'s doc for the invariant. `[]` (the default) =
121    /// no degradations reported — every pre-#32 Run is unaffected.
122    #[serde(default, skip_serializing_if = "Vec::is_empty")]
123    pub degradations: Vec<DegradationEntry>,
124    /// Operator session id bound to this Run, if any (WS operator
125    /// correlation).
126    pub operator_sid: Option<String>,
127    /// The Run's terminal result payload, set once by
128    /// [`RunStore::set_result`]. `None` while the Run is in flight.
129    #[schemars(with = "Option<serde_json::Value>")]
130    pub result_ref: Option<serde_json::Value>,
131    /// Opaque JSON snapshot of the launch input this Run was kicked with
132    /// (blueprint / init_ctx / operator injection / ttl / …). The server
133    /// serializes its own launch-input struct into this string at Run
134    /// creation time so an `Interrupted` Run can be resumed under the SAME
135    /// `run_id` without re-deriving the input from a since-stale request
136    /// body. The store treats it as an opaque blob — the schema is owned by
137    /// the caller (the server crate). `None` = no snapshot recorded (older
138    /// rows predating resume support, or a caller that never opts in); such
139    /// a Run cannot be resumed. Additive with `#[serde(default)]` so
140    /// pre-existing serialized rows deserialize unchanged.
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub input_json: Option<String>,
143    /// Unix epoch seconds — creation time.
144    pub created_at: u64,
145    /// Unix epoch seconds — last update time.
146    pub updated_at: u64,
147}
148
149/// Errors surfaced by a [`RunStore`] implementation.
150#[derive(Debug, Error)]
151pub enum RunStoreError {
152    /// No Run exists for the given id.
153    #[error("run not found: {0}")]
154    NotFound(RunId),
155
156    /// `create` was called with an id that is already stored.
157    #[error("run already exists: {0}")]
158    Duplicate(RunId),
159
160    /// Backend-specific failure not covered by the other variants.
161    #[error("other: {0}")]
162    Other(String),
163}
164
165/// Pairs a [`RunId`] with the [`RunStore`] used to persist its trace.
166///
167/// Threaded from the server entry points (`POST /v1/tasks`, `POST
168/// /v1/tasks/:id/runs`) down through `TaskApplication::handle_with_run` /
169/// `TaskLaunchService::launch` / `EngineDispatcher` (issue #13 run_id
170/// propagation) so every step the dispatcher runs can be appended to
171/// `RunRecord.step_entries` and the run's id exposed to workers via
172/// `Ctx.meta.runtime["run_id"]`. Kept as a distinct type — rather than a
173/// new field on `TaskApplicationInput` — so the pre-existing exhaustive
174/// struct literal in `mlua-swarm-cli`'s MCP adapter (`TaskApplicationInput
175/// { .. }`, no `run_ctx`) keeps compiling unchanged: callers that don't
176/// care about run tracing keep calling `TaskApplication::handle` /
177/// `TaskLaunchService::launch`, which pass `None` through internally.
178#[derive(Clone)]
179pub struct RunContext {
180    /// The Run this dispatch's steps should be traced into.
181    pub run_id: RunId,
182    /// Where to append [`StepEntry`] rows as steps are dispatched.
183    pub run_store: Arc<dyn RunStore>,
184    /// Optional [`ReplayStore`] the engine will append a Ctx-snapshot +
185    /// step-output row to after every completed step (see
186    /// [`crate::store::replay`] for the primitive). `None` (the default)
187    /// disables logging entirely — pre-replay callers keep their behavior
188    /// byte-for-byte.
189    pub replay_store: Option<Arc<dyn ReplayStore>>,
190    /// Optional [`ReplayCursor`] the engine consults BEFORE dispatching
191    /// each step. When present and the cursor has a matching row for
192    /// `(step_ref, input_hash, occurrence)`, the engine returns the
193    /// stored `DispatchOutcome::Pass(value)` verbatim and skips the
194    /// Adapter spawn — this is the replay-hit path. `None` (the default)
195    /// disables replay entirely.
196    pub replay_cursor: Option<Arc<Mutex<ReplayCursor>>>,
197}
198
199impl RunContext {
200    /// Construct a `RunContext` with just the RunStore wired — the same
201    /// shape all pre-replay callers use (`replay_store` / `replay_cursor`
202    /// both `None`). Preserved as a convenience so a caller that never
203    /// opts into replay can keep constructing `RunContext` positionally.
204    pub fn new(run_id: RunId, run_store: Arc<dyn RunStore>) -> Self {
205        Self {
206            run_id,
207            run_store,
208            replay_store: None,
209            replay_cursor: None,
210        }
211    }
212
213    /// Builder-style setter: attach a [`ReplayStore`] to log every
214    /// completed step's Ctx snapshot + output into.
215    pub fn with_replay_store(mut self, store: Arc<dyn ReplayStore>) -> Self {
216        self.replay_store = Some(store);
217        self
218    }
219
220    /// Builder-style setter: attach a [`ReplayCursor`] the dispatcher
221    /// consults for a hit before dispatching each step.
222    pub fn with_replay_cursor(mut self, cursor: Arc<Mutex<ReplayCursor>>) -> Self {
223        self.replay_cursor = Some(cursor);
224        self
225    }
226}
227
228impl std::fmt::Debug for RunContext {
229    // `dyn RunStore` carries no `Debug` bound (backend implementations
230    // shouldn't be forced to derive it just to satisfy this struct's
231    // `Debug`); render `run_store` as its `name()` instead, same idiom as
232    // `WorkerInvocation`'s manual `Debug` for its `Arc<dyn OutputSink>`
233    // field.
234    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235        f.debug_struct("RunContext")
236            .field("run_id", &self.run_id)
237            .field("run_store", &self.run_store.name())
238            .field(
239                "replay_store",
240                &self.replay_store.as_ref().map(|s| s.name()),
241            )
242            .field("replay_cursor", &self.replay_cursor.is_some())
243            .finish()
244    }
245}
246
247// ──────────────────────────────────────────────────────────────────────────
248// RunStore trait
249// ──────────────────────────────────────────────────────────────────────────
250
251/// Persistence interface for `Run` records — one kick of a Task, in the
252/// issue #13 ID hierarchy.
253#[async_trait]
254pub trait RunStore: Send + Sync {
255    /// Backend name — for diagnostics/logging.
256    fn name(&self) -> &str;
257
258    /// Create a new Run row. Returns `Duplicate` if `record.id` is already
259    /// stored.
260    async fn create(&self, record: RunRecord) -> Result<(), RunStoreError>;
261
262    /// Fetch a Run by id.
263    async fn get(&self, id: &RunId) -> Result<RunRecord, RunStoreError>;
264
265    /// List every Run kicked from `task_id`, ascending by `created_at`
266    /// (oldest kick first).
267    async fn list_by_task(&self, task_id: &TaskId) -> Result<Vec<RunRecord>, RunStoreError>;
268
269    /// Append one step-trace entry to a Run's `step_entries`, bumping
270    /// `updated_at` to now.
271    async fn append_step_entry(&self, id: &RunId, entry: StepEntry) -> Result<(), RunStoreError>;
272
273    /// Append one worker-reported degradation to a Run's `degradations`
274    /// (GH #32), bumping `updated_at` to now. Independent of
275    /// [`Self::append_step_entry`] — degradations never flow through step
276    /// OUTPUT/fold.
277    async fn append_degradation(
278        &self,
279        id: &RunId,
280        entry: DegradationEntry,
281    ) -> Result<(), RunStoreError>;
282
283    /// Update a Run's status, bumping `updated_at` to now.
284    async fn update_status(&self, id: &RunId, status: RunStatus) -> Result<(), RunStoreError>;
285
286    /// Atomically transition a Run's status from `from` to `to`, bumping
287    /// `updated_at` to now — the compare-and-set primitive the resume path
288    /// (`POST /v1/runs/:id/resume`) uses to guard against a double resume
289    /// racing the same `Interrupted` Run into `Running` twice.
290    ///
291    /// Returns `Ok(true)` when a row with this `id` AND current status
292    /// `from` was found and flipped to `to`; `Ok(false)` when the row's
293    /// current status was not `from` (a concurrent transition already won,
294    /// or the Run is absent). Never a hard error for the status-mismatch /
295    /// absent case — the boolean is the caller's race signal.
296    async fn try_transition(
297        &self,
298        id: &RunId,
299        from: RunStatus,
300        to: RunStatus,
301    ) -> Result<bool, RunStoreError>;
302
303    /// Set a Run's terminal `result_ref`, bumping `updated_at` to now.
304    async fn set_result(
305        &self,
306        id: &RunId,
307        result_ref: serde_json::Value,
308    ) -> Result<(), RunStoreError>;
309
310    /// List every Run currently `Running` (issue #35 ST2 boot sweep +
311    /// ST4 occupancy check reuse this). No ordering guarantee.
312    async fn list_running(&self) -> Result<Vec<RunRecord>, RunStoreError>;
313}
314
315// ──────────────────────────────────────────────────────────────────────────
316// Shared inner state used by the InMemory backend.
317// ──────────────────────────────────────────────────────────────────────────
318
319#[derive(Default)]
320pub(crate) struct Inner {
321    /// Insertion order — used as a stable tie-break under `list_by_task()`.
322    pub(crate) order: Vec<RunId>,
323    pub(crate) records: HashMap<RunId, RunRecord>,
324}
325
326pub(crate) type SharedInner = Mutex<Inner>;