sqry_daemon/workspace/persisted_state.rs
1//! On-disk persisted-state schema for daemon workspace bookkeeping.
2//!
3//! STEP_6 (workspace-aware-cross-repo, 2026-04-26) introduced the
4//! `workspace_id` dimension on [`super::state::WorkspaceKey`] and a
5//! corresponding **v2** wire shape. To keep older sqryd state files —
6//! which never carried `workspace_id` and used the legacy `index_root`
7//! field name — readable across an upgrade, this module owns:
8//!
9//! - [`PersistedState::FORMAT_VERSION`] — the current schema version
10//! (bumped from `1` to `2` by STEP_6).
11//! - [`PersistedStateV1`] — the legacy v1 wire shape.
12//! - [`PersistedState`] (the canonical v2 form) — `format_version` +
13//! a vector of `WorkspaceKey`s.
14//! - [`load_persisted_state`] — the on-load upconverter. Reads JSON,
15//! peeks the version field, and either deserialises directly into
16//! the v2 form or upconverts a v1 payload by injecting
17//! `workspace_id = None` into every key (the `WorkspaceKey` serde
18//! impl handles the `index_root → source_root` field rename via the
19//! `#[serde(alias)]` attribute set in `state.rs`).
20//!
21//! No active-user migration is required (per `CLAUDE.md` ground rule
22//! #6: "we are in DEVELOPMENT, we DO NOT have to consider providing
23//! migration paths"). The upconverter exists as the minimal correctness
24//! gate so any future persisted-state writer cannot accidentally
25//! produce an unparseable file under the new schema.
26//!
27//! The daemon does not currently *write* persisted state to disk — the
28//! production durability surface is the per-graph snapshot at
29//! `.sqry/graph/snapshot.sqry` plus the derived-cache companion at
30//! `.sqry/graph/derived.sqry`, both owned by `sqry-core`. This module
31//! lays the typed groundwork for the daemon-side state writer that
32//! will land alongside the eventual session-resumption work; the
33//! v1→v2 upconverter and frozen test fixtures verify the contract
34//! today so the writer can be flipped on without revisiting the
35//! schema.
36
37use std::path::Path;
38
39use serde::{Deserialize, Serialize};
40
41use super::state::WorkspaceKey;
42
43/// The canonical v2 persisted-state shape introduced by STEP_6.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct PersistedState {
46 /// On-disk schema version. v2 is current; v1 is upconverted on load.
47 pub format_version: u32,
48 /// Bookkeeping snapshot — every workspace key the daemon was
49 /// tracking when the state was persisted. Per-key state (current
50 /// bytes / last_good_at / pinned) is recovered from
51 /// `.sqry/graph/snapshot.sqry` on reload, so we only persist the
52 /// keys themselves.
53 pub keys: Vec<WorkspaceKey>,
54}
55
56impl PersistedState {
57 /// Current persisted-state schema version. v1 lacked
58 /// `workspace_id` and used `index_root`; v2 (this constant) carries
59 /// `workspace_id: Option<WorkspaceId>` and renames the field to
60 /// `source_root`.
61 pub const FORMAT_VERSION: u32 = 2;
62
63 /// Construct a fresh v2 state from a key list.
64 #[must_use]
65 pub fn new_v2(keys: Vec<WorkspaceKey>) -> Self {
66 Self {
67 format_version: Self::FORMAT_VERSION,
68 keys,
69 }
70 }
71}
72
73/// Parse a v1-shaped persisted-state payload. Used by the on-load
74/// upconverter; not exposed publicly because v1 is a legacy format.
75#[derive(Debug, Clone, Deserialize)]
76struct PersistedStateV1 {
77 /// v1 marker is exactly `1`. The `format_version` field IS present
78 /// in v1 — the upconverter dispatches on its value.
79 #[allow(dead_code)] // shape-only — we matched on it before deserialising into this type
80 format_version: u32,
81 /// v1 keys — each carrying `index_root` (now `source_root` via
82 /// `#[serde(alias)]` on `WorkspaceKey`). Deserialising into the v2
83 /// `WorkspaceKey` here does the field-rename + `workspace_id =
84 /// None` injection in one step.
85 keys: Vec<WorkspaceKey>,
86}
87
88/// Errors surfaced by the persisted-state loader.
89#[derive(Debug, thiserror::Error)]
90pub enum PersistedStateError {
91 /// I/O failure reading the state file.
92 #[error("failed to read persisted state from {path}: {source}", path = path.display())]
93 Io {
94 /// File path the daemon attempted to read.
95 path: std::path::PathBuf,
96 /// Underlying transport error.
97 #[source]
98 source: std::io::Error,
99 },
100 /// JSON parse failure (top-level shape did not match either schema).
101 #[error("failed to parse persisted state JSON: {0}")]
102 Parse(#[from] serde_json::Error),
103 /// `format_version` was present but greater than
104 /// [`PersistedState::FORMAT_VERSION`] — the daemon refuses to
105 /// downgrade-parse a forward-version state file.
106 #[error("persisted state format_version {found} is newer than supported {supported}")]
107 UnsupportedFutureVersion {
108 /// Version we read off disk.
109 found: u32,
110 /// Highest version this daemon understands.
111 supported: u32,
112 },
113}
114
115/// Load a persisted-state payload from disk, upconverting v1 → v2 on
116/// the fly. The returned [`PersistedState::format_version`] always
117/// equals [`PersistedState::FORMAT_VERSION`] (v2) — callers can rely on
118/// the v2 invariants regardless of the on-disk layout.
119///
120/// # Errors
121///
122/// - [`PersistedStateError::Io`] for file-system failures.
123/// - [`PersistedStateError::Parse`] when the payload is not valid JSON
124/// or matches neither v1 nor v2.
125/// - [`PersistedStateError::UnsupportedFutureVersion`] when the on-disk
126/// `format_version` is greater than [`PersistedState::FORMAT_VERSION`]
127/// (`> 2`).
128pub fn load_persisted_state(path: &Path) -> Result<PersistedState, PersistedStateError> {
129 let bytes = std::fs::read(path).map_err(|source| PersistedStateError::Io {
130 path: path.to_path_buf(),
131 source,
132 })?;
133 parse_persisted_state(&bytes)
134}
135
136/// Parse a persisted-state payload from raw JSON bytes. Split out
137/// from [`load_persisted_state`] so unit tests can drive the
138/// upconverter without writing a temp file.
139///
140/// # Errors
141///
142/// - [`PersistedStateError::Parse`] when the payload is not valid JSON
143/// or matches neither the v1 nor v2 schema.
144/// - [`PersistedStateError::UnsupportedFutureVersion`] when the on-disk
145/// `format_version` exceeds [`PersistedState::FORMAT_VERSION`].
146pub fn parse_persisted_state(bytes: &[u8]) -> Result<PersistedState, PersistedStateError> {
147 // Peek the version field first. We do this with a minimal struct
148 // so a v1 payload that has been augmented with experimental fields
149 // future-versions might add does not break the dispatch.
150 #[derive(Deserialize)]
151 struct VersionPeek {
152 format_version: Option<u32>,
153 }
154 let peek: VersionPeek = serde_json::from_slice(bytes)?;
155 let version = peek.format_version.unwrap_or(1); // missing → assume v1
156
157 match version {
158 v if v == PersistedState::FORMAT_VERSION => Ok(serde_json::from_slice(bytes)?),
159 1 => {
160 // v1 → v2 upconvert: the `WorkspaceKey` Deserialize impl
161 // already accepts the legacy `index_root` field name (via
162 // `#[serde(alias)]`) and defaults `workspace_id` to `None`,
163 // so deserialising the inner `keys` array directly into the
164 // v2 `WorkspaceKey` shape is the upconvert.
165 let v1: PersistedStateV1 = serde_json::from_slice(bytes)?;
166 Ok(PersistedState::new_v2(v1.keys))
167 }
168 future if future > PersistedState::FORMAT_VERSION => {
169 Err(PersistedStateError::UnsupportedFutureVersion {
170 found: future,
171 supported: PersistedState::FORMAT_VERSION,
172 })
173 }
174 // Any other value (0, etc.) is a corrupt file — surface as a
175 // generic parse error so the caller can decide whether to
176 // delete or re-derive.
177 _ => Err(PersistedStateError::Parse(serde::de::Error::invalid_value(
178 serde::de::Unexpected::Unsigned(u64::from(version)),
179 &"format_version 1 or 2",
180 ))),
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use std::path::PathBuf;
188
189 use sqry_core::project::ProjectRootMode;
190
191 #[test]
192 fn parse_v2_round_trips() {
193 let original = PersistedState::new_v2(vec![WorkspaceKey::new(
194 PathBuf::from("/repos/example"),
195 ProjectRootMode::GitRoot,
196 0xabcd_ef01,
197 )]);
198 let wire = serde_json::to_vec(&original).expect("serialise v2");
199 let back = parse_persisted_state(&wire).expect("parse v2");
200 assert_eq!(back, original);
201 }
202
203 #[test]
204 fn parse_v1_upconverts_to_v2_with_workspace_id_none() {
205 // Synthetic v1 payload: `format_version=1`, legacy `index_root`
206 // field name, no `workspace_id`.
207 let v1 = serde_json::json!({
208 "format_version": 1,
209 "keys": [
210 {
211 "index_root": "/repos/example",
212 "root_mode": "gitRoot",
213 "config_fingerprint": 0
214 }
215 ]
216 });
217 let wire = serde_json::to_vec(&v1).unwrap();
218 let upconverted = parse_persisted_state(&wire).expect("upconvert v1");
219 assert_eq!(upconverted.format_version, PersistedState::FORMAT_VERSION);
220 assert_eq!(upconverted.keys.len(), 1);
221 assert!(
222 upconverted.keys[0].workspace_id.is_none(),
223 "v1 upconvert must inject workspace_id = None"
224 );
225 assert_eq!(
226 upconverted.keys[0].source_root,
227 PathBuf::from("/repos/example")
228 );
229 }
230
231 #[test]
232 fn parse_unsupported_future_version_errors() {
233 let future = serde_json::json!({
234 "format_version": 99,
235 "keys": []
236 });
237 let wire = serde_json::to_vec(&future).unwrap();
238 let err = parse_persisted_state(&wire).expect_err("must reject future version");
239 assert!(matches!(
240 err,
241 PersistedStateError::UnsupportedFutureVersion {
242 found: 99,
243 supported: 2
244 }
245 ));
246 }
247}