import { randomUUID } from "node:crypto"; import { Router, type Request, type Response } from "express"; import multer from "multer"; import { z } from "zod"; import type { Db } from "@paperclipai/db"; import { issueExecutionDecisions } from "@paperclipai/db"; import { addIssueCommentSchema, createIssueAttachmentMetadataSchema, createIssueWorkProductSchema, createIssueLabelSchema, checkoutIssueSchema, createChildIssueSchema, createIssueSchema, feedbackTargetTypeSchema, feedbackTraceStatusSchema, feedbackVoteValueSchema, upsertIssueFeedbackVoteSchema, linkIssueApprovalSchema, issueDocumentKeySchema, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, restoreIssueDocumentRevisionSchema, updateIssueWorkProductSchema, upsertIssueDocumentSchema, updateIssueSchema, getClosedIsolatedExecutionWorkspaceMessage, isClosedIsolatedExecutionWorkspace, type ExecutionWorkspace, } from "@paperclipai/shared"; import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry"; import { getTelemetryClient } from "../telemetry.js"; import type { StorageService } from "../storage/types.js"; import { validate } from "../middleware/validate.js"; import { accessService, agentService, executionWorkspaceService, feedbackService, goalService, heartbeatService, instanceSettingsService, issueApprovalService, ISSUE_LIST_DEFAULT_LIMIT, ISSUE_LIST_MAX_LIMIT, issueReferenceService, issueService, clampIssueListLimit, documentService, logActivity, projectService, routineService, workProductService, } from "../services/index.js"; import { logger } from "../middleware/logger.js"; import { conflict, forbidden, HttpError, notFound, unauthorized } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { assertNoAgentHostWorkspaceCommandMutation, collectIssueWorkspaceCommandPaths, } from "./workspace-command-authz.js"; import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js"; import { isInlineAttachmentContentType, MAX_ATTACHMENT_BYTES, normalizeContentType, SVG_CONTENT_TYPE, } from "../attachment-types.js"; import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js"; import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy, parseIssueExecutionState, } from "../services/issue-execution-policy.js"; const MAX_ISSUE_COMMENT_LIMIT = 500; const updateIssueRouteSchema = updateIssueSchema.extend({ interrupt: z.boolean().optional(), }); type ParsedExecutionState = NonNullable>; type NormalizedExecutionPolicy = NonNullable>; type ActivityIssueRelationSummary = { id: string; identifier: string | null; title: string; }; type ActivityExecutionParticipant = Pick< NormalizedExecutionPolicy["stages"][number]["participants"][number], "type" | "agentId" | "userId" >; type ExecutionStageWakeContext = { wakeRole: "reviewer" | "approver" | "executor"; stageId: string | null; stageType: ParsedExecutionState["currentStageType"]; currentParticipant: ParsedExecutionState["currentParticipant"]; returnAssignee: ParsedExecutionState["returnAssignee"]; lastDecisionOutcome: ParsedExecutionState["lastDecisionOutcome"]; allowedActions: string[]; }; function executionPrincipalsEqual( left: ParsedExecutionState["currentParticipant"] | null, right: ParsedExecutionState["currentParticipant"] | null, ) { if (!left || !right || left.type !== right.type) return false; return left.type === "agent" ? left.agentId === right.agentId : left.userId === right.userId; } function buildExecutionStageWakeContext(input: { state: ParsedExecutionState; wakeRole: ExecutionStageWakeContext["wakeRole"]; allowedActions: string[]; }): ExecutionStageWakeContext { return { wakeRole: input.wakeRole, stageId: input.state.currentStageId, stageType: input.state.currentStageType, currentParticipant: input.state.currentParticipant, returnAssignee: input.state.returnAssignee, lastDecisionOutcome: input.state.lastDecisionOutcome, allowedActions: input.allowedActions, }; } function summarizeIssueRelationForActivity(relation: { id: string; identifier: string | null; title: string; }): ActivityIssueRelationSummary { return { id: relation.id, identifier: relation.identifier, title: relation.title, }; } function summarizeIssueReferenceActivityDetails(input: | { addedReferencedIssues: ActivityIssueRelationSummary[]; removedReferencedIssues: ActivityIssueRelationSummary[]; currentReferencedIssues: ActivityIssueRelationSummary[]; } | null | undefined, ) { if (!input) return {}; return { ...(input.addedReferencedIssues.length > 0 ? { addedReferencedIssues: input.addedReferencedIssues } : {}), ...(input.removedReferencedIssues.length > 0 ? { removedReferencedIssues: input.removedReferencedIssues } : {}), ...(input.currentReferencedIssues.length > 0 ? { currentReferencedIssues: input.currentReferencedIssues } : {}), }; } function activityExecutionParticipantKey(participant: ActivityExecutionParticipant): string { return participant.type === "agent" ? `agent:${participant.agentId}` : `user:${participant.userId}`; } function summarizeExecutionParticipants( policy: NormalizedExecutionPolicy | null, stageType: NormalizedExecutionPolicy["stages"][number]["type"], ): ActivityExecutionParticipant[] { const stage = policy?.stages.find((candidate) => candidate.type === stageType); return ( stage?.participants.map((participant) => ({ type: participant.type, agentId: participant.agentId ?? null, userId: participant.userId ?? null, })) ?? [] ); } function isClosedIssueStatus(status: string | null | undefined): status is "done" | "cancelled" { return status === "done" || status === "cancelled"; } function shouldImplicitlyReopenCommentForAgent(input: { issueStatus: string | null | undefined; assigneeAgentId: string | null | undefined; actorType: "agent" | "user"; actorId: string; }) { if (!isClosedIssueStatus(input.issueStatus)) return false; if (typeof input.assigneeAgentId !== "string" || input.assigneeAgentId.length === 0) return false; if (input.actorType === "agent" && input.actorId === input.assigneeAgentId) return false; return true; } function diffExecutionParticipants( previousPolicy: NormalizedExecutionPolicy | null, nextPolicy: NormalizedExecutionPolicy | null, stageType: NormalizedExecutionPolicy["stages"][number]["type"], ) { const previousParticipants = summarizeExecutionParticipants(previousPolicy, stageType); const nextParticipants = summarizeExecutionParticipants(nextPolicy, stageType); const previousByKey = new Map(previousParticipants.map((participant) => [ activityExecutionParticipantKey(participant), participant, ])); const nextByKey = new Map(nextParticipants.map((participant) => [ activityExecutionParticipantKey(participant), participant, ])); return { participants: nextParticipants, addedParticipants: nextParticipants.filter((participant) => !previousByKey.has(activityExecutionParticipantKey(participant))), removedParticipants: previousParticipants.filter((participant) => !nextByKey.has(activityExecutionParticipantKey(participant))), }; } function buildExecutionStageWakeup(input: { issueId: string; previousState: ParsedExecutionState | null; nextState: ParsedExecutionState | null; interruptedRunId: string | null; requestedByActorType: "user" | "agent"; requestedByActorId: string; }) { const { issueId, previousState, nextState, interruptedRunId } = input; if (!nextState) return null; if (nextState.status === "pending") { const agentId = nextState.currentParticipant?.type === "agent" ? (nextState.currentParticipant.agentId ?? null) : null; const stageChanged = previousState?.status !== "pending" || previousState?.currentStageId !== nextState.currentStageId || !executionPrincipalsEqual(previousState?.currentParticipant ?? null, nextState.currentParticipant ?? null); if (!agentId || !stageChanged) return null; const reason = nextState.currentStageType === "approval" ? "execution_approval_requested" : "execution_review_requested"; const executionStage = buildExecutionStageWakeContext({ state: nextState, wakeRole: nextState.currentStageType === "approval" ? "approver" : "reviewer", allowedActions: ["approve", "request_changes"], }); return { agentId, wakeup: { source: "assignment" as const, triggerDetail: "system" as const, reason, payload: { issueId, mutation: "update", executionStage, ...(interruptedRunId ? { interruptedRunId } : {}), }, requestedByActorType: input.requestedByActorType, requestedByActorId: input.requestedByActorId, contextSnapshot: { issueId, taskId: issueId, wakeReason: reason, source: "issue.execution_stage", executionStage, ...(interruptedRunId ? { interruptedRunId } : {}), }, }, }; } if (nextState.status === "changes_requested") { const agentId = nextState.returnAssignee?.type === "agent" ? (nextState.returnAssignee.agentId ?? null) : null; const becameChangesRequested = previousState?.status !== "changes_requested" || previousState?.lastDecisionId !== nextState.lastDecisionId || !executionPrincipalsEqual(previousState?.returnAssignee ?? null, nextState.returnAssignee ?? null); if (!agentId || !becameChangesRequested) return null; const executionStage = buildExecutionStageWakeContext({ state: nextState, wakeRole: "executor", allowedActions: ["address_changes", "resubmit"], }); return { agentId, wakeup: { source: "assignment" as const, triggerDetail: "system" as const, reason: "execution_changes_requested", payload: { issueId, mutation: "update", executionStage, ...(interruptedRunId ? { interruptedRunId } : {}), }, requestedByActorType: input.requestedByActorType, requestedByActorId: input.requestedByActorId, contextSnapshot: { issueId, taskId: issueId, wakeReason: "execution_changes_requested", source: "issue.execution_stage", executionStage, ...(interruptedRunId ? { interruptedRunId } : {}), }, }, }; } return null; } export function issueRoutes( db: Db, storage: StorageService, opts?: { feedbackExportService?: { flushPendingFeedbackTraces(input?: { companyId?: string; traceId?: string; limit?: number; now?: Date; }): Promise; }; }, ) { const router = Router(); const svc = issueService(db); const access = accessService(db); const heartbeat = heartbeatService(db); const feedback = feedbackService(db); const instanceSettings = instanceSettingsService(db); const agentsSvc = agentService(db); const projectsSvc = projectService(db); const goalsSvc = goalService(db); const issueApprovalsSvc = issueApprovalService(db); const executionWorkspacesSvc = executionWorkspaceService(db); const workProductsSvc = workProductService(db); const documentsSvc = documentService(db); const issueReferencesSvc = issueReferenceService(db); const routinesSvc = routineService(db); const feedbackExportService = opts?.feedbackExportService; const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 }, }); function withContentPath(attachment: T) { return { ...attachment, contentPath: `/api/attachments/${attachment.id}/content`, }; } function parseBooleanQuery(value: unknown) { return value === true || value === "true" || value === "1"; } function parseDateQuery(value: unknown, field: string) { if (typeof value !== "string" || value.trim().length === 0) return undefined; const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { throw new HttpError(400, `Invalid ${field} query value`); } return parsed; } async function runSingleFileUpload(req: Request, res: Response) { await new Promise((resolve, reject) => { upload.single("file")(req, res, (err: unknown) => { if (err) reject(err); else resolve(); }); }); } async function assertCanManageIssueApprovalLinks(req: Request, res: Response, companyId: string) { assertCompanyAccess(req, companyId); if (req.actor.type === "board") return true; if (!req.actor.agentId) { res.status(403).json({ error: "Agent authentication required" }); return false; } const actorAgent = await agentsSvc.getById(req.actor.agentId); if (!actorAgent || actorAgent.companyId !== companyId) { res.status(403).json({ error: "Forbidden" }); return false; } if (actorAgent.role === "ceo" || Boolean(actorAgent.permissions?.canCreateAgents)) return true; res.status(403).json({ error: "Missing permission to link approvals" }); return false; } function actorCanAccessCompany(req: Request, companyId: string) { if (req.actor.type === "none") return false; if (req.actor.type === "agent") return req.actor.companyId === companyId; if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true; return (req.actor.companyIds ?? []).includes(companyId); } function canCreateAgentsLegacy(agent: { permissions: Record | null | undefined; role: string }) { if (agent.role === "ceo") return true; if (!agent.permissions || typeof agent.permissions !== "object") return false; return Boolean((agent.permissions as Record).canCreateAgents); } async function assertCanAssignTasks(req: Request, companyId: string) { assertCompanyAccess(req, companyId); if (req.actor.type === "board") { if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; const allowed = await access.canUser(companyId, req.actor.userId, "tasks:assign"); if (!allowed) throw forbidden("Missing permission: tasks:assign"); return; } if (req.actor.type === "agent") { if (!req.actor.agentId) throw forbidden("Agent authentication required"); const allowedByGrant = await access.hasPermission(companyId, "agent", req.actor.agentId, "tasks:assign"); if (allowedByGrant) return; const actorAgent = await agentsSvc.getById(req.actor.agentId); if (actorAgent && actorAgent.companyId === companyId && canCreateAgentsLegacy(actorAgent)) return; throw forbidden("Missing permission: tasks:assign"); } throw unauthorized(); } function requireAgentRunId(req: Request, res: Response) { if (req.actor.type !== "agent") return null; const runId = req.actor.runId?.trim(); if (runId) return runId; res.status(401).json({ error: "Agent run id required" }); return null; } async function hasActiveCheckoutManagementOverride( actorAgentId: string, companyId: string, assigneeAgentId: string, ) { const allowedByGrant = await access.hasPermission( companyId, "agent", actorAgentId, "tasks:manage_active_checkouts", ); if (allowedByGrant) return true; const companyAgents = await agentsSvc.list(companyId); const agentsById = new Map(companyAgents.map((agent) => [agent.id, agent])); const actorAgent = agentsById.get(actorAgentId); if (!actorAgent) return false; if (canCreateAgentsLegacy(actorAgent)) return true; // Reporting-chain managers may intervene in an agent's active checkout // without taking the task over. Peers must own the checkout/run first. let cursor: string | null = assigneeAgentId; for (let depth = 0; cursor && depth < 50; depth += 1) { const assignee = agentsById.get(cursor); if (!assignee) return false; if (assignee.reportsTo === actorAgentId) return true; cursor = assignee.reportsTo; } return false; } async function assertAgentIssueMutationAllowed( req: Request, res: Response, issue: { id: string; companyId: string; status: string; assigneeAgentId: string | null }, ) { if (req.actor.type !== "agent") return true; const actorAgentId = req.actor.agentId; if (!actorAgentId) { res.status(403).json({ error: "Agent authentication required" }); return false; } if (issue.status !== "in_progress" || issue.assigneeAgentId === null) { return true; } if (issue.assigneeAgentId !== actorAgentId) { if (await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, issue.assigneeAgentId)) { return true; } res.status(409).json({ error: "Issue is checked out by another agent", details: { issueId: issue.id, assigneeAgentId: issue.assigneeAgentId, actorAgentId, }, }); return false; } const runId = requireAgentRunId(req, res); if (!runId) return false; const ownership = await svc.assertCheckoutOwner(issue.id, actorAgentId, runId); if (ownership.adoptedFromRunId) { const actor = getActorInfo(req); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.checkout_lock_adopted", entityType: "issue", entityId: issue.id, details: { previousCheckoutRunId: ownership.adoptedFromRunId, checkoutRunId: runId, reason: "stale_checkout_run", }, }); } 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 normalizeIssueAssigneeAgentReference( companyId: string, rawAssigneeAgentId: string | null | undefined, ) { if (rawAssigneeAgentId === undefined || rawAssigneeAgentId === null) { return rawAssigneeAgentId; } const raw = rawAssigneeAgentId.trim(); if (raw.length === 0) { return rawAssigneeAgentId; } const resolved = await agentsSvc.resolveByReference(companyId, raw); if (resolved.ambiguous) { throw conflict("Agent shortname is ambiguous in this company. Use the agent ID."); } if (!resolved.agent) { throw notFound("Agent not found"); } return resolved.agent.id; } function toValidTimestamp(value: Date | string | null | undefined) { if (!value) return null; const timestamp = value instanceof Date ? value.getTime() : new Date(value).getTime(); return Number.isFinite(timestamp) ? timestamp : null; } function isQueuedIssueCommentForActiveRun(params: { comment: { authorAgentId?: string | null; createdAt?: Date | string | null; }; activeRun: { agentId?: string | null; startedAt?: Date | string | null; createdAt?: Date | string | null; }; }) { const activeRunStartedAtMs = toValidTimestamp(params.activeRun.startedAt) ?? toValidTimestamp(params.activeRun.createdAt); const commentCreatedAtMs = toValidTimestamp(params.comment.createdAt); if (activeRunStartedAtMs === null || commentCreatedAtMs === null) return false; if (params.comment.authorAgentId && params.comment.authorAgentId === params.activeRun.agentId) return false; return commentCreatedAtMs >= activeRunStartedAtMs; } async function getClosedIssueExecutionWorkspace(issue: { executionWorkspaceId?: string | null }) { if (!issue.executionWorkspaceId) return null; const workspace = await executionWorkspacesSvc.getById(issue.executionWorkspaceId); if (!workspace || !isClosedIsolatedExecutionWorkspace(workspace)) return null; return workspace; } function respondClosedIssueExecutionWorkspace( res: Response, workspace: Pick, ) { res.status(409).json({ error: getClosedIsolatedExecutionWorkspaceMessage(workspace), executionWorkspace: workspace, }); } async function normalizeIssueIdentifier(rawId: string): Promise { if (/^[A-Z]+-\d+$/i.test(rawId)) { const issue = await svc.getByIdentifier(rawId); if (issue) { return issue.id; } } return rawId; } async function resolveIssueProjectAndGoal(issue: { companyId: string; projectId: string | null; goalId: string | null; }) { const projectPromise = issue.projectId ? projectsSvc.getById(issue.projectId) : Promise.resolve(null); const directGoalPromise = issue.goalId ? goalsSvc.getById(issue.goalId) : Promise.resolve(null); const [project, directGoal] = await Promise.all([projectPromise, directGoalPromise]); if (directGoal) { return { project, goal: directGoal }; } const projectGoalId = project?.goalId ?? project?.goalIds[0] ?? null; if (projectGoalId) { const projectGoal = await goalsSvc.getById(projectGoalId); return { project, goal: projectGoal }; } if (!issue.projectId) { const defaultGoal = await goalsSvc.getDefaultCompanyGoal(issue.companyId); return { project, goal: defaultGoal }; } return { project, goal: null }; } // Resolve issue identifiers (e.g. "PAP-39") to UUIDs for all /issues/:id routes router.param("id", async (req, res, next, rawId) => { try { req.params.id = await normalizeIssueIdentifier(rawId); next(); } catch (err) { next(err); } }); // Resolve issue identifiers (e.g. "PAP-39") to UUIDs for company-scoped attachment routes. router.param("issueId", async (req, res, next, rawId) => { try { req.params.issueId = await normalizeIssueIdentifier(rawId); next(); } catch (err) { next(err); } }); // Common malformed path when companyId is empty in "/api/companies/{companyId}/issues". router.get("/issues", (_req, res) => { res.status(400).json({ error: "Missing companyId in path. Use /api/companies/{companyId}/issues.", }); }); router.get("/companies/:companyId/issues", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const assigneeUserFilterRaw = req.query.assigneeUserId as string | undefined; const touchedByUserFilterRaw = req.query.touchedByUserId as string | undefined; const inboxArchivedByUserFilterRaw = req.query.inboxArchivedByUserId as string | undefined; const unreadForUserFilterRaw = req.query.unreadForUserId as string | undefined; const assigneeUserId = assigneeUserFilterRaw === "me" && req.actor.type === "board" ? req.actor.userId : assigneeUserFilterRaw; const touchedByUserId = touchedByUserFilterRaw === "me" && req.actor.type === "board" ? req.actor.userId : touchedByUserFilterRaw; const inboxArchivedByUserId = inboxArchivedByUserFilterRaw === "me" && req.actor.type === "board" ? req.actor.userId : inboxArchivedByUserFilterRaw; const unreadForUserId = unreadForUserFilterRaw === "me" && req.actor.type === "board" ? req.actor.userId : unreadForUserFilterRaw; const rawLimit = req.query.limit as string | undefined; const parsedLimit = rawLimit !== undefined && /^\d+$/.test(rawLimit) ? Number.parseInt(rawLimit, 10) : null; const limit = parsedLimit === null ? ISSUE_LIST_DEFAULT_LIMIT : clampIssueListLimit(parsedLimit); if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) { res.status(403).json({ error: "assigneeUserId=me requires board authentication" }); return; } if (touchedByUserFilterRaw === "me" && (!touchedByUserId || req.actor.type !== "board")) { res.status(403).json({ error: "touchedByUserId=me requires board authentication" }); return; } if (inboxArchivedByUserFilterRaw === "me" && (!inboxArchivedByUserId || req.actor.type !== "board")) { res.status(403).json({ error: "inboxArchivedByUserId=me requires board authentication" }); return; } if (unreadForUserFilterRaw === "me" && (!unreadForUserId || req.actor.type !== "board")) { res.status(403).json({ error: "unreadForUserId=me requires board authentication" }); return; } if (rawLimit !== undefined && (parsedLimit === null || !Number.isInteger(parsedLimit) || parsedLimit <= 0)) { res.status(400).json({ error: `limit must be a positive integer up to ${ISSUE_LIST_MAX_LIMIT}` }); return; } const result = await svc.list(companyId, { status: req.query.status as string | undefined, assigneeAgentId: req.query.assigneeAgentId as string | undefined, participantAgentId: req.query.participantAgentId as string | undefined, assigneeUserId, touchedByUserId, inboxArchivedByUserId, unreadForUserId, projectId: req.query.projectId as string | undefined, executionWorkspaceId: req.query.executionWorkspaceId as string | undefined, parentId: req.query.parentId as string | undefined, labelId: req.query.labelId as string | undefined, originKind: req.query.originKind as string | undefined, originId: req.query.originId as string | undefined, includeRoutineExecutions: req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1", excludeRoutineExecutions: req.query.excludeRoutineExecutions === "true" || req.query.excludeRoutineExecutions === "1", q: req.query.q as string | undefined, limit, }); res.json(result); }); router.get("/companies/:companyId/labels", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const result = await svc.listLabels(companyId); res.json(result); }); router.post("/companies/:companyId/labels", validate(createIssueLabelSchema), async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const label = await svc.createLabel(companyId, req.body); const actor = getActorInfo(req); await logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "label.created", entityType: "label", entityId: label.id, details: { name: label.name, color: label.color }, }); res.status(201).json(label); }); router.delete("/labels/:labelId", async (req, res) => { const labelId = req.params.labelId as string; const existing = await svc.getLabelById(labelId); if (!existing) { res.status(404).json({ error: "Label not found" }); return; } assertCompanyAccess(req, existing.companyId); const removed = await svc.deleteLabel(labelId); if (!removed) { res.status(404).json({ error: "Label not found" }); return; } const actor = getActorInfo(req); await logActivity(db, { companyId: removed.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "label.deleted", entityType: "label", entityId: removed.id, details: { name: removed.name, color: removed.color }, }); res.json(removed); }); router.get("/issues/:id/heartbeat-context", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); const wakeCommentId = typeof req.query.wakeCommentId === "string" && req.query.wakeCommentId.trim().length > 0 ? req.query.wakeCommentId.trim() : null; const [{ project, goal }, ancestors, commentCursor, wakeComment, relations, attachments, continuationSummary] = await Promise.all([ resolveIssueProjectAndGoal(issue), svc.getAncestors(issue.id), svc.getCommentCursor(issue.id), wakeCommentId ? svc.getComment(wakeCommentId) : null, svc.getRelationSummaries(issue.id), svc.listAttachments(issue.id), documentsSvc.getIssueDocumentByKey(issue.id, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY), ]); res.json({ issue: { id: issue.id, identifier: issue.identifier, title: issue.title, description: issue.description, status: issue.status, priority: issue.priority, projectId: issue.projectId, goalId: goal?.id ?? issue.goalId, parentId: issue.parentId, blockedBy: relations.blockedBy, blocks: relations.blocks, assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId, updatedAt: issue.updatedAt, }, ancestors: ancestors.map((ancestor) => ({ id: ancestor.id, identifier: ancestor.identifier, title: ancestor.title, status: ancestor.status, priority: ancestor.priority, })), project: project ? { id: project.id, name: project.name, status: project.status, targetDate: project.targetDate, } : null, goal: goal ? { id: goal.id, title: goal.title, status: goal.status, level: goal.level, parentId: goal.parentId, } : null, commentCursor, wakeComment: wakeComment && wakeComment.issueId === issue.id ? wakeComment : null, attachments: attachments.map((a) => ({ id: a.id, filename: a.originalFilename, contentType: a.contentType, byteSize: a.byteSize, contentPath: withContentPath(a).contentPath, createdAt: a.createdAt, })), continuationSummary: continuationSummary ? { key: continuationSummary.key, title: continuationSummary.title, body: continuationSummary.body, latestRevisionId: continuationSummary.latestRevisionId, latestRevisionNumber: continuationSummary.latestRevisionNumber, updatedAt: continuationSummary.updatedAt, } : null, }); }); router.get("/issues/:id", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload, relations, referenceSummary] = await Promise.all([ resolveIssueProjectAndGoal(issue), svc.getAncestors(issue.id), svc.findMentionedProjectIds(issue.id, { includeCommentBodies: false }), documentsSvc.getIssueDocumentPayload(issue), svc.getRelationSummaries(issue.id), issueReferencesSvc.listIssueReferenceSummary(issue.id), ]); const mentionedProjects = mentionedProjectIds.length > 0 ? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds) : []; const currentExecutionWorkspace = issue.executionWorkspaceId ? await executionWorkspacesSvc.getById(issue.executionWorkspaceId) : null; const workProducts = await workProductsSvc.listForIssue(issue.id); res.json({ ...issue, goalId: goal?.id ?? issue.goalId, ancestors, blockedBy: relations.blockedBy, blocks: relations.blocks, relatedWork: referenceSummary, referencedIssueIdentifiers: referenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id), ...documentPayload, project: project ?? null, goal: goal ?? null, mentionedProjects, currentExecutionWorkspace, workProducts, }); }); router.get("/issues/:id/work-products", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); const workProducts = await workProductsSvc.listForIssue(issue.id); res.json(workProducts); }); router.get("/issues/:id/documents", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); const docs = await documentsSvc.listIssueDocuments(issue.id, { includeSystem: req.query.includeSystem === "true", }); res.json(docs); }); router.get("/issues/:id/documents/:key", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); if (!keyParsed.success) { res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); return; } const doc = await documentsSvc.getIssueDocumentByKey(issue.id, keyParsed.data); if (!doc) { res.status(404).json({ error: "Document not found" }); return; } res.json(doc); }); router.put("/issues/:id/documents/:key", validate(upsertIssueDocumentSchema), async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); if (!keyParsed.success) { res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); return; } const actor = getActorInfo(req); const referenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id); const result = await documentsSvc.upsertIssueDocument({ issueId: issue.id, key: keyParsed.data, title: req.body.title ?? null, format: req.body.format, body: req.body.body, changeSummary: req.body.changeSummary ?? null, baseRevisionId: req.body.baseRevisionId ?? null, createdByAgentId: actor.agentId ?? null, createdByUserId: actor.actorType === "user" ? actor.actorId : null, createdByRunId: actor.runId ?? null, }); const doc = result.document; await issueReferencesSvc.syncDocument(doc.id); const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id); const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: result.created ? "issue.document_created" : "issue.document_updated", entityType: "issue", entityId: issue.id, details: { key: doc.key, documentId: doc.id, title: doc.title, format: doc.format, revisionNumber: doc.latestRevisionNumber, ...summarizeIssueReferenceActivityDetails({ addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity), removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity), currentReferencedIssues: referenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity), }), }, }); res.status(result.created ? 201 : 200).json(doc); }); router.get("/issues/:id/documents/:key/revisions", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); if (!keyParsed.success) { res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); return; } const revisions = await documentsSvc.listIssueDocumentRevisions(issue.id, keyParsed.data); res.json(revisions); }); router.post( "/issues/:id/documents/:key/revisions/:revisionId/restore", validate(restoreIssueDocumentRevisionSchema), async (req, res) => { const id = req.params.id as string; const revisionId = req.params.revisionId as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); if (!keyParsed.success) { res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); return; } const actor = getActorInfo(req); const referenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id); const result = await documentsSvc.restoreIssueDocumentRevision({ issueId: issue.id, key: keyParsed.data, revisionId, createdByAgentId: actor.agentId ?? null, createdByUserId: actor.actorType === "user" ? actor.actorId : null, }); await issueReferencesSvc.syncDocument(result.document.id); const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id); const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.document_restored", entityType: "issue", entityId: issue.id, details: { key: result.document.key, documentId: result.document.id, title: result.document.title, format: result.document.format, revisionNumber: result.document.latestRevisionNumber, restoredFromRevisionId: result.restoredFromRevisionId, restoredFromRevisionNumber: result.restoredFromRevisionNumber, ...summarizeIssueReferenceActivityDetails({ addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity), removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity), currentReferencedIssues: referenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity), }), }, }); res.json(result.document); }, ); router.delete("/issues/:id/documents/:key", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); if (req.actor.type !== "board") { res.status(403).json({ error: "Board authentication required" }); return; } const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); if (!keyParsed.success) { res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); return; } const referenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id); const removed = await documentsSvc.deleteIssueDocument(issue.id, keyParsed.data); if (!removed) { res.status(404).json({ error: "Document not found" }); return; } await issueReferencesSvc.deleteDocumentSource(removed.id); const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id); const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter); const actor = getActorInfo(req); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.document_deleted", entityType: "issue", entityId: issue.id, details: { key: removed.key, documentId: removed.id, title: removed.title, ...summarizeIssueReferenceActivityDetails({ addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity), removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity), currentReferencedIssues: referenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity), }), }, }); res.json({ ok: true }); }); router.post("/issues/:id/work-products", validate(createIssueWorkProductSchema), async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; const product = await workProductsSvc.createForIssue(issue.id, issue.companyId, { ...req.body, projectId: req.body.projectId ?? issue.projectId ?? null, }); if (!product) { res.status(422).json({ error: "Invalid work product payload" }); return; } const actor = getActorInfo(req); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.work_product_created", entityType: "issue", entityId: issue.id, details: { workProductId: product.id, type: product.type, provider: product.provider }, }); res.status(201).json(product); }); router.patch("/work-products/:id", validate(updateIssueWorkProductSchema), async (req, res) => { const id = req.params.id as string; const existing = await workProductsSvc.getById(id); if (!existing) { res.status(404).json({ error: "Work product not found" }); return; } assertCompanyAccess(req, existing.companyId); const issue = await svc.getById(existing.issueId); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; const product = await workProductsSvc.update(id, req.body); if (!product) { res.status(404).json({ error: "Work product not found" }); return; } const actor = getActorInfo(req); await logActivity(db, { companyId: existing.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.work_product_updated", entityType: "issue", entityId: existing.issueId, details: { workProductId: product.id, changedKeys: Object.keys(req.body).sort() }, }); res.json(product); }); router.delete("/work-products/:id", async (req, res) => { const id = req.params.id as string; const existing = await workProductsSvc.getById(id); if (!existing) { res.status(404).json({ error: "Work product not found" }); return; } assertCompanyAccess(req, existing.companyId); const issue = await svc.getById(existing.issueId); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; const removed = await workProductsSvc.remove(id); if (!removed) { res.status(404).json({ error: "Work product not found" }); return; } const actor = getActorInfo(req); await logActivity(db, { companyId: existing.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.work_product_deleted", entityType: "issue", entityId: existing.issueId, details: { workProductId: removed.id, type: removed.type }, }); res.json(removed); }); router.post("/issues/:id/read", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); if (req.actor.type !== "board") { res.status(403).json({ error: "Board authentication required" }); return; } if (!req.actor.userId) { res.status(403).json({ error: "Board user context required" }); return; } const readState = await svc.markRead(issue.companyId, issue.id, req.actor.userId, new Date()); const actor = getActorInfo(req); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.read_marked", entityType: "issue", entityId: issue.id, details: { userId: req.actor.userId, lastReadAt: readState.lastReadAt }, }); res.json(readState); }); router.delete("/issues/:id/read", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); if (req.actor.type !== "board") { res.status(403).json({ error: "Board authentication required" }); return; } if (!req.actor.userId) { res.status(403).json({ error: "Board user context required" }); return; } const removed = await svc.markUnread(issue.companyId, issue.id, req.actor.userId); const actor = getActorInfo(req); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.read_unmarked", entityType: "issue", entityId: issue.id, details: { userId: req.actor.userId }, }); res.json({ id: issue.id, removed }); }); router.post("/issues/:id/inbox-archive", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); if (req.actor.type !== "board") { res.status(403).json({ error: "Board authentication required" }); return; } if (!req.actor.userId) { res.status(403).json({ error: "Board user context required" }); return; } const archiveState = await svc.archiveInbox(issue.companyId, issue.id, req.actor.userId, new Date()); const actor = getActorInfo(req); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.inbox_archived", entityType: "issue", entityId: issue.id, details: { userId: req.actor.userId, archivedAt: archiveState.archivedAt }, }); res.json(archiveState); }); router.delete("/issues/:id/inbox-archive", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); if (req.actor.type !== "board") { res.status(403).json({ error: "Board authentication required" }); return; } if (!req.actor.userId) { res.status(403).json({ error: "Board user context required" }); return; } const removed = await svc.unarchiveInbox(issue.companyId, issue.id, req.actor.userId); const actor = getActorInfo(req); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.inbox_unarchived", entityType: "issue", entityId: issue.id, details: { userId: req.actor.userId }, }); res.json(removed ?? { ok: true }); }); router.get("/issues/:id/approvals", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); const approvals = await issueApprovalsSvc.listApprovalsForIssue(id); res.json(approvals); }); router.post("/issues/:id/approvals", validate(linkIssueApprovalSchema), async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; if (!(await assertCanManageIssueApprovalLinks(req, res, issue.companyId))) return; const actor = getActorInfo(req); await issueApprovalsSvc.link(id, req.body.approvalId, { agentId: actor.agentId, userId: actor.actorType === "user" ? actor.actorId : null, }); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.approval_linked", entityType: "issue", entityId: issue.id, details: { approvalId: req.body.approvalId }, }); const approvals = await issueApprovalsSvc.listApprovalsForIssue(id); res.status(201).json(approvals); }); router.delete("/issues/:id/approvals/:approvalId", async (req, res) => { const id = req.params.id as string; const approvalId = req.params.approvalId as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; if (!(await assertCanManageIssueApprovalLinks(req, res, issue.companyId))) return; await issueApprovalsSvc.unlink(id, approvalId); const actor = getActorInfo(req); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.approval_unlinked", entityType: "issue", entityId: issue.id, details: { approvalId }, }); res.json({ ok: true }); }); router.post("/companies/:companyId/issues", validate(createIssueSchema), async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); if (req.body.assigneeAgentId || req.body.assigneeUserId) { await assertCanAssignTasks(req, companyId); } const actor = getActorInfo(req); const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy); const issue = await svc.create(companyId, { ...req.body, executionPolicy, createdByAgentId: actor.agentId, createdByUserId: actor.actorType === "user" ? actor.actorId : null, }); await issueReferencesSvc.syncIssue(issue.id); const referenceSummary = await issueReferencesSvc.listIssueReferenceSummary(issue.id); const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary( issueReferencesSvc.emptySummary(), referenceSummary, ); await logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.created", entityType: "issue", entityId: issue.id, details: { title: issue.title, identifier: issue.identifier, ...(Array.isArray(req.body.blockedByIssueIds) ? { blockedByIssueIds: req.body.blockedByIssueIds } : {}), ...summarizeIssueReferenceActivityDetails({ addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity), removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity), currentReferencedIssues: referenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity), }), }, }); void queueIssueAssignmentWakeup({ heartbeat, issue, reason: "issue_assigned", mutation: "create", contextSource: "issue.create", requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, }); res.status(201).json({ ...issue, relatedWork: referenceSummary, referencedIssueIdentifiers: referenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id), }); }); router.post("/issues/:id/children", validate(createChildIssueSchema), async (req, res) => { const parentId = req.params.id as string; const parent = await svc.getById(parentId); if (!parent) { res.status(404).json({ error: "Parent issue not found" }); return; } assertCompanyAccess(req, parent.companyId); assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); if (req.body.assigneeAgentId || req.body.assigneeUserId) { await assertCanAssignTasks(req, parent.companyId); } const actor = getActorInfo(req); const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy); const { issue, parentBlockerAdded } = await svc.createChild(parent.id, { ...req.body, executionPolicy, createdByAgentId: actor.agentId, createdByUserId: actor.actorType === "user" ? actor.actorId : null, actorAgentId: actor.agentId, actorUserId: actor.actorType === "user" ? actor.actorId : null, }); await logActivity(db, { companyId: parent.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.child_created", entityType: "issue", entityId: issue.id, details: { parentId: parent.id, identifier: issue.identifier, title: issue.title, inheritedExecutionWorkspaceFromIssueId: parent.id, ...(Array.isArray(req.body.blockedByIssueIds) ? { blockedByIssueIds: req.body.blockedByIssueIds } : {}), ...(parentBlockerAdded ? { parentBlockerAdded: true } : {}), }, }); void queueIssueAssignmentWakeup({ heartbeat, issue, reason: "issue_assigned", mutation: "create", contextSource: "issue.child_create", requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, }); res.status(201).json(issue); }); router.patch("/issues/:id", validate(updateIssueRouteSchema), async (req, res) => { const id = req.params.id as string; const existing = await svc.getById(id); if (!existing) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, existing.companyId); assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return; const actor = getActorInfo(req); const isClosed = isClosedIssueStatus(existing.status); const normalizedAssigneeAgentId = await normalizeIssueAssigneeAgentReference( existing.companyId, req.body.assigneeAgentId as string | null | undefined, ); const titleOrDescriptionChanged = req.body.title !== undefined || req.body.description !== undefined; const existingRelations = Array.isArray(req.body.blockedByIssueIds) ? await svc.getRelationSummaries(existing.id) : null; const { comment: commentBody, reopen: reopenRequested, interrupt: interruptRequested, hiddenAt: hiddenAtRaw, ...updateFields } = req.body; const requestedAssigneeAgentId = normalizedAssigneeAgentId === undefined ? existing.assigneeAgentId : normalizedAssigneeAgentId; const effectiveReopenRequested = reopenRequested || (!!commentBody && shouldImplicitlyReopenCommentForAgent({ issueStatus: existing.status, assigneeAgentId: requestedAssigneeAgentId, actorType: actor.actorType, actorId: actor.actorId, })); const updateReferenceSummaryBefore = titleOrDescriptionChanged ? await issueReferencesSvc.listIssueReferenceSummary(existing.id) : null; let interruptedRunId: string | null = null; const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(existing); const isAgentWorkUpdate = req.actor.type === "agent" && Object.keys(updateFields).length > 0; if (closedExecutionWorkspace && (commentBody || isAgentWorkUpdate)) { respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace); return; } 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; } if (commentBody && effectiveReopenRequested && isClosed && updateFields.status === undefined) { updateFields.status = "todo"; } if (req.body.executionPolicy !== undefined) { updateFields.executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy); } const previousExecutionPolicy = normalizeIssueExecutionPolicy(existing.executionPolicy ?? null); const nextExecutionPolicy = updateFields.executionPolicy !== undefined ? (updateFields.executionPolicy as NormalizedExecutionPolicy | null) : previousExecutionPolicy; if (normalizedAssigneeAgentId !== undefined) { updateFields.assigneeAgentId = normalizedAssigneeAgentId; } const transition = applyIssueExecutionPolicyTransition({ issue: existing, policy: nextExecutionPolicy, requestedStatus: typeof updateFields.status === "string" ? updateFields.status : undefined, requestedAssigneePatch: { assigneeAgentId: normalizedAssigneeAgentId, assigneeUserId: req.body.assigneeUserId === undefined ? undefined : (req.body.assigneeUserId as string | null), }, actor: { agentId: actor.agentId ?? null, userId: actor.actorType === "user" ? actor.actorId : null, }, commentBody, }); const decisionId = transition.decision ? randomUUID() : null; if (decisionId) { const nextExecutionState = transition.patch.executionState; if (!nextExecutionState || typeof nextExecutionState !== "object") { throw new Error("Execution policy decision patch is missing executionState"); } transition.patch.executionState = { ...nextExecutionState, lastDecisionId: decisionId, }; } Object.assign(updateFields, transition.patch); const nextAssigneeAgentId = updateFields.assigneeAgentId === undefined ? existing.assigneeAgentId : (updateFields.assigneeAgentId as string | null); const nextAssigneeUserId = updateFields.assigneeUserId === undefined ? existing.assigneeUserId : (updateFields.assigneeUserId as string | null); const assigneeWillChange = nextAssigneeAgentId !== existing.assigneeAgentId || nextAssigneeUserId !== existing.assigneeUserId; const isAgentReturningIssueToCreator = req.actor.type === "agent" && !!req.actor.agentId && existing.assigneeAgentId === req.actor.agentId && nextAssigneeAgentId === null && typeof nextAssigneeUserId === "string" && !!existing.createdByUserId && nextAssigneeUserId === existing.createdByUserId; if (assigneeWillChange && !transition.workflowControlledAssignment) { if (!isAgentReturningIssueToCreator) { await assertCanAssignTasks(req, existing.companyId); } } let issue; try { if (transition.decision && decisionId) { const decision = transition.decision; issue = await db.transaction(async (tx) => { const updated = await svc.update( id, { ...updateFields, actorAgentId: actor.agentId ?? null, actorUserId: actor.actorType === "user" ? actor.actorId : null, }, tx, ); if (!updated) return null; await tx.insert(issueExecutionDecisions).values({ id: decisionId, companyId: updated.companyId, issueId: updated.id, stageId: decision.stageId, stageType: decision.stageType, actorAgentId: actor.agentId ?? null, actorUserId: actor.actorType === "user" ? actor.actorId : null, outcome: decision.outcome, body: decision.body, createdByRunId: actor.runId ?? null, }); return updated; }); } else { issue = await svc.update(id, { ...updateFields, actorAgentId: actor.agentId ?? null, actorUserId: actor.actorType === "user" ? actor.actorId : null, }); } } catch (err) { if (err instanceof HttpError && err.status === 422) { logger.warn( { issueId: id, companyId: existing.companyId, assigneePatch: { assigneeAgentId: normalizedAssigneeAgentId === undefined ? "__omitted__" : normalizedAssigneeAgentId, assigneeUserId: req.body.assigneeUserId === undefined ? "__omitted__" : req.body.assigneeUserId, }, currentAssignee: { assigneeAgentId: existing.assigneeAgentId, assigneeUserId: existing.assigneeUserId, }, error: err.message, details: err.details, }, "issue update rejected with 422", ); } throw err; } if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } if (titleOrDescriptionChanged) { await issueReferencesSvc.syncIssue(issue.id); } const updateReferenceSummaryAfter = titleOrDescriptionChanged ? await issueReferencesSvc.listIssueReferenceSummary(issue.id) : null; const updateReferenceDiff = updateReferenceSummaryBefore && updateReferenceSummaryAfter ? issueReferencesSvc.diffIssueReferenceSummary(updateReferenceSummaryBefore, updateReferenceSummaryAfter) : null; let issueResponse: typeof issue & { blockedBy?: unknown; blocks?: unknown; relatedWork?: Awaited>; referencedIssueIdentifiers?: string[]; } = issue; let updatedRelations: Awaited> | null = null; if (issue && Array.isArray(req.body.blockedByIssueIds)) { updatedRelations = await svc.getRelationSummaries(issue.id); issueResponse = { ...issue, blockedBy: updatedRelations.blockedBy, blocks: updatedRelations.blocks, }; } await routinesSvc.syncRunStatusForIssue(issue.id); if (actor.runId) { await heartbeat.reportRunActivity(actor.runId).catch((err) => logger.warn({ err, runId: actor.runId }, "failed to clear detached run warning after issue activity")); } // Build activity details with previous values for changed fields const previous: Record = {}; for (const key of Object.keys(updateFields)) { if (key in existing && (existing as Record)[key] !== (updateFields as Record)[key]) { previous[key] = (existing as Record)[key]; } } if (Array.isArray(req.body.blockedByIssueIds)) { previous.blockedByIssueIds = existingRelations?.blockedBy.map((relation) => relation.id) ?? []; } const hasFieldChanges = Object.keys(previous).length > 0; const reopened = commentBody && effectiveReopenRequested && isClosed && previous.status !== undefined && issue.status === "todo"; const reopenFromStatus = reopened ? existing.status : null; await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.updated", entityType: "issue", entityId: issue.id, details: { ...updateFields, identifier: issue.identifier, ...(commentBody ? { source: "comment" } : {}), ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), _previous: hasFieldChanges ? previous : undefined, ...summarizeIssueReferenceActivityDetails( updateReferenceDiff ? { addedReferencedIssues: updateReferenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity), removedReferencedIssues: updateReferenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity), currentReferencedIssues: updateReferenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity), } : null, ), }, }); if (Array.isArray(req.body.blockedByIssueIds)) { const previousBlockedByIds = new Set((existingRelations?.blockedBy ?? []).map((relation) => relation.id)); const nextBlockedByIds = new Set(req.body.blockedByIssueIds as string[]); const addedBlockedByIssueIds = [...nextBlockedByIds].filter((candidate) => !previousBlockedByIds.has(candidate)); const removedBlockedByIssueIds = [...previousBlockedByIds].filter((candidate) => !nextBlockedByIds.has(candidate)); const nextBlockedByRelations = updatedRelations?.blockedBy ?? []; const previousBlockedByRelations = existingRelations?.blockedBy ?? []; if (addedBlockedByIssueIds.length > 0 || removedBlockedByIssueIds.length > 0) { await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.blockers_updated", entityType: "issue", entityId: issue.id, details: { identifier: issue.identifier, blockedByIssueIds: req.body.blockedByIssueIds, addedBlockedByIssueIds, removedBlockedByIssueIds, blockedByIssues: nextBlockedByRelations.map(summarizeIssueRelationForActivity), addedBlockedByIssues: nextBlockedByRelations .filter((relation) => addedBlockedByIssueIds.includes(relation.id)) .map(summarizeIssueRelationForActivity), removedBlockedByIssues: previousBlockedByRelations .filter((relation) => removedBlockedByIssueIds.includes(relation.id)) .map(summarizeIssueRelationForActivity), }, }); } } const reviewerChanges = diffExecutionParticipants(previousExecutionPolicy, nextExecutionPolicy, "review"); if (reviewerChanges.addedParticipants.length > 0 || reviewerChanges.removedParticipants.length > 0) { await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.reviewers_updated", entityType: "issue", entityId: issue.id, details: { identifier: issue.identifier, participants: reviewerChanges.participants, addedParticipants: reviewerChanges.addedParticipants, removedParticipants: reviewerChanges.removedParticipants, }, }); } const approverChanges = diffExecutionParticipants(previousExecutionPolicy, nextExecutionPolicy, "approval"); if (approverChanges.addedParticipants.length > 0 || approverChanges.removedParticipants.length > 0) { await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.approvers_updated", entityType: "issue", entityId: issue.id, details: { identifier: issue.identifier, participants: approverChanges.participants, addedParticipants: approverChanges.addedParticipants, removedParticipants: approverChanges.removedParticipants, }, }); } if (issue.status === "done" && existing.status !== "done") { const tc = getTelemetryClient(); if (tc && actor.agentId) { const actorAgent = await agentsSvc.getById(actor.agentId); if (actorAgent) { const model = typeof actorAgent.adapterConfig?.model === "string" ? actorAgent.adapterConfig.model : undefined; trackAgentTaskCompleted(tc, { agentRole: actorAgent.role, agentId: actorAgent.id, adapterType: actorAgent.adapterType, model, }); } } } let comment = null; if (commentBody) { const commentReferenceSummaryBefore = updateReferenceSummaryAfter ?? await issueReferencesSvc.listIssueReferenceSummary(issue.id); comment = await svc.addComment(id, commentBody, { agentId: actor.agentId ?? undefined, userId: actor.actorType === "user" ? actor.actorId : undefined, runId: actor.runId, }); await issueReferencesSvc.syncComment(comment.id); const commentReferenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id); const commentReferenceDiff = issueReferencesSvc.diffIssueReferenceSummary( commentReferenceSummaryBefore, commentReferenceSummaryAfter, ); issueResponse = { ...issueResponse, relatedWork: commentReferenceSummaryAfter, referencedIssueIdentifiers: commentReferenceSummaryAfter.outbound.map( (item) => item.issue.identifier ?? item.issue.id, ), }; await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.comment_added", entityType: "issue", entityId: issue.id, details: { commentId: comment.id, bodySnippet: comment.body.slice(0, 120), identifier: issue.identifier, issueTitle: issue.title, ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), ...(hasFieldChanges ? { updated: true } : {}), ...summarizeIssueReferenceActivityDetails({ addedReferencedIssues: commentReferenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity), removedReferencedIssues: commentReferenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity), currentReferencedIssues: commentReferenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity), }), }, }); } else if (updateReferenceSummaryAfter) { issueResponse = { ...issueResponse, relatedWork: updateReferenceSummaryAfter, referencedIssueIdentifiers: updateReferenceSummaryAfter.outbound.map( (item) => item.issue.identifier ?? item.issue.id, ), }; } const assigneeChanged = issue.assigneeAgentId !== existing.assigneeAgentId || issue.assigneeUserId !== existing.assigneeUserId; const statusChangedFromBacklog = existing.status === "backlog" && issue.status !== "backlog" && req.body.status !== undefined; const statusChangedFromBlockedToTodo = existing.status === "blocked" && issue.status === "todo" && req.body.status !== undefined; const previousExecutionState = parseIssueExecutionState(existing.executionState); const nextExecutionState = parseIssueExecutionState(issue.executionState); const executionStageWakeup = buildExecutionStageWakeup({ issueId: issue.id, previousState: previousExecutionState, nextState: nextExecutionState, interruptedRunId, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, }); // Merge all wakeups from this update into one enqueue per agent to avoid duplicate runs. void (async () => { type WakeupRequest = NonNullable[1]>; const wakeups = new Map(); const addWakeup = (agentId: string, wakeup: WakeupRequest) => { const wakeIssueId = wakeup.payload && typeof wakeup.payload === "object" && typeof wakeup.payload.issueId === "string" ? wakeup.payload.issueId : issue.id; wakeups.set(`${agentId}:${wakeIssueId}`, { agentId, wakeup }); }; if (executionStageWakeup) { addWakeup(executionStageWakeup.agentId, executionStageWakeup.wakeup); } else if (assigneeChanged && issue.assigneeAgentId && issue.status !== "backlog") { addWakeup(issue.assigneeAgentId, { source: "assignment", triggerDetail: "system", reason: "issue_assigned", payload: { issueId: issue.id, ...(comment ? { commentId: comment.id } : {}), mutation: "update", ...(interruptedRunId ? { interruptedRunId } : {}), }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, contextSnapshot: { issueId: issue.id, ...(comment ? { taskId: issue.id, commentId: comment.id, wakeCommentId: comment.id, } : {}), source: "issue.update", ...(interruptedRunId ? { interruptedRunId } : {}), }, }); } if (!assigneeChanged && (statusChangedFromBacklog || statusChangedFromBlockedToTodo) && issue.assigneeAgentId) { addWakeup(issue.assigneeAgentId, { source: "automation", triggerDetail: "system", reason: "issue_status_changed", payload: { issueId: issue.id, mutation: "update", ...(interruptedRunId ? { interruptedRunId } : {}), }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, contextSnapshot: { issueId: issue.id, source: "issue.status_change", ...(interruptedRunId ? { interruptedRunId } : {}), }, }); } if (commentBody && comment) { const assigneeId = issue.assigneeAgentId; const actorIsAgent = actor.actorType === "agent"; const selfComment = actorIsAgent && actor.actorId === assigneeId; const skipAssigneeCommentWake = selfComment || isClosed; if (assigneeId && !assigneeChanged && (reopened || !skipAssigneeCommentWake)) { addWakeup(assigneeId, { source: "automation", triggerDetail: "system", reason: reopened ? "issue_reopened_via_comment" : "issue_commented", payload: { issueId: id, commentId: comment.id, mutation: "comment", ...(reopened ? { reopenedFrom: reopenFromStatus } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, contextSnapshot: { issueId: id, taskId: id, commentId: comment.id, wakeCommentId: comment.id, source: reopened ? "issue.comment.reopen" : "issue.comment", wakeReason: reopened ? "issue_reopened_via_comment" : "issue_commented", ...(reopened ? { reopenedFrom: reopenFromStatus } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), }, }); } let mentionedIds: string[] = []; try { mentionedIds = await svc.findMentionedAgents(issue.companyId, commentBody); } catch (err) { logger.warn({ err, issueId: id }, "failed to resolve @-mentions"); } for (const mentionedId of mentionedIds) { if (actor.actorType === "agent" && actor.actorId === mentionedId) continue; addWakeup(mentionedId, { source: "automation", triggerDetail: "system", reason: "issue_comment_mentioned", payload: { issueId: id, commentId: comment.id }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, contextSnapshot: { issueId: id, taskId: id, commentId: comment.id, wakeCommentId: comment.id, wakeReason: "issue_comment_mentioned", source: "comment.mention", }, }); } } const becameDone = existing.status !== "done" && issue.status === "done"; if (becameDone) { const dependents = await svc.listWakeableBlockedDependents(issue.id); for (const dependent of dependents) { addWakeup(dependent.assigneeAgentId, { source: "automation", triggerDetail: "system", reason: "issue_blockers_resolved", payload: { issueId: dependent.id, resolvedBlockerIssueId: issue.id, blockerIssueIds: dependent.blockerIssueIds, }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, contextSnapshot: { issueId: dependent.id, taskId: dependent.id, wakeReason: "issue_blockers_resolved", source: "issue.blockers_resolved", resolvedBlockerIssueId: issue.id, blockerIssueIds: dependent.blockerIssueIds, }, }); } } const becameTerminal = !["done", "cancelled"].includes(existing.status) && ["done", "cancelled"].includes(issue.status); if (becameTerminal && issue.parentId) { const parent = await svc.getWakeableParentAfterChildCompletion(issue.parentId); if (parent) { addWakeup(parent.assigneeAgentId, { source: "automation", triggerDetail: "system", reason: "issue_children_completed", payload: { issueId: parent.id, completedChildIssueId: issue.id, childIssueIds: parent.childIssueIds, childIssueSummaries: parent.childIssueSummaries, childIssueSummaryTruncated: parent.childIssueSummaryTruncated, }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, contextSnapshot: { issueId: parent.id, taskId: parent.id, wakeReason: "issue_children_completed", source: "issue.children_completed", completedChildIssueId: issue.id, childIssueIds: parent.childIssueIds, childIssueSummaries: parent.childIssueSummaries, childIssueSummaryTruncated: parent.childIssueSummaryTruncated, }, }); } } for (const { agentId, wakeup } of wakeups.values()) { heartbeat .wakeup(agentId, wakeup) .catch((err) => logger.warn({ err, issueId: issue.id, agentId }, "failed to wake agent on issue update")); } })(); res.json({ ...issueResponse, comment }); }); router.delete("/issues/:id", async (req, res) => { const id = req.params.id as string; const existing = await svc.getById(id); if (!existing) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, existing.companyId); if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return; const attachments = await svc.listAttachments(id); const issue = await svc.remove(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } for (const attachment of attachments) { try { await storage.deleteObject(attachment.companyId, attachment.objectKey); } catch (err) { logger.warn({ err, issueId: id, attachmentId: attachment.id }, "failed to delete attachment object during issue delete"); } } const actor = getActorInfo(req); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.deleted", entityType: "issue", entityId: issue.id, }); res.json(issue); }); router.post("/issues/:id/checkout", validate(checkoutIssueSchema), async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); if (issue.projectId) { const project = await projectsSvc.getById(issue.projectId); if (project?.pausedAt) { res.status(409).json({ error: project.pauseReason === "budget" ? "Project is paused because its budget hard-stop was reached" : "Project is paused", }); return; } } if (req.actor.type === "agent" && req.actor.agentId !== req.body.agentId) { res.status(403).json({ error: "Agent can only checkout as itself" }); return; } const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue); if (closedExecutionWorkspace) { respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace); return; } const checkoutRunId = requireAgentRunId(req, res); if (req.actor.type === "agent" && !checkoutRunId) return; const updated = await svc.checkout(id, req.body.agentId, req.body.expectedStatuses, checkoutRunId); const actor = getActorInfo(req); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.checked_out", entityType: "issue", entityId: issue.id, details: { agentId: req.body.agentId }, }); if ( shouldWakeAssigneeOnCheckout({ actorType: req.actor.type, actorAgentId: req.actor.type === "agent" ? req.actor.agentId ?? null : null, checkoutAgentId: req.body.agentId, checkoutRunId, }) ) { void heartbeat .wakeup(req.body.agentId, { source: "assignment", triggerDetail: "system", reason: "issue_checked_out", payload: { issueId: issue.id, mutation: "checkout" }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, contextSnapshot: { issueId: issue.id, source: "issue.checkout" }, }) .catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue checkout")); } res.json(updated); }); router.post("/issues/:id/release", async (req, res) => { const id = req.params.id as string; const existing = await svc.getById(id); if (!existing) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, existing.companyId); if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return; const actorRunId = requireAgentRunId(req, res); if (req.actor.type === "agent" && !actorRunId) return; const released = await svc.release( id, req.actor.type === "agent" ? req.actor.agentId : undefined, actorRunId, ); if (!released) { res.status(404).json({ error: "Issue not found" }); return; } const actor = getActorInfo(req); await logActivity(db, { companyId: released.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.released", entityType: "issue", entityId: released.id, }); res.json(released); }); router.get("/issues/:id/comments", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); const afterCommentId = typeof req.query.after === "string" && req.query.after.trim().length > 0 ? req.query.after.trim() : typeof req.query.afterCommentId === "string" && req.query.afterCommentId.trim().length > 0 ? req.query.afterCommentId.trim() : null; const order = typeof req.query.order === "string" && req.query.order.trim().toLowerCase() === "asc" ? "asc" : "desc"; const limitRaw = typeof req.query.limit === "string" && req.query.limit.trim().length > 0 ? Number(req.query.limit) : null; const limit = limitRaw && Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), MAX_ISSUE_COMMENT_LIMIT) : null; const comments = await svc.listComments(id, { afterCommentId, order, limit, }); res.json(comments); }); router.get("/issues/:id/comments/:commentId", async (req, res) => { const id = req.params.id as string; const commentId = req.params.commentId as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); const comment = await svc.getComment(commentId); if (!comment || comment.issueId !== id) { res.status(404).json({ error: "Comment not found" }); return; } res.json(comment); }); router.delete("/issues/:id/comments/:commentId", async (req, res) => { const id = req.params.id as string; const commentId = req.params.commentId as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; const comment = await svc.getComment(commentId); if (!comment || comment.issueId !== id) { res.status(404).json({ error: "Comment not found" }); return; } const actor = getActorInfo(req); const actorOwnsComment = actor.actorType === "agent" ? comment.authorAgentId === actor.agentId : comment.authorUserId === actor.actorId; if (!actorOwnsComment) { res.status(403).json({ error: "Only the comment author can cancel queued comments" }); return; } const activeRun = await resolveActiveIssueRun(issue); if (!activeRun) { res.status(409).json({ error: "Queued comment can no longer be canceled" }); return; } if (!isQueuedIssueCommentForActiveRun({ comment, activeRun })) { res.status(409).json({ error: "Only queued comments can be canceled" }); return; } const removed = await svc.removeComment(commentId); if (!removed) { res.status(404).json({ error: "Comment not found" }); return; } await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.comment_cancelled", entityType: "issue", entityId: issue.id, details: { commentId: removed.id, bodySnippet: removed.body.slice(0, 120), identifier: issue.identifier, issueTitle: issue.title, source: "queue_cancel", queueTargetRunId: activeRun.id, }, }); res.json(removed); }); router.get("/issues/:id/feedback-votes", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); if (req.actor.type !== "board") { res.status(403).json({ error: "Only board users can view feedback votes" }); return; } const votes = await feedback.listIssueVotesForUser(id, req.actor.userId ?? "local-board"); res.json(votes); }); router.get("/issues/:id/feedback-traces", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); if (req.actor.type !== "board") { res.status(403).json({ error: "Only board users can view feedback traces" }); return; } const targetTypeRaw = typeof req.query.targetType === "string" ? req.query.targetType : undefined; const voteRaw = typeof req.query.vote === "string" ? req.query.vote : undefined; const statusRaw = typeof req.query.status === "string" ? req.query.status : undefined; const targetType = targetTypeRaw ? feedbackTargetTypeSchema.parse(targetTypeRaw) : undefined; const vote = voteRaw ? feedbackVoteValueSchema.parse(voteRaw) : undefined; const status = statusRaw ? feedbackTraceStatusSchema.parse(statusRaw) : undefined; const traces = await feedback.listFeedbackTraces({ companyId: issue.companyId, issueId: issue.id, targetType, vote, status, from: parseDateQuery(req.query.from, "from"), to: parseDateQuery(req.query.to, "to"), sharedOnly: parseBooleanQuery(req.query.sharedOnly), includePayload: parseBooleanQuery(req.query.includePayload), }); res.json(traces); }); router.get("/feedback-traces/:traceId", async (req, res) => { const traceId = req.params.traceId as string; if (req.actor.type !== "board") { res.status(403).json({ error: "Only board users can view feedback traces" }); return; } const includePayload = parseBooleanQuery(req.query.includePayload) || req.query.includePayload === undefined; const trace = await feedback.getFeedbackTraceById(traceId, includePayload); if (!trace || !actorCanAccessCompany(req, trace.companyId)) { res.status(404).json({ error: "Feedback trace not found" }); return; } res.json(trace); }); router.get("/feedback-traces/:traceId/bundle", async (req, res) => { const traceId = req.params.traceId as string; if (req.actor.type !== "board") { res.status(403).json({ error: "Only board users can view feedback trace bundles" }); return; } const bundle = await feedback.getFeedbackTraceBundle(traceId); if (!bundle || !actorCanAccessCompany(req, bundle.companyId)) { res.status(404).json({ error: "Feedback trace not found" }); return; } res.json(bundle); }); router.post("/issues/:id/comments", validate(addIssueCommentSchema), async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue); if (closedExecutionWorkspace) { respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace); return; } const actor = getActorInfo(req); const reopenRequested = req.body.reopen === true; const interruptRequested = req.body.interrupt === true; const isClosed = isClosedIssueStatus(issue.status); const effectiveReopenRequested = reopenRequested || shouldImplicitlyReopenCommentForAgent({ issueStatus: issue.status, assigneeAgentId: issue.assigneeAgentId, actorType: actor.actorType, actorId: actor.actorId, }); let reopened = false; let reopenFromStatus: string | null = null; let interruptedRunId: string | null = null; let currentIssue = issue; const commentReferenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id); if (effectiveReopenRequested && isClosed) { const reopenedIssue = await svc.update(id, { status: "todo" }); if (!reopenedIssue) { res.status(404).json({ error: "Issue not found" }); return; } reopened = true; reopenFromStatus = issue.status; currentIssue = reopenedIssue; await logActivity(db, { companyId: currentIssue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.updated", entityType: "issue", entityId: currentIssue.id, details: { status: "todo", reopened: true, reopenedFrom: reopenFromStatus, source: "comment", identifier: currentIssue.identifier, }, }); } if (interruptRequested) { 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(currentIssue); 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: currentIssue.id }, }); } } } const comment = await svc.addComment(id, req.body.body, { agentId: actor.agentId ?? undefined, userId: actor.actorType === "user" ? actor.actorId : undefined, runId: actor.runId, }); await issueReferencesSvc.syncComment(comment.id); const commentReferenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(currentIssue.id); const commentReferenceDiff = issueReferencesSvc.diffIssueReferenceSummary( commentReferenceSummaryBefore, commentReferenceSummaryAfter, ); if (actor.runId) { await heartbeat.reportRunActivity(actor.runId).catch((err) => logger.warn({ err, runId: actor.runId }, "failed to clear detached run warning after issue comment")); } await logActivity(db, { companyId: currentIssue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.comment_added", entityType: "issue", entityId: currentIssue.id, details: { commentId: comment.id, bodySnippet: comment.body.slice(0, 120), identifier: currentIssue.identifier, issueTitle: currentIssue.title, ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), ...summarizeIssueReferenceActivityDetails({ addedReferencedIssues: commentReferenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity), removedReferencedIssues: commentReferenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity), currentReferencedIssues: commentReferenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity), }), }, }); // Merge all wakeups from this comment into one enqueue per agent to avoid duplicate runs. void (async () => { const wakeups = new Map[1]>(); const assigneeId = currentIssue.assigneeAgentId; const actorIsAgent = actor.actorType === "agent"; const selfComment = actorIsAgent && actor.actorId === assigneeId; const skipWake = selfComment || isClosed; if (assigneeId && (reopened || !skipWake)) { if (reopened) { wakeups.set(assigneeId, { source: "automation", triggerDetail: "system", reason: "issue_reopened_via_comment", payload: { issueId: currentIssue.id, commentId: comment.id, reopenedFrom: reopenFromStatus, mutation: "comment", ...(interruptedRunId ? { interruptedRunId } : {}), }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, contextSnapshot: { issueId: currentIssue.id, taskId: currentIssue.id, commentId: comment.id, wakeCommentId: comment.id, source: "issue.comment.reopen", wakeReason: "issue_reopened_via_comment", reopenedFrom: reopenFromStatus, ...(interruptedRunId ? { interruptedRunId } : {}), }, }); } else { wakeups.set(assigneeId, { source: "automation", triggerDetail: "system", reason: "issue_commented", payload: { issueId: currentIssue.id, commentId: comment.id, mutation: "comment", ...(interruptedRunId ? { interruptedRunId } : {}), }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, contextSnapshot: { issueId: currentIssue.id, taskId: currentIssue.id, commentId: comment.id, wakeCommentId: comment.id, source: "issue.comment", wakeReason: "issue_commented", ...(interruptedRunId ? { interruptedRunId } : {}), }, }); } } let mentionedIds: string[] = []; try { mentionedIds = await svc.findMentionedAgents(issue.companyId, req.body.body); } catch (err) { logger.warn({ err, issueId: id }, "failed to resolve @-mentions"); } for (const mentionedId of mentionedIds) { if (wakeups.has(mentionedId)) continue; if (actorIsAgent && actor.actorId === mentionedId) continue; wakeups.set(mentionedId, { source: "automation", triggerDetail: "system", reason: "issue_comment_mentioned", payload: { issueId: id, commentId: comment.id }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, contextSnapshot: { issueId: id, taskId: id, commentId: comment.id, wakeCommentId: comment.id, wakeReason: "issue_comment_mentioned", source: "comment.mention", }, }); } for (const [agentId, wakeup] of wakeups.entries()) { heartbeat .wakeup(agentId, wakeup) .catch((err) => logger.warn({ err, issueId: currentIssue.id, agentId }, "failed to wake agent on issue comment")); } })(); res.status(201).json(comment); }); router.post("/issues/:id/feedback-votes", validate(upsertIssueFeedbackVoteSchema), async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); if (req.actor.type !== "board") { res.status(403).json({ error: "Only board users can vote on AI feedback" }); return; } const actor = getActorInfo(req); const result = await feedback.saveIssueVote({ issueId: id, targetType: req.body.targetType, targetId: req.body.targetId, vote: req.body.vote, reason: req.body.reason, authorUserId: req.actor.userId ?? "local-board", allowSharing: req.body.allowSharing === true, }); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.feedback_vote_saved", entityType: "issue", entityId: issue.id, details: { identifier: issue.identifier, targetType: result.vote.targetType, targetId: result.vote.targetId, vote: result.vote.vote, hasReason: Boolean(result.vote.reason), sharingEnabled: result.sharingEnabled, }, }); if (result.consentEnabledNow) { await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "company.feedback_data_sharing_updated", entityType: "company", entityId: issue.companyId, details: { feedbackDataSharingEnabled: true, source: "issue_feedback_vote", }, }); } if (result.persistedSharingPreference) { const settings = await instanceSettings.get(); const companyIds = await instanceSettings.listCompanyIds(); await Promise.all( companyIds.map((companyId) => logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "instance.settings.general_updated", entityType: "instance_settings", entityId: settings.id, details: { general: settings.general, changedKeys: ["feedbackDataSharingPreference"], source: "issue_feedback_vote", }, }), ), ); } if (result.sharingEnabled && result.traceId && feedbackExportService) { try { await feedbackExportService.flushPendingFeedbackTraces({ companyId: issue.companyId, traceId: result.traceId, limit: 1, }); } catch (err) { logger.warn({ err, issueId: issue.id, traceId: result.traceId }, "failed to flush shared feedback trace immediately"); } } res.status(201).json(result.vote); }); router.get("/issues/:id/attachments", async (req, res) => { const issueId = req.params.id as string; const issue = await svc.getById(issueId); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); const attachments = await svc.listAttachments(issueId); res.json(attachments.map(withContentPath)); }); router.post("/companies/:companyId/issues/:issueId/attachments", async (req, res) => { const companyId = req.params.companyId as string; const issueId = req.params.issueId as string; assertCompanyAccess(req, companyId); const issue = await svc.getById(issueId); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } if (issue.companyId !== companyId) { res.status(422).json({ error: "Issue does not belong to company" }); return; } if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; try { await runSingleFileUpload(req, res); } catch (err) { if (err instanceof multer.MulterError) { if (err.code === "LIMIT_FILE_SIZE") { res.status(422).json({ error: `Attachment exceeds ${MAX_ATTACHMENT_BYTES} bytes` }); return; } res.status(400).json({ error: err.message }); return; } throw err; } const file = (req as Request & { file?: { mimetype: string; buffer: Buffer; originalname: string } }).file; if (!file) { res.status(400).json({ error: "Missing file field 'file'" }); return; } const contentType = normalizeContentType(file.mimetype); if (file.buffer.length <= 0) { res.status(422).json({ error: "Attachment is empty" }); return; } const parsedMeta = createIssueAttachmentMetadataSchema.safeParse(req.body ?? {}); if (!parsedMeta.success) { res.status(400).json({ error: "Invalid attachment metadata", details: parsedMeta.error.issues }); return; } const actor = getActorInfo(req); const stored = await storage.putFile({ companyId, namespace: `issues/${issueId}`, originalFilename: file.originalname || null, contentType, body: file.buffer, }); const attachment = await svc.createAttachment({ issueId, issueCommentId: parsedMeta.data.issueCommentId ?? null, provider: stored.provider, objectKey: stored.objectKey, contentType: stored.contentType, byteSize: stored.byteSize, sha256: stored.sha256, originalFilename: stored.originalFilename, createdByAgentId: actor.agentId, createdByUserId: actor.actorType === "user" ? actor.actorId : null, }); await logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.attachment_added", entityType: "issue", entityId: issueId, details: { attachmentId: attachment.id, originalFilename: attachment.originalFilename, contentType: attachment.contentType, byteSize: attachment.byteSize, }, }); res.status(201).json(withContentPath(attachment)); }); router.get("/attachments/:attachmentId/content", async (req, res, next) => { const attachmentId = req.params.attachmentId as string; const attachment = await svc.getAttachmentById(attachmentId); if (!attachment) { res.status(404).json({ error: "Attachment not found" }); return; } assertCompanyAccess(req, attachment.companyId); const object = await storage.getObject(attachment.companyId, attachment.objectKey); const responseContentType = normalizeContentType(attachment.contentType || object.contentType); res.setHeader("Content-Type", responseContentType); res.setHeader("Content-Length", String(attachment.byteSize || object.contentLength || 0)); res.setHeader("Cache-Control", "private, max-age=60"); res.setHeader("X-Content-Type-Options", "nosniff"); if (responseContentType === SVG_CONTENT_TYPE) { res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'"); } const filename = attachment.originalFilename ?? "attachment"; const disposition = isInlineAttachmentContentType(responseContentType) ? "inline" : "attachment"; res.setHeader("Content-Disposition", `${disposition}; filename=\"${filename.replaceAll("\"", "")}\"`); object.stream.on("error", (err) => { next(err); }); object.stream.pipe(res); }); router.delete("/attachments/:attachmentId", async (req, res) => { const attachmentId = req.params.attachmentId as string; const attachment = await svc.getAttachmentById(attachmentId); if (!attachment) { res.status(404).json({ error: "Attachment not found" }); return; } assertCompanyAccess(req, attachment.companyId); const issue = await svc.getById(attachment.issueId); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; try { await storage.deleteObject(attachment.companyId, attachment.objectKey); } catch (err) { logger.warn({ err, attachmentId }, "storage delete failed while removing attachment"); } const removed = await svc.removeAttachment(attachmentId); if (!removed) { res.status(404).json({ error: "Attachment not found" }); return; } const actor = getActorInfo(req); await logActivity(db, { companyId: removed.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.attachment_removed", entityType: "issue", entityId: removed.issueId, details: { attachmentId: removed.id, }, }); res.json({ ok: true }); }); return router; }