1use crate::snapshot::{EntryKind, Manifest};
17use std::collections::BTreeSet;
18use std::path::Path;
19use std::time::{Duration, SystemTime, UNIX_EPOCH};
20
21const SCHEMA_VERSION: i64 = 2;
22const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ManifestRole {
28 Pre,
29 Post,
30}
31
32impl ManifestRole {
33 fn as_str(self) -> &'static str {
34 match self {
35 Self::Pre => "pre",
36 Self::Post => "post",
37 }
38 }
39}
40
41#[derive(Debug, thiserror::Error)]
42pub enum JournalError {
43 #[error("sqlite error: {0}")]
44 Sqlite(#[from] rusqlite::Error),
45 #[error("journal io: {0}")]
46 Io(#[from] std::io::Error),
47 #[error("manifest serialization: {0}")]
48 Serde(#[from] serde_json::Error),
49 #[error("action {0} not found")]
50 ActionNotFound(i64),
51 #[error("no completable action with tool_use_id {tool_use_id} in session {session_id}")]
52 NoPendingForToolUse {
53 session_id: String,
54 tool_use_id: String,
55 },
56 #[error("cannot undo action {id}: status is {status:?} (must be completed or abandoned)")]
57 NotUndoable { id: i64, status: ActionStatus },
58 #[error(
59 "cannot undo action {id}: it reverses another undo; run undo on the original action {original} instead"
60 )]
61 UndoTooDeep { id: i64, original: i64 },
62 #[error(
63 "manifest was written by a newer doover (schema {found}, this build understands {})",
64 crate::snapshot::MANIFEST_SCHEMA
65 )]
66 ManifestTooNew { found: u32 },
67 #[error(
68 "journal schema version {found} is newer than this doover understands ({SCHEMA_VERSION})"
69 )]
70 SchemaTooNew { found: i64 },
71}
72
73pub type ActionId = i64;
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum ActionKind {
77 Command,
78 Undo,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum ActionStatus {
83 Pending,
84 Completed,
85 Abandoned,
86 Undone,
87}
88
89impl ActionKind {
90 fn parse(s: &str) -> Self {
91 match s {
92 "undo" => Self::Undo,
93 _ => Self::Command,
94 }
95 }
96}
97
98impl ActionStatus {
99 fn as_str(self) -> &'static str {
100 match self {
101 Self::Pending => "pending",
102 Self::Completed => "completed",
103 Self::Abandoned => "abandoned",
104 Self::Undone => "undone",
105 }
106 }
107 fn parse(s: &str) -> Self {
108 match s {
109 "completed" => Self::Completed,
110 "abandoned" => Self::Abandoned,
111 "undone" => Self::Undone,
112 _ => Self::Pending,
113 }
114 }
115}
116
117pub struct NewAction<'a> {
118 pub session_id: &'a str,
119 pub tool_use_id: Option<&'a str>,
120 pub raw_command: &'a str,
121 pub effect: &'a str,
123 pub rule_id: Option<&'a str>,
124 pub has_unknown: bool,
125}
126
127#[derive(Debug, Clone)]
128pub struct ActionRecord {
129 pub id: ActionId,
130 pub session_id: String,
131 pub seq: i64,
132 pub kind: ActionKind,
133 pub tool_use_id: Option<String>,
134 pub raw_command: String,
135 pub effect: String,
136 pub rule_id: Option<String>,
137 pub has_unknown: bool,
138 pub status: ActionStatus,
139 pub target_action_id: Option<ActionId>,
140 pub target_prior_status: Option<ActionStatus>,
143 pub pinned: bool,
144 pub started_at_ms: i64,
145 pub duration_ms: Option<i64>,
146 pub note: Option<String>,
147}
148
149fn is_busy(e: &JournalError) -> bool {
152 matches!(
153 e,
154 JournalError::Sqlite(rusqlite::Error::SqliteFailure(f, _))
155 if matches!(
156 f.code,
157 rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
158 )
159 )
160}
161
162pub fn now_ms() -> i64 {
163 SystemTime::now()
164 .duration_since(UNIX_EPOCH)
165 .map(|d| d.as_millis() as i64)
166 .unwrap_or(0)
167}
168
169pub struct Journal {
170 conn: rusqlite::Connection,
171}
172
173impl Journal {
174 pub fn open(path: &Path) -> Result<Self, JournalError> {
175 let mut last = None;
181 for _ in 0..40 {
182 match Self::open_attempt(path) {
183 Err(e) if is_busy(&e) => {
184 last = Some(e);
185 std::thread::sleep(Duration::from_millis(25));
186 }
187 other => return other,
188 }
189 }
190 Err(last.expect("loop ran at least once"))
191 }
192
193 fn open_attempt(path: &Path) -> Result<Self, JournalError> {
194 let conn = rusqlite::Connection::open(path)?;
195 #[cfg(unix)]
199 {
200 use std::os::unix::fs::PermissionsExt;
201 let private = std::fs::Permissions::from_mode(0o600);
202 std::fs::set_permissions(path, private.clone())?;
203 for ext in ["-wal", "-shm"] {
204 let mut side = path.as_os_str().to_owned();
205 side.push(ext);
206 let _ = std::fs::set_permissions(std::path::Path::new(&side), private.clone());
207 }
208 }
209 conn.busy_timeout(BUSY_TIMEOUT)?;
210 conn.pragma_update(None, "journal_mode", "wal")?;
211 conn.pragma_update(None, "synchronous", "NORMAL")?;
212 conn.pragma_update(None, "foreign_keys", "ON")?;
213
214 conn.execute_batch("BEGIN EXCLUSIVE;")?;
223 let migrate = (|| -> Result<(), JournalError> {
224 let mut version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
225 if version > SCHEMA_VERSION {
226 return Err(JournalError::SchemaTooNew { found: version });
227 }
228 if version == 0 {
231 conn.execute_batch(
232 "CREATE TABLE IF NOT EXISTS sessions(
233 id TEXT PRIMARY KEY,
234 harness TEXT NOT NULL,
235 cwd TEXT NOT NULL,
236 started_at_ms INTEGER NOT NULL,
237 ended_at_ms INTEGER
238 );
239 CREATE TABLE IF NOT EXISTS actions(
240 id INTEGER PRIMARY KEY,
241 session_id TEXT NOT NULL REFERENCES sessions(id),
242 seq INTEGER NOT NULL,
243 kind TEXT NOT NULL CHECK(kind IN ('command','undo')),
244 tool_use_id TEXT,
245 raw_command TEXT NOT NULL,
246 effect TEXT NOT NULL,
247 rule_id TEXT,
248 has_unknown INTEGER NOT NULL DEFAULT 0,
249 status TEXT NOT NULL
250 CHECK(status IN ('pending','completed','abandoned','undone')),
251 target_action_id INTEGER REFERENCES actions(id),
252 target_prior_status TEXT,
253 pinned INTEGER NOT NULL DEFAULT 0,
254 started_at_ms INTEGER NOT NULL,
255 duration_ms INTEGER,
256 note TEXT,
257 UNIQUE(session_id, seq)
258 );
259 CREATE INDEX IF NOT EXISTS idx_actions_session ON actions(session_id);
260 CREATE INDEX IF NOT EXISTS idx_actions_tool_use ON actions(session_id, tool_use_id);
261 CREATE TABLE IF NOT EXISTS manifests(
262 id INTEGER PRIMARY KEY,
263 action_id INTEGER NOT NULL REFERENCES actions(id),
264 path TEXT NOT NULL,
265 manifest_json TEXT NOT NULL,
266 hashes TEXT NOT NULL,
267 truncated INTEGER NOT NULL
268 );
269 CREATE INDEX IF NOT EXISTS idx_manifests_action ON manifests(action_id);
270 PRAGMA user_version = 1;",
271 )?;
272 version = 1;
273 }
274 if version == 1 {
275 conn.execute_batch(
278 "ALTER TABLE manifests ADD COLUMN role TEXT NOT NULL DEFAULT 'pre'
279 CHECK(role IN ('pre','post'));
280 PRAGMA user_version = 2;",
281 )?;
282 }
283 Ok(())
284 })();
285 match &migrate {
286 Ok(()) => conn.execute_batch("COMMIT;")?,
287 Err(_) => conn.execute_batch("ROLLBACK;").unwrap_or(()),
288 }
289 migrate?;
290 Ok(Self { conn })
291 }
292
293 pub fn begin_session(&self, id: &str, harness: &str, cwd: &str) -> Result<(), JournalError> {
294 self.conn.execute(
295 "INSERT INTO sessions(id, harness, cwd, started_at_ms) VALUES (?1, ?2, ?3, ?4)
296 ON CONFLICT(id) DO UPDATE SET cwd = excluded.cwd",
297 rusqlite::params![id, harness, cwd, now_ms()],
298 )?;
299 Ok(())
300 }
301
302 pub fn start_action(&self, new: &NewAction) -> Result<ActionId, JournalError> {
306 self.conn.execute_batch("BEGIN IMMEDIATE;")?;
307 let result = (|| -> Result<ActionId, JournalError> {
308 self.conn.execute(
309 "UPDATE actions SET status = 'abandoned',
310 note = COALESCE(note, 'no post event received')
311 WHERE session_id = ?1 AND status = 'pending'",
312 [new.session_id],
313 )?;
314 let seq: i64 = self.conn.query_row(
315 "SELECT COALESCE(MAX(seq), 0) + 1 FROM actions WHERE session_id = ?1",
316 [new.session_id],
317 |r| r.get(0),
318 )?;
319 self.conn.execute(
320 "INSERT INTO actions(session_id, seq, kind, tool_use_id, raw_command,
321 effect, rule_id, has_unknown, status, started_at_ms)
322 VALUES (?1, ?2, 'command', ?3, ?4, ?5, ?6, ?7, 'pending', ?8)",
323 rusqlite::params![
324 new.session_id,
325 seq,
326 new.tool_use_id,
327 new.raw_command,
328 new.effect,
329 new.rule_id,
330 new.has_unknown,
331 now_ms(),
332 ],
333 )?;
334 Ok(self.conn.last_insert_rowid())
335 })();
336 match &result {
337 Ok(_) => self.conn.execute_batch("COMMIT;")?,
338 Err(_) => self.conn.execute_batch("ROLLBACK;").unwrap_or(()),
339 }
340 result
341 }
342
343 pub fn complete_by_tool_use(
348 &self,
349 session_id: &str,
350 tool_use_id: &str,
351 duration_ms: i64,
352 ) -> Result<ActionId, JournalError> {
353 let updated: Option<i64> = self
354 .conn
355 .query_row(
356 "UPDATE actions SET status = 'completed', duration_ms = ?3
359 WHERE id = (SELECT id FROM actions
360 WHERE session_id = ?1 AND tool_use_id = ?2
361 AND status IN ('pending', 'abandoned')
362 ORDER BY seq DESC LIMIT 1)
363 RETURNING id",
364 rusqlite::params![session_id, tool_use_id, duration_ms],
365 |r| r.get(0),
366 )
367 .map(Some)
368 .or_else(|e| match e {
369 rusqlite::Error::QueryReturnedNoRows => Ok(None),
370 other => Err(other),
371 })?;
372 updated.ok_or_else(|| JournalError::NoPendingForToolUse {
373 session_id: session_id.to_string(),
374 tool_use_id: tool_use_id.to_string(),
375 })
376 }
377
378 pub fn end_session(&self, id: &str) -> Result<(), JournalError> {
379 self.conn.execute(
380 "UPDATE actions SET status = 'abandoned',
381 note = COALESCE(note, 'session ended without post event')
382 WHERE session_id = ?1 AND status = 'pending'",
383 [id],
384 )?;
385 self.conn.execute(
386 "UPDATE sessions SET ended_at_ms = ?2 WHERE id = ?1",
387 rusqlite::params![id, now_ms()],
388 )?;
389 Ok(())
390 }
391
392 pub fn attach_manifest(
393 &self,
394 action: ActionId,
395 manifest: &Manifest,
396 role: ManifestRole,
397 ) -> Result<(), JournalError> {
398 let json = serde_json::to_string(manifest)?;
399 let hashes: Vec<&str> = manifest
400 .entries
401 .iter()
402 .filter_map(|e| match &e.kind {
403 EntryKind::File { hash, .. } => Some(hash.as_str()),
404 _ => None,
405 })
406 .collect();
407 self.conn.execute(
408 "INSERT INTO manifests(action_id, path, manifest_json, hashes, truncated, role)
409 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
410 rusqlite::params![
411 action,
412 manifest.path.to_string_lossy(),
413 json,
414 serde_json::to_string(&hashes)?,
415 manifest.truncated,
416 role.as_str(),
417 ],
418 )?;
419 Ok(())
420 }
421
422 pub fn manifests(&self, action: ActionId) -> Result<Vec<Manifest>, JournalError> {
424 self.manifests_query(
425 "SELECT manifest_json FROM manifests WHERE action_id = ?1 ORDER BY id",
426 action,
427 )
428 }
429
430 pub fn manifests_by_role(
433 &self,
434 action: ActionId,
435 role: ManifestRole,
436 ) -> Result<Vec<Manifest>, JournalError> {
437 match role {
438 ManifestRole::Pre => self.manifests_query(
439 "SELECT manifest_json FROM manifests
440 WHERE action_id = ?1 AND role = 'pre' ORDER BY id",
441 action,
442 ),
443 ManifestRole::Post => self.manifests_query(
444 "SELECT manifest_json FROM manifests
445 WHERE action_id = ?1 AND role = 'post' ORDER BY id",
446 action,
447 ),
448 }
449 }
450
451 fn manifests_query(&self, sql: &str, action: ActionId) -> Result<Vec<Manifest>, JournalError> {
452 let mut stmt = self.conn.prepare(sql)?;
453 let rows = stmt.query_map([action], |r| r.get::<_, String>(0))?;
454 let mut out: Vec<Manifest> = Vec::new();
455 for json in rows {
456 let m: Manifest = serde_json::from_str(&json?)?;
457 if m.schema > crate::snapshot::MANIFEST_SCHEMA {
458 return Err(JournalError::ManifestTooNew { found: m.schema });
459 }
460 out.push(m);
461 }
462 Ok(out)
463 }
464
465 pub fn latest_undoable(&self) -> Result<Option<ActionRecord>, JournalError> {
468 let mut stmt = self.conn.prepare(&format!(
469 "{SELECT_ACTION} WHERE kind = 'command'
470 AND status IN ('completed','abandoned')
471 AND EXISTS (SELECT 1 FROM manifests m
472 WHERE m.action_id = actions.id AND m.role = 'pre')
473 ORDER BY id DESC LIMIT 1"
474 ))?;
475 let mut rows = stmt.query_map([], row_to_action)?;
476 Ok(rows.next().transpose()?)
477 }
478
479 pub fn recent_actions(&self, limit: i64) -> Result<Vec<ActionRecord>, JournalError> {
481 let mut stmt = self
482 .conn
483 .prepare(&format!("{SELECT_ACTION} ORDER BY id DESC LIMIT ?1"))?;
484 let rows = stmt.query_map([limit], row_to_action)?;
485 Ok(rows.collect::<Result<Vec<_>, _>>()?)
486 }
487
488 pub fn latest_redoable(&self) -> Result<Option<ActionRecord>, JournalError> {
490 let mut stmt = self.conn.prepare(&format!(
491 "{SELECT_ACTION} WHERE kind = 'undo' AND status = 'completed'
492 ORDER BY id DESC LIMIT 1"
493 ))?;
494 let mut rows = stmt.query_map([], row_to_action)?;
495 Ok(rows.next().transpose()?)
496 }
497
498 pub fn record_undo(
512 &self,
513 session_id: &str,
514 target: ActionId,
515 ) -> Result<ActionId, JournalError> {
516 self.conn.execute_batch("BEGIN IMMEDIATE;")?;
517 let result = (|| -> Result<ActionId, JournalError> {
518 let target_rec = self.action(target)?;
519 if matches!(
520 target_rec.status,
521 ActionStatus::Pending | ActionStatus::Undone
522 ) {
523 return Err(JournalError::NotUndoable {
524 id: target,
525 status: target_rec.status,
526 });
527 }
528 if target_rec.kind == ActionKind::Undo {
529 let inner = self.action(
530 target_rec
531 .target_action_id
532 .ok_or(JournalError::ActionNotFound(target))?,
533 )?;
534 if inner.kind == ActionKind::Undo {
535 let mut original = inner;
537 let mut hops = 0;
538 while original.kind == ActionKind::Undo && hops < 64 {
539 match original.target_action_id {
540 Some(t) => original = self.action(t)?,
541 None => break,
542 }
543 hops += 1;
544 }
545 return Err(JournalError::UndoTooDeep {
546 id: target,
547 original: original.id,
548 });
549 }
550 }
551 let seq: i64 = self.conn.query_row(
552 "SELECT COALESCE(MAX(seq), 0) + 1 FROM actions WHERE session_id = ?1",
553 [session_id],
554 |r| r.get(0),
555 )?;
556 self.conn.execute(
557 "INSERT INTO actions(session_id, seq, kind, raw_command, effect,
558 status, target_action_id, target_prior_status, started_at_ms)
559 VALUES (?1, ?2, 'undo', ?3, 'destructive', 'completed', ?4, ?5, ?6)",
560 rusqlite::params![
561 session_id,
562 seq,
563 format!("undo of action {target}"),
564 target,
565 target_rec.status.as_str(),
566 now_ms(),
567 ],
568 )?;
569 let undo_id = self.conn.last_insert_rowid();
570 self.conn.execute(
571 "UPDATE actions SET status = 'undone' WHERE id = ?1",
572 [target],
573 )?;
574 if target_rec.kind == ActionKind::Undo {
578 if let (Some(original), Some(prior)) =
579 (target_rec.target_action_id, target_rec.target_prior_status)
580 {
581 self.conn.execute(
582 "UPDATE actions SET status = ?2 WHERE id = ?1",
583 rusqlite::params![original, prior.as_str()],
584 )?;
585 }
586 }
587 Ok(undo_id)
588 })();
589 match &result {
590 Ok(_) => self.conn.execute_batch("COMMIT;")?,
591 Err(_) => self.conn.execute_batch("ROLLBACK;").unwrap_or(()),
592 }
593 result
594 }
595
596 pub fn add_note(&self, action: ActionId, note: &str) -> Result<(), JournalError> {
599 let n = self.conn.execute(
600 "UPDATE actions SET note = COALESCE(note || char(10), '') || ?2 WHERE id = ?1",
601 rusqlite::params![action, note],
602 )?;
603 if n == 0 {
604 return Err(JournalError::ActionNotFound(action));
605 }
606 Ok(())
607 }
608
609 pub fn set_pinned(&self, action: ActionId, pinned: bool) -> Result<(), JournalError> {
610 let n = self.conn.execute(
611 "UPDATE actions SET pinned = ?2 WHERE id = ?1",
612 rusqlite::params![action, pinned],
613 )?;
614 if n == 0 {
615 return Err(JournalError::ActionNotFound(action));
616 }
617 Ok(())
618 }
619
620 pub fn live_hashes(&self, retain_after_ms: i64) -> Result<BTreeSet<String>, JournalError> {
625 let mut stmt = self.conn.prepare(
626 "SELECT m.hashes FROM manifests m
631 JOIN actions a ON a.id = m.action_id
632 WHERE a.pinned = 1 OR a.started_at_ms >= ?1 OR a.status = 'pending'
633 OR EXISTS (SELECT 1 FROM actions r
634 WHERE r.target_action_id = a.id
635 AND (r.pinned = 1 OR r.started_at_ms >= ?1))",
636 )?;
637 let rows = stmt.query_map([retain_after_ms], |r| r.get::<_, String>(0))?;
638 let mut out = BTreeSet::new();
639 for json in rows {
640 let hashes: Vec<String> = serde_json::from_str(&json?)?;
641 out.extend(hashes);
642 }
643 Ok(out)
644 }
645
646 pub fn action(&self, id: ActionId) -> Result<ActionRecord, JournalError> {
647 self.conn
648 .query_row(
649 &format!("{SELECT_ACTION} WHERE id = ?1"),
650 [id],
651 row_to_action,
652 )
653 .map_err(|e| match e {
654 rusqlite::Error::QueryReturnedNoRows => JournalError::ActionNotFound(id),
655 other => other.into(),
656 })
657 }
658
659 pub fn session_actions(&self, session_id: &str) -> Result<Vec<ActionRecord>, JournalError> {
660 let mut stmt = self.conn.prepare(&format!(
661 "{SELECT_ACTION} WHERE session_id = ?1 ORDER BY seq"
662 ))?;
663 let rows = stmt.query_map([session_id], row_to_action)?;
664 Ok(rows.collect::<Result<Vec<_>, _>>()?)
665 }
666
667 pub fn max_started_at(&self) -> Result<Option<i64>, JournalError> {
671 Ok(self
672 .conn
673 .query_row("SELECT MAX(started_at_ms) FROM actions", [], |r| r.get(0))?)
674 }
675
676 pub fn prune_before(&self, cutoff_ms: i64, dry_run: bool) -> Result<(u64, u64), JournalError> {
683 const CANDIDATES: &str = "FROM actions a
684 WHERE a.started_at_ms < ?1 AND a.pinned = 0 AND a.status != 'pending'
685 AND NOT EXISTS (SELECT 1 FROM actions r WHERE r.target_action_id = a.id)";
686 if dry_run {
687 let n: i64 = self.conn.query_row(
688 &format!("SELECT COUNT(*) {CANDIDATES}"),
689 [cutoff_ms],
690 |r| r.get(0),
691 )?;
692 let s: i64 = self.conn.query_row(
695 "SELECT COUNT(*) FROM sessions s
696 WHERE s.started_at_ms < ?1
697 AND NOT EXISTS (
698 SELECT 1 FROM actions a WHERE a.session_id = s.id
699 AND NOT (a.started_at_ms < ?1 AND a.pinned = 0
700 AND a.status != 'pending'
701 AND NOT EXISTS (SELECT 1 FROM actions r
702 WHERE r.target_action_id = a.id)))",
703 [cutoff_ms],
704 |r| r.get(0),
705 )?;
706 return Ok((n as u64, s as u64));
707 }
708 self.conn.execute_batch("BEGIN IMMEDIATE;")?;
709 let result = (|| -> Result<(u64, u64), JournalError> {
710 self.conn.execute(
711 &format!("DELETE FROM manifests WHERE action_id IN (SELECT a.id {CANDIDATES})"),
712 [cutoff_ms],
713 )?;
714 let actions = self.conn.execute(
715 &format!("DELETE FROM actions WHERE id IN (SELECT a.id {CANDIDATES})"),
716 [cutoff_ms],
717 )? as u64;
718 let sessions = self.conn.execute(
723 "DELETE FROM sessions
724 WHERE started_at_ms < ?1
725 AND id NOT IN (SELECT DISTINCT session_id FROM actions)",
726 [cutoff_ms],
727 )? as u64;
728 Ok((actions, sessions))
729 })();
730 match &result {
731 Ok(_) => self.conn.execute_batch("COMMIT;")?,
732 Err(_) => self.conn.execute_batch("ROLLBACK;").unwrap_or(()),
733 }
734 result
735 }
736
737 pub fn oldest_evictable_batch_end(
743 &self,
744 batch: u32,
745 before_ms: i64,
746 ) -> Result<Option<i64>, JournalError> {
747 let mut stmt = self.conn.prepare(
748 "SELECT a.started_at_ms FROM actions a
749 WHERE a.started_at_ms < ?1 AND a.pinned = 0 AND a.status != 'pending'
750 AND NOT EXISTS (SELECT 1 FROM actions r WHERE r.target_action_id = a.id)
751 ORDER BY a.started_at_ms ASC
752 LIMIT ?2",
753 )?;
754 let mut last = None;
755 for row in stmt.query_map(rusqlite::params![before_ms, batch], |r| r.get::<_, i64>(0))? {
756 last = Some(row?);
757 }
758 Ok(last)
759 }
760
761 pub fn set_started_at_for_test(
764 &self,
765 action: ActionId,
766 at_ms: i64,
767 ) -> Result<(), JournalError> {
768 let n = self.conn.execute(
769 "UPDATE actions SET started_at_ms = ?2 WHERE id = ?1",
770 rusqlite::params![action, at_ms],
771 )?;
772 if n == 0 {
773 return Err(JournalError::ActionNotFound(action));
774 }
775 Ok(())
776 }
777
778 pub fn set_session_started_at_for_test(
781 &self,
782 session_id: &str,
783 at_ms: i64,
784 ) -> Result<(), JournalError> {
785 self.conn.execute(
786 "UPDATE sessions SET started_at_ms = ?2 WHERE id = ?1",
787 rusqlite::params![session_id, at_ms],
788 )?;
789 Ok(())
790 }
791
792 pub fn stats(&self) -> Result<(u64, Vec<(String, u64)>), JournalError> {
794 let sessions: i64 = self
795 .conn
796 .query_row("SELECT COUNT(*) FROM sessions", [], |r| r.get(0))?;
797 let mut stmt = self
798 .conn
799 .prepare("SELECT status, COUNT(*) FROM actions GROUP BY status ORDER BY status")?;
800 let rows = stmt.query_map([], |r| {
801 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)? as u64))
802 })?;
803 Ok((sessions as u64, rows.collect::<Result<Vec<_>, _>>()?))
804 }
805
806 pub fn integrity_check(&self) -> Result<bool, JournalError> {
807 let verdict: String = self
808 .conn
809 .query_row("PRAGMA integrity_check", [], |r| r.get(0))?;
810 Ok(verdict == "ok")
811 }
812}
813
814const SELECT_ACTION: &str = "SELECT id, session_id, seq, kind, tool_use_id, raw_command, effect,
815 rule_id, has_unknown, status, target_action_id, target_prior_status, pinned,
816 started_at_ms, duration_ms, note
817 FROM actions";
818
819fn row_to_action(r: &rusqlite::Row) -> Result<ActionRecord, rusqlite::Error> {
820 Ok(ActionRecord {
821 id: r.get(0)?,
822 session_id: r.get(1)?,
823 seq: r.get(2)?,
824 kind: ActionKind::parse(&r.get::<_, String>(3)?),
825 tool_use_id: r.get(4)?,
826 raw_command: r.get(5)?,
827 effect: r.get(6)?,
828 rule_id: r.get(7)?,
829 has_unknown: r.get(8)?,
830 status: ActionStatus::parse(&r.get::<_, String>(9)?),
831 target_action_id: r.get(10)?,
832 target_prior_status: r
833 .get::<_, Option<String>>(11)?
834 .map(|s| ActionStatus::parse(&s)),
835 pinned: r.get(12)?,
836 started_at_ms: r.get(13)?,
837 duration_ms: r.get(14)?,
838 note: r.get(15)?,
839 })
840}