Skip to main content

prospero_core/caliband/
wire.rs

1//! Mirrored caliband IPC wire types.
2//!
3//! These mirror `caliban-supervisor`'s `proto.rs`. The **wire format is the
4//! only contract** between Prospero and caliban — we deliberately do not
5//! depend on the caliban crate. If caliban's protocol changes, these types
6//! (and the golden tests) are where the drift surfaces.
7
8use std::path::PathBuf;
9
10use serde::{Deserialize, Serialize};
11
12pub use crate::model::AgentStatus;
13
14/// Where a caliband socket lives, independent of transport family. Mirrors
15/// `caliban-supervisor::transport::Endpoint` byte-for-byte on the wire (ADR 0051).
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(tag = "scheme", rename_all = "snake_case")]
18pub enum Endpoint {
19    /// Local Unix-domain socket at this filesystem path.
20    Unix {
21        /// Socket file path.
22        path: PathBuf,
23    },
24    /// TCP endpoint as a `host:port` string (host may be a DNS name).
25    Tcp {
26        /// `host:port`.
27        addr: String,
28    },
29}
30
31impl Endpoint {
32    /// The Unix socket path, when this endpoint is Unix-domain.
33    #[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/// Snapshot describing a registered sub-agent (caliband `AgentRecord`).
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct AgentRecord {
45    /// Opaque id.
46    pub id: String,
47    /// Human-readable label.
48    pub name: String,
49    /// Current lifecycle state.
50    pub status: AgentStatus,
51    /// RFC-3339 registration timestamp.
52    pub started_at: String,
53    /// Path to the agent's session directory.
54    pub session_dir: PathBuf,
55    /// Endpoint for the agent's per-agent socket (for attach).
56    pub endpoint: Endpoint,
57    /// Original spawn spec.
58    pub spec: SpawnSpec,
59}
60
61/// Daemon status (caliband `DaemonStatus`).
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct DaemonStatus {
64    /// Daemon PID.
65    pub pid: u32,
66    /// Number of registered agents.
67    pub agents: u32,
68    /// Seconds since the daemon started.
69    pub uptime_secs: u64,
70    /// Endpoint of the control socket.
71    pub endpoint: Endpoint,
72}
73
74/// Parameters for a new sub-agent spawn (caliband `SpawnSpec`).
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct SpawnSpec {
77    /// Optional human-readable name.
78    #[serde(default)]
79    pub label: Option<String>,
80    /// Path to a frontmatter markdown file, if any.
81    #[serde(default)]
82    pub frontmatter_path: Option<PathBuf>,
83    /// Initial prompt handed to the agent.
84    pub initial_prompt: String,
85    /// Optional model override.
86    #[serde(default)]
87    pub model: Option<String>,
88    /// Optional provider override (e.g. `"anthropic"`, `"ollama"`, `"openai"`,
89    /// `"google"`). The caliban worker parses this to select the provider
90    /// before model resolution; without it the worker uses caliban's default
91    /// (anthropic). Mirrors caliban `SpawnSpec.provider` (#93). Prospero fills
92    /// it from the repo's stored provider config at spawn time.
93    #[serde(default)]
94    pub provider: Option<String>,
95    /// Optional tool allowlist.
96    #[serde(default)]
97    pub tool_allowlist: Option<Vec<String>>,
98    /// True iff the agent runs in an isolated worktree.
99    #[serde(default)]
100    pub isolation_worktree: bool,
101    /// Whether to inherit parent hooks.
102    #[serde(default = "true_default")]
103    pub inherit_hooks: bool,
104    /// When true, the worker runs in interactive mode: at each end-of-run
105    /// boundary it awaits inbound operator messages over the per-agent socket
106    /// instead of finishing. Mirrors caliban `SpawnSpec.interactive`.
107    #[serde(default)]
108    pub interactive: bool,
109}
110
111fn true_default() -> bool {
112    true
113}
114
115/// Inbound control frames written to an interactive agent's per-agent socket.
116/// Mirrors caliban `AttachInbound` (`caliban/src/attach.rs`); the outbound
117/// stream stays caliban stream-json, so the two never share a direction.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(tag = "type")]
120pub enum AttachInbound {
121    /// Inject a user message and resume the run.
122    UserMessage {
123        /// Message text.
124        text: String,
125    },
126    /// Signal end-of-input: the agent finishes after this.
127    EndInput,
128}
129
130/// Control-plane requests sent to the daemon.
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(tag = "kind", rename_all = "snake_case")]
133pub enum CtlRequest {
134    /// List all registered agents.
135    List,
136    /// Register and start a new agent.
137    Spawn {
138        /// Spec describing the agent.
139        spec: SpawnSpec,
140    },
141    /// Return the dedicated socket for an agent.
142    Attach {
143        /// Target agent.
144        id: String,
145    },
146    /// Terminate an agent.
147    Kill {
148        /// Target agent.
149        id: String,
150    },
151    /// Kill + respawn with the same spec.
152    Respawn {
153        /// Target agent.
154        id: String,
155    },
156    /// Remove an agent from the registry.
157    Rm {
158        /// Target agent.
159        id: String,
160        /// Force-remove even if running.
161        #[serde(default)]
162        force: bool,
163    },
164    /// Daemon health probe.
165    Status,
166    /// Ask the daemon to drain and shut down.
167    Shutdown,
168}
169
170/// Control-plane replies.
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(tag = "kind", rename_all = "snake_case")]
173pub enum CtlReply {
174    /// Successful list.
175    Listed {
176        /// Registered agents.
177        agents: Vec<AgentRecord>,
178    },
179    /// Successful spawn.
180    Spawned {
181        /// New id.
182        id: String,
183        /// Per-agent endpoint.
184        endpoint: Endpoint,
185    },
186    /// Successful attach handshake.
187    AttachAck {
188        /// Per-agent endpoint.
189        endpoint: Endpoint,
190    },
191    /// Successful kill.
192    Killed,
193    /// Successful respawn.
194    Respawned {
195        /// New id (old id removed).
196        id: String,
197    },
198    /// Successful rm.
199    Removed,
200    /// Daemon status snapshot.
201    Status(DaemonStatus),
202    /// Daemon will shut down once drained.
203    ShutdownAck,
204    /// An error occurred.
205    Error {
206        /// Structured error.
207        error: SupervisorError,
208    },
209}
210
211/// Errors the supervisor reports to clients.
212#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(tag = "kind", rename_all = "snake_case")]
214pub enum SupervisorError {
215    /// No such agent.
216    #[error("agent not found: {id}")]
217    NotFound {
218        /// Missing id.
219        id: String,
220    },
221    /// Agent is in the wrong state for the operation.
222    #[error("invalid state for {op}: agent {id} is {status:?}")]
223    InvalidState {
224        /// Operation attempted.
225        op: String,
226        /// Target id.
227        id: String,
228        /// Actual status.
229        status: AgentStatus,
230    },
231    /// Generic internal daemon error.
232    #[error("internal supervisor error: {message}")]
233    Internal {
234        /// Free-form message.
235        message: String,
236    },
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn endpoint_matches_caliban_wire_shape() {
245        // Byte-for-byte parity with caliban's transport::Endpoint.
246        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        // Round-trips both ways.
261        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        // Golden JSON in caliban's serialized SpawnSpec form (proto.rs). Pinned
314        // so upstream protocol drift on `interactive` fails loudly here.
315        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        // Bidirectional pin: our serialized form must match caliban's exact wire
324        // shape (field set + order), so adding/dropping a field drifts loudly.
325        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        // A provider set on our side must serialize into caliban's wire form,
335        // and caliban's serialized provider must deserialize back. Guards the
336        // #93 contract end-to-end at the wire boundary.
337        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        // Back-compat: a pre-provider spec (field absent) still deserializes.
350        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        // Back-compat: a pre-interactive spec (field absent) still deserializes.
358        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        // Symmetric drift guard: the tagged shape must survive a serialize →
381        // deserialize round-trip for both variants.
382        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}