1use anyhow::{Context, Result};
7use rusqlite::{Connection, OptionalExtension, params};
8
9use super::*;
10
11pub 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 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 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 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 pub fn create(&self, new: NewTask) -> Result<TaskRecord> {
103 let now = now_rfc3339();
104 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 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 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 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 let rows = stmt.query_map([clamp_limit(limit)], task_from_row_opt)?;
185 collect_tolerant(rows)
186 }
187
188 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 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 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 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 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 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 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 pub fn start(&self, mut new: NewToolRun) -> Result<ToolRunRecord> {
335 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 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 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 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 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 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 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 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 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 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 pub fn decide(&self, id: &str, user_decision: &str) -> Result<()> {
578 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 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 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 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 pub fn list_pending(&self) -> Result<Vec<ApprovalRecord>> {
656 self.list_pending_with_archived(false)
657 }
658
659 pub fn list_pending_all(&self) -> Result<Vec<ApprovalRecord>> {
663 self.list_pending_with_archived(true)
664 }
665
666 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 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 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 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 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 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 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 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 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 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 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 pub fn list(&self, limit: usize) -> Result<Vec<CheckpointRecord>> {
910 self.list_with_archived(limit, false)
911 }
912
913 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn verify_token(&self, token_hash: &str) -> Result<Option<PairingTokenRecord>> {
1308 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 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 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 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 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}