1use std::path::{Path, PathBuf};
2use std::time::Duration;
3
4use rusqlite::{Connection, TransactionBehavior, params};
5use thiserror::Error;
6use tracing::info;
7
8mod activities;
9mod harness_history;
10mod harness_sessions;
11mod harness_steps;
12mod recovery;
13mod run_traces;
14mod trace_payloads;
15mod trace_storage;
16mod traces;
17mod workflow_types;
18mod workflows;
19pub(crate) use traces::{NewTraceSpan, TraceEnding};
20
21pub(crate) use harness_steps::HarnessStepContext;
22pub(crate) use workflow_types::StoredWorkflowType;
23
24const MIGRATIONS: &[&str] = &[
27 "CREATE TABLE recent_folders (
29 path TEXT PRIMARY KEY NOT NULL,
30 last_opened_at INTEGER NOT NULL,
31 is_open INTEGER NOT NULL DEFAULT 0 CHECK (is_open IN (0, 1))
32 ) STRICT",
33 "CREATE TABLE sessions (
35 id INTEGER PRIMARY KEY AUTOINCREMENT,
36 folder TEXT NOT NULL,
37 name TEXT NOT NULL,
38 started_at INTEGER NOT NULL
39 ) STRICT;
40 CREATE INDEX sessions_folder ON sessions(folder);
41 CREATE TABLE workflows (
42 id INTEGER PRIMARY KEY AUTOINCREMENT,
43 session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
44 name TEXT NOT NULL,
45 kind TEXT NOT NULL CHECK (kind IN ('draft', 'terminal'))
46 ) STRICT;
47 CREATE INDEX workflows_session ON workflows(session_id);
48 CREATE TABLE folder_selection (
49 folder TEXT PRIMARY KEY NOT NULL,
50 session_id INTEGER REFERENCES sessions(id) ON DELETE SET NULL
51 ) STRICT",
52 "CREATE TABLE workflow_types (
54 id INTEGER PRIMARY KEY AUTOINCREMENT
55 ) STRICT;
56 CREATE TABLE workflow_type_versions (
57 type_id INTEGER NOT NULL REFERENCES workflow_types(id) ON DELETE CASCADE,
58 version INTEGER NOT NULL CHECK (version > 0),
59 definition TEXT NOT NULL,
60 created_at INTEGER NOT NULL,
61 PRIMARY KEY (type_id, version)
62 ) STRICT",
63 "CREATE TABLE workflows_new (
65 id INTEGER PRIMARY KEY AUTOINCREMENT,
66 session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
67 name TEXT NOT NULL,
68 kind TEXT NOT NULL CHECK (kind IN ('draft', 'terminal', 'single_agent')),
69 harness TEXT CHECK (harness IN ('codex', 'claude_code', 'pi')),
70 agent_status TEXT CHECK (agent_status IN ('running', 'exited', 'failed', 'cancelled'))
71 ) STRICT;
72 INSERT INTO workflows_new (id, session_id, name, kind)
73 SELECT id, session_id, name, kind FROM workflows;
74 UPDATE sqlite_sequence SET seq = MAX(seq, COALESCE(
75 (SELECT seq FROM sqlite_sequence WHERE name = 'workflows'), 0))
76 WHERE name = 'workflows_new';
77 DROP TABLE workflows;
78 ALTER TABLE workflows_new RENAME TO workflows;
79 CREATE INDEX workflows_session ON workflows(session_id)",
80 "CREATE TABLE workflows_v5 (
84 id INTEGER PRIMARY KEY AUTOINCREMENT,
85 session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
86 name TEXT NOT NULL,
87 kind TEXT NOT NULL CHECK (kind IN ('draft', 'terminal', 'single_agent', 'agents')),
88 harness TEXT CHECK (harness IN ('codex', 'claude_code', 'pi')),
89 agent_status TEXT CHECK (agent_status IN ('running', 'exited', 'failed', 'cancelled'))
90 ) STRICT;
91 INSERT INTO workflows_v5 (id, session_id, name, kind, harness, agent_status)
92 SELECT id, session_id, name, kind, harness, agent_status FROM workflows;
93 DELETE FROM sqlite_sequence WHERE name = 'workflows_v5';
94 INSERT INTO sqlite_sequence (name, seq)
95 SELECT 'workflows_v5', seq FROM sqlite_sequence WHERE name = 'workflows';
96 DROP TABLE workflows;
97 ALTER TABLE workflows_v5 RENAME TO workflows;
98 CREATE INDEX workflows_session ON workflows(session_id);
99 CREATE TABLE agents (
100 id INTEGER PRIMARY KEY AUTOINCREMENT,
101 workflow_id INTEGER NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
102 role TEXT NOT NULL
103 ) STRICT;
104 CREATE INDEX agents_workflow ON agents(workflow_id)",
105 "ALTER TABLE workflows ADD COLUMN closed_at INTEGER;
107 CREATE TABLE trace_lanes (
108 id INTEGER PRIMARY KEY AUTOINCREMENT,
109 workflow_id INTEGER NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
110 lane_key TEXT NOT NULL,
111 name TEXT NOT NULL,
112 is_agent INTEGER NOT NULL CHECK (is_agent IN (0, 1)),
113 role TEXT,
114 harness TEXT,
115 UNIQUE (workflow_id, lane_key)
116 ) STRICT;
117 CREATE TABLE trace_spans (
118 id INTEGER PRIMARY KEY AUTOINCREMENT,
119 lane_id INTEGER NOT NULL REFERENCES trace_lanes(id) ON DELETE CASCADE,
120 title TEXT NOT NULL,
121 started_at INTEGER NOT NULL CHECK (started_at >= 0),
122 ended_at INTEGER CHECK (ended_at >= started_at),
123 status TEXT NOT NULL CHECK (status IN ('running', 'exited', 'failed', 'stopped')),
124 terminal_id INTEGER
125 ) STRICT;
126 CREATE INDEX trace_spans_lane ON trace_spans(lane_id, id);
127 CREATE INDEX trace_spans_terminal ON trace_spans(terminal_id);
128 CREATE TABLE trace_events (
129 id INTEGER PRIMARY KEY AUTOINCREMENT,
130 workflow_id INTEGER NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
131 span_id INTEGER REFERENCES trace_spans(id) ON DELETE CASCADE,
132 timestamp INTEGER NOT NULL CHECK (timestamp >= 0),
133 kind TEXT NOT NULL CHECK (kind IN ('processStarted', 'processExited', 'processFailed', 'processStopped')),
134 message TEXT NOT NULL,
135 terminal_id INTEGER,
136 byte_offset INTEGER,
137 CHECK ((terminal_id IS NULL) = (byte_offset IS NULL)),
138 CHECK (terminal_id IS NULL OR terminal_id > 0),
139 CHECK (byte_offset IS NULL OR byte_offset >= 0)
140 ) STRICT;
141 CREATE INDEX trace_events_workflow ON trace_events(workflow_id, id);
142 CREATE INDEX trace_events_span ON trace_events(span_id, id)",
143 "CREATE TABLE workflow_runs (
145 workflow_id INTEGER PRIMARY KEY REFERENCES workflows(id) ON DELETE CASCADE,
146 state TEXT NOT NULL,
147 trace_sequence INTEGER NOT NULL DEFAULT 0
148 ) STRICT;
149 ALTER TABLE trace_spans ADD COLUMN run_generation INTEGER;
150 CREATE TABLE trace_events_v7 (
151 id INTEGER PRIMARY KEY AUTOINCREMENT,
152 workflow_id INTEGER NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
153 span_id INTEGER REFERENCES trace_spans(id) ON DELETE CASCADE,
154 timestamp INTEGER NOT NULL CHECK (timestamp >= 0),
155 kind TEXT NOT NULL CHECK (kind IN ('processStarted', 'processExited', 'processFailed', 'processStopped', 'workflowEvent')),
156 message TEXT NOT NULL,
157 terminal_id INTEGER,
158 byte_offset INTEGER,
159 CHECK ((terminal_id IS NULL) = (byte_offset IS NULL)),
160 CHECK (terminal_id IS NULL OR terminal_id > 0),
161 CHECK (byte_offset IS NULL OR byte_offset >= 0)
162 ) STRICT;
163 INSERT INTO trace_events_v7 SELECT * FROM trace_events;
164 DELETE FROM sqlite_sequence WHERE name = 'trace_events_v7';
165 INSERT INTO sqlite_sequence (name, seq)
166 SELECT 'trace_events_v7', seq FROM sqlite_sequence WHERE name = 'trace_events';
167 DROP TABLE trace_events;
168 ALTER TABLE trace_events_v7 RENAME TO trace_events;
169 CREATE INDEX trace_events_workflow ON trace_events(workflow_id, id);
170 CREATE INDEX trace_events_span ON trace_events(span_id, id)",
171 "ALTER TABLE trace_events ADD COLUMN boundary_sizes BLOB CHECK
173 (boundary_sizes IS NULL OR (length(boundary_sizes) <= 2048 AND length(boundary_sizes) % 8 = 0))",
174 "ALTER TABLE workflows ADD COLUMN lifecycle_status TEXT CHECK
176 (lifecycle_status IN ('running', 'exited', 'failed', 'cancelled', 'interrupted'));
177 UPDATE workflows SET lifecycle_status = agent_status WHERE kind = 'single_agent'",
178 "ALTER TABLE trace_spans ADD COLUMN work_span INTEGER NOT NULL DEFAULT 0 CHECK (work_span IN (0, 1))",
181 "ALTER TABLE trace_spans ADD COLUMN harness_turn_id TEXT;
183 CREATE UNIQUE INDEX trace_harness_turn ON trace_spans(terminal_id, harness_turn_id)
184 WHERE harness_turn_id IS NOT NULL",
185 "CREATE TABLE workflow_terminals (
187 terminal_id INTEGER PRIMARY KEY CHECK (terminal_id > 0),
188 workflow_id INTEGER NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
189 agent_id INTEGER
190 ) STRICT;
191 CREATE INDEX workflow_terminals_workflow ON workflow_terminals(workflow_id, terminal_id);
192 INSERT OR IGNORE INTO workflow_terminals (terminal_id, workflow_id, agent_id)
193 SELECT s.terminal_id, l.workflow_id,
194 CASE WHEN l.lane_key LIKE 'agent:%' THEN CAST(SUBSTR(l.lane_key, 7) AS INTEGER) END
195 FROM trace_spans s JOIN trace_lanes l ON l.id = s.lane_id
196 JOIN workflows w ON w.id = l.workflow_id
197 WHERE s.terminal_id > 0 AND (w.kind IN ('draft', 'terminal') OR l.is_agent = 1);
198 INSERT OR IGNORE INTO workflow_terminals (terminal_id, workflow_id)
199 SELECT e.terminal_id, e.workflow_id FROM trace_events e
200 JOIN workflows w ON w.id = e.workflow_id
201 WHERE e.terminal_id > 0 AND w.kind IN ('draft', 'terminal')",
202 "ALTER TABLE workflows ADD COLUMN harness_id TEXT CHECK
205 (harness_id IN ('codex', 'claude_code', 'pi', 'antigravity', 'omp'));
206 UPDATE workflows SET harness_id = harness",
207 "ALTER TABLE workflows RENAME COLUMN harness_id TO legacy_harness_id;
210 ALTER TABLE workflows ADD COLUMN harness_id TEXT;
211 UPDATE workflows SET harness_id = legacy_harness_id",
212 "ALTER TABLE workflow_terminals ADD COLUMN harness_session TEXT",
214 "CREATE TABLE open_folders (
216 path TEXT PRIMARY KEY NOT NULL,
217 last_opened_at INTEGER NOT NULL
218 ) STRICT;
219 INSERT INTO open_folders SELECT path, last_opened_at FROM recent_folders WHERE is_open = 1",
220 "CREATE TABLE trace_activities (
222 id INTEGER PRIMARY KEY AUTOINCREMENT,
223 span_id INTEGER NOT NULL REFERENCES trace_spans(id) ON DELETE CASCADE,
224 source_id TEXT NOT NULL,
225 parent_source_id TEXT,
226 kind TEXT NOT NULL CHECK (kind IN ('tool', 'subagent')),
227 title TEXT NOT NULL,
228 started_at INTEGER,
229 ended_at INTEGER,
230 status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'failed', 'interrupted')),
231 input TEXT NOT NULL,
232 output TEXT NOT NULL,
233 anchor TEXT,
234 UNIQUE(span_id, source_id)
235 ) STRICT;
236 CREATE INDEX trace_activities_parent ON trace_activities(span_id, parent_source_id)",
237 "ALTER TABLE trace_activities RENAME TO trace_activities_v17;
239 DROP INDEX trace_activities_parent;
240 CREATE TABLE trace_activities (
241 id INTEGER PRIMARY KEY AUTOINCREMENT,
242 span_id INTEGER NOT NULL REFERENCES trace_spans(id) ON DELETE CASCADE,
243 source_id TEXT NOT NULL, parent_source_id TEXT,
244 kind TEXT NOT NULL CHECK (kind IN ('tool', 'subagent', 'model', 'note')),
245 title TEXT NOT NULL, started_at INTEGER, ended_at INTEGER,
246 status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'failed', 'interrupted')),
247 input TEXT NOT NULL, output TEXT NOT NULL, anchor TEXT,
248 input_key TEXT, output_key TEXT,
249 input_bytes INTEGER, output_bytes INTEGER,
250 metadata TEXT NOT NULL DEFAULT '{}',
251 UNIQUE(span_id, source_id)
252 ) STRICT;
253 INSERT INTO trace_activities (id,span_id,source_id,parent_source_id,kind,title,started_at,ended_at,status,input,output,anchor)
254 SELECT id,span_id,source_id,parent_source_id,kind,title,started_at,ended_at,status,input,output,anchor FROM trace_activities_v17;
255 INSERT INTO sqlite_sequence(name,seq) SELECT 'trace_activities',seq FROM sqlite_sequence WHERE name='trace_activities_v17'
256 AND NOT EXISTS(SELECT 1 FROM sqlite_sequence WHERE name='trace_activities');
257 UPDATE sqlite_sequence SET seq=MAX(seq,COALESCE((SELECT seq FROM sqlite_sequence WHERE name='trace_activities_v17'),0))
258 WHERE name='trace_activities';
259 DROP TABLE trace_activities_v17;
260 CREATE INDEX trace_activities_parent ON trace_activities(span_id, parent_source_id)",
261 "ALTER TABLE trace_spans ADD COLUMN pinned_details INTEGER NOT NULL DEFAULT 0 CHECK(pinned_details IN (0,1));
263 CREATE TABLE trace_storage_preferences (
264 id INTEGER PRIMARY KEY CHECK(id=1),budget_bytes INTEGER NOT NULL,retention_days INTEGER NOT NULL,
265 payload_bytes INTEGER NOT NULL DEFAULT 0,payload_files INTEGER NOT NULL DEFAULT 0,updated_at INTEGER NOT NULL DEFAULT 0,
266 clear_generation INTEGER NOT NULL DEFAULT 0,completed_clear_generation INTEGER NOT NULL DEFAULT 0
267 ) STRICT;
268 INSERT INTO trace_storage_preferences(id,budget_bytes,retention_days) VALUES(1,536870912,90)"
269];
270
271const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
274
275#[derive(Debug, Eq, PartialEq)]
277pub(crate) struct StoredFolder {
278 pub(crate) path: PathBuf,
279 pub(crate) is_open: bool,
281}
282
283#[derive(Debug)]
285pub(crate) struct Store {
286 connection: Connection,
287 payloads: trace_payloads::TracePayloads,
288 pub(crate) workflow_directory: std::path::PathBuf,
289 _workflow_temp: Option<tempfile::TempDir>,
290}
291
292fn workflow_file_directory(config_path: Option<&Path>) -> PathBuf {
293 config_path.and_then(Path::parent).map_or_else(
294 || std::env::temp_dir().join("twine/workflows"),
295 |directory| directory.join("workflows"),
296 )
297}
298
299impl Store {
300 pub(crate) fn open(path: &Path) -> Result<Self, StoreError> {
303 if let Some(directory) = path.parent() {
304 std::fs::create_dir_all(directory).map_err(|source| StoreError::CreateDirectory {
305 path: directory.to_owned(),
306 source,
307 })?;
308 }
309 let connection = Connection::open(path).map_err(|source| StoreError::Open {
310 path: path.to_owned(),
311 source,
312 })?;
313 Self::with_connection(connection)
314 }
315
316 #[cfg(test)]
317 pub(crate) fn execute_test_sql(&self, sql: &str) {
318 self.connection
319 .execute_batch(sql)
320 .expect("test SQL should execute");
321 }
322
323 #[cfg(test)]
324 pub(crate) fn open_in_memory() -> Result<Self, StoreError> {
325 Self::with_connection(Connection::open_in_memory()?)
326 }
327
328 fn with_connection(mut connection: Connection) -> Result<Self, StoreError> {
329 connection.busy_timeout(BUSY_TIMEOUT)?;
330 connection.pragma_update(None, "journal_mode", "WAL")?;
331 connection.pragma_update(None, "foreign_keys", true)?;
332 migrate(&mut connection)?;
333 let payloads = trace_payloads::TracePayloads::new(connection.path())?;
334 let temporary = if connection.path().is_none() || connection.path() == Some("") {
335 Some(
336 tempfile::tempdir().map_err(|source| StoreError::CreateDirectory {
337 path: std::env::temp_dir(),
338 source,
339 })?,
340 )
341 } else {
342 None
343 };
344 let workflow_directory = if let Some(temporary) = &temporary {
345 temporary.path().join("workflows")
346 } else if cfg!(test) {
347 std::path::Path::new(connection.path().expect("disk store"))
348 .parent()
349 .unwrap_or_else(|| std::path::Path::new("."))
350 .join("workflows")
351 } else {
352 workflow_file_directory(crate::config::Config::user_path().as_deref())
353 };
354 Ok(Self {
355 connection,
356 payloads,
357 workflow_directory,
358 _workflow_temp: temporary,
359 })
360 }
361
362 pub(crate) fn recent_folders(&self) -> Result<Vec<StoredFolder>, StoreError> {
364 query_recent_folders(&self.connection)
365 }
366
367 pub(crate) fn data_version(&self) -> Result<i64, StoreError> {
368 Ok(self
369 .connection
370 .pragma_query_value(None, "data_version", |row| row.get(0))?)
371 }
372
373 pub(crate) fn open_folder_paths(&self) -> Result<Vec<PathBuf>, StoreError> {
374 let mut statement = self
375 .connection
376 .prepare("SELECT path FROM open_folders ORDER BY last_opened_at DESC")?;
377 Ok(statement
378 .query_map([], |row| Ok(PathBuf::from(row.get::<_, String>(0)?)))?
379 .collect::<Result<_, _>>()?)
380 }
381
382 pub(crate) fn record_folder_opened(
388 &mut self,
389 path: &str,
390 opened_at_millis: i64,
391 limit: i64,
392 ) -> Result<Vec<StoredFolder>, StoreError> {
393 self.record_window_folder_opened(path, opened_at_millis, limit, None, true)
394 }
395
396 pub(crate) fn record_window_folder_opened(
397 &mut self,
398 path: &str,
399 opened_at_millis: i64,
400 limit: i64,
401 previous: Option<&Path>,
402 exclusive: bool,
403 ) -> Result<Vec<StoredFolder>, StoreError> {
404 let transaction = self.connection.transaction()?;
405 if exclusive {
406 transaction.execute("DELETE FROM open_folders", [])?;
407 transaction.execute(
408 "UPDATE recent_folders SET is_open = 0 WHERE is_open = 1",
409 [],
410 )?;
411 } else if let Some(previous) = previous {
412 transaction.execute(
413 "DELETE FROM open_folders WHERE path = ?1",
414 [previous.to_string_lossy()],
415 )?;
416 transaction.execute(
417 "UPDATE recent_folders SET is_open = 0 WHERE path = ?1",
418 [previous.to_string_lossy()],
419 )?;
420 }
421 transaction.execute(
422 "INSERT INTO recent_folders (path, last_opened_at, is_open)
423 VALUES (?1, MAX(?2, (SELECT COALESCE(MAX(last_opened_at), 0) + 1 FROM
424 (SELECT last_opened_at FROM recent_folders UNION ALL SELECT last_opened_at FROM open_folders))), 1)
425 ON CONFLICT (path) DO UPDATE
426 SET last_opened_at = excluded.last_opened_at, is_open = 1",
427 params![path, opened_at_millis],
428 )?;
429 transaction.execute(
430 "INSERT INTO open_folders (path, last_opened_at)
431 SELECT path, last_opened_at FROM recent_folders WHERE path = ?1
432 ON CONFLICT(path) DO UPDATE SET last_opened_at = excluded.last_opened_at",
433 [path],
434 )?;
435 transaction.execute(
436 "DELETE FROM recent_folders WHERE path NOT IN
437 (SELECT path FROM recent_folders ORDER BY last_opened_at DESC LIMIT ?1)",
438 [limit],
439 )?;
440 let folders = query_recent_folders(&transaction)?;
441 transaction.commit()?;
442 Ok(folders)
443 }
444
445 pub(crate) fn record_folder_closed(&mut self) -> Result<Vec<StoredFolder>, StoreError> {
448 let transaction = self.connection.transaction()?;
449 transaction.execute("DELETE FROM open_folders", [])?;
450 transaction.execute(
451 "UPDATE recent_folders SET is_open = 0 WHERE is_open = 1",
452 [],
453 )?;
454 let folders = query_recent_folders(&transaction)?;
455 transaction.commit()?;
456 Ok(folders)
457 }
458
459 pub(crate) fn record_window_folder_closed(
460 &mut self,
461 path: Option<&Path>,
462 ) -> Result<Vec<StoredFolder>, StoreError> {
463 let transaction = self.connection.transaction()?;
464 if let Some(path) = path {
465 transaction.execute(
466 "DELETE FROM open_folders WHERE path = ?1",
467 [path.to_string_lossy()],
468 )?;
469 transaction.execute(
470 "UPDATE recent_folders SET is_open = 0 WHERE path = ?1",
471 [path.to_string_lossy()],
472 )?;
473 }
474 let folders = query_recent_folders(&transaction)?;
475 transaction.commit()?;
476 Ok(folders)
477 }
478
479 pub(crate) fn remove_recent_folder(
481 &mut self,
482 path: &str,
483 ) -> Result<Vec<StoredFolder>, StoreError> {
484 self.remove_recent_folder_preserving_windows(path, false)
485 }
486
487 pub(crate) fn remove_recent_folder_preserving_windows(
488 &mut self,
489 path: &str,
490 preserve_open: bool,
491 ) -> Result<Vec<StoredFolder>, StoreError> {
492 let transaction = self.connection.transaction()?;
493 if !preserve_open {
494 transaction.execute("DELETE FROM open_folders WHERE path = ?1", [path])?;
495 }
496 transaction.execute("DELETE FROM recent_folders WHERE path = ?1", [path])?;
497 let folders = query_recent_folders(&transaction)?;
498 transaction.commit()?;
499 Ok(folders)
500 }
501}
502
503fn query_recent_folders(connection: &Connection) -> Result<Vec<StoredFolder>, StoreError> {
504 let mut statement = connection
505 .prepare("SELECT path, is_open FROM recent_folders ORDER BY last_opened_at DESC")?;
506 let folders = statement
507 .query_map([], |row| {
508 Ok(StoredFolder {
509 path: PathBuf::from(row.get::<_, String>(0)?),
510 is_open: row.get(1)?,
511 })
512 })?
513 .collect::<Result<_, _>>()?;
514 Ok(folders)
515}
516
517fn migrate(connection: &mut Connection) -> Result<(), StoreError> {
521 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
522 let version: i64 = transaction.pragma_query_value(None, "user_version", |row| row.get(0))?;
523 let applied = usize::try_from(version)
524 .ok()
525 .filter(|applied| *applied <= MIGRATIONS.len())
526 .ok_or(StoreError::UnsupportedSchemaVersion { version })?;
527
528 for (version, migration) in (1_i64..).zip(MIGRATIONS).skip(applied) {
529 transaction
530 .execute_batch(migration)
531 .map_err(|source| StoreError::Migrate { version, source })?;
532 transaction.pragma_update(None, "user_version", version)?;
533 info!(version, "applied database migration");
534 }
535 transaction.commit()?;
536 Ok(())
537}
538
539fn sql_integer(value: u64) -> Result<i64, StoreError> {
540 i64::try_from(value).map_err(|_| StoreError::InvalidIdentifier)
541}
542
543fn unsigned_column(row: &rusqlite::Row<'_>, index: usize) -> rusqlite::Result<u64> {
544 let value: i64 = row.get(index)?;
545 u64::try_from(value).map_err(|_| rusqlite::Error::IntegralValueOutOfRange(index, value))
546}
547
548#[derive(Debug, Error)]
549pub enum StoreError {
550 #[error("couldn't read or store full trace details")]
551 TracePayload(#[source] std::io::Error),
552 #[error("failed to resolve data directory {}", path.display())]
553 ResolveDirectory {
554 path: PathBuf,
555 source: std::io::Error,
556 },
557 #[error("failed to encode workflow run state")]
558 RunEncoding(#[from] serde_json::Error),
559 #[error("database returned an invalid identifier")]
560 InvalidIdentifier,
561 #[error("failed to create the data directory {}", path.display())]
562 CreateDirectory {
563 path: PathBuf,
564 #[source]
565 source: std::io::Error,
566 },
567 #[error("failed to apply database migration {version}")]
568 Migrate {
569 version: i64,
570 #[source]
571 source: rusqlite::Error,
572 },
573 #[error("failed to open the database {}", path.display())]
574 Open {
575 path: PathBuf,
576 #[source]
577 source: rusqlite::Error,
578 },
579 #[error(transparent)]
580 Sqlite(#[from] rusqlite::Error),
581 #[error("database schema version {version} is not one this version of Twine supports")]
582 UnsupportedSchemaVersion { version: i64 },
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588 use crate::workflow::SessionId;
589
590 fn schema_version(connection: &Connection) -> i64 {
591 connection
592 .pragma_query_value(None, "user_version", |row| row.get(0))
593 .expect("the schema version should be readable")
594 }
595
596 #[test]
597 fn workflows_follow_the_selected_config_directory() {
598 for directory in [
599 "/checkout/out/runtime/debug/config",
600 "/home/user/.config/twine",
601 "/custom/twine",
602 ] {
603 assert_eq!(
604 workflow_file_directory(Some(&Path::new(directory).join("config.toml"))),
605 Path::new(directory).join("workflows")
606 );
607 }
608 }
609
610 #[test]
611 fn open_creates_the_directory_and_migrates_once() {
612 let directory = tempfile::tempdir().expect("a temporary directory should be available");
613 let path = directory.path().join("nested").join("twine.db");
614
615 let store = Store::open(&path).expect("the store should open");
616 assert_eq!(
617 schema_version(&store.connection),
618 i64::try_from(MIGRATIONS.len()).expect("the migration count fits in i64")
619 );
620 drop(store);
621
622 let reopened = Store::open(&path).expect("the migrated store should reopen");
623 assert_eq!(
624 schema_version(&reopened.connection),
625 i64::try_from(MIGRATIONS.len()).expect("the migration count fits in i64")
626 );
627 }
628
629 fn legacy_workflow_history() -> Connection {
630 let connection = Connection::open_in_memory().unwrap();
631 connection
632 .pragma_update(None, "foreign_keys", true)
633 .unwrap();
634 for migration in &MIGRATIONS[..11] {
635 connection.execute_batch(migration).unwrap();
636 }
637 connection.pragma_update(None, "user_version", 11).unwrap();
638 connection.execute_batch(
639 "INSERT INTO sessions (id, folder, name, started_at) VALUES (1, '/folder', 'Session', 123);
640 INSERT INTO workflows (id, session_id, name, kind, harness, lifecycle_status, closed_at)
641 VALUES (1, 1, 'Claude Code', 'single_agent', 'claude_code', 'cancelled', 456),
642 (2, 1, 'Agents', 'agents', NULL, NULL, NULL),
643 (99, 1, 'Deleted', 'draft', NULL, NULL, NULL);
644 DELETE FROM workflows WHERE id = 99;
645 INSERT INTO agents (id, workflow_id, role) VALUES (7, 2, 'Worker');
646 INSERT INTO workflow_runs (workflow_id, state, trace_sequence) VALUES (2, 'kept run state', 3);
647 INSERT INTO trace_lanes (id, workflow_id, lane_key, name, is_agent, harness)
648 VALUES (8, 2, 'worker', 'Worker', 1, 'pi');
649 INSERT INTO trace_spans (id, lane_id, title, started_at, status, harness_turn_id)
650 VALUES (9, 8, 'Task', 123, 'running', 'turn-1');
651 INSERT INTO trace_events (id, workflow_id, span_id, timestamp, kind, message)
652 VALUES (10, 2, 9, 123, 'processStarted', 'Started');"
653 ).unwrap();
654 connection
655 }
656
657 #[test]
658 fn new_harness_migration_preserves_workflows_and_dependent_history() {
659 let connection = legacy_workflow_history();
660 let mut store = Store::with_connection(connection).unwrap();
661 let preserved: (String, String, i64) = store
662 .connection
663 .query_row(
664 "SELECT harness_id, lifecycle_status, closed_at FROM workflows WHERE id = 1",
665 [],
666 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
667 )
668 .unwrap();
669 assert_eq!(preserved, ("claude_code".into(), "cancelled".into(), 456));
670 let run: (String, i64) = store
671 .connection
672 .query_row(
673 "SELECT state, trace_sequence FROM workflow_runs WHERE workflow_id = 2",
674 [],
675 |row| Ok((row.get(0)?, row.get(1)?)),
676 )
677 .unwrap();
678 assert_eq!(run, ("kept run state".into(), 3));
679 for (table, id) in [
680 ("agents", 7),
681 ("trace_lanes", 8),
682 ("trace_spans", 9),
683 ("trace_events", 10),
684 ] {
685 let stored: i64 = store
686 .connection
687 .query_row(&format!("SELECT id FROM {table}"), [], |row| row.get(0))
688 .unwrap();
689 assert_eq!(stored, id);
690 }
691 assert!(
692 store
693 .connection
694 .prepare("PRAGMA foreign_key_check")
695 .unwrap()
696 .query([])
697 .unwrap()
698 .next()
699 .unwrap()
700 .is_none()
701 );
702 for harness in [
703 crate::HarnessId::Antigravity,
704 crate::HarnessId::Omp,
705 crate::HarnessId::Opencode,
706 ] {
707 store
708 .update_workflow(
709 crate::WorkflowId(1),
710 harness.definition().name,
711 crate::WorkflowKind::SingleAgent,
712 Some(harness),
713 )
714 .unwrap();
715 let stored: String = store
716 .connection
717 .query_row("SELECT harness_id FROM workflows WHERE id = 1", [], |row| {
718 row.get(0)
719 })
720 .unwrap();
721 assert_eq!(stored, super::workflows::harness_name(harness));
722 }
723 let (id, _) = store
724 .create_workflow(SessionId(1), "New", crate::WorkflowKind::Draft, &[])
725 .unwrap();
726 assert!(id.0 > 99, "Deleted workflow IDs must not be reused");
727 store
728 .connection
729 .execute("DELETE FROM workflows WHERE id = 2", [])
730 .unwrap();
731 for table in [
732 "agents",
733 "workflow_runs",
734 "trace_lanes",
735 "trace_spans",
736 "trace_events",
737 ] {
738 let remaining: i64 = store
739 .connection
740 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| {
741 row.get(0)
742 })
743 .unwrap();
744 assert_eq!(
745 remaining, 0,
746 "Deleting a workflow still cascades to {table}"
747 );
748 }
749 }
750
751 #[test]
752 fn harness_migrations_preserve_version_twelve_terminal_ownership() {
753 let connection = legacy_workflow_history();
754 connection.execute_batch(MIGRATIONS[11]).unwrap();
755 connection.pragma_update(None, "user_version", 12).unwrap();
756 connection
757 .execute_batch(
758 "INSERT INTO workflow_terminals (terminal_id, workflow_id, agent_id)
759 VALUES (42, 1, NULL), (43, 2, 7)",
760 )
761 .unwrap();
762
763 let store = Store::with_connection(connection).unwrap();
764 let harness: String = store
765 .connection
766 .query_row("SELECT harness_id FROM workflows WHERE id = 1", [], |row| {
767 row.get(0)
768 })
769 .unwrap();
770 assert_eq!(harness, "claude_code");
771 for (workflow, terminal, agent) in [(1, 42, None), (2, 43, Some(crate::AgentId(7)))] {
772 let history = store.terminal_history(crate::WorkflowId(workflow)).unwrap();
773 assert_eq!(history.len(), 1);
774 assert_eq!(history[0].terminal_id.value(), terminal);
775 assert_eq!(history[0].agent_id, agent);
776 }
777 store
778 .connection
779 .execute("DELETE FROM workflows WHERE id = 2", [])
780 .unwrap();
781 assert_eq!(store.terminal_history(crate::WorkflowId(2)).unwrap(), []);
782 assert_eq!(
783 store.terminal_history(crate::WorkflowId(1)).unwrap().len(),
784 1
785 );
786 assert!(
787 store
788 .connection
789 .prepare("PRAGMA foreign_key_check")
790 .unwrap()
791 .query([])
792 .unwrap()
793 .next()
794 .unwrap()
795 .is_none()
796 );
797 }
798
799 #[test]
800 fn extensible_harness_migration_preserves_version_thirteen_choices_and_history() {
801 let connection = legacy_workflow_history();
802 for migration in &MIGRATIONS[11..13] {
803 connection.execute_batch(migration).unwrap();
804 }
805 connection.pragma_update(None, "user_version", 13).unwrap();
806 connection
807 .execute("UPDATE workflows SET harness_id = 'omp' WHERE id = 1", [])
808 .unwrap();
809 let mut store = Store::with_connection(connection).unwrap();
810 let preserved: (String, String, i64) = store
811 .connection
812 .query_row(
813 "SELECT harness_id, lifecycle_status, closed_at FROM workflows WHERE id = 1",
814 [],
815 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
816 )
817 .unwrap();
818 assert_eq!(preserved, ("omp".into(), "cancelled".into(), 456));
819 store
820 .update_workflow(
821 crate::WorkflowId(1),
822 "OpenCode",
823 crate::WorkflowKind::SingleAgent,
824 Some(crate::HarnessId::Opencode),
825 )
826 .unwrap();
827 let harness: String = store
828 .connection
829 .query_row("SELECT harness_id FROM workflows WHERE id = 1", [], |row| {
830 row.get(0)
831 })
832 .unwrap();
833 assert_eq!(harness, "opencode");
834 for (table, id) in [
835 ("agents", 7),
836 ("trace_lanes", 8),
837 ("trace_spans", 9),
838 ("trace_events", 10),
839 ] {
840 let stored: i64 = store
841 .connection
842 .query_row(&format!("SELECT id FROM {table}"), [], |row| row.get(0))
843 .unwrap();
844 assert_eq!(stored, id);
845 }
846 let (id, _) = store
847 .create_workflow(SessionId(1), "New", crate::WorkflowKind::Draft, &[])
848 .unwrap();
849 assert!(id.0 > 99);
850 assert!(
851 store
852 .connection
853 .prepare("PRAGMA foreign_key_check")
854 .unwrap()
855 .query([])
856 .unwrap()
857 .next()
858 .unwrap()
859 .is_none()
860 );
861 }
862
863 #[test]
864 fn migration_from_version_one_preserves_folders_and_adds_session_storage() {
865 let mut connection = Connection::open_in_memory().unwrap();
866 connection.execute_batch(MIGRATIONS[0]).unwrap();
867 connection
868 .execute("INSERT INTO recent_folders VALUES ('/folder', 1, 1)", [])
869 .unwrap();
870 connection.pragma_update(None, "user_version", 1).unwrap();
871 migrate(&mut connection).unwrap();
872 let mut store = Store::with_connection(connection).unwrap();
873 assert_eq!(
874 store.recent_folders().unwrap(),
875 [StoredFolder {
876 path: "/folder".into(),
877 is_open: true
878 }]
879 );
880 let id = store
881 .create_session(Path::new("/folder"), "Session", 123)
882 .unwrap();
883 store
884 .create_workflow(id, "Terminal", crate::WorkflowKind::Terminal, &[])
885 .unwrap();
886 assert_eq!(
887 store.selected_session(Path::new("/folder")).unwrap(),
888 Some(id)
889 );
890 assert_eq!(store.workflows(Path::new("/folder")).unwrap().len(), 1);
891 }
892
893 #[test]
894 fn migration_from_version_two_preserves_sessions_and_adds_workflow_types() {
895 let mut connection = Connection::open_in_memory().unwrap();
896 connection.execute_batch(MIGRATIONS[0]).unwrap();
897 connection.execute_batch(MIGRATIONS[1]).unwrap();
898 connection
899 .execute(
900 "INSERT INTO sessions (folder, name, started_at) VALUES ('/folder', 'Kept', 1)",
901 [],
902 )
903 .unwrap();
904 connection.pragma_update(None, "user_version", 2).unwrap();
905 migrate(&mut connection).unwrap();
906 let mut store = Store::with_connection(connection).unwrap();
907 assert_eq!(
908 store.sessions(Path::new("/folder")).unwrap()[0].name,
909 "Kept"
910 );
911 let type_id = store.create_workflow_type("definition", 1).unwrap();
912 assert_eq!(
913 store
914 .add_workflow_type_version(type_id, "edited", 2)
915 .unwrap(),
916 Some(2)
917 );
918 assert_eq!(
919 store
920 .add_workflow_type_version(type_id + 1, "missing", 3)
921 .unwrap(),
922 None
923 );
924 }
925
926 #[test]
927 fn migration_from_version_two_keeps_workflows_and_stores_a_harness() {
928 let mut connection = Connection::open_in_memory().unwrap();
929 connection
930 .pragma_update(None, "foreign_keys", true)
931 .unwrap();
932 connection.execute_batch(MIGRATIONS[0]).unwrap();
933 connection.execute_batch(MIGRATIONS[1]).unwrap();
934 connection
935 .execute_batch(
936 "INSERT INTO sessions VALUES (1, '/folder', 'Session', 1);
937 INSERT INTO workflows VALUES (1, 1, 'Terminal', 'terminal');
938 INSERT INTO workflows VALUES (2, 1, 'Terminal', 'terminal');
939 INSERT INTO workflows VALUES (3, 1, 'Terminal', 'terminal');
940 DELETE FROM workflows WHERE id IN (2, 3)",
941 )
942 .unwrap();
943 connection.pragma_update(None, "user_version", 2).unwrap();
944 migrate(&mut connection).unwrap();
945 let mut store = Store::with_connection(connection).unwrap();
946
947 let (id, _) = store
949 .create_workflow(
950 SessionId(1),
951 "New workflow",
952 crate::WorkflowKind::Draft,
953 &[],
954 )
955 .unwrap();
956 assert!(
957 id.0 > 3,
958 "deleted workflow IDs must not be reused, got {id:?}"
959 );
960 store
961 .update_workflow(
962 id,
963 "Single agent",
964 crate::WorkflowKind::SingleAgent,
965 Some(crate::HarnessId::ClaudeCode),
966 )
967 .unwrap();
968
969 let workflows = store.workflows(Path::new("/folder")).unwrap();
970 assert_eq!(workflows.len(), 2);
971 assert_eq!(workflows[0].kind, crate::WorkflowKind::Terminal);
972 assert_eq!(workflows[0].harness, None);
973 assert_eq!(workflows[1].kind, crate::WorkflowKind::SingleAgent);
974 assert_eq!(workflows[1].harness, Some(crate::HarnessId::ClaudeCode));
975 }
976
977 #[test]
978 fn migration_to_agents_keeps_workflows_and_never_reuses_their_ids() {
979 let connection = Connection::open_in_memory().unwrap();
980 connection
981 .pragma_update(None, "foreign_keys", true)
982 .unwrap();
983 for migration in &MIGRATIONS[..4] {
984 connection.execute_batch(migration).unwrap();
985 }
986 connection.pragma_update(None, "user_version", 4).unwrap();
987 connection
988 .execute_batch(
989 "INSERT INTO sessions (folder, name, started_at) VALUES ('/folder', 'Session', 1);
990 INSERT INTO workflows (session_id, name, kind)
991 VALUES (1, 'New workflow', 'draft'), (1, 'Terminal', 'terminal'),
992 (1, 'Closed', 'terminal');
993 DELETE FROM workflows WHERE id = 3;",
994 )
995 .unwrap();
996 let mut store = Store::with_connection(connection).unwrap();
997 let folder = Path::new("/folder");
998 assert_eq!(
999 store
1000 .workflows(folder)
1001 .unwrap()
1002 .iter()
1003 .map(|workflow| (
1004 workflow.workflow_id.0,
1005 workflow.name.as_str(),
1006 workflow.kind
1007 ))
1008 .collect::<Vec<_>>(),
1009 [
1010 (1, "New workflow", crate::WorkflowKind::Draft),
1011 (2, "Terminal", crate::WorkflowKind::Terminal)
1012 ]
1013 );
1014
1015 let (workflow_id, agent_ids) = store
1016 .create_workflow(
1017 crate::SessionId(1),
1018 "Agents",
1019 crate::WorkflowKind::Agents,
1020 &["Implementer", "Reviewer"],
1021 )
1022 .unwrap();
1023 assert_eq!(
1024 workflow_id.0, 4,
1025 "a closed workflow's ID must not be reused"
1026 );
1027 let workflows = store.workflows(folder).unwrap();
1028 let agents = &workflows.last().unwrap().agents;
1029 assert_eq!(
1030 agents
1031 .iter()
1032 .map(|agent| (agent.agent_id, agent.role.as_str()))
1033 .collect::<Vec<_>>(),
1034 [(agent_ids[0], "Implementer"), (agent_ids[1], "Reviewer")]
1035 );
1036 assert!(
1037 workflows[..2]
1038 .iter()
1039 .all(|workflow| workflow.agents.is_empty())
1040 );
1041
1042 store
1043 .connection
1044 .execute(
1045 "DELETE FROM workflows WHERE id = ?1",
1046 [sql_integer(workflow_id.0).unwrap()],
1047 )
1048 .unwrap();
1049 let remaining: i64 = store
1050 .connection
1051 .query_row("SELECT COUNT(*) FROM agents", [], |row| row.get(0))
1052 .unwrap();
1053 assert_eq!(remaining, 0, "deleting a workflow deletes its agents");
1054 assert!(
1055 store
1056 .connection
1057 .execute(
1058 "INSERT INTO workflows (session_id, name, kind) VALUES (1, 'Other', 'other')",
1059 [],
1060 )
1061 .is_err()
1062 );
1063 }
1064
1065 #[test]
1066 fn migration_from_version_three_preserves_types_sessions_and_workflows() {
1067 let connection = Connection::open_in_memory().unwrap();
1068 for migration in &MIGRATIONS[..3] {
1069 connection.execute_batch(migration).unwrap();
1070 }
1071 connection.pragma_update(None, "user_version", 3).unwrap();
1072 connection.execute_batch(
1073 "INSERT INTO sessions (id, folder, name, started_at) VALUES (1, '/folder', 'Kept session', 123);
1074 INSERT INTO workflows (id, session_id, name, kind) VALUES (1, 1, 'Kept workflow', 'terminal');
1075 INSERT INTO folder_selection (folder, session_id) VALUES ('/folder', 1);
1076 INSERT INTO workflow_types (id) VALUES (1);
1077 INSERT INTO workflow_type_versions (type_id, version, definition, created_at)
1078 VALUES (1, 1, 'kept definition', 123);"
1079 ).unwrap();
1080 let mut store = Store::with_connection(connection).unwrap();
1081 assert_eq!(
1082 usize::try_from(schema_version(&store.connection)).unwrap(),
1083 MIGRATIONS.len()
1084 );
1085 assert_eq!(
1086 store.sessions(Path::new("/folder")).unwrap()[0].name,
1087 "Kept session"
1088 );
1089 assert_eq!(
1090 store.selected_session(Path::new("/folder")).unwrap(),
1091 Some(crate::SessionId(1))
1092 );
1093 let workflows = store.workflows(Path::new("/folder")).unwrap();
1094 assert_eq!(workflows.len(), 1);
1095 assert_eq!(workflows[0].name, "Kept workflow");
1096 assert_eq!(
1097 store.workflow_type_version(1, 1).unwrap().as_deref(),
1098 Some("kept definition")
1099 );
1100 assert_eq!(
1101 store
1102 .add_workflow_type_version(1, "new definition", 124)
1103 .unwrap(),
1104 Some(2)
1105 );
1106 let summary = store.trace_summary(crate::WorkflowId(1)).unwrap();
1107 assert_eq!(summary.span_count, 0);
1108 let span = store
1109 .start_trace_span(&NewTraceSpan {
1110 workflow_id: crate::WorkflowId(1),
1111 lane_key: "terminal",
1112 lane_name: "Terminal",
1113 is_agent: false,
1114 role: None,
1115 harness: None,
1116 title: "Shell",
1117 started_at: 124,
1118 anchor: None,
1119 })
1120 .unwrap();
1121 assert_eq!(
1122 store
1123 .trace_summary(crate::WorkflowId(1))
1124 .unwrap()
1125 .span_count,
1126 1
1127 );
1128 assert_eq!(store.trace_events(span, None, 10).unwrap().events.len(), 1);
1129 store.close_workflow(crate::WorkflowId(1), 125).unwrap();
1130 assert!(store.workflows(Path::new("/folder")).unwrap().is_empty());
1131 assert_eq!(
1132 store
1133 .workflow_trace(crate::WorkflowId(1), None, 10)
1134 .unwrap()
1135 .spans
1136 .len(),
1137 1
1138 );
1139 }
1140
1141 #[test]
1142 fn migration_from_version_four_keeps_agent_harness_status_and_adds_traces() {
1143 let connection = Connection::open_in_memory().unwrap();
1144 for migration in &MIGRATIONS[..4] {
1145 connection.execute_batch(migration).unwrap();
1146 }
1147 connection.pragma_update(None, "user_version", 4).unwrap();
1148 connection.execute_batch(
1149 "INSERT INTO sessions (id, folder, name, started_at) VALUES (1, '/folder', 'Session', 123);
1150 INSERT INTO workflows (id, session_id, name, kind, harness, agent_status)
1151 VALUES (1, 1, 'Claude Code', 'single_agent', 'claude_code', 'cancelled');"
1152 ).unwrap();
1153 let mut store = Store::with_connection(connection).unwrap();
1154 assert_eq!(
1155 usize::try_from(schema_version(&store.connection)).unwrap(),
1156 MIGRATIONS.len()
1157 );
1158 let workflows = store.workflows(Path::new("/folder")).unwrap();
1159 assert_eq!(workflows.len(), 1);
1160 assert_eq!(workflows[0].kind, crate::WorkflowKind::SingleAgent);
1161 assert_eq!(workflows[0].harness, Some(crate::HarnessId::ClaudeCode));
1162 assert_eq!(
1163 workflows[0].agent_status,
1164 Some(crate::WorkflowStatus::Cancelled)
1165 );
1166 let span = store
1167 .start_trace_span(&NewTraceSpan {
1168 workflow_id: workflows[0].workflow_id,
1169 lane_key: "agent",
1170 lane_name: "Agent",
1171 is_agent: true,
1172 role: Some("agent"),
1173 harness: Some("Claude Code"),
1174 title: "Claude Code",
1175 started_at: 124,
1176 anchor: None,
1177 })
1178 .unwrap();
1179 assert_eq!(store.trace_events(span, None, 10).unwrap().events.len(), 1);
1180 store.close_workflow(workflows[0].workflow_id, 125).unwrap();
1181 assert!(store.workflows(Path::new("/folder")).unwrap().is_empty());
1182 assert_eq!(
1183 store
1184 .workflow_trace(workflows[0].workflow_id, None, 10)
1185 .unwrap()
1186 .spans
1187 .len(),
1188 1
1189 );
1190 }
1191
1192 #[test]
1193 fn migration_from_version_five_preserves_agent_identity_and_role_order() {
1194 let connection = Connection::open_in_memory().unwrap();
1195 for migration in &MIGRATIONS[..5] {
1196 connection.execute_batch(migration).unwrap();
1197 }
1198 connection.pragma_update(None, "user_version", 5).unwrap();
1199 connection.execute_batch(
1200 "INSERT INTO sessions (id, folder, name, started_at) VALUES (1, '/folder', 'Session', 123);
1201 INSERT INTO workflows (id, session_id, name, kind) VALUES (1, 1, 'Agents', 'agents');
1202 INSERT INTO agents (id, workflow_id, role) VALUES (7, 1, 'Reviewer'), (8, 1, 'Implementer');"
1203 ).unwrap();
1204 let store = Store::with_connection(connection).unwrap();
1205 let workflows = store.workflows(Path::new("/folder")).unwrap();
1206 assert_eq!(workflows[0].kind, crate::WorkflowKind::Agents);
1207 assert_eq!(
1208 workflows[0]
1209 .agents
1210 .iter()
1211 .map(|agent| (agent.agent_id.0, agent.role.as_str()))
1212 .collect::<Vec<_>>(),
1213 [(7, "Reviewer"), (8, "Implementer")]
1214 );
1215 assert_eq!(
1216 store
1217 .trace_summary(workflows[0].workflow_id)
1218 .unwrap()
1219 .span_count,
1220 0
1221 );
1222 assert_eq!(
1223 usize::try_from(schema_version(&store.connection)).unwrap(),
1224 MIGRATIONS.len()
1225 );
1226 }
1227
1228 #[test]
1229 fn migration_from_version_six_keeps_trace_ids_and_accepts_workflow_events() {
1230 let connection = Connection::open_in_memory().unwrap();
1231 for migration in &MIGRATIONS[..6] {
1232 connection.execute_batch(migration).unwrap();
1233 }
1234 connection.pragma_update(None, "user_version", 6).unwrap();
1235 connection.execute_batch(
1236 "INSERT INTO sessions (id, folder, name, started_at) VALUES (1, '/folder', 'Work', 123);
1237 INSERT INTO workflows (id, session_id, name, kind) VALUES (1, 1, 'Shell', 'terminal');
1238 INSERT INTO trace_events (id, workflow_id, timestamp, kind, message)
1239 VALUES (80, 1, 123, 'processStarted', 'Earlier event');
1240 DELETE FROM trace_events WHERE id = 80;"
1241 ).unwrap();
1242 let store = Store::with_connection(connection).unwrap();
1243 store
1244 .connection
1245 .execute(
1246 "INSERT INTO trace_events (workflow_id, timestamp, kind, message)
1247 VALUES (1, 124, 'workflowEvent', 'Stage started')",
1248 [],
1249 )
1250 .unwrap();
1251 assert_eq!(store.connection.last_insert_rowid(), 81);
1252 assert_eq!(store.workflows(Path::new("/folder")).unwrap().len(), 1);
1253 }
1254
1255 #[test]
1256 fn migration_preserves_idle_shell_and_agent_terminal_ownership() {
1257 let connection = Connection::open_in_memory().unwrap();
1258 for migration in &MIGRATIONS[..11] {
1259 connection.execute_batch(migration).unwrap();
1260 }
1261 connection.pragma_update(None, "user_version", 11).unwrap();
1262 connection.execute_batch(
1263 "INSERT INTO sessions (id, folder, name, started_at) VALUES (1, '/folder', 'Work', 1);
1264 INSERT INTO workflows (id, session_id, name, kind) VALUES
1265 (1, 1, 'Shell', 'terminal'), (2, 1, 'Agent', 'single_agent'), (3, 1, 'Run', 'agents');
1266 INSERT INTO trace_lanes (id, workflow_id, lane_key, name, is_agent) VALUES
1267 (1, 2, 'terminal', 'Terminal', 0), (2, 2, 'agent', 'Agent', 1), (3, 3, 'agent:8', 'Worker', 1);
1268 INSERT INTO trace_spans (lane_id, title, started_at, status, terminal_id) VALUES
1269 (1, 'Shell', 1, 'stopped', 20), (2, 'Agent', 2, 'exited', 21), (3, 'Worker', 2, 'exited', 31);
1270 INSERT INTO trace_events (workflow_id, timestamp, kind, message, terminal_id, byte_offset)
1271 VALUES (1, 1, 'processStarted', 'Idle shell', 10, 0);"
1272 ).unwrap();
1273 let store = Store::with_connection(connection).unwrap();
1274 let history = |id| store.terminal_history(crate::WorkflowId(id)).unwrap();
1275 assert_eq!(history(1)[0].terminal_id.value(), 10);
1276 assert_eq!(history(2).len(), 1);
1277 assert_eq!(history(2)[0].terminal_id.value(), 21);
1278 assert_eq!(history(3)[0].agent_id, Some(crate::AgentId(8)));
1279 }
1280
1281 #[test]
1282 fn migration_from_version_nine_preserves_process_spans_and_terminal_anchors() {
1283 let connection = Connection::open_in_memory().unwrap();
1284 for migration in &MIGRATIONS[..9] {
1285 connection.execute_batch(migration).unwrap();
1286 }
1287 connection.pragma_update(None, "user_version", 9).unwrap();
1288 connection.execute_batch(
1289 "INSERT INTO sessions (id, folder, name, started_at) VALUES (1, '/folder', 'Work', 100);
1290 INSERT INTO workflows (id, session_id, name, kind) VALUES (1, 1, 'Adversarial', 'agents');
1291 INSERT INTO trace_lanes (id, workflow_id, lane_key, name, is_agent, role, harness)
1292 VALUES (1, 1, 'agent:1', 'Implementer', 1, 'implementer', 'pi');
1293 INSERT INTO trace_spans (id, lane_id, title, started_at, ended_at, status, terminal_id, run_generation)
1294 VALUES (1, 1, 'Implement', 100, 200, 'exited', 99, 1);
1295 INSERT INTO trace_events (id, workflow_id, span_id, timestamp, kind, message, terminal_id, byte_offset, boundary_sizes)
1296 VALUES (1, 1, 1, 100, 'processStarted', 'Process started.', 99, 0, X''),
1297 (2, 1, 1, 200, 'processExited', 'Process exited with code 0.', 99, 42, X'');"
1298 ).unwrap();
1299 let store = Store::with_connection(connection).unwrap();
1300 let page = store
1301 .workflow_trace(crate::WorkflowId(1), None, 10)
1302 .unwrap();
1303 let span = &page.spans[0];
1304 assert_eq!(span.title, "Implement");
1305 assert_eq!((span.started_at, span.ended_at), (100, Some(200)));
1306 assert_eq!(span.status, crate::TraceSpanStatus::Exited);
1307 let events = store.trace_events(span.span_id, None, 10).unwrap().events;
1308 assert_eq!(events.len(), 2);
1309 assert_eq!(events[1].kind, crate::TraceEventKind::ProcessExited);
1310 assert_eq!(
1311 events[1].anchor,
1312 Some(crate::TraceAnchor {
1313 terminal_id: crate::TerminalId::from_value(99),
1314 byte_offset: 42,
1315 boundary_sizes: Some(vec![]),
1316 })
1317 );
1318 }
1319
1320 #[test]
1321 fn a_newer_schema_is_rejected() {
1322 let directory = tempfile::tempdir().expect("a temporary directory should be available");
1323 let path = directory.path().join("twine.db");
1324 let connection = Connection::open(&path).expect("the database should open");
1325 connection
1326 .pragma_update(None, "user_version", 99)
1327 .expect("the schema version should be writable");
1328 drop(connection);
1329
1330 assert!(matches!(
1331 Store::open(&path),
1332 Err(StoreError::UnsupportedSchemaVersion { version: 99 })
1333 ));
1334 }
1335
1336 #[test]
1337 fn recent_folders_are_ordered_capped_and_track_the_open_folder() {
1338 let mut store = Store::open_in_memory().expect("the store should open");
1339 store
1340 .record_folder_opened("/a", 5_000, 2)
1341 .expect("a should be recorded");
1342 store
1344 .record_folder_opened("/b", 1_000, 2)
1345 .expect("b should be recorded");
1346 store
1347 .record_folder_opened("/c", 1_000, 2)
1348 .expect("c should be recorded");
1349
1350 assert_eq!(
1351 store.recent_folders().expect("folders should load"),
1352 [
1353 StoredFolder {
1354 path: PathBuf::from("/c"),
1355 is_open: true,
1356 },
1357 StoredFolder {
1358 path: PathBuf::from("/b"),
1359 is_open: false,
1360 },
1361 ]
1362 );
1363
1364 store
1365 .record_folder_opened("/b", 1_000, 2)
1366 .expect("b should be recorded again");
1367 store
1368 .record_folder_closed()
1369 .expect("closing should be recorded");
1370 let remaining = store
1371 .remove_recent_folder("/c")
1372 .expect("c should be removed");
1373 assert_eq!(
1374 remaining,
1375 [StoredFolder {
1376 path: PathBuf::from("/b"),
1377 is_open: false,
1378 }]
1379 );
1380 assert_eq!(
1381 store.recent_folders().expect("folders should load"),
1382 remaining
1383 );
1384 }
1385}