返回 CodeWhale
parity_state.rs
根目录 / crates / state / tests / parity_state.rs
1 use std::path::PathBuf;
2
3 use codewhale_state::{SessionSource, StateStore, ThreadListFilters, ThreadMetadata, ThreadStatus};
4 use rusqlite::Connection;
5
6 fn temp_state_path(label: &str) -> PathBuf {
7 std::env::temp_dir().join(format!(
8 "deepseek_state_test_{}_{}_{}.db",
9 label,
10 std::process::id(),
11 chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
12 ))
13 }
14
15 fn assert_workflow_trace_schema(conn: &Connection) {
16 let user_version: u32 = conn
17 .query_row("PRAGMA user_version;", [], |row| row.get(0))
18 .expect("read user_version");
19 // v5 (goal stall-history migration) adds `thread_goals.last_gap_fingerprint`,
20 // `repeated_gap_count`, `last_gap_pass` and `pause_reason` on top of the v4
21 // continuation-count column and the v3 workflow-trace + thread_goals tables.
22 // The table set asserted below is unchanged; only the schema version advanced.
23 assert_eq!(user_version, 5);
24
25 for table in [
26 "workflow_runs",
27 "branch_runs",
28 "leaf_runs",
29 "control_node_runs",
30 "teacher_candidates",
31 "thread_goals",
32 ] {
33 let exists: bool = conn
34 .query_row(
35 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
36 [table],
37 |row| row.get(0),
38 )
39 .unwrap_or_else(|err| panic!("read sqlite_master for {table}: {err}"));
40 assert!(exists, "missing workflow trace table {table}");
41 }
42 }
43
44 #[test]
45 fn upsert_and_resume_thread_metadata() {
46 let path = temp_state_path("upsert_resume");
47 let store = StateStore::open(Some(path.clone())).expect("open state store");
48 let now = chrono::Utc::now().timestamp();
49 let thread = ThreadMetadata {
50 id: "thread-test-1".to_string(),
51 rollout_path: Some(PathBuf::from("/tmp/rollout.jsonl")),
52 preview: "hello".to_string(),
53 ephemeral: false,
54 model_provider: "deepseek".to_string(),
55 created_at: now,
56 updated_at: now,
57 status: ThreadStatus::Running,
58 path: Some(PathBuf::from("/tmp/project")),
59 cwd: PathBuf::from("/tmp/project"),
60 cli_version: "0.0.0-test".to_string(),
61 source: SessionSource::Interactive,
62 name: Some("Test Thread".to_string()),
63 sandbox_policy: Some("workspace-write".to_string()),
64 approval_mode: Some("on-request".to_string()),
65 archived: false,
66 archived_at: None,
67 git_sha: None,
68 git_branch: None,
69 git_origin_url: None,
70 memory_mode: Some("extended".to_string()),
71 current_leaf_id: None,
72 };
73 store.upsert_thread(&thread).expect("upsert thread");
74
75 let loaded = store
76 .get_thread("thread-test-1")
77 .expect("read thread")
78 .expect("thread must exist");
79 assert_eq!(loaded.id, "thread-test-1");
80 assert_eq!(loaded.name.as_deref(), Some("Test Thread"));
81 assert_eq!(loaded.memory_mode.as_deref(), Some("extended"));
82 assert_eq!(
83 loaded.rollout_path,
84 Some(PathBuf::from("/tmp/rollout.jsonl"))
85 );
86
87 store
88 .mark_archived("thread-test-1")
89 .expect("archive thread");
90 let archived = store
91 .get_thread("thread-test-1")
92 .expect("read archived thread")
93 .expect("thread exists after archive");
94 assert!(archived.archived);
95
96 let listed = store
97 .list_threads(ThreadListFilters {
98 include_archived: true,
99 limit: Some(10),
100 })
101 .expect("list threads");
102 assert!(!listed.is_empty());
103 }
104
105 #[test]
106 fn init_schema_migration() {
107 let path = temp_state_path("init_schema_migration");
108 let conn = Connection::open(&path).expect("open state db");
109 conn.execute_batch(
110 r#"
111 CREATE TABLE IF NOT EXISTS threads (
112 id TEXT PRIMARY KEY,
113 rollout_path TEXT,
114 preview TEXT NOT NULL,
115 ephemeral INTEGER NOT NULL,
116 model_provider TEXT NOT NULL,
117 created_at INTEGER NOT NULL,
118 updated_at INTEGER NOT NULL,
119 status TEXT NOT NULL,
120 path TEXT,
121 cwd TEXT NOT NULL,
122 cli_version TEXT NOT NULL,
123 source TEXT NOT NULL,
124 title TEXT,
125 sandbox_policy TEXT,
126 approval_mode TEXT,
127 archived INTEGER NOT NULL DEFAULT 0,
128 archived_at INTEGER,
129 git_sha TEXT,
130 git_branch TEXT,
131 git_origin_url TEXT,
132 memory_mode TEXT
133 );
134 CREATE TABLE IF NOT EXISTS messages (
135 id INTEGER PRIMARY KEY AUTOINCREMENT,
136 thread_id TEXT NOT NULL,
137 role TEXT NOT NULL,
138 content TEXT NOT NULL,
139 item_json TEXT,
140 created_at INTEGER NOT NULL,
141 FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
142 );
143 INSERT INTO threads (
144 id, preview, ephemeral, model_provider, created_at, updated_at, status, cwd, cli_version, source, archived
145 )
146 VALUES (
147 'thread-test-1', 'hello', false, 'deepseek', 0, 0, 'running', '/tmp/project', '0.0.0-test', 'interactive', false
148 );
149 INSERT INTO messages (thread_id, role, content, created_at) VALUES
150 ('thread-test-1', 'foo0', 'bar0', 0),
151 ('thread-test-1', 'foo1', 'bar1', 1),
152 ('thread-test-1', 'foo2', 'bar2', 2);
153 "#,
154 )
155 .expect("init schema migration");
156
157 let store = StateStore::open(Some(path.clone())).expect("open state store");
158 let thread = store
159 .get_thread("thread-test-1")
160 .expect("read thread")
161 .unwrap();
162 assert_eq!(thread.id, "thread-test-1");
163 assert_eq!(thread.preview, "hello");
164 assert!(!thread.ephemeral);
165 assert_eq!(thread.model_provider, "deepseek");
166 assert_eq!(thread.created_at, 0);
167 assert_eq!(thread.updated_at, 0);
168 assert_eq!(thread.status, ThreadStatus::Running);
169 assert_eq!(thread.cwd, PathBuf::from("/tmp/project"));
170 assert_eq!(thread.cli_version, "0.0.0-test");
171 assert_eq!(thread.source, SessionSource::Interactive);
172 assert!(thread.current_leaf_id.is_some());
173
174 let messages = store
175 .list_messages("thread-test-1", None)
176 .expect("list messages");
177 assert_eq!(messages.len(), 3);
178 for (i, message) in messages.iter().enumerate() {
179 assert_eq!(message.thread_id, "thread-test-1");
180 assert_eq!(message.role, format!("foo{i}"));
181 assert_eq!(message.content, format!("bar{i}"));
182 assert_eq!(message.created_at, i as i64);
183 }
184
185 // Test idempotent
186 StateStore::open(Some(path.clone())).expect("open state store");
187 }
188
189 #[test]
190 fn fresh_schema_includes_workflow_trace_tables() {
191 let path = temp_state_path("fresh_schema_includes_workflow_trace_tables");
192
193 StateStore::open(Some(path.clone())).expect("open state store");
194
195 let conn = Connection::open(&path).expect("open state db");
196 assert_workflow_trace_schema(&conn);
197 }
198
199 #[test]
200 fn v1_schema_migrates_workflow_trace_tables() {
201 let path = temp_state_path("v1_schema_migrates_workflow_trace_tables");
202 let conn = Connection::open(&path).expect("open state db");
203 conn.execute_batch(
204 r#"
205 CREATE TABLE threads (
206 id TEXT PRIMARY KEY,
207 rollout_path TEXT,
208 preview TEXT NOT NULL,
209 ephemeral INTEGER NOT NULL,
210 model_provider TEXT NOT NULL,
211 created_at INTEGER NOT NULL,
212 updated_at INTEGER NOT NULL,
213 status TEXT NOT NULL,
214 path TEXT,
215 cwd TEXT NOT NULL,
216 cli_version TEXT NOT NULL,
217 source TEXT NOT NULL,
218 title TEXT,
219 sandbox_policy TEXT,
220 approval_mode TEXT,
221 archived INTEGER NOT NULL DEFAULT 0,
222 archived_at INTEGER,
223 git_sha TEXT,
224 git_branch TEXT,
225 git_origin_url TEXT,
226 memory_mode TEXT,
227 current_leaf_id INTEGER
228 );
229 CREATE TABLE messages (
230 id INTEGER PRIMARY KEY AUTOINCREMENT,
231 thread_id TEXT NOT NULL,
232 role TEXT NOT NULL,
233 content TEXT NOT NULL,
234 item_json TEXT,
235 created_at INTEGER NOT NULL,
236 parent_entry_id INTEGER
237 );
238 CREATE TABLE checkpoints (
239 thread_id TEXT NOT NULL,
240 checkpoint_id TEXT NOT NULL,
241 state_json TEXT NOT NULL,
242 created_at INTEGER NOT NULL,
243 PRIMARY KEY(thread_id, checkpoint_id)
244 );
245 CREATE TABLE jobs (
246 id TEXT PRIMARY KEY,
247 name TEXT NOT NULL,
248 status TEXT NOT NULL,
249 progress INTEGER,
250 detail TEXT,
251 created_at INTEGER NOT NULL,
252 updated_at INTEGER NOT NULL
253 );
254 CREATE TABLE thread_dynamic_tools (
255 thread_id TEXT NOT NULL,
256 position INTEGER NOT NULL,
257 name TEXT NOT NULL,
258 description TEXT,
259 input_schema TEXT NOT NULL,
260 PRIMARY KEY (thread_id, position)
261 );
262 INSERT INTO threads (
263 id, preview, ephemeral, model_provider, created_at, updated_at, status, cwd, cli_version, source, archived
264 )
265 VALUES (
266 'thread-test-1', 'hello', false, 'deepseek', 0, 0, 'running', '/tmp/project', '0.0.0-test', 'interactive', false
267 );
268 PRAGMA user_version = 1;
269 "#,
270 )
271 .expect("create v1 schema");
272 drop(conn);
273
274 let store = StateStore::open(Some(path.clone())).expect("open state store");
275 let thread = store
276 .get_thread("thread-test-1")
277 .expect("read thread")
278 .expect("thread survives migration");
279 assert_eq!(thread.preview, "hello");
280
281 let conn = Connection::open(&path).expect("open state db");
282 assert_workflow_trace_schema(&conn);
283 }
284
285 #[test]
286 fn init_schema_migration_same_second_messages() {
287 let path = temp_state_path("init_schema_migration_same_second_messages");
288 let conn = Connection::open(&path).expect("open state db");
289 conn.execute_batch(
290 r#"
291 CREATE TABLE IF NOT EXISTS threads (
292 id TEXT PRIMARY KEY,
293 rollout_path TEXT,
294 preview TEXT NOT NULL,
295 ephemeral INTEGER NOT NULL,
296 model_provider TEXT NOT NULL,
297 created_at INTEGER NOT NULL,
298 updated_at INTEGER NOT NULL,
299 status TEXT NOT NULL,
300 path TEXT,
301 cwd TEXT NOT NULL,
302 cli_version TEXT NOT NULL,
303 source TEXT NOT NULL,
304 title TEXT,
305 sandbox_policy TEXT,
306 approval_mode TEXT,
307 archived INTEGER NOT NULL DEFAULT 0,
308 archived_at INTEGER,
309 git_sha TEXT,
310 git_branch TEXT,
311 git_origin_url TEXT,
312 memory_mode TEXT
313 );
314 CREATE TABLE IF NOT EXISTS messages (
315 id INTEGER PRIMARY KEY AUTOINCREMENT,
316 thread_id TEXT NOT NULL,
317 role TEXT NOT NULL,
318 content TEXT NOT NULL,
319 item_json TEXT,
320 created_at INTEGER NOT NULL,
321 FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
322 );
323 INSERT INTO threads (
324 id, preview, ephemeral, model_provider, created_at, updated_at, status, cwd, cli_version, source, archived
325 )
326 VALUES (
327 'thread-test-2', 'hello', false, 'deepseek', 0, 0, 'running', '/tmp/project', '0.0.0-test', 'interactive', false
328 );
329 INSERT INTO messages (thread_id, role, content, created_at) VALUES
330 ('thread-test-2', 'foo0', 'bar0', 123),
331 ('thread-test-2', 'foo1', 'bar1', 123),
332 ('thread-test-2', 'foo2', 'bar2', 123),
333 ('thread-test-2', 'foo3', 'bar3', 123);
334 "#,
335 )
336 .expect("init schema migration");
337
338 let store = StateStore::open(Some(path.clone())).expect("open state store");
339 let messages = store
340 .list_messages("thread-test-2", None)
341 .expect("list messages");
342 assert_eq!(messages.len(), 4);
343 for (i, message) in messages.iter().enumerate() {
344 assert_eq!(message.thread_id, "thread-test-2");
345 assert_eq!(message.role, format!("foo{i}"));
346 assert_eq!(message.content, format!("bar{i}"));
347 assert_eq!(message.created_at, 123);
348 }
349 assert_eq!(messages[0].parent_entry_id, None);
350 assert_eq!(messages[1].parent_entry_id, Some(messages[0].id));
351 assert_eq!(messages[2].parent_entry_id, Some(messages[1].id));
352 assert_eq!(messages[3].parent_entry_id, Some(messages[2].id));
353
354 // Test idempotent reopen after same-second parent links are migrated.
355 StateStore::open(Some(path.clone())).expect("open state store - idempotent");
356 }
357
358 #[test]
359 fn test_fork() {
360 let path = temp_state_path("test_fork");
361 let store = StateStore::open(Some(path.clone())).expect("open state store");
362 let now = chrono::Utc::now().timestamp();
363 let thread = ThreadMetadata {
364 id: "thread-test-1".to_string(),
365 rollout_path: Some(PathBuf::from("/tmp/rollout.jsonl")),
366 preview: "hello".to_string(),
367 ephemeral: false,
368 model_provider: "deepseek".to_string(),
369 created_at: now,
370 updated_at: now,
371 status: ThreadStatus::Running,
372 path: Some(PathBuf::from("/tmp/project")),
373 cwd: PathBuf::from("/tmp/project"),
374 cli_version: "0.0.0-test".to_string(),
375 source: SessionSource::Interactive,
376 name: Some("Test Thread".to_string()),
377 sandbox_policy: Some("workspace-write".to_string()),
378 approval_mode: Some("on-request".to_string()),
379 archived: false,
380 archived_at: None,
381 git_sha: None,
382 git_branch: None,
383 git_origin_url: None,
384 memory_mode: Some("extended".to_string()),
385 current_leaf_id: None,
386 };
387
388 store.upsert_thread(&thread).expect("upsert thread");
389 store
390 .append_message("thread-test-1", "foo0", "bar0", None)
391 .expect("append message");
392 store
393 .append_message("thread-test-1", "foo1", "bar1", None)
394 .expect("append message");
395 store
396 .append_message("thread-test-1", "foo2", "bar2", None)
397 .expect("append message");
398 store
399 .append_message("thread-test-1", "foo3", "bar3", None)
400 .expect("append message");
401 store
402 .append_message("thread-test-1", "foo4", "bar4", None)
403 .expect("append message");
404
405 let messages = store
406 .list_messages("thread-test-1", None)
407 .expect("list messages");
408 assert_eq!(messages.len(), 5);
409 let ids = messages
410 .iter()
411 .enumerate()
412 .map(|(i, message)| {
413 assert_eq!(message.thread_id, "thread-test-1");
414 assert_eq!(message.role, format!("foo{i}"));
415 assert_eq!(message.content, format!("bar{i}"));
416 message.id.to_string()
417 })
418 .collect::<Vec<_>>();
419
420 store.upsert_thread(&thread).expect("upsert thread");
421
422 store
423 .fork_at_message(&ids[2], "foo5", "bar5", None)
424 .expect("fork at message");
425 let messages = store
426 .list_messages("thread-test-1", None)
427 .expect("list messages");
428 assert_eq!(messages.len(), 4);
429 const LIST_1: [i64; 4] = [0, 1, 2, 5];
430 messages
431 .iter()
432 .zip(LIST_1.iter())
433 .for_each(|(message, &i)| {
434 assert_eq!(message.thread_id, "thread-test-1");
435 assert_eq!(message.role, format!("foo{i}"));
436 assert_eq!(message.content, format!("bar{i}"));
437 });
438 let leaves = store
439 .list_leaf_messages("thread-test-1")
440 .expect("list leaf messages");
441 assert_eq!(leaves.len(), 2);
442
443 store
444 .set_current_leaf_id("thread-test-1", &ids[4])
445 .expect("set current leaf id");
446 store
447 .append_message("thread-test-1", "foo6", "bar6", None)
448 .expect("append message");
449 let messages = store
450 .list_messages("thread-test-1", None)
451 .expect("list messages");
452 assert_eq!(messages.len(), 6);
453 const LIST_2: [i64; 6] = [0, 1, 2, 3, 4, 6];
454 messages
455 .iter()
456 .zip(LIST_2.iter())
457 .for_each(|(message, &i)| {
458 assert_eq!(message.thread_id, "thread-test-1");
459 assert_eq!(message.role, format!("foo{i}"));
460 assert_eq!(message.content, format!("bar{i}"));
461 });
462
463 let leaves = store
464 .list_leaf_messages("thread-test-1")
465 .expect("list leaf messages");
466 assert_eq!(leaves.len(), 2);
467
468 store
469 .clear_messages("thread-test-1")
470 .expect("clear messages");
471 let leaves = store
472 .list_leaf_messages("thread-test-1")
473 .expect("list leaf messages");
474 assert_eq!(leaves.len(), 0);
475 let thread = store
476 .get_thread("thread-test-1")
477 .expect("get thread")
478 .unwrap();
479 assert!(thread.current_leaf_id.is_none());
480 }
481
481 lines RUST