diff --git a/CHANGELOG.md b/CHANGELOG.md index 86af64c8..c1348588 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Cross-package release notes for relayburn. Package changelogs contain package-le ## [Unreleased] +- `relayburn-cli` (Rust): wire `burn run ` driver + claude adapter (eager unit-struct in `EAGER_ADAPTERS`); `afterExit` ingest folds into `[burn] claude ingest: ...` summary line. (#248 D5) - `relayburn-cli` (Rust): wire `burn ingest` (no-flag scan, `--watch` poll loop, `--hook claude --quiet`) and `burn mcp-server` stdio subcommand exposing `burn__sessionCost`; closes #210. (#248 D8) - `relayburn-cli` (Rust): wire codex `HarnessAdapter` via `pending_stamp::adapter_static` factory; registered in `RUNTIME_ADAPTERS`. (#248 D6) - `relayburn-cli` (Rust): wire `burn compare` as a presenter over `relayburn_sdk::analyze::compare` building blocks (`build_compare_table` + the per-turn fidelity gate), matching the TS CLI flag set (positional comma-separated model list, `--include-partial` / `--fidelity` / `--since` / `--project` / `--session` / `--min-sample` / `--csv` / `--no-archive`) and producing byte-equivalent stdout for the cli-golden `compare` / `compare-json` invocations. (#248 D3) diff --git a/crates/relayburn-cli/Cargo.toml b/crates/relayburn-cli/Cargo.toml index a3635c94..4da85224 100644 --- a/crates/relayburn-cli/Cargo.toml +++ b/crates/relayburn-cli/Cargo.toml @@ -77,11 +77,14 @@ thiserror = { workspace = true } # bodies via a current-thread runtime. async-trait = "0.1" phf = { version = "0.11", features = ["macros"] } +# `process` is needed by `commands/run.rs` so the `burn run` driver can +# `await` the child via `tokio::process::Command::status()` rather than +# blocking the current-thread runtime with `std::process::Command::status()`. # `signal` is needed for the `burn ingest --watch` SIGINT/SIGTERM trap # (#248 D8); the watch loop blocks the foreground until a stop signal # comes in. `rt` drives the current-thread runtime that wraps the SDK's # async ingest verb from otherwise-sync presenter bodies. -tokio = { workspace = true, features = ["sync", "rt", "signal"] } +tokio = { workspace = true, features = ["sync", "rt", "process", "signal"] } # `IndexMap` preserves first-seen iteration order, which matters for the # Wave 2 read-path commands so their grouped output (`summary --by-model`, diff --git a/crates/relayburn-cli/src/cli.rs b/crates/relayburn-cli/src/cli.rs index e8b2d364..522d74fc 100644 --- a/crates/relayburn-cli/src/cli.rs +++ b/crates/relayburn-cli/src/cli.rs @@ -109,7 +109,7 @@ pub enum Command { /// Run an agent CLI under a harness wrapper that ingests its /// session log on exit. - Run, + Run(RunArgs), /// Inspect or rebuild derived state under `~/.relayburn`. State(StateArgs), @@ -188,6 +188,40 @@ pub struct McpServerArgs { pub debug: bool, } +/// `burn run [--tag k=v ...] [-- ]` — flags + +/// trailing argv for the harness driver. Mirrors the TS surface in +/// `packages/cli/src/commands/run.ts`. +/// +/// The first positional is the harness name (`claude`, `codex`, +/// `opencode`). Everything after `--` (or any unknown flag, courtesy of +/// `trailing_var_arg`) is captured into `passthrough` and forwarded to +/// the spawned binary verbatim. `--tag k=v` may be repeated; bad shapes +/// (no `=`, empty key) are rejected at runtime by the driver with the +/// same error message as the TS sibling. +#[derive(Debug, Clone, ClapArgs)] +pub struct RunArgs { + /// Lowercase harness identifier (`claude`, `codex`, `opencode`). + /// Optional so `burn run --help` and `burn run` both succeed; the + /// driver translates a missing name to a help-or-exit-2 outcome + /// matching the TS sibling. + #[arg(value_name = "HARNESS")] + pub harness: Option, + + /// User-supplied stamp enrichment. Repeatable — `--tag workflow=foo + /// --tag agent=bar` produces `{"workflow":"foo","agent":"bar"}` on + /// the resulting [`relayburn_sdk::Stamp`]. + #[arg(long = "tag", value_name = "K=V")] + pub tag: Vec, + + /// Everything after the harness name (or `--`). Forwarded to the + /// spawned binary in `SpawnPlan::args` after the adapter's own + /// transport-level args. `trailing_var_arg = true` makes clap stop + /// option parsing at the first non-flag token so `burn run claude + /// --resume` works without an explicit `--`. + #[arg(trailing_var_arg = true, allow_hyphen_values = true, value_name = "ARGS")] + pub passthrough: Vec, +} + /// Per-command flag set for `burn compare`. Mirrors /// `packages/cli/src/commands/compare.ts` so the CLI surfaces match /// byte-for-byte; see that file for the canonical help text. diff --git a/crates/relayburn-cli/src/commands/mod.rs b/crates/relayburn-cli/src/commands/mod.rs index 4fca48e4..8faef5a2 100644 --- a/crates/relayburn-cli/src/commands/mod.rs +++ b/crates/relayburn-cli/src/commands/mod.rs @@ -33,6 +33,12 @@ use crate::render::error::report_unimplemented; /// Shared "not yet implemented" exit path for every subcommand stub. /// Honors `--json` via [`crate::render::error::report_unimplemented`]. +// +// All Wave 2 D1–D8 PRs have wired their presenters; no command currently +// calls this helper. Kept in place (with `#[allow(dead_code)]`) so a +// future scaffold of a new stub subcommand has a ready landing pad and +// doesn't have to re-derive the JSON-aware error envelope here. +#[allow(dead_code)] pub(crate) fn not_yet_implemented(name: &str, globals: &GlobalArgs) -> i32 { report_unimplemented(name, globals) } diff --git a/crates/relayburn-cli/src/commands/run.rs b/crates/relayburn-cli/src/commands/run.rs index 84d665d3..1ca5c5a0 100644 --- a/crates/relayburn-cli/src/commands/run.rs +++ b/crates/relayburn-cli/src/commands/run.rs @@ -1,13 +1,357 @@ //! `burn run ` — wrapper that spawns an agent CLI under a //! `HarnessAdapter` and ingests its session log on exit. //! -//! Stub. Wave 2 D5 wires this up using the `HarnessAdapter` trait + -//! lazy `phf` registry from #248-b. TS source of truth: -//! `packages/cli/src/commands/run.ts` plus the per-harness adapters -//! under `packages/cli/src/harnesses/`. +//! Mirrors `packages/cli/src/commands/run.ts`. Lifecycle: +//! +//! 1. Resolve the named adapter from +//! [`relayburn_cli::harnesses::lookup`]. Unknown name → typed error +//! listing the known set. +//! 2. Build a [`relayburn_cli::harnesses::PlanCtx`] from `cwd`, +//! `passthrough`, and the merged `--tag` / `RELAYBURN_*` enrichment. +//! 3. `adapter.plan(&ctx).await` → [`relayburn_cli::harnesses::SpawnPlan`]. +//! 4. `adapter.before_spawn(&ctx, &plan).await` — claude stamps now; +//! pending-stamp adapters drop a manifest the post-exit pass resolves. +//! 5. Optional `adapter.start_watcher(&ctx, sink)` — claude returns +//! `None`; codex/opencode (D6) drain their session store while the +//! child runs. Reports flow into the same accumulator as `after_exit`. +//! 6. Spawn the child. `stdio: 'inherit'` mirrors the TS sibling. +//! 7. Wait for exit. The driver is **transparent** — the user-visible +//! exit code is the child's; relayburn's own ingest failures fall +//! through `report_error`. +//! 8. Stop the watcher (if any), run `adapter.after_exit(&ctx, &plan).await`, +//! fold both reports into a single +//! `[burn] ingest: N session(s) (+M turns)` line on stderr. +//! +//! The driver is async so adapter calls can stay async; we drive it on a +//! current-thread tokio runtime, the same pattern the D1 summary +//! presenter uses for `ingest_all`. Process spawn goes through +//! `tokio::process::Command::status().await` so the watcher can tick +//! while the child is alive — `std::process::Command::status()` would +//! synchronously block the only thread on the current-thread runtime. + +use std::collections::BTreeMap; +use std::process::{ExitStatus, Stdio}; +use std::sync::{Arc, Mutex}; + +use relayburn_cli::harnesses::{list_harness_names, lookup, HarnessAdapter, PlanCtx}; +use relayburn_cli::util::time::iso_from_system_time; +use relayburn_sdk::{Enrichment, IngestReport, ReportSink}; +use tokio::process::Command as TokioCommand; + +use crate::cli::{GlobalArgs, RunArgs}; +use crate::render::error::report_error; + +/// Spawner-owned tagging contract. Mirrors `SPAWN_ENV_TAG_KEYS` in +/// `packages/cli/src/spawn-tags.ts` byte-for-byte. Keep this in lockstep +/// with the TS sibling — orchestrators thread the same env vars across. +const SPAWN_ENV_TAG_KEYS: &[(&str, &str)] = &[ + ("RELAYBURN_WORKFLOW_ID", "workflowId"), + ("RELAYBURN_STEP_ID", "stepId"), + ("RELAYBURN_AGENT_ID", "agentId"), + ("RELAYBURN_PARENT_AGENT_ID", "parentAgentId"), + ("RELAYBURN_PERSONA", "persona"), + ("RELAYBURN_TIER", "tier"), +]; + +const RUN_HELP_PREFIX: &str = "burn run — spawn an agent harness with attribution\n\n\ +Usage:\n burn run [--tag k=v ...] [-- ]\n\n"; + +const RUN_HELP_EXAMPLES: &str = "\nExamples:\n \ +burn run claude --tag workflow=refactor -- --resume\n \ +burn run codex --tag workflow=refactor\n \ +burn run opencode --tag workflow=refactor\n"; + +pub fn run(globals: &GlobalArgs, args: RunArgs) -> i32 { + match run_inner(globals, args) { + Ok(code) => code, + Err(err) => report_error(&err, globals), + } +} + +fn run_inner(globals: &GlobalArgs, args: RunArgs) -> anyhow::Result { + // No harness positional → print help + exit 2 (TS sibling does the + // same; clap won't trigger this for `burn run --help` because clap's + // built-in help short-circuits the dispatch entirely with exit 0). + let harness_name = match args.harness.as_deref() { + Some(name) if !name.is_empty() => name.to_string(), + _ => { + print_run_help(); + return Ok(2); + } + }; + + let adapter = match lookup(&harness_name) { + Some(a) => a, + None => { + let known = list_harness_names().join(", "); + return Err(anyhow::anyhow!( + "unknown harness \"{harness_name}\". Known: {known}" + )); + } + }; + + let tags = build_enrichment(&args.tag)?; + + // `--ledger-path` is honored by setting RELAYBURN_HOME for the rest + // of this process. The adapter's `before_spawn`/`after_exit` open + // their own `Ledger` via env-var fallback, and the spawned child + // inherits the same value. Mirrors how summary.rs threads + // `globals.ledger_path` into `LedgerOpenOptions::with_home`, but + // works through env so adapter calls see it. + if let Some(p) = globals.ledger_path.as_deref() { + std::env::set_var("RELAYBURN_HOME", p); + } + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + rt.block_on(drive(globals, adapter, args.passthrough, tags)) +} + +fn print_run_help() { + let mut s = String::new(); + s.push_str(RUN_HELP_PREFIX); + s.push_str("Known harnesses: "); + s.push_str(&list_harness_names().join(", ")); + s.push('\n'); + s.push_str(RUN_HELP_EXAMPLES); + print!("{s}"); +} + +/// Async core. Owns the plan → before_spawn → spawn → after_exit +/// sequence and aggregates ingest reports. +async fn drive( + _globals: &GlobalArgs, + adapter: &'static dyn HarnessAdapter, + passthrough: Vec, + user_tags: Enrichment, +) -> anyhow::Result { + // Merge env-derived defaults with explicit `--tag` flags. CLI flags + // win on key collision. + let mut tags: Enrichment = read_spawn_env_tags(); + for (k, v) in user_tags { + tags.insert(k, v); + } + tags.insert("harness".to_string(), adapter.name().to_string()); + tags.insert("burnSpawn".to_string(), "1".to_string()); + let spawn_start_ts = std::time::SystemTime::now(); + tags.insert("burnSpawnTs".to_string(), iso_from_system_time(spawn_start_ts)); + + let cwd = std::env::current_dir()?; + let ctx = PlanCtx { + cwd, + passthrough, + tags: tags.clone(), + spawn_start_ts, + }; + + let plan = adapter.plan(&ctx).await?; + adapter.before_spawn(&ctx, &plan).await?; + + // Watcher accumulator: every tick adds to the running totals; we + // aggregate after_exit's report on top. The TS sibling does the same. + let totals = Arc::new(Mutex::new(IngestReport::default())); + let totals_for_sink = totals.clone(); + let on_report: ReportSink = + Arc::new(move |report: &IngestReport| { + if let Ok(mut t) = totals_for_sink.lock() { + t.scanned_sessions += report.scanned_sessions; + t.ingested_sessions += report.ingested_sessions; + t.appended_turns += report.appended_turns; + } + }); + + let watcher = adapter.start_watcher(&ctx, on_report); + if watcher.is_some() { + eprintln!("[burn] {}: ingest watcher ready", adapter.name()); + } + eprintln!("[burn] {}: starting {}", adapter.name(), plan.binary); + + // Spawn the child. inherits stdio so the user-facing harness UI + // stays interactive. Layer plan.env_overrides on top of the parent + // env, plus re-export the merged tag bag so transitive `burn …` + // invocations inside the child see the same context. + // + // Use `tokio::process::Command::status().await` (not + // `std::process::Command::status()`): the driver runs on a + // current-thread tokio runtime so a synchronous `status()` call + // would block the only thread, starving any watcher ticks scheduled + // on the same runtime. The async variant yields between tokio + // primitives so periodic watcher work can land while the child + // lives. + let mut cmd = TokioCommand::new(&plan.binary); + cmd.args(&plan.args); + cmd.stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + for (k, v) in spawn_tag_env_overrides(&tags) { + cmd.env(k, v); + } + for (k, v) in &plan.env_overrides { + cmd.env(k, v); + } + + // First tick fires immediately so a fast-finishing child has at + // least one chance to drain new sessions before exit. This mirrors + // `void watcher.tick()` in run.ts. We swallow tick errors on + // purpose — the watch loop logs internally and the after_exit pass + // is the source-of-truth fallback. + if let Some(w) = &watcher { + w.tick().await; + } + + // Capture the spawn outcome up front so cleanup ALWAYS runs: + // `before_spawn` may have written a stamp / pending manifest that + // `after_exit` needs to reconcile, and the watcher may have + // accumulated reports during its first tick. Returning early on + // spawn failure (the previous shape) skipped both. The TS sibling + // runs finalization regardless of spawn success; this matches that. + let spawn_outcome: SpawnOutcome = match cmd.status().await { + Ok(status) => SpawnOutcome::Exited(status), + Err(err) => { + eprintln!("[burn] failed to spawn {}: {err}", plan.binary); + SpawnOutcome::SpawnFailed + } + }; + + if let Some(w) = &watcher { + w.stop().await; + } + + // `after_exit` may itself fail (stamp resolve, ledger I/O); fold + // that error into the summary line rather than short-circuiting, + // so the user always gets the `[burn] ingest: …` line. + match adapter.after_exit(&ctx, &plan).await { + Ok(final_report) => { + let mut t = totals.lock().unwrap(); + t.scanned_sessions += final_report.scanned_sessions; + t.ingested_sessions += final_report.ingested_sessions; + t.appended_turns += final_report.appended_turns; + } + Err(err) => { + eprintln!("[burn] {} after_exit failed: {err}", adapter.name()); + } + } + let totals = totals.lock().unwrap().clone(); + let session_word = if totals.ingested_sessions == 1 { + "session" + } else { + "sessions" + }; + let turn_word = if totals.appended_turns == 1 { + "turn" + } else { + "turns" + }; + eprintln!( + "[burn] {} ingest: {} {} (+{} {})", + adapter.name(), + totals.ingested_sessions, + session_word, + totals.appended_turns, + turn_word, + ); + + // Match the TS sibling: 127 for spawn failure (POSIX "command not + // found"-ish), otherwise propagate the child's exit code (0 if it + // exited via signal, mirroring `ExitStatus::code().unwrap_or(0)`). + Ok(match spawn_outcome { + SpawnOutcome::Exited(status) => status.code().unwrap_or(0), + SpawnOutcome::SpawnFailed => 127, + }) +} + +/// Captured spawn result. The driver finalizes (stops the watcher, runs +/// `after_exit`, emits the summary line) regardless of which arm fired, +/// then maps to a process exit code at the very end. +enum SpawnOutcome { + Exited(ExitStatus), + SpawnFailed, +} + +/// Parse `--tag k=v` repetitions into an [`Enrichment`]. Mirrors the +/// TS sibling's `--tag` parser shape — bad input throws a typed error +/// rather than silently dropping the entry. +fn build_enrichment(tags: &[String]) -> anyhow::Result { + let mut out: Enrichment = BTreeMap::new(); + for raw in tags { + let (k, v) = raw + .split_once('=') + .ok_or_else(|| anyhow::anyhow!("--tag expects k=v, got \"{raw}\""))?; + if k.is_empty() { + return Err(anyhow::anyhow!("--tag key must be non-empty (got \"{raw}\")")); + } + out.insert(k.to_string(), v.to_string()); + } + Ok(out) +} + +/// Read `RELAYBURN_*` env vars into an enrichment bag. Mirrors +/// `readSpawnEnvTags` in `spawn-tags.ts`. +fn read_spawn_env_tags() -> Enrichment { + let mut out: Enrichment = BTreeMap::new(); + for (env, tag) in SPAWN_ENV_TAG_KEYS { + if let Ok(v) = std::env::var(env) { + if !v.is_empty() { + out.insert((*tag).to_string(), v); + } + } + } + out +} + +/// Inverse of `read_spawn_env_tags`: re-export the merged tag bag as +/// `RELAYBURN_*` env so the spawned harness's transitive `burn …` +/// invocations inherit the same context. +fn spawn_tag_env_overrides(final_tags: &Enrichment) -> Vec<(String, String)> { + let mut out = Vec::new(); + for (env, tag) in SPAWN_ENV_TAG_KEYS { + if let Some(v) = final_tags.get(*tag) { + if !v.is_empty() { + out.push(((*env).to_string(), v.clone())); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_enrichment_parses_kv_pairs() { + let got = build_enrichment(&[ + "workflow=refactor".into(), + "agent=alpha".into(), + ]) + .unwrap(); + assert_eq!(got.get("workflow").map(String::as_str), Some("refactor")); + assert_eq!(got.get("agent").map(String::as_str), Some("alpha")); + } + + #[test] + fn build_enrichment_rejects_missing_eq() { + let err = build_enrichment(&["workflow".into()]).unwrap_err(); + assert!(format!("{err}").contains("--tag expects k=v")); + } -use crate::cli::GlobalArgs; + #[test] + fn build_enrichment_rejects_empty_key() { + let err = build_enrichment(&["=missing-key".into()]).unwrap_err(); + assert!(format!("{err}").contains("--tag key must be non-empty")); + } -pub fn run(globals: &GlobalArgs) -> i32 { - super::not_yet_implemented("run", globals) + #[test] + fn spawn_tag_env_overrides_re_exports_known_keys() { + let mut tags: Enrichment = BTreeMap::new(); + tags.insert("workflowId".into(), "wf-1".into()); + tags.insert("agentId".into(), "agent-x".into()); + tags.insert("burnSpawn".into(), "1".into()); // not in keys → dropped + let env = spawn_tag_env_overrides(&tags); + let map: BTreeMap<_, _> = env.into_iter().collect(); + assert_eq!(map.get("RELAYBURN_WORKFLOW_ID").map(String::as_str), Some("wf-1")); + assert_eq!(map.get("RELAYBURN_AGENT_ID").map(String::as_str), Some("agent-x")); + assert!(!map.contains_key("RELAYBURN_BURN_SPAWN")); + } } diff --git a/crates/relayburn-cli/src/harnesses/claude.rs b/crates/relayburn-cli/src/harnesses/claude.rs new file mode 100644 index 00000000..edafb61c --- /dev/null +++ b/crates/relayburn-cli/src/harnesses/claude.rs @@ -0,0 +1,242 @@ +//! Claude harness adapter — Rust port of +//! `packages/cli/src/harnesses/claude.ts`. +//! +//! Claude is the simplest of the three production harnesses and serves +//! as the canonical "eager / unit-struct adapter" example for the +//! [`super::registry::EAGER_ADAPTERS`] tier: +//! +//! - **`plan`** mints a fresh session id (UUID v4) and injects it via +//! `--session-id`, plus exports `RELAYBURN_SESSION_ID` so any nested +//! `burn …` invocation inside the child sees the same id. +//! - **`before_spawn`** stamps the session up front with the user's +//! enrichment tags. The session id is final from the moment the child +//! spawns, so we don't need a pending-stamp manifest like +//! codex/opencode. +//! - **`start_watcher`** is left at the default `None`. Claude writes +//! exactly one JSONL file per session at `~/.claude/projects//.jsonl`, +//! and the post-exit fast-path +//! ([`relayburn_sdk::ingest_claude_session`]) reads it directly. There +//! is nothing for a watch loop to drain. +//! - **`after_exit`** runs the per-session fast-path against the known +//! sessionId. +//! +//! The adapter itself is a zero-sized unit struct; the static +//! [`CLAUDE_ADAPTER`] handed to [`super::registry::EAGER_ADAPTERS`] is a +//! compile-time `&'static dyn HarnessAdapter` reference, so harness +//! lookup costs nothing at startup. + +use std::path::PathBuf; + +use async_trait::async_trait; +use relayburn_sdk::{ + ingest_claude_session, Enrichment, IngestReport, Ledger, LedgerOpenOptions, RawIngestOptions, + Stamp, StampSelector, +}; + +use super::{HarnessAdapter, PlanCtx, SpawnPlan}; +use crate::util::time::iso_now; + +/// Public unit-struct adapter for `claude`. Held as `&'static +/// CLAUDE_ADAPTER` in the eager `phf::Map` registry — the value `&CLAUDE_ADAPTER` +/// is a const expression so it satisfies `phf_map!`'s value bound directly. +pub struct ClaudeAdapter; + +/// Static singleton handed to the eager registry. Lifetime: `'static`, +/// stateless; cloning is unnecessary. +pub static CLAUDE_ADAPTER: ClaudeAdapter = ClaudeAdapter; + +/// Default Claude session-store root: `$HOME/.claude/projects`. +fn claude_projects_root() -> PathBuf { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + home.join(".claude").join("projects") +} + +/// Mint a v4 UUID using the current SystemTime + process id as a weak +/// entropy source. The harness only needs a stable identifier the +/// child claude binary will adopt; the SDK validates the shape via +/// [`relayburn_sdk::is_valid_session_id`] when it stamps. We avoid +/// pulling in the `uuid` crate just for this one call site — the +/// formatting matches RFC 4122 (variant + version bits set correctly). +fn mint_session_id() -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let pid = std::process::id(); + + // Two 64-bit hash mixes derived from time + pid. This is "weak + // randomness" by cryptographic standards but more than adequate + // for picking an unused session id; `claude --session-id` accepts + // any UUID-shaped string. + let mut h1 = DefaultHasher::new(); + now.hash(&mut h1); + pid.hash(&mut h1); + let lo = h1.finish(); + + let mut h2 = DefaultHasher::new(); + lo.hash(&mut h2); + now.wrapping_mul(0x9e37_79b9_7f4a_7c15).hash(&mut h2); + let hi = h2.finish(); + + let bytes: [u8; 16] = { + let mut b = [0u8; 16]; + b[..8].copy_from_slice(&lo.to_le_bytes()); + b[8..].copy_from_slice(&hi.to_le_bytes()); + // RFC 4122 §4.4: set version = 4 (random) and variant = 10xx. + b[6] = (b[6] & 0x0F) | 0x40; + b[8] = (b[8] & 0x3F) | 0x80; + b + }; + + format!( + "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + bytes[0], bytes[1], bytes[2], bytes[3], + bytes[4], bytes[5], + bytes[6], bytes[7], + bytes[8], bytes[9], + bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15], + ) +} + +#[async_trait] +impl HarnessAdapter for ClaudeAdapter { + fn name(&self) -> &'static str { + "claude" + } + + fn session_root(&self) -> PathBuf { + claude_projects_root() + } + + async fn plan(&self, ctx: &PlanCtx) -> anyhow::Result { + let session_id = mint_session_id(); + let mut args = vec!["--session-id".to_string(), session_id.clone()]; + args.extend(ctx.passthrough.iter().cloned()); + Ok(SpawnPlan { + binary: "claude".to_string(), + args, + env_overrides: vec![("RELAYBURN_SESSION_ID".to_string(), session_id.clone())], + session_id: Some(session_id), + }) + } + + async fn before_spawn(&self, ctx: &PlanCtx, plan: &SpawnPlan) -> anyhow::Result<()> { + let session_id = plan + .session_id + .as_ref() + .ok_or_else(|| anyhow::anyhow!("claude adapter: plan must include sessionId"))?; + write_session_stamp(session_id, &ctx.tags)?; + eprintln!("[burn] session-id={session_id}"); + Ok(()) + } + + async fn after_exit(&self, ctx: &PlanCtx, plan: &SpawnPlan) -> anyhow::Result { + let session_id = plan + .session_id + .as_ref() + .ok_or_else(|| anyhow::anyhow!("claude adapter: plan must include sessionId"))?; + // Open a ledger handle scoped to the resolved RELAYBURN_HOME and + // run the per-session fast-path. The SDK encodes cwd → flattened + // dir name internally and persists a cursor at EOF so the next + // sweep skips the file. + let mut handle = Ledger::open(LedgerOpenOptions::default())?; + let cwd_str = ctx.cwd.to_string_lossy().into_owned(); + let opts = RawIngestOptions::default(); + ingest_claude_session(handle.raw_mut(), &cwd_str, session_id, &opts).await + } +} + +/// Append a session stamp via the SDK ledger. Mirrors the TS sibling's +/// `await stamp({ sessionId }, ctx.tags)` call, but goes through the +/// Rust SDK's typed `Stamp::new` + `Ledger::append_stamp` pair. +fn write_session_stamp(session_id: &str, enrichment: &Enrichment) -> anyhow::Result<()> { + let mut handle = Ledger::open(LedgerOpenOptions::default())?; + let selector = StampSelector { + session_id: Some(session_id.to_string()), + ..Default::default() + }; + let stamp = Stamp::new(iso_now(), selector, enrichment.clone())?; + handle.raw_mut().append_stamp(&stamp)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + #[tokio::test] + async fn plan_mints_session_id_and_prepends_session_id_arg() { + let ctx = PlanCtx { + cwd: PathBuf::from("/tmp"), + passthrough: vec!["--resume".to_string(), "abc".to_string()], + tags: Enrichment::new(), + spawn_start_ts: std::time::SystemTime::now(), + }; + let plan = CLAUDE_ADAPTER.plan(&ctx).await.unwrap(); + assert_eq!(plan.binary, "claude"); + assert_eq!(plan.args[0], "--session-id"); + let sid = plan.args.get(1).cloned().unwrap_or_default(); + assert!(plan.session_id.as_deref() == Some(sid.as_str())); + assert_eq!(&plan.args[2..], &["--resume".to_string(), "abc".to_string()]); + // Env override carries the same id so a nested `burn …` inherits it. + assert!(plan + .env_overrides + .iter() + .any(|(k, v)| k == "RELAYBURN_SESSION_ID" && v == &sid)); + } + + #[test] + fn name_is_claude_lowercase() { + assert_eq!(CLAUDE_ADAPTER.name(), "claude"); + } + + #[test] + fn session_root_lands_under_dot_claude_projects() { + let root = CLAUDE_ADAPTER.session_root(); + let s = root.to_string_lossy(); + assert!( + s.ends_with(".claude/projects") || s.ends_with(".claude\\projects"), + "expected session_root under .claude/projects, got {s}" + ); + } + + #[test] + fn mint_session_id_round_trips_a_v4_uuid_shape() { + let s = mint_session_id(); + // 8-4-4-4-12 hex. + let parts: Vec<&str> = s.split('-').collect(); + assert_eq!(parts.len(), 5); + assert_eq!(parts[0].len(), 8); + assert_eq!(parts[1].len(), 4); + assert_eq!(parts[2].len(), 4); + assert_eq!(parts[3].len(), 4); + assert_eq!(parts[4].len(), 12); + // Version nibble = 4. + assert_eq!(&parts[2][..1], "4", "version nibble should be 4 in {s}"); + // Variant bits: top two bits of the first nibble of `parts[3]` are 10. + let variant_nibble = u8::from_str_radix(&parts[3][..1], 16).unwrap(); + assert_eq!(variant_nibble & 0xC, 0x8, "variant nibble should be 10xx"); + } + + #[test] + fn iso_now_is_zulu_iso8601() { + let s = iso_now(); + // Coarse shape: YYYY-MM-DDTHH:MM:SS.mmmZ + assert_eq!(s.len(), "1970-01-01T00:00:00.000Z".len()); + assert!(s.ends_with('Z')); + assert_eq!(&s[4..5], "-"); + assert_eq!(&s[7..8], "-"); + assert_eq!(&s[10..11], "T"); + assert_eq!(&s[13..14], ":"); + assert_eq!(&s[16..17], ":"); + assert_eq!(&s[19..20], "."); + } +} diff --git a/crates/relayburn-cli/src/harnesses/mod.rs b/crates/relayburn-cli/src/harnesses/mod.rs index a3f741fe..9559bfff 100644 --- a/crates/relayburn-cli/src/harnesses/mod.rs +++ b/crates/relayburn-cli/src/harnesses/mod.rs @@ -44,6 +44,7 @@ use std::path::PathBuf; use async_trait::async_trait; use relayburn_sdk::{Enrichment, IngestReport, WatchController}; +pub mod claude; pub mod codex; pub mod pending_stamp; pub mod registry; diff --git a/crates/relayburn-cli/src/harnesses/registry.rs b/crates/relayburn-cli/src/harnesses/registry.rs index 373c0050..ac03b694 100644 --- a/crates/relayburn-cli/src/harnesses/registry.rs +++ b/crates/relayburn-cli/src/harnesses/registry.rs @@ -54,21 +54,19 @@ use std::sync::LazyLock; use phf::phf_map; -use super::{codex, HarnessAdapter}; +use super::{claude, codex, HarnessAdapter}; /// Compile-time perfect-hash map from harness name to a `&'static dyn /// HarnessAdapter`. Holds eager / unit-struct adapters whose value is a -/// const expression (`&SOMETHING_STATIC`). Empty on this branch — -/// populated by the claude Wave 2 PR (#248-d). +/// const expression (`&SOMETHING_STATIC`). Wave 2 D5 (#248-d) registers +/// the claude adapter here. /// /// **Do not register pending-stamp adapters here.** /// `pending_stamp::adapter_static` returns a value produced by /// `Box::leak` at runtime; that is not a const expression and cannot /// appear inside `phf_map!`. Those adapters go in [`RUNTIME_ADAPTERS`]. static EAGER_ADAPTERS: phf::Map<&'static str, &'static dyn HarnessAdapter> = phf_map! { - // Wave 2 PRs will populate these slots: - // - // "claude" => &claude::CLAUDE_ADAPTER, // #248-d + "claude" => &claude::CLAUDE_ADAPTER, }; /// Runtime-constructed adapters. The closure runs once on first @@ -237,16 +235,13 @@ mod tests { /// reach for `RUNTIME_ADAPTERS.keys()` here later. #[test] fn list_harness_names_is_deterministic() { - /// Snapshot of the expected harness ordering. Empty on this - /// branch; Wave 2 PRs will append their entries: - /// `&["claude", "codex", "opencode"]` post-#248-d/e/f. + /// Snapshot of the expected harness ordering. Wave 2 D5 (#248-d) + /// landed claude in `EAGER_ADAPTERS`; codex (#248-e) and + /// opencode (#248-f) will append their runtime entries here. const EXPECTED_HARNESS_NAMES: &[&str] = &[ - // Wave 2 PRs append their names here in the same order they - // appear in EAGER_ADAPTERS / RUNTIME_ADAPTER_NAMES: - // - // "claude", // #248-d (eager) - "codex", // #248-e (runtime) - // "opencode", // #248-f (runtime) + "claude", // #248-d (eager) + "codex", // #248-e (runtime) + // "opencode", // #248-f (runtime) ]; let names = list_harness_names(); diff --git a/crates/relayburn-cli/src/lib.rs b/crates/relayburn-cli/src/lib.rs index fce58564..b06c3046 100644 --- a/crates/relayburn-cli/src/lib.rs +++ b/crates/relayburn-cli/src/lib.rs @@ -16,3 +16,4 @@ //! disturbing `main.rs`. pub mod harnesses; +pub mod util; diff --git a/crates/relayburn-cli/src/main.rs b/crates/relayburn-cli/src/main.rs index a6385420..4b38b334 100644 --- a/crates/relayburn-cli/src/main.rs +++ b/crates/relayburn-cli/src/main.rs @@ -35,7 +35,7 @@ fn dispatch(args: Args) -> i32 { Command::Hotspots(sub) => commands::hotspots::run(&globals, sub), Command::Overhead(args) => commands::overhead::run(&globals, args), Command::Compare(args) => commands::compare::run(&globals, args), - Command::Run => commands::run::run(&globals), + Command::Run(args) => commands::run::run(&globals, args), Command::State(args) => commands::state::run(&globals, args), Command::Ingest(args) => commands::ingest::run(&globals, args), Command::McpServer(args) => commands::mcp_server::run(&globals, args), diff --git a/crates/relayburn-cli/src/util/mod.rs b/crates/relayburn-cli/src/util/mod.rs new file mode 100644 index 00000000..8e511d8d --- /dev/null +++ b/crates/relayburn-cli/src/util/mod.rs @@ -0,0 +1,14 @@ +//! Crate-internal utility modules shared between the binary and the +//! library tree (`harnesses/*`). +//! +//! Lives in `lib.rs` so both the `burn` binary (`commands/run.rs`) and +//! the library tree (`harnesses/claude.rs`) can reach the same helpers. +//! Keep this surface minimal — anything that can live in a single +//! call-site module should live there instead. + +// `pub` (not `pub(crate)`) because `commands/run.rs` lives in the binary +// crate tree and reaches the helpers through the library crate's public +// surface (`relayburn_cli::util::time::*`). The library crate is consumed +// only by the in-repo binary + tests, so `pub` here is still effectively +// crate-private from a published-API standpoint. +pub mod time; diff --git a/crates/relayburn-cli/src/util/time.rs b/crates/relayburn-cli/src/util/time.rs new file mode 100644 index 00000000..224bb7c2 --- /dev/null +++ b/crates/relayburn-cli/src/util/time.rs @@ -0,0 +1,105 @@ +//! Tiny time helpers shared by the harness adapter ([`super::super::harnesses::claude`]) +//! and the `burn run` driver ([`super::super::commands::run`]). +//! +//! Both call sites need the same two operations: +//! +//! - [`iso_now`] — current wall-clock in `YYYY-MM-DDTHH:MM:SS.mmmZ` format, +//! matching `new Date().toISOString()` in the TS sibling. +//! - [`civil_from_days`] — Howard Hinnant's days-since-epoch → (Y, M, D) +//! conversion. Pulled out so we don't pay a `chrono` / `time` dependency +//! for two tiny call sites. +//! +//! Until D5 / D6 these were duplicated across `harnesses/claude.rs` and +//! `commands/run.rs` with a `keep them in sync` comment. CodeRabbit caught +//! the duplication during PR #318 review; this module is the resolution. + +/// Build an ISO-8601 UTC timestamp suitable for `Stamp::ts` / the +/// `burnSpawnTs` enrichment tag. Mirrors `new Date().toISOString()` in +/// the TS sibling. +pub fn iso_now() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let total_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + iso_from_ms(total_ms) +} + +/// Format an absolute `total_ms` (millis since the Unix epoch) as an +/// ISO-8601 UTC string. Split out so callers that already captured a +/// `SystemTime` (e.g. the driver's `spawn_start_ts`) can format it without +/// re-reading the clock. +pub fn iso_from_system_time(t: std::time::SystemTime) -> String { + use std::time::UNIX_EPOCH; + let total_ms = t + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + iso_from_ms(total_ms) +} + +fn iso_from_ms(total_ms: i64) -> String { + let total_secs = total_ms.div_euclid(1000); + let ms = total_ms.rem_euclid(1000) as u32; + // Civil-date conversion (Howard Hinnant's algorithm). Sufficient for + // the y2038-and-beyond range we care about. + let z = total_secs.div_euclid(86_400); + let secs_of_day = total_secs.rem_euclid(86_400) as u32; + let (y, m, d) = civil_from_days(z); + let hh = secs_of_day / 3600; + let mm = (secs_of_day % 3600) / 60; + let ss = secs_of_day % 60; + format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}.{ms:03}Z") +} + +/// Days-since-epoch → (year, month, day). Hinnant 2014. +/// +/// `z` is days since 1970-01-01; negative values are pre-epoch. Returns +/// the proleptic Gregorian (year, 1-12 month, 1-31 day). +pub fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (y + (if m <= 2 { 1 } else { 0 }), m, d) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn iso_now_is_zulu_iso8601_shape() { + let s = iso_now(); + assert_eq!(s.len(), "1970-01-01T00:00:00.000Z".len()); + assert!(s.ends_with('Z')); + assert_eq!(&s[4..5], "-"); + assert_eq!(&s[7..8], "-"); + assert_eq!(&s[10..11], "T"); + assert_eq!(&s[13..14], ":"); + assert_eq!(&s[16..17], ":"); + assert_eq!(&s[19..20], "."); + } + + #[test] + fn civil_from_days_round_trips_known_dates() { + // 1970-01-01 = day 0 + assert_eq!(civil_from_days(0), (1970, 1, 1)); + // 2000-01-01 = day 10957 + assert_eq!(civil_from_days(10_957), (2000, 1, 1)); + // 2024-02-29 (leap) = day 19782 + assert_eq!(civil_from_days(19_782), (2024, 2, 29)); + } + + #[test] + fn iso_from_system_time_uses_unix_epoch() { + use std::time::{Duration, UNIX_EPOCH}; + let t = UNIX_EPOCH + Duration::from_millis(0); + assert_eq!(iso_from_system_time(t), "1970-01-01T00:00:00.000Z"); + } +} diff --git a/crates/relayburn-cli/tests/smoke.rs b/crates/relayburn-cli/tests/smoke.rs index 90dd191a..c3bcaa61 100644 --- a/crates/relayburn-cli/tests/smoke.rs +++ b/crates/relayburn-cli/tests/smoke.rs @@ -36,13 +36,14 @@ const SUBCOMMANDS: &[&str] = &[ /// Subcommands that still print "not yet implemented" when invoked /// without args. Wave 2 D1 wired up `summary` and `hotspots`, D2 wired -/// up `overhead`, D3 wired up `compare`, D4 wired up `state`, and D8 -/// wired up `ingest` + `mcp-server` as real presenters, so they're -/// excluded from the stub-mode tripwire below. The remaining entries -/// are owned by sibling Wave 2 PRs (D5 owns `run`). -const UNIMPLEMENTED_SUBCOMMANDS: &[&str] = &[ - "run", -]; +/// up `overhead`, D3 wired up `compare`, D4 wired up `state`, D5 wired +/// up `run`, and D8 wired up `ingest` + `mcp-server` as real +/// presenters — every subcommand is now wired, so this list is empty +/// and `each_stub_exits_one_with_not_yet_implemented_message` becomes +/// a no-op iteration. The constant is retained so a future scaffold +/// (a new stub subcommand) has somewhere to land without re-introducing +/// the iteration helper. +const UNIMPLEMENTED_SUBCOMMANDS: &[&str] = &[]; /// Helper: build a `Command` driving the locally-built `burn` binary. fn burn() -> Command { @@ -130,16 +131,18 @@ fn compare_command_rejects_missing_models() { } #[test] -fn json_mode_emits_error_envelope_on_unimplemented() { +fn json_mode_emits_error_envelope_on_argument_failure() { // The `--json` global flips error reporting from a stderr line to // a `{"error": …}` JSON envelope on stdout. Cover the toggle so - // Wave 2 commands inherit a consistent JSON-mode error shape. - // Use a still-stubbed command (`run`) so the assertion remains - // meaningful as Wave 2 PRs replace stubs with real presenters. + // every wired Wave 2 command inherits a consistent JSON-mode error + // shape. With every subcommand now wired, we pivot from the old + // "still-stubbed" target to a wired command's argument-validation + // failure (`burn compare` with no positional models) — same code + // path through `report_error`, same envelope shape. let output = burn() - .args(["--json", "run"]) + .args(["--json", "compare"]) .assert() - .code(1) + .code(2) .get_output() .clone(); let stdout = String::from_utf8(output.stdout).expect("stdout should be valid UTF-8"); @@ -148,8 +151,46 @@ fn json_mode_emits_error_envelope_on_unimplemented() { "expected JSON-mode envelope on stdout; got:\n{stdout}", ); assert!( - stdout.contains("not yet implemented"), - "expected JSON-mode envelope to carry the not-yet-implemented message; got:\n{stdout}", + stdout.contains("needs at least 2 models"), + "expected JSON-mode envelope to carry the compare error message; got:\n{stdout}", + ); +} + +#[test] +fn run_command_lists_known_harnesses_when_invoked_without_args() { + // `burn run` (Wave 2 D5) prints help + exits 2 when no harness + // positional is supplied — the same shape as the TS sibling. + let output = burn().arg("run").assert().code(2).get_output().clone(); + let stdout = String::from_utf8(output.stdout).expect("stdout should be valid UTF-8"); + assert!( + stdout.contains("Known harnesses:"), + "expected `burn run` to list known harnesses; got:\n{stdout}", + ); + assert!( + stdout.contains("claude"), + "expected `burn run` help to mention claude; got:\n{stdout}", + ); +} + +#[test] +fn run_command_rejects_unknown_harness() { + // Unknown harness must exit non-zero with a typed error mentioning + // both the bogus name and the known set. Driver maps this through + // `report_error`, which lands at exit code 2 in human mode. + let output = burn() + .args(["run", "definitely-not-a-real-harness"]) + .assert() + .code(2) + .get_output() + .clone(); + let stderr = String::from_utf8(output.stderr).expect("stderr should be valid UTF-8"); + assert!( + stderr.contains("definitely-not-a-real-harness"), + "expected stderr to echo the unknown harness name; got:\n{stderr}", + ); + assert!( + stderr.contains("claude"), + "expected stderr to list claude as a known harness; got:\n{stderr}", ); } diff --git a/crates/relayburn-sdk/src/lib.rs b/crates/relayburn-sdk/src/lib.rs index 0ec7b5f5..e93506f3 100644 --- a/crates/relayburn-sdk/src/lib.rs +++ b/crates/relayburn-sdk/src/lib.rs @@ -109,9 +109,9 @@ pub use crate::analyze::{ }; pub use crate::ingest::{ - cleanup_stale_pending_stamps, ingest_all, ingest_codex_sessions, ingest_opencode_sessions, - run_ingest_tick, start_watch_loop, write_pending_stamp, ErrorSink, IngestFn, - IngestOptions as RawIngestOptions, IngestReport, IngestRoots, PendingStamp, + cleanup_stale_pending_stamps, ingest_all, ingest_claude_session, ingest_codex_sessions, + ingest_opencode_sessions, run_ingest_tick, start_watch_loop, write_pending_stamp, ErrorSink, + IngestFn, IngestOptions as RawIngestOptions, IngestReport, IngestRoots, PendingStamp, PendingStampHarness, PendingStampWriteResult, ReportSink, StartWatchLoopOptions, WatchController, WriteOptions as PendingStampWriteOptions, };