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>
147 lines
4.9 KiB
Rust
147 lines
4.9 KiB
Rust
//! 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"));
|
|
}
|