Skip to content

feat(api,admin): agent events endpoint and failure notifications (CLEAN-139) - #134

Merged
maksymhryzodub-prog merged 7 commits into
mainfrom
feat/CLEAN-139-agent-event-notifications
Oct 12, 2026
Merged

maksymhryzodub-prog merged 7 commits into
mainfrom
feat/CLEAN-139-agent-event-notifications

Conversation

@maksymhryzodub-prog

@maksymhryzodub-prog maksymhryzodub-prog commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Ticket: CLEAN-139 · spec, plan and tasks in specs/020-agent-event-notifications/

Draft: the screens still need a walk-through in a browser — see "Not verified".

What it does

The team must find out when an agent goes down. Ranch already detected most failures and told nobody; an outside sender would be a second witness and had nowhere to post.

  • POST /agent-events — cluster tooling reports an agent failure. Authorised by an API key with one new scope, events:write, that can do nothing else. Body: agentId, status (failed | recovered), optional datetime, reason, source, eventId. The sender's guide is docs/operations/agent-events.md.
  • Ranch's own failures become events too. AgentGateway.updateStatus now announces a real transition exactly once — whatever the replica count — through a new AgentStatusChanges port; transitions to failed / unreachable are recorded with Ranch as the witness.
  • One incident per agent, one message when it opens and one when it closes. "At most one open per agent" is a unique column, not a check in code. An incident closes when Ranch has seen the agent running for ten quiet minutes; silently when a person stops or deletes the agent.
  • An outside event never writes an agent's status, and one about a stopped or restarting agent is recorded and notifies nobody — an outside sender cannot tell a restart from a crash.
  • Slack through an incoming webhook, behind an outbox with retries. The message that an agent went down mentions the whole channel; the one that it is back mentions nobody; a sender's own <!channel> is escaped. The address lives in its own table and is never returned by any route or tool (GET /settings hands secret values back to the console today, so it is not a setting).
  • Console: an Events page, an Events section on the agent page, Settings → Notifications (set / replace / remove, send a test, what a sender has to send), events:write in the key dialog.
  • Agent tools: list_agent_events, list_agent_incidents, get_ / set_ / remove_notification_destination, send_test_notification.

One additive migration, four tables. No new dependency.

Verified

  • cd api && bun run build
  • cd api && NODE_OPTIONS=--experimental-vm-modules npx jest src/slices/agent/event src/slices/agent/agent src/slices/user/apiKey src/slices/mcp — 34 suites, 428 tests
  • cd admin && bun test slices — 171 tests · cd admin && npx nuxt typecheck · bun run locale:check
  • Against a real Postgres, with two API processes on it (fresh database, all 51 migrations applied, prisma migrate diff reports no difference from the schema): every answer of the endpoint (201 / 200 duplicate / 400 / 401 / 403 / 429 with Retry-After), the key refused on every other route tried, suppressed_stopped and suppressed_starting, the same failure posted to both processes at once and ten at once (one incident, one opening message), cursor paging, roles on the console routes, non-Slack and in-cluster addresses refused. Ranch's own watch marking pod-less agents failed produced exactly one event per transition with both processes sweeping; a stopped agent's incident closed silently; an incident aged past the quiet period closed with exactly one closing message.
  • Real delivery to Slack (a test channel, same local stack): the destination set through the API, the test message, an outside failure → opening message 1.5 s after the event was accepted, five more failures → no further message, the aged incident → "No further reports", Ranch's own watch → "Reported by: Ranch", the agent back and aged → "Agent back … Down for 15 min", and a failure carrying the channel mention. Sender-supplied <…> and & arrived as plain characters.
  • The flood limit under concurrency: 200 parallel posts from one key against a running API → 60 accepted, 140 answered 429.
  • Every new and changed .vue compiles in the admin dev server.

Not verified

  • The screens were not walked through in a browser. They typecheck and compile; no scenario was clicked through.
  • What the notifier does with Slack's failure answers (429, 404, a timeout) is covered by specs with a mocked fetch only.
  • The Rancher agent was not started to call the new tools (the API validated all 163 tools at boot).
  • The ten-minute close was reached by ageing a row, not by waiting with a running pod.

Twin consoles

admin slices with a twin that were touched: agent (one section in sections.ts, one branch in Canvas.vue), user (the events:write scope), common (formatSpan, one sidebar icon). Checked in app/: app/slices/agent has no section list (its workspace is Provider, Rail, RailItem), app/slices/user has no API-key screen, and app shows no time span and has its own sidebar. Events are an operator's concern — a customer is not told that their agent went down — so app needed nothing. app/ is untouched.

Worth a reviewer's eye

  • api/src/slices/agent/agent/data/agent.gateway.ts — updateStatus is the most carefully guarded write in the API. It now does a conditional updateMany first and falls back to the old update when the status did not change; what is written is the same. The existing agent-slice specs pass untouched.
  • Retries wait the schedule's gap after each failed attempt, not from when the message was queued — a test caught that a message picked up late would otherwise fire all its retries back to back.

Open points that are not this PR's to close

  • How many API replicas production runs. k8s/platform/api says 2 with HPA 2–5, k8s/deploy/30-api.yaml says 1, and the bridle hub's presence map is per process. This feature is correct at any count; but with more than one replica agent statuses can flap today and each flap would become an incident. Check before a destination is set (see "Before switching notifications on" in the guide).
  • ADMIN_URL on the API deployment — without it messages carry no link.
  • Which tool DevOps installs as the sender; "Ranch is down" → Slack from Argo CD directly.

🤖 Generated with Claude Code

maksymhryzodub-prog and others added 7 commits October 6, 2026 15:49
…AN-139)

The team must find out when an agent goes down. Ranch already detected most
failures and told nobody; an outside sender would be a second witness and had
nowhere to post.

api
- POST /agent-events, authorised by an API key with the new events:write
  scope and nothing else. An event is stored and may notify; it never writes
  an agent's status.
- AgentGateway.updateStatus announces a real transition exactly once, at any
  replica count (conditional update), through a new AgentStatusChanges port.
  Transitions to failed / unreachable become Ranch-witness events.
- One open incident per agent, enforced by a unique column. It closes when
  Ranch has seen the agent running for ten quiet minutes; silently when a
  person stops or deletes the agent. Outside events for a stopped or
  restarting agent are recorded and notify nobody.
- Slack through an incoming webhook, behind an outbox with retries. The
  address has its own table and routes and is never returned.
- Read routes for the console, six operator tools, 90 days of retention.
- One additive migration, four tables.

admin
- Events page, an Events section on the agent page, Settings → Notifications
  with a test button and the sender's guide, events:write in the key dialog.

docs
- docs/operations/agent-events.md (the sender's guide), README section,
  specs/020-agent-event-notifications.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…keep internal errors off the wire (CLEAN-139)

Two findings from the security review of the first commit.

The limit of 60 events a minute per key was a database count followed by an
insert. A burst of parallel requests could all read "none so far" and all
pass. A per-key sliding window in the process now takes the slot
synchronously, before anything is awaited; the database count stays for what
one process cannot see — the other replicas. 200 parallel posts against a
running API: 60 accepted, 140 answered 429.

POST /agent-events answered an unexpected internal error with the error's own
text (the global interceptor's behaviour), which can name tables and columns
to a caller that holds only a sender key. It now answers a plain "try again"
and logs the detail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Asked for after the first real delivery to Slack. The message that an
incident opened now starts with a channel mention, so it reaches people who
are not looking; the message that the agent is back mentions nobody. The
mention is Ranch's own and sits outside everything a sender supplied — a
sender's `<!channel>` is still escaped to plain characters (FR-037).

Also records in tasks.md that delivery to Slack was verified against a real
channel: test message, opening and closing messages from an outside event
and from Ranch's own watch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…AN-139)

CI's api#lint failed on seven `unbound-method` errors in
agentEvent.controller.spec.ts: route handlers were read off the controller's
prototype as `Controller.prototype.post` to inspect their guard metadata.
They are now taken through the property descriptor by a small `handlerOf`
helper — a value read for its metadata, never a method lifted off its object.

Also runs Prettier over the files this branch added; CI's `eslint --fix` was
reformatting fourteen of them on the fly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… (CLEAN-139)

With real data the page read badly: an "open incidents" table stacked on a
flat table of every report, so five identical reports of one crash loop and a
burst about an unknown agent buried what the page is opened for.

It is now two views behind tabs, held in ?view=:

- Incidents (default, the tab shows the open count): one row per incident —
  a state badge (Failed / Unreachable, Recovering once Ranch has seen the
  agent come up, how it ended once closed), the agent, how many reports, the
  first cause, since when and for how long, who reported, whether the opening
  and closing messages were delivered. Open / Closed / All, search by agent.
  A row opens into its reports.
- Event log: every report as it arrived, with a "No incident" filter for the
  ones attached to nothing.

api: GET /agent-events takes incidentId, for a row's timeline. SDK
regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ast the limits (CLEAN-139)

Asked: how heavy is the console's constant re-requesting, and does paging
work once the limits are passed. Measured on a local Postgres.

The poll was not the cost; one of its reads was. GET /agent-incidents counted
reports and de-duplicated senders through AgentEvent on every call — 5 ms at
149 events, 160 ms at 60 000 in one incident, repeated every 5 s by every
open console.

api
- AgentIncident keeps eventCount and witnesses on the row, updated as each
  report is attached (the sender's name by a conditional update, so two
  reports from a new sender add it once). Second migration, with a backfill.
  The list reads no events: 4 ms at 60 000.
- Index on AgentIncident.openedAt for the all-agents list.

admin
- The 5 s refresh asks only for what the open tab shows: two requests
  instead of three; a tab is caught up when it is opened.
- Open incidents are read whole (the cursor is followed) — "what is down"
  never stops at a page size.
- An incident's reports are paged: "Load older reports", "showing the latest
  50 of 60,011".
- A refresh that brings a full page sharing nothing with the list restarts
  the list from that page instead of leaving an unseen hole.
- Lists say how much is loaded; search works on loaded rows and says so.

Verified against 60 149 events and 261 incidents: every page of each list
walked through the API — no duplicate, no gap, in order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Brings in CLEAN-138, CLEAN-144 and CLEAN-149. The admin SDK is regenerated from the merged API rather than auto-merged. .specify/feature.json keeps this branch's feature.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant