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). Anabsorbedsignal 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}/ackor the Inboxalertitem:{id}ackaction) -- publishesstatus: "acked". A repeat ack on an already-acked alert is a no-op and does not re-publish (idempotent, matches the store's ownalreadysemantics).Dismiss(POST /alerts/{id}/dismissoralertitem:{id}dismiss) -- publishesstatus: "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: adoneclaim with no PR and the task stillin_progressis recorded and published asfailed).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-- everypublish*Eventhelper (all nine kinds) emits exactly its documentedkind+ identifying fields, at the broker level (no DB required).TestEventsSSEAlertLifecycle-- aPOST /runsfail report drivesAlertStore.Ingestend-to-end over real SSE, thenPOST /alerts/{id}/ackpublishes the acked transition.TestEventsSSEAgentRunLifecycle-- enqueue -> claim -> complete, plus a second run's claim -> release, each observed as a real SSE frame.TestEventsSSEInboxResolveAction-- analertitem:{id}ack throughPOST /inbox/{id}/resolvepublishesinbox.TestEventsSSETaskLifecycle(pre-existing) -- task lifecycle over real SSE.