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`]
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}