diff --git a/Cargo.lock b/Cargo.lock index a28bdad..42f27d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -332,8 +341,10 @@ version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ + "iana-time-zone", "num-traits", "serde", + "windows-link", ] [[package]] @@ -388,6 +399,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -902,6 +919,30 @@ dependencies = [ "serde", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -2302,12 +2343,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.59.0" diff --git a/Cargo.toml b/Cargo.toml index ce6c31f..d33da55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ path = "src/main.rs" anyhow = "1" axum = "0.8" blake3 = "1" -chrono = { version = "0.4", default-features = false, features = ["std", "serde"] } +chrono = { version = "0.4", default-features = false, features = ["std", "serde", "clock"] } clap = { version = "4", features = ["derive", "env"] } hydra = "0.1" notify = "8" diff --git a/src/app.rs b/src/app.rs index 6ecaf9c..cdd81f2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -36,6 +36,7 @@ use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; use tokio::task::JoinHandle; +use crate::health::HealthState; use crate::http::{self as http_layer, HttpState}; use crate::index::NoteIndex; use crate::store::NoteStore; @@ -114,6 +115,8 @@ pub struct Anwesen { /// keeps it current via the writer's batches, and the HTTP layer reads /// from it on every request. pub store: Arc, + /// Shared mutable health surface consumed by `/health` ([ANW-8]). + pub health: Arc, } impl Anwesen { @@ -124,6 +127,7 @@ impl Anwesen { bind, counters: RestartCounters::new(), store: NoteStore::new(), + health: HealthState::new(), } } } @@ -146,26 +150,37 @@ impl Application for Anwesen { // registered and in its message loop before `vault_scanner` runs. // The `one_for_one` strategy makes the order irrelevant for restart // semantics, only for the cold-start handshake. + // + // The cold-start handshake also relies on this order in the other + // direction: `index_writer.init` skips the `rescan_now` cast on its + // first init (`record_init() == 0`) because `vault_scanner` is + // about to run its own startup walk. Reordering these children + // would silently break that assumption -- so don't. let children = [ IndexWriter { counters: self.counters.clone(), store: self.store.clone(), + health: self.health.clone(), } .child_spec(), VaultScanner { vault: self.vault.clone(), counters: self.counters.clone(), + health: self.health.clone(), } .child_spec(), FilesystemWatcher { vault: self.vault.clone(), counters: self.counters.clone(), + health: self.health.clone(), } .child_spec(), HttpServer { bind: self.bind, counters: self.counters.clone(), store: self.store.clone(), + health: self.health.clone(), + vault: self.vault.clone(), } .child_spec(), ]; @@ -201,23 +216,30 @@ fn call_not_supported(role: &str) -> Result, ExitReason> { pub struct VaultScanner { vault: PathBuf, counters: Arc, + health: Arc, } impl VaultScanner { fn child_spec(self) -> ChildSpec { let counters = self.counters.clone(); let vault = self.vault.clone(); + let health = self.health.clone(); ChildSpec::new(VAULT_SCANNER_NAME).start(move || { VaultScanner { vault: vault.clone(), counters: counters.clone(), + health: health.clone(), } .start_link(GenServerOptions::new().name(VAULT_SCANNER_NAME)) }) } fn run_walk(&self) { + self.health.set_in_flight_rescan(true); let result = vault::scan(&self.vault); + // Clear before the cast so a consumer that reads `/health` right + // after the cast doesn't see a stale `in_flight_rescan=true`. + self.health.set_in_flight_rescan(false); tracing::info!( notes = result.notes.len(), issues = result.issues.len(), @@ -285,16 +307,19 @@ pub enum FilesystemWatcherMessage { pub struct FilesystemWatcher { vault: PathBuf, counters: Arc, + health: Arc, } impl FilesystemWatcher { fn child_spec(self) -> ChildSpec { let counters = self.counters.clone(); let vault = self.vault.clone(); + let health = self.health.clone(); ChildSpec::new(FILESYSTEM_WATCHER_NAME).start(move || { FilesystemWatcherState { vault: vault.clone(), counters: counters.clone(), + health: health.clone(), watcher: None, debouncer: None, } @@ -310,6 +335,7 @@ impl FilesystemWatcher { struct FilesystemWatcherState { vault: PathBuf, counters: Arc, + health: Arc, watcher: Option, debouncer: Option>, } @@ -337,15 +363,33 @@ impl GenServer for FilesystemWatcherState { // (process shutdown). let _ = tx.send(res); }) - .map_err(|e| ExitReason::from(format!("filesystem_watcher: recommended_watcher: {e}")))?; + .map_err(|e| { + // Failing to construct the watcher means inotify isn't bound. + // Surface the state so /health reflects it before we exit and + // let the supervisor restart us. + self.health + .set_watcher_state(crate::health::WatcherState::Degraded); + ExitReason::from(format!("filesystem_watcher: recommended_watcher: {e}")) + })?; watcher .watch(&self.vault, RecursiveMode::Recursive) - .map_err(|e| ExitReason::from(format!("filesystem_watcher: watch: {e}")))?; + .map_err(|e| { + self.health + .set_watcher_state(crate::health::WatcherState::Degraded); + ExitReason::from(format!("filesystem_watcher: watch: {e}")) + })?; - let handle = tokio::spawn(run_debouncer(rx, self.vault.clone(), WATCH_DEBOUNCE_WINDOW)); + let handle = tokio::spawn(run_debouncer( + rx, + self.vault.clone(), + WATCH_DEBOUNCE_WINDOW, + self.health.clone(), + )); self.watcher = Some(watcher); self.debouncer = Some(handle); + self.health + .set_watcher_state(crate::health::WatcherState::Running); tracing::info!( restart, @@ -404,16 +448,19 @@ pub struct IndexBatch { pub struct IndexWriter { counters: Arc, store: Arc, + health: Arc, } impl IndexWriter { fn child_spec(self) -> ChildSpec { let counters = self.counters.clone(); let store = self.store.clone(); + let health = self.health.clone(); ChildSpec::new(INDEX_WRITER_NAME).start(move || { IndexWriterState { counters: counters.clone(), store: store.clone(), + health: health.clone(), index: None, } .start_link(GenServerOptions::new().name(INDEX_WRITER_NAME)) @@ -429,6 +476,7 @@ impl IndexWriter { pub(crate) struct IndexWriterState { counters: Arc, store: Arc, + health: Arc, /// Created lazily in `init` so a Tantivy construction failure surfaces /// as an `ExitReason` and triggers a supervisor restart, rather than /// poisoning the child spec. @@ -508,6 +556,7 @@ impl GenServer for IndexWriterState { tracing::debug!(%path, "index_writer: deleted"); } } + self.health.record_index_update(chrono::Utc::now()); Ok(()) } @@ -521,6 +570,10 @@ impl GenServer for IndexWriterState { } // -- http_server ------------------------------------------------------------ +// +// The axum binding lands here in ANW-13; the routes follow in /notes, +// /notes//, /query, /health. Updating this comment line when the +// surface grows so it doesn't quietly drift back into "stub" wording. /// The HTTP server has no inbound message protocol; the single `Noop` /// variant satisfies Hydra's `Receivable` bound. @@ -534,6 +587,8 @@ pub struct HttpServer { bind: SocketAddr, counters: Arc, store: Arc, + health: Arc, + vault: PathBuf, } impl HttpServer { @@ -541,11 +596,16 @@ impl HttpServer { let bind = self.bind; let counters = self.counters.clone(); let store = self.store.clone(); + let health = self.health.clone(); + let vault = self.vault.clone(); ChildSpec::new(HTTP_SERVER_NAME).start(move || { HttpServerState { bind, counters: counters.clone(), store: store.clone(), + health: health.clone(), + restart_counters: counters.clone(), + vault: vault.clone(), server: None, } .start_link(GenServerOptions::new().name(HTTP_SERVER_NAME)) @@ -557,6 +617,9 @@ struct HttpServerState { bind: SocketAddr, counters: Arc, store: Arc, + health: Arc, + restart_counters: Arc, + vault: PathBuf, server: Option>, } @@ -580,6 +643,9 @@ impl GenServer for HttpServerState { let router = http_layer::router(HttpState { store: self.store.clone(), + health: self.health.clone(), + restart_counters: self.restart_counters.clone(), + vault: self.vault.clone(), }); let server = tokio::spawn(async move { if let Err(e) = axum::serve(listener, router).await { diff --git a/src/health.rs b/src/health.rs new file mode 100644 index 0000000..4bf1ff2 --- /dev/null +++ b/src/health.rs @@ -0,0 +1,153 @@ +//! Shared health state surfaced by the `/health` endpoint per [ANW-8]. +//! +//! The fields the User Manual exposes are written from three places: +//! +//! - `in_flight_rescan` -- set by `vault_scanner` around each walk +//! (startup or `RescanNow`); +//! - `last_index_update_ts` -- set by `index_writer` on each successful +//! Rebuild / Batch / Upsert / Delete commit; +//! - `last_event_ts` and `watcher_state` -- set by `filesystem_watcher` +//! as raw events arrive (or fail to bind, per [[ADR-003 Filesystem +//! Change Tracking]]). +//! +//! Reads come from the HTTP layer. Atomic counters are used so updates +//! never contend on a lock; `chrono::DateTime` is stored as a Unix +//! millisecond stamp (0 means "never"). + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU8, Ordering}; + +use chrono::{DateTime, TimeZone, Utc}; + +/// Stable identifier for `watcher_state` -- the inotify binding only. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WatcherState { + Running, + Degraded, +} + +impl WatcherState { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Running => "running", + Self::Degraded => "degraded", + } + } +} + +#[derive(Debug, Default)] +pub struct HealthState { + in_flight_rescan: AtomicBool, + last_index_update_ms: AtomicI64, + last_event_ms: AtomicI64, + watcher_state: AtomicU8, // 0 = Running, 1 = Degraded +} + +impl HealthState { + #[must_use] + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + pub fn set_in_flight_rescan(&self, on: bool) { + self.in_flight_rescan.store(on, Ordering::Release); + } + + /// # Panics + /// Never; the timestamp is unconditionally stored. + pub fn record_index_update(&self, at: DateTime) { + self.last_index_update_ms + .store(at.timestamp_millis(), Ordering::Release); + } + + pub fn record_event(&self, at: DateTime) { + self.last_event_ms + .store(at.timestamp_millis(), Ordering::Release); + } + + pub fn set_watcher_state(&self, s: WatcherState) { + self.watcher_state.store( + match s { + WatcherState::Running => 0, + WatcherState::Degraded => 1, + }, + Ordering::Release, + ); + } + + #[must_use] + pub fn in_flight_rescan(&self) -> bool { + self.in_flight_rescan.load(Ordering::Acquire) + } + + #[must_use] + pub fn last_index_update(&self) -> Option> { + let ms = self.last_index_update_ms.load(Ordering::Acquire); + if ms == 0 { + None + } else { + Utc.timestamp_millis_opt(ms).single() + } + } + + #[must_use] + pub fn last_event(&self) -> Option> { + let ms = self.last_event_ms.load(Ordering::Acquire); + if ms == 0 { + None + } else { + Utc.timestamp_millis_opt(ms).single() + } + } + + #[must_use] + pub fn watcher_state(&self) -> WatcherState { + if self.watcher_state.load(Ordering::Acquire) == 0 { + WatcherState::Running + } else { + WatcherState::Degraded + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fresh_state_reads_initial_values() { + let s = HealthState::default(); + assert!(!s.in_flight_rescan()); + assert!(s.last_index_update().is_none()); + assert!(s.last_event().is_none()); + assert_eq!(s.watcher_state(), WatcherState::Running); + } + + #[test] + fn rescan_flag_round_trips() { + let s = HealthState::default(); + s.set_in_flight_rescan(true); + assert!(s.in_flight_rescan()); + s.set_in_flight_rescan(false); + assert!(!s.in_flight_rescan()); + } + + #[test] + fn timestamp_round_trips_at_millisecond_resolution() { + let s = HealthState::default(); + let now = Utc.with_ymd_and_hms(2026, 5, 14, 12, 0, 0).unwrap(); + s.record_index_update(now); + // Stored as ms; expect the same wall-clock value back. + assert_eq!(s.last_index_update(), Some(now)); + } + + #[test] + fn watcher_state_round_trips() { + let s = HealthState::default(); + s.set_watcher_state(WatcherState::Degraded); + assert_eq!(s.watcher_state(), WatcherState::Degraded); + s.set_watcher_state(WatcherState::Running); + assert_eq!(s.watcher_state(), WatcherState::Running); + } +} diff --git a/src/http.rs b/src/http.rs index 80a23a1..3c04629 100644 --- a/src/http.rs +++ b/src/http.rs @@ -24,6 +24,10 @@ use chrono::{DateTime, SecondsFormat, Utc}; use serde::Serialize; use std::collections::BTreeMap; +use std::path::PathBuf; + +use crate::app::RestartCounters; +use crate::health::HealthState; use crate::store::NoteStore; use crate::vault::{Note, frontmatter_to_json}; @@ -38,6 +42,9 @@ fn rfc3339_z(dt: DateTime) -> String { #[derive(Clone)] pub struct HttpState { pub store: Arc, + pub health: Arc, + pub restart_counters: Arc, + pub vault: PathBuf, } pub fn router(state: HttpState) -> Router { @@ -49,6 +56,7 @@ pub fn router(state: HttpState) -> Router { .route("/notes/", get(list_root_folder)) .route("/notes/{*path}", get(get_notes)) .route("/query", get(get_query)) + .route("/health", get(get_health)) .with_state(state) } @@ -101,6 +109,42 @@ async fn list_root_folder(State(state): State) -> Response { list_folder(&state, "").into_response() } +#[derive(Debug, Serialize)] +struct HealthResponse { + vault_path: String, + note_count: usize, + last_index_update_ts: Option, + last_event_ts: Option, + watcher_state: &'static str, + in_flight_rescan: bool, + supervisor: SupervisorBlock, +} + +#[derive(Debug, Serialize)] +struct SupervisorBlock { + restarts: BTreeMap<&'static str, u32>, +} + +async fn get_health(State(state): State) -> Response { + let body = HealthResponse { + vault_path: state.vault.to_string_lossy().into_owned(), + note_count: state.store.len(), + last_index_update_ts: state.health.last_index_update().map(rfc3339_z), + last_event_ts: state.health.last_event().map(rfc3339_z), + watcher_state: state.health.watcher_state().as_str(), + in_flight_rescan: state.health.in_flight_rescan(), + supervisor: SupervisorBlock { + restarts: state.restart_counters.snapshot(), + }, + }; + let bytes = serde_json::to_vec(&body).expect("health response serializes"); + Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(bytes)) + .expect("static response") +} + async fn get_query(State(state): State, OriginalUri(uri): OriginalUri) -> Response { let raw = uri.query().unwrap_or(""); let parsed = match crate::query::parse(raw) { @@ -377,6 +421,9 @@ mod tests { store.replace(notes); let r = router(HttpState { store: store.clone(), + health: crate::health::HealthState::new(), + restart_counters: crate::app::RestartCounters::new(), + vault: PathBuf::from("/test/vault"), }); (r, store) } diff --git a/src/lib.rs b/src/lib.rs index 9b992ec..9685f20 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ //! supervisor tree, and the HTTP surface as those issues land. pub mod app; +pub mod health; pub mod http; pub mod index; pub mod query; diff --git a/src/query.rs b/src/query.rs index 77dbdb3..7fb24da 100644 --- a/src/query.rs +++ b/src/query.rs @@ -24,7 +24,7 @@ use serde::Serialize; use serde_json::Value as JsonValue; use crate::store::NoteStore; -use crate::vault::{Frontmatter, Note, Value, frontmatter_to_json}; +use crate::vault::{Frontmatter, Value, frontmatter_to_json}; #[derive(Debug, PartialEq, Eq)] pub enum QueryError { @@ -127,6 +127,21 @@ pub fn parse(raw: &str) -> Result { } let (field, op) = split_operator(&key)?; + // Pre-validate values whose error-shape is documented per ANW-15 sie + // review #4 (regex) and #5 (exists). Surface 400s at parse time + // instead of silently zero-matching. + match op { + Operator::Regex => { + Regex::new(&value) + .map_err(|e| QueryError::BadValue(format!("{key}: invalid regex: {e}")))?; + } + Operator::Exists => { + value + .parse::() + .map_err(|_| QueryError::BadValue(format!("{key}={value}")))?; + } + _ => {} + } q.predicates.push(Predicate { field: field.to_string(), op, @@ -238,13 +253,17 @@ pub struct QueryResponse { } /// Run a parsed query against the in-memory note set. +/// +/// Per ANW-15 sie review #6, the per-match clone shed inside the +/// read-lock closure projects directly into the response shape -- no +/// `raw_bytes` / `body` copy happens for `/query`. pub fn execute(store: &NoteStore, query: &ParsedQuery, format_ts: F) -> QueryResponse where F: Fn(chrono::DateTime) -> String, { let prefix = query.path_prefix.as_deref().unwrap_or(""); let matches = store.with_read(|notes| { - let mut out = Vec::new(); + let mut out: Vec = Vec::new(); for (path, note) in notes { if !path_matches(path, prefix, query.recursive) { continue; @@ -252,27 +271,23 @@ where if !predicates_match(&query.predicates, ¬e.frontmatter) { continue; } - out.push(note.clone()); + out.push(ResultEntry { + path: note.path.clone(), + frontmatter: frontmatter_to_json(¬e.frontmatter), + last_modified: format_ts(note.last_modified), + etag: note.etag.clone(), + size: note.size, + }); } out }); let total = matches.len(); - let (results, truncated): (Vec, bool) = match query.limit { + let (results, truncated): (Vec, bool) = match query.limit { Some(limit) if matches.len() > limit => (matches.into_iter().take(limit).collect(), true), _ => (matches, false), }; - let entries = results - .into_iter() - .map(|n| ResultEntry { - path: n.path, - frontmatter: frontmatter_to_json(&n.frontmatter), - last_modified: format_ts(n.last_modified), - etag: n.etag, - size: n.size, - }) - .collect(); QueryResponse { - results: entries, + results, total, truncated, } @@ -282,15 +297,18 @@ fn path_matches(path: &str, prefix: &str, recursive: bool) -> bool { if prefix.is_empty() { return recursive || !path.contains('/'); } - let scoped = match path.strip_prefix(prefix) { - Some(rest) => rest.strip_prefix('/').unwrap_or(rest), - None => return false, - }; - if scoped.is_empty() { + // Anchor the prefix on a segment boundary so `__anw-path=Projects` + // doesn't match `Projects-old/x.md`. Fix-pattern matches the folder + // listing in ANW-14 (sie's ANW-15 review #3). + let scoped = if path == prefix { // The prefix itself names a note; consider it in-scope. - return true; - } - if recursive { + "" + } else if let Some(rest) = path.strip_prefix(&format!("{prefix}/")) { + rest + } else { + return false; + }; + if recursive || scoped.is_empty() { true } else { !scoped.contains('/') @@ -475,6 +493,8 @@ mod tests { use chrono::{TimeZone, Utc}; use std::collections::BTreeMap; + use crate::vault::Note; + fn note(path: &str, fm: Frontmatter) -> Note { Note { path: path.into(), @@ -679,6 +699,34 @@ mod tests { assert_eq!(r.results[0].path, "Projects/anwesen/x.md"); } + #[test] + fn control_param_path_anchors_on_segment_boundary() { + // ANW-22 regression: `Projects` must not match `Projects-old/x.md`. + let q = parse("__anw-path=Projects").unwrap(); + let s = store_with(vec![ + note("Projects/y.md", fm(&[])), + note("Projects-old/x.md", fm(&[])), + ]); + let r = run(&q, &s); + assert_eq!(r.total, 1); + assert_eq!(r.results[0].path, "Projects/y.md"); + } + + #[test] + fn regex_invalid_returns_bad_value() { + // ANW-15 sie review #4: invalid regex is 400 at parse time, not + // silently zero-match at exec time. + let err = parse("title__regex=(unclosed").unwrap_err(); + assert!(matches!(err, QueryError::BadValue(_))); + } + + #[test] + fn exists_unparseable_bool_returns_bad_value() { + // ANW-15 sie review #5: malformed bool is 400, not silent false. + let err = parse("deprecated__exists=maybe").unwrap_err(); + assert!(matches!(err, QueryError::BadValue(_))); + } + #[test] fn control_param_recursive_false_limits_to_direct_children() { let q = parse("__anw-path=Projects&__anw-recursive=false").unwrap(); diff --git a/src/watcher.rs b/src/watcher.rs index fefc56d..b8913e3 100644 --- a/src/watcher.rs +++ b/src/watcher.rs @@ -12,6 +12,7 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::time::{Duration, Instant}; use hydra::{Dest, GenServer}; @@ -22,6 +23,7 @@ use tokio::sync::mpsc::UnboundedReceiver; use crate::app::{INDEX_WRITER_NAME, IndexBatch, IndexWriterMessage, IndexWriterState}; use crate::app::{VAULT_SCANNER_NAME, VaultScanner, VaultScannerMessage}; +use crate::health::HealthState; use crate::vault; /// One path-scoped action derived from a native filesystem event. Always @@ -183,11 +185,13 @@ pub async fn run_debouncer( mut rx: UnboundedReceiver>, vault_root: PathBuf, window: Duration, + health: Arc, ) { loop { let Some(first) = rx.recv().await else { break; }; + health.record_event(chrono::Utc::now()); let mut events = vec![first]; let deadline = Instant::now() + window; loop {