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::types::{RunId, StepId, TaskId};
21use async_trait::async_trait;
22use serde::{Deserialize, Serialize};
23use std::collections::HashMap;
24use std::sync::{Arc, Mutex};
25use thiserror::Error;
26
27pub mod inmemory;
28pub mod sqlite;
29pub use inmemory::InMemoryRunStore;
30pub use sqlite::SqliteRunStore;
31
32// ──────────────────────────────────────────────────────────────────────────
33// RunStatus / StepEntry / RunRecord
34// ──────────────────────────────────────────────────────────────────────────
35
36/// Lifecycle status of a [`RunRecord`] — the outcome of one specific kick
37/// of a Task.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
39#[serde(rename_all = "snake_case")]
40pub enum RunStatus {
41 /// Minted, not yet dispatched.
42 Pending,
43 /// Steps are currently being dispatched for this Run.
44 Running,
45 /// The Run completed successfully.
46 Done,
47 /// The Run failed.
48 Failed,
49 /// The Run was still `Running` when the server process restarted
50 /// (issue #35 ST2 boot-time recovery sweep). Terminal — in-flight
51 /// `EngineState` is process-local and unrecoverable; this variant
52 /// records the fact without attempting to reconstruct or resume it.
53 Interrupted,
54}
55
56/// One worker-reported degradation entry — a worker fell back to a
57/// substitute behavior instead of failing outright (e.g. a tool call errored
58/// and the worker used a cached/default value). Independent channel from
59/// [`StepEntry`]/`result_ref`: degradations never flow through step OUTPUT
60/// or the fold path (GH #32; sibling of the GH #34 audit sidecar — both
61/// keep observational signal off the BP-chain value). Reported via `POST
62/// /v1/worker/degradation`; the server injects `step_ref`/`attempt`/`at`
63/// before persisting.
64#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
65pub struct DegradationEntry {
66 /// The tool (or capability) the worker attempted to use.
67 pub tool: String,
68 /// The error that triggered the fallback, in the worker's own words.
69 pub error: String,
70 /// What the worker substituted instead of failing.
71 pub fallback: String,
72 /// Optional free-form context from the worker.
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub note: Option<String>,
75 /// The Blueprint step ref (`Step.ref`) this degradation was reported
76 /// under, if known. Server-injected metadata, not worker-supplied.
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub step_ref: Option<String>,
79 /// The attempt number this degradation was reported under, if known.
80 /// Server-injected metadata, not worker-supplied.
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub attempt: Option<u32>,
83 /// Unix epoch seconds — when this entry was recorded. Server-injected.
84 pub at: u64,
85}
86
87/// One entry in a Run's step trace — appended as the engine dispatches
88/// (and finishes) each step. Purely observational: no field here is
89/// consulted for flow control.
90#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
91pub struct StepEntry {
92 /// The step this entry traces.
93 #[schemars(with = "String")]
94 pub step_id: StepId,
95 /// The Blueprint step ref (`Step.ref`) that was dispatched, if known.
96 pub step_ref: Option<String>,
97 /// Free-form status label for this step at the time the entry was
98 /// recorded (e.g. `"dispatched"`, `"passed"`, `"blocked"`).
99 pub status: Option<String>,
100 /// Unix epoch seconds — when this entry was recorded.
101 pub at: u64,
102}
103
104/// One persisted `Run` row — one kick of a [`crate::store::task::TaskRecord`].
105#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
106pub struct RunRecord {
107 /// Run identifier.
108 #[schemars(with = "String")]
109 pub id: RunId,
110 /// The Task this Run was kicked from.
111 #[schemars(with = "String")]
112 pub task_id: TaskId,
113 /// Current lifecycle status.
114 pub status: RunStatus,
115 /// Trace of dispatched steps, in append order.
116 pub step_entries: Vec<StepEntry>,
117 /// Worker-reported degradations, in append order (GH #32). Independent
118 /// channel from [`Self::step_entries`]/[`Self::result_ref`] — see
119 /// [`DegradationEntry`]'s doc for the invariant. `[]` (the default) =
120 /// no degradations reported — every pre-#32 Run is unaffected.
121 #[serde(default, skip_serializing_if = "Vec::is_empty")]
122 pub degradations: Vec<DegradationEntry>,
123 /// Operator session id bound to this Run, if any (WS operator
124 /// correlation).
125 pub operator_sid: Option<String>,
126 /// The Run's terminal result payload, set once by
127 /// [`RunStore::set_result`]. `None` while the Run is in flight.
128 #[schemars(with = "Option<serde_json::Value>")]
129 pub result_ref: Option<serde_json::Value>,
130 /// Unix epoch seconds — creation time.
131 pub created_at: u64,
132 /// Unix epoch seconds — last update time.
133 pub updated_at: u64,
134}
135
136/// Errors surfaced by a [`RunStore`] implementation.
137#[derive(Debug, Error)]
138pub enum RunStoreError {
139 /// No Run exists for the given id.
140 #[error("run not found: {0}")]
141 NotFound(RunId),
142
143 /// `create` was called with an id that is already stored.
144 #[error("run already exists: {0}")]
145 Duplicate(RunId),
146
147 /// Backend-specific failure not covered by the other variants.
148 #[error("other: {0}")]
149 Other(String),
150}
151
152/// Pairs a [`RunId`] with the [`RunStore`] used to persist its trace.
153///
154/// Threaded from the server entry points (`POST /v1/tasks`, `POST
155/// /v1/tasks/:id/runs`) down through `TaskApplication::handle_with_run` /
156/// `TaskLaunchService::launch` / `EngineDispatcher` (issue #13 run_id
157/// propagation) so every step the dispatcher runs can be appended to
158/// `RunRecord.step_entries` and the run's id exposed to workers via
159/// `Ctx.meta.runtime["run_id"]`. Kept as a distinct type — rather than a
160/// new field on `TaskApplicationInput` — so the pre-existing exhaustive
161/// struct literal in `mlua-swarm-cli`'s MCP adapter (`TaskApplicationInput
162/// { .. }`, no `run_ctx`) keeps compiling unchanged: callers that don't
163/// care about run tracing keep calling `TaskApplication::handle` /
164/// `TaskLaunchService::launch`, which pass `None` through internally.
165#[derive(Clone)]
166pub struct RunContext {
167 /// The Run this dispatch's steps should be traced into.
168 pub run_id: RunId,
169 /// Where to append [`StepEntry`] rows as steps are dispatched.
170 pub run_store: Arc<dyn RunStore>,
171}
172
173impl std::fmt::Debug for RunContext {
174 // `dyn RunStore` carries no `Debug` bound (backend implementations
175 // shouldn't be forced to derive it just to satisfy this struct's
176 // `Debug`); render `run_store` as its `name()` instead, same idiom as
177 // `WorkerInvocation`'s manual `Debug` for its `Arc<dyn OutputSink>`
178 // field.
179 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180 f.debug_struct("RunContext")
181 .field("run_id", &self.run_id)
182 .field("run_store", &self.run_store.name())
183 .finish()
184 }
185}
186
187// ──────────────────────────────────────────────────────────────────────────
188// RunStore trait
189// ──────────────────────────────────────────────────────────────────────────
190
191/// Persistence interface for `Run` records — one kick of a Task, in the
192/// issue #13 ID hierarchy.
193#[async_trait]
194pub trait RunStore: Send + Sync {
195 /// Backend name — for diagnostics/logging.
196 fn name(&self) -> &str;
197
198 /// Create a new Run row. Returns `Duplicate` if `record.id` is already
199 /// stored.
200 async fn create(&self, record: RunRecord) -> Result<(), RunStoreError>;
201
202 /// Fetch a Run by id.
203 async fn get(&self, id: &RunId) -> Result<RunRecord, RunStoreError>;
204
205 /// List every Run kicked from `task_id`, ascending by `created_at`
206 /// (oldest kick first).
207 async fn list_by_task(&self, task_id: &TaskId) -> Result<Vec<RunRecord>, RunStoreError>;
208
209 /// Append one step-trace entry to a Run's `step_entries`, bumping
210 /// `updated_at` to now.
211 async fn append_step_entry(&self, id: &RunId, entry: StepEntry) -> Result<(), RunStoreError>;
212
213 /// Append one worker-reported degradation to a Run's `degradations`
214 /// (GH #32), bumping `updated_at` to now. Independent of
215 /// [`Self::append_step_entry`] — degradations never flow through step
216 /// OUTPUT/fold.
217 async fn append_degradation(
218 &self,
219 id: &RunId,
220 entry: DegradationEntry,
221 ) -> Result<(), RunStoreError>;
222
223 /// Update a Run's status, bumping `updated_at` to now.
224 async fn update_status(&self, id: &RunId, status: RunStatus) -> Result<(), RunStoreError>;
225
226 /// Set a Run's terminal `result_ref`, bumping `updated_at` to now.
227 async fn set_result(
228 &self,
229 id: &RunId,
230 result_ref: serde_json::Value,
231 ) -> Result<(), RunStoreError>;
232
233 /// List every Run currently `Running` (issue #35 ST2 boot sweep +
234 /// ST4 occupancy check reuse this). No ordering guarantee.
235 async fn list_running(&self) -> Result<Vec<RunRecord>, RunStoreError>;
236}
237
238// ──────────────────────────────────────────────────────────────────────────
239// Shared inner state used by the InMemory backend.
240// ──────────────────────────────────────────────────────────────────────────
241
242#[derive(Default)]
243pub(crate) struct Inner {
244 /// Insertion order — used as a stable tie-break under `list_by_task()`.
245 pub(crate) order: Vec<RunId>,
246 pub(crate) records: HashMap<RunId, RunRecord>,
247}
248
249pub(crate) type SharedInner = Mutex<Inner>;