Skip to main content

mermaid_model/
records.rs

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