Notifications
Processes
A process is a long-running operation that broadcasts its lifecycle as process.* events on the event bus. The server keeps a last-event-wins view with staleness expiry behind GET /v1/processes, so "what is running right now" needs no persistence — a restart forgets everything, and owners simply re-register and keep heartbeating.
Identity
Processes are keyed (sender.identity, id). The id is chosen by the publisher and only needs to be unique per publisher; sender.identity is server-stamped (and signature-verified on network scopes), so another account can neither collide with nor spoof your process. All devices of one account share an identity — put a run or device uuid in the id when several devices might run the same kind of work.
The id follows the event-target grammar [A-Za-z0-9._-]{1,128} because it becomes the envelope target (and a pub/sub topic segment such as ev/process/progress/<id>).
The event convention
| Type | data |
|---|---|
process.started |
{kind, title, target?} |
process.progress |
{kind, title, target?, done, total?, message?} |
process.done / process.cancelled |
{kind, title, target?} |
process.failed |
{kind, title, target?, error: {code?, message}} |
process.cancel |
{identity} — a directive at the owner, not a state change |
- The descriptor (
kind/title/target) is folded into every frame, so one progress heartbeat fully materializes a process on a device that missedprocess.started. data.targetis the process's subject (an object id, a run id); the envelopetargetcarries the process id.done/totalare free-unit counters; absenttotalmeans unknown.messageis a short status line (≤ 1024 bytes).- Counters fold with absent-means-keep semantics on every frame that carries them; a partial frame never regresses a value. Frames from other publishers are sanitized before entering the view (invalid
kind/targetdropped, oversized text clipped, negative counters clamped).
The endpoints below emit these frames for you. A publisher in the same space that emits them by hand through POST /v1/events shows up in everyone's view identically.
Endpoints
Account-scoped, behind the /v1 auth guard. Every POST answers the bus publish reply {"subscribers": n} (local matches, fire-and-forget).
| Method | Path | Body | Emits |
|---|---|---|---|
| POST | /v1/processes |
{id, kind, title, scope, spaceId?, target?} |
process.started |
| POST | /v1/processes/:id/progress |
{done?, total?, message?} — all optional, {} is a pure heartbeat |
process.progress |
| POST | /v1/processes/:id/finish |
{status: "done" | "failed" | "cancelled", error?} — error {code?, message} required iff failed |
terminal event |
| POST | /v1/processes/:id/cancel |
{identity?} |
process.cancel on the process's own scope |
| GET | /v1/processes |
— | {"processes": [...]}, expired rows swept, ordered by first observation |
kind is a short vocabulary token, title the human display line (≤ 256 bytes). Re-registering an id restarts its row — the supported owner-restart path. Progress and finish require the process to be live in the view under this account's identity, else 404 process.not_found.
B=http://127.0.0.1:7001/v1
curl -X POST $B/processes -H 'Content-Type: application/json' \
-d '{"id":"import-7f3a","kind":"import","title":"Importing notes.zip","scope":"space","spaceId":"SPACE"}'
curl -X POST $B/processes/import-7f3a/progress -H 'Content-Type: application/json' \
-d '{"done":120,"total":900,"message":"pages"}'
curl -X POST $B/processes/import-7f3a/progress -d '{}' # heartbeat
curl -X POST $B/processes/import-7f3a/finish -H 'Content-Type: application/json' \
-d '{"status":"done"}'
any process list
any process cancel import-7f3a
A row in the live view:
{ "identity": "A5k…", "self": true, "id": "import-7f3a",
"kind": "import", "title": "Importing notes.zip",
"scope": "space", "spaceId": "spc_…",
"state": "running", "done": 120, "total": 900, "message": "pages",
"startedAt": 1755000000, "updatedAt": 1755000042 }
startedAt / updatedAt are unix seconds of local observation on this device's clock. There is no /processes/subscribe; watch the raw frames with GET /v1/events/subscribe?type=process.* (or any events subscribe --type 'process.*') and re-read the view when one lands.
Heartbeat and staleness
- Owners POST progress at least every 15 s, even when idle — progress doubles as the heartbeat. Explicit values set (
total: 0back to unknown,message: ""to blank); absent values keep. - A running row not heard from for 45 s expires from the view — no synthesized event, it just vanishes; the owner's next progress POST answers
404 process.not_foundand it re-registers. - A terminal row (
done/failed/cancelled) lingers 60 s so consumers observe the outcome, then expires. - Stop heartbeating before finishing: a progress frame arriving after the terminal event resurrects the row to
running(last-event-wins) until the next finish or expiry.
Why it matters. Nothing is written anywhere to track progress, so a crashed owner leaves no stuck row to clean up, and a phone that joins a space mid-import fills its view from the next heartbeat without any history to replay.
Cancel flow
canceller POST /v1/processes/:id/cancel
→ server resolves (identity, id) in its view
→ emits process.cancel {identity: <owner>} on the process's own scope
owner listens on GET /v1/events/subscribe?type=process.cancel&target=<id>
→ ignores cancels whose data.identity is not its own
→ stops work
→ POST /v1/processes/:id/finish {"status": "cancelled"}
Cancel never mutates the view; only the owner's terminal event (or staleness expiry if the owner is dead) removes a process. With identity omitted, a single match wins; zero matches answer 404 process.not_found, several answer 409 process.ambiguous with details.identities listing them — pass identity to pick one. Any space member may cancel; the owner is free to ignore.
Remote visibility
| Scope | Visible on other devices |
|---|---|
device |
never leaves the machine |
account |
always — every server holds a standing interest on ev/process/> in the account's tech space, so processes on the account's other devices materialize with no local subscriber |
space |
while some local subscriber holds an interest on that space that covers process.* — type=process.*, a subset like type=process.cancel, or no type filter at all. A stream filtered to other types feeds nothing into the view |
A progress UI for a space keeps GET /v1/events/subscribe?scope=space&spaceId=…&type=process.* open and expects the view to fill within one heartbeat (≤ 15 s). The view is per device and eventually consistent.
Built-in producers
The server reports its own long work through the same view, all at device scope and under index.* kinds. Ordinary indexing stays silent: the FTS and embedding passes announce only once they have been running for more than 3 s, so a single edit never appears while a cold re-index or a large catch-up shows up with its counters already carrying the work done so far.
| Process id | kind |
Counters |
|---|---|---|
index.embed.<spaceId> |
index.embed |
done / total in documents; the total is re-read every round so late arrivals extend the bar |
index.fts.<spaceId> |
index.fts |
done in processed changes; total unknown |
index.model_download |
index.model_download |
done / total in bytes; announces at download start, even offline |
A failed download attempt is not terminal — it retries forever, keeping the row running with a generic message; the only terminals are done and, on shutdown, cancelled. A worker stopped mid-drain finishes as cancelled rather than leaving a ghost. Failure messages are generic on the wire (details go to the server log), and all three producers ignore cancel requests. This is how a client answers "why is semantic search empty right now" — see Indexing.
Errors
| Code | When |
|---|---|
404 process.not_found |
progress / finish on a process not live under this account; cancel with no match |
409 process.ambiguous |
cancel matched several identities; details.identities lists them |
events.*, space.* |
the emit path reuses the event-bus codes, e.g. 409 events.no_read_key on scope: space |
Note. Everything the event bus says applies: at-most-once, no replay, 64 KiB payloads, ~30 msg/s per-peer network budget. A 15 s heartbeat costs nothing; coalesce sub-second progress ticks.