ANW-8 /health endpoint + ANW-15 local follow-ups

This commit is contained in:
Andreas Brenner 2026-05-14 16:23:08 +02:00
parent b045efd1f0
commit 1e4d2a7b7c
8 changed files with 440 additions and 27 deletions

94
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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<NoteStore>,
/// Shared mutable health surface consumed by `/health` ([ANW-8]).
pub health: Arc<HealthState>,
}
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<M>(role: &str) -> Result<Option<M>, ExitReason> {
pub struct VaultScanner {
vault: PathBuf,
counters: Arc<RestartCounters>,
health: Arc<HealthState>,
}
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<RestartCounters>,
health: Arc<HealthState>,
}
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<RestartCounters>,
health: Arc<HealthState>,
watcher: Option<notify::RecommendedWatcher>,
debouncer: Option<JoinHandle<()>>,
}
@ -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<RestartCounters>,
store: Arc<NoteStore>,
health: Arc<HealthState>,
}
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<RestartCounters>,
store: Arc<NoteStore>,
health: Arc<HealthState>,
/// 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/<folder>/, /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<RestartCounters>,
store: Arc<NoteStore>,
health: Arc<HealthState>,
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<RestartCounters>,
store: Arc<NoteStore>,
health: Arc<HealthState>,
restart_counters: Arc<RestartCounters>,
vault: PathBuf,
server: Option<JoinHandle<()>>,
}
@ -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 {

153
src/health.rs Normal file
View file

@ -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<Utc>` 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<Self> {
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<Utc>) {
self.last_index_update_ms
.store(at.timestamp_millis(), Ordering::Release);
}
pub fn record_event(&self, at: DateTime<Utc>) {
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<DateTime<Utc>> {
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<DateTime<Utc>> {
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);
}
}

View file

@ -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<Utc>) -> String {
#[derive(Clone)]
pub struct HttpState {
pub store: Arc<NoteStore>,
pub health: Arc<HealthState>,
pub restart_counters: Arc<RestartCounters>,
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<HttpState>) -> Response {
list_folder(&state, "").into_response()
}
#[derive(Debug, Serialize)]
struct HealthResponse {
vault_path: String,
note_count: usize,
last_index_update_ts: Option<String>,
last_event_ts: Option<String>,
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<HttpState>) -> 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<HttpState>, 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)
}

View file

@ -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;

View file

@ -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<ParsedQuery, QueryError> {
}
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::<bool>()
.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<F>(store: &NoteStore, query: &ParsedQuery, format_ts: F) -> QueryResponse
where
F: Fn(chrono::DateTime<chrono::Utc>) -> 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<ResultEntry> = 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, &note.frontmatter) {
continue;
}
out.push(note.clone());
out.push(ResultEntry {
path: note.path.clone(),
frontmatter: frontmatter_to_json(&note.frontmatter),
last_modified: format_ts(note.last_modified),
etag: note.etag.clone(),
size: note.size,
});
}
out
});
let total = matches.len();
let (results, truncated): (Vec<Note>, bool) = match query.limit {
let (results, truncated): (Vec<ResultEntry>, 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();

View file

@ -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<notify::Result<Event>>,
vault_root: PathBuf,
window: Duration,
health: Arc<HealthState>,
) {
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 {