What It Does
Files on a mounted backend change outside your workspace all the time: a teammate uploads to Nextcloud, a pipeline rewrites an S3 object, a cron job deletes a report.watch turns those external changes into an async event
stream, so an agent reacts instead of rescanning and diffing directories.
Scopes
The root’s shape defines the depth, GNU shell glob style.* never crosses
/, and matching happens at delivery time, so files created after the watch
started still match.
The Event
FileMetadata carries fingerprint (the backend’s ETag/rev, or an
mtime|size composite), size, and modified. Producers fill only what their
signal honestly knows: a listing walk fills all three, a webhook payload none.
unknown is the overflow signal: if a burst exceeds the queue cap, pending
events collapse into one unknown event per watch root, meaning
“re-inventory this subtree”. Precision degrades; dirtiness is never lost.
Events are level-triggered: an event says what is dirty, not every
intermediate edit. Read current content through the workspace after receiving
one. That read is guaranteed fresh, because Mirage invalidates the changed
path’s cache entries, and every cached ancestor listing up to the mount root,
before the event reaches any subscriber.
Push Mode
Host a small endpoint in your own service, map the provider payload to aFileEvent, and inject it with ws.notify. For Nextcloud, the
webhook_listeners app (Nextcloud 30+) POSTs on every file event:
occ registration commands,
is examples/python/nextcloud/watch.py.
Pull Mode
Backends that implementdelta_hook() (Nextcloud today) answer one question:
what changed under this root since the last checkpoint? A baseline pull
(checkpoint=None) emits nothing; every later pull diffs against the
checkpoint you hand back. The whole consumer poller is:
Queues
Eachwatch() owns its own delivery queue: notify fans one event out into
every queue whose scope matches, so two watches on overlapping scopes each
consume at their own pace and a slow consumer only overflows its own queue.
The default RAMWatchQueue coalesces per path with level-triggered semantics
(create then update stays create; create then delete cancels; delete then
create becomes update), so pending size is bounded by distinct dirty paths,
not event volume. Overflow policy is collapse (default, the unknown
rescan event), drop_oldest, or error.
To customize, attach the runtime explicitly before the first watch:
WatchQueue protocol (push / pop /
pending / clear / close) drops in, so a Redis- or SQS-backed queue needs
no interface changes. See
examples/python/nextcloud/watch_custom_queue.py
for a runnable custom-queue setup.
To tear down without closing the workspace, await ws.detach_watch_runtime(): every subscriber queue closes (active watch
loops end cleanly) and the workspace returns to its idle state; the next
watch lazily attaches a fresh default runtime.
Scope Notes
- Watch roots are fixed at subscription time: there is no API to add or
remove roots on a live watch. To change scope, start a new
watch()with the updated list and stop iterating the old one (its pending events are dropped when it closes). A mutable subscription handle is future work. - A watch may span mounts, including a mount nested inside another
mount’s subtree: scope matching is on virtual paths, and each event is
invalidated on the mount that owns its path (longest prefix). Detection
stays per backend, so each mount needs its own signal feeding
ws.notify. - Python only for now; the TypeScript mirror is planned.
- Nextcloud is the first backend with a
delta_hook; push mode works with any backend today sincews.notifyaccepts events from any detection you run. See the Watch Matrix for per-resource support. - Events are in-memory and at-most-once per subscriber; durable queues and acknowledgement are future work.