Skip to main content

mermaid_runtime/storage/
repos.rs

1//! One repository type per table. Each is a thin `&Connection` wrapper; they
2//! share nothing else, which is what made this the most mechanical seam in the
3//! tree — and why `TasksRepo`'s declaration had drifted 100 lines from its own
4//! `impl`, with two unrelated repos in between.
5
6use anyhow::{Context, Result};
7use rusqlite::{Connection, OptionalExtension, params};
8
9use super::*;
10
11// Bumped to 5 for the additive `tasks.prompt` column (the daemon scheduler
12// executes queued tasks later, so the full prompt must be persisted at enqueue
13// time — `title` is truncated at 80 chars). Additive, but the bump lets a DB
14// already at v4 re-run the migration once to pick it up. The bump is
15// load-bearing alongside the F17 early-return in `init_schema`: a DB at an
16// older version still runs the migration (the idempotent baseline plus any
17// per-version step dispatched by `migrate_within_txn`) exactly once, while an
18// already-current DB skips the write lock entirely.
19//
20// History: v2 added the additive `tasks.owner_kind` column (F18/RC-E); v3 added
21// the F75 covering indexes; v4 added the `outcomes` table.
22pub struct TasksRepo<'a> {
23    pub(crate) conn: &'a Connection,
24}
25
26pub struct SessionsRepo<'a> {
27    pub(crate) conn: &'a Connection,
28}
29
30impl SessionsRepo<'_> {
31    /// # Errors
32    ///
33    /// Errors if the write statement fails, or if the row cannot be read back
34    /// afterwards -- the reload is what produces the returned record.
35    pub fn upsert(&self, new: NewSession) -> Result<SessionRecord> {
36        let now = now_rfc3339();
37        let id = new.id.unwrap_or_else(|| fresh_id("session"));
38        self.conn.execute(
39            "INSERT INTO sessions
40             (id, project_path, model_id, title, conversation_path, created_at, updated_at, total_tokens)
41             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
42             ON CONFLICT(id) DO UPDATE SET
43                project_path = excluded.project_path,
44                model_id = excluded.model_id,
45                title = excluded.title,
46                conversation_path = excluded.conversation_path,
47                updated_at = excluded.updated_at,
48                total_tokens = excluded.total_tokens",
49            params![
50                id,
51                new.project_path,
52                new.model_id,
53                new.title,
54                new.conversation_path,
55                now,
56                now,
57                new.total_tokens,
58            ],
59        )?;
60        self.get(&id)?
61            .context("session was upserted but could not be reloaded")
62    }
63
64    /// # Errors
65    ///
66    /// Errors if the query fails or the stored row does not decode. A row that is
67    /// not there is `Ok(None)`, not an error.
68    pub fn get(&self, id: &str) -> Result<Option<SessionRecord>> {
69        self.conn
70            .query_row(
71                "SELECT id, project_path, model_id, title, conversation_path,
72                        created_at, updated_at, total_tokens
73                 FROM sessions WHERE id = ?1",
74                [id],
75                session_from_row,
76            )
77            .optional()
78            .map_err(Into::into)
79    }
80
81    /// # Errors
82    ///
83    /// Errors if the statement fails to prepare or run, or if any row does not
84    /// decode -- one undecodable row fails the whole call.
85    pub fn list(&self, limit: usize) -> Result<Vec<SessionRecord>> {
86        let mut stmt = self.conn.prepare(
87            "SELECT id, project_path, model_id, title, conversation_path,
88                    created_at, updated_at, total_tokens
89             FROM sessions ORDER BY updated_at DESC LIMIT ?1",
90        )?;
91        let rows = stmt.query_map([clamp_limit(limit)], session_from_row)?;
92        rows.collect::<rusqlite::Result<Vec<_>>>()
93            .map_err(Into::into)
94    }
95}
96
97impl TasksRepo<'_> {
98    /// # Errors
99    ///
100    /// Errors if the write transaction fails, or if the row cannot be read back
101    /// afterwards -- the reload is what produces the returned record.
102    pub fn create(&self, new: NewTask) -> Result<TaskRecord> {
103        let now = now_rfc3339();
104        // Owner tag isn't part of the public `TaskRecord`; move it out before the
105        // record consumes the rest of `new`, then persist it on its own column.
106        let owner_kind = new.owner_kind;
107        let record = TaskRecord {
108            id: fresh_id("task"),
109            title: new.title,
110            status: TaskStatus::Queued,
111            priority: new.priority,
112            project_path: new.project_path,
113            model_id: new.model_id,
114            conversation_id: new.conversation_id,
115            created_at: now.clone(),
116            updated_at: now.clone(),
117            final_report: None,
118            prompt: new.prompt,
119        };
120        // The task row and its initial event are one logical write — commit
121        // them atomically so a crash between can't leave an event-less task.
122        let tx = self.conn.unchecked_transaction()?;
123        tx.execute(
124            "INSERT INTO tasks
125             (id, title, status, priority, project_path, model_id, conversation_id, created_at, updated_at, final_report, owner_kind, prompt)
126             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
127            params![
128                record.id,
129                record.title,
130                record.status.as_str(),
131                record.priority.as_str(),
132                record.project_path,
133                record.model_id,
134                record.conversation_id,
135                record.created_at,
136                record.updated_at,
137                record.final_report,
138                owner_kind,
139                record.prompt,
140            ],
141        )?;
142        tx.execute(
143            "INSERT INTO task_events (task_id, kind, message, created_at)
144             VALUES (?1, ?2, ?3, ?4)",
145            params![record.id, "task_created", "task created", now],
146        )?;
147        tx.commit()?;
148        self.get(&record.id)?
149            .context("task was inserted but could not be reloaded")
150    }
151
152    /// # Errors
153    ///
154    /// Errors if the query fails or the stored row does not decode. A row that is
155    /// not there is `Ok(None)`, not an error.
156    pub fn get(&self, id: &str) -> Result<Option<TaskRecord>> {
157        self.conn
158            .query_row(
159                "SELECT id, title, status, priority, project_path, model_id, conversation_id,
160                        created_at, updated_at, final_report, prompt
161                 FROM tasks WHERE id = ?1",
162                [id],
163                task_from_row,
164            )
165            .optional()
166            .map_err(Into::into)
167    }
168
169    /// # Errors
170    ///
171    /// Errors if the statement fails to prepare or run, or if any row does not
172    /// decode -- one undecodable row fails the whole call.
173    pub fn list(&self, limit: usize) -> Result<Vec<TaskRecord>> {
174        let mut stmt = self.conn.prepare(
175            "SELECT id, title, status, priority, project_path, model_id, conversation_id,
176                    created_at, updated_at, final_report, prompt
177             FROM tasks
178             ORDER BY updated_at DESC
179             LIMIT ?1",
180        )?;
181        // F19 (RC-E): skip-and-warn a single undecodable row (e.g. a status enum
182        // a different binary wrote) instead of `collect`ing a `Result` that would
183        // blank the WHOLE tasks panel on one poison row.
184        let rows = stmt.query_map([clamp_limit(limit)], task_from_row_opt)?;
185        collect_tolerant(rows)
186    }
187
188    /// # Errors
189    ///
190    /// Errors if the statement fails. The work runs in a transaction, so a failure
191    /// leaves the table unchanged.
192    pub fn update_status(
193        &self,
194        id: &str,
195        status: TaskStatus,
196        final_report: Option<&str>,
197    ) -> Result<()> {
198        let now = now_rfc3339();
199        // Status update + its event are one logical write.
200        let tx = self.conn.unchecked_transaction()?;
201        tx.execute(
202            "UPDATE tasks
203             SET status = ?2, updated_at = ?3, final_report = COALESCE(?4, final_report)
204             WHERE id = ?1",
205            params![id, status.as_str(), now, final_report],
206        )?;
207        tx.execute(
208            "INSERT INTO task_events (task_id, kind, message, created_at)
209             VALUES (?1, ?2, ?3, ?4)",
210            params![
211                id,
212                "status_changed",
213                format!("status changed to {status}"),
214                now
215            ],
216        )?;
217        tx.commit()?;
218        Ok(())
219    }
220
221    /// Link a task to the conversation its run produced.
222    ///
223    /// The column is set at task CREATION today, where the conversation does
224    /// not exist yet — so it was `NULL` for every daemon task, and every
225    /// join through it came back empty. The run knows the id only when it
226    /// returns, which is where this is called from.
227    ///
228    /// # Errors
229    ///
230    /// The write statement. A task id that no longer exists updates zero
231    /// rows and is `Ok`: the link is bookkeeping, and a task pruned mid-run
232    /// must not fail the terminal-status path that carries it.
233    pub fn set_conversation(&self, id: &str, conversation_id: &str) -> Result<()> {
234        self.conn.execute(
235            "UPDATE tasks SET conversation_id = ?2, updated_at = ?3 WHERE id = ?1",
236            params![id, conversation_id, now_rfc3339()],
237        )?;
238        Ok(())
239    }
240
241    /// Atomically claim the next runnable queued task for the daemon scheduler:
242    /// flip it to `Running` and return it, or `None` when the queue is empty.
243    ///
244    /// Only daemon-owned tasks WITH a persisted prompt are claimable —
245    /// metadata-only tasks (interactive CLI runs, external `create_task`
246    /// callers) are never executed by the scheduler. Order: priority
247    /// (high → normal → low), then FIFO by `created_at` (id as tiebreaker,
248    /// since two enqueues can share a coarse-clock timestamp). The claim is a
249    /// single `UPDATE … RETURNING`, so concurrent claimers can never run the
250    /// same task twice.
251    ///
252    /// # Errors
253    ///
254    /// Errors if the query fails or the stored row does not decode. A row that is
255    /// not there is `Ok(None)`, not an error. The work runs in a transaction, so a
256    /// failure leaves the table unchanged.
257    pub fn claim_next_queued(&self) -> Result<Option<TaskRecord>> {
258        let tx = self.conn.unchecked_transaction()?;
259        let claimed = tx
260            .query_row(
261                "UPDATE tasks SET status = 'running', updated_at = ?1
262                 WHERE id = (
263                     SELECT id FROM tasks
264                     WHERE status = 'queued' AND owner_kind = ?2 AND prompt IS NOT NULL
265                     ORDER BY CASE priority
266                                  WHEN 'high' THEN 0
267                                  WHEN 'normal' THEN 1
268                                  WHEN 'low' THEN 2
269                                  ELSE 1
270                              END,
271                              created_at ASC, id ASC
272                     LIMIT 1
273                 )
274                 RETURNING id, title, status, priority, project_path, model_id,
275                           conversation_id, created_at, updated_at, final_report, prompt",
276                params![now_rfc3339(), OWNER_KIND_DAEMON],
277                task_from_row,
278            )
279            .optional()?;
280        if let Some(task) = &claimed {
281            tx.execute(
282                "INSERT INTO task_events (task_id, kind, message, created_at)
283                 VALUES (?1, ?2, ?3, ?4)",
284                params![
285                    task.id,
286                    "status_changed",
287                    "status changed to running (claimed by scheduler)",
288                    now_rfc3339(),
289                ],
290            )?;
291        }
292        tx.commit()?;
293        Ok(claimed)
294    }
295
296    /// # Errors
297    ///
298    /// Errors if the statement fails.
299    pub fn add_event(&self, task_id: &str, kind: &str, message: &str) -> Result<()> {
300        self.conn.execute(
301            "INSERT INTO task_events (task_id, kind, message, created_at)
302             VALUES (?1, ?2, ?3, ?4)",
303            params![task_id, kind, message, now_rfc3339()],
304        )?;
305        Ok(())
306    }
307
308    /// # Errors
309    ///
310    /// Errors if the statement fails to prepare or run, or if any row does not
311    /// decode -- one undecodable row fails the whole call.
312    pub fn events(&self, task_id: &str) -> Result<Vec<TaskTimelineEvent>> {
313        let mut stmt = self.conn.prepare(
314            "SELECT id, task_id, kind, message, created_at
315             FROM task_events
316             WHERE task_id = ?1
317             ORDER BY id ASC",
318        )?;
319        // F19 (RC-E): one undecodable event row must not blank the whole timeline.
320        let rows = stmt.query_map([task_id], task_event_from_row_opt)?;
321        collect_tolerant(rows)
322    }
323}
324
325pub struct ToolRunsRepo<'a> {
326    pub(crate) conn: &'a Connection,
327}
328
329impl ToolRunsRepo<'_> {
330    /// # Errors
331    ///
332    /// Errors if the write statement fails, or if the row cannot be read back
333    /// afterwards -- the reload is what produces the returned record.
334    pub fn start(&self, mut new: NewToolRun) -> Result<ToolRunRecord> {
335        // The repository is the mandatory persistence choke point. Callers may
336        // pass executable arguments unchanged; only this cloned serialized
337        // representation is scrubbed before SQLite sees it.
338        new.args_json = new
339            .args_json
340            .as_deref()
341            .map(crate::redact::redact_json_text);
342        let id = new.id.unwrap_or_else(|| fresh_id("toolrun"));
343        self.conn.execute(
344            "INSERT INTO tool_runs
345             (id, task_id, turn_id, call_id, tool_name, status, args_json, output_json, started_at, finished_at)
346             VALUES (?1, ?2, ?3, ?4, ?5, 'running', ?6, NULL, ?7, NULL)",
347            params![
348                id,
349                new.task_id,
350                new.turn_id,
351                new.call_id,
352                new.tool_name,
353                new.args_json,
354                now_rfc3339(),
355            ],
356        )?;
357        self.get(&id)?
358            .context("tool run was inserted but could not be reloaded")
359    }
360
361    /// # Errors
362    ///
363    /// Errors if the statement fails.
364    pub fn finish(&self, id: &str, status: &str, output_json: Option<&str>) -> Result<()> {
365        let output_json = output_json.map(crate::redact::redact_json_text);
366        let changed = self.conn.execute(
367            "UPDATE tool_runs
368             SET status = ?2, output_json = ?3, finished_at = ?4
369             WHERE id = ?1",
370            params![id, status, output_json, now_rfc3339()],
371        )?;
372        anyhow::ensure!(changed > 0, "tool run not found: {id}");
373        Ok(())
374    }
375
376    /// # Errors
377    ///
378    /// Errors if the query fails or the stored row does not decode. A row that is
379    /// not there is `Ok(None)`, not an error.
380    pub fn get(&self, id: &str) -> Result<Option<ToolRunRecord>> {
381        self.conn
382            .query_row(
383                "SELECT id, task_id, turn_id, call_id, tool_name, status, args_json,
384                        output_json, started_at, finished_at
385                 FROM tool_runs WHERE id = ?1",
386                [id],
387                tool_run_from_row,
388            )
389            .optional()
390            .map_err(Into::into)
391    }
392
393    /// # Errors
394    ///
395    /// Errors if the statement fails to prepare or run, or if any row does not
396    /// decode -- one undecodable row fails the whole call.
397    pub fn list(&self, limit: usize) -> Result<Vec<ToolRunRecord>> {
398        let mut stmt = self.conn.prepare(
399            "SELECT id, task_id, turn_id, call_id, tool_name, status, args_json,
400                    output_json, started_at, finished_at
401             FROM tool_runs ORDER BY started_at DESC LIMIT ?1",
402        )?;
403        let rows = stmt.query_map([clamp_limit(limit)], tool_run_from_row)?;
404        rows.collect::<rusqlite::Result<Vec<_>>>()
405            .map_err(Into::into)
406    }
407}
408
409pub struct OutcomesRepo<'a> {
410    pub(crate) conn: &'a Connection,
411}
412
413impl OutcomesRepo<'_> {
414    /// Record a verifiable outcome / reward signal for a trajectory. Append-only
415    /// — the loop reads these; nothing mutates them after the fact.
416    ///
417    /// # Errors
418    ///
419    /// Errors if the write statement fails, or if the row cannot be read back
420    /// afterwards -- the reload is what produces the returned record.
421    pub fn record(&self, new: NewOutcome) -> Result<OutcomeRecord> {
422        let id = new.id.unwrap_or_else(|| fresh_id("outcome"));
423        self.conn.execute(
424            "INSERT INTO outcomes
425             (id, task_id, tool_run_id, kind, label, reward, source, detail_json, created_at)
426             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
427            params![
428                id,
429                new.task_id,
430                new.tool_run_id,
431                new.kind,
432                new.label,
433                new.reward,
434                new.source,
435                new.detail_json,
436                now_rfc3339(),
437            ],
438        )?;
439        self.get(&id)?
440            .context("outcome was inserted but could not be reloaded")
441    }
442
443    /// # Errors
444    ///
445    /// Errors if the query fails or the stored row does not decode. A row that is
446    /// not there is `Ok(None)`, not an error.
447    pub fn get(&self, id: &str) -> Result<Option<OutcomeRecord>> {
448        self.conn
449            .query_row(
450                "SELECT id, task_id, tool_run_id, kind, label, reward, source,
451                        detail_json, created_at
452                 FROM outcomes WHERE id = ?1",
453                [id],
454                outcome_from_row,
455            )
456            .optional()
457            .map_err(Into::into)
458    }
459
460    /// Every outcome recorded against one task, oldest first (the order the
461    /// trajectory earned them).
462    ///
463    /// # Errors
464    ///
465    /// Errors if the statement fails to prepare or run, or if any row does not
466    /// decode -- one undecodable row fails the whole call.
467    pub fn list_for_task(&self, task_id: &str) -> Result<Vec<OutcomeRecord>> {
468        let mut stmt = self.conn.prepare(
469            "SELECT id, task_id, tool_run_id, kind, label, reward, source,
470                    detail_json, created_at
471             FROM outcomes WHERE task_id = ?1 ORDER BY created_at ASC",
472        )?;
473        let rows = stmt.query_map([task_id], outcome_from_row)?;
474        rows.collect::<rusqlite::Result<Vec<_>>>()
475            .map_err(Into::into)
476    }
477
478    /// # Errors
479    ///
480    /// Errors if the statement fails to prepare or run, or if any row does not
481    /// decode -- one undecodable row fails the whole call.
482    pub fn list(&self, limit: usize) -> Result<Vec<OutcomeRecord>> {
483        let mut stmt = self.conn.prepare(
484            "SELECT id, task_id, tool_run_id, kind, label, reward, source,
485                    detail_json, created_at
486             FROM outcomes ORDER BY created_at DESC LIMIT ?1",
487        )?;
488        let rows = stmt.query_map([clamp_limit(limit)], outcome_from_row)?;
489        rows.collect::<rusqlite::Result<Vec<_>>>()
490            .map_err(Into::into)
491    }
492}
493
494pub(crate) fn outcome_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<OutcomeRecord> {
495    Ok(OutcomeRecord {
496        id: row.get(0)?,
497        task_id: row.get(1)?,
498        tool_run_id: row.get(2)?,
499        kind: row.get(3)?,
500        label: row.get(4)?,
501        reward: row.get(5)?,
502        source: row.get(6)?,
503        detail_json: row.get(7)?,
504        created_at: row.get(8)?,
505    })
506}
507
508pub struct ApprovalsRepo<'a> {
509    pub(crate) conn: &'a Connection,
510}
511
512impl ApprovalsRepo<'_> {
513    /// # Errors
514    ///
515    /// Errors if the write statement fails, or if the row cannot be read back
516    /// afterwards -- the reload is what produces the returned record.
517    pub fn create(&self, new: NewApproval) -> Result<ApprovalRecord> {
518        let record = ApprovalRecord {
519            id: fresh_id("approval"),
520            task_id: new.task_id,
521            proposed_action: new.proposed_action,
522            risk_classification: new.risk_classification,
523            policy_decision: new.policy_decision,
524            user_decision: None,
525            args_summary: new.args_summary,
526            checkpoint_id: new.checkpoint_id,
527            pending_action_json: new.pending_action_json,
528            created_at: now_rfc3339(),
529            decided_at: None,
530            archived_at: None,
531            archive_reason: None,
532        };
533        self.conn.execute(
534            "INSERT INTO approvals
535             (id, task_id, proposed_action, risk_classification, policy_decision, user_decision,
536              args_summary, checkpoint_id, pending_action_json, created_at, decided_at)
537             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
538            params![
539                record.id,
540                record.task_id,
541                record.proposed_action,
542                record.risk_classification,
543                record.policy_decision,
544                record.user_decision,
545                record.args_summary,
546                record.checkpoint_id,
547                record.pending_action_json,
548                record.created_at,
549                record.decided_at,
550            ],
551        )?;
552        self.get(&record.id)?
553            .context("approval was inserted but could not be reloaded")
554    }
555
556    /// # Errors
557    ///
558    /// Errors if the query fails or the stored row does not decode. A row that is
559    /// not there is `Ok(None)`, not an error.
560    pub fn get(&self, id: &str) -> Result<Option<ApprovalRecord>> {
561        self.conn
562            .query_row(
563                "SELECT id, task_id, proposed_action, risk_classification, policy_decision,
564                        user_decision, args_summary, checkpoint_id, pending_action_json,
565                        created_at, decided_at, archived_at, archive_reason
566                 FROM approvals WHERE id = ?1",
567                [id],
568                approval_from_row,
569            )
570            .optional()
571            .map_err(Into::into)
572    }
573
574    /// # Errors
575    ///
576    /// Errors if the statement fails.
577    pub fn decide(&self, id: &str, user_decision: &str) -> Result<()> {
578        // Single-shot decision: only an undecided, un-archived approval can be
579        // decided, so a denied approval cannot be resurrected as "approved".
580        // `approval::approve_and_replay` runs the (un-rollback-able) replay
581        // effect *before* calling `decide`, so the "approved" mark lands only
582        // after the action ran: a crash mid-replay leaves the row undecided and
583        // safely re-runnable, never "approved but never applied" (#62). Mirrors
584        // the `archive` `WHERE archived_at IS NULL` idempotency pattern below.
585        let changed = self.conn.execute(
586            "UPDATE approvals
587             SET user_decision = ?2, decided_at = ?3
588             WHERE id = ?1 AND user_decision IS NULL AND archived_at IS NULL",
589            params![id, user_decision, now_rfc3339()],
590        )?;
591        anyhow::ensure!(
592            changed > 0,
593            "approval {id} cannot be decided (already decided, archived, or not found)"
594        );
595        Ok(())
596    }
597
598    /// Atomically claim an undecided approval for replay (#118). Sets
599    /// `user_decision='approving'` only when it is currently NULL and
600    /// un-archived, and reports whether THIS caller won the claim. Two concurrent
601    /// `approve <id>` calls race this single UPDATE; exactly one sees
602    /// `rows_affected == 1` and runs the un-rollback-able effect, the other sees
603    /// `false` and bails — so the effect can't fire twice. A claim that crashes
604    /// before finalizing is reset to NULL by the daemon's startup reconcile.
605    ///
606    /// # Errors
607    ///
608    /// Errors if the UPDATE fails. Losing the race is `Ok(false)`, not an error:
609    /// the row was already decided, already claimed, or archived.
610    pub fn claim(&self, id: &str) -> Result<bool> {
611        let changed = self.conn.execute(
612            "UPDATE approvals
613             SET user_decision = 'approving'
614             WHERE id = ?1 AND user_decision IS NULL AND archived_at IS NULL",
615            params![id],
616        )?;
617        Ok(changed == 1)
618    }
619
620    /// Release a claim taken by [`Self::claim`] back to undecided, so the action
621    /// stays re-runnable after the replay effect failed.
622    ///
623    /// # Errors
624    ///
625    /// Errors if the statement fails.
626    pub fn release_claim(&self, id: &str) -> Result<()> {
627        self.conn.execute(
628            "UPDATE approvals SET user_decision = NULL
629             WHERE id = ?1 AND user_decision = 'approving'",
630            params![id],
631        )?;
632        Ok(())
633    }
634
635    /// Finalize a claimed approval's decision (the `approving` → terminal-value
636    /// transition that [`Self::decide`]'s `WHERE user_decision IS NULL` can't make).
637    ///
638    /// # Errors
639    ///
640    /// Errors if the statement fails.
641    pub fn finalize_claimed(&self, id: &str, user_decision: &str) -> Result<()> {
642        let changed = self.conn.execute(
643            "UPDATE approvals
644             SET user_decision = ?2, decided_at = ?3
645             WHERE id = ?1 AND user_decision = 'approving'",
646            params![id, user_decision, now_rfc3339()],
647        )?;
648        anyhow::ensure!(changed > 0, "approval {id} was not in the claimed state");
649        Ok(())
650    }
651
652    /// # Errors
653    ///
654    /// Errors if the underlying query fails or any row does not decode.
655    pub fn list_pending(&self) -> Result<Vec<ApprovalRecord>> {
656        self.list_pending_with_archived(false)
657    }
658
659    /// # Errors
660    ///
661    /// Errors if the underlying query fails or any row does not decode.
662    pub fn list_pending_all(&self) -> Result<Vec<ApprovalRecord>> {
663        self.list_pending_with_archived(true)
664    }
665
666    /// # Errors
667    ///
668    /// Errors if the statement fails to prepare or run, or if any row does not
669    /// decode -- one undecodable row fails the whole call.
670    pub fn list_all(&self, limit: usize) -> Result<Vec<ApprovalRecord>> {
671        let mut stmt = self.conn.prepare(
672            "SELECT id, task_id, proposed_action, risk_classification, policy_decision,
673                    user_decision, args_summary, checkpoint_id, pending_action_json,
674                    created_at, decided_at, archived_at, archive_reason
675             FROM approvals
676             ORDER BY created_at DESC
677             LIMIT ?1",
678        )?;
679        let rows = stmt.query_map([clamp_limit(limit)], approval_from_row)?;
680        rows.collect::<rusqlite::Result<Vec<_>>>()
681            .map_err(Into::into)
682    }
683
684    pub(crate) fn list_pending_with_archived(
685        &self,
686        include_archived: bool,
687    ) -> Result<Vec<ApprovalRecord>> {
688        let archived_filter = if include_archived {
689            ""
690        } else {
691            " AND archived_at IS NULL"
692        };
693        let mut stmt = self.conn.prepare(&format!(
694            "SELECT id, task_id, proposed_action, risk_classification, policy_decision,
695                    user_decision, args_summary, checkpoint_id, pending_action_json,
696                    created_at, decided_at, archived_at, archive_reason
697             FROM approvals
698             WHERE user_decision IS NULL{archived_filter}
699             ORDER BY created_at DESC"
700        ))?;
701        let rows = stmt.query_map([], approval_from_row)?;
702        rows.collect::<rusqlite::Result<Vec<_>>>()
703            .map_err(Into::into)
704    }
705
706    /// # Errors
707    ///
708    /// Errors if any one of the per-id updates fails, and stops there -- the ids
709    /// before it stay archived, because the loop runs outside a transaction. The
710    /// count is of rows actually changed, so ids already archived add nothing and
711    /// are not an error.
712    pub fn archive(&self, ids: &[String], reason: &str) -> Result<usize> {
713        let archived_at = now_rfc3339();
714        let mut changed = 0;
715        for id in ids {
716            changed += self.conn.execute(
717                "UPDATE approvals
718                 SET archived_at = COALESCE(archived_at, ?2),
719                     archive_reason = COALESCE(archive_reason, ?3)
720                 WHERE id = ?1 AND archived_at IS NULL",
721                params![id, archived_at, reason],
722            )?;
723        }
724        Ok(changed)
725    }
726
727    /// # Errors
728    ///
729    /// Errors if the count query fails.
730    pub fn count_archived(&self) -> Result<usize> {
731        self.conn
732            .query_row(
733                "SELECT COUNT(*) FROM approvals WHERE archived_at IS NOT NULL",
734                [],
735                |row| row.get::<_, i64>(0),
736            )
737            .map(|count| count as usize)
738            .map_err(Into::into)
739    }
740}
741
742pub struct ProcessesRepo<'a> {
743    pub(crate) conn: &'a Connection,
744}
745
746impl ProcessesRepo<'_> {
747    /// # Errors
748    ///
749    /// Errors if the write statement fails, or if the row cannot be read back
750    /// afterwards -- the reload is what produces the returned record.
751    pub fn upsert(&self, new: NewProcess) -> Result<ProcessRecord> {
752        let now = now_rfc3339();
753        let id = new.id.unwrap_or_else(|| fresh_id("process"));
754        self.conn.execute(
755            "INSERT INTO processes
756             (id, task_id, pid, command, cwd, log_path, detected_url, status, health, created_at, updated_at)
757             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
758             ON CONFLICT(id) DO UPDATE SET
759                task_id = excluded.task_id,
760                pid = excluded.pid,
761                command = excluded.command,
762                cwd = excluded.cwd,
763                log_path = excluded.log_path,
764                detected_url = excluded.detected_url,
765                status = excluded.status,
766                health = excluded.health,
767                updated_at = excluded.updated_at",
768            params![
769                id,
770                new.task_id,
771                new.pid,
772                new.command,
773                new.cwd,
774                new.log_path,
775                new.detected_url,
776                new.status.as_str(),
777                new.health,
778                now,
779                now,
780            ],
781        )?;
782        self.get(&id)?
783            .context("process was upserted but could not be reloaded")
784    }
785
786    /// # Errors
787    ///
788    /// Errors if the query fails or the stored row does not decode. A row that is
789    /// not there is `Ok(None)`, not an error.
790    pub fn get(&self, id: &str) -> Result<Option<ProcessRecord>> {
791        self.conn
792            .query_row(
793                "SELECT id, task_id, pid, command, cwd, log_path, detected_url, status, health,
794                        created_at, updated_at
795                 FROM processes WHERE id = ?1",
796                [id],
797                process_from_row,
798            )
799            .optional()
800            .map_err(Into::into)
801    }
802
803    /// # Errors
804    ///
805    /// Errors if the statement fails to prepare or run, or if any row does not
806    /// decode -- one undecodable row fails the whole call.
807    pub fn list(&self, limit: usize) -> Result<Vec<ProcessRecord>> {
808        let mut stmt = self.conn.prepare(
809            "SELECT id, task_id, pid, command, cwd, log_path, detected_url, status, health,
810                    created_at, updated_at
811             FROM processes
812             ORDER BY updated_at DESC
813             LIMIT ?1",
814        )?;
815        // F19 (RC-E): skip-and-warn an undecodable row (e.g. a status enum a
816        // different binary wrote) rather than blanking the whole processes panel.
817        let rows = stmt.query_map([clamp_limit(limit)], process_from_row_opt)?;
818        collect_tolerant(rows)
819    }
820}
821
822pub struct CheckpointsRepo<'a> {
823    pub(crate) conn: &'a Connection,
824}
825
826impl CheckpointsRepo<'_> {
827    /// # Errors
828    ///
829    /// Errors if the write statement fails, or if the row cannot be read back
830    /// afterwards -- the reload is what produces the returned record.
831    pub fn create(&self, new: NewCheckpoint) -> Result<CheckpointRecord> {
832        let id = new.id.unwrap_or_else(|| fresh_id("checkpoint"));
833        self.conn.execute(
834            "INSERT INTO checkpoints
835             (id, task_id, project_path, snapshot_path, changed_files_json,
836              pending_action_json, approval_id, created_at, session_id, message_index)
837             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
838            params![
839                id,
840                new.task_id,
841                new.project_path,
842                new.snapshot_path,
843                new.changed_files_json,
844                new.pending_action_json,
845                new.approval_id,
846                now_rfc3339(),
847                new.session_id,
848                new.message_index,
849            ],
850        )?;
851        self.get(&id)?
852            .context("checkpoint was inserted but could not be reloaded")
853    }
854
855    /// # Errors
856    ///
857    /// Errors if the query fails or the stored row does not decode. A row that is
858    /// not there is `Ok(None)`, not an error.
859    pub fn get(&self, id: &str) -> Result<Option<CheckpointRecord>> {
860        self.conn
861            .query_row(
862                "SELECT id, task_id, project_path, snapshot_path, changed_files_json,
863                        pending_action_json, approval_id, created_at, archived_at, archive_reason,
864                        session_id, message_index
865                 FROM checkpoints WHERE id = ?1",
866                [id],
867                checkpoint_from_row,
868            )
869            .optional()
870            .map_err(Into::into)
871    }
872
873    /// # Errors
874    ///
875    /// Errors if the statement fails.
876    pub fn set_approval(&self, id: &str, approval_id: &str) -> Result<()> {
877        let changed = self.conn.execute(
878            "UPDATE checkpoints SET approval_id = ?2 WHERE id = ?1",
879            params![id, approval_id],
880        )?;
881        anyhow::ensure!(changed > 0, "checkpoint not found: {id}");
882        Ok(())
883    }
884
885    /// Delete a checkpoint row outright. Returns whether a row was removed.
886    ///
887    /// F23 (RC-F): coordinates the on-disk checkpoint-dir GC
888    /// ([`crate::checkpoint::gc_old_checkpoint_dirs`]) with the DB. The dir GC
889    /// prunes by mtime regardless of archive state, while storage [`Self`] /
890    /// `gc()` only removes ARCHIVED checkpoint rows — so a never-archived old
891    /// checkpoint would lose its directory while its row survived, and a later
892    /// `restore_checkpoint` would fail on the missing manifest. The dir GC now
893    /// calls this so `list()` and the on-disk dirs stay in agreement.
894    ///
895    /// # Errors
896    ///
897    /// Errors if the DELETE fails. A checkpoint that was not there is `Ok(false)`,
898    /// not an error.
899    pub fn delete(&self, id: &str) -> Result<bool> {
900        let changed = self
901            .conn
902            .execute("DELETE FROM checkpoints WHERE id = ?1", params![id])?;
903        Ok(changed > 0)
904    }
905
906    /// # Errors
907    ///
908    /// Errors if the statement fails.
909    pub fn list(&self, limit: usize) -> Result<Vec<CheckpointRecord>> {
910        self.list_with_archived(limit, false)
911    }
912
913    /// # Errors
914    ///
915    /// Errors if the statement fails.
916    pub fn list_all(&self, limit: usize) -> Result<Vec<CheckpointRecord>> {
917        self.list_with_archived(limit, true)
918    }
919
920    pub(crate) fn list_with_archived(
921        &self,
922        limit: usize,
923        include_archived: bool,
924    ) -> Result<Vec<CheckpointRecord>> {
925        let archived_filter = if include_archived {
926            ""
927        } else {
928            "WHERE archived_at IS NULL"
929        };
930        let mut stmt = self.conn.prepare(&format!(
931            "SELECT id, task_id, project_path, snapshot_path, changed_files_json,
932                    pending_action_json, approval_id, created_at, archived_at, archive_reason,
933                    session_id, message_index
934             FROM checkpoints {archived_filter} ORDER BY created_at DESC LIMIT ?1"
935        ))?;
936        let rows = stmt.query_map([clamp_limit(limit)], checkpoint_from_row)?;
937        rows.collect::<rusqlite::Result<Vec<_>>>()
938            .map_err(Into::into)
939    }
940
941    /// Unarchived checkpoints of `session_id` anchored STRICTLY past
942    /// `after_message_index`, oldest first. Strict `>` is the fork-boundary
943    /// invariant: a fork at user-message index `k` keeps `messages[..k]`, and
944    /// a checkpoint stamped `message_index == k` snapshotted state from
945    /// BEFORE that user message existed — it belongs to the kept prefix, not
946    /// the discarded timeline. Oldest-first because each checkpoint is a
947    /// PRE-mutation snapshot: the oldest one past the cut holds the file
948    /// state closest to the fork point.
949    ///
950    /// # Errors
951    ///
952    /// Errors if the statement fails to prepare or run, or if any row does not
953    /// decode -- one undecodable row fails the whole call.
954    pub fn list_for_session(
955        &self,
956        session_id: &str,
957        after_message_index: i64,
958    ) -> Result<Vec<CheckpointRecord>> {
959        let mut stmt = self.conn.prepare(
960            "SELECT id, task_id, project_path, snapshot_path, changed_files_json,
961                    pending_action_json, approval_id, created_at, archived_at, archive_reason,
962                    session_id, message_index
963             FROM checkpoints
964             WHERE session_id = ?1 AND message_index > ?2 AND archived_at IS NULL
965             ORDER BY created_at ASC",
966        )?;
967        let rows = stmt.query_map(
968            params![session_id, after_message_index],
969            checkpoint_from_row,
970        )?;
971        rows.collect::<rusqlite::Result<Vec<_>>>()
972            .map_err(Into::into)
973    }
974
975    /// # Errors
976    ///
977    /// Errors if any one of the per-id updates fails, and stops there -- the ids
978    /// before it stay archived, because the loop runs outside a transaction. The
979    /// count is of rows actually changed, so ids already archived add nothing and
980    /// are not an error.
981    pub fn archive(&self, ids: &[String], reason: &str) -> Result<usize> {
982        let archived_at = now_rfc3339();
983        let mut changed = 0;
984        for id in ids {
985            changed += self.conn.execute(
986                "UPDATE checkpoints
987                 SET archived_at = COALESCE(archived_at, ?2),
988                     archive_reason = COALESCE(archive_reason, ?3)
989                 WHERE id = ?1 AND archived_at IS NULL",
990                params![id, archived_at, reason],
991            )?;
992        }
993        Ok(changed)
994    }
995
996    /// # Errors
997    ///
998    /// Errors if the count query fails.
999    pub fn count_archived(&self) -> Result<usize> {
1000        self.conn
1001            .query_row(
1002                "SELECT COUNT(*) FROM checkpoints WHERE archived_at IS NOT NULL",
1003                [],
1004                |row| row.get::<_, i64>(0),
1005            )
1006            .map(|count| count as usize)
1007            .map_err(Into::into)
1008    }
1009}
1010
1011pub struct CompactionsRepo<'a> {
1012    pub(crate) conn: &'a Connection,
1013}
1014
1015impl CompactionsRepo<'_> {
1016    /// # Errors
1017    ///
1018    /// Errors if the write statement fails, or if the row cannot be read back
1019    /// afterwards -- the reload is what produces the returned record.
1020    pub fn create(&self, new: NewCompaction) -> Result<CompactionRecord> {
1021        let id = new.id.unwrap_or_else(|| fresh_id("compaction"));
1022        self.conn.execute(
1023            "INSERT INTO compactions
1024             (id, task_id, session_id, source_token_estimate, summary_token_count,
1025              preserved_turns, archive_path, verification_status, created_at)
1026             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
1027             ON CONFLICT(id) DO UPDATE SET
1028                task_id = excluded.task_id,
1029                session_id = excluded.session_id,
1030                source_token_estimate = excluded.source_token_estimate,
1031                summary_token_count = excluded.summary_token_count,
1032                preserved_turns = excluded.preserved_turns,
1033                archive_path = excluded.archive_path,
1034                verification_status = excluded.verification_status",
1035            params![
1036                id,
1037                new.task_id,
1038                new.session_id,
1039                new.source_token_estimate,
1040                new.summary_token_count,
1041                new.preserved_turns,
1042                new.archive_path,
1043                new.verification_status,
1044                now_rfc3339(),
1045            ],
1046        )?;
1047        self.get(&id)?
1048            .context("compaction was inserted but could not be reloaded")
1049    }
1050
1051    /// # Errors
1052    ///
1053    /// Errors if the query fails or the stored row does not decode. A row that is
1054    /// not there is `Ok(None)`, not an error.
1055    pub fn get(&self, id: &str) -> Result<Option<CompactionRecord>> {
1056        self.conn
1057            .query_row(
1058                "SELECT id, task_id, session_id, source_token_estimate, summary_token_count,
1059                        preserved_turns, archive_path, verification_status, created_at
1060                 FROM compactions WHERE id = ?1",
1061                [id],
1062                compaction_from_row,
1063            )
1064            .optional()
1065            .map_err(Into::into)
1066    }
1067
1068    /// # Errors
1069    ///
1070    /// Errors if the statement fails to prepare or run, or if any row does not
1071    /// decode -- one undecodable row fails the whole call.
1072    pub fn list(&self, limit: usize) -> Result<Vec<CompactionRecord>> {
1073        let mut stmt = self.conn.prepare(
1074            "SELECT id, task_id, session_id, source_token_estimate, summary_token_count,
1075                    preserved_turns, archive_path, verification_status, created_at
1076             FROM compactions ORDER BY created_at DESC LIMIT ?1",
1077        )?;
1078        let rows = stmt.query_map([clamp_limit(limit)], compaction_from_row)?;
1079        rows.collect::<rusqlite::Result<Vec<_>>>()
1080            .map_err(Into::into)
1081    }
1082}
1083
1084pub struct PluginsRepo<'a> {
1085    pub(crate) conn: &'a Connection,
1086}
1087
1088impl PluginsRepo<'_> {
1089    /// # Errors
1090    ///
1091    /// Errors if the write statement fails, or if the row cannot be read back
1092    /// afterwards -- the reload is what produces the returned record.
1093    pub fn install(&self, new: NewPluginInstall) -> Result<PluginInstallRecord> {
1094        let now = now_rfc3339();
1095        let id = new.id.unwrap_or_else(|| fresh_id("plugin"));
1096        self.conn.execute(
1097            "INSERT INTO plugin_installs
1098             (id, name, source, version, enabled, manifest_json, installed_at, updated_at)
1099             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
1100             ON CONFLICT(id) DO UPDATE SET
1101                name = excluded.name,
1102                source = excluded.source,
1103                version = excluded.version,
1104                enabled = excluded.enabled,
1105                manifest_json = excluded.manifest_json,
1106                updated_at = excluded.updated_at",
1107            params![
1108                id,
1109                new.name,
1110                new.source,
1111                new.version,
1112                if new.enabled { 1 } else { 0 },
1113                new.manifest_json,
1114                now,
1115                now,
1116            ],
1117        )?;
1118        self.get(&id)?
1119            .context("plugin install was inserted but could not be reloaded")
1120    }
1121
1122    /// # Errors
1123    ///
1124    /// Errors if the query fails or the stored row does not decode. A row that is
1125    /// not there is `Ok(None)`, not an error.
1126    pub fn get(&self, id: &str) -> Result<Option<PluginInstallRecord>> {
1127        self.conn
1128            .query_row(
1129                "SELECT id, name, source, version, enabled, manifest_json, installed_at, updated_at
1130                 FROM plugin_installs WHERE id = ?1",
1131                [id],
1132                plugin_from_row,
1133            )
1134            .optional()
1135            .map_err(Into::into)
1136    }
1137
1138    /// # Errors
1139    ///
1140    /// Errors if the statement fails to prepare or run, or if any row does not
1141    /// decode -- one undecodable row fails the whole call.
1142    pub fn list(&self) -> Result<Vec<PluginInstallRecord>> {
1143        let mut stmt = self.conn.prepare(
1144            "SELECT id, name, source, version, enabled, manifest_json, installed_at, updated_at
1145             FROM plugin_installs ORDER BY name ASC",
1146        )?;
1147        let rows = stmt.query_map([], plugin_from_row)?;
1148        rows.collect::<rusqlite::Result<Vec<_>>>()
1149            .map_err(Into::into)
1150    }
1151
1152    /// # Errors
1153    ///
1154    /// Errors if the statement fails.
1155    pub fn set_enabled(&self, id: &str, enabled: bool) -> Result<()> {
1156        self.conn.execute(
1157            "UPDATE plugin_installs SET enabled = ?2, updated_at = ?3 WHERE id = ?1",
1158            params![id, if enabled { 1 } else { 0 }, now_rfc3339()],
1159        )?;
1160        Ok(())
1161    }
1162}
1163
1164pub struct ProviderProbesRepo<'a> {
1165    pub(crate) conn: &'a Connection,
1166}
1167
1168impl ProviderProbesRepo<'_> {
1169    /// # Errors
1170    ///
1171    /// Errors if the write statement fails, or if the row cannot be read back
1172    /// afterwards -- the reload is what produces the returned record.
1173    pub fn upsert(&self, new: NewProviderProbe) -> Result<ProviderProbeRecord> {
1174        let now = now_rfc3339();
1175        let provider = new.provider;
1176        let model_id = new.model_id;
1177        let capability_key = new.capability_key;
1178        self.conn.execute(
1179            "INSERT INTO provider_probes
1180             (provider, model_id, capability_key, capability_value, confidence, error, probed_at)
1181             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
1182             ON CONFLICT(provider, model_id, capability_key) DO UPDATE SET
1183                capability_value = excluded.capability_value,
1184                confidence = excluded.confidence,
1185                error = excluded.error,
1186                probed_at = excluded.probed_at",
1187            params![
1188                &provider,
1189                &model_id,
1190                &capability_key,
1191                new.capability_value,
1192                new.confidence,
1193                new.error,
1194                now,
1195            ],
1196        )?;
1197        self.get(&provider, &model_id, &capability_key)?
1198            .context("provider probe was inserted but could not be reloaded")
1199    }
1200
1201    /// # Errors
1202    ///
1203    /// Errors if the query fails or the stored row does not decode. A row that is
1204    /// not there is `Ok(None)`, not an error.
1205    pub fn get(
1206        &self,
1207        provider: &str,
1208        model_id: &str,
1209        capability_key: &str,
1210    ) -> Result<Option<ProviderProbeRecord>> {
1211        self.conn
1212            .query_row(
1213                "SELECT provider, model_id, capability_key, capability_value, confidence, error, probed_at
1214                 FROM provider_probes
1215                 WHERE provider = ?1 AND model_id = ?2 AND capability_key = ?3",
1216                params![provider, model_id, capability_key],
1217                provider_probe_from_row,
1218            )
1219            .optional()
1220            .map_err(Into::into)
1221    }
1222
1223    /// # Errors
1224    ///
1225    /// Errors if the statement fails to prepare or run, or if any row does not
1226    /// decode -- one undecodable row fails the whole call.
1227    pub fn list(
1228        &self,
1229        provider: Option<&str>,
1230        model_id: Option<&str>,
1231    ) -> Result<Vec<ProviderProbeRecord>> {
1232        let mut stmt = self.conn.prepare(
1233            "SELECT provider, model_id, capability_key, capability_value, confidence, error, probed_at
1234             FROM provider_probes ORDER BY provider ASC, model_id ASC, capability_key ASC",
1235        )?;
1236        let rows = stmt.query_map([], provider_probe_from_row)?;
1237        let mut out = Vec::new();
1238        for row in rows {
1239            let probe = row?;
1240            if provider.is_some_and(|p| probe.provider != p) {
1241                continue;
1242            }
1243            if model_id.is_some_and(|m| probe.model_id != m) {
1244                continue;
1245            }
1246            out.push(probe);
1247        }
1248        Ok(out)
1249    }
1250}
1251
1252pub struct PairingTokensRepo<'a> {
1253    pub(crate) conn: &'a Connection,
1254}
1255
1256impl PairingTokensRepo<'_> {
1257    /// # Errors
1258    ///
1259    /// Errors if the write statement fails, or if the row cannot be read back
1260    /// afterwards -- the reload is what produces the returned record.
1261    pub fn create(
1262        &self,
1263        token_hash: &str,
1264        label: Option<&str>,
1265        expires_at: Option<&str>,
1266    ) -> Result<PairingTokenRecord> {
1267        let id = fresh_id("pairing");
1268        self.conn.execute(
1269            "INSERT INTO pairing_tokens
1270                 (id, token_hash, label, enabled, created_at, last_used_at, expires_at)
1271             VALUES (?1, ?2, ?3, 1, ?4, NULL, ?5)",
1272            params![id, token_hash, label, now_rfc3339(), expires_at],
1273        )?;
1274        self.get(&id)?
1275            .context("pairing token was inserted but could not be reloaded")
1276    }
1277
1278    /// # Errors
1279    ///
1280    /// Errors if the query fails or the stored row does not decode. A row that is
1281    /// not there is `Ok(None)`, not an error.
1282    pub fn get(&self, id: &str) -> Result<Option<PairingTokenRecord>> {
1283        self.conn
1284            .query_row(
1285                "SELECT id, token_hash, label, enabled, created_at, last_used_at, expires_at
1286                 FROM pairing_tokens WHERE id = ?1",
1287                [id],
1288                pairing_from_row,
1289            )
1290            .optional()
1291            .map_err(Into::into)
1292    }
1293
1294    /// Look up an enabled, unexpired pairing token by hash.
1295    ///
1296    /// The hash is **not** matched in SQL (`WHERE token_hash = ?`) — that is a
1297    /// DB-level equality on the secret and a theoretical timing channel.
1298    /// Instead we fetch the enabled, unexpired candidates (neither predicate is
1299    /// secret) and compare each hash in constant time. The candidate count is
1300    /// tiny and not secret. All candidates are scanned without early exit so the
1301    /// timing doesn't reveal which (if any) token matched.
1302    ///
1303    /// # Errors
1304    ///
1305    /// Errors if the query fails or a row does not decode. No match is `Ok(None)`,
1306    /// not an error, and so is a token that matches but has expired.
1307    pub fn verify_token(&self, token_hash: &str) -> Result<Option<PairingTokenRecord>> {
1308        // Expiry is evaluated in Rust as a parsed instant (see `is_expired`),
1309        // not via a SQL `expires_at > ?` string compare. The skipped-because-
1310        // expired branch is on non-secret data; the hash itself is still matched
1311        // in constant time over every non-expired candidate with no early exit.
1312        let now = chrono::Utc::now();
1313        let mut stmt = self.conn.prepare(
1314            "SELECT id, token_hash, label, enabled, created_at, last_used_at, expires_at
1315             FROM pairing_tokens
1316             WHERE enabled = 1",
1317        )?;
1318        let candidates = stmt
1319            .query_map([], pairing_from_row)?
1320            .collect::<rusqlite::Result<Vec<_>>>()?;
1321        let mut found = None;
1322        for record in candidates {
1323            if is_expired(record.expires_at.as_deref(), now) {
1324                continue;
1325            }
1326            if ct_eq(record.token_hash.as_bytes(), token_hash.as_bytes()) {
1327                found = Some(record);
1328            }
1329        }
1330        Ok(found)
1331    }
1332
1333    /// # Errors
1334    ///
1335    /// Errors if the statement fails to prepare or run, or if any row does not
1336    /// decode -- one undecodable row fails the whole call.
1337    pub fn list(&self) -> Result<Vec<PairingTokenRecord>> {
1338        let mut stmt = self.conn.prepare(
1339            "SELECT id, token_hash, label, enabled, created_at, last_used_at, expires_at
1340             FROM pairing_tokens ORDER BY created_at DESC",
1341        )?;
1342        let rows = stmt.query_map([], pairing_from_row)?;
1343        rows.collect::<rusqlite::Result<Vec<_>>>()
1344            .map_err(Into::into)
1345    }
1346
1347    /// Like [`list`](Self::list), but with `token_hash` blanked. Use for any
1348    /// surface that crosses a trust boundary — e.g. the daemon snapshot served
1349    /// over the local socket to same-UID processes. The hash is
1350    /// secret-equivalent (it's all `verify_token` compares against) and must
1351    /// not leave the store.
1352    ///
1353    /// # Errors
1354    ///
1355    /// Errors if the underlying query fails or any row does not decode.
1356    pub fn list_redacted(&self) -> Result<Vec<PairingTokenRecord>> {
1357        Ok(self
1358            .list()?
1359            .into_iter()
1360            .map(|mut record| {
1361                record.token_hash = String::new();
1362                record
1363            })
1364            .collect())
1365    }
1366
1367    /// # Errors
1368    ///
1369    /// Errors if the statement fails.
1370    pub fn mark_used(&self, id: &str) -> Result<()> {
1371        self.conn.execute(
1372            "UPDATE pairing_tokens SET last_used_at = ?2 WHERE id = ?1 AND enabled = 1",
1373            params![id, now_rfc3339()],
1374        )?;
1375        Ok(())
1376    }
1377
1378    /// Revoke a token by disabling it. Returns `true` if a live token was
1379    /// revoked, `false` if it was already disabled or unknown.
1380    ///
1381    /// # Errors
1382    ///
1383    /// Errors if the UPDATE fails. A token that was already revoked, or absent, is
1384    /// `Ok(false)`, not an error.
1385    pub fn revoke(&self, id: &str) -> Result<bool> {
1386        let changed = self.conn.execute(
1387            "UPDATE pairing_tokens SET enabled = 0 WHERE id = ?1 AND enabled = 1",
1388            params![id],
1389        )?;
1390        Ok(changed > 0)
1391    }
1392}