Skip to main content

ix_daemon/
lib.rs

1//! ix-daemon: Global per-user daemon for Ixchel.
2//!
3//! Provides IPC, sync queueing, and single-writer enforcement across repos and tools.
4//!
5//! # Protocol
6//!
7//! All messages are UTF-8 JSON lines over Unix socket (`~/.ixchel/run/ixcheld.sock`).
8//! See `specs/design.md` for the full protocol specification.
9
10mod client;
11mod queue;
12mod server;
13
14pub use client::Client;
15pub use queue::{QueueKey, SyncJob, SyncQueue};
16pub use server::Server;
17
18use serde::{Deserialize, Serialize};
19use thiserror::Error;
20
21/// Protocol version. Increment on breaking changes.
22pub const PROTOCOL_VERSION: u32 = 1;
23
24/// Default socket path (Unix).
25pub const DEFAULT_SOCKET_PATH: &str = "~/.ixchel/run/ixcheld.sock";
26
27/// Default idle timeout before daemon shuts down (milliseconds).
28pub const DEFAULT_IDLE_TIMEOUT_MS: u64 = 300_000; // 5 minutes
29
30// ============================================================================
31// Errors
32// ============================================================================
33
34#[derive(Debug, Error)]
35pub enum DaemonError {
36    #[error("Invalid request: {0}")]
37    InvalidRequest(String),
38
39    #[error("Incompatible protocol version: expected {expected}, got {got}")]
40    IncompatibleVersion { expected: u32, got: u32 },
41
42    #[error("Repository not found: {0}")]
43    RepoNotFound(String),
44
45    #[error("Timeout waiting for sync: {0}")]
46    Timeout(String),
47
48    #[error("Internal error: {0}")]
49    Internal(String),
50
51    #[error("IO error: {0}")]
52    Io(#[from] std::io::Error),
53
54    #[error("JSON error: {0}")]
55    Json(#[from] serde_json::Error),
56}
57
58/// Error codes for protocol responses.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum ErrorCode {
62    InvalidRequest,
63    IncompatibleVersion,
64    RepoNotFound,
65    Timeout,
66    InternalError,
67}
68
69impl From<&DaemonError> for ErrorCode {
70    fn from(err: &DaemonError) -> Self {
71        match err {
72            DaemonError::InvalidRequest(_) => Self::InvalidRequest,
73            DaemonError::IncompatibleVersion { .. } => Self::IncompatibleVersion,
74            DaemonError::RepoNotFound(_) => Self::RepoNotFound,
75            DaemonError::Timeout(_) => Self::Timeout,
76            DaemonError::Internal(_) | DaemonError::Io(_) | DaemonError::Json(_) => {
77                Self::InternalError
78            }
79        }
80    }
81}
82
83// ============================================================================
84// Protocol: Request
85// ============================================================================
86
87/// Request envelope sent from CLI to daemon.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Request {
90    /// Protocol version.
91    pub version: u32,
92
93    /// Unique request ID for correlation.
94    pub id: String,
95
96    /// Absolute path to repository root.
97    pub repo_root: String,
98
99    /// Tool name (e.g., "decisions", "issues", "reports").
100    pub tool: String,
101
102    /// Command to execute.
103    pub command: Command,
104}
105
106impl Request {
107    /// Create a new request with a random ID.
108    pub fn new(repo_root: impl Into<String>, tool: impl Into<String>, command: Command) -> Self {
109        Self {
110            version: PROTOCOL_VERSION,
111            id: uuid::Uuid::new_v4().to_string(),
112            repo_root: repo_root.into(),
113            tool: tool.into(),
114            command,
115        }
116    }
117}
118
119/// Commands supported by the daemon.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121#[serde(tag = "command", content = "payload", rename_all = "snake_case")]
122pub enum Command {
123    /// Health check.
124    Ping,
125
126    /// Enqueue a sync job.
127    EnqueueSync(EnqueueSyncPayload),
128
129    /// Wait for a sync job to complete.
130    WaitSync(WaitSyncPayload),
131
132    /// Query daemon status.
133    Status(StatusPayload),
134
135    /// Shutdown the daemon (dev/test only).
136    Shutdown(ShutdownPayload),
137}
138
139#[derive(Debug, Clone, Default, Serialize, Deserialize)]
140pub struct EnqueueSyncPayload {
141    /// Directory to sync (e.g., ".ixchel/decisions").
142    pub directory: String,
143
144    /// Force full resync even if no changes detected.
145    #[serde(default)]
146    pub force: bool,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct WaitSyncPayload {
151    /// Sync ID returned by `enqueue_sync`.
152    pub sync_id: String,
153
154    /// Timeout in milliseconds.
155    #[serde(default = "default_timeout_ms")]
156    pub timeout_ms: u64,
157}
158
159const fn default_timeout_ms() -> u64 {
160    30_000
161}
162
163#[derive(Debug, Clone, Default, Serialize, Deserialize)]
164pub struct StatusPayload {
165    /// Filter by repo root (optional).
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub repo_root: Option<String>,
168
169    /// Filter by tool (optional).
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub tool: Option<String>,
172}
173
174#[derive(Debug, Clone, Default, Serialize, Deserialize)]
175pub struct ShutdownPayload {
176    /// Reason for shutdown.
177    #[serde(default)]
178    pub reason: String,
179}
180
181// ============================================================================
182// Protocol: Response
183// ============================================================================
184
185/// Response envelope sent from daemon to CLI.
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct Response {
188    /// Protocol version.
189    pub version: u32,
190
191    /// Request ID this response correlates to.
192    pub id: String,
193
194    /// Response status.
195    #[serde(flatten)]
196    pub result: ResponseResult,
197}
198
199impl Response {
200    /// Create a success response.
201    pub fn ok(id: impl Into<String>, payload: ResponsePayload) -> Self {
202        Self {
203            version: PROTOCOL_VERSION,
204            id: id.into(),
205            result: ResponseResult::Ok { payload },
206        }
207    }
208
209    /// Create an error response.
210    pub fn error(id: impl Into<String>, code: ErrorCode, message: impl Into<String>) -> Self {
211        Self {
212            version: PROTOCOL_VERSION,
213            id: id.into(),
214            result: ResponseResult::Error {
215                error: ErrorInfo {
216                    code,
217                    message: message.into(),
218                },
219            },
220        }
221    }
222
223    /// Create an error response from a [`DaemonError`].
224    pub fn from_error(id: impl Into<String>, err: &DaemonError) -> Self {
225        Self::error(id, ErrorCode::from(err), err.to_string())
226    }
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
230#[serde(tag = "status", rename_all = "snake_case")]
231pub enum ResponseResult {
232    Ok { payload: ResponsePayload },
233    Error { error: ErrorInfo },
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct ErrorInfo {
238    pub code: ErrorCode,
239    pub message: String,
240}
241
242/// Response payloads for each command.
243#[derive(Debug, Clone, Serialize, Deserialize)]
244#[serde(untagged)]
245pub enum ResponsePayload {
246    Ping(PingResponse),
247    EnqueueSync(EnqueueSyncResponse),
248    WaitSync(WaitSyncResponse),
249    Status(StatusResponse),
250    Shutdown(ShutdownResponse),
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct PingResponse {
255    pub daemon_version: String,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct EnqueueSyncResponse {
260    pub sync_id: String,
261    pub queued_at_ms: u64,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct WaitSyncResponse {
266    pub sync_id: String,
267    pub state: SyncState,
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub stats: Option<SyncStats>,
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
273#[serde(rename_all = "snake_case")]
274pub enum SyncState {
275    Queued,
276    Running,
277    Done,
278    Error,
279}
280
281#[derive(Debug, Clone, Default, Serialize, Deserialize)]
282pub struct SyncStats {
283    pub files_scanned: u64,
284    pub files_updated: u64,
285    pub duration_ms: u64,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct StatusResponse {
290    pub queues: Vec<QueueInfo>,
291    pub uptime_ms: u64,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct QueueInfo {
296    pub repo_root: String,
297    pub tool: String,
298    pub pending: u32,
299    pub active: Option<String>,
300}
301
302#[derive(Debug, Clone, Default, Serialize, Deserialize)]
303pub struct ShutdownResponse {}
304
305// ============================================================================
306// Tests
307// ============================================================================
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn test_request_serialization() {
315        let req = Request::new("/path/to/repo", "decisions", Command::Ping);
316        let json = serde_json::to_string(&req).unwrap();
317        assert!(json.contains("\"version\":1"));
318        assert!(json.contains("\"command\":\"ping\""));
319    }
320
321    #[test]
322    fn test_enqueue_sync_request() {
323        let req = Request::new(
324            "/path/to/repo",
325            "decisions",
326            Command::EnqueueSync(EnqueueSyncPayload {
327                directory: ".ixchel/decisions".to_string(),
328                force: false,
329            }),
330        );
331        let json = serde_json::to_string(&req).unwrap();
332        assert!(json.contains("\"command\":\"enqueue_sync\""));
333        assert!(json.contains("\".ixchel/decisions\""));
334    }
335
336    #[test]
337    fn test_response_ok_serialization() {
338        let resp = Response::ok(
339            "test-id",
340            ResponsePayload::Ping(PingResponse {
341                daemon_version: "0.1.0".to_string(),
342            }),
343        );
344        let json = serde_json::to_string(&resp).unwrap();
345        assert!(json.contains("\"status\":\"ok\""));
346        assert!(json.contains("\"daemon_version\":\"0.1.0\""));
347    }
348
349    #[test]
350    fn test_response_error_serialization() {
351        let resp = Response::error("test-id", ErrorCode::RepoNotFound, "repo not found");
352        let json = serde_json::to_string(&resp).unwrap();
353        assert!(json.contains("\"status\":\"error\""));
354        assert!(json.contains("\"code\":\"repo_not_found\""));
355    }
356
357    #[test]
358    fn test_request_roundtrip() {
359        let req = Request::new(
360            "/path/to/repo",
361            "issues",
362            Command::WaitSync(WaitSyncPayload {
363                sync_id: "sync-123".to_string(),
364                timeout_ms: 5000,
365            }),
366        );
367        let json = serde_json::to_string(&req).unwrap();
368        let parsed: Request = serde_json::from_str(&json).unwrap();
369        assert_eq!(parsed.repo_root, "/path/to/repo");
370        assert_eq!(parsed.tool, "issues");
371    }
372
373    #[test]
374    fn test_sync_state_values() {
375        assert_eq!(
376            serde_json::to_string(&SyncState::Queued).unwrap(),
377            "\"queued\""
378        );
379        assert_eq!(
380            serde_json::to_string(&SyncState::Running).unwrap(),
381            "\"running\""
382        );
383        assert_eq!(serde_json::to_string(&SyncState::Done).unwrap(), "\"done\"");
384        assert_eq!(
385            serde_json::to_string(&SyncState::Error).unwrap(),
386            "\"error\""
387        );
388    }
389}