relayburn_cli/harnesses/codex.rs
1//! Codex `HarnessAdapter` — Rust port of `packages/cli/src/harnesses/codex.ts`.
2//!
3//! Codex shares the pending-stamp + watch-loop shape with OpenCode, so the
4//! adapter is constructed via [`super::pending_stamp::adapter_static`]
5//! instead of re-implementing the trait. The only codex-specific bits are:
6//!
7//! * `name = "codex"` — the dispatch key and log-line label.
8//! * `session_root` — `$HOME/.codex/sessions`, resolved lazily so tests
9//! that override `$HOME` see the override.
10//! * `ingest_sessions` — opens a fresh ledger handle and runs
11//! [`relayburn_sdk::ingest_codex_sessions`] (the codex-only ingest pass).
12//! The TS sibling calls `ingestCodexSessions()` directly here; the Rust
13//! SDK function takes `&mut Ledger`, so the closure opens a handle each
14//! call. That mirrors the TS lock-then-write-then-close shape, and the
15//! per-tick open is cheap (SQLite WAL, no DDL after first open).
16//!
17//! The factory's [`super::pending_stamp::adapter_static`] does the
18//! `Box::leak` so the registry can store the result as
19//! `&'static dyn HarnessAdapter`. See the factory module for the leak
20//! rationale (codex/opencode are the only two callers; runtime cost is
21//! a few dozen bytes per process).
22
23use std::path::PathBuf;
24use std::sync::Arc;
25
26use relayburn_sdk::{ingest_codex_sessions, Ledger, LedgerOpenOptions, RawIngestOptions};
27
28use super::pending_stamp::{self, IngestSessionsFn, PendingStampAdapter};
29use super::HarnessAdapter;
30
31/// Resolve the codex session-store root. Mirrors the TS sibling
32/// (`path.join(homedir(), '.codex', 'sessions')`) and the SDK's internal
33/// `codex_sessions_dir` default. Resolved on every call so tests that
34/// flip `$HOME` between runs see the override.
35fn codex_sessions_dir() -> PathBuf {
36 let home = std::env::var_os("HOME")
37 .map(PathBuf::from)
38 .unwrap_or_else(|| PathBuf::from("."));
39 home.join(".codex").join("sessions")
40}
41
42/// Build the [`PendingStampAdapter`] config for codex. Exposed as a
43/// constructor function (rather than a `static`) because the closure
44/// captures and the `Arc<dyn Fn>`s inside don't fit a const initializer.
45/// The registry calls this once and feeds the result to
46/// [`pending_stamp::adapter_static`].
47pub fn config() -> PendingStampAdapter {
48 let session_root: Arc<dyn Fn() -> PathBuf + Send + Sync> = Arc::new(codex_sessions_dir);
49 let ingest_sessions: IngestSessionsFn = Arc::new(|ledger_home| {
50 Box::pin(async move {
51 // Open a fresh ledger handle per tick. The TS sibling's
52 // `ingestCodexSessions` does the same via `withLock('ledger', …)`;
53 // SQLite WAL keeps the per-call open cheap. Use the same typed
54 // ledger home the pending-stamp writer used so explicit
55 // `--ledger-path` runs keep manifest writes and resolution scoped
56 // to one home.
57 let ledger_opts = match ledger_home.as_deref() {
58 Some(home) => LedgerOpenOptions::with_home(home),
59 None => LedgerOpenOptions::default(),
60 };
61 let mut handle = Ledger::open(ledger_opts)?;
62 let opts = RawIngestOptions {
63 ledger_home,
64 ..RawIngestOptions::default()
65 };
66 ingest_codex_sessions(handle.raw_mut(), &opts).await
67 })
68 });
69 PendingStampAdapter::new("codex", session_root, ingest_sessions)
70}
71
72/// Convenience: hand out a `&'static dyn HarnessAdapter` for the codex
73/// adapter. The registry calls this once at lazy-init time. See
74/// [`pending_stamp::adapter_static`] for the leak semantics — codex is
75/// one of two callers and the leaked footprint is bytes, not megabytes.
76pub fn adapter() -> &'static dyn HarnessAdapter {
77 pending_stamp::adapter_static(config())
78}
79
80#[cfg(test)]
81mod tests {
82 use super::*;
83 use crate::harnesses::test_env::with_test_home;
84
85 /// `config()` returns a `PendingStampAdapter` named `codex` with the
86 /// standard 1s tick interval. Sanity check that the constructor wires
87 /// the name through the factory contract.
88 #[test]
89 fn config_has_codex_name() {
90 let cfg = config();
91 assert_eq!(cfg.name, "codex");
92 // session_root closure resolves to `$HOME/.codex/sessions`. Use a
93 // controlled $HOME so the assertion doesn't depend on the
94 // developer's actual home dir; restored after via `with_test_home`.
95 with_test_home("/tmp/burn-codex-test-home", || {
96 let resolved = (cfg.session_root)();
97 assert_eq!(
98 resolved,
99 PathBuf::from("/tmp/burn-codex-test-home/.codex/sessions")
100 );
101 });
102 }
103
104 /// `adapter()` round-trips through the trait surface — name, session
105 /// root, and the `&'static` lifetime the registry requires. Mirrors
106 /// the registry's `pending_stamp_adapter_static_fits_runtime_registry`
107 /// check, but pinned to the codex configuration specifically.
108 #[test]
109 fn adapter_round_trip() {
110 let a: &'static dyn HarnessAdapter = adapter();
111 assert_eq!(a.name(), "codex");
112 with_test_home("/tmp/burn-codex-test-home", || {
113 assert_eq!(
114 a.session_root(),
115 PathBuf::from("/tmp/burn-codex-test-home/.codex/sessions")
116 );
117 });
118 }
119}