feat(primitives): kei-ledger Rust SQLite agent ledger

SSoT for RULE 0.12 (agent git-model). Every non-trivial Agent invocation
logs a fork row; merge ceremony validates the 6-file artefact bundle.

CLI: init / fork / done / fail / merged / list / tree / validate.
Storage: ~/.claude/agents/ledger.sqlite (override via KEI_LEDGER_DB).
Schema versioned via PRAGMA user_version.

Tests: 9/9 passing (fork+done, fail flow, tree walk, list filter,
validate missing/complete, duplicate-id reject, done idempotency,
merged transition). cargo test --release 0.01s.

Constructor Pattern: schema.rs 50, ledger.rs 170, main.rs 177,
integration.rs 147 — all under 200 LOC.

Workspace update: adds kei-ledger to _primitives/_rust members list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parfii-bot 2026-04-21 20:52:20 +08:00
parent 48d4dd0733
commit c801715a49
6 changed files with 583 additions and 0 deletions

View file

@ -0,0 +1,19 @@
[workspace]
resolver = "2"
members = ["mock-render", "visual-diff", "tokens-sync", "kei-ledger"]
[workspace.package]
edition = "2021"
rust-version = "1.75"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
image = { version = "0.25", default-features = false, features = ["png"] }
[profile.release]
opt-level = "z"
lto = true
strip = true
codegen-units = 1

View file

@ -0,0 +1,20 @@
[package]
name = "kei-ledger"
version = "0.1.0"
edition = "2021"
rust-version = "1.75"
description = "Agent fork / done / fail ledger — SQLite-backed, SSoT for RULE 0.12"
[[bin]]
name = "kei-ledger"
path = "src/main.rs"
[dependencies]
rusqlite = { version = "0.31", features = ["bundled"] }
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
[dev-dependencies]
tempfile = "3"

View file

@ -0,0 +1,170 @@
//! Ledger operations — fork / done / fail / list / tree / validate.
//!
//! Constructor Pattern: each public fn <30 LOC, single responsibility.
//! Storage: rusqlite Connection (bundled SQLite). One file per caller.
use crate::schema::{migrate, REQUIRED_ARTEFACTS};
use chrono::Utc;
use rusqlite::{params, Connection, OptionalExtension, Result as SqlResult};
use serde::Serialize;
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>,
}
/// Open or create the ledger file and run migrations.
pub fn open(path: &Path) -> SqlResult<Connection> {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let conn = Connection::open(path)?;
migrate(&conn)?;
Ok(conn)
}
/// Insert a new running-agent row. Errors if id is already present.
pub fn fork(
conn: &Connection,
id: &str,
branch: &str,
parent: Option<&str>,
spec_sha: &str,
worktree: Option<&str>,
) -> SqlResult<()> {
let now = Utc::now().timestamp();
conn.execute(
"INSERT INTO agents
(id, branch, parent_branch, spec_sha, status, started_ts, worktree_path)
VALUES (?1, ?2, ?3, ?4, 'running', ?5, ?6)",
params![id, branch, parent, spec_sha, now, worktree],
)?;
Ok(())
}
/// Mark a running agent as done. No-op if already in terminal state.
pub fn done(conn: &Connection, id: &str, summary: &str) -> SqlResult<usize> {
let now = Utc::now().timestamp();
conn.execute(
"UPDATE agents SET status='done', finished_ts=?1, summary=?2
WHERE id=?3 AND status='running'",
params![now, summary, id],
)
}
/// Mark a running agent as failed with reason.
pub fn fail(conn: &Connection, id: &str, reason: &str) -> SqlResult<usize> {
let now = Utc::now().timestamp();
conn.execute(
"UPDATE agents SET status='failed', finished_ts=?1, summary=?2
WHERE id=?3 AND status='running'",
params![now, reason, id],
)
}
/// Mark an agent as merged (post-ceremony bookkeeping).
pub fn merged(conn: &Connection, id: &str) -> SqlResult<usize> {
let now = Utc::now().timestamp();
conn.execute(
"UPDATE agents SET status='merged', finished_ts=COALESCE(finished_ts, ?1)
WHERE id=?2 AND status IN ('done','failed')",
params![now, id],
)
}
/// List all agents, optionally filtered by status.
pub fn list(conn: &Connection, status: Option<&str>) -> SqlResult<Vec<AgentRow>> {
let (sql, bound): (&str, Vec<String>) = match status {
Some(s) => (
"SELECT id, branch, parent_branch, spec_sha, status, started_ts,
finished_ts, summary, worktree_path
FROM agents WHERE status = ?1 ORDER BY started_ts DESC",
vec![s.to_string()],
),
None => (
"SELECT id, branch, parent_branch, spec_sha, status, started_ts,
finished_ts, summary, worktree_path
FROM agents ORDER BY started_ts DESC",
vec![],
),
};
let mut stmt = conn.prepare(sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(bound.iter()), row_to_agent)?
.collect::<SqlResult<Vec<_>>>()?;
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)?,
})
}
fn by_id(conn: &Connection, id: &str) -> SqlResult<Option<AgentRow>> {
conn.query_row(
"SELECT id, branch, parent_branch, spec_sha, status, started_ts,
finished_ts, summary, worktree_path
FROM agents WHERE id = ?1",
params![id],
row_to_agent,
)
.optional()
}
/// Walk the parent chain from `root_id` down to all descendants.
/// Returns rows in BFS order starting with root.
pub fn tree(conn: &Connection, root_id: &str) -> SqlResult<Vec<AgentRow>> {
let root = match by_id(conn, root_id)? {
Some(r) => r,
None => return Ok(vec![]),
};
let mut out = vec![root.clone()];
let mut frontier = vec![root.branch];
while let Some(parent_branch) = frontier.pop() {
let mut stmt = conn.prepare(
"SELECT id, branch, parent_branch, spec_sha, status, started_ts,
finished_ts, summary, worktree_path
FROM agents WHERE parent_branch = ?1 ORDER BY started_ts ASC",
)?;
let kids = stmt
.query_map(params![parent_branch], row_to_agent)?
.collect::<SqlResult<Vec<_>>>()?;
for k in kids {
frontier.push(k.branch.clone());
out.push(k);
}
}
Ok(out)
}
/// Verify all 6 required artefacts exist under `.claude/agents/<id>/`
/// rooted at `repo_root`. Returns list of missing artefacts (empty = OK).
pub fn validate(repo_root: &Path, agent_id: &str) -> Vec<String> {
let mut base: PathBuf = repo_root.to_path_buf();
base.push(".claude");
base.push("agents");
base.push(agent_id);
REQUIRED_ARTEFACTS
.iter()
.filter(|a| !base.join(a).is_file())
.map(|a| a.to_string())
.collect()
}

View file

@ -0,0 +1,177 @@
//! kei-ledger — CLI dispatcher.
//!
//! Single responsibility: parse args, dispatch to ledger ops, format output.
//! Storage: `~/.claude/agents/ledger.sqlite` (or $KEI_LEDGER_DB override).
mod ledger;
mod schema;
use clap::{Parser, Subcommand};
use std::path::PathBuf;
use std::process::ExitCode;
#[derive(Parser)]
#[command(name = "kei-ledger", version, about = "Agent fork/done/fail ledger")]
struct Cli {
/// Override ledger path (default: $KEI_LEDGER_DB or ~/.claude/agents/ledger.sqlite)
#[arg(long)]
db: Option<PathBuf>,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Create the ledger file + schema if missing.
Init,
/// Log a new running agent.
Fork {
id: String,
branch: String,
#[arg(long)]
parent: Option<String>,
#[arg(long)]
spec_sha: String,
#[arg(long)]
worktree: Option<String>,
},
/// Mark a running agent as done.
Done {
id: String,
#[arg(long)]
summary: String,
},
/// Mark a running agent as failed.
Fail {
id: String,
#[arg(long)]
reason: String,
},
/// Mark a done/failed agent as merged.
Merged { id: String },
/// List agents, optionally filtered by status.
List {
#[arg(long)]
status: Option<String>,
},
/// Print parent -> children tree starting at a root agent id.
Tree { id: String },
/// Validate required artefact bundle for a given branch's agent.
Validate {
branch: String,
#[arg(long, default_value = ".")]
repo_root: PathBuf,
},
}
fn db_path(cli_db: Option<PathBuf>) -> PathBuf {
if let Some(p) = cli_db {
return p;
}
if let Ok(env) = std::env::var("KEI_LEDGER_DB") {
return PathBuf::from(env);
}
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
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)]
);
}
ExitCode::SUCCESS
}
Err(e) => err(&format!("list 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);
let conn = match ledger::open(&path) {
Ok(c) => c,
Err(e) => return err(&format!("open {}: {e}", path.display())),
};
match cli.cmd {
Cmd::Init => {
println!("initialised {}", path.display());
ExitCode::SUCCESS
}
Cmd::Fork { id, branch, parent, spec_sha, worktree } => {
match ledger::fork(&conn, &id, &branch, parent.as_deref(), &spec_sha, worktree.as_deref()) {
Ok(()) => {
println!("forked {id} -> {branch}");
ExitCode::SUCCESS
}
Err(e) => err(&format!("fork failed: {e}")),
}
}
Cmd::Done { id, summary } => match ledger::done(&conn, &id, &summary) {
Ok(0) => err(&format!("no running agent with id {id}")),
Ok(_) => ExitCode::SUCCESS,
Err(e) => err(&format!("done failed: {e}")),
},
Cmd::Fail { id, reason } => match ledger::fail(&conn, &id, &reason) {
Ok(0) => err(&format!("no running agent with id {id}")),
Ok(_) => ExitCode::SUCCESS,
Err(e) => err(&format!("fail update failed: {e}")),
},
Cmd::Merged { id } => match ledger::merged(&conn, &id) {
Ok(0) => err(&format!("no done/failed agent with id {id}")),
Ok(_) => ExitCode::SUCCESS,
Err(e) => err(&format!("merged failed: {e}")),
},
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),
}
}

View file

@ -0,0 +1,50 @@
//! 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};
/// 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);",
];
/// Apply all pending migrations. Stores current version in pragma user_version.
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 {
conn.execute_batch(sql)?;
conn.pragma_update(None, "user_version", target)?;
}
}
Ok(())
}
/// 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",
];

View file

@ -0,0 +1,147 @@
//! Integration tests for kei-ledger.
//!
//! Constructor Pattern: each test = one scenario, one assertion target.
//! Uses tempfile for per-test isolated sqlite file. Loads source modules
//! via `#[path]` so we don't need to expose a library crate surface.
#[path = "../src/schema.rs"]
mod schema;
#[path = "../src/ledger.rs"]
mod ledger;
use rusqlite::Connection;
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
fn open_tmp() -> (TempDir, Connection) {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("ledger.sqlite");
let conn = ledger::open(&db).unwrap();
(dir, conn)
}
fn write_artefacts(root: &Path, agent_id: &str, which: &[&str]) -> PathBuf {
let base = root.join(".claude/agents").join(agent_id);
fs::create_dir_all(&base).unwrap();
for f in which {
fs::write(base.join(f), b"x").unwrap();
}
base
}
#[test]
fn fork_then_done_marks_terminal() {
let (_d, conn) = open_tmp();
ledger::fork(&conn, "a1", "agent/a1", None, "deadbeef", None).unwrap();
let running = ledger::list(&conn, Some("running")).unwrap();
assert_eq!(running.len(), 1);
assert_eq!(running[0].id, "a1");
let updated = ledger::done(&conn, "a1", "shipped").unwrap();
assert_eq!(updated, 1);
let done = ledger::list(&conn, Some("done")).unwrap();
assert_eq!(done.len(), 1);
assert_eq!(done[0].summary.as_deref(), Some("shipped"));
}
#[test]
fn fail_flow_sets_reason_and_finished_ts() {
let (_d, conn) = open_tmp();
ledger::fork(&conn, "b1", "agent/b1", Some("main"), "cafebabe", None).unwrap();
let updated = ledger::fail(&conn, "b1", "cargo build failed").unwrap();
assert_eq!(updated, 1);
let failed = ledger::list(&conn, Some("failed")).unwrap();
assert_eq!(failed.len(), 1);
assert!(failed[0].finished_ts.is_some());
assert_eq!(failed[0].summary.as_deref(), Some("cargo build failed"));
}
#[test]
fn tree_walks_parent_child_chain() {
let (_d, conn) = open_tmp();
ledger::fork(&conn, "root", "agent/root", Some("main"), "aa", None).unwrap();
ledger::fork(&conn, "c1", "agent/c1", Some("agent/root"), "bb", None).unwrap();
ledger::fork(&conn, "c2", "agent/c2", Some("agent/root"), "cc", None).unwrap();
ledger::fork(&conn, "g1", "agent/g1", Some("agent/c1"), "dd", None).unwrap();
let t = ledger::tree(&conn, "root").unwrap();
let ids: Vec<_> = t.iter().map(|a| a.id.as_str()).collect();
assert!(ids.contains(&"root"));
assert!(ids.contains(&"c1"));
assert!(ids.contains(&"c2"));
assert!(ids.contains(&"g1"));
assert_eq!(ids[0], "root");
assert_eq!(ids.len(), 4);
}
#[test]
fn list_filter_status_excludes_others() {
let (_d, conn) = open_tmp();
ledger::fork(&conn, "r1", "br-r1", None, "s1", None).unwrap();
ledger::fork(&conn, "r2", "br-r2", None, "s2", None).unwrap();
ledger::done(&conn, "r1", "ok").unwrap();
let running = ledger::list(&conn, Some("running")).unwrap();
assert_eq!(running.len(), 1);
assert_eq!(running[0].id, "r2");
let all = ledger::list(&conn, None).unwrap();
assert_eq!(all.len(), 2);
}
#[test]
fn validate_detects_missing_artefacts() {
let (d, _conn) = open_tmp();
write_artefacts(d.path(), "v1", &["spec.md", "plan.md"]);
let missing = ledger::validate(d.path(), "v1");
assert_eq!(missing.len(), 4);
assert!(missing.contains(&"progress.json".to_string()));
assert!(missing.contains(&"review.md".to_string()));
}
#[test]
fn validate_ok_when_all_six_present() {
let (d, _conn) = open_tmp();
write_artefacts(
d.path(),
"v2",
&[
"spec.md",
"plan.md",
"progress.json",
"chatlog.md",
"handoffs.md",
"review.md",
],
);
let missing = ledger::validate(d.path(), "v2");
assert!(missing.is_empty(), "got missing {missing:?}");
}
#[test]
fn duplicate_fork_id_rejected() {
let (_d, conn) = open_tmp();
ledger::fork(&conn, "dup", "br1", None, "x", None).unwrap();
let err = ledger::fork(&conn, "dup", "br2", None, "y", 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).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];
assert_eq!(row.summary.as_deref(), Some("first"));
}
#[test]
fn merged_after_done_transitions_status() {
let (_d, conn) = open_tmp();
ledger::fork(&conn, "m1", "br-m1", None, "h", None).unwrap();
ledger::done(&conn, "m1", "ready").unwrap();
assert_eq!(ledger::merged(&conn, "m1").unwrap(), 1);
let merged = ledger::list(&conn, Some("merged")).unwrap();
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].summary.as_deref(), Some("ready"));
}