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