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: `<root>/orchestrator.sock`, or
109/// `SUPERCODE_ORCHESTRATOR_SOCK` where the embedding keeps the socket off the
110/// root (a home on a network filesystem cannot hold a Unix socket). The
111/// daemon's loop derives the same path (`socketPathFor`).
112pub fn socket_path(root: &Path) -> PathBuf {
113    match std::env::var_os("SUPERCODE_ORCHESTRATOR_SOCK") {
114        Some(path) if !path.is_empty() => PathBuf::from(path),
115        _ => root.join(SOCKET_FILE),
116    }
117}
118
119/// Whether a LIVE daemon is serving this home right now.
120///
121/// Both halves must hold: a lease naming a process that is alive, and a socket
122/// file beside it. A lease whose process is gone is stale and a socket left by
123/// a killed daemon is dead, so either alone would send the write into a void.
124pub fn daemon_is_live(root: &Path) -> bool {
125    crate::orchestrator::live_lease(root).is_some() && socket_path(root).exists()
126}
127
128/// Perform one operator verb through whichever door this home publishes.
129pub fn call(root: &Path, op: &str, args: &Value, profile: &str) -> Result<DoorAnswer> {
130    if daemon_is_live(root) {
131        match call_live(root, op, args, profile) {
132            Ok(answer) => return Ok(answer),
133            // A refusal is the package's answer and is final. Only a broken
134            // socket falls through to the cold path — a daemon that died
135            // between the lease check and the connect must not lose the write.
136            Err(DoorError::Refused(message)) => return Err(DoorError::Refused(message)),
137            Err(DoorError::Failed(_)) => {}
138        }
139    }
140    call_cold(root, op, args, profile)
141}
142
143/// The narration for the live door: what went over the socket.
144fn narrate_live(root: &Path, op: &str, args: &Value, profile: &str) -> String {
145    format!(
146        "{} {op} --profile {profile} --json {}",
147        socket_path(root).display(),
148        shell_quote(&args.to_string())
149    )
150}
151
152#[cfg(unix)]
153fn call_live(root: &Path, op: &str, args: &Value, profile: &str) -> Result<DoorAnswer> {
154    use std::os::unix::net::UnixStream;
155
156    let ran = narrate_live(root, op, args, profile);
157    let path = socket_path(root);
158    let mut stream = UnixStream::connect(&path).map_err(|error| {
159        DoorError::Failed(format!(
160            "the orchestrator daemon is leased for `{}` but its socket `{}` did not accept a \
161             connection: {error}",
162            root.display(),
163            path.display()
164        ))
165    })?;
166    let line = serde_json::json!({"op": op, "args": args, "profile": profile});
167    stream
168        .write_all(format!("{line}\n").as_bytes())
169        .and_then(|()| stream.flush())
170        .map_err(|error| DoorError::Failed(format!("`{ran}` could not be sent: {error}")))?;
171    let mut reader = BufReader::new(stream);
172    let mut answer = String::new();
173    reader
174        .read_line(&mut answer)
175        .map_err(|error| DoorError::Failed(format!("`{ran}` was not answered: {error}")))?;
176    if answer.trim().is_empty() {
177        return Err(DoorError::Failed(format!(
178            "`{ran}`: the orchestrator closed the connection without answering"
179        )));
180    }
181    interpret(&ran, Door::Live, answer.trim())
182}
183
184#[cfg(not(unix))]
185fn call_live(root: &Path, op: &str, args: &Value, profile: &str) -> Result<DoorAnswer> {
186    Err(DoorError::Failed(format!(
187        "`{}`: the daemon's door is a Unix socket, which this platform has no client for; the \
188         cold path answers instead",
189        narrate_live(root, op, args, profile)
190    )))
191}
192
193/// The package's own CLI: `node <entry> <op> --root … --profile … --json …`.
194fn call_cold(root: &Path, op: &str, args: &Value, profile: &str) -> Result<DoorAnswer> {
195    let entry = crate::orchestrator::daemon_entry().map_err(|error| {
196        DoorError::Failed(format!(
197            "the orchestrator's write door is its own package, and it could not be located: \
198             {error}"
199        ))
200    })?;
201    let node = std::env::var_os(NODE_BIN_ENV)
202        .map(|value| value.to_string_lossy().trim().to_string())
203        .filter(|value| !value.is_empty())
204        .unwrap_or_else(|| "node".to_string());
205    let payload = args.to_string();
206    let arguments = vec![
207        entry.to_string_lossy().into_owned(),
208        op.to_string(),
209        "--root".to_string(),
210        root.to_string_lossy().into_owned(),
211        "--profile".to_string(),
212        profile.to_string(),
213        "--json".to_string(),
214        payload,
215    ];
216    let ran = std::iter::once(node.clone())
217        .chain(arguments.iter().cloned())
218        .map(|part| shell_quote(&part))
219        .collect::<Vec<_>>()
220        .join(" ");
221    let output = std::process::Command::new(&node)
222        .args(&arguments)
223        .stdin(std::process::Stdio::null())
224        .output()
225        .map_err(|error| DoorError::Failed(format!("`{ran}` could not be executed: {error}")))?;
226    let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
227    let last = stdout.lines().rev().find(|line| !line.trim().is_empty());
228    let Some(last) = last else {
229        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
230        return Err(DoorError::Failed(format!(
231            "`{ran}` printed nothing ({}){}",
232            output.status,
233            if stderr.is_empty() {
234                String::new()
235            } else {
236                format!(": {stderr}")
237            }
238        )));
239    };
240    interpret(&ran, Door::Cold, last.trim())
241}
242
243/// One `{ok, result}` / `{ok:false, error}` line, whichever door printed it.
244fn interpret(ran: &str, door: Door, line: &str) -> Result<DoorAnswer> {
245    let value: Value = serde_json::from_str(line).map_err(|error| {
246        DoorError::Failed(format!(
247            "`{ran}` answered something that is not JSON: {error}"
248        ))
249    })?;
250    if value.get("ok").and_then(Value::as_bool) == Some(true) {
251        return Ok(DoorAnswer {
252            ran: ran.to_string(),
253            door,
254            result: value.get("result").cloned().unwrap_or(Value::Null),
255        });
256    }
257    // The package's own sentence, verbatim: a verb its reducer refused says
258    // WHY, and that reason is the whole point of the controlled tier.
259    Err(DoorError::Refused(
260        value
261            .get("error")
262            .and_then(Value::as_str)
263            .map(str::to_string)
264            .unwrap_or_else(|| format!("`{ran}` answered `{line}`")),
265    ))
266}
267
268/// The same narration quoting the rest of the tier uses.
269fn shell_quote(value: &str) -> String {
270    if !value.is_empty()
271        && value
272            .chars()
273            .all(|c| c.is_ascii_alphanumeric() || "-_./:@=+,".contains(c))
274    {
275        return value.to_string();
276    }
277    format!("'{}'", value.replace('\'', "'\\''"))
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    fn scratch(label: &str) -> PathBuf {
285        let root = std::env::temp_dir().join(format!(
286            "supercode-orc13-{label}-{}-{}",
287            std::process::id(),
288            std::time::SystemTime::now()
289                .duration_since(std::time::UNIX_EPOCH)
290                .unwrap()
291                .as_nanos()
292        ));
293        std::fs::create_dir_all(&root).unwrap();
294        root
295    }
296
297    /// A home with no lease and no socket is never "live": the door must fall
298    /// through to the cold path rather than dial a socket that is not there.
299    #[test]
300    fn a_home_without_a_live_lease_is_not_live() {
301        let root = scratch("live");
302        assert!(!daemon_is_live(&root));
303        // A lease alone is not enough — the socket has to be there too.
304        crate::orchestrator::write_lease(
305            &root,
306            &crate::orchestrator::Lease {
307                pid: std::process::id(),
308                started_at: "2026-09-04T00:00:00Z".into(),
309                root: root.clone(),
310                host: None,
311                boot: None,
312            },
313        )
314        .unwrap();
315        assert!(!daemon_is_live(&root), "a lease without a socket is not up");
316        std::fs::write(socket_path(&root), b"").unwrap();
317        assert!(daemon_is_live(&root));
318        std::fs::remove_dir_all(&root).ok();
319    }
320
321    #[test]
322    fn a_refusal_carries_the_packages_own_sentence() {
323        let error = interpret(
324            "node entry jobs.delete",
325            Door::Cold,
326            r#"{"ok":false,"error":"jobs_delete: no job job_x"}"#,
327        )
328        .unwrap_err();
329        assert_eq!(
330            error,
331            DoorError::Refused("jobs_delete: no job job_x".into())
332        );
333    }
334
335    #[test]
336    fn an_ok_line_yields_the_packages_result() {
337        let answer = interpret(
338            "node entry jobs.create",
339            Door::Cold,
340            r#"{"ok":true,"result":{"ran":"created cron job a","job_id":"a"}}"#,
341        )
342        .unwrap();
343        assert_eq!(answer.door, Door::Cold);
344        assert_eq!(
345            answer.result.pointer("/job_id").and_then(Value::as_str),
346            Some("a")
347        );
348    }
349
350    /// The cold path is the package's own CLI, so a home that does not exist
351    /// fails through the PACKAGE's loader, never through a Rust file write.
352    #[test]
353    fn the_cold_path_runs_the_packages_cli_and_refuses_a_home_that_does_not_load() {
354        let root = scratch("cold").join("not-a-home");
355        let error = call(
356            &root,
357            "jobs.delete",
358            &serde_json::json!({"id": "x"}),
359            "default",
360        )
361        .unwrap_err();
362        let message = error.to_string();
363        assert!(
364            message.contains("not a directory") || message.contains("could not be executed"),
365            "{message}"
366        );
367        std::fs::remove_dir_all(root.parent().unwrap()).ok();
368    }
369}