forked from farhoodlabs/paperclip
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:
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Issue, Agent } from "@paperclipai/shared";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import type { ActivityEvent, Issue, Agent } from "@paperclipai/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "@/lib/router";
|
||||
import { accessApi, type CurrentBoardAccess } from "../api/access";
|
||||
@@ -24,6 +24,8 @@ type IssueRunLedgerProps = {
|
||||
childIssues: Issue[];
|
||||
agentMap: ReadonlyMap<string, Agent>;
|
||||
hasLiveRuns: boolean;
|
||||
activityEvents?: ActivityEvent[];
|
||||
renderActivityEvent?: (event: ActivityEvent) => ReactNode;
|
||||
};
|
||||
|
||||
type IssueRunLedgerContentProps = {
|
||||
@@ -33,6 +35,8 @@ type IssueRunLedgerContentProps = {
|
||||
issueStatus: Issue["status"];
|
||||
childIssues: Issue[];
|
||||
agentMap: ReadonlyMap<string, Pick<Agent, "name">>;
|
||||
activityEvents?: ActivityEvent[];
|
||||
renderActivityEvent?: (event: ActivityEvent) => ReactNode;
|
||||
pendingWatchdogDecision?: WatchdogDecisionInput["decision"] | null;
|
||||
canRecordWatchdogDecisions?: boolean;
|
||||
watchdogDecisionError?: string | null;
|
||||
@@ -45,6 +49,20 @@ type LedgerRun = RunForIssue & {
|
||||
outputSilence?: ActiveRunForIssue["outputSilence"];
|
||||
};
|
||||
|
||||
type LedgerFeedItem =
|
||||
| {
|
||||
kind: "run";
|
||||
id: string;
|
||||
timestamp: string;
|
||||
run: LedgerRun;
|
||||
}
|
||||
| {
|
||||
kind: "activity";
|
||||
id: string;
|
||||
timestamp: string;
|
||||
event: ActivityEvent;
|
||||
};
|
||||
|
||||
type LivenessCopy = {
|
||||
label: string;
|
||||
tone: string;
|
||||
@@ -256,7 +274,7 @@ function stopReasonLabel(run: RunForIssue) {
|
||||
}
|
||||
if (stopReason === "budget_paused") return "budget paused";
|
||||
if (stopReason === "cancelled") return "cancelled";
|
||||
if (stopReason === "paused") return "paused";
|
||||
if (stopReason === "paused") return "paused by board";
|
||||
if (stopReason === "process_lost") return "process lost";
|
||||
if (stopReason === "adapter_failed") return "adapter failed";
|
||||
if (stopReason === "completed") return timeoutText ? `completed (${timeoutText})` : "completed";
|
||||
@@ -345,6 +363,8 @@ export function IssueRunLedger({
|
||||
childIssues,
|
||||
agentMap,
|
||||
hasLiveRuns,
|
||||
activityEvents,
|
||||
renderActivityEvent,
|
||||
}: IssueRunLedgerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToastActions();
|
||||
@@ -405,6 +425,8 @@ export function IssueRunLedger({
|
||||
issueStatus={issueStatus}
|
||||
childIssues={childIssues}
|
||||
agentMap={agentMap}
|
||||
activityEvents={activityEvents}
|
||||
renderActivityEvent={renderActivityEvent}
|
||||
pendingWatchdogDecision={watchdogDecision.variables?.decision ?? null}
|
||||
canRecordWatchdogDecisions={canBoardRecordWatchdogDecision(companyId, boardAccess)}
|
||||
watchdogDecisionError={watchdogDecisionError}
|
||||
@@ -420,6 +442,8 @@ export function IssueRunLedgerContent({
|
||||
issueStatus,
|
||||
childIssues,
|
||||
agentMap,
|
||||
activityEvents,
|
||||
renderActivityEvent,
|
||||
pendingWatchdogDecision,
|
||||
canRecordWatchdogDecisions = true,
|
||||
watchdogDecisionError,
|
||||
@@ -436,6 +460,37 @@ export function IssueRunLedgerContent({
|
||||
[ledgerRuns],
|
||||
);
|
||||
const children = childIssueSummary(childIssues);
|
||||
const canRenderActivityEvents = Boolean(renderActivityEvent);
|
||||
const feedItems = useMemo<LedgerFeedItem[]>(() => {
|
||||
const items: LedgerFeedItem[] = [];
|
||||
for (const run of ledgerRuns) {
|
||||
items.push({
|
||||
kind: "run",
|
||||
id: run.runId,
|
||||
timestamp: run.startedAt ?? run.createdAt,
|
||||
run,
|
||||
});
|
||||
}
|
||||
if (canRenderActivityEvents) {
|
||||
for (const event of activityEvents ?? []) {
|
||||
items.push({
|
||||
kind: "activity",
|
||||
id: event.id,
|
||||
timestamp: event.createdAt instanceof Date
|
||||
? event.createdAt.toISOString()
|
||||
: String(event.createdAt),
|
||||
event,
|
||||
});
|
||||
}
|
||||
}
|
||||
return items.sort((a, b) => {
|
||||
const aTime = new Date(a.timestamp).getTime();
|
||||
const bTime = new Date(b.timestamp).getTime();
|
||||
if (aTime !== bTime) return bTime - aTime;
|
||||
if (a.kind !== b.kind) return a.kind === "run" ? -1 : 1;
|
||||
return b.id.localeCompare(a.id);
|
||||
});
|
||||
}, [activityEvents, canRenderActivityEvents, ledgerRuns]);
|
||||
|
||||
return (
|
||||
<section className="space-y-3" aria-label="Issue run ledger">
|
||||
@@ -578,28 +633,40 @@ export function IssueRunLedgerContent({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{ledgerRuns.length === 0 ? (
|
||||
{feedItems.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border px-3 py-3 text-sm text-muted-foreground">
|
||||
Historical runs without liveness metadata will appear here once linked to this issue.
|
||||
{renderActivityEvent
|
||||
? "Runs and activity will appear here once this issue has history."
|
||||
: "Historical runs without liveness metadata will appear here once linked to this issue."}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border rounded-md border border-border/70">
|
||||
{ledgerRuns.slice(0, 8).map((run) => {
|
||||
<div className="space-y-1.5">
|
||||
{feedItems.slice(0, 20).map((item) => {
|
||||
if (item.kind === "activity") {
|
||||
return <div key={`activity:${item.id}`}>{renderActivityEvent?.(item.event)}</div>;
|
||||
}
|
||||
const run = item.run;
|
||||
const liveness = livenessCopyForRun(run);
|
||||
const stopReason = stopReasonLabel(run);
|
||||
const duration = formatDuration(run.startedAt, run.finishedAt);
|
||||
const exhausted = hasExhaustedContinuation(run);
|
||||
const continuation = continuationLabel(run);
|
||||
const retryState = describeRunRetryState(run);
|
||||
const agentName = compactAgentName(run, agentMap);
|
||||
return (
|
||||
<article key={run.runId} className="space-y-2 px-3 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<article
|
||||
key={`run:${run.runId}`}
|
||||
className="space-y-1.5 rounded-lg border border-border/60 px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="font-medium text-foreground">Run</span>
|
||||
<Link
|
||||
to={`/agents/${run.agentId}/runs/${run.runId}`}
|
||||
className="min-w-0 max-w-full truncate font-mono text-xs text-foreground hover:underline"
|
||||
className="min-w-0 max-w-full truncate font-mono text-foreground hover:underline"
|
||||
>
|
||||
{run.runId.slice(0, 8)}
|
||||
</Link>
|
||||
<span>by {agentName}</span>
|
||||
<span className="rounded-md border border-border px-1.5 py-0.5 text-[11px] capitalize text-muted-foreground">
|
||||
{statusLabel(run.status)}
|
||||
</span>
|
||||
@@ -646,6 +713,7 @@ export function IssueRunLedgerContent({
|
||||
{RUN_OUTPUT_SILENCE_COPY[run.outputSilence.level]?.label}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="ml-auto shrink-0">{relativeTime(item.timestamp)}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
|
||||
@@ -696,9 +764,9 @@ export function IssueRunLedgerContent({
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
{ledgerRuns.length > 8 ? (
|
||||
{feedItems.length > 20 ? (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{ledgerRuns.length - 8} older runs not shown
|
||||
{feedItems.length - 20} older items not shown
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user