Skip to main content

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.
No setup call is needed: the watch runtime attaches lazily on first use, and an idle workspace carries no watch state at all. Mirage runs no server and no background loop. Detection is yours (a webhook receiver or a small poll loop); Mirage’s job is everything after the signal: cache invalidation, scope matching, and delivery.

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

Your service hosts the endpoint; mirage opens no socket and runs no watcher. What arrives is the provider’s own payload, and turning that into a path is the only real work. Three backends ship a mapper for it:
You import the mapper for the backend you mounted rather than asking the mount for one, because a push payload has no vendor-neutral shape: the code that builds the call already names the backend, so a generic accessor would buy nothing. A mapper answers with zero or more FileEvents and never invents a path it was not told about. When a notification names only a scope, it returns UNKNOWN on that directory, which the watcher reads as “re-inventory everything below”: IMAP IDLE says a mailbox changed, not which message, and a Slack file_shared cannot name the rendered filename. Slack is where the mapping earns its place. A message maps to channels/<name>__<CID>/<YYYY-MM-DD>/chat.jsonl, and the day is bucketed in UTC while Slack’s client shows local time, so a hand-written mapper names tomorrow’s directory for a fifth of every day and never errors, because notifying a path the mount does not serve evicts nothing. A thread reply is the other trap: chat.jsonl renders conversations.history, which returns parents only, so a reply appears in no day file at all. What changed is the parent’s reply_count, in the parent’s day. Runnable example: examples/python/slack/slack_watch.py. For a backend with no mapper, build the FileEvent yourself. Nextcloud’s webhook_listeners app (Nextcloud 30+) POSTs on every file event:
The full runnable version, including the one-time occ registration commands, is examples/python/nextcloud/watch.py.

Pull Mode

Backends that implement delta_hook() answer one question: what changed under this root since the last checkpoint? Eleven resource families ship one today, listed in the Watch Matrix with the fingerprint each one compares on. A baseline pull (checkpoint=None) emits nothing; every later pull diffs against the checkpoint you hand back. The whole consumer poller is:
A runnable version needing no credentials is examples/python/disk/watch.py: it writes to the mount’s directory behind mirage’s back, pulls the diff, and reads the changed file back through the workspace. Pull is self-healing: it diffs current backend state against your checkpoint, so events missed while your service was down surface on the next pull. A common production shape is push-first with a pull at startup as recovery, or webhook-as-doorbell: on any webhook, pump the pull once and trust its diff rather than the payload.

Queues

Each watch() 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:
Any object satisfying the async 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.
  • The TypeScript API has the same delivery, queue, and delta-hook design; see TypeScript Watch.
  • Pull detection ships for eleven resource families; push mode works with any backend today since ws.notify accepts 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.