sqry_daemon/error.rs
1//! Daemon-wide error type.
2//!
3//! Thin `thiserror` enum covering every fallible surface of the daemon:
4//! config loading, workspace lifecycle, admission control, IPC transport,
5//! rebuild dispatch, and lifecycle management (pidfile, signals, auto-start).
6//! Tasks 6–10 extend this enum as each surface lands.
7//! Every variant maps cleanly to a JSON-RPC error code when the error
8//! crosses the IPC boundary (see [`DaemonError::jsonrpc_code`]).
9//!
10//! # Exit-code mapping (Task 9 U1)
11//!
12//! Variants that can be returned before the IPC server binds (lifecycle errors)
13//! map to POSIX `sysexits.h` exit codes via [`DaemonError::exit_code`]:
14//!
15//! | Variant | Exit code | `sysexits.h` constant |
16//! |---------------------|-----------|------------------------|
17//! | `AlreadyRunning` | 75 | `EX_TEMPFAIL` |
18//! | `AutoStartTimeout` | 69 | `EX_UNAVAILABLE` |
19//! | `SignalSetup` | 70 | `EX_SOFTWARE` |
20//! | `Config` | 78 | `EX_CONFIG` |
21//! | `Io` | 73 | `EX_CANTCREAT` |
22//! | Other variants | 70 | `EX_SOFTWARE` (default)|
23
24use std::{path::PathBuf, time::SystemTime};
25
26use thiserror::Error;
27
28use crate::{
29 JSONRPC_INTERNAL_ERROR, JSONRPC_INVALID_PARAMS, JSONRPC_MEMORY_BUDGET_EXCEEDED,
30 JSONRPC_TOOL_TIMEOUT, JSONRPC_WORKSPACE_BUILD_FAILED, JSONRPC_WORKSPACE_EVICTED,
31 JSONRPC_WORKSPACE_STALE_EXPIRED,
32};
33
34/// Result alias for daemon operations.
35pub type DaemonResult<T> = Result<T, DaemonError>;
36
37/// All daemon-surface error variants.
38#[derive(Debug, Error)]
39pub enum DaemonError {
40 /// Config file could not be read or parsed.
41 #[error("config error at {path}: {source}")]
42 Config {
43 path: PathBuf,
44 #[source]
45 source: anyhow::Error,
46 },
47
48 /// An `io::Error` occurred outside the config surface (socket bind,
49 /// pidfile lock, filesystem probe, etc.).
50 #[error(transparent)]
51 Io(#[from] std::io::Error),
52
53 /// Workspace load / rebuild failed with no prior-good graph to serve from.
54 ///
55 /// Maps to JSON-RPC `-32001`.
56 #[error("workspace {root} build failed: {reason}")]
57 WorkspaceBuildFailed { root: PathBuf, reason: String },
58
59 /// Workspace is in the Failed state and the most recent successful build
60 /// is older than the configured `stale_serve_max_age_hours` cap.
61 ///
62 /// Maps to JSON-RPC `-32002`.
63 #[error("workspace {root} stale-serve window expired ({age_hours}h >= {cap_hours}h cap)")]
64 WorkspaceStaleExpired {
65 root: PathBuf,
66 age_hours: u64,
67 cap_hours: u32,
68 /// Last successful build timestamp, if any. `None` when the workspace
69 /// has never successfully built (edge case: should not reach
70 /// `WorkspaceStaleExpired` in that case — `WorkspaceBuildFailed` is
71 /// returned instead — but the type is permissive for future-proofing).
72 last_good_at: Option<SystemTime>,
73 /// Textual diagnostic from the most recent failed build, if any.
74 last_error: Option<String>,
75 },
76
77 /// Admission control could not satisfy a reservation after evicting every
78 /// non-pinned workspace.
79 ///
80 /// Maps to JSON-RPC `-32003`.
81 #[error(
82 "memory budget exceeded: requested {requested_bytes} B, \
83 {current_bytes} B loaded + {reserved_bytes} B reserved + \
84 {retained_bytes} B retained / {limit_bytes} B limit"
85 )]
86 MemoryBudgetExceeded {
87 limit_bytes: u64,
88 current_bytes: u64,
89 reserved_bytes: u64,
90 retained_bytes: u64,
91 requested_bytes: u64,
92 },
93
94 /// Workspace was evicted or removed between a rebuild dispatch and its
95 /// admission / publish commit. Signals the Task 7b2 watcher task and any
96 /// direct `handle_changes` caller to terminate their per-workspace loop —
97 /// subsequent dispatches on the same `WorkspaceKey` must route through a
98 /// fresh `get_or_load` first.
99 ///
100 /// Surfaced by `RebuildDispatcher::handle_changes`' top-of-drain-loop
101 /// eviction gate AND by `WorkspaceManager::reserve_rebuild`'s Phase-1
102 /// `workspaces.read()` membership + cancellation check (both paths use
103 /// this typed variant so 7b2 can match on it without string parsing).
104 ///
105 /// Maps to JSON-RPC `-32004`.
106 #[error("workspace {root} evicted mid-rebuild")]
107 WorkspaceEvicted { root: PathBuf },
108
109 /// Caller requested `daemon/rebuild` or `daemon/cancel_rebuild` for a
110 /// path that is not currently registered in the `WorkspaceManager`.
111 ///
112 /// Shares the JSON-RPC `-32004` code with [`Self::WorkspaceEvicted`].
113 /// The `error_data` `"hint"` field distinguishes the two situations on
114 /// the wire.
115 ///
116 /// Maps to JSON-RPC `-32004`.
117 #[error("workspace {root} is not loaded")]
118 WorkspaceNotLoaded { root: PathBuf },
119
120 /// Tool invocation exceeded [`DaemonConfig::tool_timeout_secs`].
121 /// Emitted by `tool_core::classify_and_execute` (Task 8 Phase 8c U6)
122 /// when the `tokio::time::timeout(tool_timeout, spawn_blocking(run))`
123 /// outer timer fires. The detached [`tokio::task::JoinHandle`] is
124 /// dropped — the OS thread may continue executing the tool closure
125 /// but its result is discarded.
126 ///
127 /// The `deadline_ms` field is the canonical wire value (populated by
128 /// the constructor as `secs * 1000`) so `error_data` does not have
129 /// to re-derive it on every call and serialised payloads remain
130 /// byte-for-byte identical regardless of constructor shape.
131 ///
132 /// Maps to JSON-RPC `-32000`.
133 ///
134 /// [`DaemonConfig::tool_timeout_secs`]: crate::config::DaemonConfig
135 #[error(
136 "tool invocation exceeded deadline of {deadline_ms}ms for workspace {}",
137 root.display()
138 )]
139 ToolTimeout {
140 root: PathBuf,
141 secs: u64,
142 /// Derived: `secs * 1000`. Stored explicitly to avoid
143 /// re-calculating inside `error_data` / `Display` impls and to
144 /// give the MCP-path wrapper (`daemon_err_to_mcp`, Phase 8c U8)
145 /// a single field to read.
146 deadline_ms: u64,
147 },
148
149 /// Argument validation failure surfaced by `tool_core` BEFORE any
150 /// workspace classification runs. Used for `resolve_index_root`
151 /// failures, missing `path` arguments in MCP tool args, and any
152 /// other precondition violation that must be rejected with a
153 /// JSON-RPC `-32602` "Invalid params" response.
154 ///
155 /// Maps to JSON-RPC `-32602`.
156 #[error("invalid argument: {reason}")]
157 InvalidArgument { reason: String },
158
159 /// Catch-all for errors surfaced by
160 /// [`sqry_mcp::daemon_adapter`][1] tool execution that do not map
161 /// to a more specific `DaemonError` variant. The wrapped
162 /// `anyhow::Error` is flattened into a string on the wire via the
163 /// `Display`/`#[source]` chain.
164 ///
165 /// Maps to JSON-RPC `-32603`.
166 ///
167 /// [1]: https://docs.rs/sqry-mcp/latest/sqry_mcp/daemon_adapter/index.html
168 #[error("internal error: {0}")]
169 Internal(#[source] anyhow::Error),
170
171 // ── Task 9 U1 — lifecycle error variants ─────────────────────────────
172 /// A sqryd process already holds the exclusive flock on `lock` and has
173 /// written its PID to `pidfile`. The caller should surface this to the
174 /// user with the owner PID (if legible) and exit `EX_TEMPFAIL` (75).
175 ///
176 /// This error fires before [`IpcServer::bind`] and therefore before any
177 /// workspace is registered; it should never be stored in the workspace
178 /// `last_error` field. [`crate::workspace::manager::clone_err`] maps it
179 /// to `WorkspaceBuildFailed` as a defensive fallback.
180 ///
181 /// [`IpcServer::bind`]: crate::ipc::IpcServer
182 #[error(
183 "sqryd is already running (pid={}) on socket {} (lock: {})",
184 owner_pid.map_or_else(|| "?".to_owned(), |p| p.to_string()),
185 socket.display(),
186 lock.display()
187 )]
188 AlreadyRunning {
189 /// The IPC socket path that the running daemon owns.
190 socket: PathBuf,
191 /// The flock file that proves ownership.
192 lock: PathBuf,
193 /// PID of the owner process, if the pidfile was legible.
194 owner_pid: Option<u32>,
195 },
196
197 /// The daemon did not become ready within `timeout_secs` seconds.
198 /// Used by both the `--detach` parent wait loop and the
199 /// `lifecycle::start_detached` auto-spawn helper (Task 10).
200 ///
201 /// Callers should exit `EX_UNAVAILABLE` (69).
202 #[error(
203 "daemon did not become ready within {timeout_secs}s on socket {}",
204 socket.display()
205 )]
206 AutoStartTimeout {
207 /// How long we waited.
208 timeout_secs: u64,
209 /// The socket we polled.
210 socket: PathBuf,
211 },
212
213 /// Installing OS signal handlers failed (e.g. `sigaction` returned
214 /// `ENOSYS` in a highly-restricted container, or tokio's signal
215 /// registration failed).
216 ///
217 /// Callers should exit `EX_SOFTWARE` (70).
218 #[error("failed to install signal handlers: {source}")]
219 SignalSetup {
220 #[source]
221 source: std::io::Error,
222 },
223}
224
225impl DaemonError {
226 /// Map to the stable JSON-RPC error code used on the wire.
227 ///
228 /// Returns `None` for errors that have no public JSON-RPC code — these
229 /// are serialised as `-32603 "Internal error"` per the JSON-RPC 2.0 spec
230 /// at the IPC boundary (wired in Task 8).
231 ///
232 /// The Task 9 lifecycle variants (`AlreadyRunning`, `AutoStartTimeout`,
233 /// `SignalSetup`) fire before `IpcServer::bind` so they never cross the
234 /// IPC boundary directly; `None` is returned for them here. They are
235 /// only surfaced to human users via `exit_code()` and process exit.
236 #[must_use]
237 pub const fn jsonrpc_code(&self) -> Option<i32> {
238 match self {
239 Self::WorkspaceBuildFailed { .. } => Some(JSONRPC_WORKSPACE_BUILD_FAILED),
240 Self::WorkspaceStaleExpired { .. } => Some(JSONRPC_WORKSPACE_STALE_EXPIRED),
241 Self::MemoryBudgetExceeded { .. } => Some(JSONRPC_MEMORY_BUDGET_EXCEEDED),
242 Self::WorkspaceEvicted { .. } | Self::WorkspaceNotLoaded { .. } => {
243 Some(JSONRPC_WORKSPACE_EVICTED)
244 }
245 Self::ToolTimeout { .. } => Some(JSONRPC_TOOL_TIMEOUT),
246 Self::InvalidArgument { .. } => Some(JSONRPC_INVALID_PARAMS),
247 Self::Internal(_) => Some(JSONRPC_INTERNAL_ERROR),
248 // Lifecycle errors don't cross the IPC boundary.
249 Self::AlreadyRunning { .. }
250 | Self::AutoStartTimeout { .. }
251 | Self::SignalSetup { .. }
252 | Self::Config { .. }
253 | Self::Io(_) => None,
254 }
255 }
256
257 /// Map to a POSIX process exit code following the BSD `sysexits.h`
258 /// conventions used for daemon CLI errors (Task 9 U1).
259 ///
260 /// | Code | Symbol | Semantics |
261 /// |------|---------------|---------------------------------------------|
262 /// | 0 | `EX_OK` | Success (not an error; included for completeness) |
263 /// | 69 | `EX_UNAVAILABLE` | Service unavailable (timeout, not-ready) |
264 /// | 70 | `EX_SOFTWARE` | Internal software error |
265 /// | 73 | `EX_CANTCREAT`| IO error / cannot create required file |
266 /// | 75 | `EX_TEMPFAIL` | Try again (e.g. another instance is running)|
267 /// | 78 | `EX_CONFIG` | Configuration error |
268 ///
269 /// For variants that only occur inside the IPC / workspace layer
270 /// (not at process-startup time) the JSON-RPC code's sign-flipped
271 /// magnitude is used as a proxy, falling back to `70` (`EX_SOFTWARE`)
272 /// for anything not covered.
273 #[must_use]
274 pub const fn exit_code(&self) -> u8 {
275 match self {
276 // BSD sysexits.h (man 3 sysexits) exit codes for lifecycle errors.
277 // 75 EX_TEMPFAIL: another process already owns the socket/lock.
278 Self::AlreadyRunning { .. } => 75,
279 // 69 EX_UNAVAILABLE: daemon didn't start in time.
280 Self::AutoStartTimeout { .. } => 69,
281 // 70 EX_SOFTWARE: internal OS-level failure (signal registration).
282 Self::SignalSetup { .. } => 70,
283 // 78 EX_CONFIG: malformed or unreadable config file.
284 Self::Config { .. } => 78,
285 // 73 EX_CANTCREAT: I/O failure (pidfile write, socket bind, etc.).
286 Self::Io(_) => 73,
287 // IPC-layer errors that escape to the CLI surface default to 70.
288 Self::WorkspaceBuildFailed { .. }
289 | Self::WorkspaceStaleExpired { .. }
290 | Self::MemoryBudgetExceeded { .. }
291 | Self::WorkspaceEvicted { .. }
292 | Self::WorkspaceNotLoaded { .. }
293 | Self::ToolTimeout { .. }
294 | Self::InvalidArgument { .. }
295 | Self::Internal(_) => 70,
296 }
297 }
298
299 /// Build the `error.data` JSON payload surfaced alongside the JSON-RPC
300 /// error code. Returns `None` when no structured payload should be
301 /// attached (typically `Io`/`Config` errors routed through `-32603`).
302 ///
303 /// Task 8 Phase 8a. The IPC method dispatch consumes this to populate
304 /// `JsonRpcError.data` so clients can render actionable diagnostics
305 /// without parsing the free-form `message` string.
306 #[must_use]
307 pub fn error_data(&self) -> Option<serde_json::Value> {
308 use serde_json::json;
309 match self {
310 Self::MemoryBudgetExceeded {
311 limit_bytes,
312 current_bytes,
313 reserved_bytes,
314 retained_bytes,
315 requested_bytes,
316 } => Some(json!({
317 "limit_bytes": limit_bytes,
318 "current_bytes": current_bytes,
319 "reserved_bytes": reserved_bytes,
320 "retained_bytes": retained_bytes,
321 "requested_bytes": requested_bytes,
322 })),
323 Self::WorkspaceStaleExpired {
324 root,
325 age_hours,
326 cap_hours,
327 last_good_at,
328 last_error,
329 } => {
330 // UTC-Zulu RFC3339 (`YYYY-MM-DDTHH:MM:SSZ`). `chrono` is
331 // already a workspace dependency used throughout the repo
332 // for RFC3339 rendering; `to_rfc3339_opts(Secs, true)`
333 // emits the UTC-Zulu form required by Task 7.
334 let last_good_rfc3339 = last_good_at.map(|t| {
335 chrono::DateTime::<chrono::Utc>::from(t)
336 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
337 });
338 Some(json!({
339 "root": root,
340 "age_hours": age_hours,
341 "cap_hours": cap_hours,
342 "last_good_at": last_good_rfc3339,
343 "last_error": last_error,
344 }))
345 }
346 Self::WorkspaceBuildFailed { root, reason } => Some(json!({
347 "root": root,
348 "reason": reason,
349 })),
350 Self::WorkspaceEvicted { root } => Some(json!({ "root": root })),
351 Self::WorkspaceNotLoaded { root } => Some(json!({
352 "root": root,
353 "hint": "use daemon/load to load the workspace before calling daemon/rebuild",
354 })),
355 // Phase 8c §O canonical 4-key envelope
356 // `{kind, retryable, retry_after_ms, details}` matching
357 // standalone `sqry-mcp::rpc_error_to_mcp` shape so clients
358 // can handle daemon-path and direct-path errors with a
359 // single parser.
360 Self::ToolTimeout {
361 root,
362 secs: _,
363 deadline_ms,
364 } => Some(json!({
365 "kind": "deadline_exceeded",
366 "retryable": true,
367 "retry_after_ms": 1000,
368 "details": {
369 // `tool` is `null` here; the MCP-path wrapper
370 // `daemon_err_to_mcp` (Phase 8c U8) populates it
371 // with the method name pulled from the inbound
372 // JSON-RPC request.
373 "tool": serde_json::Value::Null,
374 "deadline_ms": deadline_ms,
375 "root": root.display().to_string(),
376 },
377 })),
378 Self::InvalidArgument { reason } => Some(json!({
379 "kind": "validation_error",
380 "retryable": false,
381 "retry_after_ms": serde_json::Value::Null,
382 "details": {
383 "reason": reason,
384 },
385 })),
386 Self::Internal(_) => Some(json!({
387 "kind": "internal",
388 "retryable": false,
389 "retry_after_ms": serde_json::Value::Null,
390 "details": serde_json::Value::Null,
391 })),
392 Self::Io(_) | Self::Config { .. } => None,
393 // Lifecycle errors don't cross the IPC boundary; no structured
394 // payload is needed.
395 Self::AlreadyRunning { .. }
396 | Self::AutoStartTimeout { .. }
397 | Self::SignalSetup { .. } => None,
398 }
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn jsonrpc_code_covers_every_public_variant() {
408 let mem = DaemonError::MemoryBudgetExceeded {
409 limit_bytes: 2_048 * 1024 * 1024,
410 current_bytes: 0,
411 reserved_bytes: 0,
412 retained_bytes: 0,
413 requested_bytes: 4_096 * 1024 * 1024,
414 };
415 assert_eq!(mem.jsonrpc_code(), Some(JSONRPC_MEMORY_BUDGET_EXCEEDED));
416
417 let stale = DaemonError::WorkspaceStaleExpired {
418 root: PathBuf::from("/repo"),
419 age_hours: 48,
420 cap_hours: 24,
421 last_good_at: None,
422 last_error: None,
423 };
424 assert_eq!(stale.jsonrpc_code(), Some(JSONRPC_WORKSPACE_STALE_EXPIRED));
425
426 let failed = DaemonError::WorkspaceBuildFailed {
427 root: PathBuf::from("/repo"),
428 reason: "plugin panic".into(),
429 };
430 assert_eq!(failed.jsonrpc_code(), Some(JSONRPC_WORKSPACE_BUILD_FAILED));
431
432 let evicted = DaemonError::WorkspaceEvicted {
433 root: PathBuf::from("/repo"),
434 };
435 assert_eq!(evicted.jsonrpc_code(), Some(JSONRPC_WORKSPACE_EVICTED));
436 }
437
438 #[test]
439 fn jsonrpc_code_is_none_for_internal_variants() {
440 let io = DaemonError::Io(std::io::Error::other("boom"));
441 assert!(io.jsonrpc_code().is_none());
442
443 let cfg = DaemonError::Config {
444 path: PathBuf::from("/etc/sqry.toml"),
445 source: anyhow::anyhow!("malformed"),
446 };
447 assert!(cfg.jsonrpc_code().is_none());
448 }
449
450 // -----------------------------------------------------------------
451 // Task 8 Phase 8c U5 — Tool-dispatch error variants
452 // -----------------------------------------------------------------
453 //
454 // These tests pin the stable wire contract defined in the design
455 // doc §O for `ToolTimeout` / `InvalidArgument` / `Internal`. Any
456 // change to the JSON-RPC codes or the `{kind, retryable,
457 // retry_after_ms, details}` envelope shape will fail at least one
458 // of these tests and force a matching update to the MCP-path
459 // wrapper (`daemon_err_to_mcp`) so daemon-path and direct-path
460 // MCP responses stay byte-identical.
461
462 #[test]
463 fn tool_timeout_has_jsonrpc_code_32000_and_deadline_exceeded_kind() {
464 let err = DaemonError::ToolTimeout {
465 root: PathBuf::from("/tmp/workspace"),
466 secs: 60,
467 deadline_ms: 60_000,
468 };
469 assert_eq!(err.jsonrpc_code(), Some(JSONRPC_TOOL_TIMEOUT));
470 assert_eq!(err.jsonrpc_code(), Some(-32000));
471 let data = err.error_data().expect("ToolTimeout must emit data");
472 assert_eq!(data["kind"], "deadline_exceeded");
473 assert_eq!(data["retryable"], true);
474 assert_eq!(data["retry_after_ms"], 1000);
475 assert_eq!(data["details"]["deadline_ms"], 60_000);
476 assert_eq!(data["details"]["root"], "/tmp/workspace");
477 // Placeholder for the MCP-path wrapper (Phase 8c U8) to
478 // overwrite with the inbound method name.
479 assert!(data["details"]["tool"].is_null());
480 }
481
482 #[test]
483 fn invalid_argument_has_jsonrpc_code_32602_and_validation_error_kind() {
484 let err = DaemonError::InvalidArgument {
485 reason: "missing path argument".into(),
486 };
487 assert_eq!(err.jsonrpc_code(), Some(JSONRPC_INVALID_PARAMS));
488 assert_eq!(err.jsonrpc_code(), Some(-32602));
489 let data = err.error_data().expect("InvalidArgument must emit data");
490 assert_eq!(data["kind"], "validation_error");
491 assert_eq!(data["retryable"], false);
492 assert!(data["retry_after_ms"].is_null());
493 assert_eq!(data["details"]["reason"], "missing path argument");
494 }
495
496 #[test]
497 fn internal_has_jsonrpc_code_32603_and_internal_kind() {
498 let err = DaemonError::Internal(anyhow::anyhow!("something blew up"));
499 assert_eq!(err.jsonrpc_code(), Some(JSONRPC_INTERNAL_ERROR));
500 assert_eq!(err.jsonrpc_code(), Some(-32603));
501 let data = err.error_data().expect("Internal must emit data");
502 assert_eq!(data["kind"], "internal");
503 assert_eq!(data["retryable"], false);
504 assert!(data["retry_after_ms"].is_null());
505 assert!(data["details"].is_null());
506 }
507
508 #[test]
509 fn error_data_envelope_shape_is_canonical_for_tool_dispatch_variants() {
510 // All 3 new Phase 8c U5 variants must emit EXACTLY the 4
511 // canonical top-level keys and no others — this is the
512 // contract documented in the design doc §O.3 and is what
513 // the MCP-path wrapper relies on to avoid renaming / reshaping
514 // fields.
515 let expected: std::collections::BTreeSet<String> =
516 ["kind", "retryable", "retry_after_ms", "details"]
517 .iter()
518 .map(|s| (*s).to_string())
519 .collect();
520
521 let errs = [
522 DaemonError::ToolTimeout {
523 root: PathBuf::from("/tmp"),
524 secs: 10,
525 deadline_ms: 10_000,
526 },
527 DaemonError::InvalidArgument { reason: "x".into() },
528 DaemonError::Internal(anyhow::anyhow!("y")),
529 ];
530 for err in errs {
531 let data = err.error_data().expect("variant must emit data");
532 let obj = data
533 .as_object()
534 .expect("error_data envelope must be a JSON object");
535 let keys: std::collections::BTreeSet<String> = obj.keys().cloned().collect();
536 assert_eq!(
537 keys, expected,
538 "error_data envelope for {err:?} must be exactly the 4 canonical keys"
539 );
540 }
541 }
542
543 // -----------------------------------------------------------------
544 // Task 9 U1 — DaemonError lifecycle variant tests
545 // -----------------------------------------------------------------
546
547 /// `AlreadyRunning` must have no JSON-RPC code (it never reaches the wire)
548 /// and must exit with code 75 (`EX_TEMPFAIL`).
549 #[test]
550 fn already_running_has_no_jsonrpc_code_and_exit_75() {
551 let err = DaemonError::AlreadyRunning {
552 owner_pid: Some(12345),
553 socket: PathBuf::from("/run/user/1000/sqryd.sock"),
554 lock: PathBuf::from("/run/user/1000/sqryd.lock"),
555 };
556 assert!(
557 err.jsonrpc_code().is_none(),
558 "AlreadyRunning must not carry a JSON-RPC code"
559 );
560 assert_eq!(
561 err.exit_code(),
562 75,
563 "AlreadyRunning must exit with EX_TEMPFAIL (75)"
564 );
565 assert!(
566 err.error_data().is_none(),
567 "AlreadyRunning must not carry IPC error_data"
568 );
569 }
570
571 /// `AlreadyRunning` with `owner_pid = None` must render `pid=?` in Display.
572 #[test]
573 fn already_running_owner_pid_none_display_contains_pid_question_mark() {
574 let err = DaemonError::AlreadyRunning {
575 owner_pid: None,
576 socket: PathBuf::from("/tmp/sqryd.sock"),
577 lock: PathBuf::from("/tmp/sqryd.lock"),
578 };
579 assert_eq!(err.exit_code(), 75);
580 assert!(err.jsonrpc_code().is_none());
581 let msg = err.to_string();
582 assert!(
583 msg.contains("pid=?"),
584 "Display for owner_pid=None must contain 'pid=?', got: {msg}"
585 );
586 }
587
588 /// `AutoStartTimeout` must have no JSON-RPC code and must exit with code
589 /// 69 (`EX_UNAVAILABLE`). The design doc iter-0 m5 explicitly changed this
590 /// from 73 (`EX_CANTCREAT`) to 69 (`EX_UNAVAILABLE`) — this test pins that
591 /// decision and guards against accidental reversion.
592 #[test]
593 fn auto_start_timeout_has_no_jsonrpc_code_and_exit_69_not_73() {
594 let err = DaemonError::AutoStartTimeout {
595 timeout_secs: 10,
596 socket: PathBuf::from("/run/user/1000/sqryd.sock"),
597 };
598 assert!(
599 err.jsonrpc_code().is_none(),
600 "AutoStartTimeout must not carry a JSON-RPC code"
601 );
602 assert_eq!(
603 err.exit_code(),
604 69,
605 "AutoStartTimeout must exit with EX_UNAVAILABLE (69), NOT EX_CANTCREAT (73)"
606 );
607 assert!(
608 err.error_data().is_none(),
609 "AutoStartTimeout must not carry IPC error_data"
610 );
611 }
612
613 /// `SignalSetup` must have no JSON-RPC code and must exit with code 70
614 /// (`EX_SOFTWARE`).
615 #[test]
616 fn signal_setup_has_no_jsonrpc_code_and_exit_70() {
617 let err = DaemonError::SignalSetup {
618 source: std::io::Error::other("SIGTERM handler failed"),
619 };
620 assert!(
621 err.jsonrpc_code().is_none(),
622 "SignalSetup must not carry a JSON-RPC code"
623 );
624 assert_eq!(
625 err.exit_code(),
626 70,
627 "SignalSetup must exit with EX_SOFTWARE (70)"
628 );
629 assert!(
630 err.error_data().is_none(),
631 "SignalSetup must not carry IPC error_data"
632 );
633 }
634
635 /// `Config` must exit with code 78 (`EX_CONFIG`).
636 #[test]
637 fn config_exits_with_78() {
638 let err = DaemonError::Config {
639 path: PathBuf::from("/etc/sqry/daemon.toml"),
640 source: anyhow::anyhow!("invalid TOML"),
641 };
642 assert_eq!(err.exit_code(), 78, "Config must exit with EX_CONFIG (78)");
643 assert!(err.jsonrpc_code().is_none());
644 }
645
646 /// `Io` must exit with code 73 (`EX_CANTCREAT`).
647 #[test]
648 fn io_error_exits_with_73() {
649 let err = DaemonError::Io(std::io::Error::other("socket bind failed"));
650 assert_eq!(err.exit_code(), 73, "Io must exit with EX_CANTCREAT (73)");
651 assert!(err.jsonrpc_code().is_none());
652 }
653
654 /// All IPC-path variants must have a defined exit code of 70 (the
655 /// `EX_SOFTWARE` default). They should never reach process exit, but the
656 /// method must be exhaustive.
657 #[test]
658 fn ipc_path_variants_exit_with_70_default() {
659 let cases: &[DaemonError] = &[
660 DaemonError::WorkspaceBuildFailed {
661 root: PathBuf::from("/repo"),
662 reason: "build failed".into(),
663 },
664 DaemonError::WorkspaceStaleExpired {
665 root: PathBuf::from("/repo"),
666 age_hours: 48,
667 cap_hours: 24,
668 last_good_at: None,
669 last_error: None,
670 },
671 DaemonError::MemoryBudgetExceeded {
672 limit_bytes: 1024 * 1024 * 1024,
673 current_bytes: 512 * 1024 * 1024,
674 reserved_bytes: 0,
675 retained_bytes: 0,
676 requested_bytes: 4 * 1024 * 1024 * 1024,
677 },
678 DaemonError::WorkspaceEvicted {
679 root: PathBuf::from("/repo"),
680 },
681 DaemonError::ToolTimeout {
682 root: PathBuf::from("/tmp/ws"),
683 secs: 60,
684 deadline_ms: 60_000,
685 },
686 DaemonError::InvalidArgument {
687 reason: "missing path".into(),
688 },
689 DaemonError::Internal(anyhow::anyhow!("internal error")),
690 ];
691 for err in cases {
692 assert_eq!(
693 err.exit_code(),
694 70,
695 "IPC-path variant {err:?} must default to EX_SOFTWARE (70)"
696 );
697 }
698 }
699
700 /// `clone_err` must handle all three Task 9 lifecycle variants without
701 /// panicking. All three collapse to `WorkspaceBuildFailed` (matching the
702 /// pattern for `Config`/`Io`) because they fire before `IpcServer::bind`
703 /// and should never reach workspace state storage — but the collapse must
704 /// preserve the human-readable message.
705 #[test]
706 fn clone_err_handles_lifecycle_variants_without_panic() {
707 use crate::workspace::manager::clone_err;
708
709 let ar = DaemonError::AlreadyRunning {
710 owner_pid: Some(42),
711 socket: PathBuf::from("/tmp/sqryd.sock"),
712 lock: PathBuf::from("/tmp/sqryd.lock"),
713 };
714 let cloned = clone_err(&ar);
715 assert!(
716 cloned.to_string().contains("sqryd.sock"),
717 "clone_err for AlreadyRunning must preserve socket path, got: {cloned}"
718 );
719
720 // Must not panic with owner_pid=None.
721 let ar_none = DaemonError::AlreadyRunning {
722 owner_pid: None,
723 socket: PathBuf::from("/tmp/sqryd.sock"),
724 lock: PathBuf::from("/tmp/sqryd.lock"),
725 };
726 let _ = clone_err(&ar_none);
727
728 let at = DaemonError::AutoStartTimeout {
729 timeout_secs: 15,
730 socket: PathBuf::from("/run/user/1000/sqryd.sock"),
731 };
732 let cloned = clone_err(&at);
733 assert!(
734 cloned.to_string().contains("15"),
735 "clone_err for AutoStartTimeout must preserve timeout_secs, got: {cloned}"
736 );
737
738 let ss = DaemonError::SignalSetup {
739 source: std::io::Error::other("SIGTERM handler failed"),
740 };
741 let cloned = clone_err(&ss);
742 assert!(
743 cloned.to_string().contains("SIGTERM handler failed"),
744 "clone_err for SignalSetup must preserve the source message via Display, got: {cloned}"
745 );
746 }
747
748 #[test]
749 fn clone_err_round_trips_tool_dispatch_variants() {
750 // `clone_err` lives in `workspace::manager` so it can be used
751 // by `classify_for_serve` to reproduce the stored
752 // `last_error` on every read path. The helper is
753 // `pub(crate)` so we exercise it directly from inside the
754 // daemon crate — Phase 8c U5 must keep all new variants
755 // round-trippable or `classify_for_serve` will collapse them
756 // into the generic `WorkspaceBuildFailed` fallback.
757 use crate::workspace::manager::clone_err;
758
759 let tt = DaemonError::ToolTimeout {
760 root: PathBuf::from("/tmp/workspace"),
761 secs: 60,
762 deadline_ms: 60_000,
763 };
764 let cloned = clone_err(&tt);
765 match cloned {
766 DaemonError::ToolTimeout {
767 root,
768 secs,
769 deadline_ms,
770 } => {
771 assert_eq!(root, PathBuf::from("/tmp/workspace"));
772 assert_eq!(secs, 60);
773 assert_eq!(deadline_ms, 60_000);
774 }
775 other => panic!("expected ToolTimeout round-trip, got {other:?}"),
776 }
777
778 let ia = DaemonError::InvalidArgument {
779 reason: "missing path argument".into(),
780 };
781 let cloned = clone_err(&ia);
782 match cloned {
783 DaemonError::InvalidArgument { reason } => {
784 assert_eq!(reason, "missing path argument");
785 }
786 other => panic!("expected InvalidArgument round-trip, got {other:?}"),
787 }
788
789 let inner = DaemonError::Internal(anyhow::anyhow!("something blew up"));
790 let cloned = clone_err(&inner);
791 match cloned {
792 DaemonError::Internal(err) => {
793 // `anyhow::Error` is not `Clone`; `clone_err`
794 // re-creates it from the `Display` representation so
795 // the user-facing message survives round-trips.
796 assert!(
797 err.to_string().contains("something blew up"),
798 "cloned Internal error must preserve the Display text, got: {err}"
799 );
800 }
801 other => panic!("expected Internal round-trip, got {other:?}"),
802 }
803 }
804}