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): wire opencode `HarnessAdapter` via `pending_stamp::adapter_static` factory; registered in `RUNTIME_ADAPTERS`. (#248 D7)
- `relayburn-cli` (Rust): wire `burn run <harness>` 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)
Expand Down
27 changes: 1 addition & 26 deletions crates/relayburn-cli/src/harnesses/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,32 +73,7 @@ pub fn adapter() -> &'static dyn HarnessAdapter {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;

/// Serialize tests that mutate `$HOME` so a parallel test (in this
/// module or another using the same env var) can't observe a
/// half-set state. Mirrors the `ENV_LOCK` pattern in
/// `relayburn_sdk::query_verbs::state_status` tests. Poisoned-mutex
/// recovery is intentional — a panicking test shouldn't break
/// every subsequent run.
static ENV_LOCK: Mutex<()> = Mutex::new(());

/// Runs `f` with `$HOME` pinned to `home`, restoring (or removing)
/// the prior value before returning. Holds `ENV_LOCK` for the whole
/// closure so concurrent tests serialize on the env mutation.
fn with_test_home(home: &str, f: impl FnOnce()) {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev = std::env::var_os("HOME");
std::env::set_var("HOME", home);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
match prev {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
if let Err(payload) = result {
std::panic::resume_unwind(payload);
}
}
use crate::harnesses::test_env::with_test_home;

/// `config()` returns a `PendingStampAdapter` named `codex` with the
/// standard 1s tick interval. Sanity check that the constructor wires
Expand Down
4 changes: 4 additions & 0 deletions crates/relayburn-cli/src/harnesses/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,13 @@ use relayburn_sdk::{Enrichment, IngestReport, WatchController};

pub mod claude;
pub mod codex;
pub mod opencode;
pub mod pending_stamp;
pub mod registry;

#[cfg(test)]
mod test_env;

pub use registry::{list_harness_names, lookup};

/// Driver-side context handed to every adapter call. Mirrors the TS
Expand Down
126 changes: 126 additions & 0 deletions crates/relayburn-cli/src/harnesses/opencode.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
//! OpenCode `HarnessAdapter` — Rust port of `packages/cli/src/harnesses/opencode.ts`.
//!
//! OpenCode shares the pending-stamp + watch-loop shape with codex, so the
//! adapter is constructed via [`super::pending_stamp::adapter_static`]
//! instead of re-implementing the trait. The only opencode-specific bits are:
//!
//! * `name = "opencode"` — the dispatch key and log-line label.
//! * `session_root` — `$HOME/.local/share/opencode/storage/session`,
//! resolved lazily so tests that override `$HOME` see the override.
//! Mirrors the TS sibling's `path.join(homedir(), '.local', 'share',
//! 'opencode', 'storage', 'session')` exactly.
//! * `ingest_sessions` — opens a fresh ledger handle and runs
//! [`relayburn_sdk::ingest_opencode_sessions`] (the opencode-only ingest
//! pass). The TS sibling calls `ingestOpencodeSessions()` directly here;
//! the Rust SDK function takes `&mut Ledger`, so the closure opens a
//! handle each call. That mirrors the TS lock-then-write-then-close
//! shape, and the per-tick open is cheap (SQLite WAL, no DDL after first
//! open).
//!
//! The factory's [`super::pending_stamp::adapter_static`] does the
//! `Box::leak` so the registry can store the result as
//! `&'static dyn HarnessAdapter`. See the factory module for the leak
//! rationale (codex/opencode are the only two callers; runtime cost is
//! a few dozen bytes per process).

use std::path::PathBuf;
use std::sync::Arc;

use relayburn_sdk::{ingest_opencode_sessions, Ledger, LedgerOpenOptions, RawIngestOptions};

use super::pending_stamp::{self, IngestSessionsFn, PendingStampAdapter};
use super::HarnessAdapter;

/// Resolve the opencode session-store root. Mirrors the TS sibling
/// (`path.join(homedir(), '.local', 'share', 'opencode', 'storage',
/// 'session')`) and the SDK's internal `opencode_sessions_dir` default.
/// Resolved on every call so tests that flip `$HOME` between runs see
/// the override.
fn opencode_sessions_dir() -> PathBuf {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
home.join(".local")
.join("share")
.join("opencode")
.join("storage")
.join("session")
}

/// Build the [`PendingStampAdapter`] config for opencode. Exposed as a
/// constructor function (rather than a `static`) because the closure
/// captures and the `Arc<dyn Fn>`s inside don't fit a const initializer.
/// The registry calls this once and feeds the result to
/// [`pending_stamp::adapter_static`].
pub fn config() -> PendingStampAdapter {
let session_root: Arc<dyn Fn() -> PathBuf + Send + Sync> = Arc::new(opencode_sessions_dir);
let ingest_sessions: IngestSessionsFn = Arc::new(|| {
Box::pin(async move {
// Open a fresh ledger handle per tick. The TS sibling's
// `ingestOpencodeSessions` does the same via `withLock('ledger', …)`;
// SQLite WAL keeps the per-call open cheap (no DDL after first
// open). Defaults pull `$RELAYBURN_HOME` (or `~/.relayburn`)
// and the same per-harness session-store root the factory's
// `session_root` closure resolves above.
let mut handle = Ledger::open(LedgerOpenOptions::default())?;
let opts = RawIngestOptions::default();
ingest_opencode_sessions(handle.raw_mut(), &opts).await
})
});
PendingStampAdapter::new("opencode", session_root, ingest_sessions)
}

/// Convenience: hand out a `&'static dyn HarnessAdapter` for the opencode
/// adapter. The registry calls this once at lazy-init time. See
/// [`pending_stamp::adapter_static`] for the leak semantics — opencode is
/// one of two callers and the leaked footprint is bytes, not megabytes.
pub fn adapter() -> &'static dyn HarnessAdapter {
pending_stamp::adapter_static(config())
}

#[cfg(test)]
mod tests {
use super::*;
use crate::harnesses::test_env::with_test_home;

/// `config()` returns a `PendingStampAdapter` named `opencode` with
/// the standard 1s tick interval. Sanity check that the constructor
/// wires the name through the factory contract and that the
/// `session_root` closure resolves to the TS-mirrored path.
#[test]
fn config_has_opencode_name() {
let cfg = config();
assert_eq!(cfg.name, "opencode");
// session_root closure resolves to
// `$HOME/.local/share/opencode/storage/session`. Use a controlled
// $HOME so the assertion doesn't depend on the developer's actual
// home dir; restored after via `with_test_home`.
with_test_home("/tmp/burn-opencode-test-home", || {
let resolved = (cfg.session_root)();
assert_eq!(
resolved,
PathBuf::from(
"/tmp/burn-opencode-test-home/.local/share/opencode/storage/session"
)
);
});
}

/// `adapter()` round-trips through the trait surface — name, session
/// root, and the `&'static` lifetime the registry requires. Mirrors
/// the registry's `pending_stamp_adapter_static_fits_runtime_registry`
/// check, but pinned to the opencode configuration specifically.
#[test]
fn adapter_round_trip() {
let a: &'static dyn HarnessAdapter = adapter();
assert_eq!(a.name(), "opencode");
with_test_home("/tmp/burn-opencode-test-home", || {
assert_eq!(
a.session_root(),
PathBuf::from(
"/tmp/burn-opencode-test-home/.local/share/opencode/storage/session"
)
);
});
}
}
13 changes: 7 additions & 6 deletions crates/relayburn-cli/src/harnesses/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ use std::sync::LazyLock;

use phf::phf_map;

use super::{claude, codex, HarnessAdapter};
use super::{claude, codex, opencode, HarnessAdapter};

/// Compile-time perfect-hash map from harness name to a `&'static dyn
/// HarnessAdapter`. Holds eager / unit-struct adapters whose value is a
Expand Down Expand Up @@ -90,6 +90,7 @@ static RUNTIME_ADAPTERS: LazyLock<HashMap<&'static str, &'static dyn HarnessAdap
LazyLock::new(|| {
let mut m = HashMap::new();
m.insert("codex", codex::adapter()); // #248-e (Wave 2 D6)
m.insert("opencode", opencode::adapter()); // #248-f (Wave 2 D7)
m
});

Expand All @@ -109,8 +110,8 @@ static RUNTIME_ADAPTERS: LazyLock<HashMap<&'static str, &'static dyn HarnessAdap
/// this module's `tests` block pins the resulting order.
static RUNTIME_ADAPTER_NAMES: &[&str] = &[
// Wave 2 PRs populate these slots in lockstep with RUNTIME_ADAPTERS:
"codex", // #248-e (Wave 2 D6)
// "opencode", // #248-f (Wave 2 D7)
"codex", // #248-e (Wave 2 D6)
"opencode", // #248-f (Wave 2 D7)
];

/// Look up an adapter by name. Returns `None` for unknown names; the
Expand Down Expand Up @@ -239,9 +240,9 @@ mod tests {
/// landed claude in `EAGER_ADAPTERS`; codex (#248-e) and
/// opencode (#248-f) will append their runtime entries here.
const EXPECTED_HARNESS_NAMES: &[&str] = &[
"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();
Expand Down
48 changes: 48 additions & 0 deletions crates/relayburn-cli/src/harnesses/test_env.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//! Shared test-only env-mutation helper for harness tests.
//!
//! `HOME` is process-global, so a per-module `Mutex` (one in `codex.rs`,
//! one in `opencode.rs`, …) doesn't actually serialize anything — `cargo
//! test` runs modules in parallel and a `set_var("HOME", …)` in one
//! module's test can be observed by `(cfg.session_root)()` in another.
//!
//! This module hosts a single crate-wide [`ENV_LOCK`] and a
//! [`with_test_home`] helper that all harness tests should funnel
//! through. As a side benefit, the helper also normalizes the
//! save-set-restore-resume_unwind pattern so individual adapters don't
//! re-derive it (and don't drift on the unwind-safety details — a
//! panicking assertion still must restore `HOME` before propagating).
//!
//! Scope: harness-side `HOME` mutation only. SDK-side `RELAYBURN_*` env
//! tests carry their own lock in `relayburn_sdk::query_verbs`. A future
//! workspace-wide consolidation could lift both into a shared
//! `dev-dependencies` test-utility crate, but that's a deliberate
//! follow-up — keeping the scope tight here.

use std::sync::Mutex;

/// Crate-wide lock for any harness test that mutates `HOME`. Poisoned-
/// mutex recovery is intentional — a panicking test shouldn't break
/// every subsequent run.
pub(crate) static ENV_LOCK: Mutex<()> = Mutex::new(());

/// Run `f` with `$HOME` pinned to `home`, restoring (or removing) the
/// prior value before returning. Holds [`ENV_LOCK`] for the whole
/// closure so concurrent harness tests serialize on the env mutation.
///
/// Wraps `f` in [`std::panic::catch_unwind`] so an assertion failure
/// inside the closure still restores `HOME` before the panic
/// propagates — without this, a failed test would leak its sentinel
/// `HOME` value into whichever test acquired the lock next.
pub(crate) fn with_test_home(home: &str, f: impl FnOnce()) {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev = std::env::var_os("HOME");
std::env::set_var("HOME", home);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
match prev {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
if let Err(payload) = result {
std::panic::resume_unwind(payload);
}
}
Loading