Skip to main content

unifier/daemon/
protocol.rs

1//! Line-delimited JSON protocol between CLI clients and the hot daemon.
2
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6use crate::postbox::Message;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9#[serde(tag = "op", rename_all = "snake_case")]
10pub enum Request {
11    Put {
12        key: String,
13        value: String,
14    },
15    Get {
16        key: String,
17    },
18    Del {
19        key: String,
20    },
21    Send {
22        #[serde(default)]
23        from: Option<String>,
24        recipient: String,
25        message: String,
26    },
27    Cron {
28        schedule: String,
29        message: String,
30    },
31    Poll {
32        recipient: String,
33    },
34    PollCron,
35    List {
36        path: String,
37    },
38    Ack {
39        id_or_path: String,
40    },
41    Flush,
42    Ping,
43    Shutdown,
44    TickStart {
45        label: String,
46    },
47    TickEnd,
48    TickStatus,
49    TickLock {
50        key: String,
51    },
52    TickUnlock {
53        key: String,
54    },
55    /// Announce a named phase within the active tick (e.g. sense / decide / act).
56    TickPhase {
57        phase: String,
58    },
59    Event {
60        payload: String,
61        /// Seconds until the event is deleted. Omit to use the default (24h) or payload fields.
62        #[serde(default)]
63        ttl: Option<u64>,
64    },
65    AgentMessage {
66        from: String,
67        to: String,
68        payload: String,
69    },
70    /// Report HTTP base URL / port for the www server.
71    WebStatus,
72    /// List published temp files.
73    WebList,
74    /// Remove a published temp file by name.
75    WebRm {
76        name: String,
77    },
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(tag = "status", rename_all = "snake_case")]
82pub enum Response {
83    Ok {
84        #[serde(default, skip_serializing_if = "Option::is_none")]
85        value: Option<String>,
86        #[serde(default, skip_serializing_if = "Option::is_none")]
87        uuid: Option<Uuid>,
88        #[serde(default, skip_serializing_if = "Option::is_none")]
89        found: Option<bool>,
90        #[serde(default, skip_serializing_if = "Option::is_none")]
91        dirty: Option<bool>,
92        #[serde(default, skip_serializing_if = "Option::is_none")]
93        tick: Option<u64>,
94        #[serde(default, skip_serializing_if = "Option::is_none")]
95        queued: Option<usize>,
96        /// Active tick phase (`start`, custom phase, or unset when idle).
97        #[serde(default, skip_serializing_if = "Option::is_none")]
98        phase: Option<String>,
99        /// Latest committed tick number (tick status responses).
100        #[serde(default, skip_serializing_if = "Option::is_none")]
101        committed: Option<u64>,
102        /// Active tick label (tick status / start responses).
103        #[serde(default, skip_serializing_if = "Option::is_none")]
104        label: Option<String>,
105        #[serde(default, skip_serializing_if = "Vec::is_empty")]
106        locked_keys: Vec<String>,
107        #[serde(default, skip_serializing_if = "Vec::is_empty")]
108        messages: Vec<MessageDto>,
109    },
110    Err {
111        error: String,
112    },
113}
114
115/// Whether a request is allowed on `.daemon/tick.sock`.
116///
117/// The tick socket is a capability boundary: an external tick driver may run
118/// turn lifecycles without gaining the ability to mutate the board.
119pub fn is_tick_socket_request(request: &Request) -> bool {
120    matches!(
121        request,
122        Request::Ping
123            | Request::TickStart { .. }
124            | Request::TickEnd
125            | Request::TickStatus
126            | Request::TickLock { .. }
127            | Request::TickUnlock { .. }
128            | Request::TickPhase { .. }
129    )
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct MessageDto {
134    pub id: Uuid,
135    pub path: String,
136    pub body: String,
137}
138
139impl From<Message> for MessageDto {
140    fn from(m: Message) -> Self {
141        Self {
142            id: m.id,
143            path: m.path.display().to_string(),
144            body: m.body,
145        }
146    }
147}
148
149impl From<MessageDto> for Message {
150    fn from(m: MessageDto) -> Self {
151        Self {
152            id: m.id,
153            path: m.path.into(),
154            body: m.body,
155        }
156    }
157}
158
159pub fn encode_response(response: &Response) -> Result<String, serde_json::Error> {
160    Ok(format!("{}\n", serde_json::to_string(response)?))
161}
162
163pub fn decode_request(line: &str) -> Result<Request, serde_json::Error> {
164    serde_json::from_str(line.trim())
165}
166
167pub fn decode_response(line: &str) -> Result<Response, serde_json::Error> {
168    serde_json::from_str(line.trim())
169}
170
171pub fn ok_empty() -> Response {
172    Response::Ok {
173        value: None,
174        uuid: None,
175        found: None,
176        dirty: None,
177        tick: None,
178        queued: None,
179        phase: None,
180        committed: None,
181        label: None,
182        locked_keys: vec![],
183        messages: vec![],
184    }
185}