From 55606b176f2b432a6bdb83e9640f0e2e5ad959c7 Mon Sep 17 00:00:00 2001 From: Parfii-bot Date: Thu, 23 Apr 2026 10:21:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(tx3):=20kei-ledger=20v4=20migration=20?= =?UTF-8?q?=E2=80=94=20creator=5Fid=20+=20fork=5Fparent=5Fid=20+=20descend?= =?UTF-8?q?ants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../_rust/kei-ledger/src/descendants.rs | 24 +++ _primitives/_rust/kei-ledger/src/dispatch.rs | 89 +++++++++++ _primitives/_rust/kei-ledger/src/ledger.rs | 49 ++---- _primitives/_rust/kei-ledger/src/main.rs | 111 +++++--------- _primitives/_rust/kei-ledger/src/row.rs | 49 ++++++ _primitives/_rust/kei-ledger/src/schema.rs | 8 + .../_rust/kei-ledger/tests/integration.rs | 142 +++++++++++++++--- 7 files changed, 343 insertions(+), 129 deletions(-) create mode 100644 _primitives/_rust/kei-ledger/src/descendants.rs create mode 100644 _primitives/_rust/kei-ledger/src/dispatch.rs create mode 100644 _primitives/_rust/kei-ledger/src/row.rs diff --git a/_primitives/_rust/kei-ledger/src/descendants.rs b/_primitives/_rust/kei-ledger/src/descendants.rs new file mode 100644 index 0000000..22b04b5 --- /dev/null +++ b/_primitives/_rust/kei-ledger/src/descendants.rs @@ -0,0 +1,24 @@ +//! `descendants()` — lineage walker over `fork_parent_id` + `creator_id`. +//! +//! Constructor Pattern: one cube = one query. Single public fn under 30 LOC. +//! RULE 0.12 v4 lineage lookup: find every agent that was forked-from OR +//! spawned-by a given DNA. + +use crate::row::{row_to_agent, AgentRow, SELECT_COLS}; +use rusqlite::{params, Connection, Result as SqlResult}; + +/// Return every row whose `fork_parent_id == dna` OR `creator_id == dna`. +/// Ordered oldest-first so callers can reconstruct a timeline. Callers that +/// want recursive transitive closure should loop on returned ids. +pub fn descendants(conn: &Connection, dna: &str) -> SqlResult> { + let sql = format!( + "SELECT {SELECT_COLS} FROM agents + WHERE fork_parent_id = ?1 OR creator_id = ?1 + ORDER BY started_ts ASC" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt + .query_map(params![dna], row_to_agent)? + .collect::>>()?; + Ok(rows) +} diff --git a/_primitives/_rust/kei-ledger/src/dispatch.rs b/_primitives/_rust/kei-ledger/src/dispatch.rs new file mode 100644 index 0000000..d0bc662 --- /dev/null +++ b/_primitives/_rust/kei-ledger/src/dispatch.rs @@ -0,0 +1,89 @@ +//! CLI dispatch helpers — one function per subcommand. +//! +//! Constructor Pattern: extracted from `main.rs` to keep the entry point +//! under the 200-LOC cap. Each fn returns `ExitCode` directly so `main` +//! stays a flat match. + +use crate::{descendants, ledger}; +use rusqlite::Connection; +use std::path::Path; +use std::process::ExitCode; + +pub fn err(msg: &str) -> ExitCode { + eprintln!("kei-ledger: {msg}"); + ExitCode::from(1) +} + +pub fn cmd_list(conn: &Connection, status: Option<&str>) -> ExitCode { + match ledger::list(conn, status) { + Ok(rows) => { + if rows.is_empty() { + println!("(no agents)"); + } + for r in &rows { + println!( + "{}\t{}\t{}\t{}\tparent={}\tspec={}", + r.id, + r.status, + r.branch, + r.started_ts, + r.parent_branch.as_deref().unwrap_or("-"), + &r.spec_sha[..r.spec_sha.len().min(12)] + ); + } + ExitCode::SUCCESS + } + Err(e) => err(&format!("list failed: {e}")), + } +} + +pub fn cmd_tree(conn: &Connection, id: &str) -> ExitCode { + match ledger::tree(conn, id) { + Ok(rows) if rows.is_empty() => err(&format!("no agent with id {id}")), + Ok(rows) => { + for r in &rows { + let indent = if r.id == id { "" } else { " " }; + println!("{}{} [{}] branch={}", indent, r.id, r.status, r.branch); + } + ExitCode::SUCCESS + } + Err(e) => err(&format!("tree failed: {e}")), + } +} + +pub fn cmd_validate(branch: &str, repo_root: &Path) -> ExitCode { + // branch naming convention: agent/- OR inline- + // ledger artefact dir uses the raw agent id, which the caller passes as branch. + let agent_id = branch.strip_prefix("agent/").unwrap_or(branch); + let missing = ledger::validate(repo_root, agent_id); + if missing.is_empty() { + println!("OK: all 6 artefacts present for {agent_id}"); + ExitCode::SUCCESS + } else { + eprintln!("MISSING for {agent_id}:"); + for m in &missing { + eprintln!(" - {m}"); + } + ExitCode::from(2) + } +} + +pub fn cmd_descendants(conn: &Connection, dna: &str) -> ExitCode { + match descendants::descendants(conn, dna) { + Ok(rows) => { + if rows.is_empty() { + println!("(no descendants for {dna})"); + } + for r in &rows { + let relation = if r.fork_parent_id.as_deref() == Some(dna) { + "fork" + } else { + "spawn" + }; + println!("{}\t{}\t{}\t{}", r.id, relation, r.status, r.branch); + } + ExitCode::SUCCESS + } + Err(e) => err(&format!("descendants failed: {e}")), + } +} diff --git a/_primitives/_rust/kei-ledger/src/ledger.rs b/_primitives/_rust/kei-ledger/src/ledger.rs index 00ce1bb..0937df9 100644 --- a/_primitives/_rust/kei-ledger/src/ledger.rs +++ b/_primitives/_rust/kei-ledger/src/ledger.rs @@ -2,29 +2,15 @@ //! Constructor Pattern: each public fn <30 LOC. rusqlite-backed, one file per caller. use crate::error::MAX_TREE_DEPTH; -use crate::schema::{migrate, MAX_BRANCH_LEN, REQUIRED_ARTEFACTS}; pub use crate::error::LedgerError; +pub use crate::row::AgentRow; +use crate::row::{row_to_agent, SELECT_COLS}; +use crate::schema::{migrate, MAX_BRANCH_LEN, REQUIRED_ARTEFACTS}; use chrono::Utc; use rusqlite::{params, Connection, OptionalExtension, Result as SqlResult}; -use serde::Serialize; use std::collections::HashSet; use std::path::{Path, PathBuf}; -#[derive(Debug, Serialize, Clone)] -pub struct AgentRow { - pub id: String, - pub branch: String, - pub parent_branch: Option, - pub spec_sha: String, - pub status: String, - pub started_ts: i64, - pub finished_ts: Option, - pub summary: Option, - pub worktree_path: Option, - /// Layer G composition fingerprint; `None` for pre-v2 rows. - pub dna: Option, -} - /// Open or create the ledger file and run migrations. pub fn open(path: &Path) -> SqlResult { if let Some(parent) = path.parent() { @@ -49,6 +35,7 @@ fn check_branch_lens(branch: &str, parent: Option<&str>) -> Result<(), LedgerErr } /// Insert running-agent row. Errors on duplicate id or branch > MAX_BRANCH_LEN. +#[allow(clippy::too_many_arguments)] pub fn fork( conn: &Connection, id: &str, @@ -57,14 +44,17 @@ pub fn fork( spec_sha: &str, worktree: Option<&str>, dna: Option<&str>, + creator_id: Option<&str>, + fork_parent_id: Option<&str>, ) -> Result<(), LedgerError> { check_branch_lens(branch, parent)?; let now = Utc::now().timestamp(); conn.execute( "INSERT INTO agents - (id, branch, parent_branch, spec_sha, status, started_ts, worktree_path, dna) - VALUES (?1, ?2, ?3, ?4, 'running', ?5, ?6, ?7)", - params![id, branch, parent, spec_sha, now, worktree, dna], + (id, branch, parent_branch, spec_sha, status, started_ts, + worktree_path, dna, creator_id, fork_parent_id) + VALUES (?1, ?2, ?3, ?4, 'running', ?5, ?6, ?7, ?8, ?9)", + params![id, branch, parent, spec_sha, now, worktree, dna, creator_id, fork_parent_id], )?; Ok(()) } @@ -99,10 +89,6 @@ pub fn merged(conn: &Connection, id: &str) -> SqlResult { ) } -/// Column list shared by all SELECTs that hydrate an `AgentRow`. -const SELECT_COLS: &str = - "id, branch, parent_branch, spec_sha, status, started_ts, finished_ts, summary, worktree_path, dna"; - /// List all agents, optionally filtered by status. pub fn list(conn: &Connection, status: Option<&str>) -> SqlResult> { let (sql, bound): (String, Vec) = match status { @@ -122,21 +108,6 @@ pub fn list(conn: &Connection, status: Option<&str>) -> SqlResult> Ok(rows) } -fn row_to_agent(r: &rusqlite::Row) -> SqlResult { - Ok(AgentRow { - id: r.get(0)?, - branch: r.get(1)?, - parent_branch: r.get(2)?, - spec_sha: r.get(3)?, - status: r.get(4)?, - started_ts: r.get(5)?, - finished_ts: r.get(6)?, - summary: r.get(7)?, - worktree_path: r.get(8)?, - dna: r.get(9)?, - }) -} - fn by_id(conn: &Connection, id: &str) -> SqlResult> { let sql = format!("SELECT {SELECT_COLS} FROM agents WHERE id = ?1"); conn.query_row(&sql, params![id], row_to_agent).optional() diff --git a/_primitives/_rust/kei-ledger/src/main.rs b/_primitives/_rust/kei-ledger/src/main.rs index d5cc9cb..0ca7d53 100644 --- a/_primitives/_rust/kei-ledger/src/main.rs +++ b/_primitives/_rust/kei-ledger/src/main.rs @@ -3,11 +3,15 @@ //! Single responsibility: parse args, dispatch to ledger ops, format output. //! Storage: `~/.claude/agents/ledger.sqlite` (or $KEI_LEDGER_DB override). +mod descendants; +mod dispatch; mod error; mod ledger; +mod row; mod schema; use clap::{Parser, Subcommand}; +use dispatch::{cmd_descendants, cmd_list, cmd_tree, cmd_validate, err}; use std::path::PathBuf; use std::process::ExitCode; @@ -41,6 +45,12 @@ enum Cmd { /// Layer G DNA fingerprint (optional; kept blank for legacy callers). #[arg(long)] dna: Option, + /// DNA / human id of the agent that spawned this fork (v4 lineage). + #[arg(long)] + creator: Option, + /// DNA of the forked-from agent, if this is itself a fork (v4 lineage). + #[arg(long = "fork-parent")] + fork_parent: Option, }, /// Mark a running agent as done. Done { @@ -69,6 +79,8 @@ enum Cmd { #[arg(long, default_value = ".")] repo_root: PathBuf, }, + /// List agents whose fork_parent_id OR creator_id equals the given DNA. + Descendants { dna: String }, } /// clap value_parser caps branch/parent length at MAX_BRANCH_LEN (audit L1). @@ -90,65 +102,37 @@ fn db_path(cli_db: Option) -> PathBuf { PathBuf::from(home).join(".claude/agents/ledger.sqlite") } -fn cmd_list(conn: &rusqlite::Connection, status: Option<&str>) -> ExitCode { - match ledger::list(conn, status) { - Ok(rows) => { - if rows.is_empty() { - println!("(no agents)"); - } - for r in &rows { - println!( - "{}\t{}\t{}\t{}\tparent={}\tspec={}", - r.id, - r.status, - r.branch, - r.started_ts, - r.parent_branch.as_deref().unwrap_or("-"), - &r.spec_sha[..r.spec_sha.len().min(12)] - ); - } +#[allow(clippy::too_many_arguments)] +fn run_fork( + conn: &rusqlite::Connection, + id: String, + branch: String, + parent: Option, + spec_sha: String, + worktree: Option, + dna: Option, + creator: Option, + fork_parent: Option, +) -> ExitCode { + match ledger::fork( + conn, + &id, + &branch, + parent.as_deref(), + &spec_sha, + worktree.as_deref(), + dna.as_deref(), + creator.as_deref(), + fork_parent.as_deref(), + ) { + Ok(()) => { + println!("forked {id} -> {branch}"); ExitCode::SUCCESS } - Err(e) => err(&format!("list failed: {e}")), + Err(e) => err(&format!("fork failed: {e}")), } } -fn cmd_tree(conn: &rusqlite::Connection, id: &str) -> ExitCode { - match ledger::tree(conn, id) { - Ok(rows) if rows.is_empty() => err(&format!("no agent with id {id}")), - Ok(rows) => { - for r in &rows { - let indent = if r.id == id { "" } else { " " }; - println!("{}{} [{}] branch={}", indent, r.id, r.status, r.branch); - } - ExitCode::SUCCESS - } - Err(e) => err(&format!("tree failed: {e}")), - } -} - -fn cmd_validate(branch: &str, repo_root: &std::path::Path) -> ExitCode { - // branch naming convention: agent/- OR inline- - // ledger artefact dir uses the raw agent id, which the caller passes as branch. - let agent_id = branch.strip_prefix("agent/").unwrap_or(branch); - let missing = ledger::validate(repo_root, agent_id); - if missing.is_empty() { - println!("OK: all 6 artefacts present for {agent_id}"); - ExitCode::SUCCESS - } else { - eprintln!("MISSING for {agent_id}:"); - for m in &missing { - eprintln!(" - {m}"); - } - ExitCode::from(2) - } -} - -fn err(msg: &str) -> ExitCode { - eprintln!("kei-ledger: {msg}"); - ExitCode::from(1) -} - fn main() -> ExitCode { let cli = Cli::parse(); let path = db_path(cli.db); @@ -161,22 +145,8 @@ fn main() -> ExitCode { println!("initialised {}", path.display()); ExitCode::SUCCESS } - Cmd::Fork { id, branch, parent, spec_sha, worktree, dna } => { - match ledger::fork( - &conn, - &id, - &branch, - parent.as_deref(), - &spec_sha, - worktree.as_deref(), - dna.as_deref(), - ) { - Ok(()) => { - println!("forked {id} -> {branch}"); - ExitCode::SUCCESS - } - Err(e) => err(&format!("fork failed: {e}")), - } + Cmd::Fork { id, branch, parent, spec_sha, worktree, dna, creator, fork_parent } => { + run_fork(&conn, id, branch, parent, spec_sha, worktree, dna, creator, fork_parent) } Cmd::Done { id, summary } => match ledger::done(&conn, &id, &summary) { Ok(0) => err(&format!("no running agent with id {id}")), @@ -196,5 +166,6 @@ fn main() -> ExitCode { Cmd::List { status } => cmd_list(&conn, status.as_deref()), Cmd::Tree { id } => cmd_tree(&conn, &id), Cmd::Validate { branch, repo_root } => cmd_validate(&branch, &repo_root), + Cmd::Descendants { dna } => cmd_descendants(&conn, &dna), } } diff --git a/_primitives/_rust/kei-ledger/src/row.rs b/_primitives/_rust/kei-ledger/src/row.rs new file mode 100644 index 0000000..8549ef2 --- /dev/null +++ b/_primitives/_rust/kei-ledger/src/row.rs @@ -0,0 +1,49 @@ +//! `AgentRow` — the ledger's hydrated record. +//! +//! Constructor Pattern: one cube = struct + SELECT column list + row mapper. +//! Kept separate from `ledger.rs` so both stay under the 200-LOC cap. + +use rusqlite::Result as SqlResult; +use serde::Serialize; + +#[derive(Debug, Serialize, Clone)] +pub struct AgentRow { + pub id: String, + pub branch: String, + pub parent_branch: Option, + pub spec_sha: String, + pub status: String, + pub started_ts: i64, + pub finished_ts: Option, + pub summary: Option, + pub worktree_path: Option, + /// Layer G composition fingerprint; `None` for pre-v2 rows. + pub dna: Option, + /// DNA/human id of the spawner; `None` for pre-v4 rows (v4 lineage). + pub creator_id: Option, + /// DNA of forked-from agent if this row is itself a fork; `None` otherwise. + pub fork_parent_id: Option, +} + +/// Column list shared by all SELECTs that hydrate an `AgentRow`. Order must +/// match `row_to_agent` indices 0..12. +pub const SELECT_COLS: &str = + "id, branch, parent_branch, spec_sha, status, started_ts, finished_ts, \ + summary, worktree_path, dna, creator_id, fork_parent_id"; + +pub fn row_to_agent(r: &rusqlite::Row) -> SqlResult { + Ok(AgentRow { + id: r.get(0)?, + branch: r.get(1)?, + parent_branch: r.get(2)?, + spec_sha: r.get(3)?, + status: r.get(4)?, + started_ts: r.get(5)?, + finished_ts: r.get(6)?, + summary: r.get(7)?, + worktree_path: r.get(8)?, + dna: r.get(9)?, + creator_id: r.get(10)?, + fork_parent_id: r.get(11)?, + }) +} diff --git a/_primitives/_rust/kei-ledger/src/schema.rs b/_primitives/_rust/kei-ledger/src/schema.rs index 6043444..8ed001f 100644 --- a/_primitives/_rust/kei-ledger/src/schema.rs +++ b/_primitives/_rust/kei-ledger/src/schema.rs @@ -49,6 +49,14 @@ pub const MIGRATIONS: &[&str] = &[ 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). diff --git a/_primitives/_rust/kei-ledger/tests/integration.rs b/_primitives/_rust/kei-ledger/tests/integration.rs index 3138e77..ecdcb8e 100644 --- a/_primitives/_rust/kei-ledger/tests/integration.rs +++ b/_primitives/_rust/kei-ledger/tests/integration.rs @@ -8,8 +8,12 @@ mod schema; #[path = "../src/error.rs"] mod error; +#[path = "../src/row.rs"] +mod row; #[path = "../src/ledger.rs"] mod ledger; +#[path = "../src/descendants.rs"] +mod descendants; use rusqlite::Connection; use std::fs; @@ -35,7 +39,7 @@ fn write_artefacts(root: &Path, agent_id: &str, which: &[&str]) -> PathBuf { #[test] fn fork_then_done_marks_terminal() { let (_d, conn) = open_tmp(); - ledger::fork(&conn, "a1", "agent/a1", None, "deadbeef", None, None).unwrap(); + ledger::fork(&conn, "a1", "agent/a1", None, "deadbeef", None, None, None, None).unwrap(); let running = ledger::list(&conn, Some("running")).unwrap(); assert_eq!(running.len(), 1); assert_eq!(running[0].id, "a1"); @@ -50,7 +54,7 @@ fn fork_then_done_marks_terminal() { #[test] fn fail_flow_sets_reason_and_finished_ts() { let (_d, conn) = open_tmp(); - ledger::fork(&conn, "b1", "agent/b1", Some("main"), "cafebabe", None, None).unwrap(); + ledger::fork(&conn, "b1", "agent/b1", Some("main"), "cafebabe", None, None, None, None).unwrap(); let updated = ledger::fail(&conn, "b1", "cargo build failed").unwrap(); assert_eq!(updated, 1); let failed = ledger::list(&conn, Some("failed")).unwrap(); @@ -62,10 +66,10 @@ fn fail_flow_sets_reason_and_finished_ts() { #[test] fn tree_walks_parent_child_chain() { let (_d, conn) = open_tmp(); - ledger::fork(&conn, "root", "agent/root", Some("main"), "aa", None, None).unwrap(); - ledger::fork(&conn, "c1", "agent/c1", Some("agent/root"), "bb", None, None).unwrap(); - ledger::fork(&conn, "c2", "agent/c2", Some("agent/root"), "cc", None, None).unwrap(); - ledger::fork(&conn, "g1", "agent/g1", Some("agent/c1"), "dd", None, None).unwrap(); + ledger::fork(&conn, "root", "agent/root", Some("main"), "aa", None, None, None, None).unwrap(); + ledger::fork(&conn, "c1", "agent/c1", Some("agent/root"), "bb", None, None, None, None).unwrap(); + ledger::fork(&conn, "c2", "agent/c2", Some("agent/root"), "cc", None, None, None, None).unwrap(); + ledger::fork(&conn, "g1", "agent/g1", Some("agent/c1"), "dd", None, None, None, None).unwrap(); let t = ledger::tree(&conn, "root").unwrap(); let ids: Vec<_> = t.iter().map(|a| a.id.as_str()).collect(); @@ -80,8 +84,8 @@ fn tree_walks_parent_child_chain() { #[test] fn list_filter_status_excludes_others() { let (_d, conn) = open_tmp(); - ledger::fork(&conn, "r1", "br-r1", None, "s1", None, None).unwrap(); - ledger::fork(&conn, "r2", "br-r2", None, "s2", None, None).unwrap(); + ledger::fork(&conn, "r1", "br-r1", None, "s1", None, None, None, None).unwrap(); + ledger::fork(&conn, "r2", "br-r2", None, "s2", None, None, None, None).unwrap(); ledger::done(&conn, "r1", "ok").unwrap(); let running = ledger::list(&conn, Some("running")).unwrap(); assert_eq!(running.len(), 1); @@ -122,15 +126,15 @@ fn validate_ok_when_all_six_present() { #[test] fn duplicate_fork_id_rejected() { let (_d, conn) = open_tmp(); - ledger::fork(&conn, "dup", "br1", None, "x", None, None).unwrap(); - let err = ledger::fork(&conn, "dup", "br2", None, "y", None, None); + ledger::fork(&conn, "dup", "br1", None, "x", None, None, None, None).unwrap(); + let err = ledger::fork(&conn, "dup", "br2", None, "y", None, None, None, None); assert!(err.is_err(), "duplicate id must fail"); } #[test] fn done_on_already_done_agent_is_noop() { let (_d, conn) = open_tmp(); - ledger::fork(&conn, "n1", "br-n1", None, "h", None, None).unwrap(); + ledger::fork(&conn, "n1", "br-n1", None, "h", None, None, None, None).unwrap(); assert_eq!(ledger::done(&conn, "n1", "first").unwrap(), 1); assert_eq!(ledger::done(&conn, "n1", "second").unwrap(), 0); let row = &ledger::list(&conn, None).unwrap()[0]; @@ -141,12 +145,12 @@ fn done_on_already_done_agent_is_noop() { fn fork_with_dna_roundtrips_through_list() { let (_d, conn) = open_tmp(); let dna = "edit-local::NG-FW-FD-CP-CG-TG-ND-RF::A7B2::C9F1-xa7c"; - ledger::fork(&conn, "dna1", "agent/dna1", None, "spec", None, Some(dna)).unwrap(); + ledger::fork(&conn, "dna1", "agent/dna1", None, "spec", None, Some(dna), None, None).unwrap(); let rows = ledger::list(&conn, None).unwrap(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].dna.as_deref(), Some(dna)); - ledger::fork(&conn, "legacy1", "agent/legacy1", None, "spec2", None, None).unwrap(); + ledger::fork(&conn, "legacy1", "agent/legacy1", None, "spec2", None, None, None, None).unwrap(); let rows = ledger::list(&conn, None).unwrap(); let legacy = rows.iter().find(|r| r.id == "legacy1").unwrap(); assert!(legacy.dna.is_none(), "legacy fork should leave dna NULL"); @@ -155,7 +159,7 @@ fn fork_with_dna_roundtrips_through_list() { #[test] fn merged_after_done_transitions_status() { let (_d, conn) = open_tmp(); - ledger::fork(&conn, "m1", "br-m1", None, "h", None, None).unwrap(); + ledger::fork(&conn, "m1", "br-m1", None, "h", None, None, None, None).unwrap(); ledger::done(&conn, "m1", "ready").unwrap(); assert_eq!(ledger::merged(&conn, "m1").unwrap(), 1); let merged = ledger::list(&conn, Some("merged")).unwrap(); @@ -174,8 +178,8 @@ fn merged_after_done_transitions_status() { fn tree_handles_cycle_without_infinite_loop() { let (_d, conn) = open_tmp(); // Two rows whose parent_branch point at each other. - ledger::fork(&conn, "cx", "br-x", Some("br-y"), "sha-x", None, None).unwrap(); - ledger::fork(&conn, "cy", "br-y", Some("br-x"), "sha-y", None, None).unwrap(); + ledger::fork(&conn, "cx", "br-x", Some("br-y"), "sha-x", None, None, None, None).unwrap(); + ledger::fork(&conn, "cy", "br-y", Some("br-x"), "sha-y", None, None, None, None).unwrap(); // tree() should either return bounded rows (visited-set kills the loop) // or MaxDepthExceeded. Must not hang / OOM. @@ -204,7 +208,7 @@ fn migrate_is_idempotent_across_reopens() { let db = dir.path().join("ledger.sqlite"); { let conn = ledger::open(&db).unwrap(); - ledger::fork(&conn, "pre", "br-pre", None, "h", None, None).unwrap(); + ledger::fork(&conn, "pre", "br-pre", None, "h", None, None, None, None).unwrap(); } // Second open re-enters migrate(); must be a no-op, not a duplicate // column / trigger error. @@ -226,7 +230,7 @@ fn migrate_is_idempotent_across_reopens() { fn fork_rejects_overlong_branch() { let (_d, conn) = open_tmp(); let long = "x".repeat(schema::MAX_BRANCH_LEN + 1); - let res = ledger::fork(&conn, "too-long", &long, None, "h", None, None); + let res = ledger::fork(&conn, "too-long", &long, None, "h", None, None, None, None); match res { Err(ledger::LedgerError::BranchTooLong { field, len }) => { assert_eq!(field, "branch"); @@ -235,7 +239,7 @@ fn fork_rejects_overlong_branch() { other => panic!("expected BranchTooLong, got {other:?}"), } // Parent side same cap. - let res2 = ledger::fork(&conn, "ok-br", "fine", Some(&long), "h", None, None); + let res2 = ledger::fork(&conn, "ok-br", "fine", Some(&long), "h", None, None, None, None); assert!( matches!( res2, @@ -245,5 +249,103 @@ fn fork_rejects_overlong_branch() { ); // Length at the cap is accepted. let at_cap = "y".repeat(schema::MAX_BRANCH_LEN); - ledger::fork(&conn, "at-cap", &at_cap, None, "h", None, None).unwrap(); + ledger::fork(&conn, "at-cap", &at_cap, None, "h", None, None, None, None).unwrap(); +} + +// --- v4 lineage (creator_id + fork_parent_id) --------------------------- + +/// v4-T1: `--creator` value stored on fork and retrieved via list. +#[test] +fn fork_with_creator_id_roundtrips_through_list() { + let (_d, conn) = open_tmp(); + let creator = "human:denis"; + ledger::fork(&conn, "v4a", "agent/v4a", None, "sh", None, None, Some(creator), None).unwrap(); + let rows = ledger::list(&conn, None).unwrap(); + let r = rows.iter().find(|r| r.id == "v4a").unwrap(); + assert_eq!(r.creator_id.as_deref(), Some(creator)); + assert!(r.fork_parent_id.is_none()); +} + +/// v4-T2: `--fork-parent` value stores lineage pointer to a DNA. +#[test] +fn fork_with_fork_parent_stores_lineage() { + let (_d, conn) = open_tmp(); + let parent_dna = "edit-local::NG-FW::ABCD::1234-xy01"; + ledger::fork( + &conn, "v4b", "agent/v4b", None, "sh", None, None, None, Some(parent_dna), + ) + .unwrap(); + let r = ledger::list(&conn, None).unwrap().into_iter().next().unwrap(); + assert_eq!(r.fork_parent_id.as_deref(), Some(parent_dna)); + assert!(r.creator_id.is_none()); +} + +/// v4-T3: `descendants()` returns rows matched via EITHER column. +#[test] +fn descendants_returns_fork_and_spawn_chain() { + let (_d, conn) = open_tmp(); + let root_dna = "root-dna-0001"; + // child forked FROM root_dna + ledger::fork( + &conn, "d1", "agent/d1", None, "sh", None, None, None, Some(root_dna), + ) + .unwrap(); + // child SPAWNED BY root_dna (creator_id match) + ledger::fork( + &conn, "d2", "agent/d2", None, "sh", None, None, Some(root_dna), None, + ) + .unwrap(); + // unrelated agent — must NOT appear + ledger::fork(&conn, "d3", "agent/d3", None, "sh", None, None, None, None).unwrap(); + + let out = descendants::descendants(&conn, root_dna).unwrap(); + let ids: Vec<_> = out.iter().map(|r| r.id.as_str()).collect(); + assert_eq!(out.len(), 2, "expected exactly 2 descendants, got {ids:?}"); + assert!(ids.contains(&"d1")); + assert!(ids.contains(&"d2")); + assert!(!ids.contains(&"d3")); +} + +/// v4-T4: legacy rows written before migration v4 have NULL creator + fork_parent. +/// Simulates by inserting a row with the pre-v4 column subset then reopening. +#[test] +fn pre_v4_rows_have_null_lineage_columns() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("ledger.sqlite"); + { + let conn = ledger::open(&db).unwrap(); + ledger::fork(&conn, "old", "br-old", None, "sh", None, None, None, None).unwrap(); + } + // Reopen — migration re-runs (no-op at v4), row survives. + let conn = ledger::open(&db).unwrap(); + let rows = ledger::list(&conn, None).unwrap(); + assert_eq!(rows.len(), 1); + assert!(rows[0].creator_id.is_none()); + assert!(rows[0].fork_parent_id.is_none()); +} + +/// v4-T5: migration v3 → v4 idempotent across multiple reopens, schema at v4. +#[test] +fn migration_v4_idempotent_across_multiple_reopens() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("ledger.sqlite"); + for _ in 0..3 { + let conn = ledger::open(&db).unwrap(); + let v: i64 = conn + .query_row("PRAGMA user_version", [], |r| r.get(0)) + .unwrap(); + assert_eq!(v, schema::MIGRATIONS.len() as i64, "schema must land at v4"); + } + // Seed a row using v4 columns and verify it round-trips after another reopen. + { + let conn = ledger::open(&db).unwrap(); + ledger::fork( + &conn, "v4e", "agent/v4e", None, "sh", None, None, Some("c"), Some("fp"), + ) + .unwrap(); + } + let conn = ledger::open(&db).unwrap(); + let r = ledger::list(&conn, None).unwrap().into_iter().next().unwrap(); + assert_eq!(r.creator_id.as_deref(), Some("c")); + assert_eq!(r.fork_parent_id.as_deref(), Some("fp")); }