Skip to main content

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}