Skip to content

SSE invalidation event contract (ARGUS-619)

Problem

GET /events (ARGUS-579) is a working SSE broker, but its event kinds grew ad hoc as each feature wired its own publisher: landing, deploy, task, approval, run, schedule. Nothing documented which kind fires on every state change each app surface renders, and three real surfaces had no invalidation event at all: native alert-store transitions, the merged Inbox feed's review actions, and agent-run (coding-agent queue) status changes. The app's polling replacement (ARGUS-620) needs a complete, documented contract so it never learns state late or by accident.

Transport

GET /events (bearer-gated, same token as POST /runs) opens an SSE stream. Optional ?kinds=task,run,... filters server-side to a comma-joined kind set; omitted or empty means "every kind." Each event is one SSE frame:

event: <kind>
data: {"kind":"<kind>","id":"...","repo":"...","pr_number":0,"status":"..."}

InvalidationEvent (internal/hub/events.go) is a refetch signal, not a data delta -- every field below is only enough to decide which read model to reload, never a replacement for calling the JSON endpoint. A spurious or duplicate event is harmless (an extra refetch); a missing one is the bug class this contract exists to close. All fields are omitempty; a kind uses only the fields it needs.

Field Type Carried by
kind string every event
id string task, approval, run, agent_run, alert, schedule, inbox
repo string landing
pr_number int landing
status string landing, run, deploy, agent_run, alert, inbox

Kinds

task -- Work surface (task/board/flow)

Fires on every task mutation: create (POST /tasks), add/delete evidence, patch, add/remove link, add/remove tag, lifecycle transition (POST /tasks/{id}/lifecycle), and both the manual and scheduled verify sweeps (each task the sweep actually changed gets its own event). id is the task id (e.g. ARGUS-619). No payload beyond identity -- the client always re-fetches the task/board read model.

approval -- Inbox surface (approval doors)

Fires when a task's pending-approval state changes: task creation with verify=approval, POST /tasks/{id}/evidence with kind=approval (status pending), and POST /approvals/{id} acting on it (status "<action>d" -- approved/rejected). id is the task id. A task event always accompanies an approval event (approval state is task state), so a client subscribed to task alone still sees it -- approval exists for a narrower Inbox-only subscription.

landing -- Work surface (landing queue) / Runs surface

Fires on every landing-queue stage transition, driven from inside LandingQueue itself (not just the API entry points): queued -> rebasing -> ci -> merging -> landed | failed, plus a withdrawn terminal state and re-attempts (branch moved during CI bumps attempts and re-publishes the current status). repo (owner/name) + pr_number identify the entry; status is the new stage.

run -- Runs surface (cron/task ledger)

Fires when a wrapped task/cron script reports a terminal outcome via POST /runs (status != "started"), excluding routine/internal housekeeping tasks (IsRoutineTask). id is the runs-ledger row id, status is ok | fail | noshow | interrupted | skipped. This is the watchdog/cron ledger -- not the coding-agent queue (see agent_run below); the two are separate tables with separate lifecycles that happen to both render on the app's Runs / Activity view.

deploy -- Runs surface (deploy pipeline)

Fires on deploy claim (POST /deploys/claim, status = the claimed row's status, typically running) and completion (POST /deploys/{id}/complete, status ok | fail). id is the deploy row id.

schedule -- Settings surface (schedule editing)

Fires when a schedule row's active version changes (create/update, pause/resume -- schedules are append-only, ARGUS-U10). id is the schedule name.

alert -- Alerts surface / Inbox surface (native alert store, US-11)

Closed by this task. Fires on every committed AlertStore transition:

  • Ingest -- a signal folds into the store: created | extended | reopened | absorbed (into an existing row, or a storm row). Whichever alert row the signal actually landed on publishes, carrying its current lifecycle status (firing, since a fresh/extended/reopened row is never auto-acked). An absorbed signal that doesn't reach storm threshold publishes nothing (no alert row changed -- the signal was silently folded into an existing storm's child list, which itself already published when it became a storm).
  • Ack (POST /alerts/{id}/ack or the Inbox alertitem:{id} ack action) -- publishes status: "acked". A repeat ack on an already-acked alert is a no-op and does not re-publish (idempotent, matches the store's own already semantics).
  • Dismiss (POST /alerts/{id}/dismiss or alertitem:{id} dismiss) -- publishes status: "resolved". A repeat dismiss does not re-publish.

id is the alert row id (strconv.FormatInt). Auto-resolve (a condition clearing on its own, e.g. a no-show's task reporting again) goes through Ingest with Firing: false and publishes exactly like any other Ingest transition.

agent_run -- Runs surface (coding-agent queue)

Closed by this task -- previously zero events. Fires on every agent_runs status change:

  • POST /runs/agent (enqueue) -- status: "queued".
  • POST /runs/agent/claim -- status: "running".
  • POST /runs/agent/{id}/complete -- status: "done" | "failed" (the status actually persisted, including the ARGUS-602 no-progress downgrade: a done claim with no PR and the task still in_progress is recorded and published as failed).
  • POST /runs/agent/{id}/release (usage-cap pause requeue, running -> queued) -- status: "queued".

id is the agent_runs row id. POST /runs/agent/{id}/progress (heartbeat and milestone notes) deliberately does not publish -- that channel is high-frequency and already has its own push path (Live Activity updates); adding SSE noise on every heartbeat would defeat the "refetch signal" design. A client wanting live progress text still polls or reads the Live Activity push; agent_run only tells it the row's status changed.

inbox -- Inbox surface (merged feed)

Closed by this task -- previously zero events. Fires on every successful POST /inbox/{id}/resolve disposition -- the one mutation door for every review-item kind the merged feed renders that has no other event of its own: memlint (dismiss/resolve), memflag (approve/reject), parked (retry), skilllint (prune), attestlint (dismiss), and alertitem (ack/dismiss, alongside the alert event that same action fires -- the Inbox feed groups alert-store rows in with review items, so both are needed: alert for the Alerts surface, inbox for the Inbox surface's differently-shaped feed). id mirrors the inbox item id acted on (e.g. memlint:7,8 for a grouped-finding dismiss, alertitem:42); status is the disposition applied (dismissed, actioned, approve, reject, retried, pruned, or the alert's new lifecycle state for alertitem). A no-op repeat (e.g. an already-resolved memflag) does not publish -- nothing in the feed changed.

Inbox composition beyond resolve actions: new approvals and task-lint findings surface through the existing task/approval events (their source rows are tasks); new native alerts surface through alert. The remaining review sources -- memory-lint findings, dedup flags, parked distill units, skill lint -- are produced by the async distill/lint pipeline (the memory service), which is out of scope for this hub-side broker; a client should treat task, approval, and alert as inbox-composition signals too (all three can add or remove an inbox row), on top of inbox for row-level actions. This is a documented limitation, not a silent gap: those sources already fall back to the Inbox surface's existing poll cadence.

Per-surface subscription guide (for ARGUS-620)

App surface Subscribe to
Command (home) task, alert, agent_run, landing, deploy (severity-ordered summary of all of them)
Inbox task, approval, alert, inbox
Work -> Tasks/Board/Flow task
Work -> Activity/Runs run, deploy, agent_run
Work -> Landing landing
Alerts (native store view) alert
Settings -> Projects/Schedules schedule

Verification

internal/hub/events_test.go:

  • TestPublishersFireTheirKind -- every publish*Event helper (all nine kinds) emits exactly its documented kind + identifying fields, at the broker level (no DB required).
  • TestEventsSSEAlertLifecycle -- a POST /runs fail report drives AlertStore.Ingest end-to-end over real SSE, then POST /alerts/{id}/ack publishes the acked transition.
  • TestEventsSSEAgentRunLifecycle -- enqueue -> claim -> complete, plus a second run's claim -> release, each observed as a real SSE frame.
  • TestEventsSSEInboxResolveAction -- an alertitem:{id} ack through POST /inbox/{id}/resolve publishes inbox.
  • TestEventsSSETaskLifecycle (pre-existing) -- task lifecycle over real SSE.