Skip to main content

devflow_core/
registry.rs

1//! Machine-global registry of currently-active DevFlow project roots.
2//!
3//! `Gates::list_open` (`gates.rs`) is scoped to one `project_root`, and every
4//! caller inherits that scope — there is nowhere in this codebase that
5//! answers "what is DevFlow doing across every project on this machine?"
6//! without shelling out to `ps` and `find` (see `23-ORPHAN-FORENSICS.md`).
7//! This module is that answer: a `(project_root, phase)` pair is registered
8//! on the same code path that already writes `state.monitor_pid`, so a
9//! running phase cannot be missing from the registry.
10//!
11//! **Storage shape (23-03 revision, cross-AI review BLOCKER 4):** one file
12//! per `(project_root, phase)` under a `roots/` subdirectory of the cache
13//! dir, enumerated with `read_dir`. Registration writes only its own file —
14//! there is no load-modify-write step and therefore no lost-update race to
15//! defend. A corrupt or truncated entry costs one entry, never the whole
16//! registry.
17
18use crate::phase_id::PhaseId;
19use serde::{Deserialize, Serialize};
20use std::path::{Path, PathBuf};
21
22/// A registered `(project_root, phase)` pair — one DevFlow phase this
23/// machine is (or recently was) running.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct RegisteredRoot {
26    /// The project's root directory.
27    pub project_root: PathBuf,
28    /// Phase number registered.
29    pub phase: PhaseId,
30    /// Unix timestamp (seconds) when this entry was written, as a string —
31    /// matches `GateFile.timestamp`'s existing wire shape.
32    pub registered_at: String,
33}
34
35/// Errors produced by registry operations.
36#[derive(Debug, thiserror::Error)]
37pub enum RegistryError {
38    /// Filesystem operation failed.
39    #[error("registry I/O failed: {0}")]
40    Io(#[from] std::io::Error),
41    /// JSON parse or serialization failed.
42    #[error("registry JSON failed: {0}")]
43    Json(#[from] serde_json::Error),
44}
45
46/// Resolve the DevFlow cache directory. The ONLY env-reading function in
47/// this module. Resolution order: `DEVFLOW_CACHE_DIR` (test/override hook),
48/// then `XDG_CACHE_HOME/devflow`, then `HOME/.cache/devflow`. Returns `None`
49/// when none of the three is set.
50///
51/// This workspace has no `dirs` crate and must not gain one
52/// (`23-RESEARCH.md` Standard Stack: zero new dependencies).
53pub fn cache_dir() -> Option<PathBuf> {
54    if let Some(dir) = std::env::var_os("DEVFLOW_CACHE_DIR") {
55        return Some(PathBuf::from(dir));
56    }
57    if let Some(dir) = std::env::var_os("XDG_CACHE_HOME") {
58        return Some(PathBuf::from(dir).join("devflow"));
59    }
60    let home = std::env::var_os("HOME")?;
61    Some(PathBuf::from(home).join(".cache").join("devflow"))
62}
63
64/// The `roots/` subdirectory of a cache dir, where per-registration entry
65/// files live.
66pub fn roots_dir_in(cache_dir: &Path) -> PathBuf {
67    cache_dir.join("roots")
68}
69
70/// The deterministic per-registration entry file path for `(project_root,
71/// phase)`. The digest is only a filename disambiguator — the authoritative
72/// `project_root` lives inside the file itself, and `load_roots_in` reads it
73/// from there, so a digest collision costs at most one shadowed entry and
74/// never a wrong path.
75pub fn entry_path_in(cache_dir: &Path, project_root: &Path, phase: PhaseId) -> PathBuf {
76    let digest = path_digest(project_root);
77    roots_dir_in(cache_dir).join(format!(
78        "{digest:016x}-{padded}.json",
79        padded = phase.padded()
80    ))
81}
82
83/// Inline FNV-1a 64-bit hash over `path`'s OS-string bytes, used only to
84/// derive a stable per-entry filename. `std::collections::hash_map::
85/// DefaultHasher` is deliberately not used here: its output is explicitly
86/// not guaranteed stable across Rust releases, and an unstable filename
87/// would orphan every existing entry on a toolchain bump.
88fn path_digest(path: &Path) -> u64 {
89    use std::os::unix::ffi::OsStrExt;
90    const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
91    const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
92    let mut hash = FNV_OFFSET_BASIS;
93    for byte in path.as_os_str().as_bytes() {
94        hash ^= u64::from(*byte);
95        hash = hash.wrapping_mul(FNV_PRIME);
96    }
97    hash
98}
99
100/// Register `(project_root, phase)` into the machine-global registry under
101/// `cache_dir`. Pure with respect to env. Creates the cache directory and
102/// the roots directory if absent (both private, mode `0o700` — T-23-33)
103/// and writes only this registration's own file, atomically — there is no
104/// load step, no merge step, and no rewrite of any other entry.
105/// Re-registering the same pair simply overwrites its own file with a
106/// fresh `registered_at`.
107///
108/// Atomicity here protects against a torn READ of one entry (two
109/// concurrent registrations of the SAME pair both complete and
110/// `load_roots_in` sees exactly one, valid file). It is not the mitigation
111/// for a lost update — the per-file shape has no read-modify-write step to
112/// lose one in, so two concurrent registrations of DIFFERENT pairs both
113/// survive by construction, not by locking.
114pub fn register_in(
115    cache_dir: &Path,
116    project_root: &Path,
117    phase: PhaseId,
118) -> Result<(), RegistryError> {
119    ensure_private_dir(cache_dir)?;
120    let dir = roots_dir_in(cache_dir);
121    ensure_private_dir(&dir)?;
122    let entry = RegisteredRoot {
123        project_root: project_root.to_path_buf(),
124        phase,
125        registered_at: unix_now(),
126    };
127    let path = entry_path_in(cache_dir, project_root, phase);
128    write_atomic(&path, &serde_json::to_string_pretty(&entry)?)?;
129    Ok(())
130}
131
132/// Remove the entry file for every registered root whose `project_root` no
133/// longer exists on disk, plus every entry file that cannot be parsed at
134/// all (so unreadable files cannot accumulate forever), returning the
135/// number of files removed. Removal is per-file `remove_file`; there is no
136/// rewrite of surviving entries, so pruning cannot disturb a registration
137/// written concurrently with it. Deliberately NOT called from
138/// [`load_roots_in`] — that must stay side-effect-free so a read-only
139/// command cannot mutate machine state; callers invoke this explicitly.
140pub fn prune_missing_in(cache_dir: &Path) -> usize {
141    let mut removed = 0;
142    let dir = roots_dir_in(cache_dir);
143    let Ok(entries) = std::fs::read_dir(&dir) else {
144        return 0;
145    };
146    for entry in entries.flatten() {
147        let name = entry.file_name();
148        let Some(name) = name.to_str() else { continue };
149        if !name.ends_with(".json") {
150            continue;
151        }
152        let path = entry.path();
153        let root_still_exists = std::fs::read_to_string(&path)
154            .ok()
155            .and_then(|contents| serde_json::from_str::<RegisteredRoot>(&contents).ok())
156            .is_some_and(|root| root.project_root.is_dir());
157        if !root_still_exists && std::fs::remove_file(&path).is_ok() {
158            removed += 1;
159        }
160    }
161    removed
162}
163
164/// [`prune_missing_in`] against the resolved machine-global cache dir. `0`
165/// when [`cache_dir`] resolves to `None`.
166pub fn prune_missing() -> usize {
167    let Some(dir) = cache_dir() else {
168        return 0;
169    };
170    prune_missing_in(&dir)
171}
172
173/// Remove the entry file for `(project_root, phase)`, if present. With the
174/// per-file storage shape this is a single `remove_file` on
175/// [`entry_path_in`] — no load, no rewrite, and therefore no way to
176/// disturb a sibling entry belonging to another phase or another root. A
177/// missing file (never registered, or already deregistered) is treated as
178/// success rather than an error.
179pub fn deregister_in(
180    cache_dir: &Path,
181    project_root: &Path,
182    phase: PhaseId,
183) -> Result<(), RegistryError> {
184    let path = entry_path_in(cache_dir, project_root, phase);
185    match std::fs::remove_file(path) {
186        Ok(()) => Ok(()),
187        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
188        Err(err) => Err(err.into()),
189    }
190}
191
192/// [`deregister_in`] against the resolved machine-global cache dir.
193/// Deregistration is best-effort observability cleanup, so any error
194/// (including [`cache_dir`] resolving to `None`) is swallowed — mirrors
195/// how every call site invokes this with `let _ =`.
196pub fn deregister(project_root: &Path, phase: PhaseId) {
197    let Some(dir) = cache_dir() else {
198        return;
199    };
200    let _ = deregister_in(&dir, project_root, phase);
201}
202
203/// Create `dir` if absent and set its mode to `0o700` — the registry names
204/// every project this user is currently running, which is information
205/// disclosure on a shared host (T-23-33). Same rationale D-09 recorded for
206/// the socket directory, reused here for a plain directory of files.
207fn ensure_private_dir(dir: &Path) -> Result<(), RegistryError> {
208    use std::os::unix::fs::PermissionsExt;
209    std::fs::create_dir_all(dir)?;
210    std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
211    Ok(())
212}
213
214/// Write `contents` to `path` atomically: write a uniquely-named temp file
215/// in the same directory, then `rename` over the target so a reader never
216/// observes a partial file. The temp name is unique per call (process id +
217/// a monotonic counter), unlike `gates.rs::write_atomic`'s fixed `.tmp`
218/// suffix — that shape is safe there because writers to one gate file are
219/// serialized elsewhere, but registry entries have no such lock, and two
220/// concurrent writers to the SAME entry sharing one temp path could tear
221/// each other's in-flight write.
222fn write_atomic(path: &Path, contents: &str) -> Result<(), RegistryError> {
223    static TEMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
224    let n = TEMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
225    let tmp = path.with_extension(format!("tmp.{}.{n}", std::process::id()));
226    std::fs::write(&tmp, contents)?;
227    std::fs::rename(&tmp, path)?;
228    Ok(())
229}
230
231/// Every registered root, sorted by `(project_root, phase)` so output is
232/// deterministic (`read_dir` order is not). `read_dir`s the roots
233/// directory, parsing each `.json` entry and skipping any that is
234/// unreadable or unparsable — exactly as `Gates::list_open` already skips
235/// unparsable gate files. Returns an empty `Vec` when the directory is
236/// absent. Never returns `Result`; enumeration must degrade, not die.
237pub fn load_roots_in(cache_dir: &Path) -> Vec<RegisteredRoot> {
238    let mut roots = Vec::new();
239    let dir = roots_dir_in(cache_dir);
240    let Ok(entries) = std::fs::read_dir(&dir) else {
241        return roots;
242    };
243    for entry in entries.flatten() {
244        let name = entry.file_name();
245        let Some(name) = name.to_str() else { continue };
246        if !name.ends_with(".json") {
247            continue;
248        }
249        let Ok(contents) = std::fs::read_to_string(entry.path()) else {
250            continue;
251        };
252        let Ok(root) = serde_json::from_str::<RegisteredRoot>(&contents) else {
253            continue;
254        };
255        roots.push(root);
256    }
257    roots.sort_by(|a, b| (&a.project_root, a.phase).cmp(&(&b.project_root, b.phase)));
258    roots
259}
260
261/// Register `(project_root, phase)` into the resolved machine-global cache
262/// dir. A silent `Ok(())` no-op when [`cache_dir`] resolves to `None` —
263/// registration is best-effort observability, never a reason to fail a
264/// launch.
265pub fn register(project_root: &Path, phase: PhaseId) -> Result<(), RegistryError> {
266    let Some(dir) = cache_dir() else {
267        return Ok(());
268    };
269    register_in(&dir, project_root, phase)
270}
271
272/// Every registered root in the resolved machine-global cache dir. An empty
273/// `Vec` when [`cache_dir`] resolves to `None`.
274pub fn load_roots() -> Vec<RegisteredRoot> {
275    let Some(dir) = cache_dir() else {
276        return Vec::new();
277    };
278    load_roots_in(&dir)
279}
280
281fn unix_now() -> String {
282    std::time::SystemTime::now()
283        .duration_since(std::time::UNIX_EPOCH)
284        .map(|d| d.as_secs().to_string())
285        .unwrap_or_else(|_| "0".to_string())
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn register_in_two_different_pairs_both_survive_and_load_sorted() {
294        let dir = tempfile::tempdir().unwrap();
295        let cache = dir.path();
296        let root_a = PathBuf::from("/tmp/project-a");
297        let root_b = PathBuf::from("/tmp/project-b");
298
299        register_in(cache, &root_a, PhaseId::new(5)).unwrap();
300        register_in(cache, &root_b, PhaseId::new(7)).unwrap();
301
302        let roots = load_roots_in(cache);
303        assert_eq!(roots.len(), 2);
304        assert!(
305            roots
306                .iter()
307                .any(|r| r.project_root == root_a && r.phase == PhaseId::new(5))
308        );
309        assert!(
310            roots
311                .iter()
312                .any(|r| r.project_root == root_b && r.phase == PhaseId::new(7))
313        );
314        // Sorted by (project_root, phase).
315        assert!(roots[0].project_root <= roots[1].project_root);
316    }
317
318    #[test]
319    fn register_in_same_root_two_phases_survive_as_distinct_files() {
320        let dir = tempfile::tempdir().unwrap();
321        let cache = dir.path();
322        let root = PathBuf::from("/tmp/project-multi-phase");
323
324        register_in(cache, &root, PhaseId::new(1)).unwrap();
325        register_in(cache, &root, PhaseId::new(2)).unwrap();
326
327        let roots = load_roots_in(cache);
328        assert_eq!(roots.len(), 2);
329        assert!(roots.iter().any(|r| r.phase == PhaseId::new(1)));
330        assert!(roots.iter().any(|r| r.phase == PhaseId::new(2)));
331    }
332
333    #[test]
334    fn load_roots_in_skips_one_corrupt_entry_and_keeps_its_sibling() {
335        let dir = tempfile::tempdir().unwrap();
336        let cache = dir.path();
337        let root = PathBuf::from("/tmp/project-good");
338        register_in(cache, &root, PhaseId::new(3)).unwrap();
339
340        let junk_path = roots_dir_in(cache).join("junk-entry.json");
341        std::fs::write(&junk_path, "{not json").unwrap();
342
343        let roots = load_roots_in(cache);
344        assert_eq!(roots.len(), 1);
345        assert_eq!(roots[0].project_root, root);
346        assert_eq!(roots[0].phase, PhaseId::new(3));
347    }
348
349    #[test]
350    fn load_roots_in_on_absent_directory_returns_empty_without_panicking() {
351        let dir = tempfile::tempdir().unwrap();
352        let cache = dir.path().join("never-created");
353        assert!(load_roots_in(&cache).is_empty());
354    }
355
356    #[test]
357    fn register_in_same_pair_twice_results_in_exactly_one_entry() {
358        let dir = tempfile::tempdir().unwrap();
359        let cache = dir.path();
360        let root = PathBuf::from("/tmp/project-reregister");
361
362        register_in(cache, &root, PhaseId::new(9)).unwrap();
363        register_in(cache, &root, PhaseId::new(9)).unwrap();
364
365        let roots = load_roots_in(cache);
366        assert_eq!(roots.len(), 1);
367    }
368
369    /// Cross-AI review BLOCKER 4's required fix: two concurrent
370    /// registrations for two DIFFERENT (project_root, phase) pairs must
371    /// BOTH survive — the per-file storage shape has no read-modify-write
372    /// step to lose one in.
373    #[test]
374    fn concurrent_registration_of_different_pairs_both_survive() {
375        let cache = tempfile::tempdir().unwrap();
376        let cache_path = cache.path().to_path_buf();
377        let root_a = PathBuf::from("/tmp/concurrent-project-a");
378        let root_b = PathBuf::from("/tmp/concurrent-project-b");
379
380        std::thread::scope(|scope| {
381            let a = scope.spawn(|| register_in(&cache_path, &root_a, PhaseId::new(1)));
382            let b = scope.spawn(|| register_in(&cache_path, &root_b, PhaseId::new(1)));
383            a.join().unwrap().unwrap();
384            b.join().unwrap().unwrap();
385        });
386
387        let roots = load_roots_in(&cache_path);
388        assert_eq!(roots.len(), 2, "both concurrent registrations must survive");
389        assert!(roots.iter().any(|r| r.project_root == root_a));
390        assert!(roots.iter().any(|r| r.project_root == root_b));
391    }
392
393    /// Two concurrent registrations for the SAME pair must never produce a
394    /// torn file — write-temp-then-rename per entry protects against a
395    /// torn read of the one file both writers target.
396    #[test]
397    fn concurrent_registration_of_same_pair_results_in_one_valid_entry() {
398        let cache = tempfile::tempdir().unwrap();
399        let cache_path = cache.path().to_path_buf();
400        let root = PathBuf::from("/tmp/concurrent-project-same");
401
402        std::thread::scope(|scope| {
403            let a = scope.spawn(|| register_in(&cache_path, &root, PhaseId::new(1)));
404            let b = scope.spawn(|| register_in(&cache_path, &root, PhaseId::new(1)));
405            a.join().unwrap().unwrap();
406            b.join().unwrap().unwrap();
407        });
408
409        let entry_path = entry_path_in(&cache_path, &root, PhaseId::new(1));
410        let contents = std::fs::read_to_string(&entry_path).unwrap();
411        let parsed: RegisteredRoot =
412            serde_json::from_str(&contents).expect("entry must not be torn");
413        assert_eq!(parsed.project_root, root);
414
415        let roots = load_roots_in(&cache_path);
416        assert_eq!(roots.len(), 1);
417    }
418
419    /// T-23-33: the registry names every project this user is currently
420    /// running — both the cache dir and the roots dir must be created
421    /// private (0700), not inherit whatever the parent directory's mode is.
422    #[test]
423    fn register_in_creates_cache_and_roots_dirs_with_mode_0700() {
424        use std::os::unix::fs::PermissionsExt;
425        let base = tempfile::tempdir().unwrap();
426        let cache_path = base.path().join("nested-cache");
427        let root = PathBuf::from("/tmp/project-perm");
428
429        register_in(&cache_path, &root, PhaseId::new(1)).unwrap();
430
431        let cache_mode = std::fs::metadata(&cache_path).unwrap().permissions().mode() & 0o777;
432        assert_eq!(
433            cache_mode, 0o700,
434            "cache dir must be created with mode 0700"
435        );
436
437        let roots_mode = std::fs::metadata(roots_dir_in(&cache_path))
438            .unwrap()
439            .permissions()
440            .mode()
441            & 0o777;
442        assert_eq!(
443            roots_mode, 0o700,
444            "roots dir must be created with mode 0700"
445        );
446    }
447
448    #[test]
449    fn prune_missing_in_removes_entry_for_deleted_root_and_reports_count() {
450        let cache = tempfile::tempdir().unwrap();
451        let project = tempfile::tempdir().unwrap();
452        let project_path = project.path().to_path_buf();
453        register_in(cache.path(), &project_path, PhaseId::new(1)).unwrap();
454        drop(project); // deletes the project's directory from disk
455
456        let removed = prune_missing_in(cache.path());
457
458        assert_eq!(removed, 1);
459        assert!(load_roots_in(cache.path()).is_empty());
460    }
461
462    #[test]
463    fn prune_missing_in_keeps_entry_for_existing_root() {
464        let cache = tempfile::tempdir().unwrap();
465        let project = tempfile::tempdir().unwrap();
466        register_in(cache.path(), project.path(), PhaseId::new(1)).unwrap();
467
468        let removed = prune_missing_in(cache.path());
469
470        assert_eq!(removed, 0);
471        assert_eq!(load_roots_in(cache.path()).len(), 1);
472    }
473
474    #[test]
475    fn prune_missing_in_removes_and_counts_unparsable_entry() {
476        let cache = tempfile::tempdir().unwrap();
477        let dir = roots_dir_in(cache.path());
478        std::fs::create_dir_all(&dir).unwrap();
479        std::fs::write(dir.join("junk.json"), "{not json").unwrap();
480
481        let removed = prune_missing_in(cache.path());
482
483        assert_eq!(removed, 1);
484        assert!(load_roots_in(cache.path()).is_empty());
485    }
486
487    #[test]
488    fn dereg_removes_matching_pair_and_leaves_sibling_phase_intact() {
489        let cache = tempfile::tempdir().unwrap();
490        let root = PathBuf::from("/tmp/project-dereg-phase");
491        register_in(cache.path(), &root, PhaseId::new(1)).unwrap();
492        register_in(cache.path(), &root, PhaseId::new(2)).unwrap();
493
494        deregister_in(cache.path(), &root, PhaseId::new(1)).unwrap();
495
496        let roots = load_roots_in(cache.path());
497        assert_eq!(roots.len(), 1);
498        assert_eq!(roots[0].phase, PhaseId::new(2));
499    }
500
501    /// Deregistration must be scoped to one root as well as one phase —
502    /// deleting `rootA`'s entry must never touch `rootB`'s.
503    #[test]
504    fn dereg_is_scoped_to_one_root_and_leaves_sibling_root_intact() {
505        let cache = tempfile::tempdir().unwrap();
506        let root_a = PathBuf::from("/tmp/project-dereg-root-a");
507        let root_b = PathBuf::from("/tmp/project-dereg-root-b");
508        register_in(cache.path(), &root_a, PhaseId::new(1)).unwrap();
509        register_in(cache.path(), &root_b, PhaseId::new(1)).unwrap();
510
511        deregister_in(cache.path(), &root_a, PhaseId::new(1)).unwrap();
512
513        let roots = load_roots_in(cache.path());
514        assert_eq!(roots.len(), 1);
515        assert_eq!(roots[0].project_root, root_b);
516    }
517
518    #[test]
519    fn dereg_on_never_registered_pair_is_a_noop() {
520        let cache = tempfile::tempdir().unwrap();
521        let root = PathBuf::from("/tmp/project-never-registered");
522
523        deregister_in(cache.path(), &root, PhaseId::new(1)).unwrap();
524
525        assert!(load_roots_in(cache.path()).is_empty());
526    }
527
528    /// `deregister_in` treats a missing file as success — calling it twice
529    /// (the second time on an already-removed entry) must not error.
530    #[test]
531    fn dereg_is_idempotent_when_entry_already_removed() {
532        let cache = tempfile::tempdir().unwrap();
533        let root = PathBuf::from("/tmp/project-dereg-idempotent");
534        register_in(cache.path(), &root, PhaseId::new(1)).unwrap();
535
536        deregister_in(cache.path(), &root, PhaseId::new(1)).unwrap();
537        deregister_in(cache.path(), &root, PhaseId::new(1)).unwrap();
538
539        assert!(load_roots_in(cache.path()).is_empty());
540    }
541
542    #[test]
543    fn path_digest_is_stable_and_distinguishes_different_paths() {
544        let a = Path::new("/tmp/project-a");
545        let b = Path::new("/tmp/project-b");
546
547        assert_eq!(path_digest(a), path_digest(a), "digest must be stable");
548        assert_ne!(
549            path_digest(a),
550            path_digest(b),
551            "different paths must yield different digests"
552        );
553
554        let cache = Path::new("/tmp/cache");
555        assert_ne!(
556            entry_path_in(cache, a, PhaseId::new(1)),
557            entry_path_in(cache, b, PhaseId::new(1)),
558            "different project roots must yield different entry paths"
559        );
560    }
561}