Skip to content
Like what we’re building? Star on GitHub

Flow — notifications and callbacks: one outcome event, three outbound families ​

A run that pauses or terminates publishes one outcome event, and three independent outbound families read it — a browser push to the run's users, a signed POST to the callback URL its launcher supplied, and an operator alert — each deduped by a claim on the episode rather than by trusting the bus to deliver once.

Drawn from the code at commit f564846d3; how this page is kept true: README.

The spine and the three families ​

trigger.BuildRunOutcome is the shared authority: both emitters — runview.emitRunOutcome in process, Runner.fireOutcomeEvent on a cloud runner pod — re-read the persisted run, let its status override the kind derived from the body error, and mint the same per-episode id. That id is what makes delivery idempotent, because the bus is deliberately lossy: with no subscriber at publish instant the event is gone, so the push and the operator-alert families each keep a twin 2-minute reconciliation sweep that rebuilds the same canonical event from the store and replays it through the same handler. One Mongo collection holds every claim; the operator family namespaces its keys with an ops| prefix so the two families share it without colliding.

The third family does not ride the bus at all: the run-completion callback is fired directly by the same two sites, before the event is published, and is pure best effort. pkg/mail is not a channel here — it serves invitations and password resets only.

The episode claim ​

One collection, one _id per episode, two phases. IsMarked reads true for a pending claim inside the grace; WasDelivered is strictly narrower, and a consumer certifying side-state off another claim's outcome must read that one.

StateMeansWritten by
absentno row: the episode was never claimed, was released, or its TTL reaped itUnmark, the sent_at_ttl index
pendingdelivered:false — a live fan-out holds it, and shields it for ClaimGraceTryMark, and its own stale-claim takeover
delivereddelivered:true — settled, never retriedMarkDelivered

Run health, observed in process ​

alert.Manager is the local lane: it has no bus and no claim store, and tracks liveness off the observed event stream with a persisted run_health twin. It is blind to runner pods, which is why the cloud OpsDispatcher exists.

StateMeansFires
activeliveness advancing, currentNode recordedbudget_warning, budget_exceeded
pausedan intentional wait: checkStalls skips the candidate and leaves its state untouched, so only node_started, run_resumed or a terminal event moves itnothing
stalledstallAlerted, once per episode; the next observed event of any kind clears itstall, then stall_recovered on the way out
terminalterminalAt stamped, reaped after 30 minutesrun_failed

Steps ​

ActorActionCode
user, studioregisters the service worker, subscribes under the server's VAPID key, POSTs the subscription — the worker is registered here and never at page loadenablePush, swRegistration
servergates on the feature, validates the three fields, upserts on the endpoint with the identity's team as provenancewebPushEnabled, handlePushSubscribe, Upsert
user, serversets the per-user scope; a row exists only when the user leaves the default, so absence means own-runs-onlyhandlePutNotificationPrefs, ValidScope
enginewrites the interaction, emits human_input_requested then run_paused, saves the checkpoint with the paused status in one writedoPause, pauseEventData, PauseRun
server, runnerfire the completion callback, then publish the outcome event — the runner only on a final delivery dispositionFireForRun, emitRunOutcome, fireOutcomeEvent
enginere-reads the run, overrides the kind with the persisted status, mints the per-episode idBuildRunOutcome, RunOutcomeEventID
serversubscribes the two consumers on the spine, and starts both sweepsstartUserNotify, startOperatorAlerts, Attach
servermaps the event to a kind (a usage park gets its own, read from the wall the event carries), vetoes a standby pause, claims the episodekindFor, usageParkWall, hasActiveVeille, TryMark
server, storeresolves recipients as the owner union the team-wide opt-ins, renders, deep-linksbuild, ListTeamWide, render, deeplink.Run
providerlists the recipients' subscriptions in one query and pushes in bounded parallel, pruning a dead endpointDeliver, ListForUsers, push, Prune
storesettles the episode: confirmed on one success, released when every sink failedMarkDelivered, Unmark
serverreplays what the bus dropped, pre-checking the claim before loading a runSweepOnce, sweepRefs, IsMarked, ListNotifiableRuns
serverresolves the final answer, vets and pins the host, POSTs the signed enveloperesolveFinalAnswer, resolveCallbackIP, deliver, Sign
serverclassifies a failed run for the operator and claims an ops-prefixed episodeclassify, opsEpisodeKey, fanOut
serverescalates a router decision through the same dispatcher and the same claimnotifyRouteDecisionErr, NotifyOperator
serverobserves the event stream, fires stall and budget alerts, reaps terminal stateObserve, checkStalls, Start
user, studiothe service worker renders the OS notification and focuses or navigates the studio tab on clickthe push and notificationclick handlers

Guards that refuse ​

  • webPushEnabled — a subscription store plus both VAPID keys. Without it registerNotificationRoutes is never called, so the five push and prefs routes do not exist: GET /api/v1/notifications/prefs reaches the SPA handler's JSON 404, while the mutating ones hit the GET / catch-all and answer 405 Method Not Allowed. A route that does exist answers 404 web push is not configured on this server when the store is later nil.
  • The subscription payload — 400 subscription requires endpoint, keys.p256dh and keys.auth; a bad body is 400 invalid subscription payload. Unsubscribe passes the caller's own user id into DeleteByEndpoint, so one user cannot delete another's endpoint.
  • ValidScope — 400 scope must be own or team; a server with no prefs store answers 404 notification prefs are not available on this server. handlePushTest additionally needs the live sink only startUserNotify builds.
  • kindFor — a run.paused carrying an empty interaction_id is an operator soft-pause and maps to no kind at all; an event with an empty subject id is dropped the same way.
  • pauseSuppressor — the standby veto, applied before any claim so the episode stays replayable: a run holding watched issue ids, or an active assistant watch row, gets no pause push. It is consulted on the human_input_requested kind only — a terminal outcome never reaches it, so the same assistant-watched run still pushes its finished, failed or cancelled notification.
  • TryMark — the claim: a delivered row, or a pending one inside ClaimGrace, loses. This single refusal is what dedups the bus path against the sweep and against every other replica.
  • The addressable check — no recipient or no wired sink confirms the claim instead of releasing it, so a deliberate no-op is never retried by the sweep or by the grace takeover.
  • WasDelivered — the operator dispatcher stamps a transition marker only for a confirmed episode: certifying a pending claim races its Unmark and would make every later sweep skip a released episode before Handle runs.
  • classify's default arm — a run that resumed or finished between the publish and the load owes nothing.
  • startOperatorAlerts — refuses out loud when the alerts webhook is set but no claim store is wired, because every replica and the sweep would otherwise re-send each episode.
  • shouldNotify — paused statuses are skipped: the run is waiting, not done, and the resume that terminates it fires the callback then.
  • The SSRF guard — http or https only, one host resolution that fails closed, strict unless private receivers are explicitly allowed, the single validated IP pinned into a per-delivery dialer, and redirects refused so no 3xx hop re-opens the rebinding window. The outbound callback has no role gate: this is its authorization decision.
  • The ignored event types — run_health and run_workspace_checkpoint never count as progress, so the alert manager cannot alert on its own twin and a workspace ticker cannot make a hung run read as alive; a blocking-human-wait proof found in flight cancels a stall about to fire.

Collections touched ​

push_subscriptions (upserted on the endpoint, listed by user_id with no tenant filter, pruned on 404/410, touched on success) · notification_prefs (written by the prefs route, read for the team-wide opt-ins) · sent_notifications (the episode claim — _id is the key, no tenant field at all, a 30-day TTL on sent_at, shared with the operator family under an ops| prefix) · runs (read by BuildRunOutcome, build, classify and FireForRun, and listed by ListNotifiableRuns, under WithoutTenantFilter — except the runner's FireForRun, which reads the run under the queue message's tenant identity, and the veto's read, which rides the handler's own context) · assistant_run_watches (read by the standby veto through ListActiveByAssistant, one query per pause episode) · interactions (written by doPause, read for the human-input body) · events (the pause and failure events the alert manager observes, and the persisted run_health twin it writes).