Skip to main content

supercode_harness/
orchestrator_door.rs

1//! ORC-13 — the orchestrator's WRITE door, the one every controlled-tier noun
2//! goes through when `--harness orchestrator` names it.
3//!
4//! Hermes's write door is `hermes cron …`; OpenClaw's is `openclaw cron …`
5//! through its Gateway. The orchestrator's is its own package: the only writer
6//! that keeps the folder's byte-stability and its residue rules is `save()`
7//! inside `sdk/orchestrator` (`docs/ORCHESTRATOR-IR.md` §1 rule 2, §6). This
8//! module is the uniform client of that writer, and it never touches the
9//! folder itself — no file here is opened for writing, ever.
10//!
11//! Two doors, one protocol, one answer (`docs/ORCHESTRATOR-IR.md` §4.6):
12//!
13//! * **live** — when `<root>/orchestrator.lock` names a process that is alive
14//!   AND `<root>/orchestrator.sock` accepts a connection, one JSON line goes
15//!   over that socket:
16//!
17//!   ```text
18//!   {"op":"jobs.create","args":{…},"profile":"coder"}
19//!   {"ok":true,"result":{"ran":"created cron job …","job":{…}}}
20//!   ```
21//!
22//!   The daemon dispatches the matching operator event on its own queue, so
23//!   the write is ordered against inbound messages and ticks and the running
24//!   loop's in-memory state and the folder can never disagree.
25//! * **cold** — otherwise the package's own CLI runs the identical verb
26//!   through the identical `applyOperator`:
27//!
28//!   ```text
29//!   node <entry> jobs.create --root <home> --profile coder --json '{…}'
30//!   ```
31//!
32//!   The entry is resolved exactly the way `supercode orchestrator start`
33//!   resolves it ([`crate::orchestrator::daemon_entry`]), so the cold path and
34//!   the daemon are always the same build of the same package.
35//!
36//! Writing through a LIVE daemon's socket rather than its folder is not a
37//! nicety: a daemon holds the loaded state in memory and re-saves it on every
38//! `save` effect, so a folder edited behind its back would be silently
39//! overwritten on the next tick. The lease is what makes that choice
40//! mechanical instead of a guess.
41//!
42//! The answer is the PACKAGE's, never supercode's: `{ok:false, error}` is
43//! surfaced verbatim (it carries the reducer's own refusal line), and each
44//! caller re-reads its row through the ORCH readers afterwards.
45
46use std::io::{BufRead, BufReader, Write};
47use std::path::{Path, PathBuf};
48
49use serde_json::Value;
50
51/// Environment variable overriding the `node` used for the cold path (tests).
52pub const NODE_BIN_ENV: &str = "SUPERCODE_NODE_BIN";
53
54/// The daemon's local socket inside one orchestrator home.
55pub const SOCKET_FILE: &str = "orchestrator.sock";
56
57/// Which door answered.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Door {
60    /// The running daemon's Unix socket.
61    Live,
62    /// The package's own CLI, run as a subprocess.
63    Cold,
64}
65
66impl Door {
67    /// Uniform spelling used in narrations and outcomes.
68    pub const fn as_str(self) -> &'static str {
69        match self {
70            Self::Live => "live",
71            Self::Cold => "cold",
72        }
73    }
74}
75
76/// One answered operator call.
77#[derive(Debug, Clone)]
78pub struct DoorAnswer {
79    /// The exact door that was driven, narrated.
80    pub ran: String,
81    /// Which door it was.
82    pub door: Door,
83    /// The package's own `result` object.
84    pub result: Value,
85}
86
87/// Why an operator call could not be answered.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum DoorError {
90    /// The package refused the verb; the message is ITS words.
91    Refused(String),
92    /// The door itself could not be driven (no entry, no node, a crash).
93    Failed(String),
94}
95
96impl std::fmt::Display for DoorError {
97    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        match self {
99            Self::Refused(message) | Self::Failed(message) => formatter.write_str(message),
100        }
101    }
102}
103
104impl std::error::Error for DoorError {}
105
106type Result<T> = std::result::Result<T, DoorError>;
107
108/// The daemon socket path for one home.
109pub fn socket_path(root: &Path) -> PathBuf {
110    root.join(SOCKET_FILE)
111}
112
113/// Whether a LIVE daemon is serving this home right now.
114///
115/// Both halves must hold: a lease naming a process that is alive, and a socket
116/// file beside it. A lease whose process is gone is stale and a socket left by
117/// a killed daemon is dead, so either alone would send the write into a void.
118pub fn daemon_is_live(root: &Path) -> bool {
119    crate::orchestrator::live_lease(root).is_some() && socket_path(root).exists()
120}
121
122/// Perform one operator verb through whichever door this home publishes.
123pub fn call(root: &Path, op: &str, args: &Value, profile: &str) -> Result<DoorAnswer> {
124    if daemon_is_live(root) {
125        match call_live(root, op, args, profile) {
126            Ok(answer) => return Ok(answer),
127            // A refusal is the package's answer and is final. Only a broken
128            // socket falls through to the cold path — a daemon that died
129            // between the lease check and the connect must not lose the write.
130            Err(DoorError::Refused(message)) => return Err(DoorError::Refused(message)),
131            Err(DoorError::Failed(_)) => {}
132        }
133    }
134    call_cold(root, op, args, profile)
135}
136
137/// The narration for the live door: what went over the socket.
138fn narrate_live(root: &Path, op: &str, args: &Value, profile: &str) -> String {
139    format!(
140        "{} {op} --profile {profile} --json {}",
141        socket_path(root).display(),
142        shell_quote(&args.to_string())
143    )
144}
145
146#[cfg(unix)]
147fn call_live(root: &Path, op: &str, args: &Value, profile: &str) -> Result<DoorAnswer> {
148    use std::os::unix::net::UnixStream;
149
150    let ran = narrate_live(root, op, args, profile);
151    let path = socket_path(root);
152    let mut stream = UnixStream::connect(&path).map_err(|error| {
153        DoorError::Failed(format!(
154            "the orchestrator daemon is leased for `{}` but its socket `{}` did not accept a \
155             connection: {error}",
156            root.display(),
157            path.display()
158        ))
159    })?;
160    let line = serde_json::json!({"op": op, "args": args, "profile": profile});
161    stream
162        .write_all(format!("{line}\n").as_bytes())
163        .and_then(|()| stream.flush())
164        .map_err(|error| DoorError::Failed(format!("`{ran}` could not be sent: {error}")))?;
165    let mut reader = BufReader::new(stream);
166    let mut answer = String::new();
167    reader
168        .read_line(&mut answer)
169        .map_err(|error| DoorError::Failed(format!("`{ran}` was not answered: {error}")))?;
170    if answer.trim().is_empty() {
171        return Err(DoorError::Failed(format!(
172            "`{ran}`: the orchestrator closed the connection without answering"
173        )));
174    }
175    interpret(&ran, Door::Live, answer.trim())
176}
177
178#[cfg(not(unix))]
179fn call_live(root: &Path, op: &str, args: &Value, profile: &str) -> Result<DoorAnswer> {
180    Err(DoorError::Failed(format!(
181        "`{}`: the daemon's door is a Unix socket, which this platform has no client for; the \
182         cold path answers instead",
183        narrate_live(root, op, args, profile)
184    )))
185}
186
187/// The package's own CLI: `node <entry> <op> --root … --profile … --json …`.
188fn call_cold(root: &Path, op: &str, args: &Value, profile: &str) -> Result<DoorAnswer> {
189    let entry = crate::orchestrator::daemon_entry().map_err(|error| {
190        DoorError::Failed(format!(
191            "the orchestrator's write door is its own package, and it could not be located: \
192             {error}"
193        ))
194    })?;
195    let node = std::env::var_os(NODE_BIN_ENV)
196        .map(|value| value.to_string_lossy().trim().to_string())
197        .filter(|value| !value.is_empty())
198        .unwrap_or_else(|| "node".to_string());
199    let payload = args.to_string();
200    let arguments = vec![
201        entry.to_string_lossy().into_owned(),
202        op.to_string(),
203        "--root".to_string(),
204        root.to_string_lossy().into_owned(),
205        "--profile".to_string(),
206        profile.to_string(),
207        "--json".to_string(),
208        payload,
209    ];
210    let ran = std::iter::once(node.clone())
211        .chain(arguments.iter().cloned())
212        .map(|part| shell_quote(&part))
213        .collect::<Vec<_>>()
214        .join(" ");
215    let output = std::process::Command::new(&node)
216        .args(&arguments)
217        .stdin(std::process::Stdio::null())
218        .output()
219        .map_err(|error| DoorError::Failed(format!("`{ran}` could not be executed: {error}")))?;
220    let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
221    let last = stdout.lines().rev().find(|line| !line.trim().is_empty());
222    let Some(last) = last else {
223        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
224        return Err(DoorError::Failed(format!(
225            "`{ran}` printed nothing ({}){}",
226            output.status,
227            if stderr.is_empty() {
228                String::new()
229            } else {
230                format!(": {stderr}")
231            }
232        )));
233    };
234    interpret(&ran, Door::Cold, last.trim())
235}
236
237/// One `{ok, result}` / `{ok:false, error}` line, whichever door printed it.
238fn interpret(ran: &str, door: Door, line: &str) -> Result<DoorAnswer> {
239    let value: Value = serde_json::from_str(line).map_err(|error| {
240        DoorError::Failed(format!(
241            "`{ran}` answered something that is not JSON: {error}"
242        ))
243    })?;
244    if value.get("ok").and_then(Value::as_bool) == Some(true) {
245        return Ok(DoorAnswer {
246            ran: ran.to_string(),
247            door,
248            result: value.get("result").cloned().unwrap_or(Value::Null),
249        });
250    }
251    // The package's own sentence, verbatim: a verb its reducer refused says
252    // WHY, and that reason is the whole point of the controlled tier.
253    Err(DoorError::Refused(
254        value
255            .get("error")
256            .and_then(Value::as_str)
257            .map(str::to_string)
258            .unwrap_or_else(|| format!("`{ran}` answered `{line}`")),
259    ))
260}
261
262/// The same narration quoting the rest of the tier uses.
263fn shell_quote(value: &str) -> String {
264    if !value.is_empty()
265        && value
266            .chars()
267            .all(|c| c.is_ascii_alphanumeric() || "-_./:@=+,".contains(c))
268    {
269        return value.to_string();
270    }
271    format!("'{}'", value.replace('\'', "'\\''"))
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    fn scratch(label: &str) -> PathBuf {
279        let root = std::env::temp_dir().join(format!(
280            "supercode-orc13-{label}-{}-{}",
281            std::process::id(),
282            std::time::SystemTime::now()
283                .duration_since(std::time::UNIX_EPOCH)
284                .unwrap()
285                .as_nanos()
286        ));
287        std::fs::create_dir_all(&root).unwrap();
288        root
289    }
290
291    /// A home with no lease and no socket is never "live": the door must fall
292    /// through to the cold path rather than dial a socket that is not there.
293    #[test]
294    fn a_home_without_a_live_lease_is_not_live() {
295        let root = scratch("live");
296        assert!(!daemon_is_live(&root));
297        // A lease alone is not enough — the socket has to be there too.
298        crate::orchestrator::write_lease(
299            &root,
300            &crate::orchestrator::Lease {
301                pid: std::process::id(),
302                started_at: "2026-09-04T00:00:00Z".into(),
303                root: root.clone(),
304            },
305        )
306        .unwrap();
307        assert!(!daemon_is_live(&root), "a lease without a socket is not up");
308        std::fs::write(socket_path(&root), b"").unwrap();
309        assert!(daemon_is_live(&root));
310        std::fs::remove_dir_all(&root).ok();
311    }
312
313    #[test]
314    fn a_refusal_carries_the_packages_own_sentence() {
315        let error = interpret(
316            "node entry jobs.delete",
317            Door::Cold,
318            r#"{"ok":false,"error":"jobs_delete: no job job_x"}"#,
319        )
320        .unwrap_err();
321        assert_eq!(
322            error,
323            DoorError::Refused("jobs_delete: no job job_x".into())
324        );
325    }
326
327    #[test]
328    fn an_ok_line_yields_the_packages_result() {
329        let answer = interpret(
330            "node entry jobs.create",
331            Door::Cold,
332            r#"{"ok":true,"result":{"ran":"created cron job a","job_id":"a"}}"#,
333        )
334        .unwrap();
335        assert_eq!(answer.door, Door::Cold);
336        assert_eq!(
337            answer.result.pointer("/job_id").and_then(Value::as_str),
338            Some("a")
339        );
340    }
341
342    /// The cold path is the package's own CLI, so a home that does not exist
343    /// fails through the PACKAGE's loader, never through a Rust file write.
344    #[test]
345    fn the_cold_path_runs_the_packages_cli_and_refuses_a_home_that_does_not_load() {
346        let root = scratch("cold").join("not-a-home");
347        let error = call(
348            &root,
349            "jobs.delete",
350            &serde_json::json!({"id": "x"}),
351            "default",
352        )
353        .unwrap_err();
354        let message = error.to_string();
355        assert!(
356            message.contains("not a directory") || message.contains("could not be executed"),
357            "{message}"
358        );
359        std::fs::remove_dir_all(root.parent().unwrap()).ok();
360    }
361}