- Board renamed docs/plan/00-kanban.md -> docs/00-status.md and rebuilt: ▶ NEXT PLAN pointer (iteration 4 — emitter, corpus, `woc build`) then six buckets — stories, in progress, done, pending, discarded, learnings. It covered only the Rust runtime before, so the whole OOP track was invisible. All 16 inbound refs repointed; `Kanban:` banners renamed to `Status:`. - New discarded.md (settled rejections with reasons: inheritance, `abstract`, Money/SKU/Float, Dynamic/cast/macro/extern, AOT-to-C, Menhir, shared engine state) and learnings.md (plumbed≠enforced, vacuous goldens, exit-0-wrong- output, malloc-path ASan trick, deferred checks that never reach the VM). - RECOVERED docs/plan/exploration/blue-green-vm/00-vision.md — gone from disk, never committed (gitignored path), cited by five docs incl. principle 12. Root cause was broader: all seven forward-roadmap plans in docs/superpowers/plans/ were untracked and ignored, on one disk only. Dropped the docs ignore rules with a do-not-re-add note; added __pycache__/*.pyc. - Repaired broken links across docs/, 270 -> 36: fixes a regression from the earlier reference/ -> .dev/reference/ move (relative paths at ../../ and deeper were skipped), plus depth and reorg drift. The 36 residual point at content that does not exist and need decisions, not paths. - New spec docs/superpowers/specs/2026-08-10-logwatcher-gap-closure-design.md, applied: `and`/`or` verdict row; Part 3 gains `env` (six modules), swaps time.mono for iso/local, adds 22 bare core builtins; throw/time.mono/is cut (0 uses in the sample). Plan 8: Task 2 gains and/or, Task 5 drops throw, abstract+`is` task deleted, 8/9 renumber to 7/8. Plan 9 gains core builtins. Plan 10 gains the 307 -> 0 diagnostic gate. WO-E205 re-filed unreachable-by- design. types.ml header drops its false satisfaction-set claim. 00-code- review.md reduced to a stub — its rival Phase 1-4 roadmap retired.
7.2 KiB
07 — inotify Content Watcher
Status: ⬜ not started — Track 1 (runtime foundations). Board: 00-status.md
Context sources: ./02-event-loop-epoll.md, ./linux/00-linux.md § File Watching, ../02-recovery.md § No AWS Infrastructure.
Goal
First Stage-3 capability. When a .wo source file under the active project directory changes, inotify fires on the phase-02 event loop and the runtime hot-reloads the affected schema — parser re-run, catalog refreshed, live routes updated in-place. Maps to 00-linux.md's "watch the content directory for file creates, modifications, and deletes. Triggers re-indexing and subscriber notification when articles change. Replaces the S3 + Lambda event pipeline entirely."
Also the first real second consumer of the phase-02 EventLoop beyond the HTTP listener — validates the abstraction under cross-feature load.
Design decisions (locked)
inotify_init1(IN_CLOEXEC | IN_NONBLOCK)+inotify_add_watch. The fd is registered on the phase-02 event loop alongside the HTTP listener. No polling. No cross-platform fallback (kqueueon macOS,ReadDirectoryChangesWon Windows) — writeonce targets Linux only.- Per-directory watches, not per-file.
types/,ui/,logic/,tests/, andapp.wo's parent get a watch each; individual files are resolved from the event'swd+namefields. Prevents fd exhaustion on large projects (the defaultfs.inotify.max_user_watchesis 8192 on most distros, but we'd rather spend watches carefully). - Debounce at 150 ms. Editors issue multiple events per save (create tempfile → write → rename → delete old). A
TimerFd::oneshot(150ms)per-watch absorbs the burst; only the final "settled" state triggers a recompile. - Full recompile, not incremental. A file change invalidates the full schema catalog — re-run
rt::discover()→rt::parser::parse()→rt::compile::Catalog::from_schemas(). The sample projects are small (8 files for the blog); a full parse is < 50 ms. Incremental type-graph invalidation is a phase 09+ optimization. - Atomic catalog swap. The
Engine's catalog is behind anArc<ArcSwap<Catalog>>or equivalent — a new catalog replaces the old under a single pointer write, and in-flight HTTP handlers finish with the old one while new ones see the new. Under single-threaded execution this is essentially free; under sharding it becomes the per-shard atomic. - Module at
crates/rt/src/watch/. Same extraction-deferred rule as earlier modules.
Scope
New files inside crates/rt/src/watch/
| File | Responsibility | Port source |
|---|---|---|
mod.rs |
Re-exports Watcher, WatchEvent |
— |
inotify.rs |
Raw wrappers: init(), add_watch(path, mask), read_events() -> Vec<RawEvent>. Registers on the EventLoop. |
reference/crates/wo-watch/src/lib.rs (280 LOC) — v1 already does exactly this |
recursive.rs |
Walks the project root, calls add_watch for every directory matching types/|ui/|logic/|tests/ or containing *.wo |
~80 new LOC |
debounce.rs |
Coalesces bursts per-watch-descriptor, fires a TimerFd for the 150 ms settle window |
~100 new LOC |
reload.rs |
On debounced fire: re-discover, re-parse, re-compile, ArcSwap::store(new_catalog) |
~80 new LOC |
Total: ~540 LOC (280 ported + ~260 new).
Cargo.toml change
None. libc already covers inotify_init1 / inotify_add_watch / inotify_rm_watch.
Routing change in crates/rt/src/server.rs
The router needs to re-resolve the catalog on each request rather than close over a snapshot at boot:
// before
let router = Router::new().route("/api/articles", list_h_bound_to_catalog_snapshot);
// after
let shared = Arc::new(ArcSwap::from_pointee(catalog));
let router = Router::new().route("/api/articles", move |req, st| {
let cat = shared.load();
list_h(req, &cat, st)
});
One-time rewrite of the 12 handlers (4 types × 3 ops). Mechanical.
API shape (target)
use rt::event::EventLoop;
use rt::watch::Watcher;
let mut loop_ = EventLoop::new()?;
let mut watcher = Watcher::recursive(Path::new("docs/examples/blog"), Duration::from_millis(150))?;
watcher.register(&mut loop_)?;
for event in loop_.wait_once(None)? {
if event.token() == watcher.token() {
for change in watcher.drain() {
eprintln!("[wo] content change: {} ({})", change.path.display(), change.kind);
// reload pipeline fires here
}
}
}
Exit criteria
cargo buildgreen. No new deps.- Unit test: create a temp dir, write
a.wo, spin up aWatcheron a loop in a test thread, modifya.wo, assert the debouncedWatchEvent::Modified(path)arrives within 250 ms. - End-to-end manual:
cargo run --bin wo -- run docs/examples/blog & # observe: `curl :8080/api/articles` returns [...] # edit docs/examples/blog/types/article.wo — add a `nickname: Text?` field # wait 200 ms # observe: `curl :8080/api/articles` response shape reflects new field (no restart) [wo]log lines match the spec in 00-linux.md — one line per debounced change, showing the relative path and event kind.- All 14
rtunit tests still pass.reference/rest/blog.rest20-assertion battery still green. - No fd leak —
ls -la /proc/$PID/fdbefore and after ten consecutive edits shows the same count.
Non-scope
- No cross-platform fallback.
kqueueandReadDirectoryChangesWare not on the roadmap. Linux only. - No
fanotify. 00-linux.md lists it as "useful if watching needs to span mount points" — writeonce projects live in one directory tree;inotifyis enough. - No incremental reparse. Full recompile per settled change. If a real project hits the full-recompile wall, phase 09+ can add a dependency-graph-aware rebuilder.
- No subscription push. Phase 07 only detects and reloads. Notifying connected clients (the
register! { #{blog-title} => notify(fd) }model in 00-linux.md) is phase 09 oncesubactivates.
Verification
cargo build
cargo test --lib watch
cargo test --lib # 14 existing + watcher tests green
# manual hot-reload check
cargo run --bin wo -- run docs/examples/blog &
PID=$!
sleep 2
curl -s http://127.0.0.1:8080/api/articles
echo ' policy read anyone' >> docs/examples/blog/types/article.wo
sleep 0.5
curl -s http://127.0.0.1:8080/api/articles # server did not restart; catalog refreshed
kill $PID
git checkout docs/examples/blog/types/article.wo # undo the edit
cd reference/crates && cargo build && cargo test # v1 untouched
After this phase
The runtime now does what docs/02-recovery.md originally promised: the binary watches its own content directory with inotify and re-indexes on change. The S3 + Lambda + sync-trigger pipeline is fully replaced by one fd on one event loop in one process.
Phase 08 adds the other half of the v1 kernel-primitive story — sendfile for zero-copy static serving.