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 sqry_core::graph::acquisition::GraphAcquisitionError;
27use thiserror::Error;
28
29use crate::{
30 JSONRPC_INTERNAL_ERROR, JSONRPC_INVALID_PARAMS, JSONRPC_MEMORY_BUDGET_EXCEEDED,
31 JSONRPC_QUERY_TOO_BROAD, JSONRPC_RESET_CANCELLATION_DISPATCHED, JSONRPC_RESET_WHILE_LOADING,
32 JSONRPC_SOCKET_SETUP, JSONRPC_TOOL_TIMEOUT, JSONRPC_WORKSPACE_BUILD_FAILED,
33 JSONRPC_WORKSPACE_EVICTED, JSONRPC_WORKSPACE_INCOMPATIBLE_GRAPH, JSONRPC_WORKSPACE_OVERSIZE,
34 JSONRPC_WORKSPACE_PINNED, JSONRPC_WORKSPACE_STALE_EXPIRED,
35};
36
37/// Wire-stable `kind` tag for the cost-gate rejection on the
38/// daemon-hosted MCP path. Mirror of
39/// [`sqry_mcp::error::KIND_QUERY_TOO_BROAD`][1] for byte-identical
40/// envelopes across the standalone and daemon-hosted MCP transports.
41///
42/// Source: `B_cost_gate.md` §3 + `00_contracts.md` §3.CC-2.
43///
44/// [1]: https://docs.rs/sqry-mcp/latest/sqry_mcp/error/constant.KIND_QUERY_TOO_BROAD.html
45pub const KIND_QUERY_TOO_BROAD: &str = "query_too_broad";
46
47/// Result alias for daemon operations.
48pub type DaemonResult<T> = Result<T, DaemonError>;
49
50/// All daemon-surface error variants.
51#[derive(Debug, Error)]
52pub enum DaemonError {
53 /// Config file could not be read or parsed.
54 #[error("config error at {path}: {source}")]
55 Config {
56 path: PathBuf,
57 #[source]
58 source: anyhow::Error,
59 },
60
61 /// An `io::Error` occurred outside the config surface (socket bind,
62 /// pidfile lock, filesystem probe, etc.).
63 #[error(transparent)]
64 Io(#[from] std::io::Error),
65
66 /// Workspace load / rebuild failed with no prior-good graph to serve from.
67 ///
68 /// Maps to JSON-RPC `-32001`.
69 #[error("workspace {root} build failed: {reason}")]
70 WorkspaceBuildFailed { root: PathBuf, reason: String },
71
72 /// Workspace is in the Failed state and the most recent successful build
73 /// is older than the configured `stale_serve_max_age_hours` cap.
74 ///
75 /// Maps to JSON-RPC `-32002`.
76 #[error("workspace {root} stale-serve window expired ({age_hours}h >= {cap_hours}h cap)")]
77 WorkspaceStaleExpired {
78 root: PathBuf,
79 age_hours: u64,
80 cap_hours: u32,
81 /// Last successful build timestamp, if any. `None` when the workspace
82 /// has never successfully built (edge case: should not reach
83 /// `WorkspaceStaleExpired` in that case — `WorkspaceBuildFailed` is
84 /// returned instead — but the type is permissive for future-proofing).
85 last_good_at: Option<SystemTime>,
86 /// Textual diagnostic from the most recent failed build, if any.
87 last_error: Option<String>,
88 },
89
90 /// Admission control could not satisfy a reservation after evicting every
91 /// non-pinned workspace.
92 ///
93 /// Maps to JSON-RPC `-32003`.
94 #[error(
95 "memory budget exceeded: requested {requested_bytes} B, \
96 {current_bytes} B loaded + {reserved_bytes} B reserved + \
97 {retained_bytes} B retained / {limit_bytes} B limit"
98 )]
99 MemoryBudgetExceeded {
100 limit_bytes: u64,
101 current_bytes: u64,
102 reserved_bytes: u64,
103 retained_bytes: u64,
104 requested_bytes: u64,
105 },
106
107 /// Workspace was evicted or removed between a rebuild dispatch and its
108 /// admission / publish commit. Signals the Task 7b2 watcher task and any
109 /// direct `handle_changes` caller to terminate their per-workspace loop —
110 /// subsequent dispatches on the same `WorkspaceKey` must route through a
111 /// fresh `get_or_load` first.
112 ///
113 /// Surfaced by `RebuildDispatcher::handle_changes`' top-of-drain-loop
114 /// eviction gate AND by `WorkspaceManager::reserve_rebuild`'s Phase-1
115 /// `workspaces.read()` membership + cancellation check (both paths use
116 /// this typed variant so 7b2 can match on it without string parsing).
117 ///
118 /// Maps to JSON-RPC `-32004`.
119 #[error("workspace {root} evicted mid-rebuild")]
120 WorkspaceEvicted { root: PathBuf },
121
122 /// Caller requested `daemon/rebuild` or `daemon/cancel_rebuild` for a
123 /// path that is not currently registered in the `WorkspaceManager`.
124 ///
125 /// Shares the JSON-RPC `-32004` code with [`Self::WorkspaceEvicted`].
126 /// The `error_data` `"hint"` field distinguishes the two situations on
127 /// the wire.
128 ///
129 /// Maps to JSON-RPC `-32004`.
130 #[error("workspace {root} is not loaded")]
131 WorkspaceNotLoaded { root: PathBuf },
132
133 /// On-disk graph snapshot or manifest is incompatible with this binary
134 /// (unknown plugin ids in the manifest, or a snapshot format the
135 /// runtime cannot parse). SGA02 / SGA04 mandate this stay distinct
136 /// from [`Self::WorkspaceBuildFailed`] so clients can route
137 /// "rebuild" vs. "upgrade binary" vs. "wait" responses correctly.
138 ///
139 /// `reason` is a human-readable rendering of the underlying
140 /// [`sqry_core::graph::acquisition::PluginSelectionStatus`] — the
141 /// `From<GraphAcquisitionError>` impl below preserves the variant
142 /// faithfully so no information is lost on the wire.
143 ///
144 /// Maps to JSON-RPC `-32005`.
145 #[error("workspace {root} graph is incompatible with this binary: {reason}")]
146 WorkspaceIncompatibleGraph { root: PathBuf, reason: String },
147
148 /// Tool invocation exceeded [`DaemonConfig::tool_timeout_secs`].
149 /// Emitted by `tool_core::classify_and_execute` (Task 8 Phase 8c U6)
150 /// when the `tokio::time::timeout(tool_timeout, spawn_blocking(run))`
151 /// outer timer fires. The detached [`tokio::task::JoinHandle`] is
152 /// dropped — the OS thread may continue executing the tool closure
153 /// but its result is discarded.
154 ///
155 /// The `deadline_ms` field is the canonical wire value (populated by
156 /// the constructor as `secs * 1000`) so `error_data` does not have
157 /// to re-derive it on every call and serialised payloads remain
158 /// byte-for-byte identical regardless of constructor shape.
159 ///
160 /// Maps to JSON-RPC `-32000`.
161 ///
162 /// [`DaemonConfig::tool_timeout_secs`]: crate::config::DaemonConfig
163 #[error(
164 "tool invocation exceeded deadline of {deadline_ms}ms for workspace {}",
165 root.display()
166 )]
167 ToolTimeout {
168 root: PathBuf,
169 secs: u64,
170 /// Derived: `secs * 1000`. Stored explicitly to avoid
171 /// re-calculating inside `error_data` / `Display` impls and to
172 /// give the MCP-path wrapper (`daemon_err_to_mcp`, Phase 8c U8)
173 /// a single field to read.
174 deadline_ms: u64,
175 },
176
177 /// Argument validation failure surfaced by `tool_core` BEFORE any
178 /// workspace classification runs. Used for `resolve_index_root`
179 /// failures, missing `path` arguments in MCP tool args, and any
180 /// other precondition violation that must be rejected with a
181 /// JSON-RPC `-32602` "Invalid params" response.
182 ///
183 /// Maps to JSON-RPC `-32602`.
184 #[error("invalid argument: {reason}")]
185 InvalidArgument { reason: String },
186
187 /// Typed `sqry_mcp::error::RpcError` preserved through the
188 /// daemon-hosted MCP path so the wire envelope is byte-identical
189 /// to the standalone MCP response (cluster-C iter-3, codex PR
190 /// review recommendation).
191 ///
192 /// The daemon adapter (`sqry-mcp/src/daemon_adapter/dispatch.rs`)
193 /// previously rewrapped param-parsing failures with
194 /// `anyhow!("invalid arguments: {e}")`, which destroyed the typed
195 /// `RpcError` root before [`crate::ipc::tool_core::execute_with_timeout`]
196 /// could downcast it. The downstream `daemon_err_to_mcp`
197 /// then mapped through `DaemonError::Internal` →
198 /// `McpError::internal_error` (`-32603`) regardless of the
199 /// `RpcError`'s actual `code`. This variant is the dedicated
200 /// pass-through: the inner `RpcError` carries the correct
201 /// `code` (`-32602` for validation failures, etc.), `kind`,
202 /// `retryable`, `retry_after_ms`, and `details`, and
203 /// [`daemon_err_to_mcp`][1] renders them through the same
204 /// `invalid_params` / `internal_error` selector the standalone
205 /// path uses.
206 ///
207 /// [1]: crate::mcp_host::error_map::daemon_err_to_mcp
208 #[error("{0}")]
209 RpcErrorPreserved(sqry_mcp::error::RpcError),
210
211 /// Catch-all for errors surfaced by
212 /// [`sqry_mcp::daemon_adapter`][1] tool execution that do not map
213 /// to a more specific `DaemonError` variant. The wrapped
214 /// `anyhow::Error` is flattened into a string on the wire via the
215 /// `Display`/`#[source]` chain.
216 ///
217 /// Maps to JSON-RPC `-32603`.
218 ///
219 /// [1]: https://docs.rs/sqry-mcp/latest/sqry_mcp/daemon_adapter/index.html
220 #[error("internal error: {0}")]
221 Internal(#[source] anyhow::Error),
222
223 // ── Task 9 U1 — lifecycle error variants ─────────────────────────────
224 /// A sqryd process already holds the exclusive flock on `lock` and has
225 /// written its PID to `pidfile`. The caller should surface this to the
226 /// user with the owner PID (if legible) and exit `EX_TEMPFAIL` (75).
227 ///
228 /// This error fires before [`IpcServer::bind`] and therefore before any
229 /// workspace is registered; it should never be stored in the workspace
230 /// `last_error` field. [`crate::workspace::manager::clone_err`] maps it
231 /// to `WorkspaceBuildFailed` as a defensive fallback.
232 ///
233 /// [`IpcServer::bind`]: crate::ipc::IpcServer
234 #[error(
235 "sqryd is already running (pid={}) on socket {} (lock: {})",
236 owner_pid.map_or_else(|| "?".to_owned(), |p| p.to_string()),
237 socket.display(),
238 lock.display()
239 )]
240 AlreadyRunning {
241 /// The IPC socket path that the running daemon owns.
242 socket: PathBuf,
243 /// The flock file that proves ownership.
244 lock: PathBuf,
245 /// PID of the owner process, if the pidfile was legible.
246 owner_pid: Option<u32>,
247 },
248
249 /// The daemon did not become ready within `timeout_secs` seconds.
250 /// Used by both the `--detach` parent wait loop and the
251 /// `lifecycle::start_detached` auto-spawn helper (Task 10).
252 ///
253 /// Callers should exit `EX_UNAVAILABLE` (69).
254 #[error(
255 "daemon did not become ready within {timeout_secs}s on socket {}",
256 socket.display()
257 )]
258 AutoStartTimeout {
259 /// How long we waited.
260 timeout_secs: u64,
261 /// The socket we polled.
262 socket: PathBuf,
263 },
264
265 /// Installing OS signal handlers failed (e.g. `sigaction` returned
266 /// `ENOSYS` in a highly-restricted container, or tokio's signal
267 /// registration failed).
268 ///
269 /// Callers should exit `EX_SOFTWARE` (70).
270 #[error("failed to install signal handlers: {source}")]
271 SignalSetup {
272 #[source]
273 source: std::io::Error,
274 },
275
276 // ── sqry-mcp flakiness P0-1 / P1 admission + recovery variants ───────
277 /// The freshly-built graph exceeds the daemon's memory budget by
278 /// itself — even if every other workspace were evicted, the
279 /// daemon could not host it. Returned by
280 /// `WorkspaceManager::publish_and_retain` AFTER the build
281 /// completes but BEFORE the new graph is exposed to readers.
282 ///
283 /// Wire code: `-32006`. Distinct from `MemoryBudgetExceeded`
284 /// (`-32003`), which is a *projected* admission failure on a
285 /// pre-build estimate.
286 ///
287 /// Source: `G_daemon_control_plane.md` §1.4 hand-off G4.
288 #[error(
289 "workspace {} oversize: {measured_bytes} > {limit_bytes} (after eviction headroom; current loaded: {current_loaded_bytes})",
290 root.display()
291 )]
292 WorkspaceOversize {
293 root: PathBuf,
294 measured_bytes: u64,
295 limit_bytes: u64,
296 current_loaded_bytes: u64,
297 },
298
299 /// `daemon/reset` was invoked on a pinned workspace and the
300 /// caller did not pass `force = true`. Pinning is the operator
301 /// opt-in for "do not LRU-evict this workspace"; resetting it
302 /// has the same drop-graph effect as eviction and is therefore
303 /// gated behind the same explicit override.
304 ///
305 /// Wire code: `-32010`.
306 ///
307 /// Source: `G_daemon_control_plane.md` §3.2 hand-off G4.
308 #[error("workspace {} is pinned; pass force=true to reset", root.display())]
309 WorkspacePinned { root: PathBuf },
310
311 /// `daemon/reset` was invoked on a workspace whose state is
312 /// `Loading`. Cancelling a load mid-flight is structurally
313 /// unsafe (reservation accounting + admission state would
314 /// drift). Caller must wait for the load to settle (success or
315 /// `Failed`) and retry.
316 ///
317 /// Wire code: `-32008`.
318 ///
319 /// Source: `G_daemon_control_plane.md` §3.2 hand-off G4.
320 #[error("workspace {} is currently loading; retry once load settles", root.display())]
321 ResetWhileLoading { root: PathBuf },
322
323 /// `daemon/reset` was invoked on a workspace whose state is
324 /// `Rebuilding`. The reset has dispatched a cancellation token
325 /// to the runner; the caller should retry after `retry_after_ms`
326 /// for the runner to finish its drain pass and the workspace to
327 /// transition to `Failed` (which is then idempotently reset on
328 /// the next call).
329 ///
330 /// Wire code: `-32009`.
331 ///
332 /// Source: `G_daemon_control_plane.md` §3.2 hand-off G4.
333 #[error(
334 "workspace {} rebuild cancellation dispatched; retry after {retry_after_ms}ms",
335 root.display()
336 )]
337 ResetCancellationDispatched { root: PathBuf, retry_after_ms: u64 },
338
339 /// Socket parent directory cannot be created or is not writable.
340 /// Surfaced before `IpcServer::bind` so the failure mode is
341 /// distinguishable from a generic `EACCES` (which would otherwise
342 /// be wrapped as `Io`).
343 ///
344 /// Wire code: `-32007`. Note this is not normally observed on
345 /// the wire because it fires before the IPC server binds; the
346 /// JSON-RPC mapping exists for the rare case where the daemon
347 /// surface re-emits this through IPC during a hot-reload of the
348 /// socket configuration.
349 ///
350 /// Source: `G_daemon_control_plane.md` §5.2 hand-off G4.
351 #[error("socket setup failed at {}: {reason}", path.display())]
352 SocketSetup { path: PathBuf, reason: String },
353
354 /// Pre-flight cost gate rejected a query (per `B_cost_gate.md`
355 /// §3, daemon-hosted MCP parity arm). The wire envelope mirrors
356 /// the standalone `RpcError::query_too_broad` exactly so MCP
357 /// clients can use a single parser regardless of which transport
358 /// the request flowed through.
359 ///
360 /// Wire code: `-32602` (the existing `invalid_params` slot;
361 /// `kind = "query_too_broad"` is the discriminator).
362 ///
363 /// Source: `B_cost_gate.md` §3 + `00_contracts.md` §3.CC-2.
364 #[error("query rejected by cost gate: {reason}")]
365 QueryTooBroad {
366 reason: String,
367 details: serde_json::Value,
368 },
369}
370
371impl DaemonError {
372 /// Map to the stable JSON-RPC error code used on the wire.
373 ///
374 /// Returns `None` for errors that have no public JSON-RPC code — these
375 /// are serialised as `-32603 "Internal error"` per the JSON-RPC 2.0 spec
376 /// at the IPC boundary (wired in Task 8).
377 ///
378 /// The Task 9 lifecycle variants (`AlreadyRunning`, `AutoStartTimeout`,
379 /// `SignalSetup`) fire before `IpcServer::bind` so they never cross the
380 /// IPC boundary directly; `None` is returned for them here. They are
381 /// only surfaced to human users via `exit_code()` and process exit.
382 #[must_use]
383 pub const fn jsonrpc_code(&self) -> Option<i32> {
384 match self {
385 Self::WorkspaceBuildFailed { .. } => Some(JSONRPC_WORKSPACE_BUILD_FAILED),
386 Self::WorkspaceStaleExpired { .. } => Some(JSONRPC_WORKSPACE_STALE_EXPIRED),
387 Self::MemoryBudgetExceeded { .. } => Some(JSONRPC_MEMORY_BUDGET_EXCEEDED),
388 Self::WorkspaceEvicted { .. } | Self::WorkspaceNotLoaded { .. } => {
389 Some(JSONRPC_WORKSPACE_EVICTED)
390 }
391 Self::WorkspaceIncompatibleGraph { .. } => Some(JSONRPC_WORKSPACE_INCOMPATIBLE_GRAPH),
392 Self::ToolTimeout { .. } => Some(JSONRPC_TOOL_TIMEOUT),
393 Self::InvalidArgument { .. } => Some(JSONRPC_INVALID_PARAMS),
394 // Cluster-C iter-3: pass-through preserves the inner
395 // RpcError's JSON-RPC code (typically -32602 for
396 // validation failures emitted by `validate_budget_rows`
397 // and similar validators).
398 Self::RpcErrorPreserved(rpc) => Some(rpc.code),
399 Self::Internal(_) => Some(JSONRPC_INTERNAL_ERROR),
400 Self::WorkspaceOversize { .. } => Some(JSONRPC_WORKSPACE_OVERSIZE),
401 Self::WorkspacePinned { .. } => Some(JSONRPC_WORKSPACE_PINNED),
402 Self::ResetWhileLoading { .. } => Some(JSONRPC_RESET_WHILE_LOADING),
403 Self::ResetCancellationDispatched { .. } => Some(JSONRPC_RESET_CANCELLATION_DISPATCHED),
404 Self::SocketSetup { .. } => Some(JSONRPC_SOCKET_SETUP),
405 Self::QueryTooBroad { .. } => Some(JSONRPC_QUERY_TOO_BROAD),
406 // Lifecycle errors don't cross the IPC boundary.
407 Self::AlreadyRunning { .. }
408 | Self::AutoStartTimeout { .. }
409 | Self::SignalSetup { .. }
410 | Self::Config { .. }
411 | Self::Io(_) => None,
412 }
413 }
414
415 /// Map to a POSIX process exit code following the BSD `sysexits.h`
416 /// conventions used for daemon CLI errors (Task 9 U1).
417 ///
418 /// | Code | Symbol | Semantics |
419 /// |------|---------------|---------------------------------------------|
420 /// | 0 | `EX_OK` | Success (not an error; included for completeness) |
421 /// | 69 | `EX_UNAVAILABLE` | Service unavailable (timeout, not-ready) |
422 /// | 70 | `EX_SOFTWARE` | Internal software error |
423 /// | 73 | `EX_CANTCREAT`| IO error / cannot create required file |
424 /// | 75 | `EX_TEMPFAIL` | Try again (e.g. another instance is running)|
425 /// | 78 | `EX_CONFIG` | Configuration error |
426 ///
427 /// For variants that only occur inside the IPC / workspace layer
428 /// (not at process-startup time) the JSON-RPC code's sign-flipped
429 /// magnitude is used as a proxy, falling back to `70` (`EX_SOFTWARE`)
430 /// for anything not covered.
431 #[must_use]
432 pub const fn exit_code(&self) -> u8 {
433 match self {
434 // BSD sysexits.h (man 3 sysexits) exit codes for lifecycle errors.
435 // 75 EX_TEMPFAIL: another process already owns the socket/lock.
436 Self::AlreadyRunning { .. } => 75,
437 // 69 EX_UNAVAILABLE: daemon didn't start in time.
438 Self::AutoStartTimeout { .. } => 69,
439 // 70 EX_SOFTWARE: internal OS-level failure (signal registration).
440 Self::SignalSetup { .. } => 70,
441 // 78 EX_CONFIG: malformed or unreadable config file.
442 Self::Config { .. } => 78,
443 // 73 EX_CANTCREAT: I/O failure (pidfile write, socket bind, etc.).
444 Self::Io(_) => 73,
445 // IPC-layer errors that escape to the CLI surface default to 70.
446 Self::WorkspaceBuildFailed { .. }
447 | Self::WorkspaceStaleExpired { .. }
448 | Self::MemoryBudgetExceeded { .. }
449 | Self::WorkspaceEvicted { .. }
450 | Self::WorkspaceNotLoaded { .. }
451 | Self::WorkspaceIncompatibleGraph { .. }
452 | Self::ToolTimeout { .. }
453 | Self::InvalidArgument { .. }
454 | Self::RpcErrorPreserved(_)
455 | Self::Internal(_)
456 | Self::WorkspaceOversize { .. }
457 | Self::WorkspacePinned { .. }
458 | Self::ResetWhileLoading { .. }
459 | Self::ResetCancellationDispatched { .. }
460 | Self::SocketSetup { .. }
461 | Self::QueryTooBroad { .. } => 70,
462 }
463 }
464
465 /// Build the `error.data` JSON payload surfaced alongside the JSON-RPC
466 /// error code. Returns `None` when no structured payload should be
467 /// attached (typically `Io`/`Config` errors routed through `-32603`).
468 ///
469 /// Task 8 Phase 8a. The IPC method dispatch consumes this to populate
470 /// `JsonRpcError.data` so clients can render actionable diagnostics
471 /// without parsing the free-form `message` string.
472 #[must_use]
473 pub fn error_data(&self) -> Option<serde_json::Value> {
474 use serde_json::json;
475 match self {
476 Self::MemoryBudgetExceeded {
477 limit_bytes,
478 current_bytes,
479 reserved_bytes,
480 retained_bytes,
481 requested_bytes,
482 } => Some(json!({
483 "limit_bytes": limit_bytes,
484 "current_bytes": current_bytes,
485 "reserved_bytes": reserved_bytes,
486 "retained_bytes": retained_bytes,
487 "requested_bytes": requested_bytes,
488 })),
489 Self::WorkspaceStaleExpired {
490 root,
491 age_hours,
492 cap_hours,
493 last_good_at,
494 last_error,
495 } => {
496 // UTC-Zulu RFC3339 (`YYYY-MM-DDTHH:MM:SSZ`). `chrono` is
497 // already a workspace dependency used throughout the repo
498 // for RFC3339 rendering; `to_rfc3339_opts(Secs, true)`
499 // emits the UTC-Zulu form required by Task 7.
500 let last_good_rfc3339 = last_good_at.map(|t| {
501 chrono::DateTime::<chrono::Utc>::from(t)
502 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
503 });
504 Some(json!({
505 "root": root,
506 "age_hours": age_hours,
507 "cap_hours": cap_hours,
508 "last_good_at": last_good_rfc3339,
509 "last_error": last_error,
510 }))
511 }
512 Self::WorkspaceBuildFailed { root, reason } => Some(json!({
513 "root": root,
514 "reason": reason,
515 })),
516 Self::WorkspaceEvicted { root } => Some(json!({ "root": root })),
517 Self::WorkspaceNotLoaded { root } => Some(json!({
518 "root": root,
519 "hint": "use daemon/load to load the workspace before calling daemon/rebuild",
520 })),
521 Self::WorkspaceIncompatibleGraph { root, reason } => Some(json!({
522 "root": root,
523 "reason": reason,
524 })),
525 // Phase 8c §O canonical 4-key envelope
526 // `{kind, retryable, retry_after_ms, details}` matching
527 // standalone `sqry-mcp::rpc_error_to_mcp` shape so clients
528 // can handle daemon-path and direct-path errors with a
529 // single parser.
530 Self::ToolTimeout {
531 root: _,
532 secs: _,
533 deadline_ms,
534 } => Some(json!({
535 "kind": "deadline_exceeded",
536 "retryable": true,
537 // Cluster-A iter-2 BLOCKER 1: align `retry_after_ms`
538 // with the standalone `RpcError::deadline_exceeded`
539 // (`SqryServer` default = 500 ms). The IPC envelope
540 // and the MCP-host envelope must agree byte-for-byte
541 // so direct-path callers and rmcp clients see the
542 // same recovery hint.
543 "retry_after_ms": 500,
544 "details": {
545 // `tool` is `null` here; the MCP-path wrapper
546 // `daemon_err_to_mcp` (Phase 8c U8) populates it
547 // with the method name pulled from the inbound
548 // JSON-RPC request.
549 "tool": serde_json::Value::Null,
550 "deadline_ms": deadline_ms,
551 // Cluster-A iter-2 BLOCKER 1: `root` removed for
552 // wire-identity with the standalone envelope.
553 // The workspace path is still surfaced via the
554 // `Display` impl on `DaemonError::ToolTimeout`.
555 },
556 })),
557 Self::InvalidArgument { reason } => Some(json!({
558 "kind": "validation_error",
559 "retryable": false,
560 "retry_after_ms": serde_json::Value::Null,
561 "details": {
562 "reason": reason,
563 },
564 })),
565 // Cluster-C iter-3: preserve the inner RpcError's wire
566 // shape verbatim so the daemon-hosted MCP envelope is
567 // byte-identical to the standalone path's
568 // `rpc_error_to_mcp` output.
569 Self::RpcErrorPreserved(rpc) => Some(json!({
570 "kind": rpc.kind,
571 "retryable": rpc.retryable,
572 "retry_after_ms": rpc.retry_after_ms,
573 "details": rpc.details,
574 })),
575 Self::Internal(_) => Some(json!({
576 "kind": "internal",
577 "retryable": false,
578 "retry_after_ms": serde_json::Value::Null,
579 "details": serde_json::Value::Null,
580 })),
581 Self::Io(_) | Self::Config { .. } => None,
582 // Lifecycle errors don't cross the IPC boundary; no structured
583 // payload is needed.
584 Self::AlreadyRunning { .. }
585 | Self::AutoStartTimeout { .. }
586 | Self::SignalSetup { .. } => None,
587 Self::WorkspaceOversize {
588 root,
589 measured_bytes,
590 limit_bytes,
591 current_loaded_bytes,
592 } => Some(json!({
593 "root": root,
594 "measured_bytes": measured_bytes,
595 "limit_bytes": limit_bytes,
596 "current_loaded_bytes": current_loaded_bytes,
597 })),
598 Self::WorkspacePinned { root } => Some(json!({
599 "root": root,
600 "hint": "pass force=true to reset a pinned workspace",
601 })),
602 Self::ResetWhileLoading { root } => Some(json!({
603 "root": root,
604 "hint": "wait for the load to settle, then retry",
605 })),
606 Self::ResetCancellationDispatched {
607 root,
608 retry_after_ms,
609 } => Some(json!({
610 "root": root,
611 "retry_after_ms": retry_after_ms,
612 })),
613 Self::SocketSetup { path, reason } => Some(json!({
614 "path": path,
615 "reason": reason,
616 })),
617 // Phase 8c §O canonical 4-key envelope. The standalone
618 // `sqry-mcp::RpcError::query_too_broad` envelope shape is
619 // mirrored byte-for-byte (`B_cost_gate.md` §3 +
620 // `00_contracts.md` §3.CC-2). The caller assembles the
621 // CC-2 seven-key `details` value (source, kind, limit,
622 // estimated_visited_nodes / examined / predicate_shape /
623 // suggested_predicates / doc_url) and hands it to this
624 // arm verbatim — this layer only owns the 4-key
625 // envelope.
626 Self::QueryTooBroad { details, .. } => Some(json!({
627 "kind": KIND_QUERY_TOO_BROAD,
628 "retryable": false,
629 "retry_after_ms": serde_json::Value::Null,
630 "details": details,
631 })),
632 }
633 }
634}
635
636// ---------------------------------------------------------------------------
637// SGA04 — `From<GraphAcquisitionError>` for `DaemonError`.
638// ---------------------------------------------------------------------------
639//
640// Maps the transport-neutral acquisition taxonomy into the daemon's
641// existing JSON-RPC-coded error variants. This is the boundary used by
642// SGA05 dispatch wiring to surface acquisition failures through the
643// JSON-RPC / MCP envelopes without losing the InvalidPath / Evicted /
644// StaleExpired / IncompatibleGraph distinctions (per the SGA spec
645// "Adapters must not collapse" rule).
646impl From<GraphAcquisitionError> for DaemonError {
647 fn from(err: GraphAcquisitionError) -> Self {
648 match err {
649 GraphAcquisitionError::InvalidPath { path, reason } => Self::InvalidArgument {
650 reason: format!("invalid path {}: {reason}", path.display()),
651 },
652 GraphAcquisitionError::NoGraph { workspace_root } => Self::WorkspaceBuildFailed {
653 root: workspace_root,
654 reason: "no graph artifact for workspace".to_string(),
655 },
656 GraphAcquisitionError::LoadFailed {
657 source_root,
658 reason,
659 } => Self::WorkspaceBuildFailed {
660 root: source_root,
661 reason: format!("graph load failed: {reason}"),
662 },
663 GraphAcquisitionError::IncompatibleGraph {
664 source_root,
665 status,
666 } => {
667 use sqry_core::graph::acquisition::PluginSelectionStatus;
668 // Format the status losslessly into a user-facing reason
669 // string. `Exact` should never reach this arm — the core
670 // crate only constructs `IncompatibleGraph` for the two
671 // negative verdicts — but we cover it defensively to
672 // keep the conversion total.
673 let reason = match status {
674 PluginSelectionStatus::IncompatibleUnknownPluginIds {
675 unknown_plugin_ids,
676 manifest_path,
677 } => {
678 let suggested =
679 sqry_plugin_registry::missing_features_for(&unknown_plugin_ids);
680 let mut buf =
681 format!("unknown plugin ids: [{}]", unknown_plugin_ids.join(", "),);
682 if let Some(p) = manifest_path.as_ref() {
683 buf.push_str(&format!(" (manifest: {})", p.display()));
684 }
685 if !suggested.is_empty() {
686 // Cluster-E iter-2: render the full
687 // copy-paste-ready cargo install command,
688 // matching the CLI / standalone-MCP shape.
689 buf.push_str(&format!(
690 " — rebuild this binary with: \
691 cargo install --path sqry-cli --features {}",
692 suggested.join(","),
693 ));
694 }
695 buf
696 }
697 PluginSelectionStatus::IncompatibleSnapshotFormat { reason } => {
698 format!("incompatible snapshot format: {reason}")
699 }
700 PluginSelectionStatus::Exact => {
701 // Defensive: should not happen.
702 "compatibility verdict reported Exact alongside IncompatibleGraph error"
703 .to_string()
704 }
705 other => format!("unrecognised plugin selection status: {other:?}"),
706 };
707 Self::WorkspaceIncompatibleGraph {
708 root: source_root,
709 reason,
710 }
711 }
712 GraphAcquisitionError::NotReady {
713 workspace_root,
714 lifecycle,
715 } => Self::WorkspaceBuildFailed {
716 root: workspace_root,
717 reason: format!("workspace not ready (lifecycle={lifecycle})"),
718 },
719 GraphAcquisitionError::Evicted {
720 workspace_root,
721 original_lifecycle,
722 reload_failure,
723 } => {
724 // Preserve original-lifecycle + reload-failure context
725 // by tracing it before collapsing into the daemon's
726 // single-field WorkspaceEvicted variant. The wire shape
727 // for `-32004` is fixed (`{"root": ...}`); diagnostic
728 // detail rides on the daemon log channel.
729 tracing::warn!(
730 workspace = %workspace_root.display(),
731 original_lifecycle = %original_lifecycle,
732 reload_failure = ?reload_failure,
733 "graph acquisition: workspace evicted, reload failed"
734 );
735 Self::WorkspaceEvicted {
736 root: workspace_root,
737 }
738 }
739 GraphAcquisitionError::StaleExpired {
740 workspace_root,
741 age_hours,
742 } => Self::WorkspaceStaleExpired {
743 root: workspace_root,
744 age_hours: age_hours.map(|h| h as u64).unwrap_or(0),
745 cap_hours: 0,
746 last_good_at: None,
747 last_error: None,
748 },
749 GraphAcquisitionError::BuildFailed {
750 workspace_root,
751 reason,
752 } => Self::WorkspaceBuildFailed {
753 root: workspace_root,
754 reason,
755 },
756 GraphAcquisitionError::Internal { reason } => {
757 Self::Internal(anyhow::anyhow!("graph acquisition: {reason}"))
758 }
759 }
760 }
761}
762
763#[cfg(test)]
764mod tests {
765 use super::*;
766
767 #[test]
768 fn jsonrpc_code_covers_every_public_variant() {
769 let mem = DaemonError::MemoryBudgetExceeded {
770 limit_bytes: 2_048 * 1024 * 1024,
771 current_bytes: 0,
772 reserved_bytes: 0,
773 retained_bytes: 0,
774 requested_bytes: 4_096 * 1024 * 1024,
775 };
776 assert_eq!(mem.jsonrpc_code(), Some(JSONRPC_MEMORY_BUDGET_EXCEEDED));
777
778 let stale = DaemonError::WorkspaceStaleExpired {
779 root: PathBuf::from("/repo"),
780 age_hours: 48,
781 cap_hours: 24,
782 last_good_at: None,
783 last_error: None,
784 };
785 assert_eq!(stale.jsonrpc_code(), Some(JSONRPC_WORKSPACE_STALE_EXPIRED));
786
787 let failed = DaemonError::WorkspaceBuildFailed {
788 root: PathBuf::from("/repo"),
789 reason: "plugin panic".into(),
790 };
791 assert_eq!(failed.jsonrpc_code(), Some(JSONRPC_WORKSPACE_BUILD_FAILED));
792
793 let evicted = DaemonError::WorkspaceEvicted {
794 root: PathBuf::from("/repo"),
795 };
796 assert_eq!(evicted.jsonrpc_code(), Some(JSONRPC_WORKSPACE_EVICTED));
797 }
798
799 // -----------------------------------------------------------------
800 // SGA04 Gate-A major #5 — IncompatibleGraph mapping tests
801 // -----------------------------------------------------------------
802 //
803 // The acquisition taxonomy distinguishes path-policy /
804 // compatibility errors from generic build failures so MCP / IPC
805 // clients can react differently (rebuild vs. upgrade vs. retry).
806 // These tests pin that the `From<GraphAcquisitionError>` impl
807 // routes IncompatibleGraph to the dedicated
808 // `WorkspaceIncompatibleGraph` variant — NOT to
809 // `WorkspaceBuildFailed`.
810
811 #[test]
812 fn from_graph_acquisition_incompatible_unknown_plugins_maps_to_incompatible_graph() {
813 use sqry_core::graph::acquisition::{GraphAcquisitionError, PluginSelectionStatus};
814
815 let err = GraphAcquisitionError::IncompatibleGraph {
816 source_root: PathBuf::from("/repo"),
817 status: PluginSelectionStatus::IncompatibleUnknownPluginIds {
818 unknown_plugin_ids: vec!["plugin-a".to_string(), "plugin-b".to_string()],
819 manifest_path: Some(PathBuf::from("/repo/.sqry/graph/manifest.json")),
820 },
821 };
822 let de: DaemonError = err.into();
823 match de {
824 DaemonError::WorkspaceIncompatibleGraph { root, reason } => {
825 assert_eq!(root, PathBuf::from("/repo"));
826 assert!(
827 reason.contains("plugin-a") && reason.contains("plugin-b"),
828 "reason must list every unknown plugin id losslessly, got: {reason}"
829 );
830 assert!(
831 reason.contains("unknown plugin ids"),
832 "reason must surface the plugin-id verdict, got: {reason}"
833 );
834 }
835 other => panic!(
836 "GraphAcquisitionError::IncompatibleGraph(IncompatibleUnknownPluginIds) \
837 must map to DaemonError::WorkspaceIncompatibleGraph, got {other:?}"
838 ),
839 }
840 }
841
842 #[test]
843 fn from_graph_acquisition_incompatible_snapshot_format_maps_to_incompatible_graph() {
844 use sqry_core::graph::acquisition::{GraphAcquisitionError, PluginSelectionStatus};
845
846 let err = GraphAcquisitionError::IncompatibleGraph {
847 source_root: PathBuf::from("/repo"),
848 status: PluginSelectionStatus::IncompatibleSnapshotFormat {
849 reason: "V99 magic, this binary supports up to V10".to_string(),
850 },
851 };
852 let de: DaemonError = err.into();
853 match de {
854 DaemonError::WorkspaceIncompatibleGraph { root, reason } => {
855 assert_eq!(root, PathBuf::from("/repo"));
856 assert!(
857 reason.contains("incompatible snapshot format") && reason.contains("V99 magic"),
858 "reason must preserve the snapshot-format detail, got: {reason}"
859 );
860 }
861 other => panic!(
862 "GraphAcquisitionError::IncompatibleGraph(IncompatibleSnapshotFormat) \
863 must map to DaemonError::WorkspaceIncompatibleGraph, got {other:?}"
864 ),
865 }
866 }
867
868 #[test]
869 fn workspace_incompatible_graph_has_dedicated_jsonrpc_code() {
870 let err = DaemonError::WorkspaceIncompatibleGraph {
871 root: PathBuf::from("/repo"),
872 reason: "unknown plugin ids: [a, b]".to_string(),
873 };
874 assert_eq!(
875 err.jsonrpc_code(),
876 Some(JSONRPC_WORKSPACE_INCOMPATIBLE_GRAPH),
877 "WorkspaceIncompatibleGraph must carry the dedicated -32005 code"
878 );
879 assert_eq!(err.jsonrpc_code(), Some(-32005));
880 // Distinct from -32001.
881 assert_ne!(err.jsonrpc_code(), Some(JSONRPC_WORKSPACE_BUILD_FAILED));
882
883 let data = err
884 .error_data()
885 .expect("WorkspaceIncompatibleGraph must emit error_data");
886 assert_eq!(data["root"], "/repo");
887 assert_eq!(data["reason"], "unknown plugin ids: [a, b]");
888 }
889
890 #[test]
891 fn jsonrpc_code_is_none_for_internal_variants() {
892 let io = DaemonError::Io(std::io::Error::other("boom"));
893 assert!(io.jsonrpc_code().is_none());
894
895 let cfg = DaemonError::Config {
896 path: PathBuf::from("/etc/sqry.toml"),
897 source: anyhow::anyhow!("malformed"),
898 };
899 assert!(cfg.jsonrpc_code().is_none());
900 }
901
902 // -----------------------------------------------------------------
903 // Task 8 Phase 8c U5 — Tool-dispatch error variants
904 // -----------------------------------------------------------------
905 //
906 // These tests pin the stable wire contract defined in the design
907 // doc §O for `ToolTimeout` / `InvalidArgument` / `Internal`. Any
908 // change to the JSON-RPC codes or the `{kind, retryable,
909 // retry_after_ms, details}` envelope shape will fail at least one
910 // of these tests and force a matching update to the MCP-path
911 // wrapper (`daemon_err_to_mcp`) so daemon-path and direct-path
912 // MCP responses stay byte-identical.
913
914 #[test]
915 fn tool_timeout_has_jsonrpc_code_32000_and_deadline_exceeded_kind() {
916 let err = DaemonError::ToolTimeout {
917 root: PathBuf::from("/tmp/workspace"),
918 secs: 60,
919 deadline_ms: 60_000,
920 };
921 assert_eq!(err.jsonrpc_code(), Some(JSONRPC_TOOL_TIMEOUT));
922 assert_eq!(err.jsonrpc_code(), Some(-32000));
923 let data = err.error_data().expect("ToolTimeout must emit data");
924 assert_eq!(data["kind"], "deadline_exceeded");
925 assert_eq!(data["retryable"], true);
926 // Cluster-A iter-2 BLOCKER 1: aligned with the standalone
927 // `RpcError::deadline_exceeded` envelope (500 ms).
928 assert_eq!(data["retry_after_ms"], 500);
929 assert_eq!(data["details"]["deadline_ms"], 60_000);
930 // Cluster-A iter-2 BLOCKER 1: `details.root` removed for
931 // wire-identity with the standalone shape.
932 assert!(
933 data["details"].get("root").is_none(),
934 "details.root must be absent post-iter-2"
935 );
936 // Placeholder for the MCP-path wrapper (Phase 8c U8) to
937 // overwrite with the inbound method name.
938 assert!(data["details"]["tool"].is_null());
939 }
940
941 #[test]
942 fn invalid_argument_has_jsonrpc_code_32602_and_validation_error_kind() {
943 let err = DaemonError::InvalidArgument {
944 reason: "missing path argument".into(),
945 };
946 assert_eq!(err.jsonrpc_code(), Some(JSONRPC_INVALID_PARAMS));
947 assert_eq!(err.jsonrpc_code(), Some(-32602));
948 let data = err.error_data().expect("InvalidArgument must emit data");
949 assert_eq!(data["kind"], "validation_error");
950 assert_eq!(data["retryable"], false);
951 assert!(data["retry_after_ms"].is_null());
952 assert_eq!(data["details"]["reason"], "missing path argument");
953 }
954
955 #[test]
956 fn internal_has_jsonrpc_code_32603_and_internal_kind() {
957 let err = DaemonError::Internal(anyhow::anyhow!("something blew up"));
958 assert_eq!(err.jsonrpc_code(), Some(JSONRPC_INTERNAL_ERROR));
959 assert_eq!(err.jsonrpc_code(), Some(-32603));
960 let data = err.error_data().expect("Internal must emit data");
961 assert_eq!(data["kind"], "internal");
962 assert_eq!(data["retryable"], false);
963 assert!(data["retry_after_ms"].is_null());
964 assert!(data["details"].is_null());
965 }
966
967 #[test]
968 fn error_data_envelope_shape_is_canonical_for_tool_dispatch_variants() {
969 // All 3 new Phase 8c U5 variants must emit EXACTLY the 4
970 // canonical top-level keys and no others — this is the
971 // contract documented in the design doc §O.3 and is what
972 // the MCP-path wrapper relies on to avoid renaming / reshaping
973 // fields.
974 let expected: std::collections::BTreeSet<String> =
975 ["kind", "retryable", "retry_after_ms", "details"]
976 .iter()
977 .map(|s| (*s).to_string())
978 .collect();
979
980 let errs = [
981 DaemonError::ToolTimeout {
982 root: PathBuf::from("/tmp"),
983 secs: 10,
984 deadline_ms: 10_000,
985 },
986 DaemonError::InvalidArgument { reason: "x".into() },
987 DaemonError::Internal(anyhow::anyhow!("y")),
988 ];
989 for err in errs {
990 let data = err.error_data().expect("variant must emit data");
991 let obj = data
992 .as_object()
993 .expect("error_data envelope must be a JSON object");
994 let keys: std::collections::BTreeSet<String> = obj.keys().cloned().collect();
995 assert_eq!(
996 keys, expected,
997 "error_data envelope for {err:?} must be exactly the 4 canonical keys"
998 );
999 }
1000 }
1001
1002 // -----------------------------------------------------------------
1003 // Task 9 U1 — DaemonError lifecycle variant tests
1004 // -----------------------------------------------------------------
1005
1006 /// `AlreadyRunning` must have no JSON-RPC code (it never reaches the wire)
1007 /// and must exit with code 75 (`EX_TEMPFAIL`).
1008 #[test]
1009 fn already_running_has_no_jsonrpc_code_and_exit_75() {
1010 let err = DaemonError::AlreadyRunning {
1011 owner_pid: Some(12345),
1012 socket: PathBuf::from("/run/user/1000/sqryd.sock"),
1013 lock: PathBuf::from("/run/user/1000/sqryd.lock"),
1014 };
1015 assert!(
1016 err.jsonrpc_code().is_none(),
1017 "AlreadyRunning must not carry a JSON-RPC code"
1018 );
1019 assert_eq!(
1020 err.exit_code(),
1021 75,
1022 "AlreadyRunning must exit with EX_TEMPFAIL (75)"
1023 );
1024 assert!(
1025 err.error_data().is_none(),
1026 "AlreadyRunning must not carry IPC error_data"
1027 );
1028 }
1029
1030 /// `AlreadyRunning` with `owner_pid = None` must render `pid=?` in Display.
1031 #[test]
1032 fn already_running_owner_pid_none_display_contains_pid_question_mark() {
1033 let err = DaemonError::AlreadyRunning {
1034 owner_pid: None,
1035 socket: PathBuf::from("/tmp/sqryd.sock"),
1036 lock: PathBuf::from("/tmp/sqryd.lock"),
1037 };
1038 assert_eq!(err.exit_code(), 75);
1039 assert!(err.jsonrpc_code().is_none());
1040 let msg = err.to_string();
1041 assert!(
1042 msg.contains("pid=?"),
1043 "Display for owner_pid=None must contain 'pid=?', got: {msg}"
1044 );
1045 }
1046
1047 /// `AutoStartTimeout` must have no JSON-RPC code and must exit with code
1048 /// 69 (`EX_UNAVAILABLE`). The design doc iter-0 m5 explicitly changed this
1049 /// from 73 (`EX_CANTCREAT`) to 69 (`EX_UNAVAILABLE`) — this test pins that
1050 /// decision and guards against accidental reversion.
1051 #[test]
1052 fn auto_start_timeout_has_no_jsonrpc_code_and_exit_69_not_73() {
1053 let err = DaemonError::AutoStartTimeout {
1054 timeout_secs: 10,
1055 socket: PathBuf::from("/run/user/1000/sqryd.sock"),
1056 };
1057 assert!(
1058 err.jsonrpc_code().is_none(),
1059 "AutoStartTimeout must not carry a JSON-RPC code"
1060 );
1061 assert_eq!(
1062 err.exit_code(),
1063 69,
1064 "AutoStartTimeout must exit with EX_UNAVAILABLE (69), NOT EX_CANTCREAT (73)"
1065 );
1066 assert!(
1067 err.error_data().is_none(),
1068 "AutoStartTimeout must not carry IPC error_data"
1069 );
1070 }
1071
1072 /// `SignalSetup` must have no JSON-RPC code and must exit with code 70
1073 /// (`EX_SOFTWARE`).
1074 #[test]
1075 fn signal_setup_has_no_jsonrpc_code_and_exit_70() {
1076 let err = DaemonError::SignalSetup {
1077 source: std::io::Error::other("SIGTERM handler failed"),
1078 };
1079 assert!(
1080 err.jsonrpc_code().is_none(),
1081 "SignalSetup must not carry a JSON-RPC code"
1082 );
1083 assert_eq!(
1084 err.exit_code(),
1085 70,
1086 "SignalSetup must exit with EX_SOFTWARE (70)"
1087 );
1088 assert!(
1089 err.error_data().is_none(),
1090 "SignalSetup must not carry IPC error_data"
1091 );
1092 }
1093
1094 /// `Config` must exit with code 78 (`EX_CONFIG`).
1095 #[test]
1096 fn config_exits_with_78() {
1097 let err = DaemonError::Config {
1098 path: PathBuf::from("/etc/sqry/daemon.toml"),
1099 source: anyhow::anyhow!("invalid TOML"),
1100 };
1101 assert_eq!(err.exit_code(), 78, "Config must exit with EX_CONFIG (78)");
1102 assert!(err.jsonrpc_code().is_none());
1103 }
1104
1105 /// `Io` must exit with code 73 (`EX_CANTCREAT`).
1106 #[test]
1107 fn io_error_exits_with_73() {
1108 let err = DaemonError::Io(std::io::Error::other("socket bind failed"));
1109 assert_eq!(err.exit_code(), 73, "Io must exit with EX_CANTCREAT (73)");
1110 assert!(err.jsonrpc_code().is_none());
1111 }
1112
1113 /// All IPC-path variants must have a defined exit code of 70 (the
1114 /// `EX_SOFTWARE` default). They should never reach process exit, but the
1115 /// method must be exhaustive.
1116 #[test]
1117 fn ipc_path_variants_exit_with_70_default() {
1118 let cases: &[DaemonError] = &[
1119 DaemonError::WorkspaceBuildFailed {
1120 root: PathBuf::from("/repo"),
1121 reason: "build failed".into(),
1122 },
1123 DaemonError::WorkspaceStaleExpired {
1124 root: PathBuf::from("/repo"),
1125 age_hours: 48,
1126 cap_hours: 24,
1127 last_good_at: None,
1128 last_error: None,
1129 },
1130 DaemonError::MemoryBudgetExceeded {
1131 limit_bytes: 1024 * 1024 * 1024,
1132 current_bytes: 512 * 1024 * 1024,
1133 reserved_bytes: 0,
1134 retained_bytes: 0,
1135 requested_bytes: 4 * 1024 * 1024 * 1024,
1136 },
1137 DaemonError::WorkspaceEvicted {
1138 root: PathBuf::from("/repo"),
1139 },
1140 DaemonError::WorkspaceIncompatibleGraph {
1141 root: PathBuf::from("/repo"),
1142 reason: "unknown plugin ids: [a]".into(),
1143 },
1144 DaemonError::ToolTimeout {
1145 root: PathBuf::from("/tmp/ws"),
1146 secs: 60,
1147 deadline_ms: 60_000,
1148 },
1149 DaemonError::InvalidArgument {
1150 reason: "missing path".into(),
1151 },
1152 DaemonError::Internal(anyhow::anyhow!("internal error")),
1153 ];
1154 for err in cases {
1155 assert_eq!(
1156 err.exit_code(),
1157 70,
1158 "IPC-path variant {err:?} must default to EX_SOFTWARE (70)"
1159 );
1160 }
1161 }
1162
1163 /// `clone_err` must handle all three Task 9 lifecycle variants without
1164 /// panicking. All three collapse to `WorkspaceBuildFailed` (matching the
1165 /// pattern for `Config`/`Io`) because they fire before `IpcServer::bind`
1166 /// and should never reach workspace state storage — but the collapse must
1167 /// preserve the human-readable message.
1168 #[test]
1169 fn clone_err_handles_lifecycle_variants_without_panic() {
1170 use crate::workspace::manager::clone_err;
1171
1172 let ar = DaemonError::AlreadyRunning {
1173 owner_pid: Some(42),
1174 socket: PathBuf::from("/tmp/sqryd.sock"),
1175 lock: PathBuf::from("/tmp/sqryd.lock"),
1176 };
1177 let cloned = clone_err(&ar);
1178 assert!(
1179 cloned.to_string().contains("sqryd.sock"),
1180 "clone_err for AlreadyRunning must preserve socket path, got: {cloned}"
1181 );
1182
1183 // Must not panic with owner_pid=None.
1184 let ar_none = DaemonError::AlreadyRunning {
1185 owner_pid: None,
1186 socket: PathBuf::from("/tmp/sqryd.sock"),
1187 lock: PathBuf::from("/tmp/sqryd.lock"),
1188 };
1189 let _ = clone_err(&ar_none);
1190
1191 let at = DaemonError::AutoStartTimeout {
1192 timeout_secs: 15,
1193 socket: PathBuf::from("/run/user/1000/sqryd.sock"),
1194 };
1195 let cloned = clone_err(&at);
1196 assert!(
1197 cloned.to_string().contains("15"),
1198 "clone_err for AutoStartTimeout must preserve timeout_secs, got: {cloned}"
1199 );
1200
1201 let ss = DaemonError::SignalSetup {
1202 source: std::io::Error::other("SIGTERM handler failed"),
1203 };
1204 let cloned = clone_err(&ss);
1205 assert!(
1206 cloned.to_string().contains("SIGTERM handler failed"),
1207 "clone_err for SignalSetup must preserve the source message via Display, got: {cloned}"
1208 );
1209 }
1210
1211 #[test]
1212 fn clone_err_round_trips_tool_dispatch_variants() {
1213 // `clone_err` lives in `workspace::manager` so it can be used
1214 // by `classify_for_serve` to reproduce the stored
1215 // `last_error` on every read path. The helper is
1216 // `pub(crate)` so we exercise it directly from inside the
1217 // daemon crate — Phase 8c U5 must keep all new variants
1218 // round-trippable or `classify_for_serve` will collapse them
1219 // into the generic `WorkspaceBuildFailed` fallback.
1220 use crate::workspace::manager::clone_err;
1221
1222 let tt = DaemonError::ToolTimeout {
1223 root: PathBuf::from("/tmp/workspace"),
1224 secs: 60,
1225 deadline_ms: 60_000,
1226 };
1227 let cloned = clone_err(&tt);
1228 match cloned {
1229 DaemonError::ToolTimeout {
1230 root,
1231 secs,
1232 deadline_ms,
1233 } => {
1234 assert_eq!(root, PathBuf::from("/tmp/workspace"));
1235 assert_eq!(secs, 60);
1236 assert_eq!(deadline_ms, 60_000);
1237 }
1238 other => panic!("expected ToolTimeout round-trip, got {other:?}"),
1239 }
1240
1241 let ia = DaemonError::InvalidArgument {
1242 reason: "missing path argument".into(),
1243 };
1244 let cloned = clone_err(&ia);
1245 match cloned {
1246 DaemonError::InvalidArgument { reason } => {
1247 assert_eq!(reason, "missing path argument");
1248 }
1249 other => panic!("expected InvalidArgument round-trip, got {other:?}"),
1250 }
1251
1252 let inner = DaemonError::Internal(anyhow::anyhow!("something blew up"));
1253 let cloned = clone_err(&inner);
1254 match cloned {
1255 DaemonError::Internal(err) => {
1256 // `anyhow::Error` is not `Clone`; `clone_err`
1257 // re-creates it from the `Display` representation so
1258 // the user-facing message survives round-trips.
1259 assert!(
1260 err.to_string().contains("something blew up"),
1261 "cloned Internal error must preserve the Display text, got: {err}"
1262 );
1263 }
1264 other => panic!("expected Internal round-trip, got {other:?}"),
1265 }
1266 }
1267}