Ops console incident actions implementation plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task by task.
Goal: Ship PR 3 of the console rebuild: acknowledge, resolve and close an incident from its console page.
Architecture: The status rules that live inside IncidentAdmin methods move into apps/alerts. The admin and the console both call the lifted function. The console adds one POST-only view. No new write path.
Tech stack: Django 5.2, the vendored htmx 2.0.11, pytest-django.
Design: 2026-10-11-ops-console-rebuild-design.md. App rules: apps/console/AGENTS.md and apps/alerts/AGENTS.md.
Scope change from the design
The design put every operator action in PR 3. Run retry, mark-failed and re-evaluate act on runs, alerts and nodes, and the console has no page for those yet. Each moves to the PR that adds its page: runs and alerts in PR 4, nodes in PR 5. PR 3 covers the incident transitions only.
Ground rules
- Branch
feat/console-actions, cut fromfeat/console-incidents. Never commit tomain. No worktrees. - A commit runs the full suite and mypy through pre-commit. Never pass
--no-verify. - TDD. Absolute imports. Line length 100. No comments that restate the code.
- A transition enqueues a
MANUALpipeline run that can send real notifications. Tests must never drain the inbox. - 100% branch coverage on changed code.
Task 1: Lift the transition rules into apps/alerts
Files: modify apps/alerts/services.py, apps/alerts/admin.py, apps/alerts/AGENTS.md. Tests in apps/alerts/_tests/.
Today the rules sit in three IncidentAdmin methods and IncidentManager applies whatever it is told. Two submits of the same button each enqueue a run.
Add to apps/alerts/services.py:
INCIDENT_ACTIONS = ("acknowledge", "resolve", "close").available_incident_actions(status: str) -> tuple[str, ...]. Acknowledge only from open. Resolve from anything but resolved and closed. Close from anything but closed. These are the admin’s current rules, unchanged.TransitionOutcome, a frozen dataclass:ok: bool,message: str,incident: Incident.IncidentManager.transition(incident_id, action, *, actor="", summary="") -> TransitionOutcome. Inside onetransaction.atomic()it loads the incident withselect_for_update(), checks the action againstavailable_incident_actions, and either delegates to the existingacknowledge,resolveorclose, or returnsok=Falsewithout writing or announcing. An unknown action raisesValueError. A missing incident raisesIncident.DoesNotExist.- Messages keep the admin’s wording:
Incident '<title>' acknowledged.,Cannot acknowledge — status is '<status>'.,Already <status>.,Already closed.
The existing acknowledge, resolve and close already open their own atomic block, which nests as a savepoint. They must reuse the locked row’s state, so the check and the write can’t straddle another writer.
IncidentAdmin.acknowledge_incident, resolve_incident and close_incident become thin callers of transition that pass the outcome to message_user, with level warning when ok is false. The bulk actions acknowledge_selected and resolve_selected call transition per incident and count the ok outcomes.
Tests: every status against every action, a refused transition writes nothing and enqueues nothing, an accepted one enqueues exactly one run, a second identical call is refused and enqueues none, unknown action, missing incident, and the admin’s existing action tests still pass with their messages unchanged.
Commit: refactor(alerts): lift incident transition rules out of the admin.
Task 2: Console action view
Files: create apps/console/views/incident_action.py. Modify apps/console/urls.py, apps/console/views/__init__.py, apps/console/views/base.py if a shared POST guard helps, apps/console/queries/incident.py, templates/console/partials/_incident.html, static/console/console.css, apps/console/AGENTS.md. Tests in apps/console/_tests/.
View. IncidentActionView(ConsoleView) at incidents/<int:pk>/<str:action>/, name console:incident-action.
- POST only. A GET returns 405.
- Staff refusal comes first, as on every console view. Then
request.user.has_perm("alerts.change_incident"), else 403. That is the permission the admin’s change actions need. - An action outside
INCIDENT_ACTIONSis a 404. A missing incident is the console 404 page. - Calls
IncidentManager.transition(pk, action, actor=request.user.get_username(), summary=...).summaryis read only for resolve, stripped, capped at 2000 characters. - The outcome becomes a Django message,
successorwarning. - An htmx request gets the incident partial re-rendered with status 200, whatever the outcome. A plain POST gets a 303 redirect to the incident page. The redirect keeps
?all=1only if the form posted it as a hidden field.
Read model. IncidentDetail gains actions: tuple[IncidentAction, ...]. IncidentAction has key, label and url. build_incident_detail takes can_act: bool = False and fills actions from available_incident_actions only when it is true. The view passes request.user.has_perm("alerts.change_incident").
Template. In the header, one <form method="post"> per available action, with {% csrf_token %}, hx-post to the action URL, hx-target="#incident", hx-swap="innerHTML", hx-sync="#incident:replace" and hx-disabled-elt="find button". Button ids action-<key>. Resolve also offers a <details id="resolve-note"> holding a second form with a summary text field and its own submit, because an open <details> pauses polling and a polled swap would otherwise wipe what the operator is typing. Messages render at the top of the partial through the messages framework, tone by level, never |safe.
A user without the permission sees no buttons and no empty container.
Tests. Anonymous redirect, non-staff 403, staff without the permission 403, GET 405, unknown action 404, missing incident 404, each action through the view with the run enqueued, a stale action re-rendering with a warning and no run, htmx versus plain POST responses, CSRF enforced with Client(enforce_csrf_checks=True), buttons shown per status and hidden without the permission, a hostile summary stored and rendered escaped, the FORBIDDEN scan over the rendered page with buttons and with a message.
Commit: feat(console): acknowledge, resolve and close from the incident page.
Task 3: Verify and open the PR
Run black --check, ruff, mypy, bandit, pip-audit --strict and the full suite under coverage. Open the PR against feat/console-incidents, or main if that branch has merged.