Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
-- Durable persistence for the verified mutation-cursor protocol
-- (`cli/src/services/mutation_trace/`).
--
-- This migration is additive: it introduces five new tables and leaves every
-- table from 001/002 untouched. `revision` is stored as an 8-byte
-- big-endian BLOB on every column that carries one (never a SQLite INTEGER),
-- enforced by `CHECK (typeof(revision) = 'blob' AND length(revision) = 8)`,
-- so a TEXT value of matching length is still rejected. Enum-shaped columns
-- use TEXT with an explicit CHECK allow-list, matching the `role`/
-- `payload_type` convention already used in 001. `AttemptState` (transient)
-- and `external_taint` (not DB-authoritative) are deliberately not
-- represented by any table here.

CREATE TABLE IF NOT EXISTS mutation_trace_worktrees (
worktree_id TEXT PRIMARY KEY,
cursor_tree TEXT NOT NULL,
revision BLOB NOT NULL
CHECK (typeof(revision) = 'blob' AND length(revision) = 8),
tainted INTEGER NOT NULL CHECK (tainted IN (0, 1)),
failure_kind TEXT NOT NULL CHECK (failure_kind IN ('healthy', 'snapshot_failure')),
needs_rebaseline INTEGER NOT NULL CHECK (needs_rebaseline IN (0, 1)),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);

CREATE TABLE IF NOT EXISTS mutation_trace_scopes (
scope_id TEXT PRIMARY KEY,
worktree_id TEXT NOT NULL,
actor_kind TEXT NOT NULL CHECK (actor_kind IN ('claude_code', 'codex', 'opencode', 'pi')),
status TEXT NOT NULL CHECK (status IN ('never_seen', 'active', 'closed', 'abandoned')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);

CREATE INDEX IF NOT EXISTS idx_mutation_trace_scopes_worktree
ON mutation_trace_scopes (worktree_id);

CREATE INDEX IF NOT EXISTS idx_mutation_trace_scopes_worktree_status
ON mutation_trace_scopes (worktree_id, status);

-- `worktree_id` is deliberately not duplicated here: a processed event's
-- identity is exactly `(scope_id, event_id)` (the domain `EventKey`), and
-- `scope_id`'s worktree is already a permanent fact owned by
-- `mutation_trace_scopes` (`ScopeId -> WorktreeId`, never reassigned). A
-- second `worktree_id` column would create two sources of truth for the same
-- fact and could disagree with `mutation_trace_scopes` for the same
-- `scope_id`; the schema does not represent that inconsistency.
CREATE TABLE IF NOT EXISTS mutation_trace_processed_events (
scope_id TEXT NOT NULL,
event_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
PRIMARY KEY (scope_id, event_id)
);

CREATE TABLE IF NOT EXISTS mutation_trace_events (
worktree_id TEXT NOT NULL,
revision BLOB NOT NULL
CHECK (typeof(revision) = 'blob' AND length(revision) = 8),
before_tree TEXT NOT NULL,
after_tree TEXT NOT NULL,
tainted INTEGER NOT NULL CHECK (tainted IN (0, 1)),
failure_kind TEXT NOT NULL CHECK (failure_kind IN ('healthy', 'snapshot_failure')),
attribution_kind TEXT NOT NULL
CHECK (attribution_kind IN ('ineligible_unscoped', 'ai_exclusive', 'ai_contended')),
attribution_scope_id TEXT,
boundary_kind TEXT NOT NULL CHECK (boundary_kind IN ('start', 'advance', 'close', 'flush')),
boundary_scope_id TEXT,
boundary_event_id TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
PRIMARY KEY (worktree_id, revision),
CHECK (
(attribution_kind = 'ai_exclusive' AND attribution_scope_id IS NOT NULL)
OR (attribution_kind != 'ai_exclusive' AND attribution_scope_id IS NULL)
),
CHECK (
(boundary_kind IN ('start', 'advance', 'close')
AND boundary_scope_id IS NOT NULL AND boundary_event_id IS NOT NULL)
OR (boundary_kind = 'flush'
AND boundary_scope_id IS NULL AND boundary_event_id IS NULL)
)
);

CREATE TABLE IF NOT EXISTS mutation_trace_event_active_scopes (
worktree_id TEXT NOT NULL,
revision BLOB NOT NULL
CHECK (typeof(revision) = 'blob' AND length(revision) = 8),
scope_id TEXT NOT NULL,
PRIMARY KEY (worktree_id, revision, scope_id)
);
124 changes: 123 additions & 1 deletion cli/src/services/agent_trace_db/repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,11 @@ mod tests {
"agent_traces",
"messages",
"parts",
"mutation_trace_worktrees",
"mutation_trace_scopes",
"mutation_trace_processed_events",
"mutation_trace_events",
"mutation_trace_event_active_scopes",
] {
assert!(
sqlite_object_exists(&db, "table", table),
Expand All @@ -401,6 +406,8 @@ mod tests {
"idx_messages_session_message",
"idx_messages_session_order",
"idx_parts_session_message_order",
"idx_mutation_trace_scopes_worktree",
"idx_mutation_trace_scopes_worktree_status",
] {
assert!(
sqlite_object_exists(&db, "index", index),
Expand All @@ -426,9 +433,10 @@ mod tests {
vec![
String::from("001_repository_schema"),
String::from("002_repository_source_instance_id"),
String::from("003_mutation_trace_protocol"),
],
"repository DBs should be initialized from the baseline schema plus \
its additive source-instance-id migration"
its additive source-instance-id and mutation-trace-protocol migrations"
);

db.ensure_schema_ready_for_hooks()
Expand All @@ -437,6 +445,120 @@ mod tests {
remove_test_db(&db_path);
}

#[test]
fn mutation_trace_worktrees_revision_must_be_a_blob_not_matching_length_text() {
let db_path = unique_test_db_path("mutation-trace-revision-blob");
let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open");

let text_revision_error = db
.execute(
"INSERT INTO mutation_trace_worktrees
(worktree_id, cursor_tree, revision, tainted, failure_kind, needs_rebaseline)
VALUES ('wt-1', 'tree-0', '12345678', 0, 'healthy', 0)",
(),
)
.expect_err(
"an 8-byte TEXT value must still be rejected by the typeof(revision) = 'blob' check",
);
assert!(
text_revision_error.to_string().contains("CHECK"),
"unexpected error: {text_revision_error}"
);

db.execute(
"INSERT INTO mutation_trace_worktrees
(worktree_id, cursor_tree, revision, tainted, failure_kind, needs_rebaseline)
VALUES ('wt-1', 'tree-0', X'0000000000000000', 0, 'healthy', 0)",
(),
)
.expect("an 8-byte BLOB revision should be accepted");

remove_test_db(&db_path);
}

#[test]
fn mutation_trace_events_ai_exclusive_attribution_requires_a_scope_id() {
let db_path = unique_test_db_path("mutation-trace-attribution-check");
let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open");

let missing_scope_error = db
.execute(
"INSERT INTO mutation_trace_events
(worktree_id, revision, before_tree, after_tree, tainted, failure_kind,
attribution_kind, attribution_scope_id, boundary_kind, boundary_scope_id, boundary_event_id)
VALUES ('wt-1', X'0000000000000001', 'tree-0', 'tree-1', 0, 'healthy',
'ai_exclusive', NULL, 'flush', NULL, NULL)",
(),
)
.expect_err("ai_exclusive attribution with a NULL attribution_scope_id must be rejected");
assert!(
missing_scope_error.to_string().contains("CHECK"),
"unexpected error: {missing_scope_error}"
);

db.execute(
"INSERT INTO mutation_trace_events
(worktree_id, revision, before_tree, after_tree, tainted, failure_kind,
attribution_kind, attribution_scope_id, boundary_kind, boundary_scope_id, boundary_event_id)
VALUES ('wt-1', X'0000000000000001', 'tree-0', 'tree-1', 0, 'healthy',
'ai_exclusive', 'scope-1', 'start', 'scope-1', 'event-1')",
(),
)
.expect("ai_exclusive attribution with a scope ID should be accepted");

remove_test_db(&db_path);
}

#[test]
fn mutation_trace_processed_events_identity_is_scope_and_event_only() {
let db_path = unique_test_db_path("mutation-trace-processed-events-identity");
let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open");

let sql = table_sql(&db, "mutation_trace_processed_events");
assert!(
!sql.contains("worktree_id"),
"mutation_trace_processed_events must not have a worktree_id column: {sql}"
);

db.execute(
"INSERT INTO mutation_trace_processed_events (scope_id, event_id)
VALUES ('scope-1', 'event-1')",
(),
)
.expect("first (scope_id, event_id) insert should succeed");

let duplicate_error = db
.execute(
"INSERT INTO mutation_trace_processed_events (scope_id, event_id)
VALUES ('scope-1', 'event-1')",
(),
)
.expect_err("a duplicate (scope_id, event_id) pair must be rejected");
assert!(
duplicate_error.to_string().contains("UNIQUE")
|| duplicate_error.to_string().contains("PRIMARY KEY"),
"unexpected error: {duplicate_error}"
);

db.execute(
"INSERT INTO mutation_trace_processed_events (scope_id, event_id)
VALUES ('scope-2', 'event-1')",
(),
)
.expect("the same event_id under a different scope_id should be allowed");

db.execute(
"INSERT INTO mutation_trace_processed_events (scope_id, event_id)
VALUES ('scope-1', 'event-2')",
(),
)
.expect("the same scope_id with a different event_id should be allowed");

assert_eq!(row_count(&db, "mutation_trace_processed_events"), 3);

remove_test_db(&db_path);
}

#[test]
fn trace_tables_have_no_checkout_id_columns() {
let db_path = unique_test_db_path("no-checkout-id");
Expand Down
1 change: 1 addition & 0 deletions cli/src/services/mutation_trace/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@
//! unchanged).

pub mod protocol;
pub mod store;
pub mod types;

#[cfg(test)]
Expand Down
Loading
Loading