Merge TX3 — ledger creator/fork columns

This commit is contained in:
Parfii-bot 2026-04-23 10:22:38 +08:00
commit 11b85c2a61
7 changed files with 343 additions and 129 deletions

View file

@ -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<Vec<AgentRow>> {
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::<SqlResult<Vec<_>>>()?;
Ok(rows)
}

View file

@ -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/<kind>-<ts> OR inline-<ts>
// 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}")),
}
}

View file

@ -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<String>,
pub spec_sha: String,
pub status: String,
pub started_ts: i64,
pub finished_ts: Option<i64>,
pub summary: Option<String>,
pub worktree_path: Option<String>,
/// Layer G composition fingerprint; `None` for pre-v2 rows.
pub dna: Option<String>,
}
/// Open or create the ledger file and run migrations.
pub fn open(path: &Path) -> SqlResult<Connection> {
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<usize> {
)
}
/// 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<Vec<AgentRow>> {
let (sql, bound): (String, Vec<String>) = match status {
@ -122,21 +108,6 @@ pub fn list(conn: &Connection, status: Option<&str>) -> SqlResult<Vec<AgentRow>>
Ok(rows)
}
fn row_to_agent(r: &rusqlite::Row) -> SqlResult<AgentRow> {
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<Option<AgentRow>> {
let sql = format!("SELECT {SELECT_COLS} FROM agents WHERE id = ?1");
conn.query_row(&sql, params![id], row_to_agent).optional()

View file

@ -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<String>,
/// DNA / human id of the agent that spawned this fork (v4 lineage).
#[arg(long)]
creator: Option<String>,
/// DNA of the forked-from agent, if this is itself a fork (v4 lineage).
#[arg(long = "fork-parent")]
fork_parent: Option<String>,
},
/// 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 {
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<String>,
spec_sha: String,
worktree: Option<String>,
dna: Option<String>,
creator: Option<String>,
fork_parent: Option<String>,
) -> 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/<kind>-<ts> OR inline-<ts>
// 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),
}
}

View file

@ -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<String>,
pub spec_sha: String,
pub status: String,
pub started_ts: i64,
pub finished_ts: Option<i64>,
pub summary: Option<String>,
pub worktree_path: Option<String>,
/// Layer G composition fingerprint; `None` for pre-v2 rows.
pub dna: Option<String>,
/// DNA/human id of the spawner; `None` for pre-v4 rows (v4 lineage).
pub creator_id: Option<String>,
/// DNA of forked-from agent if this row is itself a fork; `None` otherwise.
pub fork_parent_id: Option<String>,
}
/// 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<AgentRow> {
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)?,
})
}

View file

@ -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).

View file

@ -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"));
}