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