Skip to main content

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}