From 6efc89fb2de9f788d7de4513080c27d31c5067f3 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 6 May 2026 08:14:20 -0400 Subject: [PATCH] relayburn-cli: HarnessAdapter trait + registry + pending-stamp factory (#248 part b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 1 D4 of the Rust port (epic #240). Lands the harness substrate the Wave 2 fan-out PRs (#248-d/e/f for claude/codex/opencode) plug into. - `HarnessAdapter` trait (`crates/relayburn-cli/src/harnesses/mod.rs`) mirrors the TS shape from `packages/cli/src/harnesses/types.ts`: `name`, `session_root`, `plan`, `before_spawn`, `start_watcher` (optional), `after_exit`. Async methods desugar through `async-trait` so adapter impls stay readable. - Lazy compile-time `phf::Map` registry (`crates/relayburn-cli/src/harnesses/registry.rs`) with `lookup` / `list_harness_names` parallel to the TS registry. Slots are reserved but empty with comments naming the Wave 2 PR that populates each one. - `pending_stamp::adapter` factory (`crates/relayburn-cli/src/harnesses/pending_stamp.rs`) mirrors the TS `createPendingStampAdapter` shape: codex + opencode adapters will construct through it so the manifest writer + watch-loop wiring lives in one place. - `relayburn-sdk` re-exports the watch-loop + pending-stamp surface (`start_watch_loop`, `WatchController`, `write_pending_stamp`, `PendingStampHarness`, …) so the CLI doesn't have to reach into private SDK modules. - New `[lib]` target in `relayburn-cli` so the harness substrate can be unit-tested with `cargo test -p relayburn-cli` without disturbing `main.rs` (owned by the parallel #248-a CLI scaffold PR). - 9 unit tests cover the trait round-trip, the static-fake registry lookup, the pending-stamp factory for both codex + opencode, the `should_panic` on unknown harness names, and the empty-registry invariant on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + Cargo.lock | 15 + crates/relayburn-cli/Cargo.toml | 30 ++ crates/relayburn-cli/src/harnesses/mod.rs | 264 +++++++++++++++ .../src/harnesses/pending_stamp.rs | 316 ++++++++++++++++++ .../relayburn-cli/src/harnesses/registry.rs | 152 +++++++++ crates/relayburn-cli/src/lib.rs | 18 + crates/relayburn-sdk/src/lib.rs | 6 +- 8 files changed, 800 insertions(+), 2 deletions(-) create mode 100644 crates/relayburn-cli/src/harnesses/mod.rs create mode 100644 crates/relayburn-cli/src/harnesses/pending_stamp.rs create mode 100644 crates/relayburn-cli/src/harnesses/registry.rs create mode 100644 crates/relayburn-cli/src/lib.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f180615..f0ca06b0 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): introduce the harness substrate — `HarnessAdapter` trait, lazy compile-time `phf` registry (`lookup` / `list_harness_names`), and the shared `pending_stamp::adapter` factory codex + opencode will reuse. Adapter slots in the registry are reserved but empty pending the Wave 2 PRs (#248-d/e/f). `relayburn-sdk` re-exports `start_watch_loop`, `WatchController`, `write_pending_stamp`, `PendingStampHarness`, and friends so the CLI doesn't have to reach into private SDK modules. (#248) - `relayburn-ingest` (Rust): port the per-process gap-warning state machine (`gap` module — `record_session_gap`, `emit_gap_warning`, `count_tool_call_gaps`, `reset_ingest_gap_warnings`, `set_ingest_gap_writer`) and `reingest_missing_content` (`reingest` module). Suppression mirrors the TS surface: one warning per fresh affected session, silent on steady-state, re-fires after the affected set decays back to empty. `relayburn-ledger` adds `Ledger::list_user_turn_session_ids` to power the `reingest_missing_content` skip filter alongside `list_content_session_ids`. (#278) - `relayburn-analyze` (Rust): port the behavioral-pattern detectors (`patterns` module). `detect_patterns` runs retry-loop, failure-run, cancellation-run, compaction-loss, edit-revert, OpenCode skill-recall-dup, OpenCode skill-pruning-protection, OpenCode system-prompt-tax, and edit-heavy detectors against an ordered turn stream, with optional content-sidecar / tool-result-event / user-turn enrichment. Public surface: `detect_patterns`, `DetectPatternsOptions`; per-pattern result structs are re-exported from `findings` (`RetryLoop`, `FailureRun`, `CancellationRun`, `CompactionLoss`, `EditRevertCycle`, `SkillRecallDup`, `SkillPruningProtection`, `SystemPromptTax`, `EditHeavySession`, `SessionPatternSummary`, `PatternsResult`, `PatternEventSource`). (#275) - `relayburn-analyze` (Rust): port the tool-output-bloat detector — Signal A's `BASH_MAX_OUTPUT_LENGTH` static-config check (with `~/.claude/settings.json` + `/.claude/settings.json` loader) and Signal B's cross-harness observed-bloat aggregation, plus the `WasteFinding` adapter. Public surface mirrors `@relayburn/analyze`: `BASH_MAX_OUTPUT_ENV_KEY`, `DEFAULT_BLOAT_TOKEN_THRESHOLD`, `detect_observed_bloat`, `detect_static_config_bloat`, `detect_tool_output_bloat`, `load_claude_settings`, `project_claude_settings_path`, `user_claude_settings_path`, `tool_output_bloat_to_finding`. (#271) diff --git a/Cargo.lock b/Cargo.lock index a5b79ccf..299fe48b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,17 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "bitflags" version = "2.11.1" @@ -416,7 +427,11 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "relayburn-cli" version = "0.0.0" dependencies = [ + "anyhow", + "async-trait", + "phf", "relayburn-sdk", + "tokio", ] [[package]] diff --git a/crates/relayburn-cli/Cargo.toml b/crates/relayburn-cli/Cargo.toml index aa00e6c1..5a097e55 100644 --- a/crates/relayburn-cli/Cargo.toml +++ b/crates/relayburn-cli/Cargo.toml @@ -11,6 +11,18 @@ description = "The `burn` CLI — published to crates.io. Crate name is relaybur name = "burn" path = "src/main.rs" +# A library target is added alongside the `burn` binary so the harness +# substrate (`HarnessAdapter` trait, registry, pending-stamp factory) +# under `src/harnesses/` can be unit-tested with `cargo test -p +# relayburn-cli` and so future integration tests under `tests/` can +# reach it without re-declaring the module tree. The binary path +# (`src/main.rs`) and the library path (`src/lib.rs`) live side-by-side +# — cargo treats them as two separate compile units that share the +# same package metadata. +[lib] +name = "relayburn_cli" +path = "src/lib.rs" + [dependencies] # The CLI is the canonical external embedder of the SDK — every read # verb the binary surfaces will wrap a `relayburn-sdk` call once #248 @@ -22,3 +34,21 @@ path = "src/main.rs" # (currently 0.0.1) without forcing a lockstep bump on every release. # Tighten this once the SDK ships a stable 0.x line. relayburn-sdk = { path = "../relayburn-sdk", version = "0.0" } + +# Harness substrate (#248-b): the `HarnessAdapter` trait under `harnesses/` +# uses `async fn` in trait — `async-trait` desugars to `Pin>` futures +# without forcing every adapter to spell that out. `phf` macros give us a +# perfect-hash registry that's evaluated at compile time, so harness lookup +# costs nothing at startup. `tokio::sync` is needed for the watch controller +# wiring exposed by `relayburn-sdk` (`WatchController` holds a `Mutex`). +anyhow = { workspace = true } +async-trait = "0.1" +phf = { version = "0.11", features = ["macros"] } +tokio = { workspace = true, features = ["sync"] } + +[dev-dependencies] +# Async test harness for the `harnesses` module unit tests (lookup, factory +# round-trips). Workspace `tokio` adds `macros` + `rt-multi-thread` so the +# `#[tokio::test]` attribute resolves and the spawned watch-loop ticks have +# a multi-threaded runtime to land on. +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync"] } diff --git a/crates/relayburn-cli/src/harnesses/mod.rs b/crates/relayburn-cli/src/harnesses/mod.rs new file mode 100644 index 00000000..3b1872c0 --- /dev/null +++ b/crates/relayburn-cli/src/harnesses/mod.rs @@ -0,0 +1,264 @@ +//! Harness substrate — Rust port of `packages/cli/src/harnesses/types.ts` +//! and friends. +//! +//! `burn run ` is a wrapper that spawns a coding-agent process +//! (Claude Code, Codex, OpenCode, …), babysits its session log while it +//! runs, and feeds the resulting turns into the relayburn ledger. Every +//! adapter contributes the same five-step shape: +//! +//! 1. **`plan`** — compute the spawn plan (binary + args + env). Per-harness +//! transports inject session ids or hook arguments here. +//! 2. **`before_spawn`** — fire any pre-spawn side effect: stamp now if the +//! session id is known up front (claude path), or drop a pending-stamp +//! manifest the post-spawn ingest pass will resolve (codex / opencode). +//! 3. **`start_watcher`** *(optional)* — return a [`WatchController`] that +//! drains a session-store directory while the child runs. Adapters that +//! ingest a single pre-known session file (claude) return `None` here; +//! adapters that share the pending-stamp shape (codex, opencode) wire +//! the watch loop through [`pending_stamp::adapter`]. +//! 4. **`after_exit`** — run a final ingest pass after the child exits and +//! return an [`IngestReport`] so the driver can fold it into the unified +//! `[burn] ingest: …` line. +//! 5. The driver itself owns step zero — collecting `cwd`, passthrough +//! args, and any user-provided enrichment tags into a [`PlanCtx`] — +//! and step six — joining the watcher and reporting summary stats. +//! +//! ## Where this fits +//! +//! This PR (#248 part b) is the substrate. The Wave 2 PRs (#248-d/e/f) +//! plug the three concrete adapters into [`registry`] and the +//! `burn run` driver in `commands::run` consumes them. The CLI scaffold +//! (#248 part a, sibling worktree) lands the clap entrypoint independently. +//! +//! ## Trait shape vs the TS sibling +//! +//! `HarnessAdapter` is a `Send + Sync` trait object so the registry can +//! hand out `&'static dyn HarnessAdapter` references. `async fn` in trait +//! is mediated by `async_trait::async_trait` to keep adapter impls +//! ergonomic; the desugared `Pin>` matches the +//! shape expected by the `burn run` driver, which `tokio::spawn`s the +//! result of `plan` / `after_exit` and joins them at the top level. + +use std::path::PathBuf; + +use async_trait::async_trait; +use relayburn_sdk::{Enrichment, IngestReport, WatchController}; + +pub mod pending_stamp; +pub mod registry; + +pub use registry::{list_harness_names, lookup}; + +/// Driver-side context handed to every adapter call. Mirrors the TS +/// `HarnessRunContext` shape one-to-one (`cwd`, `passthrough`, `tags`, +/// `spawnStartTs`). +/// +/// `tags` is a `BTreeMap` (re-exported from the SDK as +/// [`Enrichment`]) so insertion order doesn't matter for the on-disk +/// stamp record — the pending-stamp serializer canonicalizes ordering. +#[derive(Debug, Clone)] +pub struct PlanCtx { + /// Working directory the user invoked `burn run` from. Forwarded to + /// the spawned harness so it picks up project-local config. + pub cwd: PathBuf, + /// Argv tail after the subcommand boundary, e.g. `burn run claude -- + /// "explain this"` ⇒ `["explain this"]`. Adapters splice this into + /// their generated argv via [`SpawnPlan::args`]. + pub passthrough: Vec, + /// User-supplied enrichment that will be merged onto the resulting + /// stamp. Keys are free-form (`task`, `pr`, …); the Wave 2 driver + /// translates `--tag k=v` flags into entries here. + pub tags: Enrichment, + /// Wall-clock timestamp captured by the driver immediately before + /// `before_spawn`. Used by the pending-stamp manifest so the + /// post-exit resolver can match against session-file mtimes. + pub spawn_start_ts: std::time::SystemTime, +} + +/// Spawn plan returned by [`HarnessAdapter::plan`]. The `burn run` +/// driver owns the actual `tokio::process::Command` construction; this +/// struct is the per-adapter contribution to it. +/// +/// `session_id` is filled in by adapters that know the session id up +/// front (claude can mint one and inject it via `--session-id` so the +/// pre-spawn stamp is final from the start). Adapters that don't know +/// it ahead of time leave this `None` and rely on the pending-stamp +/// resolver to attach their enrichment to the freshly-discovered +/// session in `after_exit`. +#[derive(Debug, Clone, Default)] +pub struct SpawnPlan { + pub binary: String, + pub args: Vec, + /// Env vars to overlay on top of the parent process env when + /// spawning. Keep this tight — `tokio::process::Command::env_clear` + /// + this map is the typical pattern, though Wave 2 may relax that. + pub env_overrides: Vec<(String, String)>, + /// Session id the adapter pre-allocated, when known. See struct + /// docs for when this is `Some` vs `None`. + pub session_id: Option, +} + +impl SpawnPlan { + /// Convenience: minimal plan that just runs `binary` with `args` and + /// inherits the parent's env. Most adapters' `plan` returns this + /// shape directly. + pub fn new(binary: impl Into, args: Vec) -> Self { + Self { + binary: binary.into(), + args, + env_overrides: Vec::new(), + session_id: None, + } + } +} + +/// `HarnessAdapter` — five-method contract every harness implements. The +/// TS sibling lives at `packages/cli/src/harnesses/types.ts` and the +/// shape mirrors it; see the module docs for what each step does. +/// +/// Adapters are zero-sized (or near-zero-sized) stateless types that the +/// registry hands out as `&'static dyn HarnessAdapter`. State that lives +/// across `before_spawn` → `after_exit` rides on `PlanCtx` / `SpawnPlan`, +/// or in the pending-stamps directory on disk. +#[async_trait] +pub trait HarnessAdapter: Send + Sync { + /// Lowercase identifier — `claude`, `codex`, `opencode`, … — used as + /// the dispatch key and as the harness label in log lines. + fn name(&self) -> &'static str; + + /// Per-harness session-store root. Today this is a fixed path + /// resolved against the user's home directory; future iterations + /// may thread `BurnConfig` through so the root is configurable. + fn session_root(&self) -> PathBuf; + + /// Compute the spawn plan. Inject session ids or transport-level + /// args here. Populate `SpawnPlan::session_id` when known so + /// `before_spawn` / `after_exit` can stamp eagerly. + async fn plan(&self, ctx: &PlanCtx) -> anyhow::Result; + + /// Pre-spawn side effects. Stamp now if the session id is in `plan`, + /// otherwise drop a pending-stamp manifest the post-spawn ingest can + /// resolve. Default impl is a no-op so simple adapters don't have to + /// spell it out. + async fn before_spawn(&self, _ctx: &PlanCtx, _plan: &SpawnPlan) -> anyhow::Result<()> { + Ok(()) + } + + /// Optional. Return a [`WatcherController`] from + /// [`relayburn_sdk::start_watch_loop`] to drain a session store + /// while the child runs; return `None` for adapters that ingest a + /// single pre-known file at exit. + /// + /// `on_report` is a callback the driver routes into its summary + /// accumulator so the final `[burn] ingest:` line reflects + /// every tick that fired during the run, not just `after_exit`. + fn start_watcher( + &self, + _ctx: &PlanCtx, + _on_report: relayburn_sdk::ReportSink, + ) -> Option { + None + } + + /// Final ingest pass after the child exits. Returns an + /// [`IngestReport`] the driver folds into its summary line. + async fn after_exit(&self, ctx: &PlanCtx, plan: &SpawnPlan) -> anyhow::Result; +} + +/// Wrapper around the SDK's [`WatchController`]. Today this is just a +/// newtype so callers don't have to import `relayburn_sdk` directly to +/// construct or stop a watcher; tomorrow it gives us a stable boundary +/// to attach harness-side observability (e.g. a `name`, a per-adapter +/// metric counter) without leaking through to the SDK. +pub struct WatcherController { + inner: WatchController, +} + +impl WatcherController { + /// Wrap a raw SDK controller. `pending_stamp::adapter` is the + /// canonical caller; bespoke adapters that build their own watch + /// loop also funnel through here. + pub fn new(inner: WatchController) -> Self { + Self { inner } + } + + /// Run a single tick on demand. Forwards to + /// [`WatchController::tick`]. + pub async fn tick(&self) { + self.inner.tick().await; + } + + /// Stop the periodic loop and await any in-flight tick. Idempotent. + /// `burn run` calls this once the spawned child exits. + pub async fn stop(&self) { + self.inner.stop().await; + } + + /// Borrow the wrapped controller for callers that need the raw + /// SDK type (e.g. integration tests parking on `tick_done`). + pub fn raw(&self) -> &WatchController { + &self.inner + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Smoke test: `SpawnPlan::new` produces an inherit-env plan the + /// driver can hand straight to `tokio::process::Command`. Catches + /// accidental shape changes on the struct. + #[test] + fn spawn_plan_new_minimal_shape() { + let plan = SpawnPlan::new("claude", vec!["--help".into()]); + assert_eq!(plan.binary, "claude"); + assert_eq!(plan.args, vec!["--help".to_string()]); + assert!(plan.env_overrides.is_empty()); + assert!(plan.session_id.is_none()); + } + + /// Trait dispatch sanity: a fake adapter implementing `HarnessAdapter` + /// must be coercible to `&dyn HarnessAdapter` so the registry can + /// hand out trait-object references. + struct FakeAdapter; + + #[async_trait] + impl HarnessAdapter for FakeAdapter { + fn name(&self) -> &'static str { + "fake" + } + fn session_root(&self) -> PathBuf { + PathBuf::from("/tmp/fake") + } + async fn plan(&self, _ctx: &PlanCtx) -> anyhow::Result { + Ok(SpawnPlan::new("fake", vec![])) + } + async fn after_exit( + &self, + _ctx: &PlanCtx, + _plan: &SpawnPlan, + ) -> anyhow::Result { + Ok(IngestReport::default()) + } + } + + #[tokio::test] + async fn fake_adapter_round_trip() { + let adapter: &dyn HarnessAdapter = &FakeAdapter; + assert_eq!(adapter.name(), "fake"); + assert_eq!(adapter.session_root(), PathBuf::from("/tmp/fake")); + + let ctx = PlanCtx { + cwd: PathBuf::from("/tmp"), + passthrough: vec![], + tags: Enrichment::new(), + spawn_start_ts: std::time::SystemTime::now(), + }; + let plan = adapter.plan(&ctx).await.unwrap(); + assert_eq!(plan.binary, "fake"); + + let report = adapter.after_exit(&ctx, &plan).await.unwrap(); + assert_eq!(report.scanned_sessions, 0); + assert_eq!(report.ingested_sessions, 0); + } +} diff --git a/crates/relayburn-cli/src/harnesses/pending_stamp.rs b/crates/relayburn-cli/src/harnesses/pending_stamp.rs new file mode 100644 index 00000000..c66f5d44 --- /dev/null +++ b/crates/relayburn-cli/src/harnesses/pending_stamp.rs @@ -0,0 +1,316 @@ +//! `pending_stamp::adapter` factory — Rust port of +//! `packages/cli/src/harnesses/pending-stamp.ts`'s `createPendingStampAdapter`. +//! +//! Codex and OpenCode share an identical wrapper shape: pre-spawn pending +//! stamp, while-running watch loop draining the session store, post-exit +//! ingest pass. The TS sibling captures that shape once via a factory; the +//! Rust port does the same here so the Wave 2 codex / opencode adapter PRs +//! are one-line constructions instead of two near-duplicate `impl`s. +//! +//! ## Composition +//! +//! ```text +//! plan → `SpawnPlan::new(name, ctx.passthrough)` (no env, no session_id) +//! before_spawn → `relayburn_sdk::write_pending_stamp(...)` + log +//! start_watcher→ `relayburn_sdk::start_watch_loop(non-immediate, on_report → ingest_fn)` +//! after_exit → `(config.ingest_sessions)(...)` +//! ``` +//! +//! `ingest_sessions` is a caller-supplied async closure (Wave 2 will pass +//! `relayburn_sdk::ingest` with codex-only or opencode-only roots). The +//! factory doesn't reach into `relayburn_sdk::ingest` directly so adapter +//! authors can swap in test doubles without monkey-patching env vars. +//! +//! ## What this PR does NOT do +//! +//! - No concrete codex / opencode adapter — those land in #248-e / #248-f. +//! - No log line yet (`[burn] codex spawn: pending stamp …`); the TS +//! sibling writes it through `process.stderr.write`. The Rust factory +//! exposes the manifest filename via the `before_spawn` log hook so +//! Wave 2 adapters can print it under whatever logging discipline the +//! CLI scaffold (#248-a) settles on. Today we just route through +//! `eprintln!`. + +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use relayburn_sdk::{ + start_watch_loop, write_pending_stamp, IngestFn, IngestReport, PendingStampHarness, + PendingStampWriteOptions, ReportSink, StartWatchLoopOptions, +}; + +use super::{HarnessAdapter, PlanCtx, SpawnPlan, WatcherController}; + +/// Async ingest callback supplied by the caller. Returns the report the +/// watch loop and `after_exit` hand back to the driver. +pub type IngestSessionsFn = Arc< + dyn Fn() -> Pin> + Send>> + + Send + + Sync, +>; + +/// Configuration for [`adapter`]. Mirrors the TS +/// `PendingStampAdapterOptions` shape. +#[derive(Clone)] +pub struct PendingStampAdapter { + /// Lowercase harness name — `codex` or `opencode`. The factory + /// asserts this maps to a [`PendingStampHarness`] variant; passing + /// anything else is a programmer error and panics on construction. + pub name: &'static str, + /// Per-harness session-store root (e.g. `~/.codex/sessions`). + /// Resolved lazily via the supplied closure so tests can inject + /// temp dirs without touching `$HOME`. + pub session_root: Arc PathBuf + Send + Sync>, + /// Final ingest pass — called by `after_exit` and by every tick of + /// the watch loop while the child runs. + pub ingest_sessions: IngestSessionsFn, + /// Watch-loop tick interval. Defaults to 1s (matches the TS sibling). + pub watch_interval: Duration, +} + +impl PendingStampAdapter { + /// Construct a factory config with the standard 1s tick. Callers + /// that need a different cadence build the struct directly. + pub fn new( + name: &'static str, + session_root: Arc PathBuf + Send + Sync>, + ingest_sessions: IngestSessionsFn, + ) -> Self { + Self { + name, + session_root, + ingest_sessions, + watch_interval: Duration::from_millis(1000), + } + } +} + +/// Build a [`HarnessAdapter`] from a [`PendingStampAdapter`] config. +/// +/// The Wave 2 codex / opencode adapter PRs each call this once (with +/// `name = "codex"` or `name = "opencode"`) and register the returned +/// adapter as a `&'static` in [`super::registry`]. The boxed-then-leaked +/// pattern is fine because adapters live for the entire CLI process. +pub fn adapter(config: PendingStampAdapter) -> Box { + Box::new(PendingStampAdapterImpl::new(config)) +} + +/// `HarnessAdapter` implementation backing the [`adapter`] factory. Kept +/// private so callers can't construct it directly without going through +/// the validated factory. +struct PendingStampAdapterImpl { + name: &'static str, + harness: PendingStampHarness, + session_root: Arc PathBuf + Send + Sync>, + ingest_sessions: IngestSessionsFn, + watch_interval: Duration, +} + +impl PendingStampAdapterImpl { + fn new(config: PendingStampAdapter) -> Self { + let harness = match config.name { + "codex" => PendingStampHarness::Codex, + "opencode" => PendingStampHarness::Opencode, + other => { + // Programmer error: the SDK's pending-stamp protocol only + // recognises codex + opencode. Adding a third pending-stamp + // harness is a coordinated change with the SDK manifest + // schema, not a CLI-side decision. + panic!( + "pending_stamp::adapter only supports codex|opencode, got {other:?}; \ + extending the protocol requires an SDK change" + ) + } + }; + Self { + name: config.name, + harness, + session_root: config.session_root, + ingest_sessions: config.ingest_sessions, + watch_interval: config.watch_interval, + } + } + + /// Build the IngestFn the watch loop calls each tick. Captures the + /// caller-supplied `ingest_sessions` closure so the loop runs the + /// same path `after_exit` does. + fn ingest_fn(&self) -> IngestFn { + let ingest_sessions = self.ingest_sessions.clone(); + Arc::new(move || { + let f = ingest_sessions.clone(); + Box::pin(async move { f().await }) + }) + } + + /// Convenience: just the file-name component of a manifest path, + /// for stable log lines that don't dump the user's home directory. + fn manifest_basename(path: &Path) -> String { + path.file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()) + } +} + +#[async_trait] +impl HarnessAdapter for PendingStampAdapterImpl { + fn name(&self) -> &'static str { + self.name + } + + fn session_root(&self) -> PathBuf { + (self.session_root)() + } + + async fn plan(&self, ctx: &PlanCtx) -> anyhow::Result { + Ok(SpawnPlan::new(self.name, ctx.passthrough.clone())) + } + + async fn before_spawn(&self, ctx: &PlanCtx, _plan: &SpawnPlan) -> anyhow::Result<()> { + let session_dir_hint = (self.session_root)(); + let opts = PendingStampWriteOptions { + harness: self.harness, + cwd: ctx.cwd.to_string_lossy().into_owned(), + enrichment: ctx.tags.clone(), + session_dir_hint: Some(session_dir_hint.to_string_lossy().into_owned()), + spawn_start_ts: Some(ctx.spawn_start_ts), + spawner_pid: None, + }; + let written = write_pending_stamp(opts).map_err(|err| { + anyhow::anyhow!("failed to write {} pending stamp: {err}", self.name) + })?; + eprintln!( + "[burn] {} spawn: pending stamp {}", + self.name, + Self::manifest_basename(&written.file) + ); + Ok(()) + } + + fn start_watcher( + &self, + _ctx: &PlanCtx, + on_report: ReportSink, + ) -> Option { + // Match the TS adapter: do not run an immediate first tick. The + // child has barely started; let the periodic interval drive the + // first scan so we don't spawn an ingest pass that races the + // freshly-written pending stamp. + let opts = StartWatchLoopOptions::new(self.ingest_fn()) + .with_immediate(false) + .with_interval(self.watch_interval) + .with_on_report(on_report); + Some(WatcherController::new(start_watch_loop(opts))) + } + + async fn after_exit(&self, _ctx: &PlanCtx, _plan: &SpawnPlan) -> anyhow::Result { + (self.ingest_sessions)().await + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use relayburn_sdk::Enrichment; + + use super::*; + + /// `adapter()` round-trips through the trait surface for codex. + /// Exercises name + session_root + plan; `before_spawn` is covered + /// by an integration test (would need a writable $RELAYBURN_HOME). + #[tokio::test] + async fn codex_factory_round_trip() { + let session_root: Arc PathBuf + Send + Sync> = + Arc::new(|| PathBuf::from("/tmp/codex-sessions")); + let ingest_sessions: IngestSessionsFn = + Arc::new(|| Box::pin(async { Ok(IngestReport::default()) })); + let config = PendingStampAdapter::new("codex", session_root, ingest_sessions); + let adapter: Box = adapter(config); + + assert_eq!(adapter.name(), "codex"); + assert_eq!(adapter.session_root(), PathBuf::from("/tmp/codex-sessions")); + + let ctx = PlanCtx { + cwd: PathBuf::from("/tmp"), + passthrough: vec!["--help".into()], + tags: Enrichment::new(), + spawn_start_ts: std::time::SystemTime::now(), + }; + let plan = adapter.plan(&ctx).await.unwrap(); + assert_eq!(plan.binary, "codex"); + assert_eq!(plan.args, vec!["--help".to_string()]); + + // `after_exit` runs the user-supplied closure verbatim. + let report = adapter.after_exit(&ctx, &plan).await.unwrap(); + assert_eq!(report.scanned_sessions, 0); + } + + /// `adapter()` round-trips through the trait surface for opencode — + /// same shape, different name. + #[tokio::test] + async fn opencode_factory_round_trip() { + let session_root: Arc PathBuf + Send + Sync> = + Arc::new(|| PathBuf::from("/tmp/opencode-storage")); + let ingest_sessions: IngestSessionsFn = + Arc::new(|| Box::pin(async { Ok(IngestReport::default()) })); + let config = PendingStampAdapter::new("opencode", session_root, ingest_sessions); + let adapter = adapter(config); + assert_eq!(adapter.name(), "opencode"); + assert_eq!( + adapter.session_root(), + PathBuf::from("/tmp/opencode-storage") + ); + } + + /// Bogus harness names panic on construction — the factory doesn't + /// silently fall through to a default. This catches typos at adapter + /// registration time rather than at runtime. + #[test] + #[should_panic(expected = "pending_stamp::adapter only supports")] + fn unknown_name_panics() { + let session_root: Arc PathBuf + Send + Sync> = + Arc::new(|| PathBuf::from("/tmp")); + let ingest_sessions: IngestSessionsFn = + Arc::new(|| Box::pin(async { Ok(IngestReport::default()) })); + let _ = adapter(PendingStampAdapter::new( + "cursor", + session_root, + ingest_sessions, + )); + } + + /// `after_exit` invokes the supplied `ingest_sessions` closure. We + /// use an atomic counter to confirm it was called. + #[tokio::test] + async fn after_exit_invokes_supplied_ingest_fn() { + let count = Arc::new(AtomicUsize::new(0)); + let count_for_closure = count.clone(); + let session_root: Arc PathBuf + Send + Sync> = + Arc::new(|| PathBuf::from("/tmp/codex-sessions")); + let ingest_sessions: IngestSessionsFn = Arc::new(move || { + let c = count_for_closure.clone(); + Box::pin(async move { + c.fetch_add(1, Ordering::SeqCst); + Ok(IngestReport::default()) + }) + }); + let config = PendingStampAdapter::new("codex", session_root, ingest_sessions); + let adapter = adapter(config); + + let ctx = PlanCtx { + cwd: PathBuf::from("/tmp"), + passthrough: vec![], + tags: Enrichment::new(), + spawn_start_ts: std::time::SystemTime::now(), + }; + let plan = adapter.plan(&ctx).await.unwrap(); + adapter.after_exit(&ctx, &plan).await.unwrap(); + adapter.after_exit(&ctx, &plan).await.unwrap(); + + assert_eq!(count.load(Ordering::SeqCst), 2); + } +} diff --git a/crates/relayburn-cli/src/harnesses/registry.rs b/crates/relayburn-cli/src/harnesses/registry.rs new file mode 100644 index 00000000..c09bb303 --- /dev/null +++ b/crates/relayburn-cli/src/harnesses/registry.rs @@ -0,0 +1,152 @@ +//! Lazy harness registry — Rust port of `packages/cli/src/harnesses/registry.ts`. +//! +//! The TS sibling defers each adapter import (`async () => (await +//! import('./claude.js')).claudeAdapter`) so unrelated commands don't +//! pay ingest/ledger startup cost. The Rust port doesn't have lazy +//! module imports, but trait-object adapters are zero-sized so the +//! equivalent is "don't construct heavy state at registry build time". +//! All three Wave 2 adapters will be unit structs — adding them to the +//! `phf::Map` below is free. +//! +//! ## Why `phf` and not `OnceLock>` +//! +//! `phf::Map` is built at compile time; lookup is a single perfect-hash +//! probe with zero allocation. Cold-start matters here: `burn --help` +//! and `burn summary` should not pay any harness-table init cost. +//! `OnceLock>` is what we'd reach for if the table needed +//! runtime configuration (e.g. user-pluggable harnesses), which is not +//! on the roadmap. +//! +//! ## Wave 2 plug-in points +//! +//! Three slots are reserved below for the Wave 2 adapter PRs: +//! +//! * `claude` — #248-d (Wave 2 D5) +//! * `codex` — #248-e (Wave 2 D6) +//! * `opencode` — #248-f (Wave 2 D7) +//! +//! Each adds `pub mod claude;` (or codex / opencode) here and a single +//! row in [`ADAPTERS`]. The codex + opencode adapters are constructed +//! through [`super::pending_stamp::adapter`] so they share the manifest +//! + watch-loop wiring. + +use phf::phf_map; + +use super::HarnessAdapter; + +/// Compile-time perfect-hash map from harness name to a `&'static dyn +/// HarnessAdapter`. Empty on this branch — populated by the three Wave 2 +/// fan-out PRs (#248-d/e/f). +/// +/// `&'static dyn HarnessAdapter` requires the value side to be a trait +/// object reference; `phf` supports that as long as the referent has a +/// `'static` lifetime, which works for stateless unit-struct adapters +/// or adapters defined as `static`s in their own module. +static ADAPTERS: phf::Map<&'static str, &'static dyn HarnessAdapter> = phf_map! { + // Wave 2 PRs will populate these slots: + // + // "claude" => &claude::CLAUDE_ADAPTER, // #248-d + // "codex" => &codex::CODEX_ADAPTER, // #248-e + // "opencode" => &opencode::OPENCODE_ADAPTER, // #248-f +}; + +/// Look up an adapter by name. Returns `None` for unknown names; the +/// `burn run` driver maps `None` to a "did you mean …?" diagnostic +/// using [`list_harness_names`]. +pub fn lookup(name: &str) -> Option<&'static dyn HarnessAdapter> { + ADAPTERS.get(name).copied() +} + +/// List every registered harness name. The CLI's `--help` block reads +/// this so the harness list updates automatically when a new adapter is +/// registered. Order is the iteration order of `phf::Map` (stable but +/// not guaranteed alphabetical) — callers that want deterministic order +/// should sort the result, mirroring how the TS test sorts for +/// comparison. +pub fn list_harness_names() -> Vec<&'static str> { + ADAPTERS.keys().copied().collect() +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use async_trait::async_trait; + use relayburn_sdk::IngestReport; + + use super::super::{HarnessAdapter, PlanCtx, SpawnPlan}; + use super::*; + + /// A registry-injectable fake adapter used to exercise the lookup + /// path. The real registry only ships the Wave 2 adapters; this test + /// asserts the substrate independently of which slots are populated. + struct FakeAdapter; + + #[async_trait] + impl HarnessAdapter for FakeAdapter { + fn name(&self) -> &'static str { + "fake" + } + fn session_root(&self) -> PathBuf { + PathBuf::from("/tmp/fake-sessions") + } + async fn plan(&self, _ctx: &PlanCtx) -> anyhow::Result { + Ok(SpawnPlan::new("fake", vec![])) + } + async fn after_exit( + &self, + _ctx: &PlanCtx, + _plan: &SpawnPlan, + ) -> anyhow::Result { + Ok(IngestReport::default()) + } + } + + static FAKE: FakeAdapter = FakeAdapter; + + /// Static fake registry shaped exactly like production [`ADAPTERS`]. + /// `phf_map!` requires its output to live for `'static`, so the + /// fixture is module-scoped rather than declared inside the test + /// body. This proves a `&'static dyn HarnessAdapter` round-trips + /// through the same `phf::Map::get` → `Option::copied` path that + /// [`lookup`] uses, without needing to mutate the real table (which + /// is compile-time and intentionally unreachable from tests). + static FAKE_REGISTRY: phf::Map<&'static str, &'static dyn HarnessAdapter> = phf_map! { + "fake" => &FAKE, + }; + + /// Lookup-by-name on a static fake adapter. Mirrors what `lookup` + /// does internally and what the Wave 2 PRs will rely on once they + /// register `claude` / `codex` / `opencode`. + #[test] + fn dyn_adapter_round_trip_by_name() { + let got = FAKE_REGISTRY + .get("fake") + .copied() + .expect("fake registered"); + assert_eq!(got.name(), "fake"); + assert_eq!(got.session_root(), PathBuf::from("/tmp/fake-sessions")); + + assert!(FAKE_REGISTRY.get("missing").is_none()); + } + + /// On this branch the production registry is intentionally empty; + /// Wave 2 PRs (claude/codex/opencode) flip this to the `["claude", + /// "codex", "opencode"]` shape the TS sibling already ships. Once + /// those merge, this test should be tightened to assert all three. + #[test] + fn list_is_empty_until_wave_2_adapters_land() { + let names = list_harness_names(); + assert!( + names.is_empty(), + "expected registry to be empty on this branch, got {names:?}" + ); + } + + #[test] + fn lookup_unknown_returns_none() { + assert!(lookup("nope").is_none()); + assert!(lookup("").is_none()); + assert!(lookup("claude ").is_none()); + } +} diff --git a/crates/relayburn-cli/src/lib.rs b/crates/relayburn-cli/src/lib.rs new file mode 100644 index 00000000..fce58564 --- /dev/null +++ b/crates/relayburn-cli/src/lib.rs @@ -0,0 +1,18 @@ +//! `relayburn-cli` library surface. +//! +//! The CLI ships as a binary (`burn`) backed by `src/main.rs`. This +//! `lib.rs` exists so internal modules can be unit-tested with `cargo +//! test -p relayburn-cli` and so future integration tests under `tests/` +//! can reach the harness substrate without re-declaring the module tree. +//! +//! Today the only public surface here is [`harnesses`] — the `HarnessAdapter` +//! trait, the lazy registry, and the shared pending-stamp adapter factory +//! introduced in #248-b. Wave 2 PRs (claude / codex / opencode) will plug +//! their adapters in via [`harnesses::registry`]; the CLI binary will reach +//! them through `lookup` / `list_harness_names`. +//! +//! Keeping this surface as a library crate alongside the binary lets the +//! Wave 2 fan-out PRs add per-adapter modules and unit tests without +//! disturbing `main.rs`. + +pub mod harnesses; diff --git a/crates/relayburn-sdk/src/lib.rs b/crates/relayburn-sdk/src/lib.rs index f6c24f0c..0740dfd6 100644 --- a/crates/relayburn-sdk/src/lib.rs +++ b/crates/relayburn-sdk/src/lib.rs @@ -104,8 +104,10 @@ pub use crate::analyze::{ }; pub use crate::ingest::{ - cleanup_stale_pending_stamps, ingest_all, IngestOptions as RawIngestOptions, IngestReport, - IngestRoots, + cleanup_stale_pending_stamps, ingest_all, 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, }; // --- LedgerOpenOptions -----------------------------------------------------