KeiSeiKit-1.0/_primitives/_rust/kei-ledger/src/schema.rs
Parfii-bot 55606b176f feat(tx3): kei-ledger v4 migration — creator_id + fork_parent_id + descendants
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>
2026-04-23 10:21:45 +08:00

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",
];