Skip to main content

pushkin_daemon/
warm.rs

1//! Warm per-file state + watcher (spec §8.1): verdicts memoized by
2//! (path, content-hash), invalidated by keyed path events, cleared
3//! wholesale on manifest change. The memo NEVER re-decides — a hit
4//! returns the stored envelope, a miss runs the same `check_write` the
5//! cold path runs, so warm and cold are identical by construction.
6
7use notify::Watcher as _;
8use pushkin_core::envelope::CheckResult;
9use pushkin_core::manifest::Manifest;
10use pushkin_core::pipeline::{check_write, WriteRequest};
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::sync::{Arc, Mutex, RwLock};
15
16/// FNV-1a over the write content — the project's stable-hash convention
17/// (spec §7.4 chose it for nudge arms because std hashers are not
18/// contractually stable; the memo key reuses that decision).
19fn fnv1a(bytes: &[u8]) -> u64 {
20    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
21    for byte in bytes {
22        hash ^= u64::from(*byte);
23        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
24    }
25    hash
26}
27
28/// The manifest filename. Only the *resolved* one reloads (F73 phase 3);
29/// the name alone is not enough to identify it.
30const MANIFEST_NAME: &str = "pushkin.toml";
31
32type Memo = HashMap<String, (u64, CheckResult)>;
33
34struct Inner {
35    manifest: RwLock<Manifest>,
36    memo: Mutex<Memo>,
37    hits: AtomicU64,
38}
39
40/// Memoized check state shared between the accept loop and the watcher.
41pub struct WarmState {
42    inner: Arc<Inner>,
43}
44
45/// Keeps the filesystem watcher alive; dropping it stops watching.
46pub struct WatchGuard {
47    _watcher: notify::RecommendedWatcher,
48}
49
50impl WarmState {
51    #[must_use]
52    pub fn new(manifest: Manifest) -> Self {
53        Self {
54            inner: Arc::new(Inner {
55                manifest: RwLock::new(manifest),
56                memo: Mutex::new(HashMap::new()),
57                hits: AtomicU64::new(0),
58            }),
59        }
60    }
61
62    /// A handle to the same underlying state (memo, manifest, hit
63    /// counter are shared, not copied) — one per connection task.
64    #[must_use]
65    pub fn share(&self) -> Self {
66        Self {
67            inner: Arc::clone(&self.inner),
68        }
69    }
70
71    /// Warm-or-compute a verdict. A hit returns the stored envelope for
72    /// the same (path, content); a miss computes with the SAME
73    /// `check_write` the cold path uses and stores the result.
74    #[must_use]
75    pub fn check(&self, request: &WriteRequest) -> CheckResult {
76        let content_hash = fnv1a(request.content.as_bytes());
77        if let Ok(memo) = self.inner.memo.lock() {
78            if let Some((stored_hash, stored)) = memo.get(&request.file_path) {
79                if *stored_hash == content_hash {
80                    self.inner.hits.fetch_add(1, Ordering::SeqCst);
81                    return stored.clone();
82                }
83            }
84        }
85        let result = match self.inner.manifest.read() {
86            Ok(manifest) => check_write(&manifest, request),
87            Err(poisoned) => check_write(&poisoned.into_inner(), request),
88        };
89        if let Ok(mut memo) = self.inner.memo.lock() {
90            memo.insert(request.file_path.clone(), (content_hash, result.clone()));
91        }
92        result
93    }
94
95    /// Memo hits since construction.
96    #[must_use]
97    pub fn hits(&self) -> u64 {
98        self.inner.hits.load(Ordering::SeqCst)
99    }
100
101    /// Replace the manifest and drop every memoized verdict (mappings
102    /// shape every verdict; nothing memoized survives a manifest change).
103    pub fn swap_manifest(&self, manifest: Manifest) {
104        match self.inner.manifest.write() {
105            Ok(mut slot) => *slot = manifest,
106            Err(poisoned) => *poisoned.into_inner() = manifest,
107        }
108        if let Ok(mut memo) = self.inner.memo.lock() {
109            memo.clear();
110        }
111    }
112
113    /// Drop memoized verdicts for one path; untouched paths stay warm.
114    pub fn invalidate_path(&self, path: &str) {
115        if let Ok(mut memo) = self.inner.memo.lock() {
116            memo.remove(path);
117        }
118    }
119
120    /// Watch `root` recursively: a `pushkin.toml` change reloads the
121    /// manifest wholesale (parse failures keep the old manifest — a
122    /// half-saved edit must not tear down the gate); any other file
123    /// change invalidates that file's memo entry, keyed by its
124    /// root-relative path.
125    ///
126    /// # Errors
127    /// `std::io::Error` when the watcher cannot be created or attached.
128    pub fn watch(&self, root: &Path) -> std::io::Result<WatchGuard> {
129        let governing = root.join(MANIFEST_NAME);
130        self.watch_governing(root, &governing)
131    }
132
133    /// `watch`, with the governing manifest named explicitly (F73 phase 3).
134    ///
135    /// Only `governing` reloads. Every other `pushkin.toml` under the watch
136    /// root is a file the daemon does not answer to — adopting one let an agent
137    /// disarm the gate by writing a file the gate permits, because
138    /// `protected_paths = ["pushkin.toml"]` does not match a nested path.
139    ///
140    /// # Errors
141    /// `std::io::Error` when the watcher cannot be created or attached.
142    pub fn watch_governing(&self, root: &Path, governing: &Path) -> std::io::Result<WatchGuard> {
143        let inner = Arc::clone(&self.inner);
144        let root_buf = root.to_path_buf();
145        let governing_buf = canonical_or_owned(governing);
146        let mut watcher =
147            notify::recommended_watcher(move |event: Result<notify::Event, notify::Error>| {
148                let Ok(event) = event else { return };
149                for path in event.paths {
150                    handle_change(&inner, &root_buf, &governing_buf, &path);
151                }
152            })
153            .map_err(std::io::Error::other)?;
154        watcher
155            .watch(root, notify::RecursiveMode::Recursive)
156            .map_err(std::io::Error::other)?;
157        Ok(WatchGuard { _watcher: watcher })
158    }
159}
160
161/// Resolve symlinks and `..` so the predicate compares real locations, not
162/// spellings — macOS hands back `/private/var/...` for a `/var/...` watch. A
163/// path that cannot be canonicalized (it may not exist yet) is compared as
164/// given, which is the conservative direction: a miss declines a reload rather
165/// than granting one.
166fn canonical_or_owned(path: &Path) -> PathBuf {
167    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
168}
169
170fn handle_change(inner: &Inner, root: &Path, governing: &Path, changed: &Path) {
171    if canonical_or_owned(changed) == governing {
172        reload_manifest(inner, changed);
173        return;
174    }
175    if changed
176        .file_name()
177        .is_some_and(|name| name == MANIFEST_NAME)
178    {
179        // Recorded, not merely skipped (standing rule: every degradation is
180        // recorded). A nested manifest changing and nothing happening is
181        // indistinguishable from a dead watcher, and telling those two apart by
182        // experiment once cost three edit mechanisms and a discriminator.
183        eprintln!(
184            "pushkin daemon: declined to reload from {} — it is not the governing \
185             manifest ({}). Only the resolved manifest reloads.",
186            changed.display(),
187            governing.display()
188        );
189        return;
190    }
191    let key = changed
192        .strip_prefix(root)
193        .unwrap_or(changed)
194        .to_string_lossy()
195        .into_owned();
196    if let Ok(mut memo) = inner.memo.lock() {
197        memo.remove(&key);
198    }
199}
200
201/// Reload wholesale. A governing manifest that no longer loads STOPS the
202/// daemon (F71 Phase B, ruled question 4): a daemon serving rules its
203/// manifest no longer states is not fail-open, it is confidently wrong. The
204/// stop is recorded on stderr, never silent (standing rule), and clients
205/// fall back to the cold pipeline — which denies the same state, so the
206/// half-save case costs a daemon restart, not a gate hole.
207fn reload_manifest(inner: &Inner, path: &Path) {
208    // Transient vs durable, learned on CI (F76's platform shape): inotify
209    // delivers the change event MID-WRITE, so the first read can see a
210    // truncated file — the half-save the original guard protected. One
211    // settle-and-re-read separates that race from a manifest that is durably
212    // unloadable; only the second failure stops the daemon.
213    let mut outcome = load(path);
214    if outcome.is_err() {
215        std::thread::sleep(std::time::Duration::from_millis(200));
216        outcome = load(path);
217    }
218    let manifest = match outcome {
219        Ok(manifest) => manifest,
220        Err(reason) => {
221            eprintln!(
222                "pushkin daemon: the governing manifest {} no longer parses \
223                 ({reason}). Serving STOPPED — stale rules are worse than no \
224                 daemon; checks fall back to the cold pipeline until the \
225                 manifest is fixed and the daemon restarted.",
226                path.display()
227            );
228            std::process::exit(1);
229        }
230    };
231    match inner.manifest.write() {
232        Ok(mut slot) => *slot = manifest,
233        Err(poisoned) => *poisoned.into_inner() = manifest,
234    }
235    // Mappings shape every verdict; nothing memoized survives a manifest change.
236    if let Ok(mut memo) = inner.memo.lock() {
237        memo.clear();
238    }
239}
240
241/// One read-and-parse attempt, error as prose for the stop record.
242fn load(path: &Path) -> Result<Manifest, String> {
243    std::fs::read_to_string(path)
244        .map_err(|error| error.to_string())
245        .and_then(|text| Manifest::parse(&text).map_err(|error| error.to_string()))
246}