KeiSeiKit-1.0/_primitives/_rust/kei-spawn/src/ledger_sh.rs
Parfii-bot 02451f5f49 feat(sp1): NEW kei-spawn crate — automation envelope
spawn <task.toml> internally calls prepare + ledger fork, emits
JSON ready for Agent tool invocation. verify wraps post-return
check+ledger update. list-pending shows running forks.

kei-ledger invoked via subprocess (no lib.rs in kei-ledger).
KEI_SPAWN_LEDGER_NOOP=1 test escape hatch for CI without binary.

spec_sha = SHA-256 of task.toml bytes (workspace sha2 dep).

Tests: 6/6 integration (happy, explicit-id, unknown-role, non-spawnable,
verify-missing, end-to-end roundtrip).

Step 3 (Anthropic API) stays with orchestrator — next iteration adds
kei-spawn drive <task.toml> for HTTP automation.

Workspace Cargo.toml: +kei-spawn member.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:21:45 +08:00

107 lines
3.4 KiB
Rust

//! Thin subprocess wrapper around the `kei-ledger` binary.
//!
//! kei-ledger is a bin-only crate (no lib.rs at the time kei-spawn was
//! introduced). We shell to it rather than replicate SQL — same process
//! model users expect, same DB file, same env contract (`KEI_LEDGER_DB`).
//!
//! Every call surfaces stderr on failure so orchestrator sees the real
//! ledger error (branch too long, duplicate id, etc.), not a wrapped one.
use anyhow::{anyhow, Result};
use std::process::Command;
/// Resolve `kei-ledger` executable. Env override → CARGO env (tests) → PATH.
pub fn ledger_bin() -> String {
if let Ok(b) = std::env::var("KEI_LEDGER_BIN") {
return b;
}
// CARGO_BIN_EXE_kei-ledger is set for integration tests under workspace.
if let Ok(b) = std::env::var("CARGO_BIN_EXE_kei-ledger") {
return b;
}
"kei-ledger".into()
}
/// Test / sandbox escape hatch: when set, every ledger call is a no-op.
/// Integration tests use this to avoid needing the real kei-ledger binary
/// on PATH. Production callers MUST NOT set this env var.
fn is_noop() -> bool {
std::env::var("KEI_SPAWN_LEDGER_NOOP")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
/// Run `kei-ledger fork` with DNA + worktree metadata.
pub fn fork(
id: &str,
branch: &str,
parent: Option<&str>,
spec_sha: &str,
worktree: Option<&str>,
dna: Option<&str>,
) -> Result<()> {
if is_noop() {
let _ = (id, branch, parent, spec_sha, worktree, dna);
return Ok(());
}
let mut cmd = Command::new(ledger_bin());
cmd.args(["fork", id, branch, "--spec-sha", spec_sha]);
if let Some(p) = parent {
cmd.args(["--parent", p]);
}
if let Some(w) = worktree {
cmd.args(["--worktree", w]);
}
if let Some(d) = dna {
cmd.args(["--dna", d]);
}
run(&mut cmd, "fork")
}
/// Run `kei-ledger done <id> --summary <s>`.
pub fn done(id: &str, summary: &str) -> Result<()> {
if is_noop() {
let _ = (id, summary);
return Ok(());
}
let mut cmd = Command::new(ledger_bin());
cmd.args(["done", id, "--summary", summary]);
run(&mut cmd, "done")
}
/// Run `kei-ledger fail <id> --reason <r>`.
pub fn fail(id: &str, reason: &str) -> Result<()> {
if is_noop() {
let _ = (id, reason);
return Ok(());
}
let mut cmd = Command::new(ledger_bin());
cmd.args(["fail", id, "--reason", reason]);
run(&mut cmd, "fail")
}
/// Run `kei-ledger list --status running`. Returns raw stdout lines.
pub fn list_running() -> Result<String> {
if is_noop() {
return Ok(String::from("(noop: KEI_SPAWN_LEDGER_NOOP=1)\n"));
}
let mut cmd = Command::new(ledger_bin());
cmd.args(["list", "--status", "running"]);
let out = cmd.output().map_err(|e| anyhow!("spawn kei-ledger: {e}"))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
return Err(anyhow!("kei-ledger list failed: {stderr}"));
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
fn run(cmd: &mut Command, stage: &str) -> Result<()> {
let out = cmd
.output()
.map_err(|e| anyhow!("spawn kei-ledger {stage}: {e}"))?;
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
Err(anyhow!("kei-ledger {stage} failed: {stderr}"))
}