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>
This commit is contained in:
parent
010def05ad
commit
02451f5f49
9 changed files with 703 additions and 0 deletions
13
_primitives/_rust/Cargo.lock
generated
13
_primitives/_rust/Cargo.lock
generated
|
|
@ -2197,6 +2197,19 @@ dependencies = [
|
|||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kei-spawn"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"kei-agent-runtime",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kei-store"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ members = [
|
|||
"kei-pipe",
|
||||
# v1 substrate — deterministic result cache for pure (query/transform) atoms
|
||||
"kei-cache",
|
||||
# agent substrate v1 — automation envelope: prepare + ledger fork + verify
|
||||
"kei-spawn",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
|
|
|||
29
_primitives/_rust/kei-spawn/Cargo.toml
Normal file
29
_primitives/_rust/kei-spawn/Cargo.toml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
[package]
|
||||
name = "kei-spawn"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
description = "Agent substrate v1 — automation envelope around prepare + ledger fork + verify"
|
||||
|
||||
[[bin]]
|
||||
name = "kei-spawn"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lib]
|
||||
name = "kei_spawn"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
kei-agent-runtime = { path = "../kei-agent-runtime" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
anyhow = "1"
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[package.metadata.keisei]
|
||||
backend = "none"
|
||||
description = "Wraps kei-agent-runtime prepare + kei-ledger fork + kei-agent-runtime verify into a single CLI. Step 3 (Agent tool call) stays with the orchestrator."
|
||||
107
_primitives/_rust/kei-spawn/src/ledger_sh.rs
Normal file
107
_primitives/_rust/kei-spawn/src/ledger_sh.rs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
//! 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}"))
|
||||
}
|
||||
33
_primitives/_rust/kei-spawn/src/lib.rs
Normal file
33
_primitives/_rust/kei-spawn/src/lib.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
//! kei-spawn — automation envelope around kei-agent-runtime + kei-ledger.
|
||||
//!
|
||||
//! Orchestrator flow pre-kei-spawn:
|
||||
//! 1. Write task.toml manually
|
||||
//! 2. Run `kei-agent-runtime prepare`
|
||||
//! 3. Invoke Agent tool (harness-internal, orchestrator-only)
|
||||
//! 4. Run `kei-ledger fork`
|
||||
//! 5. On return, run `kei-agent-runtime verify`
|
||||
//!
|
||||
//! With kei-spawn, steps 2 + 4 collapse to one `kei-spawn spawn <task.toml>` call
|
||||
//! and step 5 collapses to one `kei-spawn verify <agent-id> <worktree>` call.
|
||||
//! Step 3 (the actual Agent tool invocation) STILL belongs to the orchestrator
|
||||
//! because Claude Code's `Agent` tool is harness-internal — it can't be invoked
|
||||
//! from Rust. `kei-spawn` emits a JSON bundle the orchestrator pastes.
|
||||
//!
|
||||
//! Design constraints:
|
||||
//! - Constructor Pattern: one module = one responsibility, ≤200 LOC file,
|
||||
//! ≤30 LOC fn.
|
||||
//! - No HTTP / no Anthropic API — that's a later `kei-spawn drive` iteration.
|
||||
//! - No git / no shell — ledger interactions go through `kei-ledger` as a
|
||||
//! subprocess to avoid adding kei-ledger as a direct dep while it still
|
||||
//! lacks a lib.rs (can't link to a bin-only crate).
|
||||
//!
|
||||
//! Per RULE 0.13: kei-spawn NEVER creates branches or commits. The orchestrator
|
||||
//! owns git state. kei-spawn only writes into `tasks/<agent-id>/` and invokes
|
||||
//! `kei-ledger` (which itself only writes to SQLite).
|
||||
|
||||
pub mod ledger_sh;
|
||||
pub mod spawn;
|
||||
pub mod verify;
|
||||
|
||||
pub use spawn::{spawn_from_task, SpawnOutput};
|
||||
pub use verify::{verify_agent, VerifyOutput};
|
||||
106
_primitives/_rust/kei-spawn/src/main.rs
Normal file
106
_primitives/_rust/kei-spawn/src/main.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
//! kei-spawn — CLI dispatcher.
|
||||
//!
|
||||
//! Three subcommands:
|
||||
//! - `spawn <task.toml>` — prepare invocation + ledger fork, emit JSON
|
||||
//! - `verify <agent-id> <worktree>` — run verify pipeline, update ledger
|
||||
//! - `list-pending` — forward `kei-ledger list --status running`
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use kei_spawn::{ledger_sh, spawn_from_task, verify_agent};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "kei-spawn",
|
||||
version,
|
||||
about = "Automation envelope: prepare + ledger fork + verify (RULE 0.13-compliant)"
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Prepare an Agent-tool invocation + register ledger row.
|
||||
Spawn {
|
||||
/// Path to task.toml.
|
||||
task: PathBuf,
|
||||
/// kit root (default: cwd).
|
||||
#[arg(long)]
|
||||
kit_root: Option<PathBuf>,
|
||||
},
|
||||
/// Run verify pipeline + update ledger status.
|
||||
Verify {
|
||||
/// agent-id previously emitted by `kei-spawn spawn`.
|
||||
agent_id: String,
|
||||
/// Worktree path reported by the Claude harness on agent return.
|
||||
worktree: PathBuf,
|
||||
#[arg(long)]
|
||||
kit_root: Option<PathBuf>,
|
||||
},
|
||||
/// Show all running ledger rows.
|
||||
ListPending,
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let cli = Cli::parse();
|
||||
match cli.cmd {
|
||||
Cmd::Spawn { task, kit_root } => run_spawn(task, kit_root),
|
||||
Cmd::Verify { agent_id, worktree, kit_root } => {
|
||||
run_verify(agent_id, worktree, kit_root)
|
||||
}
|
||||
Cmd::ListPending => run_list_pending(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_spawn(task: PathBuf, kit_root: Option<PathBuf>) -> ExitCode {
|
||||
let kit = kit_root_or_cwd(kit_root);
|
||||
match spawn_from_task(&task, &kit) {
|
||||
Ok(out) => emit_json(&out),
|
||||
Err(e) => err("spawn", e),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_verify(agent_id: String, worktree: PathBuf, kit_root: Option<PathBuf>) -> ExitCode {
|
||||
let kit = kit_root_or_cwd(kit_root);
|
||||
match verify_agent(&agent_id, &worktree, &kit) {
|
||||
Ok(out) => {
|
||||
let code = if out.is_clean { ExitCode::SUCCESS } else { ExitCode::from(2) };
|
||||
let _ = emit_json(&out);
|
||||
code
|
||||
}
|
||||
Err(e) => err("verify", e),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_list_pending() -> ExitCode {
|
||||
match ledger_sh::list_running() {
|
||||
Ok(s) => {
|
||||
print!("{s}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => err("list-pending", e),
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_json<T: serde::Serialize>(v: &T) -> ExitCode {
|
||||
match serde_json::to_string_pretty(v) {
|
||||
Ok(s) => {
|
||||
println!("{s}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => err("serialize json", e),
|
||||
}
|
||||
}
|
||||
|
||||
fn kit_root_or_cwd(arg: Option<PathBuf>) -> PathBuf {
|
||||
arg.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
|
||||
}
|
||||
|
||||
fn err(stage: &str, e: impl std::fmt::Display) -> ExitCode {
|
||||
eprintln!("kei-spawn {stage}: {e}");
|
||||
ExitCode::from(1)
|
||||
}
|
||||
114
_primitives/_rust/kei-spawn/src/spawn.rs
Normal file
114
_primitives/_rust/kei-spawn/src/spawn.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
//! spawn — orchestrator-driven task → prepared agent + ledger row.
|
||||
//!
|
||||
//! One public entry point: `spawn_from_task`. Given a task.toml and a
|
||||
//! kit_root, it:
|
||||
//! 1. Parses task.toml via `kei_agent_runtime::spawn::load_task`
|
||||
//! 2. Composes `AgentInvocation` via `kei_agent_runtime::prepare::prepare`
|
||||
//! (auto-generates agent-id if absent)
|
||||
//! 3. Copies the resolved agent-id back into the task and writes
|
||||
//! `tasks/<agent-id>/{prompt.md, task.toml}` via
|
||||
//! `kei_agent_runtime::spawn::prepare_agent`
|
||||
//! 4. Computes spec_sha (SHA-256 of the task TOML content)
|
||||
//! 5. Registers a running row in the ledger via `kei-ledger fork`
|
||||
//! 6. Returns `SpawnOutput` — everything orchestrator needs to call
|
||||
//! Claude Code's `Agent` tool (serialised as JSON).
|
||||
//!
|
||||
//! Never invokes git. Never invokes the Agent tool. Per RULE 0.13.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use kei_agent_runtime::{prepare, spawn as runtime_spawn};
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::ledger_sh;
|
||||
|
||||
/// The bundle orchestrator hands to Claude Code's Agent tool.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SpawnOutput {
|
||||
pub agent_id: String,
|
||||
pub dna: String,
|
||||
pub role: String,
|
||||
pub subagent_type: String,
|
||||
pub isolation: Option<String>,
|
||||
pub description: String,
|
||||
pub prompt: String,
|
||||
pub prompt_path: PathBuf,
|
||||
pub task_path: PathBuf,
|
||||
pub spec_sha: String,
|
||||
pub branch: String,
|
||||
pub verify_command: String,
|
||||
pub ledger_row: String,
|
||||
pub next_step: String,
|
||||
}
|
||||
|
||||
/// Main spawn entry. See module doc for the 6-step pipeline.
|
||||
pub fn spawn_from_task(task_path: &Path, kit_root: &Path) -> Result<SpawnOutput> {
|
||||
let mut task = runtime_spawn::load_task(task_path)
|
||||
.with_context(|| format!("load task {}", task_path.display()))?;
|
||||
let inv = prepare::prepare(&task, kit_root).context("compose AgentInvocation")?;
|
||||
// Propagate auto-generated agent-id back into the task so `prepare_agent`
|
||||
// can use it as the directory name and the ledger row keys by it.
|
||||
task.task.agent_id = inv.agent_id.clone();
|
||||
let prepared = runtime_spawn::prepare_agent(&task, kit_root).context("prepare_agent")?;
|
||||
let task_bytes = std::fs::read(&prepared.task_path)
|
||||
.with_context(|| format!("read written task {}", prepared.task_path.display()))?;
|
||||
let spec_sha = sha256_hex(&task_bytes);
|
||||
let branch = format!("agent/{}", inv.agent_id);
|
||||
let parent = task
|
||||
.task
|
||||
.parent_agent
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty());
|
||||
ledger_sh::fork(
|
||||
&inv.agent_id,
|
||||
&branch,
|
||||
parent,
|
||||
&spec_sha,
|
||||
prepared.dir.to_str(),
|
||||
Some(&inv.dna),
|
||||
)
|
||||
.context("kei-ledger fork")?;
|
||||
Ok(build_output(inv, prepared, spec_sha, branch))
|
||||
}
|
||||
|
||||
fn build_output(
|
||||
inv: prepare::AgentInvocation,
|
||||
prepared: runtime_spawn::PreparedAgent,
|
||||
spec_sha: String,
|
||||
branch: String,
|
||||
) -> SpawnOutput {
|
||||
let next_step = format!(
|
||||
"Invoke Agent tool with subagent_type={}, isolation={}, prompt=<see prompt field or {}>",
|
||||
inv.subagent_type,
|
||||
inv.isolation.as_deref().unwrap_or("<none>"),
|
||||
prepared.prompt_path.display()
|
||||
);
|
||||
SpawnOutput {
|
||||
agent_id: inv.agent_id,
|
||||
dna: inv.dna,
|
||||
role: inv.role,
|
||||
subagent_type: inv.subagent_type,
|
||||
isolation: inv.isolation,
|
||||
description: inv.description,
|
||||
prompt: inv.prompt,
|
||||
prompt_path: prepared.prompt_path,
|
||||
task_path: prepared.task_path,
|
||||
spec_sha,
|
||||
branch,
|
||||
verify_command: inv.verify_command,
|
||||
ledger_row: inv.ledger_row,
|
||||
next_step,
|
||||
}
|
||||
}
|
||||
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(bytes);
|
||||
let digest = h.finalize();
|
||||
let mut s = String::with_capacity(64);
|
||||
for b in digest {
|
||||
s.push_str(&format!("{:02x}", b));
|
||||
}
|
||||
s
|
||||
}
|
||||
83
_primitives/_rust/kei-spawn/src/verify.rs
Normal file
83
_primitives/_rust/kei-spawn/src/verify.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
//! verify — orchestrator-side post-return verification + ledger bookkeeping.
|
||||
//!
|
||||
//! Given an agent-id and the worktree path the harness returned, this module:
|
||||
//! 1. Reads `<kit_root>/tasks/<agent-id>/task.toml`
|
||||
//! 2. Resolves role → ordered capability list
|
||||
//! 3. Runs `kei_agent_runtime::verify::verify_task` (worktree pass)
|
||||
//! 4. On pass, marks ledger row `done`; on fail, marks `failed`
|
||||
//! 5. Emits a `VerifyOutput` JSON (pass/fail + failed-capability list)
|
||||
//!
|
||||
//! Simulated-merge pass is orchestrator-scope (needs git) so we stay in
|
||||
//! `RunMode::Worktree`. A future `kei-spawn verify-merge` flavour can be
|
||||
//! added once orchestrator-owned git helpers exist.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use kei_agent_runtime::capability::RunMode;
|
||||
use kei_agent_runtime::{spawn as runtime_spawn, verify as runtime_verify};
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::ledger_sh;
|
||||
|
||||
/// Outcome of a single verify pass, including failed-capability detail.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct VerifyOutput {
|
||||
pub agent_id: String,
|
||||
pub passed: Vec<String>,
|
||||
pub failed: Vec<runtime_verify::FailedEntry>,
|
||||
pub is_clean: bool,
|
||||
pub worktree: PathBuf,
|
||||
}
|
||||
|
||||
/// Main verify entry. On pass → ledger done; on fail → ledger failed.
|
||||
pub fn verify_agent(agent_id: &str, worktree: &Path, kit_root: &Path) -> Result<VerifyOutput> {
|
||||
let task_path = task_toml_path(kit_root, agent_id)?;
|
||||
let task = runtime_spawn::load_task(&task_path)
|
||||
.with_context(|| format!("load task {}", task_path.display()))?;
|
||||
let caps = runtime_verify::load_role_capabilities(kit_root, &task.task.role)
|
||||
.context("resolve role capabilities")?;
|
||||
let report = runtime_verify::verify_task(
|
||||
&task, agent_id, worktree, kit_root, RunMode::Worktree, &caps, None,
|
||||
)
|
||||
.context("run verify pipeline")?;
|
||||
let is_clean = report.is_clean();
|
||||
update_ledger(agent_id, &report)?;
|
||||
Ok(VerifyOutput {
|
||||
agent_id: agent_id.to_string(),
|
||||
passed: report.passed,
|
||||
failed: report.failed,
|
||||
is_clean,
|
||||
worktree: worktree.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve and validate `<kit>/tasks/<agent-id>/task.toml`.
|
||||
fn task_toml_path(kit_root: &Path, agent_id: &str) -> Result<std::path::PathBuf> {
|
||||
let p = kit_root.join("tasks").join(agent_id).join("task.toml");
|
||||
if !p.is_file() {
|
||||
return Err(anyhow!(
|
||||
"task.toml not found at {}: did you run `kei-spawn spawn` first?",
|
||||
p.display()
|
||||
));
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn update_ledger(agent_id: &str, report: &runtime_verify::VerifyReport) -> Result<()> {
|
||||
if report.is_clean() {
|
||||
let summary = format!("verify passed ({} capabilities)", report.passed.len());
|
||||
ledger_sh::done(agent_id, &summary).context("kei-ledger done")?;
|
||||
} else {
|
||||
let reason = format_failures(&report.failed);
|
||||
ledger_sh::fail(agent_id, &reason).context("kei-ledger fail")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_failures(failed: &[runtime_verify::FailedEntry]) -> String {
|
||||
let mut parts = Vec::with_capacity(failed.len());
|
||||
for f in failed {
|
||||
parts.push(format!("{}: {}", f.capability, f.reason));
|
||||
}
|
||||
parts.join("; ")
|
||||
}
|
||||
216
_primitives/_rust/kei-spawn/tests/spawn_smoke.rs
Normal file
216
_primitives/_rust/kei-spawn/tests/spawn_smoke.rs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
//! spawn_smoke — integration tests for kei-spawn library API.
|
||||
//!
|
||||
//! These tests set `KEI_SPAWN_LEDGER_NOOP=1` so the ledger subprocess is a
|
||||
//! no-op — we exercise the compose + prepare_agent + output shape path
|
||||
//! without depending on a real `kei-ledger` binary being on PATH.
|
||||
//!
|
||||
//! Fixtures follow the same pattern as kei-agent-runtime's tests: write a
|
||||
//! minimal `_roles/` + `_capabilities/` tree into a tempdir, a task.toml
|
||||
//! referencing the role, then call `spawn_from_task` and assert the JSON
|
||||
//! shape + on-disk artefacts.
|
||||
|
||||
use kei_spawn::{spawn_from_task, verify_agent};
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_capability(root: &Path, cat: &str, slug: &str, body: &str) {
|
||||
let dir = root.join("_capabilities").join(cat).join(slug);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("text.md"), body).unwrap();
|
||||
}
|
||||
|
||||
fn write_role(root: &Path, name: &str, toml: &str) {
|
||||
std::fs::create_dir_all(root.join("_roles")).unwrap();
|
||||
std::fs::write(root.join("_roles").join(format!("{name}.toml")), toml).unwrap();
|
||||
}
|
||||
|
||||
fn write_task(root: &Path, toml: &str) -> std::path::PathBuf {
|
||||
let path = root.join("task.toml");
|
||||
std::fs::write(&path, toml).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn minimal_kit(root: &Path) {
|
||||
write_capability(root, "policy", "no-git-ops", "## Never git.\n");
|
||||
write_capability(root, "output", "report-format", "## Report fields.\n");
|
||||
write_role(
|
||||
root,
|
||||
"edit-local",
|
||||
r#"
|
||||
[role]
|
||||
name = "edit-local"
|
||||
spawnable = true
|
||||
claude-subagent-type = "code-implementer"
|
||||
|
||||
[capabilities]
|
||||
required = ["policy::no-git-ops", "output::report-format"]
|
||||
"#,
|
||||
);
|
||||
}
|
||||
|
||||
fn set_noop() {
|
||||
std::env::set_var("KEI_SPAWN_LEDGER_NOOP", "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_happy_path_emits_full_output() {
|
||||
set_noop();
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
minimal_kit(root);
|
||||
|
||||
let task_path = write_task(
|
||||
root,
|
||||
r#"
|
||||
[task]
|
||||
role = "edit-local"
|
||||
|
||||
[body]
|
||||
text = "Port kei-forge templating to pure Rust."
|
||||
"#,
|
||||
);
|
||||
|
||||
let out = spawn_from_task(&task_path, root).expect("spawn should succeed");
|
||||
assert!(out.agent_id.starts_with("ag-edit-local-"), "id: {}", out.agent_id);
|
||||
assert_eq!(out.role, "edit-local");
|
||||
assert_eq!(out.subagent_type, "code-implementer");
|
||||
assert_eq!(out.isolation.as_deref(), Some("worktree"));
|
||||
assert!(out.prompt.contains("Port kei-forge"));
|
||||
assert!(out.prompt.contains("Never git"));
|
||||
assert_eq!(out.spec_sha.len(), 64, "sha256 hex = 64 chars: {}", out.spec_sha);
|
||||
assert!(out.branch.starts_with("agent/"));
|
||||
assert!(out.branch.contains(&out.agent_id));
|
||||
assert!(out.next_step.contains("code-implementer"));
|
||||
assert!(out.prompt_path.is_file());
|
||||
assert!(out.task_path.is_file());
|
||||
assert!(!out.dna.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_preserves_explicit_agent_id() {
|
||||
set_noop();
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
minimal_kit(root);
|
||||
|
||||
let task_path = write_task(
|
||||
root,
|
||||
r#"
|
||||
[task]
|
||||
role = "edit-local"
|
||||
agent-id = "ag-edit-local-explicit-12345"
|
||||
|
||||
[body]
|
||||
text = "Explicit id test."
|
||||
"#,
|
||||
);
|
||||
|
||||
let out = spawn_from_task(&task_path, root).expect("spawn should succeed");
|
||||
assert_eq!(out.agent_id, "ag-edit-local-explicit-12345");
|
||||
assert_eq!(out.branch, "agent/ag-edit-local-explicit-12345");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_rejects_unknown_role() {
|
||||
set_noop();
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let task_path = write_task(
|
||||
root,
|
||||
r#"
|
||||
[task]
|
||||
role = "does-not-exist"
|
||||
|
||||
[body]
|
||||
text = "x"
|
||||
"#,
|
||||
);
|
||||
|
||||
let err = spawn_from_task(&task_path, root).expect_err("unknown role must fail");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("role") || msg.contains("does-not-exist"),
|
||||
"error should reference the role: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_refuses_non_spawnable_role() {
|
||||
set_noop();
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
write_role(
|
||||
root,
|
||||
"git-ops",
|
||||
r#"
|
||||
[role]
|
||||
name = "git-ops"
|
||||
spawnable = false
|
||||
|
||||
[capabilities]
|
||||
required = []
|
||||
"#,
|
||||
);
|
||||
|
||||
let task_path = write_task(
|
||||
root,
|
||||
r#"
|
||||
[task]
|
||||
role = "git-ops"
|
||||
|
||||
[body]
|
||||
text = "should refuse"
|
||||
"#,
|
||||
);
|
||||
|
||||
let err = spawn_from_task(&task_path, root).expect_err("git-ops must be refused");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("RULE 0.13"), "refusal must cite RULE 0.13: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_fails_when_task_missing() {
|
||||
set_noop();
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
minimal_kit(root);
|
||||
|
||||
let worktree = tmp.path().join("wt");
|
||||
std::fs::create_dir_all(&worktree).unwrap();
|
||||
|
||||
let err = verify_agent("ag-does-not-exist", &worktree, root)
|
||||
.expect_err("missing task.toml must fail");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("task.toml not found"), "msg: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_then_verify_end_to_end() {
|
||||
set_noop();
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
minimal_kit(root);
|
||||
|
||||
let task_path = write_task(
|
||||
root,
|
||||
r#"
|
||||
[task]
|
||||
role = "edit-local"
|
||||
|
||||
[body]
|
||||
text = "Round-trip test."
|
||||
"#,
|
||||
);
|
||||
|
||||
let spawned = spawn_from_task(&task_path, root).expect("spawn");
|
||||
// worktree doesn't need real content — the two capabilities in
|
||||
// minimal_kit have no verify implementations, so the report is clean.
|
||||
let worktree = tmp.path().join("wt");
|
||||
std::fs::create_dir_all(&worktree).unwrap();
|
||||
|
||||
let verified = verify_agent(&spawned.agent_id, &worktree, root).expect("verify");
|
||||
assert_eq!(verified.agent_id, spawned.agent_id);
|
||||
assert!(verified.is_clean, "failed: {:?}", verified.failed);
|
||||
}
|
||||
Loading…
Reference in a new issue