Repository navigation
feat(api,admin): Slack bot transport — one message per incident, the story in its thread, the state as a reaction (CLEAN-165) - #140
Merged
Conversation
…N-165) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…its thread, the state as a reaction (CLEAN-165) A second INotifier port for a bot token: post (returns ts), update, setReaction, over fetch like the webhook. The destination row gains a kind (webhook | bot) and the bot's token + channel; saving one kind nulls the other's secret; the secret never leaves getDestination(). The outbox grows joined / running beside opened / closed, with a per-incident sequence and an ordered claim (NOT EXISTS earlier pending seq) so a thread never shows Resolved above Failed again. "Failed again" is queued at most once a minute; a swallowed report marks the root stale and the 30 s sweep rewrites it. The incident keeps its transport, its root (channel, ts) and the one reaction the bot holds; every rewrite renders from the row, so late or out-of-order deliveries cannot leave a stale line. A root that could only be posted after the close arrives in its final state, without the mention. Side-effect failures (a lost rewrite, a reaction) land on the destination's last delivery; the reply itself still counts as sent. AgentIncident.number: a sequence, backfilled in openedAt order. The webhook keeps today's two messages byte for byte; joins, up-turns, stops and deletes queue nothing on it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ns; incidents carry a number (CLEAN-165) Settings → Notifications offers the two kinds — incoming webhook or Slack bot — with the bot's token and channel id, a four-step guide for the Slack side, and the note that saving one kind replaces the other. The store hands the input on and keeps nothing of it; the view carries the kind, the hint and the channel, never a secret. AgentIncidentDto.number is returned by the API and shown as "#12" in the incident list; the loaded-rows search matches it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… implementation (CLEAN-165) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ion pass (CLEAN-165) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…res and the owner's bot (CLEAN-165) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
maksymhryzodub-prog
marked this pull request as ready for review
October 15, 2026 11:30
maksymhryzodub-prog
added this pull request to stack #142
October 15, 2026 12:12
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Jira: CLEAN-165 — follow-up to CLEAN-139 (#134). Spec, plan, contracts, tasks and the proof log in
specs/021-slack-bot-transport/.What it does
A second way to tell the team about an agent going down: a Slack bot beside the incoming webhook of CLEAN-139. With the bot, the channel shows one message per incident, rewritten as the incident moves; every event is a reply in that message's thread; the bot's reaction on the message is the state. The shape was settled with the owner over three live mockups in the alerts channel on 2026-10-14.
Thread: Failed → Failed again (at most one a minute; the count on the root catches up on the sweep) → Running again → Resolved / No further reports / Stopped by owner / Deleted.
@channelonly in the first version of the root.How
ISlackBotNotifier(post / update / setReaction overfetch, Slack's errors mapped to words the owner can act on, token scrubbed from anything quoted).AgentEventDestinationgainskind(webhook|bot) and the bot's token + channel; saving one kind nulls the other's secret;getDestination()stays the only read that returns a secret. The outbox growsjoined/runningbesideopened/closed, with a per-incidentseqand an ordered claim (NOT EXISTSearlier pending seq) so a thread never shows Resolved above Failed again. The incident keeps its transport, its root (slackChannel,slackTs) and the one reaction it holds; every rewrite renders from the row, so late or out-of-order deliveries cannot leave a stale line; a root that could only be posted after the close arrives in its final state without the mention.AgentIncident.numberis a sequence, backfilled inopenedAtorder.#12in the incident list and its search.set_/get_notification_destinationtake and show the kind and the channel;list_agent_incidentsreturnsnumber.tool-secrets.spec.tscoversbotToken.Migration
One, additive, with backfills:
kind: slack → webhook;AgentNotification.seq(opened → 0, closed → 1) replacing the(incidentId, kind)unique;AgentIncident.numberrenumbered byopenedAtwith the sequence moved past the max;transport = webhookfor incidents that were notified. Runs onmigrate deploy.Verified
api:bun run build; the whole jest suite — 152 suites, 2130 tests — 223 of them inagent/event+tool-secrets.eslint --quiet+ Prettier (LF) on every changed file.admin:bun test slices(195),npx nuxt typecheck,bun run locale:check; the five changed/new components compiled by the dev server.migrate deployapplied the migration,migrate diff --exit-code→ 0; numbers 1…261 distinct and strictly increasing withopenedAt;seq0/1 on the existing rows;kind: slack → webhook;transportset only where a message had gone out; the sequence at 262.PUTbot destination → view carries kind, hint and channel, never the token; test delivered;xoxp-, a channel name and a missing field refused with the contract's words. Incident #262: opened (root with@channel, 🔴, "Failed" reply) → six reports inside a minute → one "Failed again", count 7 on the root, stale flag cleared by the sweep → Ranch's own drift sweep reported the agent → a third reply "Reported by: Ranch" → aged past the quiet period →recovered, "Resolved" reply, ✅, root rewritten. #263: the same failure posted four times in parallel at both processes → one incident, one root, one "Failed again"; aged →unconfirmed, 🟢. #264: agent stopped →stopped, "Stopped by owner", ⏹, no new channel message. Switch to the webhook: the orphaned bot incident's remaining rowsskipped; the next incident, opened by Ranch's own watch, got exactlyopenedandclosedthrough the webhook — the CLEAN-139 messages. Every deliveryok.Not verified
runningthrough the gateway, so that reply and the 🟢 "up, watching" rewrite are covered by the flow spec only.slack.com, let rows queue, unblock): needs an admin hosts-file edit; the ordered claim ran on Postgres through every incident above, the out-of-order case is in the flow spec.Twin consoles
adminchanged.apphas noagent/eventsub-slice (grep -rl 'agentEvent\|agent-events' app/slices→ 0 files, as for CLEAN-139): nothing to mirror.After merge
Owner: Settings → Notifications → Slack bot → token + channel id → Send test. The Slack side (scopes
chat:write,reactions:write; reinstall; invite; channel id) is indocs/operations/agent-events.md. The API pod needs egress toslack.com.🤖 Generated with Claude Code