import { randomUUID } from "node:crypto"; import { Router, type Request, type Response } from "express"; import multer from "multer"; import { z } from "zod"; import { and, desc, eq, inArray, notInArray } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { activityLog, executionWorkspaces, heartbeatRuns, issueExecutionDecisions, issueRelations, issues as issueRows, projectWorkspaces, } from "@paperclipai/db"; import { addIssueCommentSchema, acceptIssueThreadInteractionSchema, cancelIssueThreadInteractionSchema, companySearchQuerySchema, createIssueAttachmentMetadataSchema, createIssueThreadInteractionSchema, createIssueWorkProductSchema, createIssueLabelSchema, checkoutIssueSchema, createChildIssueSchema, createIssueSchema, resolveCreateIssueStatusDefault, resolveIssueRecoveryActionSchema, feedbackTargetTypeSchema, feedbackTraceStatusSchema, feedbackVoteValueSchema, upsertIssueFeedbackVoteSchema, linkIssueApprovalSchema, issueDocumentKeySchema, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, rejectIssueThreadInteractionSchema, restoreIssueDocumentRevisionSchema, respondIssueThreadInteractionSchema, updateIssueWorkProductSchema, upsertIssueDocumentSchema, updateIssueSchema, getClosedIsolatedExecutionWorkspaceMessage, isClosedIsolatedExecutionWorkspace, normalizeIssueIdentifier as normalizeIssueReferenceIdentifier, type CompanySearchQuery, type CompanySearchResponse, type ExecutionWorkspace, type IssueRelationIssueSummary, type SuccessfulRunHandoffState, } 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 * as serviceIndex from "../services/index.js"; import { accessService, agentService, companyService, companySearchService, executionWorkspaceService, goalService, heartbeatService, issueApprovalService, issueRecoveryActionService, issueThreadInteractionService, 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, unprocessable } from "../errors.js"; import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; import { assertNoAgentHostWorkspaceCommandMutation, collectIssueWorkspaceCommandPaths, } from "./workspace-command-authz.js"; import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js"; import { isInlineAttachmentContentType, normalizeIssueAttachmentMaxBytes, normalizeContentType, SVG_CONTENT_TYPE, } from "../attachment-types.js"; import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js"; import { assertEnvironmentSelectionForCompany } from "./environment-selection.js"; import { executionWorkspaceService as executionWorkspaceServiceDirect } from "../services/execution-workspaces.js"; import { feedbackService } from "../services/feedback.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { environmentService } from "../services/environments.js"; import { redactSensitiveText } from "../redaction.js"; import { createCompanySearchRateLimiter, type CompanySearchRateLimiter, } from "../services/company-search-rate-limit.js"; import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy, parseIssueExecutionState, redactIssueMonitorExternalRef, setIssueExecutionPolicyMonitorScheduledBy, } from "../services/issue-execution-policy.js"; import { parseIssueExecutionWorkspaceSettings } from "../services/execution-workspace-policy.js"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; const MAX_ISSUE_COMMENT_LIMIT = 500; const updateIssueRouteSchema = updateIssueSchema.extend({ interrupt: z.boolean().optional(), }); type ParsedExecutionState = NonNullable>; type NormalizedExecutionPolicy = NonNullable>; type IssueRouteSnapshot = typeof issueRows.$inferSelect; type RecoveryRevalidationTrigger = | "issue_update" | "comment" | "document" | "work_product" | "read_projection"; type CompanySearchService = { search(companyId: string, query: CompanySearchQuery): Promise; }; 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"]; reviewRequest: ParsedExecutionState["reviewRequest"]; lastDecisionOutcome: ParsedExecutionState["lastDecisionOutcome"]; allowedActions: string[]; }; type SuccessfulRunHandoffActivityRow = { entityId: string; action: string; agentId: string | null; runId: string | null; details: Record | null; createdAt: Date; }; function applyCreateIssueStatusDefault(req: Request, res: Response, next: () => void) { if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) { next(); return; } const resolution = resolveCreateIssueStatusDefault(req.body as Record); res.locals.createIssueStatusDefault = resolution; if (resolution.defaulted) { req.body = { ...req.body, status: resolution.status, }; } next(); } function buildCreateIssueActivityStatusDetails( issue: { assigneeAgentId: string | null; status: string }, res: Response, ) { const statusDefault = res.locals.createIssueStatusDefault as | ReturnType | undefined; const assignmentWakeSkipped = !issue.assigneeAgentId || issue.status === "backlog"; return { status: issue.status, statusDefaulted: statusDefault?.defaulted ?? false, statusDefaultReason: statusDefault?.reason ?? "explicit", assignmentWakeSkipped, assignmentWakeSkipReason: assignmentWakeSkipped ? issue.assigneeAgentId ? "assigned_backlog" : "no_agent_assignee" : null, }; } const SUCCESSFUL_RUN_HANDOFF_ACTIONS = [ "issue.successful_run_handoff_required", "issue.successful_run_handoff_resolved", "issue.successful_run_handoff_escalated", ] as const; const ISSUE_WORKSPACE_AUDIT_FIELDS = new Set([ "projectWorkspaceId", "executionWorkspaceId", "executionWorkspacePreference", "executionWorkspaceSettings", ]); function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } function hasIssueWorkspaceAuditChange(previous: Record) { return Object.keys(previous).some((key) => ISSUE_WORKSPACE_AUDIT_FIELDS.has(key)); } function labelIssueWorkspaceMode(mode: string | null) { switch (mode) { case "shared_workspace": return "Project default"; case "isolated_workspace": return "New isolated workspace"; case "operator_branch": return "Operator branch"; case "reuse_existing": return "Reuse existing workspace"; case "agent_default": return "Agent default"; case "inherit": return "Inherited workspace"; default: return "No workspace"; } } type IssueWorkspaceAuditInput = { projectWorkspaceId?: string | null; executionWorkspaceId?: string | null; executionWorkspacePreference?: string | null; executionWorkspaceSettings?: unknown; }; type WorkspaceNameMaps = { projectWorkspaceNames: Map; executionWorkspaceNames: Map; }; function emptyWorkspaceNameMaps(): WorkspaceNameMaps { return { projectWorkspaceNames: new Map(), executionWorkspaceNames: new Map(), }; } function summarizeIssueWorkspaceForActivity( issue: IssueWorkspaceAuditInput, names: WorkspaceNameMaps, ) { const settings = parseIssueExecutionWorkspaceSettings(issue.executionWorkspaceSettings); const mode = settings?.mode ?? issue.executionWorkspacePreference ?? null; const executionWorkspaceId = issue.executionWorkspaceId ?? null; const projectWorkspaceId = issue.projectWorkspaceId ?? null; const label = (() => { if (executionWorkspaceId) { return names.executionWorkspaceNames.get(executionWorkspaceId) ?? `Workspace ${executionWorkspaceId.slice(0, 8)}`; } if (projectWorkspaceId) { return names.projectWorkspaceNames.get(projectWorkspaceId) ?? `Workspace ${projectWorkspaceId.slice(0, 8)}`; } return labelIssueWorkspaceMode(mode); })(); return { label, projectWorkspaceId, executionWorkspaceId, mode, }; } async function buildIssueWorkspaceChangeActivityDetails( db: Db, companyId: string, previousIssue: IssueWorkspaceAuditInput, nextIssue: IssueWorkspaceAuditInput, ) { const projectWorkspaceIds = [ previousIssue.projectWorkspaceId, nextIssue.projectWorkspaceId, ].filter((value): value is string => typeof value === "string" && value.length > 0); const executionWorkspaceIds = [ previousIssue.executionWorkspaceId, nextIssue.executionWorkspaceId, ].filter((value): value is string => typeof value === "string" && value.length > 0); const [projectRows, executionRows] = await Promise.all([ projectWorkspaceIds.length > 0 ? db .select({ id: projectWorkspaces.id, name: projectWorkspaces.name }) .from(projectWorkspaces) .where(and(eq(projectWorkspaces.companyId, companyId), inArray(projectWorkspaces.id, projectWorkspaceIds))) : Promise.resolve([]), executionWorkspaceIds.length > 0 ? db .select({ id: executionWorkspaces.id, name: executionWorkspaces.name }) .from(executionWorkspaces) .where(and(eq(executionWorkspaces.companyId, companyId), inArray(executionWorkspaces.id, executionWorkspaceIds))) : Promise.resolve([]), ]); const names: WorkspaceNameMaps = { projectWorkspaceNames: new Map(projectRows.map((row) => [row.id, row.name])), executionWorkspaceNames: new Map(executionRows.map((row) => [row.id, row.name])), }; return { from: summarizeIssueWorkspaceForActivity(previousIssue, names), to: summarizeIssueWorkspaceForActivity(nextIssue, names), }; } function hasExecutionParticipant(value: unknown) { const state = parseIssueExecutionState(value); if (!state || state.status !== "pending") return false; const participant = state.currentParticipant; if (!participant) return false; if (participant.type === "agent") return Boolean(participant.agentId); if (participant.type === "user") return Boolean(participant.userId); return false; } function hasScheduledMonitor(input: { existingMonitorNextCheckAt?: Date | null; patchMonitorNextCheckAt?: unknown; executionPolicy?: unknown; }) { if (input.patchMonitorNextCheckAt instanceof Date && !Number.isNaN(input.patchMonitorNextCheckAt.getTime())) return true; if (input.patchMonitorNextCheckAt === undefined && input.existingMonitorNextCheckAt) return true; const policy = normalizeIssueExecutionPolicy(input.executionPolicy ?? null); return Boolean(policy?.monitor?.nextCheckAt); } function successfulRunHandoffStateFromActivity(row: { action: string; agentId: string | null; runId: string | null; details: Record | null; createdAt: Date; }): SuccessfulRunHandoffState | null { const details = row.details ?? {}; const state = row.action === "issue.successful_run_handoff_required" ? "required" : row.action === "issue.successful_run_handoff_resolved" ? "resolved" : row.action === "issue.successful_run_handoff_escalated" ? "escalated" : null; if (!state) return null; const detectedProgressSummary = readNonEmptyString(details.detectedProgressSummary) ?? readNonEmptyString(details.detected_progress_summary) ?? null; return { state, required: state === "required", sourceRunId: readNonEmptyString(details.sourceRunId) ?? readNonEmptyString(details.source_run_id) ?? readNonEmptyString(details.resumeFromRunId) ?? row.runId ?? null, correctiveRunId: readNonEmptyString(details.correctiveRunId) ?? readNonEmptyString(details.corrective_run_id) ?? (state !== "required" ? row.runId : null), assigneeAgentId: readNonEmptyString(details.assigneeAgentId) ?? readNonEmptyString(details.agentId) ?? row.agentId ?? null, detectedProgressSummary: detectedProgressSummary ? redactSensitiveText(detectedProgressSummary) : null, createdAt: row.createdAt, }; } async function listSuccessfulRunHandoffStates( db: Db, companyId: string, issueIds: string[], ): Promise> { if (issueIds.length === 0) return new Map(); const rows = await db .select({ entityId: activityLog.entityId, action: activityLog.action, agentId: activityLog.agentId, runId: activityLog.runId, details: activityLog.details, createdAt: activityLog.createdAt, }) .from(activityLog) .where(and( eq(activityLog.companyId, companyId), eq(activityLog.entityType, "issue"), inArray(activityLog.entityId, issueIds), inArray(activityLog.action, [...SUCCESSFUL_RUN_HANDOFF_ACTIONS]), )) .orderBy(activityLog.entityId, desc(activityLog.createdAt), desc(activityLog.id)) as SuccessfulRunHandoffActivityRow[]; const states = new Map(); for (const row of rows) { if (states.has(row.entityId)) continue; const state = successfulRunHandoffStateFromActivity(row); if (state) states.set(row.entityId, state); } return states; } type RecoveryActionsLister = { listActiveForIssues: ( companyId: string, sourceIssueIds: string[], ) => Promise>>; }; async function relationRecoveryActionMap( recoveryActionsSvc: RecoveryActionsLister, companyId: string, relations: { blockedBy: IssueRelationIssueSummary[]; blocks: IssueRelationIssueSummary[] }, ): Promise>> { const candidates: IssueRelationIssueSummary[] = []; const visit = (summary: IssueRelationIssueSummary) => { candidates.push(summary); for (const terminal of summary.terminalBlockers ?? []) { visit(terminal); } }; for (const blocker of relations.blockedBy) visit(blocker); for (const blocking of relations.blocks) visit(blocking); if (candidates.length === 0) return new Map(); const ids = [...new Set(candidates.map((summary) => summary.id))]; return recoveryActionsSvc.listActiveForIssues(companyId, ids); } function withRecoveryActionsOnRelationSummaries( relations: { blockedBy: IssueRelationIssueSummary[]; blocks: IssueRelationIssueSummary[] }, recoveryActionByIssueId: Map>, ) { const augment = (summary: IssueRelationIssueSummary): IssueRelationIssueSummary => ({ ...summary, activeRecoveryAction: recoveryActionByIssueId.get(summary.id) ?? summary.activeRecoveryAction ?? null, terminalBlockers: summary.terminalBlockers?.map(augment), }); return { blockedBy: relations.blockedBy.map(augment), blocks: relations.blocks.map(augment), }; } const ACTIVE_REVIEW_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]); const INVALID_AGENT_IN_REVIEW_DISPOSITION_MESSAGE = "invalid_issue_disposition: Agent-authored updates that move an issue to in_review must include a real review path. " + "This request would leave the issue in_review without anyone or anything owning the next action. " + "Keep working instead of moving to review, create a request_confirmation or ask_user_questions interaction, " + "link or request a pending approval, assign a human reviewer with assigneeUserId, set a typed executionState.currentParticipant through an execution policy, " + "or schedule an issue monitor for an external review/check. After creating one of those review paths, retry the status update."; 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, reviewRequest: input.state.reviewRequest ?? null, 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, }; } const defaultCompanySearchRateLimiter = createCompanySearchRateLimiter(); function companySearchRateLimitActor(req: Request, companyId: string) { if (req.actor.type === "agent") { return { companyId, actorType: "agent" as const, actorId: req.actor.agentId ?? req.actor.keyId ?? "unknown-agent", }; } return { companyId, actorType: "board" as const, actorId: req.actor.userId ?? req.actor.source ?? "board", }; } 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 monitorPoliciesEqual(left: NormalizedExecutionPolicy | null, right: NormalizedExecutionPolicy | null) { return JSON.stringify(left?.monitor ?? null) === JSON.stringify(right?.monitor ?? null); } function applyActorMonitorScheduledBy( policy: NormalizedExecutionPolicy | null, actorType: "agent" | "user", ) { return setIssueExecutionPolicyMonitorScheduledBy(policy, actorType === "user" ? "board" : "assignee"); } function assertCanManageIssueMonitor(req: Request, assigneeAgentId: string | null, monitorChanged: boolean) { if (!monitorChanged) return; if (req.actor.type === "board") return; if (req.actor.type === "agent" && req.actor.agentId && req.actor.agentId === assigneeAgentId) return; throw forbidden("Only the assignee agent or a board user can manage issue monitors"); } function summarizeIssueMonitor( issue: { monitorNextCheckAt?: Date | null; monitorLastTriggeredAt?: Date | null; monitorAttemptCount?: number | null; monitorNotes?: string | null; monitorScheduledBy?: string | null; executionState?: unknown; }, policy: NormalizedExecutionPolicy | null, ) { const state = parseIssueExecutionState(issue.executionState); return { nextCheckAt: issue.monitorNextCheckAt?.toISOString() ?? policy?.monitor?.nextCheckAt ?? null, lastTriggeredAt: issue.monitorLastTriggeredAt?.toISOString() ?? state?.monitor?.lastTriggeredAt ?? null, attemptCount: issue.monitorAttemptCount ?? state?.monitor?.attemptCount ?? 0, notes: policy?.monitor?.notes ?? issue.monitorNotes ?? state?.monitor?.notes ?? null, scheduledBy: issue.monitorScheduledBy ?? policy?.monitor?.scheduledBy ?? state?.monitor?.scheduledBy ?? null, kind: policy?.monitor?.kind ?? state?.monitor?.kind ?? null, serviceName: policy?.monitor?.serviceName ?? state?.monitor?.serviceName ?? null, externalRef: redactIssueMonitorExternalRef(policy?.monitor?.externalRef ?? state?.monitor?.externalRef ?? null), timeoutAt: policy?.monitor?.timeoutAt ?? state?.monitor?.timeoutAt ?? null, maxAttempts: policy?.monitor?.maxAttempts ?? state?.monitor?.maxAttempts ?? null, recoveryPolicy: policy?.monitor?.recoveryPolicy ?? state?.monitor?.recoveryPolicy ?? null, status: state?.monitor?.status ?? (policy?.monitor ? "scheduled" : null), clearReason: state?.monitor?.clearReason ?? null, }; } 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 shouldImplicitlyMoveCommentedIssueToTodo(input: { issueStatus: string | null | undefined; assigneeAgentId: string | null | undefined; actorType: "agent" | "user"; actorId: string; }) { // Only human comments should implicitly reopen finished work. // Agent-authored comments remain communicative unless reopen was explicit. if (input.actorType !== "user") return false; if (!isClosedIssueStatus(input.issueStatus) && input.issueStatus !== "blocked") return false; if (typeof input.assigneeAgentId !== "string" || input.assigneeAgentId.length === 0) return false; return true; } function isExplicitResumeCapableStatus(status: string | null | undefined) { return status === "done" || status === "blocked" || status === "todo" || status === "in_progress"; } function queueResolvedInteractionContinuationWakeup(input: { heartbeat: ReturnType; issue: { id: string; assigneeAgentId: string | null; status: string }; interaction: { id: string; kind: string; status: string; continuationPolicy: string; sourceCommentId?: string | null; sourceRunId?: string | null; }; actor: { actorType: "user" | "agent"; actorId: string }; source: string; forceFreshSession?: boolean; workspaceRefreshReason?: string | null; }) { if ( input.interaction.continuationPolicy !== "wake_assignee" && input.interaction.continuationPolicy !== "wake_assignee_on_accept" ) return; if ( input.interaction.continuationPolicy === "wake_assignee_on_accept" && input.interaction.status !== "accepted" ) return; if (input.interaction.status === "expired") return; if (!input.issue.assigneeAgentId || isClosedIssueStatus(input.issue.status)) return; const forceFreshSession = input.forceFreshSession === true; const workspaceRefreshReason = readNonEmptyString(input.workspaceRefreshReason); void input.heartbeat.wakeup(input.issue.assigneeAgentId, { source: "automation", triggerDetail: "system", reason: "issue_commented", payload: { issueId: input.issue.id, interactionId: input.interaction.id, interactionKind: input.interaction.kind, interactionStatus: input.interaction.status, sourceCommentId: input.interaction.sourceCommentId ?? null, sourceRunId: input.interaction.sourceRunId ?? null, mutation: "interaction", }, requestedByActorType: input.actor.actorType, requestedByActorId: input.actor.actorId, contextSnapshot: { issueId: input.issue.id, taskId: input.issue.id, interactionId: input.interaction.id, interactionKind: input.interaction.kind, interactionStatus: input.interaction.status, sourceCommentId: input.interaction.sourceCommentId ?? null, sourceRunId: input.interaction.sourceRunId ?? null, wakeReason: "issue_commented", source: input.source, ...(forceFreshSession ? { forceFreshSession: true } : {}), ...(workspaceRefreshReason ? { workspaceRefreshReason } : {}), }, }).catch((err) => logger.warn({ err, issueId: input.issue.id, interactionId: input.interaction.id, agentId: input.issue.assigneeAgentId, }, "failed to wake assignee on issue interaction resolution")); } 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; }; searchService?: CompanySearchService; searchRateLimiter?: CompanySearchRateLimiter; pluginWorkerManager?: PluginWorkerManager; } = {}, ) { const router = Router(); const svc = issueService(db); const access = accessService(db); const heartbeat = heartbeatService(db, { pluginWorkerManager: opts.pluginWorkerManager, }); const feedback = feedbackService(db); const companiesSvc = companyService(db); let searchSvc = opts.searchService ?? null; const getSearchService = () => { searchSvc ??= companySearchService(db); return searchSvc; }; const searchRateLimiter = opts.searchRateLimiter ?? defaultCompanySearchRateLimiter; const instanceSettings = instanceSettingsService(db); const agentsSvc = agentService(db); const projectsSvc = projectService(db); const goalsSvc = goalService(db); const issueApprovalsSvc = issueApprovalService(db); const recoveryActionsSvc = issueRecoveryActionService(db); const executionWorkspacesSvc = executionWorkspaceServiceDirect(db); const workProductsSvc = workProductService(db); const documentsSvc = documentService(db); const issueReferencesSvc = issueReferenceService(db); const issueThreadInteractionsSvc = issueThreadInteractionService(db); const routinesSvc = routineService(db, { pluginWorkerManager: opts.pluginWorkerManager, }); const issueTreeControlFactory = Object.prototype.hasOwnProperty.call( serviceIndex, "issueTreeControlService", ) ? serviceIndex.issueTreeControlService : undefined; const treeControlSvc = issueTreeControlFactory?.(db) ?? { getActivePauseHoldGate: async () => null, }; const feedbackExportService = opts?.feedbackExportService; const environmentsSvc = environmentService(db); async function classifySourceRecoveryRevalidation(input: { issue: IssueRouteSnapshot; trigger: RecoveryRevalidationTrigger; statusChanged?: boolean; assigneeChanged?: boolean; blockersChanged?: boolean; executionPolicyChanged?: boolean; monitorChanged?: boolean; documentChanged?: boolean; workProductChanged?: boolean; resumeRequested?: boolean; reopened?: boolean; blockedToTodoRecovery?: boolean; }): Promise { const { issue } = input; if (issue.status === "done" || issue.status === "cancelled") { return `Recovery action became stale because the source issue reached ${issue.status}.`; } if (input.blockedToTodoRecovery === true) { return "Recovery action became stale because the source issue was manually moved from blocked to todo."; } if (input.trigger === "read_projection") return null; if ( input.trigger === "comment" && input.resumeRequested !== true && input.reopened !== true && input.statusChanged !== true ) { return null; } const durableSourceChange = input.statusChanged === true || input.assigneeChanged === true || input.blockersChanged === true || input.executionPolicyChanged === true || input.monitorChanged === true || input.documentChanged === true || input.workProductChanged === true || input.resumeRequested === true || input.reopened === true; if (!durableSourceChange) return null; if (issue.status === "blocked") { const readiness = await svc.getDependencyReadiness(issue.id); if (readiness.unresolvedBlockerCount > 0) { return "Recovery action became stale because the source issue now has unresolved first-class blockers."; } return null; } if (issue.assigneeUserId && issue.status !== "done" && issue.status !== "cancelled") { return "Recovery action became stale because the source issue now has a human owner."; } if ((issue.status === "todo" || issue.status === "in_progress") && issue.assigneeAgentId) { return `Recovery action became stale because the source issue is ${issue.status} with an agent owner.`; } if (issue.status === "in_review") { const executionState = parseIssueExecutionState(issue.executionState); const participant = executionState?.status === "pending" ? executionState.currentParticipant : null; if ( (participant?.type === "agent" && readNonEmptyString(participant.agentId)) || (participant?.type === "user" && readNonEmptyString(participant.userId)) ) { return "Recovery action became stale because the source issue now has a typed review participant."; } const interactions = await issueThreadInteractionsSvc.listForIssue(issue.id); if (interactions.some((interaction) => interaction.status === "pending")) { return "Recovery action became stale because the source issue now has a pending issue interaction."; } const approvals = await issueApprovalsSvc.listApprovalsForIssue(issue.id); if (approvals.some((approval) => approval.status === "pending" || approval.status === "revision_requested")) { return "Recovery action became stale because the source issue now has a pending approval."; } } const monitor = summarizeIssueMonitor(issue, normalizeIssueExecutionPolicy(issue.executionPolicy ?? null)); if (monitor.nextCheckAt && Date.parse(monitor.nextCheckAt) > Date.now()) { return "Recovery action became stale because the source issue now has a scheduled monitor."; } return null; } async function revalidateActiveSourceRecovery(input: { issue: IssueRouteSnapshot; trigger: RecoveryRevalidationTrigger; actor?: ReturnType | null; activeRecoveryAction?: Awaited> | null; statusChanged?: boolean; assigneeChanged?: boolean; blockersChanged?: boolean; executionPolicyChanged?: boolean; monitorChanged?: boolean; documentChanged?: boolean; workProductChanged?: boolean; resumeRequested?: boolean; reopened?: boolean; blockedToTodoRecovery?: boolean; }) { const activeRecoveryAction = input.activeRecoveryAction === undefined ? await recoveryActionsSvc.getActiveForIssue(input.issue.companyId, input.issue.id) : input.activeRecoveryAction; if (!activeRecoveryAction) return null; const resolutionNote = await classifySourceRecoveryRevalidation(input); if (!resolutionNote) return activeRecoveryAction; const resolved = await recoveryActionsSvc.resolveActiveForIssue({ companyId: input.issue.companyId, sourceIssueId: input.issue.id, actionId: activeRecoveryAction.id, status: "cancelled", outcome: "cancelled", resolutionNote, }); if (!resolved) return activeRecoveryAction; const actor = input.actor; await logActivity(db, { companyId: input.issue.companyId, actorType: actor?.actorType ?? "system", actorId: actor?.actorId ?? "system", agentId: actor?.agentId ?? null, runId: actor?.runId ?? null, action: "issue.recovery_action_resolved", entityType: "issue", entityId: input.issue.id, details: { identifier: input.issue.identifier, recoveryActionId: resolved.id, recoveryActionStatus: resolved.status, outcome: resolved.outcome, sourceIssueStatus: input.issue.status, resolutionNote: resolved.resolutionNote, source: "source_revalidation", trigger: input.trigger, }, }); return null; } async function revalidateActiveSourceRecoveryForRead(input: Parameters[0]) { try { return await revalidateActiveSourceRecovery(input); } catch (err) { logger.warn( { err, issueId: input.issue.id, trigger: input.trigger }, "failed to revalidate recovery action during read projection", ); return input.activeRecoveryAction ?? null; } } async function revalidateActiveSourceRecoveryAfterCommittedWrite( input: Parameters[0], ) { try { return await revalidateActiveSourceRecovery(input); } catch (err) { logger.warn( { err, issueId: input.issue.id, trigger: input.trigger }, "failed to revalidate recovery action after committed issue write", ); return input.activeRecoveryAction ?? null; } } function withContentPath(attachment: T) { return { ...attachment, contentPath: `/api/attachments/${attachment.id}/content`, }; } function parseBooleanQuery(value: unknown) { return value === true || value === "true" || value === "1"; } async function assertIssueEnvironmentSelection( companyId: string, environmentId: string | null | undefined, ) { if (environmentId === undefined || environmentId === null) return; await assertEnvironmentSelectionForCompany( environmentsSvc, companyId, environmentId, { allowedDrivers: ["local", "ssh", "sandbox"] }, ); } async function assertAgentInReviewReviewPath(input: { existing: { id: string; companyId: string; status: string; assigneeUserId?: string | null; executionState?: unknown; monitorNextCheckAt?: Date | null; }; updateFields: Record; actorType: string; }) { const nextStatus = typeof input.updateFields.status === "string" ? input.updateFields.status : input.existing.status; if (input.actorType !== "agent" || input.existing.status === "in_review" || nextStatus !== "in_review") return; const nextAssigneeUserId = input.updateFields.assigneeUserId === undefined ? input.existing.assigneeUserId : input.updateFields.assigneeUserId; if (typeof nextAssigneeUserId === "string" && nextAssigneeUserId.trim().length > 0) return; const nextExecutionState = input.updateFields.executionState === undefined ? input.existing.executionState : input.updateFields.executionState; if (hasExecutionParticipant(nextExecutionState)) return; const nextExecutionPolicy = input.updateFields.executionPolicy; if (hasScheduledMonitor({ existingMonitorNextCheckAt: input.existing.monitorNextCheckAt ?? null, patchMonitorNextCheckAt: input.updateFields.monitorNextCheckAt, executionPolicy: nextExecutionPolicy, })) return; const interactions = await issueThreadInteractionService(db).listForIssue(input.existing.id); if (interactions.some((interaction) => interaction.status === "pending")) return; const approvals = await issueApprovalsSvc.listApprovalsForIssue(input.existing.id); if (approvals.some((approval) => ACTIVE_REVIEW_APPROVAL_STATUSES.has(String(approval.status)))) return; throw unprocessable(INVALID_AGENT_IN_REVIEW_DISPOSITION_MESSAGE, { code: "invalid_issue_disposition", missing: "review_path", validReviewPaths: [ "pending_issue_thread_interaction", "linked_pending_approval", "human_assignee_user_id", "typed_execution_state_current_participant", "scheduled_issue_monitor", ], }); } async function logExpiredRequestConfirmations(input: { issue: { id: string; companyId: string; identifier?: string | null }; interactions: Array<{ id: string; kind: string; status: string; result?: unknown }>; actor: ReturnType; source: string; }) { for (const interaction of input.interactions) { await logActivity(db, { companyId: input.issue.companyId, actorType: input.actor.actorType, actorId: input.actor.actorId, agentId: input.actor.agentId, runId: input.actor.runId, action: "issue.thread_interaction_expired", entityType: "issue", entityId: input.issue.id, details: { identifier: input.issue.identifier ?? null, interactionId: interaction.id, interactionKind: interaction.kind, interactionStatus: interaction.status, source: input.source, result: interaction.result ?? null, }, }); } } 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, fileSizeLimit: number) { const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: fileSizeLimit, files: 1 }, }); 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.assigneeAgentId === null) { return true; } if (issue.assigneeAgentId !== actorAgentId) { if (await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, issue.assigneeAgentId)) { return true; } if (issue.status === "in_progress") { res.status(409).json({ error: "Issue is checked out by another agent", details: { issueId: issue.id, assigneeAgentId: issue.assigneeAgentId, actorAgentId, }, }); } else { res.status(403).json({ error: "Agent cannot mutate another agent's issue", details: { issueId: issue.id, assigneeAgentId: issue.assigneeAgentId, actorAgentId, status: issue.status, securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"], }, }); } return false; } if (issue.status !== "in_progress") { return true; } 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; } function isStatusOnlyCheapRecoveryContext(contextSnapshot: unknown) { if (!contextSnapshot || typeof contextSnapshot !== "object" || Array.isArray(contextSnapshot)) return false; const context = contextSnapshot as Record; return context.modelProfile === "cheap" && context.recoveryIntent === "status_only" && context.allowDeliverableWork === false && context.allowDocumentUpdates === false && context.resumeRequiresNormalModel === true; } function requestsCheapIssueAssigneeModelProfile(input: { assigneeAdapterOverrides?: unknown }) { const overrides = input.assigneeAdapterOverrides; return !!overrides && typeof overrides === "object" && !Array.isArray(overrides) && (overrides as Record).modelProfile === "cheap"; } async function loadActorRunContext(req: Request, companyId: string) { if (req.actor.type !== "agent") return null; const runId = req.actor.runId?.trim(); if (!runId) return null; const run = await db .select({ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId, contextSnapshot: heartbeatRuns.contextSnapshot, }) .from(heartbeatRuns) .where(eq(heartbeatRuns.id, runId)) .then((rows) => rows[0] ?? null); if (!run || run.companyId !== companyId || run.agentId !== req.actor.agentId) return null; return run; } async function assertCheapRecoveryIssueAssigneeProfileAllowed( req: Request, res: Response, issue: { id?: string; companyId: string }, input: { assigneeAdapterOverrides?: unknown }, ) { if (!requestsCheapIssueAssigneeModelProfile(input)) return true; const run = await loadActorRunContext(req, issue.companyId); if (!run || !isStatusOnlyCheapRecoveryContext(run.contextSnapshot)) return true; res.status(403).json({ error: "Cheap status-only recovery runs cannot assign downstream issue work to the cheap model profile", details: { issueId: issue.id ?? null, runId: run.id, modelProfile: "cheap", recoveryIntent: "status_only", resumeRequiresNormalModel: true, }, }); return false; } async function assertDeliverableMutationAllowedByRunContext( req: Request, res: Response, issue: { id: string; companyId: string }, ) { const run = await loadActorRunContext(req, issue.companyId); if (!run) return true; if (!isStatusOnlyCheapRecoveryContext(run.contextSnapshot)) return true; res.status(403).json({ error: "Cheap status-only recovery runs cannot update issue documents, plans, or deliverable artifacts", details: { issueId: issue.id, runId: run.id, modelProfile: "cheap", recoveryIntent: "status_only", resumeRequiresNormalModel: true, }, }); return false; } function assertStructuredCommentFieldsAllowed( req: Request, res: Response, input: { presentation?: unknown; metadata?: unknown }, ) { const hasStructuredFields = input.presentation !== undefined || input.metadata !== undefined; if (!hasStructuredFields) return true; if (req.actor.type === "board") return true; res.status(403).json({ error: "Only board users may set structured comment presentation or metadata", details: { securityPrinciples: ["Least Privilege", "Secure Defaults", "Complete Mediation"], }, }); return false; } async function assertExplicitResumeIntentAllowed( req: Request, res: Response, issue: { id: string; companyId: string; status: string; assigneeAgentId: string | null }, ) { if (issue.status === "cancelled") { res.status(409).json({ error: "Cancelled issues must be restored through the dedicated restore flow", details: { issueId: issue.id, status: issue.status, }, }); return false; } if (!isExplicitResumeCapableStatus(issue.status)) { res.status(409).json({ error: "Issue is not resumable through comment follow-up intent", details: { issueId: issue.id, status: issue.status }, }); return false; } const activePauseHold = await treeControlSvc.getActivePauseHoldGate(issue.companyId, issue.id); if (activePauseHold) { res.status(409).json({ error: "Issue follow-up blocked by active subtree pause hold", details: { issueId: issue.id, holdId: activePauseHold.holdId, rootIssueId: activePauseHold.rootIssueId, mode: activePauseHold.mode, }, }); return false; } if (issue.status === "blocked") { const readiness = await svc.getDependencyReadiness(issue.id); if (readiness.unresolvedBlockerCount > 0) { res.status(409).json({ error: "Issue follow-up blocked by unresolved blockers", details: { issueId: issue.id, unresolvedBlockerIssueIds: readiness.unresolvedBlockerIssueIds, }, }); return false; } } 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.assigneeAgentId) { res.status(409).json({ error: "Issue follow-up requires an assigned agent", details: { issueId: issue.id, actorAgentId }, }); return false; } if (issue.assigneeAgentId === actorAgentId) return true; if (await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, issue.assigneeAgentId)) { return true; } res.status(403).json({ error: "Agent cannot request follow-up for another agent's issue", details: { issueId: issue.id, assigneeAgentId: issue.assigneeAgentId, actorAgentId, }, }); return false; } async function assertRecoveryActionAuthority( req: Request, res: Response, issue: { id: string; companyId: string; assigneeAgentId: string | null }, activeRecoveryAction: Awaited>, input: { source: "issue_update" | "recovery_action_resolution" }, ) { if (req.actor.type !== "agent") return true; if (!activeRecoveryAction) return true; const actorAgentId = req.actor.agentId; if (!actorAgentId) { res.status(403).json({ error: "Agent authentication required" }); return false; } if (issue.assigneeAgentId === actorAgentId) return true; if ( issue.assigneeAgentId && await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, issue.assigneeAgentId) ) { return true; } if (activeRecoveryAction.ownerAgentId === actorAgentId) return true; if ( activeRecoveryAction.ownerAgentId && await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, activeRecoveryAction.ownerAgentId) ) { return true; } res.status(403).json({ error: "Agent cannot resolve another owner's recovery action", details: { issueId: issue.id, recoveryActionId: activeRecoveryAction.id, actorAgentId, assigneeAgentId: issue.assigneeAgentId, recoveryOwnerAgentId: activeRecoveryAction.ownerAgentId, source: input.source, securityPrinciples: ["Least Privilege", "Complete Mediation", "Secure Defaults"], }, }); return false; } 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 resolveIssueRouteId(rawId: string): Promise { const identifier = normalizeIssueReferenceIdentifier(rawId); if (identifier) { const issue = await svc.getByIdentifier(identifier); 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 resolveIssueRouteId(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 resolveIssueRouteId(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/search", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const query = companySearchQuerySchema.parse(req.query); const rateLimit = searchRateLimiter.consume(companySearchRateLimitActor(req, companyId)); res.setHeader("X-RateLimit-Limit", String(rateLimit.limit)); res.setHeader("X-RateLimit-Remaining", String(rateLimit.remaining)); if (!rateLimit.allowed) { res.setHeader("Retry-After", String(rateLimit.retryAfterSeconds)); res.status(429).json({ error: "Search rate limit exceeded", retryAfterSeconds: rateLimit.retryAfterSeconds, }); return; } const result = await getSearchService().search(companyId, query); res.json(result); }); 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); const rawOffset = req.query.offset as string | undefined; const parsedOffset = rawOffset !== undefined && /^\d+$/.test(rawOffset) ? Number.parseInt(rawOffset, 10) : null; const attention = req.query.attention as string | undefined; 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 (attention !== undefined && attention !== "blocked") { res.status(400).json({ error: "attention must be 'blocked' when provided" }); 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; } if (rawOffset !== undefined && (parsedOffset === null || !Number.isInteger(parsedOffset) || parsedOffset < 0)) { res.status(400).json({ error: "offset must be a non-negative integer" }); return; } const offset = parsedOffset ?? 0; const result = await svc.list(companyId, { attention: attention === "blocked" ? "blocked" : undefined, 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, workspaceId: req.query.workspaceId as string | undefined, executionWorkspaceId: req.query.executionWorkspaceId as string | undefined, parentId: req.query.parentId as string | undefined, descendantOf: req.query.descendantOf as string | undefined, labelId: req.query.labelId as string | undefined, originKind: req.query.originKind as string | undefined, originKindPrefix: req.query.originKindPrefix 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", includePluginOperations: req.query.includePluginOperations === "true" || req.query.includePluginOperations === "1", includeBlockedBy: req.query.includeBlockedBy === "true" || req.query.includeBlockedBy === "1", includeBlockedInboxAttention: req.query.includeBlockedInboxAttention === "true" || req.query.includeBlockedInboxAttention === "1", q: req.query.q as string | undefined, limit, offset, }); const issueIds = result.map((issue) => issue.id); const [handoffStates, recoveryActionByIssue] = await Promise.all([ listSuccessfulRunHandoffStates(db, companyId, issueIds), recoveryActionsSvc.listActiveForIssues(companyId, issueIds), ]); const actor = getActorInfo(req); await Promise.all(result.map(async (issue) => { const activeRecoveryAction = recoveryActionByIssue.get(issue.id) ?? null; if (!activeRecoveryAction) return; const revalidated = await revalidateActiveSourceRecoveryForRead({ issue, trigger: "read_projection", actor, activeRecoveryAction, }); if (revalidated) recoveryActionByIssue.set(issue.id, revalidated); else recoveryActionByIssue.delete(issue.id); })); res.json(result.map((issue) => ({ ...issue, successfulRunHandoff: handoffStates.get(issue.id) ?? null, activeRecoveryAction: recoveryActionByIssue.get(issue.id) ?? null, }))); }); router.get("/companies/:companyId/issues/count", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const attention = req.query.attention as string | undefined; if (attention !== "blocked") { res.status(400).json({ error: "issues/count currently requires attention=blocked" }); return; } if (req.query.limit !== undefined || req.query.offset !== undefined) { res.status(400).json({ error: "issues/count does not accept limit or offset" }); return; } const count = await svc.count(companyId, { attention: "blocked", status: req.query.status as string | undefined, assigneeAgentId: req.query.assigneeAgentId as string | undefined, participantAgentId: req.query.participantAgentId as string | undefined, assigneeUserId: req.query.assigneeUserId as string | undefined, projectId: req.query.projectId as string | undefined, workspaceId: req.query.workspaceId as string | undefined, executionWorkspaceId: req.query.executionWorkspaceId as string | undefined, parentId: req.query.parentId as string | undefined, descendantOf: req.query.descendantOf as string | undefined, labelId: req.query.labelId as string | undefined, originKind: req.query.originKind as string | undefined, originKindPrefix: req.query.originKindPrefix 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", includePluginOperations: req.query.includePluginOperations === "true" || req.query.includePluginOperations === "1", includeBlockedBy: true, includeBlockedInboxAttention: true, q: req.query.q as string | undefined, }); res.json({ count }); }); 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 currentExecutionWorkspacePromise = issue.executionWorkspaceId ? executionWorkspacesSvc.getById(issue.executionWorkspaceId) : Promise.resolve(null); const [ { project, goal }, ancestors, commentCursor, wakeComment, relations, blockerAttention, productivityReview, scheduledRetry, attachments, continuationSummary, currentExecutionWorkspace, activeRecoveryAction, ] = await Promise.all([ resolveIssueProjectAndGoal(issue), svc.getAncestors(issue.id), svc.getCommentCursor(issue.id), wakeCommentId ? svc.getComment(wakeCommentId) : null, svc.getRelationSummaries(issue.id), svc.listBlockerAttention(issue.companyId, [issue]).then((map) => map.get(issue.id) ?? null), svc.listProductivityReviews(issue.companyId, [issue.id]).then((map) => map.get(issue.id) ?? null), svc.getCurrentScheduledRetry(issue.id), svc.listAttachments(issue.id), documentsSvc.getIssueDocumentByKey(issue.id, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY), currentExecutionWorkspacePromise, recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id), ]); const recoveryActionsByRelationIssue = await relationRecoveryActionMap( recoveryActionsSvc, issue.companyId, relations, ); const relationsWithRecoveryActions = withRecoveryActionsOnRelationSummaries( relations, recoveryActionsByRelationIssue, ); const revalidatedActiveRecoveryAction = await revalidateActiveSourceRecoveryForRead({ issue, trigger: "read_projection", actor: getActorInfo(req), activeRecoveryAction, }); res.json({ issue: { id: issue.id, identifier: issue.identifier, title: issue.title, description: issue.description, status: issue.status, workMode: issue.workMode, ...(blockerAttention ? { blockerAttention } : {}), productivityReview, scheduledRetry, activeRecoveryAction: revalidatedActiveRecoveryAction, priority: issue.priority, projectId: issue.projectId, goalId: goal?.id ?? issue.goalId, parentId: issue.parentId, blockedBy: relationsWithRecoveryActions.blockedBy, blocks: relationsWithRecoveryActions.blocks, assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId, originKind: issue.originKind, originId: issue.originId, 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, currentExecutionWorkspace, }); }); 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, blockerAttention, productivityReview, referenceSummary, successfulRunHandoffStates, scheduledRetry, activeRecoveryAction, ] = await Promise.all([ resolveIssueProjectAndGoal(issue), svc.getAncestors(issue.id), svc.findMentionedProjectIds(issue.id, { includeCommentBodies: false }), documentsSvc.getIssueDocumentPayload(issue), svc.getRelationSummaries(issue.id), svc.listBlockerAttention(issue.companyId, [issue]).then((map) => map.get(issue.id) ?? null), svc.listProductivityReviews(issue.companyId, [issue.id]).then((map) => map.get(issue.id) ?? null), issueReferencesSvc.listIssueReferenceSummary(issue.id), listSuccessfulRunHandoffStates(db, issue.companyId, [issue.id]), svc.getCurrentScheduledRetry(issue.id), recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id), ]); const recoveryActionsByRelationIssue = await relationRecoveryActionMap( recoveryActionsSvc, issue.companyId, relations, ); const relationsWithRecoveryActions = withRecoveryActionsOnRelationSummaries( relations, recoveryActionsByRelationIssue, ); const revalidatedActiveRecoveryAction = await revalidateActiveSourceRecoveryForRead({ issue, trigger: "read_projection", actor: getActorInfo(req), activeRecoveryAction, }); 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, ...(blockerAttention ? { blockerAttention } : {}), productivityReview, successfulRunHandoff: successfulRunHandoffStates.get(issue.id) ?? null, scheduledRetry, activeRecoveryAction: revalidatedActiveRecoveryAction, blockedBy: relationsWithRecoveryActions.blockedBy, blocks: relationsWithRecoveryActions.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/recovery-actions", 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 active = await revalidateActiveSourceRecoveryForRead({ issue, trigger: "read_projection", actor: getActorInfo(req), }); res.json({ active, actions: active ? [active] : [], }); }); router.post("/issues/:id/recovery-actions/resolve", validate(resolveIssueRecoveryActionSchema), 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 activeRecoveryAction = await recoveryActionsSvc.getActiveForIssue(existing.companyId, existing.id); if ( !(await assertRecoveryActionAuthority( req, res, existing, activeRecoveryAction, { source: "recovery_action_resolution" }, )) ) { return; } const { actionId, outcome, sourceIssueStatus, resolutionNote } = req.body; if (outcome === "false_positive" || outcome === "cancelled") { assertBoard(req); } const actor = getActorInfo(req); const updateFields = sourceIssueStatus ? { status: sourceIssueStatus } : {}; await assertAgentInReviewReviewPath({ existing, updateFields, actorType: req.actor.type, }); const actionStatus = outcome === "cancelled" ? "cancelled" : "resolved"; const result = await db.transaction(async (tx) => { let issue = existing; if (outcome === "blocked") { const unresolvedBlockers = await tx .select({ id: issueRows.id }) .from(issueRelations) .innerJoin(issueRows, eq(issueRelations.issueId, issueRows.id)) .where( and( eq(issueRelations.companyId, existing.companyId), eq(issueRelations.relatedIssueId, existing.id), eq(issueRelations.type, "blocks"), notInArray(issueRows.status, ["done", "cancelled"]), ), ) .limit(1); if (unresolvedBlockers.length === 0) { throw unprocessable("Blocked recovery resolution requires an unresolved first-class blocker on the source issue"); } } if (sourceIssueStatus) { const updatedIssue = await svc.update( id, { status: sourceIssueStatus, actorAgentId: actor.agentId ?? null, actorUserId: actor.actorType === "user" ? actor.actorId : null, }, tx, ); if (!updatedIssue) throw notFound("Issue not found"); issue = updatedIssue; } const recoveryAction = await recoveryActionsSvc.resolveActiveForIssue( { companyId: existing.companyId, sourceIssueId: existing.id, actionId: actionId ?? null, status: actionStatus, outcome, resolutionNote: resolutionNote ?? null, }, tx, ); if (!recoveryAction) throw notFound("Active recovery action not found"); return { issue, recoveryAction }; }); await routinesSvc.syncRunStatusForIssue(result.issue.id); if (sourceIssueStatus && existing.status !== result.issue.status) { await logActivity(db, { companyId: result.issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.updated", entityType: "issue", entityId: result.issue.id, details: { identifier: result.issue.identifier, status: result.issue.status, source: "recovery_action_resolution", recoveryActionId: result.recoveryAction.id, _previous: { status: existing.status, }, }, }); } await logActivity(db, { companyId: result.issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.recovery_action_resolved", entityType: "issue", entityId: result.issue.id, details: { identifier: result.issue.identifier, recoveryActionId: result.recoveryAction.id, recoveryActionStatus: result.recoveryAction.status, outcome: result.recoveryAction.outcome, sourceIssueStatus: sourceIssueStatus ?? null, resolutionNote: result.recoveryAction.resolutionNote, }, }); if ( sourceIssueStatus === "todo" && existing.status !== result.issue.status && result.issue.assigneeAgentId ) { void heartbeat.wakeup(result.issue.assigneeAgentId, { source: "automation", triggerDetail: "system", reason: "issue_recovery_action_restored", payload: { issueId: result.issue.id, recoveryActionId: result.recoveryAction.id, mutation: "recovery_action_resolution", }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, contextSnapshot: { issueId: result.issue.id, taskId: result.issue.id, wakeReason: "issue_recovery_action_restored", source: "issue.recovery_action_resolution", recoveryActionId: result.recoveryAction.id, }, }).catch((err) => logger.warn( { err, issueId: result.issue.id, agentId: result.issue.assigneeAgentId }, "failed to wake agent after recovery action restored issue", )); } res.json({ issue: { ...result.issue, activeRecoveryAction: null, }, recoveryAction: result.recoveryAction, }); }); 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; if (!(await assertDeliverableMutationAllowedByRunContext(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, lockedDocumentStrategy: req.actor.type === "agent" ? "create_new_document" : "conflict", }); const doc = result.document; const redirectedFromLockedDocument = "redirectedFromLockedDocument" in result ? result.redirectedFromLockedDocument : null; 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, redirectedFromLockedDocument, ...summarizeIssueReferenceActivityDetails({ addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity), removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity), currentReferencedIssues: referenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity), }), }, }); if (!result.created) { const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument( issue, { id: doc.id, key: doc.key, latestRevisionId: doc.latestRevisionId, latestRevisionNumber: doc.latestRevisionNumber, }, { agentId: actor.agentId, userId: actor.actorType === "user" ? actor.actorId : null, }, ); await logExpiredRequestConfirmations({ issue, interactions: expiredInteractions, actor, source: "issue.document_updated", }); } await revalidateActiveSourceRecoveryAfterCommittedWrite({ issue, trigger: "document", actor, documentChanged: true, }); res.status(result.created ? 201 : 200).json(doc); }); router.post("/issues/:id/documents/:key/lock", 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 actor = getActorInfo(req); const result = await documentsSvc.lockIssueDocument({ issueId: issue.id, key: keyParsed.data, lockedByAgentId: actor.agentId ?? null, lockedByUserId: actor.actorType === "user" ? actor.actorId : null, }); if (result.changed) { await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.document_locked", entityType: "issue", entityId: issue.id, details: { key: result.document.key, documentId: result.document.id, title: result.document.title, lockedAt: result.document.lockedAt, }, }); } res.json(result.document); }); router.post("/issues/:id/documents/:key/unlock", 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 actor = getActorInfo(req); const result = await documentsSvc.unlockIssueDocument(issue.id, keyParsed.data); if (result.changed) { await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.document_unlocked", entityType: "issue", entityId: issue.id, details: { key: result.document.key, documentId: result.document.id, title: result.document.title, }, }); } res.json(result.document); }); 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; if (!(await assertDeliverableMutationAllowedByRunContext(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), }), }, }); const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument( issue, { id: result.document.id, key: result.document.key, latestRevisionId: result.document.latestRevisionId, latestRevisionNumber: result.document.latestRevisionNumber, }, { agentId: actor.agentId, userId: actor.actorType === "user" ? actor.actorId : null, }, ); await logExpiredRequestConfirmations({ issue, interactions: expiredInteractions, actor, source: "issue.document_restored", }); await revalidateActiveSourceRecoveryAfterCommittedWrite({ issue, trigger: "document", actor, documentChanged: true, }); 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), }), }, }); const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument( issue, { id: removed.id, key: removed.key, latestRevisionId: null, latestRevisionNumber: null, }, { agentId: actor.agentId, userId: actor.actorType === "user" ? actor.actorId : null, }, ); await logExpiredRequestConfirmations({ issue, interactions: expiredInteractions, actor, source: "issue.document_deleted", }); await revalidateActiveSourceRecoveryAfterCommittedWrite({ issue, trigger: "document", actor, documentChanged: true, }); 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; if (!(await assertDeliverableMutationAllowedByRunContext(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 }, }); await revalidateActiveSourceRecoveryAfterCommittedWrite({ issue, trigger: "work_product", actor, workProductChanged: true, }); 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; if (!(await assertDeliverableMutationAllowedByRunContext(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() }, }); await revalidateActiveSourceRecoveryAfterCommittedWrite({ issue, trigger: "work_product", actor, workProductChanged: true, }); 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; if (!(await assertDeliverableMutationAllowedByRunContext(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 }, }); await revalidateActiveSourceRecoveryAfterCommittedWrite({ issue, trigger: "work_product", actor, workProductChanged: true, }); 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", applyCreateIssueStatusDefault, validate(createIssueSchema), async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, { companyId }, req.body))) return; if (req.body.assigneeAgentId || req.body.assigneeUserId) { await assertCanAssignTasks(req, companyId); } await assertIssueEnvironmentSelection(companyId, req.body.executionWorkspaceSettings?.environmentId); const actor = getActorInfo(req); const executionPolicy = applyActorMonitorScheduledBy( normalizeIssueExecutionPolicy(req.body.executionPolicy), actor.actorType, ); assertCanManageIssueMonitor(req, req.body.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor)); 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, ...buildCreateIssueActivityStatusDetails(issue, res), ...(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), }), }, }); if (executionPolicy?.monitor) { await logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.monitor_scheduled", entityType: "issue", entityId: issue.id, details: { identifier: issue.identifier, nextCheckAt: executionPolicy.monitor.nextCheckAt, notes: executionPolicy.monitor.notes, scheduledBy: executionPolicy.monitor.scheduledBy, serviceName: executionPolicy.monitor.serviceName ?? null, timeoutAt: executionPolicy.monitor.timeoutAt ?? null, maxAttempts: executionPolicy.monitor.maxAttempts ?? null, recoveryPolicy: executionPolicy.monitor.recoveryPolicy ?? null, }, }); } 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", applyCreateIssueStatusDefault, 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 (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, parent, req.body))) return; if (req.body.assigneeAgentId || req.body.assigneeUserId) { await assertCanAssignTasks(req, parent.companyId); } await assertIssueEnvironmentSelection(parent.companyId, req.body.executionWorkspaceSettings?.environmentId); const actor = getActorInfo(req); const executionPolicy = applyActorMonitorScheduledBy( normalizeIssueExecutionPolicy(req.body.executionPolicy), actor.actorType, ); assertCanManageIssueMonitor(req, req.body.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor)); 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, ...buildCreateIssueActivityStatusDetails(issue, res), inheritedExecutionWorkspaceFromIssueId: parent.id, ...(Array.isArray(req.body.blockedByIssueIds) ? { blockedByIssueIds: req.body.blockedByIssueIds } : {}), ...(parentBlockerAdded ? { parentBlockerAdded: true } : {}), }, }); if (executionPolicy?.monitor) { await logActivity(db, { companyId: parent.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.monitor_scheduled", entityType: "issue", entityId: issue.id, details: { identifier: issue.identifier, parentId: parent.id, nextCheckAt: executionPolicy.monitor.nextCheckAt, notes: executionPolicy.monitor.notes, scheduledBy: executionPolicy.monitor.scheduledBy, serviceName: executionPolicy.monitor.serviceName ?? null, timeoutAt: executionPolicy.monitor.timeoutAt ?? null, maxAttempts: executionPolicy.monitor.maxAttempts ?? null, recoveryPolicy: executionPolicy.monitor.recoveryPolicy ?? null, }, }); } 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.post("/issues/:id/monitor/check-now", 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); assertCanManageIssueMonitor(req, issue.assigneeAgentId, true); const actor = getActorInfo(req); await heartbeat.triggerIssueMonitor(issue.id, { actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId ?? null, runId: actor.runId ?? null, }); res.json({ ok: true }); }); router.post("/issues/:id/scheduled-retry/retry-now", async (req, res) => { assertBoard(req); 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 actor = getActorInfo(req); const result = await heartbeat.retryScheduledRetryNow({ issueId: issue.id, actor: { actorType: actor.actorType, actorId: actor.actorId, }, }); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, action: "issue.scheduled_retry_retry_now", entityType: "issue", entityId: issue.id, agentId: result.scheduledRetry?.agentId ?? issue.assigneeAgentId ?? null, runId: result.scheduledRetry?.runId ?? null, details: { outcome: result.outcome, message: result.message, scheduledRetry: result.scheduledRetry, }, }); res.json(result); }); 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; if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, existing, req.body))) return; const actor = getActorInfo(req); const isClosed = isClosedIssueStatus(existing.status); const isBlocked = existing.status === "blocked"; 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, reviewRequest, reopen: reopenRequested, resume: resumeRequested, interrupt: interruptRequested, hiddenAt: hiddenAtRaw, ...updateFields } = req.body; const shouldCancelActiveRunForCancelledStatus = existing.status !== "cancelled" && updateFields.status === "cancelled"; if (resumeRequested === true && !commentBody) { res.status(400).json({ error: "Follow-up intent requires a comment" }); return; } if (resumeRequested === true && !(await assertExplicitResumeIntentAllowed(req, res, existing))) return; if (resumeRequested !== true && reopenRequested === true && req.actor.type === "agent") { if (!(await assertExplicitResumeIntentAllowed(req, res, existing))) return; } await assertIssueEnvironmentSelection(existing.companyId, updateFields.executionWorkspaceSettings?.environmentId); const requestedAssigneeAgentId = normalizedAssigneeAgentId === undefined ? existing.assigneeAgentId : normalizedAssigneeAgentId; const explicitMoveToTodoRequested = reopenRequested || resumeRequested === true; const recoveryRelevantSourceMutationRequested = req.body.status !== undefined || normalizedAssigneeAgentId !== undefined || req.body.assigneeUserId !== undefined || Array.isArray(req.body.blockedByIssueIds) || req.body.executionPolicy !== undefined || explicitMoveToTodoRequested; const activeRecoveryActionBeforeUpdate = recoveryRelevantSourceMutationRequested ? await recoveryActionsSvc.getActiveForIssue(existing.companyId, existing.id) : null; if ( recoveryRelevantSourceMutationRequested && !(await assertRecoveryActionAuthority( req, res, existing, activeRecoveryActionBeforeUpdate, { source: "issue_update" }, )) ) { return; } const effectiveMoveToTodoRequested = explicitMoveToTodoRequested || (!!commentBody && shouldImplicitlyMoveCommentedIssueToTodo({ issueStatus: existing.status, assigneeAgentId: requestedAssigneeAgentId, actorType: actor.actorType, actorId: actor.actorId, })); const updateReferenceSummaryBefore = titleOrDescriptionChanged ? await issueReferencesSvc.listIssueReferenceSummary(existing.id) : null; const hasUnresolvedFirstClassBlockers = isBlocked && effectiveMoveToTodoRequested ? (await svc.getDependencyReadiness(existing.id)).unresolvedBlockerCount > 0 : false; if (resumeRequested === true && isBlocked && hasUnresolvedFirstClassBlockers) { res.status(409).json({ error: "Issue follow-up blocked by unresolved blockers" }); return; } let interruptedRunId: string | null = null; const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(existing); const isAgentWorkUpdate = req.actor.type === "agent" && (Object.keys(updateFields).length > 0 || reviewRequest !== undefined); 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 }, }); } } } const runToCancelForCancelledStatus = shouldCancelActiveRunForCancelledStatus ? await resolveActiveIssueRun(existing) : null; if (hiddenAtRaw !== undefined) { updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null; } if ( commentBody && effectiveMoveToTodoRequested && (isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers)) && updateFields.status === undefined ) { updateFields.status = "todo"; } if (req.body.executionPolicy !== undefined) { updateFields.executionPolicy = applyActorMonitorScheduledBy( normalizeIssueExecutionPolicy(req.body.executionPolicy), actor.actorType, ); } const previousExecutionPolicy = normalizeIssueExecutionPolicy(existing.executionPolicy ?? null); const nextExecutionPolicy = updateFields.executionPolicy !== undefined ? (updateFields.executionPolicy as NormalizedExecutionPolicy | null) : previousExecutionPolicy; if (normalizedAssigneeAgentId !== undefined) { updateFields.assigneeAgentId = normalizedAssigneeAgentId; } const monitorChanged = monitorPoliciesEqual(previousExecutionPolicy, nextExecutionPolicy) === false; assertCanManageIssueMonitor(req, existing.assigneeAgentId, req.body.executionPolicy !== undefined && monitorChanged); const transition = applyIssueExecutionPolicyTransition({ issue: existing, policy: nextExecutionPolicy, previousPolicy: previousExecutionPolicy, 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, reviewRequest: reviewRequest === undefined ? undefined : reviewRequest, monitorExplicitlyUpdated: req.body.executionPolicy !== undefined && monitorChanged, }); 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); if (reviewRequest !== undefined && transition.patch.executionState === undefined) { const existingExecutionState = parseIssueExecutionState(existing.executionState); if (!existingExecutionState || existingExecutionState.status !== "pending") { if (reviewRequest !== null) { res.status(422).json({ error: "reviewRequest requires an active review or approval stage" }); return; } } else { updateFields.executionState = { ...existingExecutionState, reviewRequest, }; } } await assertAgentInReviewReviewPath({ existing, updateFields, actorType: req.actor.type, }); 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; } let cancelledStatusRunId: string | null = null; if (runToCancelForCancelledStatus) { try { const cancelled = await heartbeat.cancelRun(runToCancelForCancelledStatus.id); if (cancelled) { cancelledStatusRunId = 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_status_cancelled", issueId: existing.id }, }); } } catch (err) { logger.warn({ err, issueId: existing.id, runId: runToCancelForCancelledStatus.id }, "failed to cancel run for cancelled issue"); await logActivity(db, { companyId: existing.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "heartbeat.cancel_failed", entityType: "heartbeat_run", entityId: runToCancelForCancelledStatus.id, details: { source: "issue_status_cancelled", issueId: existing.id }, }); } } 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; activeRecoveryAction?: 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; let workspaceChange = null; if (hasIssueWorkspaceAuditChange(previous)) { try { workspaceChange = await buildIssueWorkspaceChangeActivityDetails(db, issue.companyId, existing, issue); } catch (err) { logger.warn({ err, issueId: issue.id }, "failed to enrich issue workspace change activity details"); const fallbackNames = emptyWorkspaceNameMaps(); workspaceChange = { from: summarizeIssueWorkspaceForActivity(existing, fallbackNames), to: summarizeIssueWorkspaceForActivity(issue, fallbackNames), }; } } const reopened = commentBody && effectiveMoveToTodoRequested && (isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers)) && previous.status !== undefined && issue.status === "todo"; const reopenFromStatus = reopened ? existing.status : null; const statusChangedFromBlockedToTodo = existing.status === "blocked" && issue.status === "todo" && (req.body.status !== undefined || reopened); const revalidatedRecoveryAction = await revalidateActiveSourceRecoveryAfterCommittedWrite({ issue, trigger: "issue_update", actor, activeRecoveryAction: activeRecoveryActionBeforeUpdate ?? undefined, statusChanged: existing.status !== issue.status, assigneeChanged: existing.assigneeAgentId !== issue.assigneeAgentId || existing.assigneeUserId !== issue.assigneeUserId, blockersChanged: Array.isArray(req.body.blockedByIssueIds), executionPolicyChanged: req.body.executionPolicy !== undefined, monitorChanged, resumeRequested: resumeRequested === true, reopened, blockedToTodoRecovery: statusChangedFromBlockedToTodo, }); if (activeRecoveryActionBeforeUpdate && !revalidatedRecoveryAction) { issueResponse = { ...issueResponse, activeRecoveryAction: 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" } : {}), ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), ...(cancelledStatusRunId ? { cancelledStatusRunId } : {}), ...(workspaceChange ? { workspaceChange } : {}), _previous: hasFieldChanges ? previous : undefined, ...summarizeIssueReferenceActivityDetails( updateReferenceDiff ? { addedReferencedIssues: updateReferenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity), removedReferencedIssues: updateReferenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity), currentReferencedIssues: updateReferenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity), } : null, ), }, }); if (existing.status === "in_progress" && issue.status !== existing.status && issue.status !== "in_progress") { await listSuccessfulRunHandoffStates(db, issue.companyId, [issue.id]) .then(async (handoffStates) => { const handoff = handoffStates.get(issue.id); if (handoff?.state !== "required") return; await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.successful_run_handoff_resolved", entityType: "issue", entityId: issue.id, details: { identifier: issue.identifier, sourceRunId: handoff.sourceRunId, correctiveRunId: handoff.correctiveRunId, resolvedByStatus: issue.status, }, }); }) .catch((err) => { logger.warn({ err, issueId: issue.id }, "failed to log successful run handoff resolution"); }); } 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, }, }); } const nextStoredExecutionPolicy = normalizeIssueExecutionPolicy(issue.executionPolicy ?? null); const previousMonitor = summarizeIssueMonitor(existing, previousExecutionPolicy); const nextMonitor = summarizeIssueMonitor(issue, nextStoredExecutionPolicy); const monitorScheduledChanged = previousMonitor.nextCheckAt !== nextMonitor.nextCheckAt; if (nextMonitor.nextCheckAt && (monitorScheduledChanged || previousMonitor.notes !== nextMonitor.notes)) { await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.monitor_scheduled", entityType: "issue", entityId: issue.id, details: { identifier: issue.identifier, nextCheckAt: nextMonitor.nextCheckAt, previousNextCheckAt: previousMonitor.nextCheckAt, notes: nextMonitor.notes, scheduledBy: nextMonitor.scheduledBy, serviceName: nextMonitor.serviceName, timeoutAt: nextMonitor.timeoutAt, maxAttempts: nextMonitor.maxAttempts, recoveryPolicy: nextMonitor.recoveryPolicy, }, }); } else if (!nextMonitor.nextCheckAt && previousMonitor.nextCheckAt) { await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.monitor_cleared", entityType: "issue", entityId: issue.id, details: { identifier: issue.identifier, previousNextCheckAt: previousMonitor.nextCheckAt, reason: nextMonitor.clearReason ?? "manual", notes: previousMonitor.notes, }, }); } 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, ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(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), }), }, }); const expiredInteractions = await issueThreadInteractionService(db).expireRequestConfirmationsSupersededByComment( issue, comment, { agentId: actor.agentId, userId: actor.actorType === "user" ? actor.actorId : null, }, ); await logExpiredRequestConfirmations({ issue, interactions: expiredInteractions, actor, source: "issue.comment", }); } 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 statusChangedFromClosedToTodo = isClosedIssueStatus(existing.status) && 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", ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(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", ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), }, }); } if ( !assigneeChanged && (statusChangedFromBacklog || statusChangedFromBlockedToTodo || statusChangedFromClosedToTodo) && issue.assigneeAgentId ) { addWakeup(issue.assigneeAgentId, { source: "automation", triggerDetail: "system", reason: "issue_status_changed", payload: { issueId: issue.id, mutation: "update", ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, contextSnapshot: { issueId: issue.id, source: "issue.status_change", ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(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 } : {}), ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(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 } : {}), ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(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.post("/issues/:id/admin/force-release", async (req, res) => { if (req.actor.type !== "board") { res.status(403).json({ error: "Board access required" }); return; } if (!req.actor.userId) { throw forbidden("Board user context required"); } 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); const clearAssignee = req.query.clearAssignee === "true"; const result = await svc.adminForceRelease(id, { clearAssignee }); if (!result) { res.status(404).json({ error: "Issue not found" }); return; } const actor = getActorInfo(req); await logActivity(db, { companyId: result.issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.admin_force_release", entityType: "issue", entityId: result.issue.id, details: { issueId: result.issue.id, actorUserId: req.actor.userId, prevCheckoutRunId: result.previous.checkoutRunId, prevExecutionRunId: result.previous.executionRunId, clearAssignee, }, }); res.json(result); }); 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/interactions", 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 interactions = await issueThreadInteractionService(db).listForIssue(id); res.json(interactions); }); router.post("/issues/:id/interactions", validate(createIssueThreadInteractionSchema), 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 === "agent") { if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; } else { assertBoard(req); } const actor = getActorInfo(req); const agentSourceRunId = req.actor.type === "agent" ? requireAgentRunId(req, res) : null; if (req.actor.type === "agent" && !agentSourceRunId) return; const interaction = await issueThreadInteractionService(db).create(issue, { ...req.body, sourceRunId: req.actor.type === "agent" ? agentSourceRunId : req.body.sourceRunId ?? null, }, { 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.thread_interaction_created", entityType: "issue", entityId: issue.id, details: { interactionId: interaction.id, interactionKind: interaction.kind, interactionStatus: interaction.status, continuationPolicy: interaction.continuationPolicy, }, }); res.status(201).json(interaction); }); router.post( "/issues/:id/interactions/:interactionId/accept", validate(acceptIssueThreadInteractionSchema), async (req, res) => { const id = req.params.id as string; const interactionId = req.params.interactionId as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); assertBoard(req); const actor = getActorInfo(req); const { interaction, createdIssues, continuationIssue } = await issueThreadInteractionService(db).acceptInteraction(issue, interactionId, req.body, { agentId: actor.agentId, userId: actor.actorType === "user" ? actor.actorId : null, }); const continuationWakeIssue = continuationIssue ?? issue; await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: interaction.status === "expired" ? "issue.thread_interaction_expired" : "issue.thread_interaction_accepted", entityType: "issue", entityId: issue.id, details: { interactionId: interaction.id, interactionKind: interaction.kind, interactionStatus: interaction.status, createdTaskCount: interaction.kind === "suggest_tasks" ? (interaction.result?.createdTasks?.length ?? 0) : 0, skippedTaskCount: interaction.kind === "suggest_tasks" ? (interaction.result?.skippedClientKeys?.length ?? 0) : 0, }, }); if (continuationIssue) { 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: { identifier: issue.identifier, status: continuationIssue.status, assigneeAgentId: continuationIssue.assigneeAgentId ?? null, assigneeUserId: continuationIssue.assigneeUserId ?? null, source: "request_confirmation_accept", interactionId: interaction.id, _previous: { status: issue.status, assigneeAgentId: issue.assigneeAgentId ?? null, assigneeUserId: issue.assigneeUserId ?? null, }, }, }); } for (const createdIssue of createdIssues) { void queueIssueAssignmentWakeup({ heartbeat, issue: createdIssue, reason: "issue_assigned", mutation: "interaction_accept", contextSource: "issue.interaction.accept", requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, }); } const acceptedPlanConfirmation = interaction.kind === "request_confirmation" && interaction.status === "accepted" && issue.workMode === "planning"; queueResolvedInteractionContinuationWakeup({ heartbeat, issue: continuationWakeIssue, interaction, actor, source: "issue.interaction.accept", forceFreshSession: acceptedPlanConfirmation, workspaceRefreshReason: acceptedPlanConfirmation ? "accepted_plan_confirmation" : null, }); res.json(interaction); }, ); router.post( "/issues/:id/interactions/:interactionId/reject", validate(rejectIssueThreadInteractionSchema), async (req, res) => { const id = req.params.id as string; const interactionId = req.params.interactionId as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); assertBoard(req); const actor = getActorInfo(req); const interaction = await issueThreadInteractionService(db).rejectInteraction(issue, interactionId, req.body, { 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: interaction.status === "expired" ? "issue.thread_interaction_expired" : "issue.thread_interaction_rejected", entityType: "issue", entityId: issue.id, details: { interactionId: interaction.id, interactionKind: interaction.kind, interactionStatus: interaction.status, rejectionReason: interaction.kind === "suggest_tasks" ? (interaction.result?.rejectionReason ?? null) : interaction.kind === "request_confirmation" ? (interaction.result?.reason ?? null) : null, }, }); queueResolvedInteractionContinuationWakeup({ heartbeat, issue, interaction, actor, source: "issue.interaction.reject", }); res.json(interaction); }, ); router.post( "/issues/:id/interactions/:interactionId/respond", validate(respondIssueThreadInteractionSchema), async (req, res) => { const id = req.params.id as string; const interactionId = req.params.interactionId as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); assertBoard(req); const actor = getActorInfo(req); const interaction = await issueThreadInteractionService(db).answerQuestions(issue, interactionId, req.body, { 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.thread_interaction_answered", entityType: "issue", entityId: issue.id, details: { interactionId: interaction.id, interactionKind: interaction.kind, interactionStatus: interaction.status, answeredQuestionCount: interaction.kind === "ask_user_questions" ? (interaction.result?.answers?.length ?? 0) : 0, }, }); queueResolvedInteractionContinuationWakeup({ heartbeat, issue, interaction, actor, source: "issue.interaction.respond", }); res.json(interaction); }, ); router.post( "/issues/:id/interactions/:interactionId/cancel", validate(cancelIssueThreadInteractionSchema), async (req, res) => { const id = req.params.id as string; const interactionId = req.params.interactionId as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); assertBoard(req); const actor = getActorInfo(req); const interaction = await issueThreadInteractionService(db).cancelQuestions(issue, interactionId, req.body, { 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.thread_interaction_cancelled", entityType: "issue", entityId: issue.id, details: { interactionId: interaction.id, interactionKind: interaction.kind, interactionStatus: interaction.status, cancellationReason: interaction.kind === "ask_user_questions" ? (interaction.result?.cancellationReason ?? null) : null, }, }); queueResolvedInteractionContinuationWakeup({ heartbeat, issue, interaction, actor, source: "issue.interaction.cancel", }); res.json(interaction); }, ); 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; if (!assertStructuredCommentFieldsAllowed(req, res, { presentation: req.body.presentation, metadata: req.body.metadata, })) return; const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue); if (closedExecutionWorkspace) { respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace); return; } const actor = getActorInfo(req); const reopenRequested = req.body.reopen === true; const resumeRequested = req.body.resume === true; const interruptRequested = req.body.interrupt === true; if (resumeRequested === true && !(await assertExplicitResumeIntentAllowed(req, res, issue))) return; if (resumeRequested !== true && reopenRequested === true && req.actor.type === "agent") { if (!(await assertExplicitResumeIntentAllowed(req, res, issue))) return; } const isClosed = isClosedIssueStatus(issue.status); const isBlocked = issue.status === "blocked"; const explicitMoveToTodoRequested = reopenRequested || resumeRequested === true; const effectiveMoveToTodoRequested = explicitMoveToTodoRequested || shouldImplicitlyMoveCommentedIssueToTodo({ issueStatus: issue.status, assigneeAgentId: issue.assigneeAgentId, actorType: actor.actorType, actorId: actor.actorId, }); const hasUnresolvedFirstClassBlockers = isBlocked && effectiveMoveToTodoRequested ? (await svc.getDependencyReadiness(issue.id)).unresolvedBlockerCount > 0 : false; if (resumeRequested === true && isBlocked && hasUnresolvedFirstClassBlockers) { res.status(409).json({ error: "Issue follow-up blocked by unresolved blockers" }); return; } let reopened = false; let reopenFromStatus: string | null = null; let interruptedRunId: string | null = null; let currentIssue = issue; const commentReferenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id); if (effectiveMoveToTodoRequested && (isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers))) { 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", ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), 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, }, { authorType: req.body.authorType ?? (actor.actorType === "agent" ? "agent" : "user"), presentation: req.body.presentation ?? null, metadata: req.body.metadata ?? null, }); 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, ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(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), }), }, }); const expiredInteractions = await issueThreadInteractionService(db).expireRequestConfirmationsSupersededByComment( currentIssue, comment, { agentId: actor.agentId, userId: actor.actorType === "user" ? actor.actorId : null, }, ); await logExpiredRequestConfirmations({ issue: currentIssue, interactions: expiredInteractions, actor, source: "issue.comment", }); await revalidateActiveSourceRecoveryAfterCommittedWrite({ issue: currentIssue, trigger: "comment", actor, statusChanged: reopened, resumeRequested: resumeRequested === true, reopened, blockedToTodoRecovery: reopened && reopenFromStatus === "blocked" && currentIssue.status === "todo", }); // 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", ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(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, ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), }, }); } else { wakeups.set(assigneeId, { source: "automation", triggerDetail: "system", reason: "issue_commented", payload: { issueId: currentIssue.id, commentId: comment.id, mutation: "comment", ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(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", ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(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; if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return; const company = await companiesSvc.getById(companyId); const attachmentMaxBytes = normalizeIssueAttachmentMaxBytes(company?.attachmentMaxBytes); try { await runSingleFileUpload(req, res, attachmentMaxBytes); } catch (err) { if (err instanceof multer.MulterError) { if (err.code === "LIMIT_FILE_SIZE") { res.status(422).json({ error: `Attachment exceeds ${attachmentMaxBytes} 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; if (!(await assertDeliverableMutationAllowedByRunContext(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; }