1use std::io::{BufRead, BufReader, Write};
47use std::path::{Path, PathBuf};
48
49use serde_json::Value;
50
51pub const NODE_BIN_ENV: &str = "SUPERCODE_NODE_BIN";
53
54pub const SOCKET_FILE: &str = "orchestrator.sock";
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Door {
60 Live,
62 Cold,
64}
65
66impl Door {
67 pub const fn as_str(self) -> &'static str {
69 match self {
70 Self::Live => "live",
71 Self::Cold => "cold",
72 }
73 }
74}
75
76#[derive(Debug, Clone)]
78pub struct DoorAnswer {
79 pub ran: String,
81 pub door: Door,
83 pub result: Value,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum DoorError {
90 Refused(String),
92 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
108pub fn socket_path(root: &Path) -> PathBuf {
110 root.join(SOCKET_FILE)
111}
112
113pub fn daemon_is_live(root: &Path) -> bool {
119 crate::orchestrator::live_lease(root).is_some() && socket_path(root).exists()
120}
121
122pub 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 Err(DoorError::Refused(message)) => return Err(DoorError::Refused(message)),
131 Err(DoorError::Failed(_)) => {}
132 }
133 }
134 call_cold(root, op, args, profile)
135}
136
137fn 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
187fn 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
237fn 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 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
262fn 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 #[test]
294 fn a_home_without_a_live_lease_is_not_live() {
295 let root = scratch("live");
296 assert!(!daemon_is_live(&root));
297 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 #[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}