Add workflow interaction cancellation and issue cost summaries (#4862)

## Thinking Path

> - Paperclip coordinates work through issue-thread interactions, run
history, and cost telemetry.
> - Operators need workflow prompts to be cancellable and costs to be
visible at the issue level.
> - The earlier rollup mixed this workflow/cost work with database
backups, reliability recovery, thread scaling, and settings polish.
> - This pull request isolates the interaction and cost surfaces into a
reviewable slice.
> - The backend now supports cancelling pending question interactions
and summarizing issue-tree costs.
> - The UI component layer can render cancelled questions and interleave
activity with run ledger rows.

## What Changed

- Added `cancelled` as an issue-thread interaction status and result
shape for question interactions.
- Added the board-only `POST
/issues/:id/interactions/:interactionId/cancel` route and service
implementation.
- Added issue-tree cost summary support in the cost service and
`/issues/:id/cost-summary` API route.
- Extended shared cost exports and UI API/query keys for issue cost
summaries.
- Updated `IssueThreadInteractionCard` and `IssueRunLedger` components
for cancelled questions, issue cost surfaces, and activity/run
interleaving.
- Added focused server and component regression coverage.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run server/src/__tests__/costs-service.test.ts
server/src/__tests__/issue-thread-interaction-routes.test.ts
server/src/__tests__/issue-thread-interactions-service.test.ts
ui/src/components/IssueRunLedger.test.tsx`
- Result: 4 test files passed, 45 tests passed.
- UI screenshots not included because this PR updates reusable
components and API surfaces without wiring a new page-level layout.

## Risks

- Adds a new interaction terminal status; clients that switch
exhaustively on interaction status may need to handle `cancelled`.
- Issue-tree cost summaries use recursive issue traversal and should be
watched on unusually large issue trees.
- Page-level issue detail wiring is intentionally left to the board
QoL/issue-detail branch to keep this PR narrow.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5.5, code execution and GitHub CLI tool use, medium
reasoning effort.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-04-30 13:57:25 -05:00
committed by GitHub
parent 87f19cd9a6
commit c4269bab59
23 changed files with 810 additions and 53 deletions
@@ -13,6 +13,7 @@ import type {
AcceptIssueThreadInteraction,
AskUserQuestionsAnswer,
AskUserQuestionsInteraction,
CancelIssueThreadInteraction,
CreateIssueThreadInteraction,
IssueThreadInteraction,
RequestConfirmationInteraction,
@@ -26,6 +27,7 @@ import {
acceptIssueThreadInteractionSchema,
askUserQuestionsPayloadSchema,
askUserQuestionsResultSchema,
cancelIssueThreadInteractionSchema,
createIssueThreadInteractionSchema,
rejectIssueThreadInteractionSchema,
requestConfirmationPayloadSchema,
@@ -1148,5 +1150,60 @@ export function issueThreadInteractionService(db: Db) {
await touchIssue(db, issue.id);
return hydrateInteraction(updated);
},
cancelQuestions: async (
issue: { id: string; companyId: string },
interactionId: string,
input: CancelIssueThreadInteraction,
actor: InteractionActor,
) => {
const data = cancelIssueThreadInteractionSchema.parse(input);
const current = await db
.select()
.from(issueThreadInteractions)
.where(eq(issueThreadInteractions.id, interactionId))
.then((rows) => rows[0] ?? null);
if (!current) throw notFound("Interaction not found");
if (current.companyId !== issue.companyId || current.issueId !== issue.id) {
throw notFound("Interaction not found");
}
if (current.kind !== "ask_user_questions") {
throw unprocessable("Only ask_user_questions interactions can be cancelled");
}
if (current.status !== "pending") {
throw conflict("Interaction has already been resolved");
}
const reason = data.reason?.trim() || null;
const [updated] = await db
.update(issueThreadInteractions)
.set({
status: "cancelled",
result: {
version: 1,
answers: [],
cancelled: true,
cancellationReason: reason,
summaryMarkdown: null,
},
resolvedByAgentId: actor.agentId ?? null,
resolvedByUserId: actor.userId ?? null,
resolvedAt: new Date(),
updatedAt: new Date(),
})
.where(and(
eq(issueThreadInteractions.id, interactionId),
eq(issueThreadInteractions.status, "pending"),
))
.returning();
if (!updated) {
throw conflict("Interaction has already been resolved");
}
await touchIssue(db, issue.id);
return hydrateInteraction(updated);
},
};
}