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