Add owner-scoped cancel action for saved Context requests

SI
5d ago
+236-10

Summary

Adds the shared cancel action to POST /api/context and the MCP context tool so a workspace member can stop a saved Context request before completion with a deterministic persisted outcome. cancelled is a status that runContextRequest, claim_context_request, create_context_execution, claim_context_execution_node and save_context_brief already honored, but nothing ever set it. The operation calls the new service-only cancel_context_request RPC through lib/supabase/context_requests/cancelContextRequest.ts, which validates the receipt (cancelled / already_cancelled / not_cancellable), rejects a receipt whose outcome contradicts the saved status or that leaks claim_token, and processCancelContextRequest.ts rechecks the workspace owner (case-insensitively, since Postgres returns lowercase UUIDs). The action never dispatches a workflow, changes credits, settles provider attempts or withdraws accepted evidence; completed or partial requests keep their saved evidence, and a failed request keeps its saved error. A workflow run that reaches a cancelled request (dispatched before the cancel, raced by an ingest, or retried after losing its claim) ends with the saved state and no longer records a plan, so CONTEXT_RECORD_BLOCKED_PLAN_ENABLED=true does not turn a cancel into a failing run. The MCP OAuth catalog gains one delegated tool (cancel_music_context_request), annotated destructiveHint: true because there is no un-cancel. This advances ticket item "Cancellation, permission changes and retry limits have deterministic persisted outcomes".

Refs recoupable/app#2122 (child of epic recoupable/app#2116).

Merge sequencing

docs recoupable/docs#359 → database recoupable/database#106 (adds public.cancel_context_request; must be applied before this PR is deployed, otherwise cancel returns 409 "Unknown function") → api (this PR). No app/chat PR. Depends on no open PR. Edits to processContextOperation.ts are limited to one schema entry after read and a four-line branch beside read_execution; the logic is in new single-purpose files.

Sibling friction to expect at rebase time:

  • lib/mcp/oauth/__tests__/fullOAuthCatalog.test.ts tool count moves 84 → 85 here; open api#1008 and api#1019 make the same 84 → 85 change and api#1023 sets 86, so whichever merges later must recount (api#996 may also touch it).
  • Open api#1011 (ownershipParity.test.ts) iterates every contextOperationSchema option generically; once both merge it will exercise cancel through the cancelContextRequest wrapper.
  • Open api#1001 writes classified failure reasons into context_requests.error; database#106 now leaves that column untouched on cancel, so those reasons survive.

Verification

  • Ran: pnpm exec vitest run lib/context → 68 files passed, 8 skipped (opt-in live tests); 336 tests passed, 8 skipped, 0 failed.
  • Ran: pnpm exec vitest run app/workflows/context lib/mcp/oauth/__tests__/fullOAuthCatalog.test.ts lib/mcp/oauth/__tests__/contextToolOperations.test.ts lib/mcp/oauth/__tests__/registerFullOAuthTools.test.ts lib/supabase/context_requests → 19 files, 67 tests passed, 0 failed.
  • Ran: pnpm exec vitest run lib/mcp/oauth → 9 files / 43 tests passed; fullOAuthRouting.test.ts cannot load on a fresh checkout because it imports the gitignored build artifact lib/mcp/extension/generated-ui.ts (pre-existing). With a temporary uncommitted stub for that file: 10 files / 46 tests passed.
  • Ran: pnpm exec tsc --noEmit → 199 errors, all pre-existing on origin/main in unrelated __tests__ files and the gitignored generated-ui import; 0 in touched files.
  • Ran: pnpm exec eslint --max-warnings=0 <changed .ts files> → clean; pnpm exec prettier --check <changed files incl. lib/context/README.md> → clean.
  • Red → green (initial commit): cancelOperation.test.ts failed with ZodError: No matching discriminator, cancelContextRequest.test.ts with Cannot find module '../cancelContextRequest', and the transports case returned HTTP 400 instead of 200.
  • Red → green (review fixes): before the fix commit, 3 new assertions failed — uppercase organization_id rejected with "Context request not found" after the database had committed the cancel; contextWorkflow called the plan step for a cancelled request; cancel_music_context_request reported destructiveHint: false. All pass after the fix.
  • Coverage only: the spotifyPipeline.test.ts cancelled case passes without this PR too (runContextRequest already returned early for cancelled); it pins that behavior and was never red.
  • Not verified: hosted deployment, live providers, production database, the MCP catalog against a hosted OAuth server, pnpm build:extension.

Out of scope / known gaps

  • Module executions: create_context_execution / claim_context_execution_node only accept completed or partial requests, which cancel reports not_cancellable, so this slice cancels the metadata stage only. Cancelling paid per-module execution, and its reservation/settlement treatment, stays with the paid-execution lane (CE07 #2123/#2105); the documented contract will need revising when per-module execution lands.
  • Guest adoption: if a member cancels a request that a guest workspace is still completing into, complete_context_guest reports "Destination request busy" and the guest worker fails and retries (bounded, free provider). Making that a clean terminal outcome needs a change to the guest migration and is left for a follow-up.
  • Audit: the actor is authorized in storage but not recorded (no cancelled_by / cancelled_at).
  • Scheduled dispatch-gap sweep; Chat UI cancel control.

🤖 Generated with Claude Code


Summary by cubic

Adds a cancel action to POST /api/context and the MCP context tool so a workspace member can stop a saved Context request before it completes.

cancelled was already a status the request pipeline honored, but nothing ever set it. The new operation validates the storage receipt, rejects inconsistent or leaked records, and rechecks workspace ownership inside the transaction case-insensitively, so an uppercase organization_id still matches the stored UUID. A cancelled request is never claimed again, and an already-dispatched workflow run reads that status and ends with its saved state without recording a plan. The MCP tool cancel_music_context_request is marked destructive because there is no un-cancel. Cancellation only changes request status: it never dispatches a workflow, refunds credits, settles provider attempts, or withdraws accepted evidence — completed or partial requests keep their saved evidence.

Migration

  • Deploy the cancel_context_request database function before this PR; otherwise, cancel returns 409 "Unknown function".