Skip to main content

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}