feat(ai-operator): task admission route, Delegate to Operator action, Playwright approve-after-browser-close (W08 of #5205) - #5272
Conversation
#5205) The only door that creates an Operator task. `ai_agents:write` + the MFA step-up (same pair `POST /ai/agents/:id/runs` carries), explicit org from the body checked against the caller's access, device resolved and site-gated with the same non-enumerating 404 the W07 read routes use, readiness recomputed on launch (422 with an actionable code), a 100-per-org pending cap (429), and 202 with the task id. Idempotency is a DATABASE constraint, not a route check. A duplicate POST can dispatch a second service restart to a customer machine, and a route-level read-then-insert loses the race between two concurrent clicks. So `ai_operator_tasks` gains a nullable `client_idempotency_key` and a PARTIAL unique index on `(org_id, client_idempotency_key)`, and admission inserts with `ON CONFLICT ... WHERE ... DO NOTHING` — `DO NOTHING` rather than catching 23505, because a unique violation aborts the transaction the read-back would have to run in. A conflict then reads the winner, scoped by org AND key, and answers 202 with that id: a replay is a success, not a conflict. Threading the key required an additive change to W06's `admitServiceRecoveryTask` (an optional input field and a `replayed` flag on its result). That crosses the wave boundary deliberately: every alternative that left the file untouched put the reservation and the task insert in different transactions, and the coordinator can pick a task up before the route binds anything. Design was put to an advisor quorum (this session + codex `xhigh`, read-only); both reached the same answer independently. Also: `features.aiOperatorTasks` on `GET /config` (AND of both flags, both default off per decision D2) plus a default-CLOSED `useAiOperatorTasksGate()` web hook, so the UI action is absent — not merely disabled — when the feature is off or `/config` is unreachable. Contract registration: the new column is classified in `CORE_TENANT_EXPORT_POLICY` (a new COLUMN on an org-cascade table breaks that contract, which is the point). No RLS change — shape 1, policies are column-agnostic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMbWgjQdqXzi98eJ5P3Uqp
…(W08 of #5205) `DelegateToOperatorButton` — hidden entirely (not disabled) unless `features.aiOperatorTasks` is on, because the gate defaults CLOSED: an unreachable or older `/config` must never surface an action that starts autonomous remediation on a customer machine. The client idempotency key is minted ONCE per opened dialog and held in a ref, so a double-click or a retry after a network blip reuses it and the server returns the same task. It is re-minted only when the dialog is re-opened, which is the one case where the operator really is asking for a second task. Alert detail passes the alert's OWN orgId (newly declared on the web type; the API always returned it), never the globally selected org — spec §5.1, "changing global organization context while drafting cannot retarget the task" — and cites the alert as the source so the verification criterion has a recurrence signal. Without one, W06's criterion can only reach `investigation_complete`, never `verified_resolved`. `extractServiceNameFromAlert` prefills the service field from the alert's prose, since no structured service name exists on an alert row. It returns null rather than guessing, and the field stays required and editable. The device-page button has no `online` gate: a task is durable work with its own deadline, not an immediate command. Compose: both operator flags are mapped explicitly in `x-api-env`. Compose interpolates only what that block names, so a value in `.env` alone never reaches the container — without these the feature could not be switched on in any deployment and `/config` would report it disabled with no error anywhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMbWgjQdqXzi98eJ5P3Uqp
…#5205) Acceptance scenario 3 / spec §7.1 "authority across time": a technician delegates a service-recovery task from a device page, closes the browser, and the approval the operator is waiting on is still decidable from a different browser session later. What the spec really drives: the Delegate to Operator button and dialog, the POST /ai/operator/tasks admission (202 + the server's own task id), the navigation to /operator/tasks/<id> and that page's render, an idempotent replay of the same client key resolving to the same task with no second row, the destruction of the creating browser context, a second independent login, and a real WebAuthn approve ceremony clicked in the approvals inbox. What is seeded, and why: the pending intent/approval the task waits on (seed-operator-task-approval.sql). Reaching it for real needs a live LLM run plus a coordinator tick; the e2e stack has neither. That transition is already proven against real Postgres by aiOperatorServiceRecoveryE2E.integration.test.ts. The task itself is NEVER seeded — it must come from the real button. What the spec deliberately cannot reach: completed + verified_resolved. That needs a connected agent to execute the restart and an independent device read to verify it, and the e2e stack runs no agent (tests/script-cancel.spec.ts records the same limit). Faking the device would prove nothing about W08. Also: - e2e-tests/webauthn.ts — virtual-authenticator + approver-device enrolment helpers, including credential export/import so a key enrolled in one browser context can sign in the next. Registration is grant-gated since #2707, so it mints a registerGrantId first; without one the options route 403s. - README: two stack prerequisites these WebAuthn specs have (PUBLIC_APP_URL must match the browser origin or the RP ID check fails, and the enrolment ceremony rotates the refresh token and burns the session it runs in). - .env.example: document the two AI Operator flags, both default off (D2). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YGPxXGTWpJKUNbEdR3TZEL
…alert call site, drop an unregistered eslint-disable (W08 of #5205) Three review findings from the PR #5272 pass. 1. Silent-failure review (HIGH): the ON CONFLICT read-back miss in admitServiceRecoveryTask returned refusal 'invalid_input', which the route turns into a 422 reading to the technician as "your input was wrong" — with nothing logged anywhere. Nothing in a caller's request can cause it (index and read-back are both org+key scoped), so it is a broken invariant: a concurrent erasure racing admission, or a future change desynchronising the index from the lookup. Now throws, matching operationService.ts's dispatch-claim cardinality check, so it surfaces as a 500 the top-level handler logs instead of a consistency break with no breadcrumb. The caller-supplied key is not logged. 2. Test-coverage review: nothing proved the alert call site passes the ALERT's own orgId rather than the globally selected one — a swap would compile, pass every existing test, and aim a live remediation action at the wrong tenant. AlertDetailPage.delegateToOperator.test.tsx now asserts it with the org store deliberately seeded to a DIFFERENT id, plus the status gating (rendered for active/acknowledged, hidden for resolved/dismissed). Red-first verified: reverting the wiring to the store value fails the test with `expected 'org-globally-selected-9999' to be 'org-alert-owner-1111'`. 3. Lint: `// eslint-disable-next-line react-hooks/exhaustive-deps` was itself the lint error — that rule is not registered in this repo's config. Removed; the reason the dep is excluded stays as a plain comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YGPxXGTWpJKUNbEdR3TZEL
Deploying breeze with
|
| Latest commit: |
510bc88
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://a16c8a25.breeze-9te.pages.dev |
| Branch Preview URL: | https://feature-5205-ai-operator-wav-nzxz.breeze-9te.pages.dev |
PR review summary —
|
Closes #5246
Wave W08 (P3-1f) of #5205: the write that starts an AI Operator task, the action that fires it, and the browser proof that a delegated task outlives the session that created it.
What this adds
1.
POST /api/v1/ai/operator/tasks(spec §12) — the only route that creates a task..strict(): a request carryingtask,policySnapshotorapprovalis rejected outright. Requests cannot supply a principal, policy, or approval result.ai_agents:writeplus the same MFA step-up the agent-run trigger uses.admitServiceRecoveryTaskwith the requester's authorized ceiling.{ taskId }once the task and its outbox row commit — Redis being down does not fail admission.clientIdempotencyKeyreturns the existing task id. Implemented asON CONFLICT DO NOTHINGon the partial unique index plus a read-back, not a caught 23505: a unique violation aborts the surrounding transaction, so the read-back could not run inside it.2. "Delegate to Operator" on the device page and on alert detail.
runAction; on 202 navigates to/operator/tasks/<id>.nullentirely unless theaiOperatorTasksruntime flag is on. The alert variant targets the alert's own org, never the globally selected one (spec §5.1).data-testid="delegate-to-operator"/delegate-to-operator-confirm.3. Playwright:
ai-operator-approve-after-browser-close.spec.ts(acceptance scenario 3, spec §7.1 "authority across time"). Page Objects inpages/OperatorTaskPage.ts,data-testidselectors only.4.
docker-compose.yml+.env.example: both flags mapped explicitly and documented, default off (decision D2 — internal/test orgs only). Compose interpolates only what theenvironment:block names, so without the mapping the feature could not be switched on in any deployment andGET /configwould report it disabled with no error anywhere.What the Playwright spec actually proves, and what it does not
Real, driven through the browser: the button, the dialog, the admission POST (202 + the server's task id), the navigation to the task detail page and its render, an idempotent replay of the identical request resolving to the same task with no second row, the destruction of the creating browser context, a second independent login, and a real WebAuthn approve ceremony clicked in the approvals inbox — followed by a DB assertion that the task-linked intent settled
approved.Seeded, with the reason stated in the fixture header: the pending intent/approval the task waits on. Producing it for real needs a live LLM run (the investigate run must propose the restart and call
createActionIntent) plus a coordinator tick. The e2e stack has neither. That transition is proven against real Postgres byaiOperatorServiceRecoveryE2E.integration.test.ts(W06). The task itself is never seeded — it must come from the real button, or the spec fails.Contract I could not satisfy: the brief asks the flow to reach
completed + verified_resolvedwith exactly one operation row. It cannot, honestly. That tail needs a connected agent to execute the restart and an independent device read to verify it; the e2e stack runs no agent —tests/script-cancel.spec.tsrecords the same limit in its own words ("the command this creates is never delivered and itsdevice_commandsrow stayspendingforever"). Faking the device from outside the API process would have proved nothing about W08. The execute→verify→document tail is covered by the W06 integration suite; this spec covers the session boundary that suite structurally cannot.Two stack prerequisites these WebAuthn specs have are now documented in
e2e-tests/README.md:PUBLIC_APP_URLmust match the browser origin (the RP ID check fails otherwise, and it fails only these specs), and the enrolment ceremony rotates the refresh token, burning the session it runs in — henceexportCredentials/importCredentialsine2e-tests/webauthn.tsto carry the enrolled key from one context to the next. Approver-device registration has been grant-gated since #2707, so the helper mints aregisterGrantIdfirst; without one the options route answers 403register_step_up_required.Known gap, not fixed here (recorded per the brief)
deadline_atis enforced only insideadvanceTask, which a paused task never re-enters — so a paused task does not expire on its own. The pause/resume/stop routes and that decision belong to P3-5.Verification
All run locally on this branch after merging
origin/main(3e56e27).The integration suite covers cross-org and site-restricted device → 404, flags off → 422, cap → 429, duplicate key → same id, the task row and outbox row present after 202, and a forged
taskfield rejected.🤖 Generated with Claude Code
https://claude.ai/code/session_01YGPxXGTWpJKUNbEdR3TZEL