1use std::collections::HashMap;
13use std::fs::{self, OpenOptions};
14use std::io::{BufRead, BufReader, Write};
15use std::path::{Path, PathBuf};
16use std::sync::{Arc, Mutex, MutexGuard};
17
18use anyhow::{Context, Result};
19use chrono::Utc;
20use rusqlite::{Connection, OptionalExtension, params};
21use serde::{Deserialize, Serialize};
22use serde_json::Value;
23
24pub use codewhale_protocol::ThreadStatus;
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(rename_all = "snake_case")]
33pub enum SessionSource {
34 Interactive,
36 Resume,
38 Fork,
40 Api,
42 Unknown,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ThreadMetadata {
52 pub id: String,
54 pub rollout_path: Option<PathBuf>,
56 pub preview: String,
58 pub ephemeral: bool,
60 pub model_provider: String,
62 pub created_at: i64,
64 pub updated_at: i64,
66 pub status: ThreadStatus,
68 pub path: Option<PathBuf>,
70 pub cwd: PathBuf,
72 pub cli_version: String,
74 pub source: SessionSource,
76 pub name: Option<String>,
78 pub sandbox_policy: Option<String>,
80 pub approval_mode: Option<String>,
82 pub archived: bool,
84 pub archived_at: Option<i64>,
86 pub git_sha: Option<String>,
88 pub git_branch: Option<String>,
90 pub git_origin_url: Option<String>,
92 pub memory_mode: Option<String>,
94 pub current_leaf_id: Option<i64>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct DynamicToolRecord {
101 pub position: i64,
103 pub name: String,
105 pub description: Option<String>,
107 pub input_schema: Value,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct MessageRecord {
117 pub id: i64,
119 pub thread_id: String,
121 pub role: String,
123 pub content: String,
125 pub item: Option<Value>,
127 pub created_at: i64,
129 pub parent_entry_id: Option<i64>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct CheckpointRecord {
136 pub thread_id: String,
138 pub checkpoint_id: String,
140 pub state: Value,
142 pub created_at: i64,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
150#[serde(rename_all = "snake_case")]
151pub enum JobStateStatus {
152 Queued,
154 Running,
156 Paused,
158 Completed,
160 Failed,
162 Cancelled,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct JobStateRecord {
169 pub id: String,
171 pub name: String,
173 pub status: JobStateStatus,
175 pub progress: Option<u8>,
177 pub detail: Option<String>,
179 pub created_at: i64,
181 pub updated_at: i64,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
187#[serde(rename_all = "snake_case")]
188pub enum ThreadGoalStatus {
189 Active,
191 Paused,
193 Blocked,
195 UsageLimited,
197 BudgetLimited,
199 Complete,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
205pub struct ThreadGoalRecord {
206 pub thread_id: String,
208 pub goal_id: String,
210 pub objective: String,
212 pub status: ThreadGoalStatus,
214 pub token_budget: Option<i64>,
216 pub tokens_used: i64,
218 pub time_used_seconds: i64,
220 pub continuation_count: i64,
222 pub created_at: i64,
224 pub updated_at: i64,
226}
227
228#[derive(Debug, Clone)]
230pub struct ThreadListFilters {
231 pub include_archived: bool,
233 pub limit: Option<usize>,
235}
236
237impl Default for ThreadListFilters {
238 fn default() -> Self {
239 Self {
240 include_archived: false,
241 limit: Some(50),
242 }
243 }
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
247struct SessionIndexEntry {
248 thread_id: String,
249 thread_name: Option<String>,
250 updated_at: i64,
251 rollout_path: Option<PathBuf>,
252}
253
254fn session_index_compact_line_threshold() -> usize {
258 if cfg!(test) { 5 } else { 5_000 }
259}
260
261#[derive(Debug, Clone)]
266pub struct StateStore {
267 db_path: PathBuf,
268 session_index_path: PathBuf,
269 conn: Arc<Mutex<Connection>>,
273}
274
275impl StateStore {
276 pub fn open(path: Option<PathBuf>) -> Result<Self> {
282 let db_path = path.unwrap_or_else(default_state_db_path);
283 let session_index_path = db_path
284 .parent()
285 .unwrap_or_else(|| Path::new("."))
286 .join("session_index.jsonl");
287 if let Some(parent) = db_path.parent() {
288 fs::create_dir_all(parent).with_context(|| {
289 format!("failed to create state directory {}", parent.display())
290 })?;
291 }
292 let conn = Connection::open(&db_path)
293 .with_context(|| format!("failed to open state db {}", db_path.display()))?;
294 conn.pragma_update(None, "foreign_keys", "ON")
295 .with_context(|| format!("failed to enable foreign keys for {}", db_path.display()))?;
296 Self::init_schema(&conn)?;
297 Ok(Self {
298 db_path,
299 session_index_path,
300 conn: Arc::new(Mutex::new(conn)),
301 })
302 }
303
304 pub fn db_path(&self) -> &Path {
306 &self.db_path
307 }
308
309 fn conn(&self) -> Result<MutexGuard<'_, Connection>> {
310 self.conn
314 .lock()
315 .map_err(|_| anyhow::anyhow!("state db connection mutex poisoned"))
316 }
317
318 fn init_schema(conn: &Connection) -> Result<()> {
319 let mut user_version: u32 = conn.query_row("PRAGMA user_version;", [], |row| row.get(0))?;
320 if user_version == 0 {
321 conn.execute_batch(
322 r#"
323 BEGIN;
324 CREATE TABLE IF NOT EXISTS threads (
325 id TEXT PRIMARY KEY,
326 rollout_path TEXT,
327 preview TEXT NOT NULL,
328 ephemeral INTEGER NOT NULL,
329 model_provider TEXT NOT NULL,
330 created_at INTEGER NOT NULL,
331 updated_at INTEGER NOT NULL,
332 status TEXT NOT NULL,
333 path TEXT,
334 cwd TEXT NOT NULL,
335 cli_version TEXT NOT NULL,
336 source TEXT NOT NULL,
337 title TEXT,
338 sandbox_policy TEXT,
339 approval_mode TEXT,
340 archived INTEGER NOT NULL DEFAULT 0,
341 archived_at INTEGER,
342 git_sha TEXT,
343 git_branch TEXT,
344 git_origin_url TEXT,
345 memory_mode TEXT
346 );
347 CREATE INDEX IF NOT EXISTS idx_threads_updated_at ON threads(updated_at DESC);
348 CREATE INDEX IF NOT EXISTS idx_threads_archived_at ON threads(archived_at DESC);
349 CREATE INDEX IF NOT EXISTS idx_threads_archived_updated ON threads(archived, updated_at DESC);
350
351 CREATE TABLE IF NOT EXISTS thread_dynamic_tools (
352 thread_id TEXT NOT NULL,
353 position INTEGER NOT NULL,
354 name TEXT NOT NULL,
355 description TEXT,
356 input_schema TEXT NOT NULL,
357 PRIMARY KEY (thread_id, position),
358 FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
359 );
360
361 CREATE TABLE IF NOT EXISTS messages (
362 id INTEGER PRIMARY KEY AUTOINCREMENT,
363 thread_id TEXT NOT NULL,
364 role TEXT NOT NULL,
365 content TEXT NOT NULL,
366 item_json TEXT,
367 created_at INTEGER NOT NULL,
368 FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
369 );
370 CREATE INDEX IF NOT EXISTS idx_messages_thread_created_at ON messages(thread_id, created_at ASC);
371
372 CREATE TABLE IF NOT EXISTS checkpoints (
373 thread_id TEXT NOT NULL,
374 checkpoint_id TEXT NOT NULL,
375 state_json TEXT NOT NULL,
376 created_at INTEGER NOT NULL,
377 PRIMARY KEY(thread_id, checkpoint_id),
378 FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
379 );
380 CREATE INDEX IF NOT EXISTS idx_checkpoints_thread_created_at ON checkpoints(thread_id, created_at DESC);
381
382 CREATE TABLE IF NOT EXISTS jobs (
383 id TEXT PRIMARY KEY,
384 name TEXT NOT NULL,
385 status TEXT NOT NULL,
386 progress INTEGER,
387 detail TEXT,
388 created_at INTEGER NOT NULL,
389 updated_at INTEGER NOT NULL
390 );
391 CREATE INDEX IF NOT EXISTS idx_jobs_updated_at ON jobs(updated_at DESC);
392
393 -- Add parent_entry_id column, and set to last message before current message
394 ALTER TABLE messages ADD COLUMN parent_entry_id INTEGER NULL;
395 UPDATE messages
396 SET parent_entry_id = (
397 SELECT m2.id
398 FROM messages m2
399 WHERE m2.thread_id = messages.thread_id
400 AND (
401 m2.created_at < messages.created_at
402 OR (
403 m2.created_at = messages.created_at
404 AND m2.id < messages.id
405 )
406 )
407 ORDER BY m2.created_at DESC, m2.id DESC
408 LIMIT 1
409 );
410 CREATE INDEX idx_messages_parent_entry_id ON messages(parent_entry_id);
411
412 -- Add current_leaf_id column, and set to last message in thread
413 ALTER TABLE threads ADD COLUMN current_leaf_id INTEGER NULL;
414 UPDATE threads
415 SET current_leaf_id = (
416 SELECT m.id
417 FROM messages m
418 WHERE m.thread_id = threads.id
419 ORDER BY m.id DESC
420 LIMIT 1
421 );
422
423 PRAGMA user_version = 1;
424 COMMIT;
425 "#,
426 )
427 .context("failed to initialize thread schema")?;
428 user_version = 1;
429 }
430 if user_version < 2 {
431 conn.execute_batch(
432 r#"
433 BEGIN;
434 CREATE TABLE IF NOT EXISTS workflow_runs (
435 id TEXT PRIMARY KEY,
436 workflow_id TEXT NOT NULL,
437 goal TEXT NOT NULL,
438 status TEXT NOT NULL,
439 input_hash TEXT,
440 started_at INTEGER NOT NULL,
441 completed_at INTEGER,
442 metadata_json TEXT NOT NULL DEFAULT '{}'
443 );
444 CREATE INDEX IF NOT EXISTS idx_workflow_runs_status_started_at
445 ON workflow_runs(status, started_at DESC);
446 CREATE INDEX IF NOT EXISTS idx_workflow_runs_workflow_started_at
447 ON workflow_runs(workflow_id, started_at DESC);
448
449 CREATE TABLE IF NOT EXISTS branch_runs (
450 id TEXT PRIMARY KEY,
451 workflow_run_id TEXT NOT NULL,
452 branch_id TEXT NOT NULL,
453 node_id TEXT NOT NULL,
454 status TEXT NOT NULL,
455 started_at INTEGER NOT NULL,
456 completed_at INTEGER,
457 result_json TEXT NOT NULL DEFAULT '{}',
458 FOREIGN KEY(workflow_run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
459 );
460 CREATE INDEX IF NOT EXISTS idx_branch_runs_workflow_run_id
461 ON branch_runs(workflow_run_id);
462 CREATE INDEX IF NOT EXISTS idx_branch_runs_branch_id
463 ON branch_runs(branch_id);
464
465 CREATE TABLE IF NOT EXISTS leaf_runs (
466 id TEXT PRIMARY KEY,
467 workflow_run_id TEXT NOT NULL,
468 branch_run_id TEXT,
469 leaf_id TEXT NOT NULL,
470 task_id TEXT NOT NULL,
471 input_hash TEXT,
472 status TEXT NOT NULL,
473 output_json TEXT NOT NULL DEFAULT '{}',
474 artifacts_json TEXT NOT NULL DEFAULT '[]',
475 started_at INTEGER NOT NULL,
476 completed_at INTEGER,
477 FOREIGN KEY(workflow_run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE,
478 FOREIGN KEY(branch_run_id) REFERENCES branch_runs(id) ON DELETE SET NULL
479 );
480 CREATE INDEX IF NOT EXISTS idx_leaf_runs_workflow_run_id
481 ON leaf_runs(workflow_run_id);
482 CREATE INDEX IF NOT EXISTS idx_leaf_runs_replay_lookup
483 ON leaf_runs(workflow_run_id, leaf_id, input_hash);
484
485 CREATE TABLE IF NOT EXISTS control_node_runs (
486 id TEXT PRIMARY KEY,
487 workflow_run_id TEXT NOT NULL,
488 node_id TEXT NOT NULL,
489 kind TEXT NOT NULL,
490 status TEXT NOT NULL,
491 selected_children_json TEXT NOT NULL DEFAULT '[]',
492 result_json TEXT NOT NULL DEFAULT '{}',
493 started_at INTEGER NOT NULL,
494 completed_at INTEGER,
495 FOREIGN KEY(workflow_run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
496 );
497 CREATE INDEX IF NOT EXISTS idx_control_node_runs_workflow_run_id
498 ON control_node_runs(workflow_run_id);
499 CREATE INDEX IF NOT EXISTS idx_control_node_runs_node_id
500 ON control_node_runs(node_id);
501
502 CREATE TABLE IF NOT EXISTS teacher_candidates (
503 id TEXT PRIMARY KEY,
504 workflow_run_id TEXT NOT NULL,
505 control_node_run_id TEXT NOT NULL,
506 candidate_id TEXT NOT NULL,
507 branch_run_id TEXT,
508 score REAL,
509 passed INTEGER,
510 rationale_json TEXT NOT NULL DEFAULT '{}',
511 created_at INTEGER NOT NULL,
512 FOREIGN KEY(workflow_run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE,
513 FOREIGN KEY(control_node_run_id) REFERENCES control_node_runs(id) ON DELETE CASCADE,
514 FOREIGN KEY(branch_run_id) REFERENCES branch_runs(id) ON DELETE SET NULL
515 );
516 CREATE INDEX IF NOT EXISTS idx_teacher_candidates_workflow_run_id
517 ON teacher_candidates(workflow_run_id);
518 CREATE INDEX IF NOT EXISTS idx_teacher_candidates_control_node_run_id
519 ON teacher_candidates(control_node_run_id);
520
521 PRAGMA user_version = 2;
522 COMMIT;
523 "#,
524 )
525 .context("failed to initialize workflow trace schema")?;
526 user_version = 2;
527 }
528 if user_version < 3 {
529 conn.execute_batch(
530 r#"
531 BEGIN;
532 CREATE TABLE IF NOT EXISTS thread_goals (
533 thread_id TEXT PRIMARY KEY NOT NULL,
534 goal_id TEXT NOT NULL,
535 objective TEXT NOT NULL,
536 status TEXT NOT NULL CHECK(status IN (
537 'active',
538 'paused',
539 'blocked',
540 'usage_limited',
541 'budget_limited',
542 'complete'
543 )),
544 token_budget INTEGER,
545 tokens_used INTEGER NOT NULL DEFAULT 0,
546 time_used_seconds INTEGER NOT NULL DEFAULT 0,
547 created_at INTEGER NOT NULL,
548 updated_at INTEGER NOT NULL,
549 FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
550 );
551
552 PRAGMA user_version = 3;
553 COMMIT;
554 "#,
555 )
556 .context("failed to initialize thread goal schema")?;
557 user_version = 3;
558 }
559 if user_version < 4 {
560 conn.execute_batch(
561 r#"
562 BEGIN;
563 ALTER TABLE thread_goals
564 ADD COLUMN continuation_count INTEGER NOT NULL DEFAULT 0;
565
566 PRAGMA user_version = 4;
567 COMMIT;
568 "#,
569 )
570 .context("failed to initialize thread goal continuation schema")?;
571 }
572 Ok(())
573 }
574
575 pub fn upsert_thread(&self, thread: &ThreadMetadata) -> Result<()> {
580 let conn = self.conn()?;
581 conn.execute(
582 r#"
583 INSERT INTO threads (
584 id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd,
585 cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at,
586 git_sha, git_branch, git_origin_url, memory_mode
587 ) VALUES (
588 ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10,
589 ?11, ?12, ?13, ?14, ?15, ?16, ?17,
590 ?18, ?19, ?20, ?21
591 )
592 ON CONFLICT(id) DO UPDATE SET
593 rollout_path=excluded.rollout_path,
594 preview=excluded.preview,
595 ephemeral=excluded.ephemeral,
596 model_provider=excluded.model_provider,
597 created_at=excluded.created_at,
598 updated_at=excluded.updated_at,
599 status=excluded.status,
600 path=excluded.path,
601 cwd=excluded.cwd,
602 cli_version=excluded.cli_version,
603 source=excluded.source,
604 title=excluded.title,
605 sandbox_policy=excluded.sandbox_policy,
606 approval_mode=excluded.approval_mode,
607 archived=excluded.archived,
608 archived_at=excluded.archived_at,
609 git_sha=excluded.git_sha,
610 git_branch=excluded.git_branch,
611 git_origin_url=excluded.git_origin_url,
612 memory_mode=excluded.memory_mode
613 "#,
614 params![
615 thread.id,
616 path_to_opt_string(thread.rollout_path.as_deref()),
617 thread.preview,
618 bool_to_i64(thread.ephemeral),
619 thread.model_provider,
620 thread.created_at,
621 thread.updated_at,
622 thread_status_to_str(&thread.status),
623 path_to_opt_string(thread.path.as_deref()),
624 thread.cwd.display().to_string(),
625 thread.cli_version,
626 session_source_to_str(&thread.source),
627 thread.name,
628 thread.sandbox_policy,
629 thread.approval_mode,
630 bool_to_i64(thread.archived),
631 thread.archived_at,
632 thread.git_sha,
633 thread.git_branch,
634 thread.git_origin_url,
635 thread.memory_mode,
636 ],
637 )
638 .context("failed to upsert thread metadata")?;
639
640 self.append_thread_name(
641 &thread.id,
642 thread.name.clone(),
643 thread.updated_at,
644 thread.rollout_path.clone(),
645 )?;
646 Ok(())
647 }
648
649 pub fn get_thread(&self, id: &str) -> Result<Option<ThreadMetadata>> {
653 let conn = self.conn()?;
654 conn.query_row(
655 r#"
656 SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd,
657 cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at,
658 git_sha, git_branch, git_origin_url, memory_mode, current_leaf_id
659 FROM threads
660 WHERE id = ?1
661 "#,
662 params![id],
663 row_to_thread,
664 )
665 .optional()
666 .context("failed to read thread")
667 }
668
669 pub fn list_threads(&self, filters: ThreadListFilters) -> Result<Vec<ThreadMetadata>> {
674 let conn = self.conn()?;
675 let sql = if filters.include_archived {
676 "SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd, cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at, git_sha, git_branch, git_origin_url, memory_mode, current_leaf_id FROM threads ORDER BY updated_at DESC LIMIT ?1"
677 } else {
678 "SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd, cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at, git_sha, git_branch, git_origin_url, memory_mode, current_leaf_id FROM threads WHERE archived = 0 ORDER BY updated_at DESC LIMIT ?1"
679 };
680
681 let mut stmt = conn.prepare(sql).context("failed to prepare list query")?;
682 let limit = i64::try_from(filters.limit.unwrap_or(50)).unwrap_or(50);
683 let mut rows = stmt
684 .query(params![limit])
685 .context("failed to query threads")?;
686 let mut out = Vec::new();
687 while let Some(row) = rows.next().context("failed to iterate thread rows")? {
688 out.push(row_to_thread(row)?);
689 }
690 Ok(out)
691 }
692
693 pub fn mark_archived(&self, id: &str) -> Result<()> {
696 let conn = self.conn()?;
697 conn.execute(
698 "UPDATE threads SET archived = 1, archived_at = ?2, status = ?3 WHERE id = ?1",
699 params![
700 id,
701 Utc::now().timestamp(),
702 thread_status_to_str(&ThreadStatus::Archived)
703 ],
704 )
705 .context("failed to archive thread")?;
706 Ok(())
707 }
708
709 pub fn mark_unarchived(&self, id: &str) -> Result<()> {
711 let conn = self.conn()?;
712 conn.execute(
713 "UPDATE threads SET archived = 0, archived_at = NULL, status = CASE WHEN status = ?2 THEN ?3 ELSE status END WHERE id = ?1",
714 params![
715 id,
716 thread_status_to_str(&ThreadStatus::Archived),
717 thread_status_to_str(&ThreadStatus::Idle),
718 ],
719 )
720 .context("failed to unarchive thread")?;
721 Ok(())
722 }
723
724 pub fn delete_thread(&self, id: &str) -> Result<()> {
727 let conn = self.conn()?;
728 conn.execute("DELETE FROM threads WHERE id = ?1", params![id])
729 .context("failed to delete thread")?;
730 Ok(())
731 }
732
733 pub fn set_thread_memory_mode(&self, id: &str, mode: Option<&str>) -> Result<()> {
737 let conn = self.conn()?;
738 conn.execute(
739 "UPDATE threads SET memory_mode = ?2 WHERE id = ?1",
740 params![id, mode],
741 )
742 .context("failed to update thread memory mode")?;
743 Ok(())
744 }
745
746 pub fn get_thread_memory_mode(&self, id: &str) -> Result<Option<String>> {
750 let conn = self.conn()?;
751 conn.query_row(
752 "SELECT memory_mode FROM threads WHERE id = ?1",
753 params![id],
754 |row| row.get::<_, Option<String>>(0),
755 )
756 .optional()
757 .context("failed to read thread memory mode")
758 .map(Option::flatten)
759 }
760
761 pub fn upsert_thread_goal(&self, goal: &ThreadGoalRecord) -> Result<()> {
763 let conn = self.conn()?;
764 let exists: Option<i64> = conn
765 .query_row(
766 "SELECT 1 FROM threads WHERE id = ?1",
767 params![goal.thread_id],
768 |row| row.get(0),
769 )
770 .optional()
771 .context("failed to verify thread before saving goal")?;
772 if exists.is_none() {
773 anyhow::bail!("thread {} not found", goal.thread_id);
774 }
775
776 conn.execute(
777 r#"
778 INSERT INTO thread_goals (
779 thread_id, goal_id, objective, status, token_budget, tokens_used,
780 time_used_seconds, continuation_count, created_at, updated_at
781 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
782 ON CONFLICT(thread_id) DO UPDATE SET
783 goal_id=excluded.goal_id,
784 objective=excluded.objective,
785 status=excluded.status,
786 token_budget=excluded.token_budget,
787 tokens_used=excluded.tokens_used,
788 time_used_seconds=excluded.time_used_seconds,
789 continuation_count=excluded.continuation_count,
790 created_at=excluded.created_at,
791 updated_at=excluded.updated_at
792 "#,
793 params![
794 goal.thread_id,
795 goal.goal_id,
796 goal.objective,
797 thread_goal_status_to_str(&goal.status),
798 goal.token_budget,
799 goal.tokens_used,
800 goal.time_used_seconds,
801 goal.continuation_count,
802 goal.created_at,
803 goal.updated_at,
804 ],
805 )
806 .context("failed to upsert thread goal")?;
807 Ok(())
808 }
809
810 pub fn record_thread_goal_usage(
826 &self,
827 thread_id: &str,
828 token_delta: i64,
829 time_delta_seconds: i64,
830 now: i64,
831 ) -> Result<Option<ThreadGoalRecord>> {
832 let conn = self.conn()?;
833 let changed = conn
834 .execute(
835 r#"
836 UPDATE thread_goals
837 SET tokens_used = tokens_used + ?2,
838 time_used_seconds = time_used_seconds + ?3,
839 updated_at = MAX(updated_at, ?4)
840 WHERE thread_id = ?1
841 "#,
842 params![thread_id, token_delta, time_delta_seconds, now],
843 )
844 .context("failed to record thread goal usage")?;
845 if changed == 0 {
846 return Ok(None);
847 }
848 Self::read_thread_goal(&conn, thread_id)
849 }
850
851 pub fn record_thread_goal_continuation(
857 &self,
858 thread_id: &str,
859 now: i64,
860 ) -> Result<Option<ThreadGoalRecord>> {
861 let conn = self.conn()?;
862 let changed = conn
863 .execute(
864 r#"
865 UPDATE thread_goals
866 SET continuation_count = continuation_count + 1,
867 updated_at = MAX(updated_at, ?2)
868 WHERE thread_id = ?1
869 "#,
870 params![thread_id, now],
871 )
872 .context("failed to record thread goal continuation")?;
873 if changed == 0 {
874 return Ok(None);
875 }
876 Self::read_thread_goal(&conn, thread_id)
877 }
878
879 pub fn get_thread_goal(&self, thread_id: &str) -> Result<Option<ThreadGoalRecord>> {
881 let conn = self.conn()?;
882 Self::read_thread_goal(&conn, thread_id)
883 }
884
885 fn read_thread_goal(conn: &Connection, thread_id: &str) -> Result<Option<ThreadGoalRecord>> {
889 conn.query_row(
890 r#"
891 SELECT thread_id, goal_id, objective, status, token_budget, tokens_used,
892 time_used_seconds, continuation_count, created_at, updated_at
893 FROM thread_goals
894 WHERE thread_id = ?1
895 "#,
896 params![thread_id],
897 row_to_thread_goal,
898 )
899 .optional()
900 .context("failed to read thread goal")
901 }
902
903 pub fn delete_thread_goal(&self, thread_id: &str) -> Result<bool> {
905 let conn = self.conn()?;
906 let changed = conn
907 .execute(
908 "DELETE FROM thread_goals WHERE thread_id = ?1",
909 params![thread_id],
910 )
911 .context("failed to delete thread goal")?;
912 Ok(changed > 0)
913 }
914
915 pub fn list_leaf_messages(&self, thread_id: &str) -> Result<Vec<MessageRecord>> {
920 let conn = self.conn()?;
921 let mut stmt = conn
922 .prepare(
923 r#"
924 SELECT m1.id, m1.thread_id, m1.role, m1.content, m1.item_json, m1.created_at, m1.parent_entry_id
925 FROM messages m1
926 LEFT JOIN messages m2 ON m1.id = m2.parent_entry_id
927 WHERE m1.thread_id = ?1 AND m2.id IS NULL
928 "#,
929 )
930 .context("failed to prepare message listing query")?;
931 let mut rows = stmt
932 .query(params![thread_id])
933 .with_context(|| format!("failed to list leaf messages for thread {thread_id}"))?;
934 let mut out = Vec::new();
935 while let Some(row) = rows.next().context("failed to iterate message rows")? {
936 let item_json: Option<String> = row.get(4).context("failed to read item json")?;
937 let item = item_json
938 .as_deref()
939 .map(serde_json::from_str)
940 .transpose()
941 .with_context(|| {
942 format!("failed to parse message item json in thread {thread_id}")
943 })?;
944 out.push(MessageRecord {
945 id: row.get(0).context("failed to read message id")?,
946 thread_id: row.get(1).context("failed to read message thread id")?,
947 role: row.get(2).context("failed to read message role")?,
948 content: row.get(3).context("failed to read message content")?,
949 item,
950 created_at: row.get(5).context("failed to read message timestamp")?,
951 parent_entry_id: row.get(6).context("failed to read parent entry id")?,
952 });
953 }
954 Ok(out)
955 }
956
957 pub fn set_current_leaf_id(&self, thread_id: &str, current_leaf_id: &str) -> Result<()> {
962 let conn = self.conn()?;
963 conn.execute(
964 "UPDATE threads SET current_leaf_id = ?1 WHERE id = ?2",
965 params![current_leaf_id, thread_id],
966 )
967 .context("failed to update thread current leaf id")?;
968 Ok(())
969 }
970
971 pub fn persist_dynamic_tools(
976 &self,
977 thread_id: &str,
978 tools: &[DynamicToolRecord],
979 ) -> Result<()> {
980 let mut conn = self.conn()?;
981 let tx = conn
982 .transaction()
983 .context("failed to begin dynamic tools transaction")?;
984 tx.execute(
985 "DELETE FROM thread_dynamic_tools WHERE thread_id = ?1",
986 params![thread_id],
987 )
988 .context("failed to clear dynamic tools")?;
989 for tool in tools {
990 tx.execute(
991 "INSERT INTO thread_dynamic_tools(thread_id, position, name, description, input_schema) VALUES (?1, ?2, ?3, ?4, ?5)",
992 params![
993 thread_id,
994 tool.position,
995 tool.name,
996 tool.description,
997 tool.input_schema.to_string()
998 ],
999 )
1000 .with_context(|| format!("failed to persist dynamic tool {}", tool.name))?;
1001 }
1002 tx.commit().context("failed to commit dynamic tools")?;
1003 Ok(())
1004 }
1005
1006 pub fn get_dynamic_tools(&self, thread_id: &str) -> Result<Vec<DynamicToolRecord>> {
1008 let conn = self.conn()?;
1009 let mut stmt = conn
1010 .prepare(
1011 "SELECT position, name, description, input_schema FROM thread_dynamic_tools WHERE thread_id = ?1 ORDER BY position ASC",
1012 )
1013 .context("failed to prepare get dynamic tools query")?;
1014 let mut rows = stmt
1015 .query(params![thread_id])
1016 .context("failed to query dynamic tools")?;
1017 let mut out = Vec::new();
1018 while let Some(row) = rows.next().context("failed to iterate dynamic tools")? {
1019 let input_schema_raw: String =
1020 row.get(3).context("failed to read tool input schema")?;
1021 let input_schema: Value =
1022 serde_json::from_str(&input_schema_raw).with_context(|| {
1023 format!("failed to parse input schema for dynamic tool in thread {thread_id}")
1024 })?;
1025 out.push(DynamicToolRecord {
1026 position: row.get(0).context("failed to read tool position")?,
1027 name: row.get(1).context("failed to read tool name")?,
1028 description: row.get(2).context("failed to read tool description")?,
1029 input_schema,
1030 });
1031 }
1032 Ok(out)
1033 }
1034
1035 pub fn append_message(
1041 &self,
1042 thread_id: &str,
1043 role: &str,
1044 content: &str,
1045 item: Option<Value>,
1046 ) -> Result<i64> {
1047 let mut conn = self.conn()?;
1048 let created_at = Utc::now().timestamp();
1049 let item_json = item
1050 .as_ref()
1051 .map(serde_json::to_string)
1052 .transpose()
1053 .context("failed to serialize message item payload")?;
1054
1055 let tx = conn
1056 .transaction()
1057 .context("failed to begin append message transaction")?;
1058
1059 let current_leaf_id: Option<i64> = tx
1060 .query_row(
1061 "SELECT current_leaf_id FROM threads WHERE id = ?1",
1062 params![thread_id],
1063 |row| row.get(0),
1064 )
1065 .with_context(|| {
1066 format!("failed to query thread current leaf id for thread {thread_id}")
1067 })?;
1068
1069 let next_leaf_id: i64 = tx.query_row(
1070 r#"
1071 INSERT INTO messages(thread_id, role, content, item_json, created_at, parent_entry_id)
1072 SELECT ?1, ?2, ?3, ?4, ?5, ?6
1073 RETURNING id
1074 "#, params![thread_id, role, content, item_json, created_at, current_leaf_id], |row| row.get(0)
1075 ).with_context(|| format!("failed to append message for thread {thread_id}"))?;
1076
1077 tx.execute(
1078 r#"
1079 UPDATE threads
1080 SET current_leaf_id = ?1
1081 WHERE id = ?2;
1082 "#,
1083 params![next_leaf_id, thread_id],
1084 )
1085 .with_context(|| {
1086 format!("failed to update thread current leaf id for thread {thread_id}")
1087 })?;
1088
1089 tx.commit()
1090 .context("failed to commit append message transaction")?;
1091
1092 Ok(next_leaf_id)
1093 }
1094
1095 pub fn list_messages(
1101 &self,
1102 thread_id: &str,
1103 limit: Option<usize>,
1104 ) -> Result<Vec<MessageRecord>> {
1105 let conn = self.conn()?;
1106 let limit = i64::try_from(limit.unwrap_or(500)).unwrap_or(500);
1107 let mut stmt = conn
1108 .prepare(
1109 r#"
1110 WITH RECURSIVE
1111 leaf_id AS (
1112 SELECT current_leaf_id FROM threads WHERE id = ?1
1113 ),
1114 ancestors AS (
1115 SELECT id, thread_id, role, content, item_json, created_at, parent_entry_id, 0 AS depth
1116 FROM messages
1117 WHERE id = (SELECT current_leaf_id FROM leaf_id)
1118
1119 UNION ALL
1120
1121 SELECT m.id, m.thread_id, m.role, m.content, m.item_json, m.created_at, m.parent_entry_id, a.depth + 1
1122 FROM messages m
1123 JOIN ancestors a ON m.id = a.parent_entry_id
1124 WHERE a.depth < ?2
1125 )
1126 SELECT id, thread_id, role, content, item_json, created_at, parent_entry_id FROM ancestors
1127 ORDER BY depth DESC
1128 "#
1129 )
1130 .context("failed to prepare message listing query")?;
1131 let mut rows = stmt
1132 .query(params![thread_id, limit - 1])
1133 .with_context(|| format!("failed to list messages for thread {thread_id}"))?;
1134 let mut out = Vec::new();
1135 while let Some(row) = rows.next().context("failed to iterate message rows")? {
1136 let item_json: Option<String> = row.get(4).context("failed to read item json")?;
1137 let item = item_json
1138 .as_deref()
1139 .map(serde_json::from_str)
1140 .transpose()
1141 .with_context(|| {
1142 format!("failed to parse message item json in thread {thread_id}")
1143 })?;
1144 out.push(MessageRecord {
1145 id: row.get(0).context("failed to read message id")?,
1146 thread_id: row.get(1).context("failed to read message thread id")?,
1147 role: row.get(2).context("failed to read message role")?,
1148 content: row.get(3).context("failed to read message content")?,
1149 item,
1150 created_at: row.get(5).context("failed to read message timestamp")?,
1151 parent_entry_id: row.get(6).context("failed to read parent entry id")?,
1152 });
1153 }
1154 Ok(out)
1155 }
1156
1157 pub fn fork_at_message(
1163 &self,
1164 message_id: &str,
1165 role: &str,
1166 content: &str,
1167 item: Option<Value>,
1168 ) -> Result<i64> {
1169 let mut conn = self.conn()?;
1170 let created_at = Utc::now().timestamp();
1171 let item_json = item
1172 .as_ref()
1173 .map(serde_json::to_string)
1174 .transpose()
1175 .context("failed to serialize message item payload")?;
1176
1177 let tx = conn
1178 .transaction()
1179 .context("failed to begin fork message transaction")?;
1180
1181 let thread_id: String = tx
1182 .query_row(
1183 "SELECT thread_id FROM messages WHERE id = ?1",
1184 params![message_id],
1185 |row| row.get(0),
1186 )
1187 .with_context(|| format!("failed to query thread id for message {message_id}"))?;
1188
1189 let next_leaf_id: i64 = tx.query_row(
1190 r#"
1191 INSERT INTO messages(thread_id, role, content, item_json, created_at, parent_entry_id)
1192 SELECT ?1, ?2, ?3, ?4, ?5, ?6
1193 RETURNING id
1194 "#, params![thread_id, role, content, item_json, created_at, message_id], |row| row.get(0)
1195 ).with_context(|| format!("failed to fork at message for thread {thread_id:?}"))?;
1196
1197 tx.execute(
1198 r#"
1199 UPDATE threads
1200 SET current_leaf_id = ?1
1201 WHERE id = ?2;
1202 "#,
1203 params![next_leaf_id, thread_id],
1204 )
1205 .with_context(|| {
1206 format!("failed to update thread current leaf id for thread {thread_id:?}")
1207 })?;
1208
1209 tx.commit()
1210 .context("failed to commit fork message transaction")?;
1211
1212 Ok(next_leaf_id)
1213 }
1214
1215 pub fn clear_messages(&self, thread_id: &str) -> Result<usize> {
1219 let mut conn = self.conn()?;
1220 let tx = conn
1221 .transaction()
1222 .context("failed to begin clear messages transaction")?;
1223
1224 tx.execute(
1225 r#"
1226 UPDATE threads
1227 SET current_leaf_id = NULL
1228 WHERE id = ?1;
1229 "#,
1230 params![thread_id],
1231 )
1232 .with_context(|| format!("failed to clear messages for thread {thread_id}"))?;
1233 let result = tx
1234 .execute(
1235 r#"
1236 DELETE FROM messages WHERE thread_id = ?1
1237 "#,
1238 params![thread_id],
1239 )
1240 .with_context(|| format!("failed to clear messages for thread {thread_id}"))?;
1241 tx.commit()
1242 .context("failed to commit clear messages transaction")?;
1243
1244 Ok(result)
1245 }
1246
1247 pub fn save_checkpoint(
1252 &self,
1253 thread_id: &str,
1254 checkpoint_id: &str,
1255 state: &Value,
1256 ) -> Result<()> {
1257 let conn = self.conn()?;
1258 let state_json =
1259 serde_json::to_string(state).context("failed to encode checkpoint state")?;
1260 conn.execute(
1261 r#"
1262 INSERT INTO checkpoints(thread_id, checkpoint_id, state_json, created_at)
1263 VALUES (?1, ?2, ?3, ?4)
1264 ON CONFLICT(thread_id, checkpoint_id) DO UPDATE SET
1265 state_json = excluded.state_json,
1266 created_at = excluded.created_at
1267 "#,
1268 params![thread_id, checkpoint_id, state_json, Utc::now().timestamp()],
1269 )
1270 .with_context(|| {
1271 format!("failed to save checkpoint {checkpoint_id} for thread {thread_id}")
1272 })?;
1273 Ok(())
1274 }
1275
1276 pub fn load_checkpoint(
1282 &self,
1283 thread_id: &str,
1284 checkpoint_id: Option<&str>,
1285 ) -> Result<Option<CheckpointRecord>> {
1286 let conn = self.conn()?;
1287 if let Some(checkpoint_id) = checkpoint_id {
1288 let row = conn
1289 .query_row(
1290 "SELECT thread_id, checkpoint_id, state_json, created_at FROM checkpoints WHERE thread_id = ?1 AND checkpoint_id = ?2",
1291 params![thread_id, checkpoint_id],
1292 |row| {
1293 Ok((
1294 row.get::<_, String>(0)?,
1295 row.get::<_, String>(1)?,
1296 row.get::<_, String>(2)?,
1297 row.get::<_, i64>(3)?,
1298 ))
1299 },
1300 )
1301 .optional()
1302 .with_context(|| {
1303 format!("failed to load checkpoint {checkpoint_id} for thread {thread_id}")
1304 })?;
1305 if let Some((thread_id, checkpoint_id, state_json, created_at)) = row {
1306 let state = parse_checkpoint_state(&state_json)?;
1307 return Ok(Some(CheckpointRecord {
1308 thread_id,
1309 checkpoint_id,
1310 state,
1311 created_at,
1312 }));
1313 }
1314 return Ok(None);
1315 }
1316
1317 let row = conn
1318 .query_row(
1319 "SELECT thread_id, checkpoint_id, state_json, created_at FROM checkpoints WHERE thread_id = ?1 ORDER BY created_at DESC LIMIT 1",
1320 params![thread_id],
1321 |row| {
1322 Ok((
1323 row.get::<_, String>(0)?,
1324 row.get::<_, String>(1)?,
1325 row.get::<_, String>(2)?,
1326 row.get::<_, i64>(3)?,
1327 ))
1328 },
1329 )
1330 .optional()
1331 .with_context(|| format!("failed to load latest checkpoint for thread {thread_id}"))?;
1332 if let Some((thread_id, checkpoint_id, state_json, created_at)) = row {
1333 let state = parse_checkpoint_state(&state_json)?;
1334 return Ok(Some(CheckpointRecord {
1335 thread_id,
1336 checkpoint_id,
1337 state,
1338 created_at,
1339 }));
1340 }
1341 Ok(None)
1342 }
1343
1344 pub fn list_checkpoints(
1348 &self,
1349 thread_id: &str,
1350 limit: Option<usize>,
1351 ) -> Result<Vec<CheckpointRecord>> {
1352 let conn = self.conn()?;
1353 let limit = i64::try_from(limit.unwrap_or(100)).unwrap_or(100);
1354 let mut stmt = conn
1355 .prepare(
1356 "SELECT thread_id, checkpoint_id, state_json, created_at FROM checkpoints WHERE thread_id = ?1 ORDER BY created_at DESC LIMIT ?2",
1357 )
1358 .context("failed to prepare checkpoint list query")?;
1359 let mut rows = stmt
1360 .query(params![thread_id, limit])
1361 .with_context(|| format!("failed to list checkpoints for thread {thread_id}"))?;
1362
1363 let mut out = Vec::new();
1364 while let Some(row) = rows.next().context("failed to iterate checkpoint rows")? {
1365 let state_json: String = row.get(2).context("failed to read checkpoint state json")?;
1366 let state = parse_checkpoint_state(&state_json)?;
1367 out.push(CheckpointRecord {
1368 thread_id: row.get(0).context("failed to read checkpoint thread id")?,
1369 checkpoint_id: row.get(1).context("failed to read checkpoint id")?,
1370 state,
1371 created_at: row.get(3).context("failed to read checkpoint timestamp")?,
1372 });
1373 }
1374 Ok(out)
1375 }
1376
1377 pub fn delete_checkpoint(&self, thread_id: &str, checkpoint_id: &str) -> Result<()> {
1379 let conn = self.conn()?;
1380 conn.execute(
1381 "DELETE FROM checkpoints WHERE thread_id = ?1 AND checkpoint_id = ?2",
1382 params![thread_id, checkpoint_id],
1383 )
1384 .with_context(|| {
1385 format!("failed to delete checkpoint {checkpoint_id} for thread {thread_id}")
1386 })?;
1387 Ok(())
1388 }
1389
1390 pub fn upsert_job(&self, job: &JobStateRecord) -> Result<()> {
1392 let conn = self.conn()?;
1393 conn.execute(
1394 r#"
1395 INSERT INTO jobs(id, name, status, progress, detail, created_at, updated_at)
1396 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
1397 ON CONFLICT(id) DO UPDATE SET
1398 name = excluded.name,
1399 status = excluded.status,
1400 progress = excluded.progress,
1401 detail = excluded.detail,
1402 created_at = excluded.created_at,
1403 updated_at = excluded.updated_at
1404 "#,
1405 params![
1406 job.id,
1407 job.name,
1408 job_state_status_to_str(&job.status),
1409 job.progress.map(i64::from),
1410 job.detail,
1411 job.created_at,
1412 job.updated_at
1413 ],
1414 )
1415 .with_context(|| format!("failed to upsert job {}", job.id))?;
1416 Ok(())
1417 }
1418
1419 pub fn get_job(&self, id: &str) -> Result<Option<JobStateRecord>> {
1423 let conn = self.conn()?;
1424 conn.query_row(
1425 "SELECT id, name, status, progress, detail, created_at, updated_at FROM jobs WHERE id = ?1",
1426 params![id],
1427 |row| {
1428 let status_raw: String = row.get(2)?;
1429 let progress: Option<i64> = row.get(3)?;
1430 Ok(JobStateRecord {
1431 id: row.get(0)?,
1432 name: row.get(1)?,
1433 status: job_state_status_from_str(&status_raw),
1434 progress: progress.and_then(|v| u8::try_from(v).ok()),
1435 detail: row.get(4)?,
1436 created_at: row.get(5)?,
1437 updated_at: row.get(6)?,
1438 })
1439 },
1440 )
1441 .optional()
1442 .with_context(|| format!("failed to read job {id}"))
1443 }
1444
1445 pub fn list_jobs(&self, limit: Option<usize>) -> Result<Vec<JobStateRecord>> {
1449 let conn = self.conn()?;
1450 let limit = i64::try_from(limit.unwrap_or(100)).unwrap_or(100);
1451 let mut stmt = conn
1452 .prepare(
1453 "SELECT id, name, status, progress, detail, created_at, updated_at FROM jobs ORDER BY updated_at DESC LIMIT ?1",
1454 )
1455 .context("failed to prepare job list query")?;
1456 let mut rows = stmt
1457 .query(params![limit])
1458 .context("failed to query persisted jobs")?;
1459 let mut out = Vec::new();
1460 while let Some(row) = rows.next().context("failed to iterate persisted jobs")? {
1461 let status_raw: String = row.get(2).context("failed to read job status")?;
1462 let progress: Option<i64> = row.get(3).context("failed to read job progress")?;
1463 out.push(JobStateRecord {
1464 id: row.get(0).context("failed to read job id")?,
1465 name: row.get(1).context("failed to read job name")?,
1466 status: job_state_status_from_str(&status_raw),
1467 progress: progress.and_then(|v| u8::try_from(v).ok()),
1468 detail: row.get(4).context("failed to read job detail")?,
1469 created_at: row.get(5).context("failed to read job created_at")?,
1470 updated_at: row.get(6).context("failed to read job updated_at")?,
1471 });
1472 }
1473 Ok(out)
1474 }
1475
1476 pub fn delete_job(&self, id: &str) -> Result<()> {
1478 let conn = self.conn()?;
1479 conn.execute("DELETE FROM jobs WHERE id = ?1", params![id])
1480 .with_context(|| format!("failed to delete job {id}"))?;
1481 Ok(())
1482 }
1483
1484 pub fn find_rollout_path_by_id(&self, id: &str) -> Result<Option<PathBuf>> {
1486 let conn = self.conn()?;
1487 conn.query_row(
1488 "SELECT rollout_path FROM threads WHERE id = ?1",
1489 params![id],
1490 |row| row.get::<_, Option<String>>(0),
1491 )
1492 .optional()
1493 .context("failed to lookup rollout path")
1494 .map(|opt| opt.flatten().map(PathBuf::from))
1495 }
1496
1497 pub fn append_thread_name(
1503 &self,
1504 thread_id: &str,
1505 thread_name: Option<String>,
1506 updated_at: i64,
1507 rollout_path: Option<PathBuf>,
1508 ) -> Result<()> {
1509 if let Some(parent) = self.session_index_path.parent() {
1510 fs::create_dir_all(parent).with_context(|| {
1511 format!(
1512 "failed to create session index directory {}",
1513 parent.display()
1514 )
1515 })?;
1516 }
1517 let entry = SessionIndexEntry {
1518 thread_id: thread_id.to_string(),
1519 thread_name,
1520 updated_at,
1521 rollout_path,
1522 };
1523 let encoded =
1524 serde_json::to_string(&entry).context("failed to serialize session index entry")?;
1525 let mut file = OpenOptions::new()
1526 .create(true)
1527 .append(true)
1528 .open(&self.session_index_path)
1529 .with_context(|| {
1530 format!(
1531 "failed to open session index {}",
1532 self.session_index_path.display()
1533 )
1534 })?;
1535 writeln!(file, "{encoded}").context("failed to append session index entry")?;
1536 self.maybe_compact_session_index()?;
1537 Ok(())
1538 }
1539
1540 pub fn find_thread_name_by_id(&self, thread_id: &str) -> Result<Option<String>> {
1544 let map = self.session_index_map()?;
1545 Ok(map
1546 .get(thread_id)
1547 .and_then(|entry| entry.thread_name.clone()))
1548 }
1549
1550 pub fn find_thread_names_by_ids(
1554 &self,
1555 ids: &[String],
1556 ) -> Result<HashMap<String, Option<String>>> {
1557 let map = self.session_index_map()?;
1558 let mut out = HashMap::new();
1559 for id in ids {
1560 let name = map.get(id).and_then(|entry| entry.thread_name.clone());
1561 out.insert(id.clone(), name);
1562 }
1563 Ok(out)
1564 }
1565
1566 pub fn find_thread_path_by_name_str(&self, name: &str) -> Result<Option<PathBuf>> {
1571 let map = self.session_index_map()?;
1572 let matched = map
1573 .values()
1574 .filter(|entry| {
1575 entry
1576 .thread_name
1577 .as_deref()
1578 .is_some_and(|n| n.eq_ignore_ascii_case(name))
1579 })
1580 .max_by_key(|entry| entry.updated_at);
1581 Ok(matched.and_then(|entry| entry.rollout_path.clone()))
1582 }
1583
1584 fn maybe_compact_session_index(&self) -> Result<()> {
1585 if !self.session_index_path.exists() {
1586 return Ok(());
1587 }
1588 let line_count = BufReader::new(
1589 OpenOptions::new()
1590 .read(true)
1591 .open(&self.session_index_path)
1592 .with_context(|| {
1593 format!(
1594 "failed to read session index {}",
1595 self.session_index_path.display()
1596 )
1597 })?,
1598 )
1599 .lines()
1600 .filter(|line| {
1601 line.as_ref()
1602 .map(|value| !value.trim().is_empty())
1603 .unwrap_or(false)
1604 })
1605 .count();
1606 if line_count <= session_index_compact_line_threshold() {
1607 return Ok(());
1608 }
1609
1610 let latest = self.session_index_map()?;
1611 let compact_path = self.session_index_path.with_extension("jsonl.compact");
1612 {
1613 let mut file = OpenOptions::new()
1614 .create(true)
1615 .write(true)
1616 .truncate(true)
1617 .open(&compact_path)
1618 .with_context(|| {
1619 format!(
1620 "failed to open compact session index {}",
1621 compact_path.display()
1622 )
1623 })?;
1624 for entry in latest.values() {
1625 let encoded = serde_json::to_string(entry)
1626 .context("failed to serialize compact session index entry")?;
1627 writeln!(file, "{encoded}")
1628 .context("failed to write compact session index entry")?;
1629 }
1630 }
1631 fs::rename(&compact_path, &self.session_index_path).with_context(|| {
1632 format!(
1633 "failed to replace session index {}",
1634 self.session_index_path.display()
1635 )
1636 })?;
1637 Ok(())
1638 }
1639
1640 #[cfg(test)]
1641 fn session_index_line_count(&self) -> Result<usize> {
1642 if !self.session_index_path.exists() {
1643 return Ok(0);
1644 }
1645 Ok(BufReader::new(
1646 OpenOptions::new()
1647 .read(true)
1648 .open(&self.session_index_path)
1649 .with_context(|| {
1650 format!(
1651 "failed to read session index {}",
1652 self.session_index_path.display()
1653 )
1654 })?,
1655 )
1656 .lines()
1657 .filter(|line| {
1658 line.as_ref()
1659 .map(|value| !value.trim().is_empty())
1660 .unwrap_or(false)
1661 })
1662 .count())
1663 }
1664
1665 fn session_index_map(&self) -> Result<HashMap<String, SessionIndexEntry>> {
1666 if !self.session_index_path.exists() {
1667 return Ok(HashMap::new());
1668 }
1669 let file = OpenOptions::new()
1670 .read(true)
1671 .open(&self.session_index_path)
1672 .with_context(|| {
1673 format!(
1674 "failed to read session index {}",
1675 self.session_index_path.display()
1676 )
1677 })?;
1678 let reader = BufReader::new(file);
1679 let mut latest = HashMap::<String, SessionIndexEntry>::new();
1680 for line in reader.lines() {
1681 let line = line.context("failed to read session index line")?;
1682 if line.trim().is_empty() {
1683 continue;
1684 }
1685 let parsed: SessionIndexEntry =
1686 serde_json::from_str(&line).context("failed to parse session index entry")?;
1687 latest.insert(parsed.thread_id.clone(), parsed);
1688 }
1689 Ok(latest)
1690 }
1691}
1692
1693fn default_state_db_path() -> PathBuf {
1694 if let Some(overridden) = codewhale_home_override() {
1701 return overridden.join("state.db");
1702 }
1703 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
1704 let primary = home.join(".codewhale").join("state.db");
1707 if primary.exists() || !home.join(".deepseek").join("state.db").exists() {
1708 primary
1709 } else {
1710 home.join(".deepseek").join("state.db")
1711 }
1712}
1713
1714fn codewhale_home_override() -> Option<PathBuf> {
1724 std::env::var_os("CODEWHALE_HOME")
1725 .filter(|value| !value.is_empty())
1726 .map(PathBuf::from)
1727}
1728
1729fn bool_to_i64(value: bool) -> i64 {
1730 if value { 1 } else { 0 }
1731}
1732
1733fn i64_to_bool(value: i64) -> bool {
1734 value != 0
1735}
1736
1737fn thread_status_to_str(status: &ThreadStatus) -> &'static str {
1738 match status {
1739 ThreadStatus::Running => "running",
1740 ThreadStatus::Idle => "idle",
1741 ThreadStatus::Completed => "completed",
1742 ThreadStatus::Failed => "failed",
1743 ThreadStatus::Paused => "paused",
1744 ThreadStatus::Archived => "archived",
1745 }
1746}
1747
1748fn thread_status_from_str(value: &str) -> ThreadStatus {
1749 match value {
1750 "running" => ThreadStatus::Running,
1751 "idle" => ThreadStatus::Idle,
1752 "completed" => ThreadStatus::Completed,
1753 "failed" => ThreadStatus::Failed,
1754 "paused" => ThreadStatus::Paused,
1755 "archived" => ThreadStatus::Archived,
1756 _ => ThreadStatus::Idle,
1757 }
1758}
1759
1760fn session_source_to_str(source: &SessionSource) -> &'static str {
1761 match source {
1762 SessionSource::Interactive => "interactive",
1763 SessionSource::Resume => "resume",
1764 SessionSource::Fork => "fork",
1765 SessionSource::Api => "api",
1766 SessionSource::Unknown => "unknown",
1767 }
1768}
1769
1770fn session_source_from_str(value: &str) -> SessionSource {
1771 match value {
1772 "interactive" => SessionSource::Interactive,
1773 "resume" => SessionSource::Resume,
1774 "fork" => SessionSource::Fork,
1775 "api" => SessionSource::Api,
1776 _ => SessionSource::Unknown,
1777 }
1778}
1779
1780fn path_to_opt_string(path: Option<&Path>) -> Option<String> {
1781 path.map(|p| p.display().to_string())
1782}
1783
1784fn parse_checkpoint_state(state_json: &str) -> Result<Value> {
1785 serde_json::from_str(state_json).context("failed to parse checkpoint state json")
1786}
1787
1788fn job_state_status_to_str(status: &JobStateStatus) -> &'static str {
1789 match status {
1790 JobStateStatus::Queued => "queued",
1791 JobStateStatus::Running => "running",
1792 JobStateStatus::Paused => "paused",
1793 JobStateStatus::Completed => "completed",
1794 JobStateStatus::Failed => "failed",
1795 JobStateStatus::Cancelled => "cancelled",
1796 }
1797}
1798
1799fn job_state_status_from_str(value: &str) -> JobStateStatus {
1800 match value {
1801 "queued" => JobStateStatus::Queued,
1802 "running" => JobStateStatus::Running,
1803 "paused" => JobStateStatus::Paused,
1804 "completed" => JobStateStatus::Completed,
1805 "failed" => JobStateStatus::Failed,
1806 "cancelled" => JobStateStatus::Cancelled,
1807 _ => JobStateStatus::Queued,
1808 }
1809}
1810
1811fn thread_goal_status_to_str(status: &ThreadGoalStatus) -> &'static str {
1812 match status {
1813 ThreadGoalStatus::Active => "active",
1814 ThreadGoalStatus::Paused => "paused",
1815 ThreadGoalStatus::Blocked => "blocked",
1816 ThreadGoalStatus::UsageLimited => "usage_limited",
1817 ThreadGoalStatus::BudgetLimited => "budget_limited",
1818 ThreadGoalStatus::Complete => "complete",
1819 }
1820}
1821
1822fn thread_goal_status_from_str(value: &str) -> ThreadGoalStatus {
1823 match value {
1824 "active" => ThreadGoalStatus::Active,
1825 "paused" => ThreadGoalStatus::Paused,
1826 "blocked" => ThreadGoalStatus::Blocked,
1827 "usage_limited" => ThreadGoalStatus::UsageLimited,
1828 "budget_limited" => ThreadGoalStatus::BudgetLimited,
1829 "complete" => ThreadGoalStatus::Complete,
1830 _ => ThreadGoalStatus::Active,
1831 }
1832}
1833
1834fn row_to_thread(row: &rusqlite::Row<'_>) -> rusqlite::Result<ThreadMetadata> {
1835 let status_raw: String = row.get(7)?;
1836 let source_raw: String = row.get(11)?;
1837 let rollout_path: Option<String> = row.get(1)?;
1838 let path: Option<String> = row.get(8)?;
1839 Ok(ThreadMetadata {
1840 id: row.get(0)?,
1841 rollout_path: rollout_path.map(PathBuf::from),
1842 preview: row.get(2)?,
1843 ephemeral: i64_to_bool(row.get(3)?),
1844 model_provider: row.get(4)?,
1845 created_at: row.get(5)?,
1846 updated_at: row.get(6)?,
1847 status: thread_status_from_str(&status_raw),
1848 path: path.map(PathBuf::from),
1849 cwd: PathBuf::from(row.get::<_, String>(9)?),
1850 cli_version: row.get(10)?,
1851 source: session_source_from_str(&source_raw),
1852 name: row.get(12)?,
1853 sandbox_policy: row.get(13)?,
1854 approval_mode: row.get(14)?,
1855 archived: i64_to_bool(row.get(15)?),
1856 archived_at: row.get(16)?,
1857 git_sha: row.get(17)?,
1858 git_branch: row.get(18)?,
1859 git_origin_url: row.get(19)?,
1860 memory_mode: row.get(20)?,
1861 current_leaf_id: row.get(21)?,
1862 })
1863}
1864
1865fn row_to_thread_goal(row: &rusqlite::Row<'_>) -> rusqlite::Result<ThreadGoalRecord> {
1866 let status_raw: String = row.get(3)?;
1867 Ok(ThreadGoalRecord {
1868 thread_id: row.get(0)?,
1869 goal_id: row.get(1)?,
1870 objective: row.get(2)?,
1871 status: thread_goal_status_from_str(&status_raw),
1872 token_budget: row.get(4)?,
1873 tokens_used: row.get(5)?,
1874 time_used_seconds: row.get(6)?,
1875 continuation_count: row.get(7)?,
1876 created_at: row.get(8)?,
1877 updated_at: row.get(9)?,
1878 })
1879}
1880
1881#[cfg(test)]
1882mod tests {
1883 use super::*;
1884 use serde_json::json;
1885 use std::time::{SystemTime, UNIX_EPOCH};
1886
1887 fn temp_state_store(name: &str) -> StateStore {
1888 let suffix = SystemTime::now()
1889 .duration_since(UNIX_EPOCH)
1890 .expect("system time")
1891 .as_nanos();
1892 let dir = std::env::temp_dir().join(format!(
1893 "codewhale-state-{name}-{}-{suffix}",
1894 std::process::id()
1895 ));
1896 fs::create_dir_all(&dir).expect("create temp state dir");
1897 StateStore::open(Some(dir.join("state.db"))).expect("open state store")
1898 }
1899
1900 fn test_thread(id: &str) -> ThreadMetadata {
1901 ThreadMetadata {
1902 id: id.to_string(),
1903 rollout_path: None,
1904 preview: "test thread".to_string(),
1905 ephemeral: false,
1906 model_provider: "deepseek".to_string(),
1907 created_at: 10,
1908 updated_at: 10,
1909 status: ThreadStatus::Running,
1910 path: None,
1911 cwd: PathBuf::from("/tmp/codewhale"),
1912 cli_version: "0.0.0-test".to_string(),
1913 source: SessionSource::Interactive,
1914 name: None,
1915 sandbox_policy: None,
1916 approval_mode: None,
1917 archived: false,
1918 archived_at: None,
1919 git_sha: None,
1920 git_branch: None,
1921 git_origin_url: None,
1922 memory_mode: None,
1923 current_leaf_id: None,
1924 }
1925 }
1926
1927 fn test_goal(thread_id: &str, objective: &str) -> ThreadGoalRecord {
1928 ThreadGoalRecord {
1929 thread_id: thread_id.to_string(),
1930 goal_id: "goal-1".to_string(),
1931 objective: objective.to_string(),
1932 status: ThreadGoalStatus::Active,
1933 token_budget: Some(123),
1934 tokens_used: 7,
1935 time_used_seconds: 11,
1936 continuation_count: 0,
1937 created_at: 100,
1938 updated_at: 101,
1939 }
1940 }
1941
1942 #[test]
1943 fn thread_goal_crud_round_trips_and_replaces() {
1944 let store = temp_state_store("thread-goal-crud");
1945 store
1946 .upsert_thread(&test_thread("thread-1"))
1947 .expect("upsert thread");
1948
1949 let goal = test_goal("thread-1", "Ship v0.8.59");
1950 store.upsert_thread_goal(&goal).expect("upsert goal");
1951 assert_eq!(
1952 store
1953 .get_thread_goal("thread-1")
1954 .expect("read goal")
1955 .as_ref(),
1956 Some(&goal)
1957 );
1958
1959 let mut replacement = test_goal("thread-1", "Ship v0.8.59 safely");
1960 replacement.goal_id = "goal-2".to_string();
1961 replacement.status = ThreadGoalStatus::BudgetLimited;
1962 replacement.token_budget = None;
1963 replacement.updated_at = 202;
1964 store
1965 .upsert_thread_goal(&replacement)
1966 .expect("replace goal");
1967 assert_eq!(
1968 store.get_thread_goal("thread-1").expect("read replacement"),
1969 Some(replacement)
1970 );
1971
1972 assert!(store.delete_thread_goal("thread-1").expect("delete goal"));
1973 assert!(
1974 store
1975 .get_thread_goal("thread-1")
1976 .expect("read empty")
1977 .is_none()
1978 );
1979 assert!(!store.delete_thread_goal("thread-1").expect("delete empty"));
1980 }
1981
1982 #[test]
1983 fn thread_goal_requires_existing_thread() {
1984 let store = temp_state_store("thread-goal-missing-thread");
1985 let err = store
1986 .upsert_thread_goal(&test_goal("missing-thread", "nope"))
1987 .expect_err("goal without a thread should fail");
1988 assert!(err.to_string().contains("thread missing-thread not found"));
1989 }
1990
1991 #[test]
1992 fn delete_thread_cascades_child_rows() {
1993 let store = temp_state_store("thread-delete-cascade");
1994 store
1995 .upsert_thread(&test_thread("thread-1"))
1996 .expect("upsert thread");
1997 store
1998 .append_message("thread-1", "user", "hello", None)
1999 .expect("append message");
2000 store
2001 .save_checkpoint("thread-1", "checkpoint-1", &serde_json::json!({"ok": true}))
2002 .expect("save checkpoint");
2003 store
2004 .persist_dynamic_tools(
2005 "thread-1",
2006 &[DynamicToolRecord {
2007 position: 0,
2008 name: "test_tool".to_string(),
2009 description: Some("test".to_string()),
2010 input_schema: serde_json::json!({"type": "object"}),
2011 }],
2012 )
2013 .expect("persist dynamic tools");
2014 store
2015 .upsert_thread_goal(&test_goal("thread-1", "Ship v0.8.67"))
2016 .expect("upsert goal");
2017
2018 store.delete_thread("thread-1").expect("delete thread");
2019
2020 let conn = store.conn().expect("conn");
2021 for table in [
2022 "messages",
2023 "checkpoints",
2024 "thread_dynamic_tools",
2025 "thread_goals",
2026 ] {
2027 let sql = format!("SELECT COUNT(*) FROM {table} WHERE thread_id = ?1");
2028 let count: i64 = conn
2029 .query_row(&sql, params!["thread-1"], |row| row.get(0))
2030 .expect("count child rows");
2031 assert_eq!(count, 0, "{table} row survived thread deletion");
2032 }
2033 }
2034
2035 #[test]
2036 fn state_store_reuses_one_connection_across_operations_and_clones() {
2037 let store = temp_state_store("conn-reuse");
2038 {
2039 let conn = store.conn().expect("conn");
2040 conn.execute_batch("CREATE TEMP TABLE conn_reuse_probe(id INTEGER);")
2041 .expect("create temp table");
2042 }
2043 let clone = store.clone();
2048 clone
2049 .upsert_thread(&test_thread("thread-conn-reuse"))
2050 .expect("upsert thread");
2051 let conn = clone.conn().expect("conn");
2052 let probe_count: i64 = conn
2053 .query_row(
2054 "SELECT COUNT(*) FROM sqlite_temp_master WHERE name = 'conn_reuse_probe'",
2055 [],
2056 |row| row.get(0),
2057 )
2058 .expect("query temp master");
2059 assert_eq!(
2060 probe_count, 1,
2061 "temp table not visible: a fresh connection was opened"
2062 );
2063 let foreign_keys: i64 = conn
2065 .query_row("PRAGMA foreign_keys;", [], |row| row.get(0))
2066 .expect("read foreign_keys pragma");
2067 assert_eq!(foreign_keys, 1);
2068 }
2069
2070 #[test]
2071 fn record_thread_goal_usage_accumulates_tokens_and_time() {
2072 let store = temp_state_store("thread-goal-usage");
2073 store
2074 .upsert_thread(&test_thread("thread-1"))
2075 .expect("upsert thread");
2076
2077 let mut goal = test_goal("thread-1", "Ship the persistent goal loop");
2079 goal.tokens_used = 0;
2080 goal.time_used_seconds = 0;
2081 goal.updated_at = 100;
2082 store.upsert_thread_goal(&goal).expect("upsert goal");
2083
2084 let after_first = store
2086 .record_thread_goal_usage("thread-1", 250, 12, 150)
2087 .expect("record usage")
2088 .expect("goal exists");
2089 assert_eq!(after_first.tokens_used, 250);
2090 assert_eq!(after_first.time_used_seconds, 12);
2091 assert_eq!(after_first.updated_at, 150);
2092 assert_eq!(after_first.goal_id, goal.goal_id);
2094 assert_eq!(after_first.objective, goal.objective);
2095 assert_eq!(after_first.status, goal.status);
2096 assert_eq!(after_first.token_budget, goal.token_budget);
2097 assert_eq!(after_first.created_at, goal.created_at);
2098 assert_eq!(after_first.continuation_count, 0);
2099
2100 let after_second = store
2102 .record_thread_goal_usage("thread-1", 75, 8, 200)
2103 .expect("record usage")
2104 .expect("goal exists");
2105 assert_eq!(after_second.tokens_used, 325);
2106 assert_eq!(after_second.time_used_seconds, 20);
2107 assert_eq!(after_second.updated_at, 200);
2108
2109 let after_stale = store
2111 .record_thread_goal_usage("thread-1", 5, 1, 1)
2112 .expect("record usage")
2113 .expect("goal exists");
2114 assert_eq!(after_stale.tokens_used, 330);
2115 assert_eq!(after_stale.time_used_seconds, 21);
2116 assert_eq!(after_stale.updated_at, 200);
2117
2118 let persisted = store
2120 .get_thread_goal("thread-1")
2121 .expect("read goal")
2122 .expect("goal exists");
2123 assert_eq!(persisted.tokens_used, 330);
2124 assert_eq!(persisted.time_used_seconds, 21);
2125 }
2126
2127 #[test]
2128 fn record_thread_goal_usage_returns_none_without_goal() {
2129 let store = temp_state_store("thread-goal-usage-missing");
2130 store
2131 .upsert_thread(&test_thread("thread-1"))
2132 .expect("upsert thread");
2133 let result = store
2136 .record_thread_goal_usage("thread-1", 100, 5, 999)
2137 .expect("record usage on goalless thread");
2138 assert!(result.is_none());
2139 assert!(
2140 store
2141 .get_thread_goal("thread-1")
2142 .expect("read goal")
2143 .is_none()
2144 );
2145 }
2146
2147 #[test]
2148 fn record_thread_goal_continuation_accumulates_durably() {
2149 let store = temp_state_store("thread-goal-continuation");
2150 store
2151 .upsert_thread(&test_thread("thread-1"))
2152 .expect("upsert thread");
2153
2154 let mut goal = test_goal("thread-1", "Keep working across turns");
2155 goal.updated_at = 100;
2156 store.upsert_thread_goal(&goal).expect("upsert goal");
2157
2158 let after_first = store
2159 .record_thread_goal_continuation("thread-1", 120)
2160 .expect("record continuation")
2161 .expect("goal exists");
2162 assert_eq!(after_first.continuation_count, 1);
2163 assert_eq!(after_first.tokens_used, goal.tokens_used);
2164 assert_eq!(after_first.time_used_seconds, goal.time_used_seconds);
2165 assert_eq!(after_first.updated_at, 120);
2166
2167 let after_second = store
2168 .record_thread_goal_continuation("thread-1", 110)
2169 .expect("record second continuation")
2170 .expect("goal exists");
2171 assert_eq!(after_second.continuation_count, 2);
2172 assert_eq!(after_second.updated_at, 120);
2173
2174 let persisted = store
2175 .get_thread_goal("thread-1")
2176 .expect("read goal")
2177 .expect("goal exists");
2178 assert_eq!(persisted.continuation_count, 2);
2179 }
2180
2181 static CODEWHALE_HOME_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2188
2189 struct CodeWhaleHomeGuard {
2190 prior: Option<std::ffi::OsString>,
2191 }
2192 impl CodeWhaleHomeGuard {
2193 fn set(value: &str) -> Self {
2194 let prior = std::env::var_os("CODEWHALE_HOME");
2195 unsafe { std::env::set_var("CODEWHALE_HOME", value) };
2197 Self { prior }
2198 }
2199 fn remove() -> Self {
2200 let prior = std::env::var_os("CODEWHALE_HOME");
2201 unsafe { std::env::remove_var("CODEWHALE_HOME") };
2203 Self { prior }
2204 }
2205 }
2206 impl Drop for CodeWhaleHomeGuard {
2207 fn drop(&mut self) {
2208 unsafe {
2210 match &self.prior {
2211 Some(value) => std::env::set_var("CODEWHALE_HOME", value),
2212 None => std::env::remove_var("CODEWHALE_HOME"),
2213 }
2214 }
2215 }
2216 }
2217
2218 #[test]
2219 fn codewhale_home_override_returns_the_env_value_verbatim() {
2220 let _lock = CODEWHALE_HOME_TEST_LOCK.lock().unwrap();
2221 let _g = CodeWhaleHomeGuard::set("/tmp/cw-isolated-state");
2222 assert_eq!(
2225 codewhale_home_override().as_deref(),
2226 Some(std::path::Path::new("/tmp/cw-isolated-state"))
2227 );
2228 }
2229
2230 #[test]
2231 fn codewhale_home_override_none_when_unset() {
2232 let _lock = CODEWHALE_HOME_TEST_LOCK.lock().unwrap();
2233 let _g = CodeWhaleHomeGuard::remove();
2234 assert!(codewhale_home_override().is_none());
2235 }
2236
2237 #[test]
2238 fn codewhale_home_override_none_when_empty() {
2239 let _lock = CODEWHALE_HOME_TEST_LOCK.lock().unwrap();
2240 let _g = CodeWhaleHomeGuard::set(" ");
2241 assert!(
2247 codewhale_home_override().is_some(),
2248 "non-empty (even whitespace) counts as set; trimming is the caller's job"
2249 );
2250 }
2251
2252 #[test]
2253 fn default_state_db_path_uses_codewhale_home_when_set() {
2254 let _lock = CODEWHALE_HOME_TEST_LOCK.lock().unwrap();
2255 let dir = std::env::temp_dir().join(format!(
2256 "cw-home-state-{}-{}",
2257 std::process::id(),
2258 std::time::SystemTime::now()
2259 .duration_since(std::time::UNIX_EPOCH)
2260 .unwrap()
2261 .as_nanos()
2262 ));
2263 let _g = CodeWhaleHomeGuard::set(dir.to_str().unwrap());
2264 assert_eq!(default_state_db_path(), dir.join("state.db"));
2268 }
2269
2270 #[test]
2271 fn load_checkpoint_propagates_invalid_state_json() {
2272 let store = temp_state_store("checkpoint-parse-error");
2273 store
2274 .upsert_thread(&test_thread("thread-1"))
2275 .expect("upsert thread");
2276 store
2277 .save_checkpoint("thread-1", "broken", &json!({"ok": true}))
2278 .expect("save checkpoint");
2279
2280 {
2281 let conn = store.conn().expect("conn");
2282 conn.execute(
2283 "UPDATE checkpoints SET state_json = ?1 WHERE thread_id = ?2 AND checkpoint_id = ?3",
2284 params!["not-json", "thread-1", "broken"],
2285 )
2286 .expect("corrupt checkpoint");
2287 }
2288
2289 let err = store
2290 .load_checkpoint("thread-1", Some("broken"))
2291 .expect_err("invalid checkpoint json should fail");
2292 assert!(
2293 err.to_string()
2294 .contains("failed to parse checkpoint state json")
2295 );
2296 }
2297
2298 #[test]
2299 fn session_index_compacts_after_threshold() {
2300 let store = temp_state_store("session-index-compact");
2301 for idx in 0..6 {
2302 store
2303 .append_thread_name("thread-1", Some(format!("name-{idx}")), idx, None)
2304 .expect("append session index entry");
2305 }
2306
2307 let line_count = store
2308 .session_index_line_count()
2309 .expect("count session index lines");
2310 assert_eq!(line_count, 1);
2311
2312 let name = store
2313 .find_thread_name_by_id("thread-1")
2314 .expect("lookup thread name");
2315 assert_eq!(name.as_deref(), Some("name-5"));
2316 }
2317}