Schema v4 adds creator_id TEXT + fork_parent_id TEXT columns with indexes. Migration one-txn matches v2/v3 pattern. fork() gains --creator + --fork-parent flags. New Descendants subcommand walks fork_parent_id OR creator_id chain. Extracted row.rs (AgentRow + SELECT_COLS) + descendants.rs + dispatch.rs to stay ≤200 LOC. Tests: 18/18 (was 13, +5: creator roundtrip, fork-parent lineage, descendants chain, pre-v4 NULL backward-compat, v4 idempotent). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
106 lines
4.3 KiB
Rust
106 lines
4.3 KiB
Rust
//! SQL schema for the agent ledger.
|
|
//!
|
|
//! Constructor Pattern: one cube = schema DDL + migration runner.
|
|
//! Single source of truth for table shape. Any structural change MUST
|
|
//! bump the migration list below; existing rows are preserved.
|
|
|
|
use rusqlite::{Connection, Result};
|
|
|
|
/// Maximum length (chars) accepted for `branch` and `parent_branch` columns.
|
|
/// Enforced by SQL CHECK (v3 migration) and CLI `value_parser` length cap.
|
|
pub const MAX_BRANCH_LEN: usize = 256;
|
|
|
|
/// Ordered migrations. Index = schema version. Never reorder; append only.
|
|
pub const MIGRATIONS: &[&str] = &[
|
|
// v1 — initial schema (RULE 0.12, 2026-04-21)
|
|
"CREATE TABLE IF NOT EXISTS agents (
|
|
id TEXT PRIMARY KEY,
|
|
branch TEXT NOT NULL,
|
|
parent_branch TEXT,
|
|
spec_sha TEXT NOT NULL,
|
|
status TEXT NOT NULL CHECK (status IN ('running','done','failed','merged','rejected')),
|
|
started_ts INTEGER NOT NULL,
|
|
finished_ts INTEGER,
|
|
summary TEXT,
|
|
worktree_path TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_parent ON agents(parent_branch);
|
|
CREATE INDEX IF NOT EXISTS idx_status ON agents(status);",
|
|
// v2 — Layer G DNA identity column + prefix index (2026-04-23)
|
|
"ALTER TABLE agents ADD COLUMN dna TEXT;
|
|
CREATE INDEX IF NOT EXISTS idx_agents_dna_prefix ON agents(substr(dna, 1, 30));",
|
|
// v3 — length caps on branch/parent_branch (audit L1, 2026-04-23)
|
|
// Enforced via trigger rather than table CHECK because CHECK cannot be
|
|
// added retroactively to an existing table without rebuilding it. The
|
|
// triggers refuse inserts / updates with over-long values.
|
|
"CREATE TRIGGER IF NOT EXISTS trg_agents_branch_len_ins
|
|
BEFORE INSERT ON agents
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'branch length exceeds 256')
|
|
WHERE length(NEW.branch) > 256;
|
|
SELECT RAISE(ABORT, 'parent_branch length exceeds 256')
|
|
WHERE NEW.parent_branch IS NOT NULL AND length(NEW.parent_branch) > 256;
|
|
END;
|
|
CREATE TRIGGER IF NOT EXISTS trg_agents_branch_len_upd
|
|
BEFORE UPDATE OF branch, parent_branch ON agents
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'branch length exceeds 256')
|
|
WHERE length(NEW.branch) > 256;
|
|
SELECT RAISE(ABORT, 'parent_branch length exceeds 256')
|
|
WHERE NEW.parent_branch IS NOT NULL AND length(NEW.parent_branch) > 256;
|
|
END;",
|
|
// v4 — creator_id + fork_parent_id lineage columns (RULE 0.12 extension,
|
|
// 2026-04-23). Both nullable for backward-compat with pre-v4 rows.
|
|
// creator_id: DNA or human id of agent that spawned this fork.
|
|
// fork_parent_id: DNA of forked-from agent if forked; NULL otherwise.
|
|
"ALTER TABLE agents ADD COLUMN creator_id TEXT;
|
|
ALTER TABLE agents ADD COLUMN fork_parent_id TEXT;
|
|
CREATE INDEX IF NOT EXISTS idx_agents_creator ON agents(creator_id);
|
|
CREATE INDEX IF NOT EXISTS idx_agents_fork_parent ON agents(fork_parent_id);",
|
|
];
|
|
|
|
/// Apply all pending migrations atomically (one transaction per version).
|
|
///
|
|
/// Prior design ran `execute_batch` and bumped `user_version` in a separate
|
|
/// call — a partial failure left the schema half-applied and wedged restarts.
|
|
/// Now each version's DDL + the `user_version` bump share a transaction, so
|
|
/// any error rolls everything back and the next startup retries cleanly.
|
|
pub fn migrate(conn: &Connection) -> Result<()> {
|
|
let current: i64 = conn
|
|
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
|
.unwrap_or(0);
|
|
for (i, sql) in MIGRATIONS.iter().enumerate() {
|
|
let target = (i + 1) as i64;
|
|
if current < target {
|
|
apply_one(conn, sql, target)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Apply a single migration atomically: DDL + user_version bump in one txn.
|
|
fn apply_one(conn: &Connection, sql: &str, target: i64) -> Result<()> {
|
|
conn.execute_batch("BEGIN IMMEDIATE")?;
|
|
let step = (|| -> Result<()> {
|
|
conn.execute_batch(sql)?;
|
|
conn.pragma_update(None, "user_version", target)?;
|
|
Ok(())
|
|
})();
|
|
match step {
|
|
Ok(()) => conn.execute_batch("COMMIT"),
|
|
Err(e) => {
|
|
let _ = conn.execute_batch("ROLLBACK");
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Six required artefacts per agent (RULE 0.12 §completion bundle).
|
|
pub const REQUIRED_ARTEFACTS: &[&str] = &[
|
|
"spec.md",
|
|
"plan.md",
|
|
"progress.json",
|
|
"chatlog.md",
|
|
"handoffs.md",
|
|
"review.md",
|
|
];
|