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 {
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
119pub fn daemon_is_live(root: &Path) -> bool {
125 crate::orchestrator::live_lease(root).is_some() && socket_path(root).exists()
126}
127
128pub 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 Err(DoorError::Refused(message)) => return Err(DoorError::Refused(message)),
137 Err(DoorError::Failed(_)) => {}
138 }
139 }
140 call_cold(root, op, args, profile)
141}
142
143fn 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
193fn 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
243fn 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 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
268fn 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 #[test]
300 fn a_home_without_a_live_lease_is_not_live() {
301 let root = scratch("live");
302 assert!(!daemon_is_live(&root));
303 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 },
311 )
312 .unwrap();
313 assert!(!daemon_is_live(&root), "a lease without a socket is not up");
314 std::fs::write(socket_path(&root), b"").unwrap();
315 assert!(daemon_is_live(&root));
316 std::fs::remove_dir_all(&root).ok();
317 }
318
319 #[test]
320 fn a_refusal_carries_the_packages_own_sentence() {
321 let error = interpret(
322 "node entry jobs.delete",
323 Door::Cold,
324 r#"{"ok":false,"error":"jobs_delete: no job job_x"}"#,
325 )
326 .unwrap_err();
327 assert_eq!(
328 error,
329 DoorError::Refused("jobs_delete: no job job_x".into())
330 );
331 }
332
333 #[test]
334 fn an_ok_line_yields_the_packages_result() {
335 let answer = interpret(
336 "node entry jobs.create",
337 Door::Cold,
338 r#"{"ok":true,"result":{"ran":"created cron job a","job_id":"a"}}"#,
339 )
340 .unwrap();
341 assert_eq!(answer.door, Door::Cold);
342 assert_eq!(
343 answer.result.pointer("/job_id").and_then(Value::as_str),
344 Some("a")
345 );
346 }
347
348 #[test]
351 fn the_cold_path_runs_the_packages_cli_and_refuses_a_home_that_does_not_load() {
352 let root = scratch("cold").join("not-a-home");
353 let error = call(
354 &root,
355 "jobs.delete",
356 &serde_json::json!({"id": "x"}),
357 "default",
358 )
359 .unwrap_err();
360 let message = error.to_string();
361 assert!(
362 message.contains("not a directory") || message.contains("could not be executed"),
363 "{message}"
364 );
365 std::fs::remove_dir_all(root.parent().unwrap()).ok();
366 }
367}