Skip to main content

sqry_daemon_protocol/
protocol.rs

1//! Wire types for the sqryd daemon IPC.
2//!
3//! Every type in this module serialises as UTF-8 JSON through serde.
4//! The wire format is versioned via the `envelope_version` field on
5//! [`DaemonHelloResponse`] / [`ShimRegisterAck`]; clients negotiate
6//! compatibility during the handshake before issuing any JSON-RPC
7//! request or entering the shim byte-pump.
8//!
9//! # JSON-RPC 2.0 conformance
10//!
11//! - Requests and responses carry the mandatory `"jsonrpc": "2.0"` tag
12//!   enforced by [`JsonRpcVersion`]'s manual serde impls.
13//! - Response ids follow the spec exactly: a response to a request
14//!   with a missing/invalid id MUST carry `id: null`; `Option<JsonRpcId>`
15//!   on [`JsonRpcResponse::id`] is NOT marked `skip_serializing_if`, so
16//!   `None` serialises as JSON `null` instead of being omitted.
17//! - Batches are implemented in the sqry-daemon router; this module
18//!   only provides the single-request envelope types.
19//!
20//! # `shim/register`
21//!
22//! [`ShimRegister`] / [`ShimProtocol`] / [`ShimRegisterAck`] are the
23//! Phase 8c shim handshake wire types. The router in sqry-daemon
24//! discriminates on the very first frame:
25//!
26//! - If the frame object has both `protocol` + `pid` keys (shim-shaped),
27//!   the router enters the shim path and deserialises as [`ShimRegister`]
28//!   with `deny_unknown_fields`. On deserialisation failure (e.g. extra
29//!   keys from the hello shape, or an unknown `protocol` variant) the
30//!   server writes [`ShimRegisterAck`]`{ accepted: false, reason: Some(..) }`
31//!   and closes. **Not** a JSON-RPC `-32600` — the shim client expects a
32//!   [`ShimRegisterAck`] as the first response, so the wire-form stays
33//!   coherent.
34//! - Otherwise the router falls through to the [`DaemonHello`] path
35//!   (JSON-RPC). A frame with neither shape is rejected with
36//!   `-32600 Invalid Request` and `id: null`.
37
38use std::marker::PhantomData;
39
40use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
41
42// ---------------------------------------------------------------------------
43// WorkspaceId — protocol-side wire wrapper for sqry-core's WorkspaceId.
44// ---------------------------------------------------------------------------
45
46/// 32-byte stable identity for a logical workspace, byte-identical to
47/// `sqry_core::workspace::WorkspaceId`.
48///
49/// Defined here in the leaf protocol crate so the daemon wire types
50/// (`DaemonHello.logical_workspace`, `daemon/load.logical_workspace`,
51/// `daemon/workspaceStatus.workspace_id`) can carry the identity without
52/// the protocol crate taking a `sqry-core` dependency. The `sqry-daemon`
53/// binary owns the `From`/`Into` bridge against the canonical
54/// `sqry_core::workspace::WorkspaceId` type — both use the same 32-byte
55/// representation, so the bridge is a zero-cost newtype unwrap.
56///
57/// STEP_6 (workspace-aware-cross-repo DAG) introduced this type. Older
58/// daemon clients that send `DaemonHello` without `logical_workspace`
59/// continue to work because the field is `#[serde(default)]` — they
60/// reproduce today's per-source-root semantics, with `workspace_id =
61/// None` on the matching [`crate::WorkspaceState`] entries.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
63pub struct WorkspaceId([u8; 32]);
64
65impl WorkspaceId {
66    /// Construct from raw 32 bytes. Callers in `sqry-daemon` use this
67    /// to bridge from `sqry_core::workspace::WorkspaceId::as_bytes()`.
68    #[must_use]
69    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
70        Self(bytes)
71    }
72
73    /// Borrow the 32-byte digest. Callers cross the bridge by feeding
74    /// these bytes back into `sqry_core::workspace::WorkspaceId`.
75    #[must_use]
76    pub const fn as_bytes(&self) -> &[u8; 32] {
77        &self.0
78    }
79
80    /// First 16 hex characters. Suitable for log lines / short
81    /// identifiers; **not** sufficient for cross-process identity.
82    #[must_use]
83    pub fn as_short_hex(&self) -> String {
84        let full = self.as_full_hex();
85        full[..16].to_string()
86    }
87
88    /// Full 64-character hex digest. Use this for any identity
89    /// comparison.
90    #[must_use]
91    pub fn as_full_hex(&self) -> String {
92        use std::fmt::Write as _;
93        let mut s = String::with_capacity(64);
94        for byte in &self.0 {
95            // `write!` to a `String` is infallible.
96            let _ = write!(s, "{byte:02x}");
97        }
98        s
99    }
100}
101
102impl std::fmt::Display for WorkspaceId {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        f.write_str(&self.as_short_hex())
105    }
106}
107
108// ---------------------------------------------------------------------------
109// LogicalWorkspaceWire — daemon-IPC wire form of sqry-core's LogicalWorkspace.
110// ---------------------------------------------------------------------------
111
112/// Wire-form summary of a `LogicalWorkspace`, attached to
113/// [`DaemonHello`] / `daemon/load` payloads. Carries the workspace
114/// identity plus the canonical source-root paths the client wants the
115/// daemon to bind under a single grouping `workspace_id`.
116///
117/// `member_folders` and `exclusions` are explicitly **not** carried on
118/// this wire shape — they are MCP / redaction-side concerns (Step 7 of
119/// the workspace-aware-cross-repo plan), not daemon admission concerns.
120/// The daemon only needs `workspace_id` + the source-root list to build
121/// one [`crate::WorkspaceState`]-keyed entry per source root.
122#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
123#[serde(deny_unknown_fields)]
124pub struct LogicalWorkspaceWire {
125    /// 32-byte BLAKE3-256 identity of the logical workspace.
126    pub workspace_id: WorkspaceId,
127    /// Canonical absolute source-root paths. The daemon constructs one
128    /// `WorkspaceKey { workspace_id: Some(this id), source_root: <p>, .. }`
129    /// per entry, all sharing the same `workspace_id` for grouping.
130    pub source_roots: Vec<std::path::PathBuf>,
131    /// STEP_11_4 — per-source-root bindings. Each entry's `path` MUST
132    /// appear in [`Self::source_roots`]; the binding's
133    /// `config_fingerprint` overrides the workspace-level default for
134    /// that root only. Empty in the common case so the wire stays
135    /// pre-STEP_11_4-compatible.
136    #[serde(default, skip_serializing_if = "Vec::is_empty")]
137    pub source_root_bindings: Vec<SourceRootBinding>,
138    /// STEP_11_4 — workspace-level config fingerprint applied to any
139    /// source root that does not carry its own
140    /// [`SourceRootBinding::config_fingerprint`] override. `0` is the
141    /// "fingerprint not set" sentinel.
142    #[serde(default, skip_serializing_if = "is_zero_u64")]
143    pub workspace_config_fingerprint: u64,
144}
145
146fn is_zero_u64(value: &u64) -> bool {
147    *value == 0
148}
149
150/// STEP_11_4 — per-source-root binding inside a [`LogicalWorkspaceWire`].
151///
152/// `path` MUST appear in the parent [`LogicalWorkspaceWire::source_roots`]
153/// vector; the daemon matches bindings to source roots by canonical path
154/// equality. A binding whose `path` is not in `source_roots` is silently
155/// ignored.
156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
157#[serde(deny_unknown_fields)]
158pub struct SourceRootBinding {
159    /// Canonical absolute path of the source root this binding applies to.
160    pub path: std::path::PathBuf,
161    /// Per-source-root override of the config fingerprint. `0` means
162    /// "use the workspace-level fingerprint"; non-zero overrides for
163    /// this source root only.
164    #[serde(default, skip_serializing_if = "is_zero_u64")]
165    pub config_fingerprint: u64,
166    /// Optional pre-resolved classpath directory for this source root.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub classpath_dir: Option<std::path::PathBuf>,
169}
170
171// ---------------------------------------------------------------------------
172// WorkspaceIndexStatus — daemon/workspaceStatus result payload.
173// ---------------------------------------------------------------------------
174
175/// Aggregate status of a single source root inside a logical workspace.
176/// Mirrors the per-source-root subset of `WorkspaceStatus` so cross-repo
177/// MCP / LSP queries can render a per-source-root state without paying
178/// the cost of the full `daemon/status` snapshot.
179///
180/// STEP_11_4 (workspace-aware-cross-repo, 2026-04-26) — adds the
181/// `classpath_present` flag so consumers of `daemon/workspaceStatus`
182/// know which source roots have JVM classpath analysis available
183/// (`<source_root>/.sqry/classpath/` exists) without having to make a
184/// separate filesystem probe. The flag is per-source-root, never
185/// aggregated, so a workspace mixing JVM and non-JVM source roots
186/// reports accurate per-root granularity.
187#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
188pub struct WorkspaceSourceRootStatus {
189    /// Canonical absolute path to the source root.
190    pub source_root: std::path::PathBuf,
191    /// Per-source-root lifecycle state. `Evicted` is a valid (and
192    /// useful — partial eviction is observable here) value for a
193    /// source root that has been LRU'd out while sibling source roots
194    /// remain `Loaded`.
195    pub state: WorkspaceState,
196    /// Live graph size for this source root, in bytes.
197    pub current_bytes: u64,
198    /// STEP_11_4 — `true` when the daemon observed
199    /// `<source_root>/.sqry/classpath/` as a directory at status time.
200    /// `false` when the directory is absent or the probe failed (the
201    /// daemon never blocks status on a classpath probe; failures
202    /// surface through the LSP-side `WorkspaceIndexStatus.warnings`
203    /// channel instead).
204    ///
205    /// `#[serde(default)]` so v1 IPC payloads (which never carried the
206    /// flag) round-trip into `false`. `skip_serializing_if = ...` is
207    /// deliberately NOT applied — the flag must be serialised even
208    /// when `false` so consumers can distinguish "JVM-aware daemon
209    /// reporting no classpath" from "older daemon that does not yet
210    /// surface the flag".
211    #[serde(default)]
212    pub classpath_present: bool,
213}
214
215/// Aggregate status of a logical workspace, returned by
216/// `daemon/workspaceStatus { workspace_id }`.
217///
218/// The daemon walks every `WorkspaceKey` whose `workspace_id` matches
219/// the request and aggregates them into this view. A workspace is
220/// "partially evicted" when at least one source root reports
221/// [`WorkspaceState::Evicted`] but at least one other reports any
222/// non-Evicted state — see [`Self::partially_evicted`].
223///
224/// STEP_12 (workspace-aware-cross-repo, 2026-04-26) introduced the
225/// hex-string telemetry fields `workspace_id_short` (16 hex chars,
226/// display) and `workspace_id_full` (64 hex chars, machine identity).
227/// Scripts consuming this payload should key on `workspace_id_full` —
228/// the 32-byte `workspace_id` is the canonical bytewise identity but
229/// the hex string is what humans / shell tooling read. The two hex
230/// fields are derived from `workspace_id`; they are NOT independent
231/// inputs — they exist purely for ergonomic JSON consumption.
232#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
233pub struct WorkspaceIndexStatus {
234    /// Identity the request matched against.
235    pub workspace_id: WorkspaceId,
236    /// STEP_12 — short (16 hex) form of `workspace_id`, suitable for
237    /// CLI columns and human-scale log lines. Display only.
238    pub workspace_id_short: String,
239    /// STEP_12 — full (64 hex) form of `workspace_id`. Machine
240    /// identity. Cross-process script consumers MUST key on this
241    /// rather than the short form to avoid the (remote, non-zero)
242    /// possibility of short-hex collisions across hundreds of
243    /// thousands of distinct workspaces.
244    pub workspace_id_full: String,
245    /// Per-source-root status rows, sorted by `source_root` for
246    /// deterministic CLI / test output.
247    pub source_roots: Vec<WorkspaceSourceRootStatus>,
248}
249
250impl WorkspaceIndexStatus {
251    /// Whether at least one source root is in [`WorkspaceState::Evicted`]
252    /// while at least one other is not. `false` for fully-loaded or
253    /// fully-evicted aggregates.
254    #[must_use]
255    pub fn partially_evicted(&self) -> bool {
256        let any_evicted = self
257            .source_roots
258            .iter()
259            .any(|r| matches!(r.state, WorkspaceState::Evicted));
260        let any_alive = self
261            .source_roots
262            .iter()
263            .any(|r| !matches!(r.state, WorkspaceState::Evicted));
264        any_evicted && any_alive
265    }
266}
267
268// ---------------------------------------------------------------------------
269// Wire envelope version.
270// ---------------------------------------------------------------------------
271
272/// Version of the daemon wire envelope ([`DaemonHelloResponse::envelope_version`],
273/// [`ShimRegisterAck::envelope_version`]).
274///
275/// Bumped when the [`ResponseEnvelope`] schema changes in an incompatible way.
276/// Kept at `1` per the Amendment-2 2026-04-09 freeze.
277///
278/// This constant lives in the leaf wire-type crate (`sqry-daemon-protocol`) so
279/// every consumer of the wire format — the daemon itself, the daemon client
280/// (`sqry-daemon-client`), and the shim-mode callers inside `sqry-lsp` /
281/// `sqry-mcp` — validates against exactly one source of truth. Clients MUST
282/// reject a response whose `envelope_version` differs from this constant
283/// rather than proceed on a mismatched wire format.
284pub const ENVELOPE_VERSION: u32 = 1;
285
286// ---------------------------------------------------------------------------
287// WorkspaceState — moved here from sqry-daemon/src/workspace/state.rs
288// ---------------------------------------------------------------------------
289
290/// Six-state workspace lifecycle per plan Task 6 Step 1 and Amendment 2 §G.5 /
291/// §G.7.
292///
293/// The `#[repr(u8)]` is load-bearing: `sqry-daemon`'s `LoadedWorkspace::state`
294/// is an `AtomicU8`, and the conversions [`Self::from_u8`] / [`Self::as_u8`]
295/// serialise the state machine without allocation. Values are deliberately
296/// contiguous from 0 so adding a variant stays backwards-compatible with
297/// persisted telemetry.
298///
299/// This type lives in the leaf wire-type crate so [`ResponseMeta`] can
300/// carry a canonical workspace_state string on every successful tool
301/// response without the leaf crate taking a dep on `sqry-daemon` itself.
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
303#[repr(u8)]
304pub enum WorkspaceState {
305    /// Workspace entry exists but no graph has been loaded yet.
306    Unloaded = 0,
307
308    /// Initial load is in progress — a single blocking read from disk or
309    /// a full rebuild with no prior snapshot.
310    Loading = 1,
311
312    /// Graph is loaded, idle, and ready to serve queries.
313    Loaded = 2,
314
315    /// A rebuild (incremental or full) is actively running on the
316    /// dispatcher's background task. Queries keep serving the prior
317    /// `ArcSwap<CodeGraph>` snapshot until `publish_and_retain` swaps
318    /// the new graph in.
319    Rebuilding = 3,
320
321    /// Workspace was LRU-evicted or explicitly unloaded. The entry is
322    /// REMOVED from the manager map — the next query must re-load via
323    /// `get_or_load`. This discriminant exists for the short window
324    /// between `execute_eviction` storing the state and
325    /// `workspaces.remove(key)` completing (both under
326    /// `workspaces.write()`); external observers routed through
327    /// `WorkspaceManager::classify_for_serve` see the map-missing arm
328    /// first and get `DaemonError::WorkspaceEvicted` regardless.
329    Evicted = 4,
330
331    /// The most recent rebuild failed. Queries are served from the last
332    /// good snapshot with `meta.stale = true`; if the
333    /// `stale_serve_max_age_hours` cap is exceeded, queries receive the
334    /// JSON-RPC `-32002 workspace_stale_expired` error instead.
335    Failed = 5,
336}
337
338impl WorkspaceState {
339    /// Round-trip the state to its discriminant.
340    #[must_use]
341    pub const fn as_u8(self) -> u8 {
342        self as u8
343    }
344
345    /// Parse a discriminant back to a state. Returns `None` on any value
346    /// outside the current enum range — callers should treat this as a
347    /// telemetry corruption rather than silently map to `Unloaded`.
348    #[must_use]
349    pub const fn from_u8(value: u8) -> Option<Self> {
350        match value {
351            0 => Some(Self::Unloaded),
352            1 => Some(Self::Loading),
353            2 => Some(Self::Loaded),
354            3 => Some(Self::Rebuilding),
355            4 => Some(Self::Evicted),
356            5 => Some(Self::Failed),
357            _ => None,
358        }
359    }
360
361    /// Canonical display string. Used by `daemon/status` output and
362    /// tracing spans.
363    #[must_use]
364    pub const fn as_str(self) -> &'static str {
365        match self {
366            Self::Unloaded => "unloaded",
367            Self::Loading => "loading",
368            Self::Loaded => "loaded",
369            Self::Rebuilding => "rebuilding",
370            Self::Evicted => "evicted",
371            Self::Failed => "failed",
372        }
373    }
374
375    /// Whether the workspace can still serve queries in this state.
376    ///
377    /// `true` for [`Self::Loaded`], [`Self::Rebuilding`] (old snapshot
378    /// still served), and [`Self::Failed`] (stale-serve subject to the
379    /// age cap). `false` for [`Self::Unloaded`], [`Self::Loading`],
380    /// and [`Self::Evicted`].
381    #[must_use]
382    pub const fn is_serving(self) -> bool {
383        matches!(self, Self::Loaded | Self::Rebuilding | Self::Failed)
384    }
385}
386
387impl std::fmt::Display for WorkspaceState {
388    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
389        f.write_str(self.as_str())
390    }
391}
392
393// ---------------------------------------------------------------------------
394// Handshake types.
395// ---------------------------------------------------------------------------
396
397/// Pre-handshake header sent as the very first frame by a CLI client.
398/// The server responds with [`DaemonHelloResponse`] before the
399/// JSON-RPC request loop begins.
400#[derive(Debug, Clone, Serialize, Deserialize)]
401#[serde(deny_unknown_fields)]
402pub struct DaemonHello {
403    /// Free-form client identifier (`env!("CARGO_PKG_VERSION")` plus
404    /// user-agent suffix). Informational only.
405    pub client_version: String,
406
407    /// Wire protocol version. Phase 8a accepts exactly `1`.
408    pub protocol_version: u32,
409
410    /// Optional logical-workspace binding hint (STEP_6 of the
411    /// workspace-aware-cross-repo plan). When present, every
412    /// subsequent `daemon/load` on this connection that does not
413    /// itself supply `logical_workspace` inherits this binding —
414    /// keeping today's anonymous behaviour for clients that do not
415    /// set the hint.
416    ///
417    /// `#[serde(default)]` so older clients (and the standalone
418    /// `sqry-mcp` / `sqry-lsp` shims that have not yet learned about
419    /// logical workspaces) keep working with `None`. The daemon
420    /// router synthesises one `WorkspaceKey` per source root with
421    /// `workspace_id = Some(this id)`.
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub logical_workspace: Option<LogicalWorkspaceWire>,
424}
425
426/// Server's reply to [`DaemonHello`]. If `compatible` is `false` the
427/// server closes the connection immediately after the frame is sent.
428#[derive(Debug, Clone, Serialize, Deserialize)]
429#[serde(deny_unknown_fields)]
430pub struct DaemonHelloResponse {
431    pub compatible: bool,
432    pub daemon_version: String,
433    pub envelope_version: u32,
434}
435
436// ---------------------------------------------------------------------------
437// Shim handshake (Phase 8c wire types).
438// ---------------------------------------------------------------------------
439
440/// Which client protocol the shim will pump bytes for. Phase 8c surface.
441#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
442#[serde(rename_all = "lowercase")]
443pub enum ShimProtocol {
444    Lsp,
445    Mcp,
446}
447
448/// Shim registration header sent as the first frame by a
449/// `sqry lsp --daemon` or `sqry mcp --daemon` process. The router in
450/// sqry-daemon shape-discriminates between [`DaemonHello`] and this
451/// type using `#[serde(deny_unknown_fields)]`.
452#[derive(Debug, Clone, Serialize, Deserialize)]
453#[serde(deny_unknown_fields)]
454pub struct ShimRegister {
455    pub protocol: ShimProtocol,
456    pub pid: u32,
457}
458
459/// Server's reply to [`ShimRegister`]. If `accepted` is `false` the
460/// server closes the connection after sending the ack and the shim
461/// client surfaces `reason` to its parent process. When `accepted` is
462/// `true`, `reason` is omitted from the wire form (skip-if-none).
463#[derive(Debug, Clone, Serialize, Deserialize)]
464#[serde(deny_unknown_fields)]
465pub struct ShimRegisterAck {
466    pub accepted: bool,
467    pub daemon_version: String,
468    /// Rejection reason. Omitted from the wire when accepted=true.
469    #[serde(skip_serializing_if = "Option::is_none")]
470    pub reason: Option<String>,
471    pub envelope_version: u32,
472}
473
474// ---------------------------------------------------------------------------
475// ResponseEnvelope.
476// ---------------------------------------------------------------------------
477
478/// Uniform successful-response wrapper. Every successful method
479/// response is serialised as `ResponseEnvelope<T>` at the JSON-RPC
480/// `result` field — clients can rely on the [`ResponseMeta`] shape
481/// being present on every successful reply regardless of method.
482#[derive(Debug, Clone, Serialize, Deserialize)]
483pub struct ResponseEnvelope<T> {
484    pub result: T,
485    pub meta: ResponseMeta,
486}
487
488/// Metadata attached to every successful response. For Phase 8a
489/// management methods the staleness fields are always absent
490/// (`stale = false`, no last_good_at, no last_error,
491/// `workspace_state = None`). Phase 8b populates them from the
492/// server-side `ServeVerdict` for tool-method responses.
493#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
494pub struct ResponseMeta {
495    pub stale: bool,
496
497    #[serde(skip_serializing_if = "Option::is_none")]
498    pub last_good_at: Option<String>,
499
500    #[serde(skip_serializing_if = "Option::is_none")]
501    pub last_error: Option<String>,
502
503    /// Canonical workspace state string (serde form of
504    /// [`WorkspaceState`]). `None` for methods not tied to a workspace.
505    #[serde(skip_serializing_if = "Option::is_none")]
506    pub workspace_state: Option<WorkspaceState>,
507
508    pub daemon_version: String,
509}
510
511impl ResponseMeta {
512    /// Construct the [`ResponseMeta`] used by daemon management methods
513    /// (`daemon/status`, `daemon/unload`, `daemon/stop` — the ones not
514    /// bound to a specific workspace).
515    #[must_use]
516    pub fn management(daemon_version: &str) -> Self {
517        Self {
518            stale: false,
519            last_good_at: None,
520            last_error: None,
521            workspace_state: None,
522            daemon_version: daemon_version.to_owned(),
523        }
524    }
525
526    /// Construct the [`ResponseMeta`] for a successful `daemon/load`.
527    /// Phase 8b adds `fresh_from` / `stale_from` constructors for
528    /// MCP tool-method responses that route through `classify_for_serve`.
529    #[must_use]
530    pub fn loaded(daemon_version: &str) -> Self {
531        Self {
532            stale: false,
533            last_good_at: None,
534            last_error: None,
535            workspace_state: Some(WorkspaceState::Loaded),
536            daemon_version: daemon_version.to_owned(),
537        }
538    }
539
540    /// Construct [`ResponseMeta`] for a tool-method response served from a
541    /// Fresh workspace verdict (`WorkspaceState::Loaded` or `Rebuilding`).
542    ///
543    /// Phase 8b Task 7 — populated by the `tool_dispatch` helper when
544    /// the daemon's `WorkspaceManager::classify_for_serve` returns
545    /// `ServeVerdict::Fresh`. `stale` is `false` and both `last_good_at`
546    /// and `last_error` are absent from the wire form (they are skipped
547    /// by `serde(skip_serializing_if = "Option::is_none")`).
548    #[must_use]
549    pub fn fresh_from(state: WorkspaceState, daemon_version: &str) -> Self {
550        Self {
551            stale: false,
552            last_good_at: None,
553            last_error: None,
554            workspace_state: Some(state),
555            daemon_version: daemon_version.to_owned(),
556        }
557    }
558
559    /// Construct [`ResponseMeta`] for a tool-method response served from a
560    /// Stale verdict. `last_good_at` is rendered as RFC3339 UTC-Zulu via
561    /// `chrono::DateTime::<Utc>::from(SystemTime) -> to_rfc3339_opts(Secs, true)`.
562    ///
563    /// `workspace_state` is fixed at [`WorkspaceState::Failed`] because
564    /// `WorkspaceManager::classify_for_serve` only emits a Stale verdict
565    /// when the observed state is `Failed`. Keeping this constructor
566    /// intentionally rigid (no caller-supplied state) prevents the wire
567    /// form from claiming `stale = true` with a workspace_state the
568    /// classifier could never have produced.
569    #[must_use]
570    pub fn stale_from(
571        last_good_at: std::time::SystemTime,
572        last_error: Option<String>,
573        daemon_version: &str,
574    ) -> Self {
575        use chrono::{DateTime, SecondsFormat, Utc};
576        let rfc3339 =
577            DateTime::<Utc>::from(last_good_at).to_rfc3339_opts(SecondsFormat::Secs, true);
578        Self {
579            stale: true,
580            last_good_at: Some(rfc3339),
581            last_error,
582            workspace_state: Some(WorkspaceState::Failed),
583            daemon_version: daemon_version.to_owned(),
584        }
585    }
586}
587
588// ---------------------------------------------------------------------------
589// daemon/load result wire type.
590// ---------------------------------------------------------------------------
591
592/// `daemon/load` success result payload.
593///
594/// Serialised under the `result` field of [`ResponseEnvelope`]. Living
595/// in the leaf protocol crate lets both the daemon (writer) and
596/// [`sqry-daemon-client`][] (reader) share a single typed definition —
597/// clients can `serde_json::from_value::<ResponseEnvelope<LoadResult>>`
598/// and get compile-time schema checking instead of stringly-typed
599/// `serde_json::Value::get` lookups.
600///
601/// [`sqry-daemon-client`]: ../../sqry-daemon-client/index.html
602#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
603#[serde(deny_unknown_fields)]
604pub struct LoadResult {
605    /// The canonicalised workspace root path that the daemon loaded.
606    pub root: std::path::PathBuf,
607
608    /// Resident graph memory footprint for the loaded workspace, in
609    /// bytes. Matches `LoadedWorkspace::heap_bytes()` at the moment of
610    /// the response.
611    pub current_bytes: u64,
612
613    /// The canonical workspace lifecycle state after the load
614    /// completes. Always [`WorkspaceState::Loaded`] on the successful
615    /// `daemon/load` path — the field is typed so clients do not have
616    /// to re-parse the string.
617    pub state: WorkspaceState,
618}
619
620/// Status of a `daemon/rebuild` invocation (cluster-G §2.4).
621///
622/// Distinguishes the four outcomes the dispatcher can produce so a
623/// `--timeout 0` (fire-and-forget) caller can distinguish "started in
624/// the background" from "actually completed in this call". Pre-§2.4
625/// callers received only the `Completed` shape and a missing field
626/// here is interpreted as `Completed` for backward compatibility.
627#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
628#[serde(rename_all = "snake_case")]
629pub enum RebuildStatus {
630    /// The rebuild ran to completion in this call. `duration_ms`,
631    /// `nodes`, `edges`, `files_indexed`, and `was_full` are all
632    /// populated.
633    #[default]
634    Completed,
635    /// `--timeout 0` (fire-and-forget): the runner-role was acquired
636    /// and the rebuild is running in the background. The stat fields
637    /// are absent. The caller should poll `daemon/status` to observe
638    /// completion.
639    Started,
640    /// Another runner is active; this request was coalesced into the
641    /// pending lane. The stat fields reflect the runner's *previous*
642    /// publish if known, or are absent.
643    Coalesced,
644    /// Reservation failed before the pipeline started (e.g.
645    /// `MemoryBudgetExceeded`, `WorkspaceOversize`). The stat fields
646    /// are absent.
647    Rejected,
648}
649
650/// `daemon/rebuild` success result payload (schema_version 2 — see
651/// cluster-G §2.4).
652///
653/// Serialised under the `result` field of [`ResponseEnvelope`]. The
654/// stat fields are `Option`-typed because `--timeout 0` callers
655/// receive them populated only when `status == Completed`.
656#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
657pub struct RebuildResult {
658    /// The canonicalised workspace root path that was rebuilt.
659    pub root: std::path::PathBuf,
660    /// Outcome of the dispatch. New in cluster-G §2.4. Older clients
661    /// that pre-date the schema bump are tolerated by the
662    /// `#[serde(default)]` here — they read the field as `Completed`
663    /// and continue to work because pre-§2.4 daemons only ever
664    /// produced the completed shape.
665    #[serde(default)]
666    pub status: RebuildStatus,
667    /// Wall-clock time the rebuild took, in milliseconds. Populated
668    /// only when `status == Completed`.
669    #[serde(default, skip_serializing_if = "Option::is_none")]
670    pub duration_ms: Option<u64>,
671    /// Node count of the freshly published graph. Populated only
672    /// when `status == Completed`.
673    #[serde(default, skip_serializing_if = "Option::is_none")]
674    pub nodes: Option<u64>,
675    /// Edge count of the freshly published graph. Populated only
676    /// when `status == Completed`.
677    #[serde(default, skip_serializing_if = "Option::is_none")]
678    pub edges: Option<u64>,
679    /// Number of source files indexed in the freshly published
680    /// graph. Populated only when `status == Completed`.
681    #[serde(default, skip_serializing_if = "Option::is_none")]
682    pub files_indexed: Option<u64>,
683    /// `true` when the rebuild was a full (non-incremental) rebuild.
684    /// Populated only when `status == Completed`.
685    #[serde(default, skip_serializing_if = "Option::is_none")]
686    pub was_full: Option<bool>,
687}
688
689/// `daemon/cancel_rebuild` success result payload.
690///
691/// Serialised under the `result` field of [`ResponseEnvelope`].
692#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
693pub struct CancelRebuildResult {
694    /// The canonicalised workspace root path whose rebuild was signalled for
695    /// cancellation.
696    pub root: std::path::PathBuf,
697    /// `true` when a rebuild was actually in flight at the moment the
698    /// cancellation signal was dispatched.
699    pub cancelled: bool,
700}
701
702// ---------------------------------------------------------------------------
703// JSON-RPC 2.0 envelope types.
704// ---------------------------------------------------------------------------
705
706/// JSON-RPC `"2.0"` version tag. Manual serde impls enforce exact
707/// string match on the wire so malformed requests never leak into the
708/// method dispatcher.
709#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
710pub struct JsonRpcVersion;
711
712impl Serialize for JsonRpcVersion {
713    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
714        s.serialize_str("2.0")
715    }
716}
717
718impl<'de> Deserialize<'de> for JsonRpcVersion {
719    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
720        struct Vis(PhantomData<JsonRpcVersion>);
721        impl<'de> de::Visitor<'de> for Vis {
722            type Value = JsonRpcVersion;
723            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
724                f.write_str("the string \"2.0\"")
725            }
726            fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
727                if v == "2.0" {
728                    Ok(JsonRpcVersion)
729                } else {
730                    Err(E::invalid_value(de::Unexpected::Str(v), &"\"2.0\""))
731                }
732            }
733        }
734        d.deserialize_str(Vis(PhantomData))
735    }
736}
737
738/// JSON-RPC id: `null`, integer (signed or unsigned), or string.
739/// `I64` covers `i64::MIN..=i64::MAX`; `U64` covers
740/// `i64::MAX + 1..=u64::MAX`. Serde's untagged deserialize tries
741/// variants in order so `0..=i64::MAX` lands in `I64` and
742/// `i64::MAX + 1..=u64::MAX` in `U64`.
743#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
744#[serde(untagged)]
745pub enum JsonRpcId {
746    /// Signed integer id.
747    I64(i64),
748    /// Unsigned integer id above `i64::MAX`.
749    U64(u64),
750    /// String id.
751    Str(String),
752}
753
754/// JSON-RPC 2.0 request.
755#[derive(Debug, Clone, Serialize, Deserialize)]
756pub struct JsonRpcRequest {
757    pub jsonrpc: JsonRpcVersion,
758
759    /// `None` ≙ notification (no response expected).
760    #[serde(default, skip_serializing_if = "Option::is_none")]
761    pub id: Option<JsonRpcId>,
762
763    pub method: String,
764
765    #[serde(default)]
766    pub params: serde_json::Value,
767}
768
769/// JSON-RPC 2.0 response. `id` is [`Option<JsonRpcId>`] with **no**
770/// `skip_serializing_if` — the `None` case serialises as JSON `null`,
771/// which is exactly what the spec demands for parse-error and
772/// invalid-request responses.
773#[derive(Debug, Clone, Serialize, Deserialize)]
774pub struct JsonRpcResponse {
775    pub jsonrpc: JsonRpcVersion,
776
777    /// `null` on the wire when the server could not determine the
778    /// originating request id (parse error, invalid request shape,
779    /// batch element with un-parseable id).
780    pub id: Option<JsonRpcId>,
781
782    #[serde(flatten)]
783    pub payload: JsonRpcPayload,
784}
785
786/// Tagged success-or-error payload. Serde `untagged` so the wire form
787/// is `{... "result": ...}` or `{... "error": ...}`, never both.
788#[derive(Debug, Clone, Serialize, Deserialize)]
789#[serde(untagged)]
790pub enum JsonRpcPayload {
791    Success { result: serde_json::Value },
792    Error { error: JsonRpcError },
793}
794
795/// JSON-RPC 2.0 error payload.
796#[derive(Debug, Clone, Serialize, Deserialize)]
797pub struct JsonRpcError {
798    pub code: i32,
799    pub message: String,
800    #[serde(skip_serializing_if = "Option::is_none")]
801    pub data: Option<serde_json::Value>,
802}
803
804impl JsonRpcResponse {
805    /// Construct a successful response.
806    #[must_use]
807    pub fn success(id: Option<JsonRpcId>, result: serde_json::Value) -> Self {
808        Self {
809            jsonrpc: JsonRpcVersion,
810            id,
811            payload: JsonRpcPayload::Success { result },
812        }
813    }
814
815    /// Construct an error response.
816    #[must_use]
817    pub fn error(
818        id: Option<JsonRpcId>,
819        code: i32,
820        message: impl Into<String>,
821        data: Option<serde_json::Value>,
822    ) -> Self {
823        Self {
824            jsonrpc: JsonRpcVersion,
825            id,
826            payload: JsonRpcPayload::Error {
827                error: JsonRpcError {
828                    code,
829                    message: message.into(),
830                    data,
831                },
832            },
833        }
834    }
835}
836
837#[cfg(test)]
838mod tests {
839    use super::*;
840
841    #[test]
842    fn jsonrpc_version_roundtrip() {
843        let wire = serde_json::to_string(&JsonRpcVersion).unwrap();
844        assert_eq!(wire, r#""2.0""#);
845        let back: JsonRpcVersion = serde_json::from_str(&wire).unwrap();
846        assert_eq!(back, JsonRpcVersion);
847    }
848
849    #[test]
850    fn jsonrpc_version_rejects_wrong_string() {
851        let err = serde_json::from_str::<JsonRpcVersion>(r#""1.0""#)
852            .expect_err("must reject non-\"2.0\"");
853        assert!(err.to_string().contains("\"2.0\""));
854    }
855
856    #[test]
857    fn jsonrpc_id_untagged_roundtrip() {
858        let cases: &[(&str, JsonRpcId)] = &[
859            ("0", JsonRpcId::I64(0)),
860            ("-7", JsonRpcId::I64(-7)),
861            (&i64::MAX.to_string(), JsonRpcId::I64(i64::MAX)),
862            ("\"abc\"", JsonRpcId::Str("abc".into())),
863        ];
864        for (wire, expected) in cases {
865            let parsed: JsonRpcId = serde_json::from_str(wire).expect(wire);
866            assert_eq!(&parsed, expected, "round-trip failed for {wire}");
867        }
868        // i64::MAX + 1 routes to U64.
869        let u: JsonRpcId = serde_json::from_str("9223372036854775808").unwrap();
870        assert_eq!(u, JsonRpcId::U64(9_223_372_036_854_775_808));
871    }
872
873    #[test]
874    fn response_id_none_serializes_as_json_null() {
875        let resp = JsonRpcResponse::error(None, -32700, "Parse error", None);
876        let wire = serde_json::to_string(&resp).unwrap();
877        assert!(
878            wire.contains(r#""id":null"#),
879            "expected id:null in wire form, got: {wire}"
880        );
881    }
882
883    #[test]
884    fn response_id_some_serializes_as_value() {
885        let resp = JsonRpcResponse::success(Some(JsonRpcId::I64(7)), serde_json::json!({}));
886        let wire = serde_json::to_string(&resp).unwrap();
887        assert!(wire.contains(r#""id":7"#));
888    }
889
890    #[test]
891    fn response_meta_management_has_none_workspace_state() {
892        let meta = ResponseMeta::management("8.0.6");
893        let wire = serde_json::to_string(&meta).unwrap();
894        assert!(!wire.contains("workspace_state"), "wire: {wire}");
895        assert!(wire.contains(r#""stale":false"#));
896        assert!(wire.contains(r#""daemon_version":"8.0.6""#));
897    }
898
899    #[test]
900    fn response_meta_loaded_has_loaded_workspace_state() {
901        let meta = ResponseMeta::loaded("8.0.6");
902        let wire = serde_json::to_string(&meta).unwrap();
903        assert!(
904            wire.contains(r#""workspace_state":"Loaded""#),
905            "wire: {wire}"
906        );
907    }
908
909    #[test]
910    fn response_meta_fresh_from_emits_state() {
911        let meta = ResponseMeta::fresh_from(WorkspaceState::Loaded, "8.0.6");
912        let wire = serde_json::to_string(&meta).unwrap();
913        assert!(
914            wire.contains(r#""workspace_state":"Loaded""#),
915            "wire: {wire}"
916        );
917        assert!(wire.contains(r#""stale":false"#), "wire: {wire}");
918        // `last_good_at` / `last_error` are omitted for a Fresh verdict.
919        assert!(!wire.contains("last_good_at"), "wire: {wire}");
920        assert!(!wire.contains("last_error"), "wire: {wire}");
921
922        // Rebuilding is also a valid Fresh variant per `classify_for_serve`.
923        let meta_rebuild = ResponseMeta::fresh_from(WorkspaceState::Rebuilding, "8.0.6");
924        let wire_rebuild = serde_json::to_string(&meta_rebuild).unwrap();
925        assert!(
926            wire_rebuild.contains(r#""workspace_state":"Rebuilding""#),
927            "wire: {wire_rebuild}"
928        );
929    }
930
931    #[test]
932    fn response_meta_stale_from_rfc3339_and_workspace_state() {
933        let anchor =
934            std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_760_000_000);
935        let meta = ResponseMeta::stale_from(anchor, Some("boom".to_owned()), "8.0.6");
936        let wire = serde_json::to_string(&meta).unwrap();
937        assert!(wire.contains(r#""stale":true"#), "wire: {wire}");
938        assert!(
939            wire.contains(r#""workspace_state":"Failed""#),
940            "wire: {wire}"
941        );
942        assert!(wire.contains(r#""last_error":"boom""#), "wire: {wire}");
943        // RFC3339 UTC-Zulu — the rendered timestamp must terminate with `Z"`.
944        let last_good_marker = r#""last_good_at":""#;
945        let start = wire
946            .find(last_good_marker)
947            .unwrap_or_else(|| panic!("missing last_good_at in wire: {wire}"))
948            + last_good_marker.len();
949        let rest = &wire[start..];
950        let end = rest
951            .find('"')
952            .expect("last_good_at must be a closed string");
953        let rfc = &rest[..end];
954        assert!(rfc.ends_with('Z'), "expected UTC-Zulu, got: {rfc}");
955        assert!(
956            rfc.contains('T'),
957            "RFC3339 must carry a 'T' separator: {rfc}"
958        );
959    }
960
961    // ------------------------------------------------------------------
962    // ShimRegisterAck tests (Phase 8c U1 new surface).
963    // ------------------------------------------------------------------
964
965    #[test]
966    fn shim_register_ack_accepted_omits_reason_on_wire() {
967        let ack = ShimRegisterAck {
968            accepted: true,
969            daemon_version: "8.0.6".to_owned(),
970            reason: None,
971            envelope_version: 1,
972        };
973        let wire = serde_json::to_string(&ack).unwrap();
974        assert!(!wire.contains("reason"), "wire: {wire}");
975        assert!(wire.contains(r#""accepted":true"#), "wire: {wire}");
976        assert!(wire.contains(r#""daemon_version":"8.0.6""#), "wire: {wire}");
977        assert!(wire.contains(r#""envelope_version":1"#), "wire: {wire}");
978    }
979
980    #[test]
981    fn shim_register_ack_rejected_includes_reason() {
982        let ack = ShimRegisterAck {
983            accepted: false,
984            daemon_version: "8.0.6".to_owned(),
985            reason: Some("cap".to_owned()),
986            envelope_version: 1,
987        };
988        let wire = serde_json::to_string(&ack).unwrap();
989        assert!(wire.contains(r#""reason":"cap""#), "wire: {wire}");
990        assert!(wire.contains(r#""accepted":false"#), "wire: {wire}");
991    }
992
993    // ------------------------------------------------------------------
994    // deny_unknown_fields verification (iter-1 M1 fix).
995    // ------------------------------------------------------------------
996
997    #[test]
998    fn daemon_hello_rejects_unknown_fields() {
999        let wire = r#"{"client_version":"x","protocol_version":1,"extra":true}"#;
1000        let err = serde_json::from_str::<DaemonHello>(wire)
1001            .expect_err("DaemonHello must reject unknown fields");
1002        // serde's `deny_unknown_fields` error message contains
1003        // "unknown field" — enough to assert without pinning exact phrasing.
1004        let msg = err.to_string();
1005        assert!(
1006            msg.contains("unknown field"),
1007            "expected 'unknown field' in error, got: {msg}"
1008        );
1009    }
1010
1011    #[test]
1012    fn shim_register_rejects_unknown_fields() {
1013        let wire = r#"{"protocol":"lsp","pid":1,"extra":true}"#;
1014        let err = serde_json::from_str::<ShimRegister>(wire)
1015            .expect_err("ShimRegister must reject unknown fields");
1016        let msg = err.to_string();
1017        assert!(
1018            msg.contains("unknown field"),
1019            "expected 'unknown field' in error, got: {msg}"
1020        );
1021    }
1022}