Skip to main content

relayburn_cli/harnesses/
mod.rs

1//! Harness substrate — Rust port of `packages/cli/src/harnesses/types.ts`
2//! and friends.
3//!
4//! `burn run <harness>` is a wrapper that spawns a coding-agent process
5//! (Claude Code, Codex, OpenCode, …), babysits its session log while it
6//! runs, and feeds the resulting turns into the relayburn ledger. Every
7//! adapter contributes the same five-step shape:
8//!
9//! 1. **`plan`** — compute the spawn plan (binary + args + env). Per-harness
10//!    transports inject session ids or hook arguments here.
11//! 2. **`before_spawn`** — fire any pre-spawn side effect: stamp now if the
12//!    session id is known up front (claude path), or drop a pending-stamp
13//!    manifest the post-spawn ingest pass will resolve (codex / opencode).
14//! 3. **`start_watcher`** *(optional)* — return a [`WatchController`] that
15//!    drains a session-store directory while the child runs. Adapters that
16//!    ingest a single pre-known session file (claude) return `None` here;
17//!    adapters that share the pending-stamp shape (codex, opencode) wire
18//!    the watch loop through [`pending_stamp::adapter`].
19//! 4. **`after_exit`** — run a final ingest pass after the child exits and
20//!    return an [`IngestReport`] so the driver can fold it into the unified
21//!    `[burn] <name> ingest: …` line.
22//! 5. The driver itself owns step zero — collecting `cwd`, passthrough
23//!    args, and any user-provided enrichment tags into a [`PlanCtx`] —
24//!    and step six — joining the watcher and reporting summary stats.
25//!
26//! ## Where this fits
27//!
28//! This PR (#248 part b) is the substrate. The Wave 2 PRs (#248-d/e/f)
29//! plug the three concrete adapters into [`registry`] and the
30//! `burn run` driver in `commands::run` consumes them. The CLI scaffold
31//! (#248 part a, sibling worktree) lands the clap entrypoint independently.
32//!
33//! ## Trait shape vs the TS sibling
34//!
35//! `HarnessAdapter` is a `Send + Sync` trait object so the registry can
36//! hand out `&'static dyn HarnessAdapter` references. `async fn` in trait
37//! is mediated by `async_trait::async_trait` to keep adapter impls
38//! ergonomic; the desugared `Pin<Box<dyn Future + Send>>` matches the
39//! shape expected by the `burn run` driver, which `tokio::spawn`s the
40//! result of `plan` / `after_exit` and joins them at the top level.
41
42use std::path::PathBuf;
43
44use async_trait::async_trait;
45use relayburn_sdk::{Enrichment, IngestReport, WatchController};
46
47pub mod claude;
48pub mod codex;
49pub mod opencode;
50pub mod pending_stamp;
51pub mod registry;
52
53#[cfg(test)]
54mod test_env;
55
56pub use registry::{list_harness_names, lookup};
57
58/// Driver-side context handed to every adapter call. Mirrors the TS
59/// `HarnessRunContext` shape one-to-one (`cwd`, `passthrough`, `tags`,
60/// `spawnStartTs`) plus the Rust port's typed ledger-home override.
61///
62/// `tags` is a `BTreeMap<String, String>` (re-exported from the SDK as
63/// [`Enrichment`]) so insertion order doesn't matter for the on-disk
64/// stamp record — the pending-stamp serializer canonicalizes ordering.
65#[derive(Debug, Clone)]
66pub struct PlanCtx {
67    /// Working directory the user invoked `burn run` from. Forwarded to
68    /// the spawned harness so it picks up project-local config.
69    pub cwd: PathBuf,
70    /// Argv tail after the subcommand boundary, e.g. `burn run claude --
71    /// "explain this"` ⇒ `["explain this"]`. Adapters splice this into
72    /// their generated argv via [`SpawnPlan::args`].
73    pub passthrough: Vec<String>,
74    /// User-supplied enrichment that will be merged onto the resulting
75    /// stamp. Keys are free-form (`task`, `pr`, …); the Wave 2 driver
76    /// translates `--tag k=v` flags into entries here.
77    pub tags: Enrichment,
78    /// Optional ledger home selected by `--ledger-path`. Pending-stamp
79    /// adapters use this for both manifest writes and ingest passes so
80    /// read/write sidecars stay scoped to the same home without relying
81    /// solely on process env mutation.
82    pub ledger_home: Option<PathBuf>,
83    /// Wall-clock timestamp captured by the driver immediately before
84    /// `before_spawn`. Used by the pending-stamp manifest so the
85    /// post-exit resolver can match against session-file mtimes.
86    pub spawn_start_ts: std::time::SystemTime,
87}
88
89/// Spawn plan returned by [`HarnessAdapter::plan`]. The `burn run`
90/// driver owns the actual `tokio::process::Command` construction; this
91/// struct is the per-adapter contribution to it.
92///
93/// `session_id` is filled in by adapters that know the session id up
94/// front (claude can mint one and inject it via `--session-id` so the
95/// pre-spawn stamp is final from the start). Adapters that don't know
96/// it ahead of time leave this `None` and rely on the pending-stamp
97/// resolver to attach their enrichment to the freshly-discovered
98/// session in `after_exit`.
99#[derive(Debug, Clone, Default)]
100pub struct SpawnPlan {
101    pub binary: String,
102    pub args: Vec<String>,
103    /// Env vars to overlay on top of the parent process env when
104    /// spawning. Keep this tight — `tokio::process::Command::env_clear`
105    /// + this map is the typical pattern, though Wave 2 may relax that.
106    pub env_overrides: Vec<(String, String)>,
107    /// Session id the adapter pre-allocated, when known. See struct
108    /// docs for when this is `Some` vs `None`.
109    pub session_id: Option<String>,
110}
111
112impl SpawnPlan {
113    /// Convenience: minimal plan that just runs `binary` with `args` and
114    /// inherits the parent's env. Most adapters' `plan` returns this
115    /// shape directly.
116    pub fn new(binary: impl Into<String>, args: Vec<String>) -> Self {
117        Self {
118            binary: binary.into(),
119            args,
120            env_overrides: Vec::new(),
121            session_id: None,
122        }
123    }
124}
125
126/// `HarnessAdapter` — five-method contract every harness implements. The
127/// TS sibling lives at `packages/cli/src/harnesses/types.ts` and the
128/// shape mirrors it; see the module docs for what each step does.
129///
130/// Adapters are zero-sized (or near-zero-sized) stateless types that the
131/// registry hands out as `&'static dyn HarnessAdapter`. State that lives
132/// across `before_spawn` → `after_exit` rides on `PlanCtx` / `SpawnPlan`,
133/// or in the pending-stamps directory on disk.
134#[async_trait]
135pub trait HarnessAdapter: Send + Sync {
136    /// Lowercase identifier — `claude`, `codex`, `opencode`, … — used as
137    /// the dispatch key and as the harness label in log lines.
138    fn name(&self) -> &'static str;
139
140    /// Per-harness session-store root. Today this is a fixed path
141    /// resolved against the user's home directory; future iterations
142    /// may thread `BurnConfig` through so the root is configurable.
143    fn session_root(&self) -> PathBuf;
144
145    /// Compute the spawn plan. Inject session ids or transport-level
146    /// args here. Populate `SpawnPlan::session_id` when known so
147    /// `before_spawn` / `after_exit` can stamp eagerly.
148    async fn plan(&self, ctx: &PlanCtx) -> anyhow::Result<SpawnPlan>;
149
150    /// Pre-spawn side effects. Stamp now if the session id is in `plan`,
151    /// otherwise drop a pending-stamp manifest the post-spawn ingest can
152    /// resolve. Default impl is a no-op so simple adapters don't have to
153    /// spell it out.
154    async fn before_spawn(&self, _ctx: &PlanCtx, _plan: &SpawnPlan) -> anyhow::Result<()> {
155        Ok(())
156    }
157
158    /// Optional. Return a [`WatcherController`] from
159    /// [`relayburn_sdk::start_watch_loop`] to drain a session store
160    /// while the child runs; return `None` for adapters that ingest a
161    /// single pre-known file at exit.
162    ///
163    /// `on_report` is a callback the driver routes into its summary
164    /// accumulator so the final `[burn] <name> ingest:` line reflects
165    /// every tick that fired during the run, not just `after_exit`.
166    fn start_watcher(
167        &self,
168        _ctx: &PlanCtx,
169        _on_report: relayburn_sdk::ReportSink,
170    ) -> Option<WatcherController> {
171        None
172    }
173
174    /// Final ingest pass after the child exits. Returns an
175    /// [`IngestReport`] the driver folds into its summary line.
176    async fn after_exit(&self, ctx: &PlanCtx, plan: &SpawnPlan) -> anyhow::Result<IngestReport>;
177}
178
179/// Wrapper around the SDK's [`WatchController`]. Today this is just a
180/// newtype so callers don't have to import `relayburn_sdk` directly to
181/// construct or stop a watcher; tomorrow it gives us a stable boundary
182/// to attach harness-side observability (e.g. a `name`, a per-adapter
183/// metric counter) without leaking through to the SDK.
184pub struct WatcherController {
185    inner: WatchController,
186}
187
188impl WatcherController {
189    /// Wrap a raw SDK controller. `pending_stamp::adapter` is the
190    /// canonical caller; bespoke adapters that build their own watch
191    /// loop also funnel through here.
192    pub fn new(inner: WatchController) -> Self {
193        Self { inner }
194    }
195
196    /// Run a single tick on demand. Forwards to
197    /// [`WatchController::tick`].
198    pub async fn tick(&self) {
199        self.inner.tick().await;
200    }
201
202    /// Stop the periodic loop and await any in-flight tick. Idempotent.
203    /// `burn run` calls this once the spawned child exits.
204    pub async fn stop(&self) {
205        self.inner.stop().await;
206    }
207
208    /// Borrow the wrapped controller for callers that need the raw
209    /// SDK type (e.g. integration tests parking on `tick_done`).
210    pub fn raw(&self) -> &WatchController {
211        &self.inner
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    /// Smoke test: `SpawnPlan::new` produces an inherit-env plan the
220    /// driver can hand straight to `tokio::process::Command`. Catches
221    /// accidental shape changes on the struct.
222    #[test]
223    fn spawn_plan_new_minimal_shape() {
224        let plan = SpawnPlan::new("claude", vec!["--help".into()]);
225        assert_eq!(plan.binary, "claude");
226        assert_eq!(plan.args, vec!["--help".to_string()]);
227        assert!(plan.env_overrides.is_empty());
228        assert!(plan.session_id.is_none());
229    }
230
231    /// Trait dispatch sanity: a fake adapter implementing `HarnessAdapter`
232    /// must be coercible to `&dyn HarnessAdapter` so the registry can
233    /// hand out trait-object references.
234    struct FakeAdapter;
235
236    #[async_trait]
237    impl HarnessAdapter for FakeAdapter {
238        fn name(&self) -> &'static str {
239            "fake"
240        }
241        fn session_root(&self) -> PathBuf {
242            PathBuf::from("/tmp/fake")
243        }
244        async fn plan(&self, _ctx: &PlanCtx) -> anyhow::Result<SpawnPlan> {
245            Ok(SpawnPlan::new("fake", vec![]))
246        }
247        async fn after_exit(
248            &self,
249            _ctx: &PlanCtx,
250            _plan: &SpawnPlan,
251        ) -> anyhow::Result<IngestReport> {
252            Ok(IngestReport::default())
253        }
254    }
255
256    #[tokio::test]
257    async fn fake_adapter_round_trip() {
258        let adapter: &dyn HarnessAdapter = &FakeAdapter;
259        assert_eq!(adapter.name(), "fake");
260        assert_eq!(adapter.session_root(), PathBuf::from("/tmp/fake"));
261
262        let ctx = PlanCtx {
263            cwd: PathBuf::from("/tmp"),
264            passthrough: vec![],
265            tags: Enrichment::new(),
266            ledger_home: None,
267            spawn_start_ts: std::time::SystemTime::now(),
268        };
269        let plan = adapter.plan(&ctx).await.unwrap();
270        assert_eq!(plan.binary, "fake");
271
272        let report = adapter.after_exit(&ctx, &plan).await.unwrap();
273        assert_eq!(report.scanned_sessions, 0);
274        assert_eq!(report.ingested_sessions, 0);
275    }
276}