1use std::path::PathBuf;
9
10use serde::{Deserialize, Serialize};
11
12pub use crate::model::AgentStatus;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(tag = "scheme", rename_all = "snake_case")]
18pub enum Endpoint {
19 Unix {
21 path: PathBuf,
23 },
24 Tcp {
26 addr: String,
28 },
29}
30
31impl Endpoint {
32 #[must_use]
34 pub fn unix_socket_path(&self) -> Option<&std::path::Path> {
35 match self {
36 Endpoint::Unix { path } => Some(path.as_path()),
37 Endpoint::Tcp { .. } => None,
38 }
39 }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct AgentRecord {
45 pub id: String,
47 pub name: String,
49 pub status: AgentStatus,
51 pub started_at: String,
53 pub session_dir: PathBuf,
55 pub endpoint: Endpoint,
57 pub spec: SpawnSpec,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct DaemonStatus {
64 pub pid: u32,
66 pub agents: u32,
68 pub uptime_secs: u64,
70 pub endpoint: Endpoint,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct SpawnSpec {
77 #[serde(default)]
79 pub label: Option<String>,
80 #[serde(default)]
82 pub frontmatter_path: Option<PathBuf>,
83 pub initial_prompt: String,
85 #[serde(default)]
87 pub model: Option<String>,
88 #[serde(default)]
94 pub provider: Option<String>,
95 #[serde(default)]
97 pub tool_allowlist: Option<Vec<String>>,
98 #[serde(default)]
100 pub isolation_worktree: bool,
101 #[serde(default = "true_default")]
103 pub inherit_hooks: bool,
104 #[serde(default)]
108 pub interactive: bool,
109}
110
111fn true_default() -> bool {
112 true
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(tag = "type")]
120pub enum AttachInbound {
121 UserMessage {
123 text: String,
125 },
126 EndInput,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(tag = "kind", rename_all = "snake_case")]
133pub enum CtlRequest {
134 List,
136 Spawn {
138 spec: SpawnSpec,
140 },
141 Attach {
143 id: String,
145 },
146 Kill {
148 id: String,
150 },
151 Respawn {
153 id: String,
155 },
156 Rm {
158 id: String,
160 #[serde(default)]
162 force: bool,
163 },
164 Status,
166 Shutdown,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(tag = "kind", rename_all = "snake_case")]
173pub enum CtlReply {
174 Listed {
176 agents: Vec<AgentRecord>,
178 },
179 Spawned {
181 id: String,
183 endpoint: Endpoint,
185 },
186 AttachAck {
188 endpoint: Endpoint,
190 },
191 Killed,
193 Respawned {
195 id: String,
197 },
198 Removed,
200 Status(DaemonStatus),
202 ShutdownAck,
204 Error {
206 error: SupervisorError,
208 },
209}
210
211#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(tag = "kind", rename_all = "snake_case")]
214pub enum SupervisorError {
215 #[error("agent not found: {id}")]
217 NotFound {
218 id: String,
220 },
221 #[error("invalid state for {op}: agent {id} is {status:?}")]
223 InvalidState {
224 op: String,
226 id: String,
228 status: AgentStatus,
230 },
231 #[error("internal supervisor error: {message}")]
233 Internal {
234 message: String,
236 },
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 #[test]
244 fn endpoint_matches_caliban_wire_shape() {
245 let unix = Endpoint::Unix {
247 path: "/tmp/a1.sock".into(),
248 };
249 assert_eq!(
250 serde_json::to_string(&unix).unwrap(),
251 r#"{"scheme":"unix","path":"/tmp/a1.sock"}"#
252 );
253 let tcp = Endpoint::Tcp {
254 addr: "host.ns.svc:9443".into(),
255 };
256 assert_eq!(
257 serde_json::to_string(&tcp).unwrap(),
258 r#"{"scheme":"tcp","addr":"host.ns.svc:9443"}"#
259 );
260 for e in [unix, tcp] {
262 let s = serde_json::to_string(&e).unwrap();
263 assert_eq!(serde_json::from_str::<Endpoint>(&s).unwrap(), e);
264 }
265 }
266
267 #[test]
268 fn endpoint_unix_socket_path_accessor() {
269 assert_eq!(
270 Endpoint::Unix {
271 path: "/x.sock".into()
272 }
273 .unix_socket_path(),
274 Some(std::path::Path::new("/x.sock"))
275 );
276 assert_eq!(
277 Endpoint::Tcp { addr: "h:1".into() }.unix_socket_path(),
278 None
279 );
280 }
281
282 #[test]
283 fn ctl_request_list_is_tagged() {
284 assert_eq!(
285 serde_json::to_string(&CtlRequest::List).unwrap(),
286 "{\"kind\":\"list\"}"
287 );
288 }
289
290 #[test]
291 fn ctl_request_rm_force_defaults_false() {
292 let r: CtlRequest = serde_json::from_str("{\"kind\":\"rm\",\"id\":\"a1\"}").unwrap();
293 assert_eq!(
294 r,
295 CtlRequest::Rm {
296 id: "a1".into(),
297 force: false
298 }
299 );
300 }
301
302 #[test]
303 fn spawn_spec_defaults_inherit_hooks_true() {
304 let s: SpawnSpec = serde_json::from_str("{\"initial_prompt\":\"hi\"}").unwrap();
305 assert!(s.inherit_hooks);
306 assert!(!s.isolation_worktree);
307 assert!(s.model.is_none());
308 assert!(s.provider.is_none());
309 }
310
311 #[test]
312 fn spawn_spec_is_wire_compatible_with_caliban_interactive() {
313 let golden = r#"{"label":null,"frontmatter_path":null,"initial_prompt":"hi","model":null,"provider":null,"tool_allowlist":null,"isolation_worktree":false,"inherit_hooks":true,"interactive":true}"#;
316 let spec: SpawnSpec = serde_json::from_str(golden).expect("deserialize caliban spec");
317 assert!(
318 spec.interactive,
319 "interactive must round-trip from caliban's wire form"
320 );
321 let json = serde_json::to_value(&spec).unwrap();
322 assert_eq!(json["interactive"], serde_json::json!(true));
323 assert_eq!(
326 serde_json::to_string(&spec).unwrap(),
327 golden,
328 "re-serialised SpawnSpec must match caliban's golden wire form"
329 );
330 }
331
332 #[test]
333 fn spawn_spec_provider_round_trips_with_caliban() {
334 let golden = r#"{"label":null,"frontmatter_path":null,"initial_prompt":"hi","model":null,"provider":"ollama","tool_allowlist":null,"isolation_worktree":false,"inherit_hooks":true,"interactive":false}"#;
338 let spec: SpawnSpec = serde_json::from_str(golden).expect("deserialize caliban spec");
339 assert_eq!(spec.provider.as_deref(), Some("ollama"));
340 assert_eq!(
341 serde_json::to_string(&spec).unwrap(),
342 golden,
343 "re-serialised SpawnSpec must match caliban's golden wire form"
344 );
345 }
346
347 #[test]
348 fn spawn_spec_without_provider_defaults_none() {
349 let old = r#"{"initial_prompt":"hi"}"#;
351 let spec: SpawnSpec = serde_json::from_str(old).unwrap();
352 assert!(spec.provider.is_none());
353 }
354
355 #[test]
356 fn spawn_spec_without_interactive_defaults_false() {
357 let old = r#"{"initial_prompt":"hi"}"#;
359 let spec: SpawnSpec = serde_json::from_str(old).unwrap();
360 assert!(!spec.interactive);
361 }
362
363 #[test]
364 fn attach_inbound_user_message_serializes() {
365 let j = serde_json::to_string(&AttachInbound::UserMessage {
366 text: "hi there".into(),
367 })
368 .unwrap();
369 assert_eq!(j, r#"{"type":"UserMessage","text":"hi there"}"#);
370 }
371
372 #[test]
373 fn attach_inbound_end_input_serializes() {
374 let j = serde_json::to_string(&AttachInbound::EndInput).unwrap();
375 assert_eq!(j, r#"{"type":"EndInput"}"#);
376 }
377
378 #[test]
379 fn attach_inbound_round_trips() {
380 for frame in [
383 AttachInbound::UserMessage { text: "hi".into() },
384 AttachInbound::EndInput,
385 ] {
386 let s = serde_json::to_string(&frame).unwrap();
387 let back: AttachInbound = serde_json::from_str(&s).unwrap();
388 assert_eq!(frame, back);
389 }
390 }
391
392 #[test]
393 fn ctl_reply_error_round_trips() {
394 let reply = CtlReply::Error {
395 error: SupervisorError::NotFound { id: "x".into() },
396 };
397 let s = serde_json::to_string(&reply).unwrap();
398 let back: CtlReply = serde_json::from_str(&s).unwrap();
399 assert_eq!(reply, back);
400 }
401
402 #[test]
403 fn spawned_reply_parses() {
404 let json =
405 r#"{"kind":"spawned","id":"a1","endpoint":{"scheme":"unix","path":"/tmp/a1.sock"}}"#;
406 let r: CtlReply = serde_json::from_str(json).unwrap();
407 assert_eq!(
408 r,
409 CtlReply::Spawned {
410 id: "a1".into(),
411 endpoint: Endpoint::Unix {
412 path: "/tmp/a1.sock".into()
413 },
414 }
415 );
416 }
417
418 #[test]
419 fn spawned_reply_parses_tcp_endpoint() {
420 let json =
421 r#"{"kind":"spawned","id":"a1","endpoint":{"scheme":"tcp","addr":"pod.ns.svc:9443"}}"#;
422 let r: CtlReply = serde_json::from_str(json).unwrap();
423 assert_eq!(
424 r,
425 CtlReply::Spawned {
426 id: "a1".into(),
427 endpoint: Endpoint::Tcp {
428 addr: "pod.ns.svc:9443".into()
429 },
430 }
431 );
432 }
433}