Skip to main content

mermaid_runtime/storage/
records.rs

1//! The row DTOs: one `XRecord` for reads, one `NewX` for writes, per table.
2//!
3//! ~490 lines of plain data with no behavior beyond `Default`. Split out
4//! because "what shape is a task row" and "how does the store open a
5//! connection" are different questions and were 600 lines apart.
6
7use std::fmt;
8
9use serde::{Deserialize, Serialize};
10
11use super::*;
12
13// Bumped to 5 for the additive `tasks.prompt` column (the daemon scheduler
14// executes queued tasks later, so the full prompt must be persisted at enqueue
15// time — `title` is truncated at 80 chars). Additive, but the bump lets a DB
16// already at v4 re-run the migration once to pick it up. The bump is
17// load-bearing alongside the F17 early-return in `init_schema`: a DB at an
18// older version still runs the migration (the idempotent baseline plus any
19// per-version step dispatched by `migrate_within_txn`) exactly once, while an
20// already-current DB skips the write lock entirely.
21//
22// History: v2 added the additive `tasks.owner_kind` column (F18/RC-E); v3 added
23// the F75 covering indexes; v4 added the `outcomes` table.
24pub(crate) const SCHEMA_VERSION: i32 = 6;
25
26/// `tasks.owner_kind` value for a task the daemon runs in-process. Only these are
27/// reset by `reconcile_after_restart`; a `NULL` owner (an interactive CLI run, or
28/// any other creator) is left alone so a live `mermaid` session that shares the
29/// store isn't wrongly failed on daemon startup (F18/RC-E).
30pub(crate) const OWNER_KIND_DAEMON: &str = "daemon";
31
32/// Durable task state. A task is the daemon-level work unit; a chat
33/// transcript is just one artifact linked to it.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum TaskStatus {
37    Queued,
38    Running,
39    WaitingForApproval,
40    Blocked,
41    Completed,
42    Failed,
43    Cancelled,
44}
45
46impl TaskStatus {
47    #[must_use]
48    pub fn as_str(self) -> &'static str {
49        match self {
50            Self::Queued => "queued",
51            Self::Running => "running",
52            Self::WaitingForApproval => "waiting_for_approval",
53            Self::Blocked => "blocked",
54            Self::Completed => "completed",
55            Self::Failed => "failed",
56            Self::Cancelled => "cancelled",
57        }
58    }
59
60    pub(crate) fn from_db(value: &str) -> std::result::Result<Self, UnknownRuntimeEnum> {
61        match value {
62            "queued" => Ok(Self::Queued),
63            "running" => Ok(Self::Running),
64            "waiting_for_approval" => Ok(Self::WaitingForApproval),
65            "blocked" => Ok(Self::Blocked),
66            "completed" => Ok(Self::Completed),
67            "failed" => Ok(Self::Failed),
68            "cancelled" => Ok(Self::Cancelled),
69            other => Err(UnknownRuntimeEnum::new("task status", other)),
70        }
71    }
72}
73
74impl fmt::Display for TaskStatus {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        f.write_str(self.as_str())
77    }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum TaskPriority {
83    Low,
84    Normal,
85    High,
86}
87
88impl TaskPriority {
89    #[must_use]
90    pub fn as_str(self) -> &'static str {
91        match self {
92            Self::Low => "low",
93            Self::Normal => "normal",
94            Self::High => "high",
95        }
96    }
97
98    pub(crate) fn from_db(value: &str) -> std::result::Result<Self, UnknownRuntimeEnum> {
99        match value {
100            "low" => Ok(Self::Low),
101            "normal" => Ok(Self::Normal),
102            "high" => Ok(Self::High),
103            other => Err(UnknownRuntimeEnum::new("task priority", other)),
104        }
105    }
106}
107
108impl fmt::Display for TaskPriority {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        f.write_str(self.as_str())
111    }
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "snake_case")]
116pub enum ProcessStatus {
117    Running,
118    Exited,
119    Unknown,
120}
121
122impl ProcessStatus {
123    #[must_use]
124    pub fn as_str(self) -> &'static str {
125        match self {
126            Self::Running => "running",
127            Self::Exited => "exited",
128            Self::Unknown => "unknown",
129        }
130    }
131
132    pub(crate) fn from_db(value: &str) -> std::result::Result<Self, UnknownRuntimeEnum> {
133        match value {
134            "running" => Ok(Self::Running),
135            "exited" => Ok(Self::Exited),
136            "unknown" => Ok(Self::Unknown),
137            other => Err(UnknownRuntimeEnum::new("process status", other)),
138        }
139    }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct TaskRecord {
144    pub id: String,
145    pub title: String,
146    pub status: TaskStatus,
147    pub priority: TaskPriority,
148    pub project_path: String,
149    pub model_id: String,
150    pub conversation_id: Option<String>,
151    pub created_at: String,
152    pub updated_at: String,
153    pub final_report: Option<String>,
154    /// Full prompt for deferred daemon execution (v5). `None` for
155    /// metadata-only tasks (interactive CLI runs, external `create_task`
156    /// callers) — the scheduler never claims those.
157    pub prompt: Option<String>,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct TaskTimelineEvent {
162    pub id: i64,
163    pub task_id: String,
164    pub kind: String,
165    pub message: String,
166    pub created_at: String,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170pub struct SessionRecord {
171    pub id: String,
172    pub project_path: String,
173    pub model_id: String,
174    pub title: Option<String>,
175    pub conversation_path: Option<String>,
176    pub created_at: String,
177    pub updated_at: String,
178    pub total_tokens: Option<i64>,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct NewSession {
183    pub id: Option<String>,
184    pub project_path: String,
185    pub model_id: String,
186    pub title: Option<String>,
187    pub conversation_path: Option<String>,
188    pub total_tokens: Option<i64>,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192pub struct MessageRecord {
193    pub id: i64,
194    pub session_id: String,
195    pub role: String,
196    pub content_json: String,
197    pub created_at: String,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct NewMessage {
202    pub session_id: String,
203    pub role: String,
204    pub content_json: String,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct NewTask {
209    pub title: String,
210    pub project_path: String,
211    pub model_id: String,
212    pub priority: TaskPriority,
213    pub conversation_id: Option<String>,
214    /// Which kind of process owns this task. `Some("daemon")` (set via
215    /// [`Self::daemon_owned`]) marks a task the daemon runs in-process, so the
216    /// startup reconcile may fail it if a crash left it `Running`. `None` — the
217    /// default, used by interactive CLI runs and any other creator — is left
218    /// untouched by reconcile so a live session isn't clobbered (F18/RC-E).
219    pub owner_kind: Option<String>,
220    /// Full prompt for deferred execution by the daemon scheduler. Tasks
221    /// without one are metadata-only and are never claimed.
222    pub prompt: Option<String>,
223}
224
225impl NewTask {
226    pub fn new(
227        title: impl Into<String>,
228        project_path: impl Into<String>,
229        model_id: impl Into<String>,
230    ) -> Self {
231        Self {
232            title: title.into(),
233            project_path: project_path.into(),
234            model_id: model_id.into(),
235            priority: TaskPriority::Normal,
236            conversation_id: None,
237            owner_kind: None,
238            prompt: None,
239        }
240    }
241
242    /// Mark this task as daemon-owned (run in the daemon process). Only such
243    /// tasks are reset by [`RuntimeStore::reconcile_after_restart`]; omit it for
244    /// interactive CLI runs so they survive a daemon restart.
245    #[must_use]
246    pub fn daemon_owned(mut self) -> Self {
247        self.owner_kind = Some(OWNER_KIND_DAEMON.to_string());
248        self
249    }
250
251    /// Persist the full prompt so the scheduler can execute this task later.
252    pub fn with_prompt(mut self, prompt: impl Into<String>) -> Self {
253        self.prompt = Some(prompt.into());
254        self
255    }
256
257    #[must_use]
258    pub fn with_priority(mut self, priority: TaskPriority) -> Self {
259        self.priority = priority;
260        self
261    }
262}
263
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265pub struct ApprovalRecord {
266    pub id: String,
267    pub task_id: Option<String>,
268    pub proposed_action: String,
269    pub risk_classification: String,
270    pub policy_decision: String,
271    pub user_decision: Option<String>,
272    pub args_summary: Option<String>,
273    pub checkpoint_id: Option<String>,
274    pub pending_action_json: Option<String>,
275    pub created_at: String,
276    pub decided_at: Option<String>,
277    pub archived_at: Option<String>,
278    pub archive_reason: Option<String>,
279}
280
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct NewApproval {
283    pub task_id: Option<String>,
284    pub proposed_action: String,
285    pub risk_classification: String,
286    pub policy_decision: String,
287    pub args_summary: Option<String>,
288    pub checkpoint_id: Option<String>,
289    pub pending_action_json: Option<String>,
290}
291
292#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
293pub struct ToolRunRecord {
294    pub id: String,
295    pub task_id: Option<String>,
296    pub turn_id: Option<String>,
297    pub call_id: Option<String>,
298    pub tool_name: String,
299    pub status: String,
300    pub args_json: Option<String>,
301    pub output_json: Option<String>,
302    pub started_at: String,
303    pub finished_at: Option<String>,
304}
305
306#[derive(Debug, Clone, PartialEq, Eq)]
307pub struct NewToolRun {
308    pub id: Option<String>,
309    pub task_id: Option<String>,
310    pub turn_id: Option<String>,
311    pub call_id: Option<String>,
312    pub tool_name: String,
313    pub args_json: Option<String>,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317pub struct ProcessRecord {
318    pub id: String,
319    pub task_id: Option<String>,
320    pub pid: u32,
321    pub command: String,
322    pub cwd: Option<String>,
323    pub log_path: Option<String>,
324    pub detected_url: Option<String>,
325    pub status: ProcessStatus,
326    pub health: Option<String>,
327    pub created_at: String,
328    pub updated_at: String,
329}
330
331#[derive(Debug, Clone, PartialEq, Eq)]
332pub struct NewProcess {
333    pub id: Option<String>,
334    pub task_id: Option<String>,
335    pub pid: u32,
336    pub command: String,
337    pub cwd: Option<String>,
338    pub log_path: Option<String>,
339    pub detected_url: Option<String>,
340    pub status: ProcessStatus,
341    pub health: Option<String>,
342}
343
344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
345pub struct CheckpointRecord {
346    pub id: String,
347    pub task_id: Option<String>,
348    pub project_path: String,
349    pub snapshot_path: String,
350    pub changed_files_json: String,
351    pub pending_action_json: Option<String>,
352    pub approval_id: Option<String>,
353    pub created_at: String,
354    pub archived_at: Option<String>,
355    pub archive_reason: Option<String>,
356    /// Conversation the checkpointed mutation belonged to, when the tool call
357    /// ran inside an interactive session. `None` for headless/daemon/manual
358    /// checkpoints.
359    pub session_id: Option<String>,
360    /// Conversation length (`messages().len()`) at tool DISPATCH. A rewind
361    /// that forks at user-message index `k` keeps `messages[..k]`, so this
362    /// checkpoint belongs to the discarded timeline iff `message_index > k`
363    /// (STRICT — see `list_for_session`).
364    pub message_index: Option<i64>,
365}
366
367#[derive(Debug, Clone, PartialEq, Eq)]
368pub struct NewCheckpoint {
369    pub id: Option<String>,
370    pub task_id: Option<String>,
371    pub project_path: String,
372    pub snapshot_path: String,
373    pub changed_files_json: String,
374    pub pending_action_json: Option<String>,
375    pub approval_id: Option<String>,
376    pub session_id: Option<String>,
377    pub message_index: Option<i64>,
378}
379
380/// Provenance of an [`OutcomeRecord`] — the axis that separates a genuine
381/// external training signal from model self-judgement. `verifier` (compiler,
382/// tests, runtime) and `user` (human edit/accept/reject) are the signals that
383/// can actually improve a model; `model` is self-judged and must never be
384/// trained on unfiltered; `system` is bookkeeping (e.g. a task's terminal
385/// status).
386pub const OUTCOME_SOURCE_VERIFIER: &str = "verifier";
387pub const OUTCOME_SOURCE_USER: &str = "user";
388pub const OUTCOME_SOURCE_MODEL: &str = "model";
389pub const OUTCOME_SOURCE_SYSTEM: &str = "system";
390
391/// Graded result of an outcome. Stored as free-form `TEXT` (like
392/// `tool_runs.status`) so the taxonomy can grow without a migration; these
393/// constants are the canonical spellings so callers don't drift.
394pub const OUTCOME_LABEL_SUCCESS: &str = "success";
395pub const OUTCOME_LABEL_FAILURE: &str = "failure";
396pub const OUTCOME_LABEL_PARTIAL: &str = "partial";
397pub const OUTCOME_LABEL_ACCEPTED: &str = "accepted";
398pub const OUTCOME_LABEL_REJECTED: &str = "rejected";
399pub const OUTCOME_LABEL_UNKNOWN: &str = "unknown";
400
401/// A verifiable outcome / reward signal attached to a trajectory (a task, and
402/// optionally a specific tool run). The other durable tables record *what
403/// happened* (messages, `tool_runs`, checkpoints=diffs); `outcomes` records *how
404/// good it was* and *who says so* ([`source`](Self::source)) — the enrichment
405/// that turns logs into a training set for the self-improving loop.
406#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
407pub struct OutcomeRecord {
408    pub id: String,
409    pub task_id: Option<String>,
410    pub tool_run_id: Option<String>,
411    /// Signal type, e.g. `task_terminal`, `build`, `test`, `tool_exec`,
412    /// `user_edit`, `git_survival`, `preference`. Free-form.
413    pub kind: String,
414    /// Graded result — one of the `OUTCOME_LABEL_*` values.
415    pub label: String,
416    /// Optional scalar reward (convention: roughly `-1.0..=1.0`). `None` when
417    /// the signal is categorical only.
418    pub reward: Option<f64>,
419    /// Provenance — one of the `OUTCOME_SOURCE_*` values.
420    pub source: String,
421    /// Optional structured payload: test counts, a git sha, or a preference
422    /// pair `{ "chosen": ..., "rejected": ... }` for DPO.
423    pub detail_json: Option<String>,
424    pub created_at: String,
425}
426
427#[derive(Debug, Clone, PartialEq)]
428pub struct NewOutcome {
429    pub id: Option<String>,
430    pub task_id: Option<String>,
431    pub tool_run_id: Option<String>,
432    pub kind: String,
433    pub label: String,
434    pub reward: Option<f64>,
435    pub source: String,
436    pub detail_json: Option<String>,
437}
438
439#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
440pub struct CompactionRecord {
441    pub id: String,
442    pub task_id: Option<String>,
443    pub session_id: Option<String>,
444    pub source_token_estimate: Option<i64>,
445    pub summary_token_count: Option<i64>,
446    pub preserved_turns: Option<i64>,
447    pub archive_path: Option<String>,
448    pub verification_status: Option<String>,
449    pub created_at: String,
450}
451
452#[derive(Debug, Clone, PartialEq, Eq)]
453pub struct NewCompaction {
454    pub id: Option<String>,
455    pub task_id: Option<String>,
456    pub session_id: Option<String>,
457    pub source_token_estimate: Option<i64>,
458    pub summary_token_count: Option<i64>,
459    pub preserved_turns: Option<i64>,
460    pub archive_path: Option<String>,
461    pub verification_status: Option<String>,
462}
463
464#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
465pub struct PluginInstallRecord {
466    pub id: String,
467    pub name: String,
468    pub source: String,
469    pub version: Option<String>,
470    pub enabled: bool,
471    pub manifest_json: String,
472    pub installed_at: String,
473    pub updated_at: String,
474}
475
476#[derive(Debug, Clone, PartialEq, Eq)]
477pub struct NewPluginInstall {
478    pub id: Option<String>,
479    pub name: String,
480    pub source: String,
481    pub version: Option<String>,
482    pub enabled: bool,
483    pub manifest_json: String,
484}
485
486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
487pub struct ProviderProbeRecord {
488    pub provider: String,
489    pub model_id: String,
490    pub capability_key: String,
491    pub capability_value: String,
492    pub confidence: String,
493    pub error: Option<String>,
494    pub probed_at: String,
495}
496
497#[derive(Debug, Clone, PartialEq, Eq)]
498pub struct NewProviderProbe {
499    pub provider: String,
500    pub model_id: String,
501    pub capability_key: String,
502    pub capability_value: String,
503    pub confidence: String,
504    pub error: Option<String>,
505}
506
507#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
508pub struct PairingTokenRecord {
509    pub id: String,
510    pub token_hash: String,
511    pub label: Option<String>,
512    pub enabled: bool,
513    pub created_at: String,
514    pub last_used_at: Option<String>,
515    /// RFC3339 expiry. `None` = never expires (opt-in via `--ttl-days 0`).
516    pub expires_at: Option<String>,
517}