Skip to main content

zeroclaw_api/
jsonrpc.rs

1//! Shared JSON-RPC 2.0 types for the ACP server and runtime RPC layer.
2//!
3//! Extracted from `zeroclaw-channels::orchestrator::acp_server` so both the
4//! ACP stdio channel and the Unix socket RPC transport can share the same
5//! wire types without cross-crate dependency.
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::HashMap;
10use std::sync::atomic::{AtomicU64, Ordering};
11use tokio::sync::{mpsc, oneshot};
12
13// ── Protocol constants ───────────────────────────────────────────
14
15/// JSON-RPC protocol version string. Used in every frame's `jsonrpc` field.
16pub const JSONRPC_VERSION: &str = "2.0";
17
18/// Prefix for server-originated outbound request IDs, disjoint from any
19/// client-issued id space.
20pub const OUTBOUND_ID_PREFIX: &str = "zc-out-";
21
22// ── Wire field name constants ────────────────────────────────────
23// Used when parsing raw `Value` frames (e.g. in the client read loop).
24
25pub mod field {
26    pub const JSONRPC: &str = "jsonrpc";
27    pub const METHOD: &str = "method";
28    pub const PARAMS: &str = "params";
29    pub const ID: &str = "id";
30    pub const RESULT: &str = "result";
31    pub const ERROR: &str = "error";
32}
33
34// ── Wire types ───────────────────────────────────────────────────
35
36#[derive(Debug, Serialize, Deserialize)]
37pub struct JsonRpcRequest {
38    pub jsonrpc: String,
39    pub method: String,
40    #[serde(default)]
41    pub params: Value,
42    pub id: Option<Value>,
43}
44
45impl JsonRpcRequest {
46    /// Build a request with an auto-incremented numeric id.
47    pub fn new(method: &str, params: Value, id: Value) -> Self {
48        Self {
49            jsonrpc: JSONRPC_VERSION.to_string(),
50            method: method.to_string(),
51            params,
52            id: Some(id),
53        }
54    }
55}
56
57#[derive(Debug, Serialize)]
58pub struct JsonRpcResponse {
59    pub jsonrpc: &'static str,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub result: Option<Value>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub error: Option<JsonRpcError>,
64    pub id: Value,
65}
66
67#[derive(Debug, Serialize)]
68pub struct JsonRpcNotification {
69    pub jsonrpc: &'static str,
70    pub method: &'static str,
71    pub params: Value,
72}
73
74impl JsonRpcNotification {
75    pub fn new(method: &'static str, params: Value) -> Self {
76        Self {
77            jsonrpc: JSONRPC_VERSION,
78            method,
79            params,
80        }
81    }
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct JsonRpcError {
86    pub code: i32,
87    pub message: String,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub data: Option<Value>,
90}
91
92// ── Error codes ──────────────────────────────────────────────────
93
94pub mod error_codes {
95    // Standard JSON-RPC 2.0
96    pub const PARSE_ERROR: i32 = -32700;
97    pub const INVALID_REQUEST: i32 = -32600;
98    pub const METHOD_NOT_FOUND: i32 = -32601;
99    pub const INVALID_PARAMS: i32 = -32602;
100    pub const INTERNAL_ERROR: i32 = -32603;
101
102    // ZeroClaw custom
103    pub const SESSION_NOT_FOUND: i32 = -32000;
104    pub const SESSION_LIMIT_REACHED: i32 = -32001;
105    pub const SESSION_BUSY: i32 = -32002;
106    pub const SESSION_NOT_OWNED: i32 = -32003;
107    pub const AUTH_REQUIRED: i32 = -32010;
108    pub const VERSION_MISMATCH: i32 = -32011;
109
110    // Filesystem RPC errors (internal numeric codes; wire uses string codes e.g. "fs.not_found")
111    pub const FS_NOT_FOUND: i32 = 4001;
112    pub const FS_PERMISSION_DENIED: i32 = 4002;
113    pub const FS_INVALID_PATH: i32 = 4003;
114
115    // String error codes for fs.* methods
116    pub const FS_NOT_FOUND_STR: &str = "fs.not_found";
117    pub const FS_PERMISSION_DENIED_STR: &str = "fs.permission_denied";
118    pub const FS_INVALID_PATH_STR: &str = "fs.invalid_path";
119}
120
121pub const ACP_PROTOCOL_VERSION: u64 = 1;
122
123// ── Outbound RPC plumbing ────────────────────────────────────────
124
125type PendingResponder = oneshot::Sender<std::result::Result<Value, JsonRpcError>>;
126
127/// Writer + outbound-call tracker shared between server loops and
128/// per-session bridges (e.g. AcpChannel, RpcDispatcher).
129///
130/// All writes go through `writer_tx` so concurrent notifications and
131/// outbound requests cannot interleave bytes. Outbound requests get string
132/// ids (`zc-out-<n>`) disjoint from any client-issued id space.
133#[derive(Debug)]
134pub struct RpcOutbound {
135    writer_tx: mpsc::Sender<String>,
136    pending: std::sync::Mutex<HashMap<String, PendingResponder>>,
137    next_id: AtomicU64,
138}
139
140struct PendingRequestGuard<'a> {
141    pending: &'a std::sync::Mutex<HashMap<String, PendingResponder>>,
142    id: String,
143}
144
145impl Drop for PendingRequestGuard<'_> {
146    fn drop(&mut self) {
147        self.pending
148            .lock()
149            .unwrap_or_else(|e| e.into_inner())
150            .remove(&self.id);
151    }
152}
153
154impl RpcOutbound {
155    pub fn new(writer_tx: mpsc::Sender<String>) -> Self {
156        Self {
157            writer_tx,
158            pending: std::sync::Mutex::new(HashMap::new()),
159            next_id: AtomicU64::new(0),
160        }
161    }
162
163    /// Send a raw pre-serialized JSON line. Returns `true` on success.
164    pub async fn send_raw(&self, json: String) -> bool {
165        self.writer_tx.send(json).await.is_ok()
166    }
167
168    /// Resolve when the writer end is closed (peer dropped). Useful for
169    /// long-lived forwarders that need to exit on disconnect even when
170    /// there is no payload to send.
171    pub async fn closed(&self) {
172        self.writer_tx.closed().await;
173    }
174
175    /// Send a JSON-RPC notification (no `id`, no response expected).
176    pub async fn notify(&self, method: &'static str, params: Value) {
177        let n = JsonRpcNotification::new(method, params);
178        if let Ok(s) = serde_json::to_string(&n) {
179            let _ = self.writer_tx.send(s).await;
180        }
181    }
182
183    /// Send a JSON-RPC request and await the response.
184    pub async fn request(
185        &self,
186        method: &str,
187        params: Value,
188    ) -> std::result::Result<Value, JsonRpcError> {
189        let n = self.next_id.fetch_add(1, Ordering::Relaxed);
190        let id = format!("{OUTBOUND_ID_PREFIX}{n}");
191        let (tx, rx) = oneshot::channel();
192        {
193            let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner());
194            pending.insert(id.clone(), tx);
195        }
196        let _pending_guard = PendingRequestGuard {
197            pending: &self.pending,
198            id: id.clone(),
199        };
200        let req = JsonRpcRequest::new(method, params, Value::String(id));
201        let body = match serde_json::to_string(&req) {
202            Ok(s) => s,
203            Err(e) => {
204                return Err(JsonRpcError {
205                    code: error_codes::INTERNAL_ERROR,
206                    message: format!("Failed to encode request: {e}"),
207                    data: None,
208                });
209            }
210        };
211        if self.writer_tx.send(body).await.is_err() {
212            return Err(JsonRpcError {
213                code: error_codes::INTERNAL_ERROR,
214                message: "Writer task closed".to_string(),
215                data: None,
216            });
217        }
218        rx.await.unwrap_or_else(|_| {
219            Err(JsonRpcError {
220                code: error_codes::INTERNAL_ERROR,
221                message: "Outbound RPC dropped".to_string(),
222                data: None,
223            })
224        })
225    }
226
227    /// Route an inbound JSON-RPC response to its pending caller.
228    pub fn dispatch_response(
229        &self,
230        id_str: &str,
231        result: Option<Value>,
232        error: Option<JsonRpcError>,
233    ) {
234        let responder = self
235            .pending
236            .lock()
237            .unwrap_or_else(|e| e.into_inner())
238            .remove(id_str);
239        if let Some(tx) = responder {
240            let payload = if let Some(err) = error {
241                Err(err)
242            } else {
243                Ok(result.unwrap_or(Value::Null))
244            };
245            let _ = tx.send(payload);
246        }
247    }
248
249    /// Number of in-flight outbound requests awaiting responses.
250    pub fn pending_count(&self) -> usize {
251        self.pending.lock().unwrap_or_else(|e| e.into_inner()).len()
252    }
253}
254
255// ── Locale RPC types ─────────────────────────────────────────────
256
257/// One selectable locale from the build's embedded `locales.toml` registry.
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct LocaleOption {
260    pub code: String,
261    pub label: String,
262}
263
264/// Response for `locales/list` — the in-memory locale registry.
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct LocalesListResponse {
267    pub locales: Vec<LocaleOption>,
268}
269
270/// Request payload for `locales/fetch`. `catalog` restricts which catalogues
271/// are downloaded; `None`/empty means all. The daemon validates `locale`
272/// against the embedded registry and `catalog` against the fixed catalog set.
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct LocalesFetchRequest {
275    pub locale: String,
276    #[serde(default)]
277    pub catalog: Vec<String>,
278}
279
280/// One fetched catalogue's bytes, returned over the wire so the client writes
281/// them into its own config dir (keeping the write in the caller's permission
282/// scope).
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct FetchedCatalog {
285    pub name: String,
286    /// Output filename (e.g. `cli.ftl`).
287    pub filename: String,
288    /// The FTL file contents.
289    pub content: String,
290}
291
292/// Response for `locales/fetch`.
293#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct LocalesFetchResponse {
295    pub locale: String,
296    pub catalogs: Vec<FetchedCatalog>,
297    /// Catalogue names that had no file on upstream and were skipped.
298    pub skipped: Vec<String>,
299}
300
301// ── Filesystem RPC types ─────────────────────────────────────────
302
303/// Request payload for `fs.list_dir`.
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct FsListDirRequest {
306    /// Relative or absolute path within the agent workspace.
307    pub path: String,
308    #[serde(default)]
309    pub show_hidden: bool,
310}
311
312/// Response for `fs.list_dir`.
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct FsListDirResponse {
315    pub entries: Vec<FsEntry>,
316    pub cwd: String,
317}
318
319/// A single directory entry returned by `fs.list_dir`.
320#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct FsEntry {
322    pub name: String,
323    pub full_path: String,
324    pub is_dir: bool,
325    pub is_hidden: bool,
326    pub size: u64,
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub mtime: Option<u64>,
329}
330
331/// Filesystem stat result (success case). Matches FsEntry shape with extra fields.
332#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct FsStatResult {
334    pub name: String,
335    pub full_path: String,
336    pub is_dir: bool,
337    pub is_hidden: bool,
338    pub size: u64,
339    pub mtime: u64,
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub mode: Option<u32>,
342}
343
344/// Filesystem stat error payload (used inside `JsonRpcError.data`).
345#[derive(Debug, Clone, Serialize, Deserialize)]
346pub struct FsStatError {
347    pub path: String,
348    pub code: &'static str, // e.g. "fs.not_found"
349    pub message: String,
350}