sqry_daemon/workspace/state.rs
1//! Workspace state machine and key types.
2//!
3//! Corresponds to Task 6 Step 1 of the sqryd plan, augmented by
4//! STEP_6 of the workspace-aware-cross-repo DAG (2026-04-26):
5//!
6//! - [`WorkspaceState`] — the six-state workspace lifecycle enum (A2 §G.5,
7//! §G.7). **Moved to `sqry-daemon-protocol` in Phase 8c U1** so the
8//! wire-type [`crate::ipc::protocol::ResponseMeta`] can carry a canonical
9//! `workspace_state` field without the leaf protocol crate taking a dep
10//! on `sqry-daemon`. Re-exported here so existing call sites continue to
11//! compile. Stored on [`crate::workspace::LoadedWorkspace`] as an
12//! [`AtomicU8`]; the `#[repr(u8)]` discriminant makes the store / load
13//! round-trip lossless and gives cheap exhaustive match arms.
14//! - [`WorkspaceKey`] — the identity used to dedup workspaces in
15//! [`crate::workspace::WorkspaceManager`].
16//!
17//! STEP_6 of the workspace-aware-cross-repo plan **augments** (does not
18//! replace) the original three-dimensional key. The composite key is
19//! now four-dimensional: an optional `workspace_id` that groups
20//! logically-related source roots, plus the canonical absolute
21//! `source_root` (renamed from `index_root` with a serde alias for
22//! wire-compat), the [`ProjectRootMode`] (so the same repo opened with
23//! different modes gets distinct cache entries), and a config
24//! fingerprint (so a meaningful config change forces a fresh load).
25//!
26//! Backward compatibility:
27//! - `workspace_id == None` reproduces today's per-source-root /
28//! anonymous semantics — exactly what the daemon did before STEP_6.
29//! - The serde wire form preserves the legacy `index_root` field name
30//! via `#[serde(alias = "index_root")]`, so persisted v1 JSON
31//! fixtures and over-the-wire payloads from older clients keep
32//! parsing without an explicit migration step. The new wire form
33//! emits `source_root`.
34//! - [`OldGraphToken`] — opaque handle used by the admission map to key
35//! retained old graphs. Never serialised; values are process-local.
36
37use std::{
38 path::{Path, PathBuf},
39 sync::atomic::{AtomicU64, Ordering},
40};
41
42use serde::{Deserialize, Serialize};
43use sqry_core::project::ProjectRootMode;
44use sqry_daemon_protocol::WorkspaceId;
45
46// ---------------------------------------------------------------------------
47// WorkspaceState — re-exported from the leaf protocol crate.
48// ---------------------------------------------------------------------------
49
50pub use sqry_daemon_protocol::protocol::WorkspaceState;
51
52// ---------------------------------------------------------------------------
53// WorkspaceKey
54// ---------------------------------------------------------------------------
55
56/// Composite identity for a loaded workspace.
57///
58/// Two workspaces with the same [`Self::source_root`] but different
59/// [`Self::root_mode`] or [`Self::config_fingerprint`] are distinct cache
60/// entries — this prevents cache collisions when the same repo is opened
61/// with different client configurations.
62///
63/// `source_root` is always the canonical absolute path (caller
64/// responsibility; [`Self::new`] does not canonicalise because a path may
65/// not exist on disk yet at the moment a key is synthesised).
66///
67/// # STEP_6 augmentation
68///
69/// `workspace_id` lifts the key into a four-dimensional space so a
70/// single logical workspace can group multiple source roots under one
71/// stable identity. With `workspace_id = None`, the key collapses to
72/// today's three-dimensional behaviour: each source root is its own
73/// anonymous / per-repo entry. With `workspace_id = Some(id)`, every
74/// `WorkspaceKey` sharing the same `id` belongs to the same logical
75/// workspace; the manager LRU still operates per-source-root so partial
76/// eviction is observable, but `daemon/workspaceStatus` aggregates
77/// across source roots that share the id.
78#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
79pub struct WorkspaceKey {
80 /// Optional identity of the logical workspace this key belongs to.
81 ///
82 /// `None` reproduces today's per-source-root / anonymous behaviour.
83 /// `Some(id)` groups this entry with every other `WorkspaceKey`
84 /// carrying the same `id` — `daemon/workspaceStatus { workspace_id }`
85 /// returns the aggregate, but the LRU still operates per-source-root.
86 ///
87 /// `#[serde(default)]` so v1 JSON fixtures (which never carry the
88 /// field) round-trip into `None`. Serialisation skips `None` so the
89 /// new wire form is identical to v1 for the anonymous case.
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub workspace_id: Option<WorkspaceId>,
92
93 /// Canonical absolute path to the source-root directory (the
94 /// per-source-root index unit).
95 ///
96 /// `#[serde(alias = "index_root")]` keeps every v1 payload that
97 /// emitted the legacy `index_root` field name parsing without
98 /// migration; the new wire form emits `source_root`.
99 #[serde(alias = "index_root")]
100 pub source_root: PathBuf,
101
102 /// How the project root was determined for this workspace.
103 pub root_mode: ProjectRootMode,
104
105 /// 64-bit fingerprint of the config values that materially affect
106 /// the graph (plugin selection, cost tiering, macro expansion
107 /// toggles, etc). Callers compute the fingerprint deterministically
108 /// from the client-declared options; an unchanged fingerprint
109 /// means the daemon is free to return the cached graph.
110 pub config_fingerprint: u64,
111}
112
113impl WorkspaceKey {
114 /// Construct a new anonymous (per-source-root) key. Caller is
115 /// responsible for passing a canonical absolute path.
116 ///
117 /// Equivalent to today's three-dimensional `WorkspaceKey`. For
118 /// logical-workspace-grouped keys, use [`Self::with_workspace_id`].
119 #[must_use]
120 pub fn new(source_root: PathBuf, root_mode: ProjectRootMode, config_fingerprint: u64) -> Self {
121 Self {
122 workspace_id: None,
123 source_root,
124 root_mode,
125 config_fingerprint,
126 }
127 }
128
129 /// Construct a logical-workspace-grouped key. Two keys sharing the
130 /// same `workspace_id` are aggregated by `daemon/workspaceStatus`;
131 /// they remain distinct cache entries (admission, LRU, eviction
132 /// all operate per source root).
133 #[must_use]
134 pub fn with_workspace_id(
135 workspace_id: WorkspaceId,
136 source_root: PathBuf,
137 root_mode: ProjectRootMode,
138 config_fingerprint: u64,
139 ) -> Self {
140 Self {
141 workspace_id: Some(workspace_id),
142 source_root,
143 root_mode,
144 config_fingerprint,
145 }
146 }
147
148 /// Backwards-compat accessor for the source-root path. Pre-STEP_6
149 /// the field was named `index_root`; the rename to `source_root`
150 /// preserves the new wire form while this accessor lets internal
151 /// call sites that read the path continue to compile without a
152 /// per-site rename.
153 #[must_use]
154 #[inline]
155 pub fn index_root(&self) -> &Path {
156 &self.source_root
157 }
158}
159
160impl std::fmt::Display for WorkspaceKey {
161 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 // The pre-STEP_6 format was `<path>[<mode>@<fingerprint>]`;
163 // we keep that suffix verbatim and prefix with the workspace-id
164 // short hex when one is bound. Anonymous keys round-trip to the
165 // exact pre-STEP_6 string.
166 if let Some(id) = &self.workspace_id {
167 write!(
168 f,
169 "{}@{}[{}@{:016x}]",
170 self.source_root.display(),
171 id,
172 self.root_mode,
173 self.config_fingerprint,
174 )
175 } else {
176 write!(
177 f,
178 "{}[{}@{:016x}]",
179 self.source_root.display(),
180 self.root_mode,
181 self.config_fingerprint,
182 )
183 }
184 }
185}
186
187// ---------------------------------------------------------------------------
188// WorkspaceId bridge — sqry_core ↔ sqry_daemon_protocol.
189// ---------------------------------------------------------------------------
190
191/// Bridge from the canonical [`sqry_core::workspace::WorkspaceId`] to
192/// the wire-form [`WorkspaceId`] (defined in `sqry-daemon-protocol` so
193/// the leaf protocol crate stays free of a `sqry-core` dependency).
194///
195/// The two types are byte-identical (32-byte BLAKE3-256 digest) so the
196/// bridge is a zero-cost copy of the underlying array.
197#[must_use]
198pub fn wire_workspace_id_from_core(core: &sqry_core::workspace::WorkspaceId) -> WorkspaceId {
199 WorkspaceId::from_bytes(*core.as_bytes())
200}
201
202// ---------------------------------------------------------------------------
203// OldGraphToken
204// ---------------------------------------------------------------------------
205
206/// Opaque token used by [`crate::workspace::admission::AdmissionState::retained_old`]
207/// to key entries for retained old graphs.
208///
209/// The token is a monotonic per-process counter sourced from a single
210/// [`AtomicU64`]. Uniqueness is guaranteed for the lifetime of the daemon
211/// process (2^64 is effectively inexhaustible at daemon-scale rates).
212/// Tokens are never persisted or serialised across restarts.
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
214pub struct OldGraphToken(u64);
215
216impl OldGraphToken {
217 /// Mint a fresh token. Thread-safe — concurrent callers always
218 /// receive distinct values.
219 pub fn new() -> Self {
220 static COUNTER: AtomicU64 = AtomicU64::new(1);
221 Self(COUNTER.fetch_add(1, Ordering::Relaxed))
222 }
223
224 /// Inspect the raw token value (useful for tracing).
225 #[must_use]
226 pub const fn raw(self) -> u64 {
227 self.0
228 }
229}
230
231impl Default for OldGraphToken {
232 fn default() -> Self {
233 Self::new()
234 }
235}
236
237impl std::fmt::Display for OldGraphToken {
238 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239 write!(f, "OldGraphToken({})", self.0)
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn state_round_trips_via_discriminant() {
249 for &s in &[
250 WorkspaceState::Unloaded,
251 WorkspaceState::Loading,
252 WorkspaceState::Loaded,
253 WorkspaceState::Rebuilding,
254 WorkspaceState::Evicted,
255 WorkspaceState::Failed,
256 ] {
257 assert_eq!(WorkspaceState::from_u8(s.as_u8()), Some(s), "{s}");
258 }
259 }
260
261 #[test]
262 fn state_from_out_of_range_is_none() {
263 assert_eq!(WorkspaceState::from_u8(6), None);
264 assert_eq!(WorkspaceState::from_u8(255), None);
265 }
266
267 #[test]
268 fn state_is_serving_matches_a2_table() {
269 assert!(!WorkspaceState::Unloaded.is_serving());
270 assert!(!WorkspaceState::Loading.is_serving());
271 assert!(WorkspaceState::Loaded.is_serving());
272 assert!(WorkspaceState::Rebuilding.is_serving());
273 assert!(!WorkspaceState::Evicted.is_serving());
274 assert!(WorkspaceState::Failed.is_serving());
275 }
276
277 #[test]
278 fn key_distinguishes_root_mode_and_fingerprint() {
279 let a = WorkspaceKey::new(
280 PathBuf::from("/repos/example"),
281 ProjectRootMode::GitRoot,
282 0x1234_5678_9abc_def0,
283 );
284 let b = WorkspaceKey::new(
285 PathBuf::from("/repos/example"),
286 ProjectRootMode::WorkspaceFolder,
287 0x1234_5678_9abc_def0,
288 );
289 let c = WorkspaceKey::new(
290 PathBuf::from("/repos/example"),
291 ProjectRootMode::GitRoot,
292 0xdead_beef_dead_beef,
293 );
294 assert_ne!(a, b, "different root_mode must be different keys");
295 assert_ne!(a, c, "different fingerprint must be different keys");
296 assert_eq!(a, a.clone(), "same components compare equal");
297
298 // STEP_6 augmentation: `workspace_id = None` reproduces today's
299 // anonymous / per-source-root semantics. Two anonymous keys
300 // with the same three classical dimensions collapse to the
301 // same cache entry — exactly the pre-STEP_6 invariant.
302 let d = WorkspaceKey::new(
303 PathBuf::from("/repos/example"),
304 ProjectRootMode::GitRoot,
305 0x1234_5678_9abc_def0,
306 );
307 assert_eq!(a, d, "anonymous keys reproduce per-source-root semantics");
308 assert!(
309 a.workspace_id.is_none(),
310 "WorkspaceKey::new must set workspace_id = None",
311 );
312
313 // STEP_6: same source-root + mode + fingerprint, different
314 // workspace_id ⇒ distinct cache entries. The classical-key
315 // semantics (acceptance criterion #7 in the STEP_6 brief) are
316 // preserved in the `None` case AND extended cleanly to `Some`.
317 let id_x = WorkspaceId::from_bytes([0x11; 32]);
318 let id_y = WorkspaceId::from_bytes([0x22; 32]);
319 let e = WorkspaceKey::with_workspace_id(
320 id_x,
321 PathBuf::from("/repos/example"),
322 ProjectRootMode::GitRoot,
323 0x1234_5678_9abc_def0,
324 );
325 let f = WorkspaceKey::with_workspace_id(
326 id_y,
327 PathBuf::from("/repos/example"),
328 ProjectRootMode::GitRoot,
329 0x1234_5678_9abc_def0,
330 );
331 let g = WorkspaceKey::with_workspace_id(
332 id_x,
333 PathBuf::from("/repos/example"),
334 ProjectRootMode::GitRoot,
335 0xdead_beef_dead_beef,
336 );
337 assert_ne!(a, e, "anonymous vs. logical key must differ");
338 assert_ne!(e, f, "different workspace_id must be different keys");
339 assert_ne!(
340 e, g,
341 "two LogicalWorkspaces sharing source-root path but differing \
342 config_fingerprint produce distinct cache entries"
343 );
344
345 // STEP_11_4 acceptance: the `LogicalWorkspace` case — two
346 // workspaces sharing the same workspace_id and source-root
347 // path but produced by distinct PluginSelectionConfig /
348 // HighCostMode / global indexing inputs (i.e. different
349 // `compute_workspace_config_fingerprint(...)` outputs)
350 // MUST land in distinct cache entries. This is the
351 // contract `WorkspaceKey` enforces on top of
352 // `LogicalWorkspace.config_fingerprint` /
353 // `SourceRoot.config_fingerprint`.
354 let fp_a = sqry_core::config::compute_workspace_config_fingerprint(
355 b"plugins:rust,go",
356 b"indexing:default",
357 );
358 let fp_b = sqry_core::config::compute_workspace_config_fingerprint(
359 b"plugins:rust,go,python",
360 b"indexing:default",
361 );
362 assert_ne!(
363 fp_a, fp_b,
364 "STEP_11_4: differing PluginSelectionConfig must produce \
365 distinct fingerprints"
366 );
367 let h = WorkspaceKey::with_workspace_id(
368 id_x,
369 PathBuf::from("/repos/example"),
370 ProjectRootMode::GitRoot,
371 fp_a,
372 );
373 let i = WorkspaceKey::with_workspace_id(
374 id_x,
375 PathBuf::from("/repos/example"),
376 ProjectRootMode::GitRoot,
377 fp_b,
378 );
379 assert_ne!(
380 h, i,
381 "STEP_11_4: two LogicalWorkspace-grouped WorkspaceKeys sharing \
382 workspace_id + source-root path but differing \
383 compute_workspace_config_fingerprint(...) MUST be distinct \
384 cache entries"
385 );
386
387 // And the per-source-root override / inheritance composes:
388 // a SourceRoot that does not carry an explicit override
389 // inherits the workspace-level fingerprint via
390 // `effective_config_fingerprint` — the daemon-side key reads
391 // the effective value, not the raw field, so the inheritance
392 // surface lives in `sqry-core` and is wire-compatible with
393 // today's `WorkspaceKey`.
394 let mut root = sqry_core::workspace::SourceRoot::from_path(PathBuf::from("/repos/example"));
395 assert_eq!(
396 root.config_fingerprint, 0,
397 "fresh SourceRoot has fingerprint 0"
398 );
399 assert_eq!(
400 root.effective_config_fingerprint(fp_a),
401 fp_a,
402 "SourceRoot with fingerprint 0 inherits the workspace default",
403 );
404 root.config_fingerprint = 0x1234_5678_9abc_def0;
405 assert_eq!(
406 root.effective_config_fingerprint(fp_a),
407 0x1234_5678_9abc_def0,
408 "SourceRoot with explicit override returns the override, not the default",
409 );
410 }
411
412 #[test]
413 fn token_is_monotonic_and_unique() {
414 let a = OldGraphToken::new();
415 let b = OldGraphToken::new();
416 let c = OldGraphToken::new();
417 assert!(a.raw() < b.raw());
418 assert!(b.raw() < c.raw());
419 assert_ne!(a, b);
420 assert_ne!(b, c);
421 }
422}