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}