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::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)]
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}
50
51/// One entry in a Run's step trace — appended as the engine dispatches
52/// (and finishes) each step. Purely observational: no field here is
53/// consulted for flow control.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct StepEntry {
56    /// The step this entry traces.
57    pub step_id: StepId,
58    /// The Blueprint step ref (`Step.ref`) that was dispatched, if known.
59    pub step_ref: Option<String>,
60    /// Free-form status label for this step at the time the entry was
61    /// recorded (e.g. `"dispatched"`, `"passed"`, `"blocked"`).
62    pub status: Option<String>,
63    /// Unix epoch seconds — when this entry was recorded.
64    pub at: u64,
65}
66
67/// One persisted `Run` row — one kick of a [`crate::store::task::TaskRecord`].
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct RunRecord {
70    /// Run identifier.
71    pub id: RunId,
72    /// The Task this Run was kicked from.
73    pub task_id: TaskId,
74    /// Current lifecycle status.
75    pub status: RunStatus,
76    /// Trace of dispatched steps, in append order.
77    pub step_entries: Vec<StepEntry>,
78    /// Operator session id bound to this Run, if any (WS operator
79    /// correlation).
80    pub operator_sid: Option<String>,
81    /// The Run's terminal result payload, set once by
82    /// [`RunStore::set_result`]. `None` while the Run is in flight.
83    pub result_ref: Option<serde_json::Value>,
84    /// Unix epoch seconds — creation time.
85    pub created_at: u64,
86    /// Unix epoch seconds — last update time.
87    pub updated_at: u64,
88}
89
90/// Errors surfaced by a [`RunStore`] implementation.
91#[derive(Debug, Error)]
92pub enum RunStoreError {
93    /// No Run exists for the given id.
94    #[error("run not found: {0}")]
95    NotFound(RunId),
96
97    /// `create` was called with an id that is already stored.
98    #[error("run already exists: {0}")]
99    Duplicate(RunId),
100
101    /// Backend-specific failure not covered by the other variants.
102    #[error("other: {0}")]
103    Other(String),
104}
105
106/// Pairs a [`RunId`] with the [`RunStore`] used to persist its trace.
107///
108/// Threaded from the server entry points (`POST /v1/tasks`, `POST
109/// /v1/tasks/:id/runs`) down through `TaskApplication::handle_with_run` /
110/// `TaskLaunchService::launch` / `EngineDispatcher` (issue #13 run_id
111/// propagation) so every step the dispatcher runs can be appended to
112/// `RunRecord.step_entries` and the run's id exposed to workers via
113/// `Ctx.meta.runtime["run_id"]`. Kept as a distinct type — rather than a
114/// new field on `TaskApplicationInput` — so the pre-existing exhaustive
115/// struct literal in `mlua-swarm-cli`'s MCP adapter (`TaskApplicationInput
116/// { .. }`, no `run_ctx`) keeps compiling unchanged: callers that don't
117/// care about run tracing keep calling `TaskApplication::handle` /
118/// `TaskLaunchService::launch`, which pass `None` through internally.
119#[derive(Clone)]
120pub struct RunContext {
121    /// The Run this dispatch's steps should be traced into.
122    pub run_id: RunId,
123    /// Where to append [`StepEntry`] rows as steps are dispatched.
124    pub run_store: Arc<dyn RunStore>,
125}
126
127impl std::fmt::Debug for RunContext {
128    // `dyn RunStore` carries no `Debug` bound (backend implementations
129    // shouldn't be forced to derive it just to satisfy this struct's
130    // `Debug`); render `run_store` as its `name()` instead, same idiom as
131    // `WorkerInvocation`'s manual `Debug` for its `Arc<dyn OutputSink>`
132    // field.
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        f.debug_struct("RunContext")
135            .field("run_id", &self.run_id)
136            .field("run_store", &self.run_store.name())
137            .finish()
138    }
139}
140
141// ──────────────────────────────────────────────────────────────────────────
142// RunStore trait
143// ──────────────────────────────────────────────────────────────────────────
144
145/// Persistence interface for `Run` records — one kick of a Task, in the
146/// issue #13 ID hierarchy.
147#[async_trait]
148pub trait RunStore: Send + Sync {
149    /// Backend name — for diagnostics/logging.
150    fn name(&self) -> &str;
151
152    /// Create a new Run row. Returns `Duplicate` if `record.id` is already
153    /// stored.
154    async fn create(&self, record: RunRecord) -> Result<(), RunStoreError>;
155
156    /// Fetch a Run by id.
157    async fn get(&self, id: &RunId) -> Result<RunRecord, RunStoreError>;
158
159    /// List every Run kicked from `task_id`, ascending by `created_at`
160    /// (oldest kick first).
161    async fn list_by_task(&self, task_id: &TaskId) -> Result<Vec<RunRecord>, RunStoreError>;
162
163    /// Append one step-trace entry to a Run's `step_entries`, bumping
164    /// `updated_at` to now.
165    async fn append_step_entry(&self, id: &RunId, entry: StepEntry) -> Result<(), RunStoreError>;
166
167    /// Update a Run's status, bumping `updated_at` to now.
168    async fn update_status(&self, id: &RunId, status: RunStatus) -> Result<(), RunStoreError>;
169
170    /// Set a Run's terminal `result_ref`, bumping `updated_at` to now.
171    async fn set_result(
172        &self,
173        id: &RunId,
174        result_ref: serde_json::Value,
175    ) -> Result<(), RunStoreError>;
176}
177
178// ──────────────────────────────────────────────────────────────────────────
179// Shared inner state used by the InMemory backend.
180// ──────────────────────────────────────────────────────────────────────────
181
182#[derive(Default)]
183pub(crate) struct Inner {
184    /// Insertion order — used as a stable tie-break under `list_by_task()`.
185    pub(crate) order: Vec<RunId>,
186    pub(crate) records: HashMap<RunId, RunRecord>,
187}
188
189pub(crate) type SharedInner = Mutex<Inner>;