diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 3715e4e6..22ae43d2 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -66,6 +66,7 @@ export type CreateIssueLabel = z.infer; export const updateIssueSchema = createIssueSchema.partial().extend({ comment: z.string().min(1).optional(), reopen: z.boolean().optional(), + interrupt: z.boolean().optional(), hiddenAt: z.string().datetime().nullable().optional(), }); diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 42c4cb0d..21bb44aa 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -19,6 +19,9 @@ const mockAccessService = vi.hoisted(() => ({ const mockHeartbeatService = vi.hoisted(() => ({ wakeup: vi.fn(async () => undefined), reportRunActivity: vi.fn(async () => undefined), + getRun: vi.fn(async () => null), + getActiveRunForAgent: vi.fn(async () => null), + cancelRun: vi.fn(async () => null), })); const mockAgentService = vi.hoisted(() => ({ @@ -143,4 +146,46 @@ describe("issue comment reopen routes", () => { }), ); }); + + it("interrupts an active run before a combined comment update", async () => { + const issue = { + ...makeIssue("todo"), + executionRunId: "run-1", + }; + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...issue, + ...patch, + })); + mockHeartbeatService.getRun.mockResolvedValue({ + id: "run-1", + companyId: "company-1", + agentId: "22222222-2222-4222-8222-222222222222", + status: "running", + }); + mockHeartbeatService.cancelRun.mockResolvedValue({ + id: "run-1", + companyId: "company-1", + agentId: "22222222-2222-4222-8222-222222222222", + status: "cancelled", + }); + + const res = await request(createApp()) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ comment: "hello", interrupt: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.getRun).toHaveBeenCalledWith("run-1"); + expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("run-1"); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "heartbeat.cancelled", + details: expect.objectContaining({ + source: "issue_comment_interrupt", + issueId: "11111111-1111-4111-8111-111111111111", + }), + }), + ); + }); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 1ff2840d..edf86063 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1,5 +1,6 @@ import { Router, type Request, type Response } from "express"; import multer from "multer"; +import { z } from "zod"; import type { Db } from "@paperclipai/db"; import { addIssueCommentSchema, @@ -38,6 +39,9 @@ import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types. import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js"; const MAX_ISSUE_COMMENT_LIMIT = 500; +const updateIssueRouteSchema = updateIssueSchema.extend({ + interrupt: z.boolean().optional(), +}); export function issueRoutes(db: Db, storage: StorageService) { const router = Router(); @@ -161,6 +165,30 @@ export function issueRoutes(db: Db, storage: StorageService) { return true; } + async function resolveActiveIssueRun(issue: { + id: string; + assigneeAgentId: string | null; + executionRunId?: string | null; + }) { + let runToInterrupt = issue.executionRunId ? await heartbeat.getRun(issue.executionRunId) : null; + + if ((!runToInterrupt || runToInterrupt.status !== "running") && issue.assigneeAgentId) { + const activeRun = await heartbeat.getActiveRunForAgent(issue.assigneeAgentId); + const activeIssueId = + activeRun && + activeRun.contextSnapshot && + typeof activeRun.contextSnapshot === "object" && + typeof (activeRun.contextSnapshot as Record).issueId === "string" + ? ((activeRun.contextSnapshot as Record).issueId as string) + : null; + if (activeRun && activeRun.status === "running" && activeIssueId === issue.id) { + runToInterrupt = activeRun; + } + } + + return runToInterrupt?.status === "running" ? runToInterrupt : null; + } + async function normalizeIssueIdentifier(rawId: string): Promise { if (/^[A-Z]+-\d+$/i.test(rawId)) { const issue = await svc.getByIdentifier(rawId); @@ -919,7 +947,7 @@ export function issueRoutes(db: Db, storage: StorageService) { res.status(201).json(issue); }); - router.patch("/issues/:id", validate(updateIssueSchema), async (req, res) => { + router.patch("/issues/:id", validate(updateIssueRouteSchema), async (req, res) => { const id = req.params.id as string; const existing = await svc.getById(id); if (!existing) { @@ -949,7 +977,45 @@ export function issueRoutes(db: Db, storage: StorageService) { const actor = getActorInfo(req); const isClosed = existing.status === "done" || existing.status === "cancelled"; - const { comment: commentBody, reopen: reopenRequested, hiddenAt: hiddenAtRaw, ...updateFields } = req.body; + const { + comment: commentBody, + reopen: reopenRequested, + interrupt: interruptRequested, + hiddenAt: hiddenAtRaw, + ...updateFields + } = req.body; + let interruptedRunId: string | null = null; + + if (interruptRequested) { + if (!commentBody) { + res.status(400).json({ error: "Interrupt is only supported when posting a comment" }); + return; + } + if (req.actor.type !== "board") { + res.status(403).json({ error: "Only board users can interrupt active runs from issue comments" }); + return; + } + + const runToInterrupt = await resolveActiveIssueRun(existing); + if (runToInterrupt) { + const cancelled = await heartbeat.cancelRun(runToInterrupt.id); + if (cancelled) { + interruptedRunId = cancelled.id; + await logActivity(db, { + companyId: cancelled.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "heartbeat.cancelled", + entityType: "heartbeat_run", + entityId: cancelled.id, + details: { agentId: cancelled.agentId, source: "issue_comment_interrupt", issueId: existing.id }, + }); + } + } + } + if (hiddenAtRaw !== undefined) { updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null; } @@ -1024,6 +1090,7 @@ export function issueRoutes(db: Db, storage: StorageService) { identifier: issue.identifier, ...(commentBody ? { source: "comment" } : {}), ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}), + ...(interruptedRunId ? { interruptedRunId } : {}), _previous: hasFieldChanges ? previous : undefined, }, }); @@ -1050,6 +1117,7 @@ export function issueRoutes(db: Db, storage: StorageService) { identifier: issue.identifier, issueTitle: issue.title, ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}), + ...(interruptedRunId ? { interruptedRunId } : {}), ...(hasFieldChanges ? { updated: true } : {}), }, }); @@ -1071,10 +1139,18 @@ export function issueRoutes(db: Db, storage: StorageService) { source: "assignment", triggerDetail: "system", reason: "issue_assigned", - payload: { issueId: issue.id, mutation: "update" }, + payload: { + issueId: issue.id, + mutation: "update", + ...(interruptedRunId ? { interruptedRunId } : {}), + }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, - contextSnapshot: { issueId: issue.id, source: "issue.update" }, + contextSnapshot: { + issueId: issue.id, + source: "issue.update", + ...(interruptedRunId ? { interruptedRunId } : {}), + }, }); } @@ -1083,10 +1159,18 @@ export function issueRoutes(db: Db, storage: StorageService) { source: "automation", triggerDetail: "system", reason: "issue_status_changed", - payload: { issueId: issue.id, mutation: "update" }, + payload: { + issueId: issue.id, + mutation: "update", + ...(interruptedRunId ? { interruptedRunId } : {}), + }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, - contextSnapshot: { issueId: issue.id, source: "issue.status_change" }, + contextSnapshot: { + issueId: issue.id, + source: "issue.status_change", + ...(interruptedRunId ? { interruptedRunId } : {}), + }, }); } @@ -1379,28 +1463,8 @@ export function issueRoutes(db: Db, storage: StorageService) { return; } - let runToInterrupt = currentIssue.executionRunId - ? await heartbeat.getRun(currentIssue.executionRunId) - : null; - - if ( - (!runToInterrupt || runToInterrupt.status !== "running") && - currentIssue.assigneeAgentId - ) { - const activeRun = await heartbeat.getActiveRunForAgent(currentIssue.assigneeAgentId); - const activeIssueId = - activeRun && - activeRun.contextSnapshot && - typeof activeRun.contextSnapshot === "object" && - typeof (activeRun.contextSnapshot as Record).issueId === "string" - ? ((activeRun.contextSnapshot as Record).issueId as string) - : null; - if (activeRun && activeRun.status === "running" && activeIssueId === currentIssue.id) { - runToInterrupt = activeRun; - } - } - - if (runToInterrupt && runToInterrupt.status === "running") { + const runToInterrupt = await resolveActiveIssueRun(currentIssue); + if (runToInterrupt) { const cancelled = await heartbeat.cancelRun(runToInterrupt.id); if (cancelled) { interruptedRunId = cancelled.id; diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index 3595883e..cb9933c4 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -37,7 +37,12 @@ interface CommentThreadProps { linkedRuns?: LinkedRunItem[]; companyId?: string | null; projectId?: string | null; - onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise; + onAdd: ( + body: string, + reopen?: boolean, + reassignment?: CommentReassignment, + interrupt?: boolean, + ) => Promise; issueStatus?: string; agentMap?: Map; imageUploadHandler?: (file: File) => Promise; @@ -50,6 +55,7 @@ interface CommentThreadProps { currentAssigneeValue?: string; suggestedAssigneeValue?: string; mentions?: MentionOption[]; + interruptAvailable?: boolean; } const DRAFT_DEBOUNCE_MS = 800; @@ -279,9 +285,11 @@ export function CommentThread({ currentAssigneeValue = "", suggestedAssigneeValue, mentions: providedMentions, + interruptAvailable = false, }: CommentThreadProps) { const [body, setBody] = useState(""); const [reopen, setReopen] = useState(true); + const [interrupt, setInterrupt] = useState(false); const [submitting, setSubmitting] = useState(false); const [attaching, setAttaching] = useState(false); const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue; @@ -351,6 +359,14 @@ export function CommentThread({ setReassignTarget(effectiveSuggestedAssigneeValue); }, [effectiveSuggestedAssigneeValue]); + const interruptVisible = interruptAvailable && body.trim().length > 0; + + useEffect(() => { + if (!interruptVisible && interrupt) { + setInterrupt(false); + } + }, [interruptVisible, interrupt]); + // Scroll to comment when URL hash matches #comment-{id} useEffect(() => { const hash = location.hash; @@ -377,10 +393,11 @@ export function CommentThread({ setSubmitting(true); try { - await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined); + await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined, interrupt ? true : undefined); setBody(""); if (draftKey) clearDraft(draftKey); setReopen(true); + setInterrupt(false); setReassignTarget(effectiveSuggestedAssigneeValue); } catch { // Parent mutation handlers surface the failure and keep the draft intact. @@ -465,6 +482,17 @@ export function CommentThread({ /> Re-open + {interruptVisible && ( + + )} {enableReassign && reassignOptions.length > 0 && ( 0 || !!activeRun; + const hasRunningIssueRun = + activeRun?.status === "running" || (liveRuns ?? []).some((run) => run.status === "running"); const sourceBreadcrumb = useMemo( () => readIssueDetailBreadcrumb(location.state, location.search) ?? { label: "Issues", href: "/issues" }, [location.state, location.search], @@ -505,8 +507,8 @@ export function IssueDetail() { }); const addComment = useMutation({ - mutationFn: ({ body, reopen }: { body: string; reopen?: boolean }) => - issuesApi.addComment(issueId!, body, reopen), + mutationFn: ({ body, reopen, interrupt }: { body: string; reopen?: boolean; interrupt?: boolean }) => + issuesApi.addComment(issueId!, body, reopen, interrupt), onMutate: async ({ body, reopen }) => { await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) }); await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) }); @@ -572,10 +574,12 @@ export function IssueDetail() { mutationFn: ({ body, reopen, + interrupt, reassignment, }: { body: string; reopen?: boolean; + interrupt?: boolean; reassignment: CommentReassignment; }) => issuesApi.update(issueId!, { @@ -583,6 +587,7 @@ export function IssueDetail() { assigneeAgentId: reassignment.assigneeAgentId, assigneeUserId: reassignment.assigneeUserId, ...(reopen ? { status: "todo" } : {}), + ...(interrupt ? { interrupt } : {}), }), onMutate: async ({ body, reopen, reassignment }) => { await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) }); @@ -1171,12 +1176,13 @@ export function IssueDetail() { currentAssigneeValue={actualAssigneeValue} suggestedAssigneeValue={suggestedAssigneeValue} mentions={mentionOptions} - onAdd={async (body, reopen, reassignment) => { + interruptAvailable={hasRunningIssueRun} + onAdd={async (body, reopen, reassignment, interrupt) => { if (reassignment) { - await addCommentAndReassign.mutateAsync({ body, reopen, reassignment }); + await addCommentAndReassign.mutateAsync({ body, reopen, reassignment, interrupt }); return; } - await addComment.mutateAsync({ body, reopen }); + await addComment.mutateAsync({ body, reopen, interrupt }); }} imageUploadHandler={async (file) => { const attachment = await uploadAttachment.mutateAsync(file);