Skip to main content

mlua_swarm/store/task/
mod.rs

1//! `TaskStore` — persistence for `Task` records.
2//!
3//! Part of the issue #13 ID-hierarchy reconciliation: Blueprint -> Task ->
4//! Run -> Step -> Attempt. A `Task` is the **work-item identity**: one row
5//! per unit of work ("resolve issue #10" + a Blueprint ref snapshot + an
6//! input ctx), created once when the work is submitted (e.g.
7//! `POST /v1/tasks`). A single Task can be kicked N times; each kick mints
8//! a [`RunId`](crate::types::RunId) and is tracked by the sibling
9//! [`crate::store::run`] store — this module owns only the 1-row-per-Task
10//! identity and its coarse lifecycle status.
11//!
12//! Current scope:
13//!
14//! - [`InMemoryTaskStore`] — process-volatile default.
15//! - [`SqliteTaskStore`] — file-backed persistence via `rusqlite-isle`
16//!   (thread-isolated `Connection`, single-writer FIFO discipline; same
17//!   shape as [`crate::store::issue::sqlite::SqliteIssueStore`]).
18//! - Other persistent backends (Git / mini-app / …) are future carries.
19
20use crate::types::TaskId;
21use async_trait::async_trait;
22use serde::{Deserialize, Serialize};
23use std::collections::HashMap;
24use std::sync::Mutex;
25use thiserror::Error;
26
27pub mod inmemory;
28pub mod sqlite;
29pub use inmemory::InMemoryTaskStore;
30pub use sqlite::SqliteTaskStore;
31
32// ──────────────────────────────────────────────────────────────────────────
33// TaskRecordStatus / TaskRecord
34// ──────────────────────────────────────────────────────────────────────────
35
36/// Lifecycle status of a [`TaskRecord`].
37///
38/// Coarser than [`crate::store::run::RunStatus`]: a Task's status tracks
39/// "is there work in flight / did the most recent kick finish", while a
40/// Run's status tracks one specific kick's own outcome.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
42#[serde(rename_all = "snake_case")]
43pub enum TaskRecordStatus {
44    /// Created, no [`crate::store::run::RunRecord`] started yet.
45    Pending,
46    /// A Run is currently in flight for this Task.
47    Running,
48    /// The Task's most recent Run completed successfully.
49    Done,
50    /// The Task's most recent Run failed.
51    Failed,
52    /// The Task's most recent Run was still `Running` when the server
53    /// process restarted (issue #35 ST2 boot-time recovery sweep).
54    /// Terminal — in-flight `EngineState` is process-local and
55    /// unrecoverable; this variant records the fact without attempting to
56    /// reconstruct or resume it.
57    Interrupted,
58}
59
60/// One persisted `Task` row — the work-item identity.
61#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
62pub struct TaskRecord {
63    /// Task identifier.
64    #[schemars(with = "String")]
65    pub id: TaskId,
66    /// Human-facing goal / description (e.g. "resolve issue #10").
67    pub goal: String,
68    /// Snapshot of the Blueprint selector supplied at creation (the
69    /// `POST /v1/tasks` body's `BlueprintSelector`). Kept as a bare
70    /// `serde_json::Value` so the store layer does not depend on the
71    /// selector's Rust type — callers decode/encode at the API boundary.
72    #[schemars(with = "serde_json::Value")]
73    pub blueprint_ref: serde_json::Value,
74    /// Input context supplied at task creation.
75    #[schemars(with = "serde_json::Value")]
76    pub input_ctx: serde_json::Value,
77    /// Issue #19 ST4: Task-level canonical fields (`project_root` /
78    /// `work_dir` / `task_metadata`) snapshot for rekick, stored as JSON
79    /// (a serialized `TaskInputSpec`) — same "bare `Value`, no Rust-type
80    /// dependency" rationale as [`Self::blueprint_ref`] /
81    /// [`Self::input_ctx`]. `None` for every pre-#19 `TaskRecord`
82    /// (backward compat) and for callers whose request carried no
83    /// Task-level fields at all.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    #[schemars(with = "Option<serde_json::Value>")]
86    pub task_input_spec: Option<serde_json::Value>,
87    /// Current lifecycle status.
88    pub status: TaskRecordStatus,
89    /// Unix epoch seconds — creation time.
90    pub created_at: u64,
91    /// Unix epoch seconds — last update time.
92    pub updated_at: u64,
93}
94
95/// Errors surfaced by a [`TaskStore`] implementation.
96#[derive(Debug, Error)]
97pub enum TaskStoreError {
98    /// No Task exists for the given id.
99    #[error("task not found: {0}")]
100    NotFound(TaskId),
101
102    /// `create` was called with an id that is already stored.
103    #[error("task already exists: {0}")]
104    Duplicate(TaskId),
105
106    /// Backend-specific failure not covered by the other variants.
107    #[error("other: {0}")]
108    Other(String),
109}
110
111// ──────────────────────────────────────────────────────────────────────────
112// TaskStore trait
113// ──────────────────────────────────────────────────────────────────────────
114
115/// Persistence interface for `Task` records — the work-item identity
116/// layer of the issue #13 ID hierarchy.
117#[async_trait]
118pub trait TaskStore: Send + Sync {
119    /// Backend name — for diagnostics/logging.
120    fn name(&self) -> &str;
121
122    /// Create a new Task row. Returns `Duplicate` if `record.id` is
123    /// already stored.
124    async fn create(&self, record: TaskRecord) -> Result<(), TaskStoreError>;
125
126    /// Fetch a Task by id.
127    async fn get(&self, id: &TaskId) -> Result<TaskRecord, TaskStoreError>;
128
129    /// List every Task, newest first (descending `created_at`).
130    async fn list(&self) -> Result<Vec<TaskRecord>, TaskStoreError>;
131
132    /// Update a Task's status, bumping `updated_at` to now.
133    async fn update_status(
134        &self,
135        id: &TaskId,
136        status: TaskRecordStatus,
137    ) -> Result<(), TaskStoreError>;
138}
139
140// ──────────────────────────────────────────────────────────────────────────
141// Shared inner state used by the InMemory backend.
142// ──────────────────────────────────────────────────────────────────────────
143
144#[derive(Default)]
145pub(crate) struct Inner {
146    /// Insertion order — used as a stable tie-break under `list()`.
147    pub(crate) order: Vec<TaskId>,
148    pub(crate) records: HashMap<TaskId, TaskRecord>,
149}
150
151pub(crate) type SharedInner = Mutex<Inner>;