Add issue comment interrupt support
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
cfb7dd4818
commit
4226e15128
5 changed files with 179 additions and 35 deletions
|
|
@ -66,6 +66,7 @@ export type CreateIssueLabel = z.infer<typeof createIssueLabelSchema>;
|
|||
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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>) => ({
|
||||
...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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>).issueId === "string"
|
||||
? ((activeRun.contextSnapshot as Record<string, unknown>).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<string> {
|
||||
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<string, unknown>).issueId === "string"
|
||||
? ((activeRun.contextSnapshot as Record<string, unknown>).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;
|
||||
|
|
|
|||
|
|
@ -37,7 +37,12 @@ interface CommentThreadProps {
|
|||
linkedRuns?: LinkedRunItem[];
|
||||
companyId?: string | null;
|
||||
projectId?: string | null;
|
||||
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
|
||||
onAdd: (
|
||||
body: string,
|
||||
reopen?: boolean,
|
||||
reassignment?: CommentReassignment,
|
||||
interrupt?: boolean,
|
||||
) => Promise<void>;
|
||||
issueStatus?: string;
|
||||
agentMap?: Map<string, Agent>;
|
||||
imageUploadHandler?: (file: File) => Promise<string>;
|
||||
|
|
@ -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
|
||||
</label>
|
||||
{interruptVisible && (
|
||||
<label className="flex items-center gap-1.5 text-xs text-red-700 dark:text-red-300 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={interrupt}
|
||||
onChange={(e) => setInterrupt(e.target.checked)}
|
||||
className="rounded border-red-300 accent-red-600"
|
||||
/>
|
||||
Interrupt
|
||||
</label>
|
||||
)}
|
||||
{enableReassign && reassignOptions.length > 0 && (
|
||||
<InlineEntitySelector
|
||||
value={reassignTarget}
|
||||
|
|
|
|||
|
|
@ -275,6 +275,8 @@ export function IssueDetail() {
|
|||
});
|
||||
|
||||
const hasLiveRuns = (liveRuns ?? []).length > 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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue