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