Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` + `<cwd>/.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)
Expand Down
15 changes: 15 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions crates/relayburn-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Box<…>>` 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"] }
264 changes: 264 additions & 0 deletions crates/relayburn-cli/src/harnesses/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
//! Harness substrate — Rust port of `packages/cli/src/harnesses/types.ts`
//! and friends.
//!
//! `burn run <harness>` 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] <name> 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<Box<dyn Future + Send>>` 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<String, String>` (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<String>,
/// 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<String>,
/// 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<String>,
}

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<String>, args: Vec<String>) -> 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<SpawnPlan>;

/// 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] <name> 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<WatcherController> {
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<IngestReport>;
}

/// 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<SpawnPlan> {
Ok(SpawnPlan::new("fake", vec![]))
}
async fn after_exit(
&self,
_ctx: &PlanCtx,
_plan: &SpawnPlan,
) -> anyhow::Result<IngestReport> {
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);
}
}
Loading
Loading