Files
PaperClipAI/server/src/routes/issues.ts
T
2026-08-15 19:19:38 +00:00

12646 lines
481 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import { Router, type Request, type Response } from "express";
import multer from "multer";
import { z } from "zod";
import { and, asc, desc, eq, inArray, isNull, notInArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
activityLog,
agents,
approvals,
companyMemberships,
documents,
executionWorkspaces,
heartbeatRuns,
issueApprovals,
issueComments,
issueDocuments,
issueExecutionDecisions,
issueRelations,
issueThreadInteractions,
issues as issueRows,
issueWorkProducts,
pipelineCaseIssueLinks,
pipelineCases,
pipelineStages,
pipelines,
projectWorkspaces,
} from "@paperclipai/db";
import {
addIssueCommentSchema,
acceptIssueThreadInteractionSchema,
attachmentArtifactWorkProductMetadataSchema,
cancelIssueThreadInteractionSchema,
withdrawIssueThreadInteractionSchema,
companySearchExtractQuerySchema,
companySearchQuerySchema,
createIssueAttachmentMetadataSchema,
createIssueThreadInteractionSchema,
createIssueWorkProductSchema,
createIssueLabelSchema,
createAcceptedPlanDecompositionSchema,
checkoutIssueSchema,
createDocumentAnnotationCommentSchema,
createDocumentAnnotationThreadSchema,
createChildIssueSchema,
createIssueSchema,
resolveCreateIssueStatusDefault,
resolveIssueRecoveryActionSchema,
feedbackTargetTypeSchema,
feedbackTraceStatusSchema,
feedbackVoteValueSchema,
upsertIssueFeedbackVoteSchema,
upsertIssueWatchdogSchema,
linkIssueApprovalSchema,
issueDocumentKeySchema,
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
ISSUE_WATCHDOG_DISCOVERY_KINDS,
TASK_WATCHDOG_PRODUCT_BUG_ORIGIN_KIND,
ONBOARDING_FIRST_TASK_ORIGIN_KIND,
rejectIssueThreadInteractionSchema,
restoreIssueDocumentRevisionSchema,
respondIssueThreadInteractionSchema,
stalledReviewDecisionSchema,
submitIssueThreadInteractionVerdictsSchema,
updateIssueWorkProductSchema,
updateDocumentAnnotationThreadSchema,
upsertIssueDocumentSchema,
updateIssueSchema,
isClosedIsolatedExecutionWorkspace,
isUuidLike,
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
type CompactIssue,
type CompanySearchExtractQuery,
type CompanySearchExtractResponse,
type CompanySearchQuery,
type CompanySearchResponse,
type ExecutionWorkspace,
type IssueBlockerDiagnosticFlag,
type IssueBlockerDiagnosticIssueSummary,
type IssueBlockerDiagnosticNode,
type IssueBlockerDiagnosticsReadiness,
type IssueBlockerDiagnosticsResponse,
type IssueSubtreeDiagnosticEdge,
type IssueSubtreeDiagnosticNode,
type IssueSubtreeDiagnosticsResponse,
type IssueWakeDiagnosticActivityRecord,
type IssueWakeDiagnosticEvent,
type IssueWakeDiagnosticWakeFailureClass,
type IssueWakeDiagnosticWakeRequest,
type IssueWakeDiagnosticsResponse,
type IssueRelationIssueSummary,
type IssueReviewPolicy,
type IssueCommentPresentation,
type IssueWatchdogDiscoveryKind,
type ProjectWorkspace,
type SourceTrustMetadata,
type SuccessfulRunHandoffState,
type WorkspaceRuntimeService,
issueWriteDenialCodeForResponsibleUserDenial,
issueWriteDenialResponse,
type IssueWriteDenialCode,
type IssueWriteDenialContext,
} from "@paperclipai/shared";
import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry";
import { getTelemetryClient } from "../telemetry.js";
import { isUniqueViolation } from "../db-errors.js";
import type { StorageService } from "../storage/types.js";
import { validate } from "../middleware/validate.js";
import * as serviceIndex from "../services/index.js";
import {
accessService,
agentService,
budgetService,
companySkillService,
companyService,
companySearchService,
executionWorkspaceService,
goalService,
heartbeatService,
issueApprovalService,
issueRecoveryActionService,
issueThreadInteractionService,
inboxAgentPolicyService,
ISSUE_LIST_DEFAULT_LIMIT,
ISSUE_LIST_MAX_LIMIT,
issueReferenceService,
issueService,
type ActivityPublication,
type IssueFilters,
clampIssueListLimit,
documentService,
documentAnnotationService,
logActivity,
publishActivity,
projectService,
routineService,
workProductService,
} from "../services/index.js";
import { buildDocumentReviewContext, buildPlanReviewContext } from "../services/plan-review-context.js";
import {
decideIssueReviewPathRecovery,
ISSUE_REVIEW_PATH_LOST_WAKE_REASON,
isReviewPathRecoveryIdempotencyConflict,
REVIEW_PATH_RECOVERY_INSTRUCTION,
} from "../services/recovery/review-path-recovery.js";
import { hydrateSuccessfulRunHandoffLiveness } from "../services/successful-run-handoff-state.js";
import {
TASK_WATCHDOG_ORIGIN_KIND,
resolveTaskWatchdogMutationScope,
taskWatchdogScopeAllowsIssueMutation,
} from "../services/task-watchdog-scope.js";
import type { TaskWatchdogServiceDeps, taskWatchdogService } from "../services/task-watchdogs.js";
import { logger } from "../middleware/logger.js";
import { badRequest, conflict, forbidden, HttpError, notFound, unauthorized, unprocessable } from "../errors.js";
import { privateJsonEtag } from "../middleware/private-json-etag.js";
import { createRequestPromiseMemo } from "../lib/request-promise-memo.js";
import { assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js";
import {
assertNoAgentHostWorkspaceCommandMutation,
collectIssueWorkspaceCommandPaths,
} from "./workspace-command-authz.js";
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
import {
GENERIC_ATTACHMENT_CONTENT_TYPES,
isInlineAttachmentContentType,
normalizeIssueAttachmentMaxBytes,
normalizeContentType,
normalizeUploadAttachmentContentType,
SVG_CONTENT_TYPE,
} from "../attachment-types.js";
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
import {
buildOnboardingGreeting,
ONBOARDING_GREETING_AUTHORIZATION_REASON,
} from "../services/onboarding-greeting.js";
import {
ISSUE_BLOCKERS_RESOLVED_WAKE_REASON,
buildIssueBlockersResolvedWakeIdempotencyKey,
findExistingIssueBlockersResolvedWake,
} from "../services/issue-dependency-wakeups.js";
import { assertEnvironmentSelectionForCompany } from "./environment-selection.js";
import {
executionWorkspaceService as executionWorkspaceServiceDirect,
STALE_REOPEN_PENDING_CONSUMPTION_GRACE_MS,
} from "../services/execution-workspaces.js";
import { decisionTrainingService } from "../services/decision-training.js";
import { feedbackService } from "../services/feedback.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import {
ISSUE_BLOCKER_DIAGNOSTICS_MAX_BLOCKERS,
ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS,
ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS,
ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS,
readAcceptedPlanConfirmationTarget,
} from "../services/issues.js";
import { authorizationDeniedDetails } from "../services/authorization.js";
import { stalledReviewDecisionService } from "../services/stalled-review-decisions.js";
import { environmentService } from "../services/environments.js";
import { environmentRuntimeService } from "../services/environment-runtime.js";
import { redactSensitiveText } from "../redaction.js";
import { createRunSecretRedactionRegistry } from "../services/run-secret-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";
import {
buildPromotedSourceTrust,
isLowTrustQuarantined,
redactQuarantinedBodyForHigherTrust,
resolveActorSourceTrustForIssue,
sanitizeQuarantinedCommentForHigherTrust,
} from "../services/source-trust.js";
import {
LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH,
resolveCoreTrustPreset,
type TrustPresetResolution,
} from "../services/trust-preset-resolver.js";
import { externalObjectService } from "../services/external-objects.js";
import { deliverAgentUnblockNotification } from "../services/routable-blocked.js";
import {
assertIssueReviewVerdictActorAllowed,
isIssueReviewVerdictInteraction,
} from "../services/issue-review-policy.js";
import {
crossIssueInfluenceLimitError,
crossIssueInfluenceRunContextError,
observeCrossIssueInfluence,
type CrossIssueInfluenceKind,
} from "../services/cross-issue-influence-limit.js";
const MAX_ISSUE_COMMENT_LIMIT = 500;
const updateIssueRouteSchema = updateIssueSchema.extend({
interrupt: z.boolean().optional(),
});
function prefersMinimalIssueUpdateResponse(req: Request) {
return (req.get("Prefer") ?? "")
.split(",")
.some((preference) => preference.trim().toLowerCase() === "return=minimal");
}
const refreshExternalObjectsSchema = z.object({
objectIds: z.array(z.string().uuid()).max(50).optional(),
}).strict();
const inboxArchiveBodySchema = z.object({
userId: z.string().trim().min(1).optional(),
}).strict().default({});
const externalObjectSummariesSchema = z.object({
issueIds: z.array(z.string().uuid()).max(1000),
}).strict();
const promoteLowTrustOutputSchema = z.object({
sourceArtifactKind: z.enum(["comment", "document", "work_product", "issue"]),
sourceArtifactId: z.string().uuid(),
title: z.string().trim().min(1).max(200),
summary: z.string().trim().min(1).max(8_000),
});
async function listIssueLinkedCases(db: Db, companyId: string, issueId: string) {
const rows = await db
.select({
link: pipelineCaseIssueLinks,
case: pipelineCases,
pipeline: pipelines,
stage: pipelineStages,
})
.from(pipelineCaseIssueLinks)
.innerJoin(pipelineCases, eq(pipelineCaseIssueLinks.caseId, pipelineCases.id))
.innerJoin(pipelines, eq(pipelineCases.pipelineId, pipelines.id))
.innerJoin(pipelineStages, eq(pipelineCases.stageId, pipelineStages.id))
.where(and(
eq(pipelineCaseIssueLinks.companyId, companyId),
eq(pipelineCaseIssueLinks.issueId, issueId),
eq(pipelineCases.companyId, companyId),
eq(pipelines.companyId, companyId),
));
return rows.map((row) => ({
id: row.case.id,
caseKey: row.case.caseKey,
title: row.case.title,
status: row.case.terminalKind ?? "open",
role: row.link.role,
pipeline: {
id: row.pipeline.id,
key: row.pipeline.key,
name: row.pipeline.name,
},
stage: {
id: row.stage.id,
key: row.stage.key,
name: row.stage.name,
kind: row.stage.kind,
},
}));
}
type ParsedExecutionState = NonNullable<ReturnType<typeof parseIssueExecutionState>>;
type NormalizedExecutionPolicy = NonNullable<ReturnType<typeof normalizeIssueExecutionPolicy>>;
type IssueRouteSnapshot = typeof issueRows.$inferSelect;
type RecoveryRevalidationTrigger =
| "issue_update"
| "comment"
| "document"
| "work_product"
| "read_projection";
type CompanySearchService = {
extract(companyId: string, query: CompanySearchExtractQuery): Promise<CompanySearchExtractResponse>;
search(companyId: string, query: CompanySearchQuery): Promise<CompanySearchResponse>;
};
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<string, unknown> | null;
createdAt: Date;
};
type TaskWatchdogService = ReturnType<typeof taskWatchdogService>;
type TaskWatchdogServiceFactory = typeof taskWatchdogService;
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<string, unknown>);
res.locals.createIssueStatusDefault = resolution;
if (resolution.defaulted) {
req.body = {
...req.body,
status: resolution.status,
};
}
next();
}
function noopTaskWatchdogService(): TaskWatchdogService {
return {
getActiveForIssue: async () => null,
listActiveSummariesForIssues: async () => new Map(),
upsertForIssue: async () => {
throw unprocessable("Task watchdog service is unavailable");
},
disableForIssue: async () => null,
reconcileTaskWatchdogs: async () => ({
checked: 0,
triggered: 0,
live: 0,
pendingFirstRun: 0,
alreadyReviewed: 0,
skipped: 0,
watchdogIssueIds: [],
}),
reconcileForIssueAndAncestors: async () => ({
checked: 0,
triggered: 0,
pendingFirstRun: 0,
skipped: 0,
watchdogIssueIds: [],
}),
revalidateMutationScope: async () => ({
allowed: true,
classification: {
state: "stopped",
reason: "Task watchdog service unavailable in this route context.",
includedIssueIds: [],
stopFingerprint: "task_watchdog_stop:unavailable",
stoppedLeaves: [],
stopSnapshot: {
version: 2,
fingerprint: "task_watchdog_stop:unavailable",
materialLeaves: [],
waitsByIssueId: {},
},
pendingInteractionsByIssueId: {},
},
}),
};
}
function buildAttachmentContentPath(attachmentId: string): string {
return `/api/attachments/${attachmentId}/content`;
}
const GENERIC_RESPONSE_ATTACHMENT_CONTENT_TYPES = new Set(GENERIC_ATTACHMENT_CONTENT_TYPES);
function inferVideoContentTypeFromFilename(filename: string | null | undefined): string | null {
const lower = (filename ?? "").toLowerCase();
if (lower.endsWith(".mp4") || lower.endsWith(".m4v")) return "video/mp4";
if (lower.endsWith(".webm")) return "video/webm";
if (lower.endsWith(".mov") || lower.endsWith(".qt") || lower.endsWith(".quicktime")) return "video/quicktime";
return null;
}
function resolveAttachmentResponseContentType(input: {
storedContentType: string | null | undefined;
objectContentType?: string | null;
originalFilename?: string | null;
}) {
const storedContentType = normalizeContentType(input.storedContentType || input.objectContentType);
if (!GENERIC_RESPONSE_ATTACHMENT_CONTENT_TYPES.has(storedContentType)) return storedContentType;
return inferVideoContentTypeFromFilename(input.originalFilename) ?? storedContentType;
}
function requiresPaperclipAttachmentMetadata(input: {
type?: unknown;
provider?: unknown;
}, fallback?: {
type?: string | null;
provider?: string | null;
}) {
const type = typeof input.type === "string" ? input.type : fallback?.type ?? null;
const provider = typeof input.provider === "string" ? input.provider : fallback?.provider ?? null;
return type === "artifact" && provider === "paperclip";
}
const attachmentArtifactMetadataInputSchema = z.object({
attachmentId: z.string().uuid(),
}).passthrough();
function buildCreateIssueActivityStatusDetails(
issue: { assigneeAgentId: string | null; status: string },
res: Response,
) {
const statusDefault = res.locals.createIssueStatusDefault as
| ReturnType<typeof resolveCreateIssueStatusDefault>
| 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 readObject(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function hasOwn(record: Record<string, unknown>, key: string) {
return Object.prototype.hasOwnProperty.call(record, key);
}
async function auditAgentIssueCreateAttributionSpoof(input: {
db: Db;
req: Request;
companyId: string;
entityId?: string | null;
surface: string;
field: "responsibleUserId" | "createdByUserId";
action: "rejected" | "stripped";
requestedValue: string | null;
}) {
const actor = getActorInfo(input.req);
await logActivity(input.db, {
companyId: input.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: input.action === "rejected"
? "issue.attribution_spoof_rejected"
: "issue.attribution_spoof_stripped",
entityType: input.entityId ? "issue" : "company",
entityId: input.entityId ?? input.companyId,
details: {
surface: input.surface,
field: input.field,
requestedValue: input.requestedValue,
derivedFrom: "authenticated_actor",
},
});
}
async function auditAgentIssueCommentAttributionSpoof(input: {
db: Db;
req: Request;
issue: { id: string; companyId: string; identifier: string | null };
surface: "issue.comment.create" | "issue.patch.comment";
requestedValue: string | null;
}) {
const actor = getActorInfo(input.req);
await logActivity(input.db, {
companyId: input.issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
responsibleUserIdOverride: authenticatedActorResponsibleUserId(input.req),
action: "issue.attribution_spoof_rejected",
entityType: "issue",
entityId: input.issue.id,
details: {
identifier: input.issue.identifier,
surface: input.surface,
field: "onBehalfOfUserId",
requestedValue: input.requestedValue,
derivedFrom: "authenticated_actor",
},
});
}
async function sanitizeIssueCreateAttribution<T extends object>(
db: Db,
req: Request,
res: Response,
companyId: string,
input: T,
options: { surface: string; entityId?: string | null },
) {
const sanitized = { ...input } as T & Record<string, unknown>;
if (req.actor.type !== "agent") return sanitized;
if (hasOwn(sanitized, "responsibleUserId") && sanitized.responsibleUserId != null) {
await auditAgentIssueCreateAttributionSpoof({
db,
req,
companyId,
entityId: options.entityId,
surface: options.surface,
field: "responsibleUserId",
action: "rejected",
requestedValue: readNonEmptyString(sanitized.responsibleUserId),
});
res.status(422).json({ error: "Agent-created issues cannot set responsibleUserId" });
return null;
}
if (hasOwn(sanitized, "createdByUserId") && sanitized.createdByUserId != null) {
await auditAgentIssueCreateAttributionSpoof({
db,
req,
companyId,
entityId: options.entityId,
surface: options.surface,
field: "createdByUserId",
action: "stripped",
requestedValue: readNonEmptyString(sanitized.createdByUserId),
});
delete sanitized.createdByUserId;
}
delete sanitized.responsibleUserId;
return sanitized;
}
function authenticatedActorResponsibleUserId(req: Request) {
return req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : undefined;
}
// Matches the partial unique index that guarantees at most one onboarding
// first-task issue per company (packages/db/src/schema/issues.ts).
function isOnboardingFirstTaskConflict(error: unknown): boolean {
for (
let current = error, depth = 0;
current && typeof current === "object" && depth < 5;
current = (current as { cause?: unknown }).cause, depth += 1
) {
const candidate = current as { code?: string; constraint?: string; message?: string };
if (
candidate.code === "23505" &&
(candidate.constraint === "issues_onboarding_first_task_uq" ||
(typeof candidate.message === "string" &&
candidate.message.includes("issues_onboarding_first_task_uq")))
) {
return true;
}
}
return false;
}
function issueWriteAuthorizationReason(
req: Request,
decision: true | { reason?: string | null },
) {
if (decision !== true && decision.reason) return decision.reason;
return req.actor.type === "agent" ? "allow_scoped_agent_write" : "allow_board_actor";
}
function readPlanConfirmationTargetForIssue(payload: unknown, issueId: string) {
const target = readObject(readObject(payload).target);
if (target.type !== "issue_document" || target.key !== "plan") return null;
if (readNonEmptyString(target.issueId) !== issueId) return null;
return {
issueId,
documentId: readNonEmptyString(target.documentId),
key: "plan",
revisionId: readNonEmptyString(target.revisionId),
revisionNumber: typeof target.revisionNumber === "number" ? target.revisionNumber : null,
};
}
function readConfirmationResultForWake(result: unknown) {
const parsed = readObject(result);
if (Object.keys(parsed).length === 0) return null;
return {
outcome: readNonEmptyString(parsed.outcome),
reason: readNonEmptyString(parsed.reason) ?? readNonEmptyString(parsed.rejectionReason),
commentId: readNonEmptyString(parsed.commentId),
};
}
function hasIssueWorkspaceAuditChange(previous: Record<string, unknown>) {
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<string, string>;
executionWorkspaceNames: Map<string, string>;
};
function emptyWorkspaceNameMaps(): WorkspaceNameMaps {
return {
projectWorkspaceNames: new Map(),
executionWorkspaceNames: new Map(),
};
}
function summarizeIssueWorkspaceForActivity(
issue: IssueWorkspaceAuditInput,
names: WorkspaceNameMaps,
) {
const settings = parseIssueExecutionWorkspaceSettings(issue.executionWorkspaceSettings, { includeEnvironmentId: true });
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<string, unknown> | 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",
hasLiveContinuation: false,
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[],
options?: { hydrateLiveness?: boolean },
): Promise<Map<string, SuccessfulRunHandoffState>> {
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<string, SuccessfulRunHandoffState>();
for (const row of rows) {
if (states.has(row.entityId)) continue;
const state = successfulRunHandoffStateFromActivity(row);
if (state) states.set(row.entityId, state);
}
return options?.hydrateLiveness === false
? states
: hydrateSuccessfulRunHandoffLiveness(db, companyId, states);
}
type RecoveryActionsLister = {
listActiveForIssues: (
companyId: string,
sourceIssueIds: string[],
) => Promise<Map<string, NonNullable<IssueRelationIssueSummary["activeRecoveryAction"]>>>;
};
async function relationRecoveryActionMap(
recoveryActionsSvc: RecoveryActionsLister,
companyId: string,
relations: { blockedBy: IssueRelationIssueSummary[]; blocks: IssueRelationIssueSummary[] },
): Promise<Map<string, NonNullable<IssueRelationIssueSummary["activeRecoveryAction"]>>> {
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<string, NonNullable<IssueRelationIssueSummary["activeRecoveryAction"]>>,
) {
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),
};
}
type IssueBlockerDiagnosticReadableIssue = {
id: string;
identifier: string | null;
title: string;
status: string;
priority: string;
assigneeAgentId: string | null;
assigneeUserId: string | null;
};
type IssueBlockerDiagnosticAuthzIssue = IssueBlockerDiagnosticReadableIssue & {
companyId: string;
projectId: string | null;
parentId: string | null;
};
function toIssueBlockerDiagnosticSummary(
issue: IssueBlockerDiagnosticReadableIssue,
): IssueBlockerDiagnosticIssueSummary {
return {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
status: issue.status as IssueBlockerDiagnosticIssueSummary["status"],
priority: issue.priority as IssueBlockerDiagnosticIssueSummary["priority"],
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
};
}
function blockerDiagnosticLabel(issue: IssueBlockerDiagnosticIssueSummary) {
return issue.identifier ?? issue.title;
}
function buildIssueBlockerDiagnosticsResponse(input: {
issue: IssueBlockerDiagnosticReadableIssue;
blockers: IssueBlockerDiagnosticAuthzIssue[];
visibleBlockers: IssueBlockerDiagnosticAuthzIssue[];
readiness: {
allBlockersDone: boolean;
isDependencyReady: boolean;
unresolvedBlockerIssueIds: string[];
pendingFinalizeBlockerIssueIds: string[];
};
truncated: boolean;
maxBlockers?: number;
}): IssueBlockerDiagnosticsResponse {
const issue = toIssueBlockerDiagnosticSummary(input.issue);
const visibleBlockerIds = new Set(input.visibleBlockers.map((blocker) => blocker.id));
const omittedUnauthorizedBlockerCount = input.blockers.filter(
(blocker) => !visibleBlockerIds.has(blocker.id),
).length;
const completeVisibleSet = !input.truncated && omittedUnauthorizedBlockerCount === 0;
const unresolvedIds = new Set(input.readiness.unresolvedBlockerIssueIds);
const pendingFinalizeIds = new Set(input.readiness.pendingFinalizeBlockerIssueIds);
const blockers: IssueBlockerDiagnosticNode[] = input.visibleBlockers.map((blockerRow) => {
const blocker = toIssueBlockerDiagnosticSummary(blockerRow);
const isPendingFinalize = pendingFinalizeIds.has(blocker.id);
const isUnresolved = unresolvedIds.has(blocker.id);
const flags: IssueBlockerDiagnosticFlag[] = [];
if (issue.status === "blocked" && blocker.status === "done") flags.push("done_but_blocking");
if (blocker.status === "cancelled") flags.push("cancelled_blocker_in_set");
if (isPendingFinalize) flags.push("workspace_finalize_pending");
return {
...blocker,
isUnresolved,
isPendingFinalize,
isDependencyReady: blocker.status === "done" && !isPendingFinalize,
flags,
};
});
const readiness: IssueBlockerDiagnosticsReadiness | null = completeVisibleSet
? {
allBlockersDone: input.readiness.allBlockersDone,
isDependencyReady: input.readiness.isDependencyReady,
unresolvedBlockerCount: input.readiness.unresolvedBlockerIssueIds.length,
pendingFinalizeBlockerCount: input.readiness.pendingFinalizeBlockerIssueIds.length,
}
: null;
const reportedOmittedUnauthorizedBlockerCount = input.truncated
? null
: omittedUnauthorizedBlockerCount;
return {
issue,
diagnosis: buildIssueBlockerDiagnosis({
issue,
blockers,
readiness,
omittedUnauthorizedBlockerCount: reportedOmittedUnauthorizedBlockerCount,
truncated: input.truncated,
maxBlockers: input.maxBlockers ?? ISSUE_BLOCKER_DIAGNOSTICS_MAX_BLOCKERS,
}),
readiness,
blockers,
omittedUnauthorizedBlockerCount: reportedOmittedUnauthorizedBlockerCount,
truncated: input.truncated,
caps: {
maxBlockers: input.maxBlockers ?? ISSUE_BLOCKER_DIAGNOSTICS_MAX_BLOCKERS,
},
};
}
function buildIssueBlockerDiagnosis(input: {
issue: IssueBlockerDiagnosticIssueSummary;
blockers: IssueBlockerDiagnosticNode[];
readiness: IssueBlockerDiagnosticsReadiness | null;
omittedUnauthorizedBlockerCount: number | null;
truncated: boolean;
maxBlockers: number;
}) {
if (input.truncated) {
return `Blocker diagnostics for ${blockerDiagnosticLabel(input.issue)} are truncated at ${
input.maxBlockers
} blockers, so readiness is not reported.`;
}
const omittedUnauthorizedBlockerCount = input.omittedUnauthorizedBlockerCount ?? 0;
if (omittedUnauthorizedBlockerCount > 0) {
return `One or more blockers for ${blockerDiagnosticLabel(
input.issue,
)} are outside this actor's authorization boundary, so this diagnosis only covers visible blockers.`;
}
if (input.blockers.length === 0) {
return input.issue.status === "blocked"
? `${blockerDiagnosticLabel(input.issue)} is blocked but has no first-class blocker relations.`
: null;
}
const pendingFinalize = input.blockers.find((blocker) => blocker.isPendingFinalize);
if (pendingFinalize) {
return `${blockerDiagnosticLabel(input.issue)} is waiting for ${blockerDiagnosticLabel(
pendingFinalize,
)} to finish workspace finalization.`;
}
const cancelled = input.blockers.find((blocker) => blocker.status === "cancelled");
if (cancelled) {
return `${blockerDiagnosticLabel(input.issue)} is blocked by ${blockerDiagnosticLabel(
cancelled,
)}, which is cancelled; cancelled blockers do not resolve until the blocker relation is removed or replaced.`;
}
const unresolved = input.blockers.find((blocker) => blocker.isUnresolved);
if (unresolved) {
return `${blockerDiagnosticLabel(input.issue)} is blocked by ${blockerDiagnosticLabel(
unresolved,
)}, which is ${unresolved.status}.`;
}
if (input.readiness?.isDependencyReady && input.issue.status === "blocked") {
return `All blockers for ${blockerDiagnosticLabel(
input.issue,
)} are resolved, but the issue is still blocked; this is likely a stale blocker hold.`;
}
if (input.readiness?.isDependencyReady) {
return `All blockers for ${blockerDiagnosticLabel(input.issue)} are resolved.`;
}
return null;
}
const ISSUE_WAKE_DIAGNOSTIC_KNOWN_SOURCES = new Set([
"timer",
"assignment",
"on_demand",
"automation",
]);
const ISSUE_WAKE_DIAGNOSTIC_KNOWN_REASONS = new Set([
"issue_assigned",
"issue_blockers_resolved",
"issue_commented",
"issue_comment_mentioned",
"issue_dependencies_blocked",
"issue_tree_hold_active",
"missing_issue_comment",
"process_lost_retry",
"run_liveness_continuation",
"heartbeat.disabled",
"heartbeat.timer.no_actionable_work",
"heartbeat.wakeOnDemand.disabled",
]);
const ISSUE_WAKE_DIAGNOSTIC_KNOWN_STATUSES = new Set([
"queued",
"claimed",
"coalesced",
"skipped",
"completed",
"failed",
"cancelled",
"deferred_issue_execution",
]);
function dateToIso(value: Date | string | null | undefined) {
if (!value) return null;
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
function projectWakeDiagnosticSource(value: string | null) {
if (!value) return null;
return ISSUE_WAKE_DIAGNOSTIC_KNOWN_SOURCES.has(value) ? value : "other";
}
function projectWakeDiagnosticReason(value: string | null) {
if (!value) return null;
return ISSUE_WAKE_DIAGNOSTIC_KNOWN_REASONS.has(value) ? value : "other";
}
function projectWakeDiagnosticStatus(value: string) {
return ISSUE_WAKE_DIAGNOSTIC_KNOWN_STATUSES.has(value) ? value : "other";
}
function wakeFailureClass(
status: string,
rawError: string | null,
): IssueWakeDiagnosticWakeFailureClass | null {
if (status === "failed" || rawError) return "failed";
if (status === "cancelled") return "cancelled";
if (status === "skipped") return "skipped";
return null;
}
function projectIssueWakeRequest(row: {
agentId: string;
source: string;
reason: string | null;
status: string;
coalescedCount: number;
runId: string | null;
requestedAt: Date | string;
claimedAt: Date | string | null;
finishedAt: Date | string | null;
error: string | null;
}, options: { includeInternalIds: boolean }): IssueWakeDiagnosticWakeRequest {
const status = projectWakeDiagnosticStatus(row.status);
return {
kind: "wake_request",
agentId: options.includeInternalIds ? row.agentId : null,
source: projectWakeDiagnosticSource(row.source) ?? "other",
reason: projectWakeDiagnosticReason(row.reason),
status,
coalescedCount: row.coalescedCount,
runId: options.includeInternalIds ? row.runId : null,
requestedAt: dateToIso(row.requestedAt)!,
claimedAt: dateToIso(row.claimedAt),
finishedAt: dateToIso(row.finishedAt),
failureClass: wakeFailureClass(status, row.error),
};
}
function wakeDiagnosticActivityAction(action: string) {
return action === "issue.tree_hold_wakeup_deferred" ? action : "other";
}
function wakeDiagnosticActivityEntityType(entityType: string) {
return entityType === "issue" || entityType === "agent_wakeup_request" ? entityType : "other";
}
function projectIssueWakeActivityRecord(
row: {
action: string;
entityType: string;
entityId: string;
agentId: string | null;
runId: string | null;
details: Record<string, unknown> | null;
createdAt: Date | string;
},
issueId: string,
options: { includeInternalIds: boolean },
): IssueWakeDiagnosticActivityRecord {
const details = row.details && typeof row.details === "object" ? row.details : {};
const action = wakeDiagnosticActivityAction(row.action);
const rootIssueId = readNonEmptyString(details["rootIssueId"]);
const detailIssueId = readNonEmptyString(details["issueId"]);
const projectedRootIssueId =
rootIssueId === issueId || detailIssueId === issueId || (row.entityType === "issue" && row.entityId === issueId)
? issueId
: null;
return {
kind: "activity",
action,
entityType: wakeDiagnosticActivityEntityType(row.entityType),
agentId: options.includeInternalIds ? row.agentId ?? readNonEmptyString(details["agentId"]) : null,
runId: options.includeInternalIds ? row.runId : null,
createdAt: dateToIso(row.createdAt)!,
source: projectWakeDiagnosticSource(readNonEmptyString(details["source"])),
requestedReason: projectWakeDiagnosticReason(readNonEmptyString(details["requestedReason"])),
previousReason: projectWakeDiagnosticReason(readNonEmptyString(details["previousReason"])),
rootIssueId: projectedRootIssueId,
holdId: options.includeInternalIds ? readNonEmptyString(details["holdId"]) : null,
summary: action === "issue.tree_hold_wakeup_deferred"
? "Wake was deferred because an active issue-tree hold was present."
: "Wake-related activity was recorded.",
};
}
function issueWakeDiagnosticEventTimestamp(event: IssueWakeDiagnosticEvent) {
const timestamp = event.kind === "wake_request" ? event.requestedAt : event.createdAt;
return new Date(timestamp).getTime();
}
function wakeDiagnosticReasonPhrase(reason: string | null) {
return reason ? ` for ${reason}` : "";
}
function buildIssueWakeDiagnosis(input: {
issue: IssueBlockerDiagnosticIssueSummary;
events: IssueWakeDiagnosticEvent[];
blockerDiagnostics: IssueBlockerDiagnosticsResponse;
truncated: boolean;
maxWakeRequests: number;
maxActivityRecords: number;
lookbackDays: number;
}) {
if (input.truncated) {
return `Wake diagnostics for ${blockerDiagnosticLabel(input.issue)} are truncated to ${
input.maxWakeRequests
} wake requests and ${input.maxActivityRecords} activity records over ${
input.lookbackDays
} days, so the diagnosis only covers returned records.`;
}
const latest = input.events[0];
if (latest?.kind === "activity" && latest.action === "issue.tree_hold_wakeup_deferred") {
return `The most recent wake-related activity for ${blockerDiagnosticLabel(
input.issue,
)} was deferred by an active issue-tree hold.`;
}
if (latest?.kind === "wake_request") {
if (latest.status === "deferred_issue_execution") {
return `The most recent wake for ${blockerDiagnosticLabel(input.issue)} is deferred${wakeDiagnosticReasonPhrase(
latest.reason,
)}.`;
}
if (latest.status === "failed") {
return `The most recent wake for ${blockerDiagnosticLabel(input.issue)} failed${wakeDiagnosticReasonPhrase(
latest.reason,
)}; raw error text is withheld.`;
}
if (latest.status === "skipped" || latest.status === "cancelled" || latest.status === "coalesced") {
const coalesced =
latest.coalescedCount > 0 ? ` and coalesced ${latest.coalescedCount} additional request(s)` : "";
return `The most recent wake for ${blockerDiagnosticLabel(input.issue)} was ${latest.status}${wakeDiagnosticReasonPhrase(
latest.reason,
)}${coalesced}.`;
}
if (latest.status === "queued" || latest.status === "claimed") {
return `The most recent wake for ${blockerDiagnosticLabel(input.issue)} is currently ${latest.status}${wakeDiagnosticReasonPhrase(
latest.reason,
)}.`;
}
if (latest.status === "completed") {
return `The most recent wake for ${blockerDiagnosticLabel(input.issue)} completed${wakeDiagnosticReasonPhrase(
latest.reason,
)}.`;
}
}
if (input.events.length > 0) return null;
const blockerDiagnostics = input.blockerDiagnostics;
if (blockerDiagnostics.truncated) {
return `No wake rows are visible for ${blockerDiagnosticLabel(
input.issue,
)} in the bounded window, and blocker diagnostics are truncated, so no wake cause is inferred.`;
}
if ((blockerDiagnostics.omittedUnauthorizedBlockerCount ?? 0) > 0) {
return `No wake rows are visible for ${blockerDiagnosticLabel(
input.issue,
)} in the bounded window, and one or more blockers are outside this actor's authorization boundary.`;
}
if (input.issue.status !== "blocked" || blockerDiagnostics.blockers.length === 0) return null;
const pendingFinalize = blockerDiagnostics.blockers.find((blocker) => blocker.isPendingFinalize);
if (pendingFinalize) {
return `No wake row exists for ${blockerDiagnosticLabel(input.issue)} in the bounded window. ${blockerDiagnosticLabel(
input.issue,
)} is waiting for ${blockerDiagnosticLabel(pendingFinalize)} to finish workspace finalization, so issue_blockers_resolved has not fired.`;
}
const cancelled = blockerDiagnostics.blockers.find((blocker) => blocker.status === "cancelled");
if (cancelled) {
return `No wake row exists for ${blockerDiagnosticLabel(input.issue)} in the bounded window. ${blockerDiagnosticLabel(
input.issue,
)} is blocked by ${blockerDiagnosticLabel(cancelled)}, which is cancelled; cancelled blockers do not fire issue_blockers_resolved.`;
}
const unresolved = blockerDiagnostics.blockers.find((blocker) => blocker.isUnresolved);
if (unresolved) {
return `No wake row exists for ${blockerDiagnosticLabel(input.issue)} in the bounded window. ${blockerDiagnosticLabel(
input.issue,
)} is blocked by ${blockerDiagnosticLabel(unresolved)}, which is ${unresolved.status}, so issue_blockers_resolved has not fired.`;
}
if (blockerDiagnostics.readiness?.isDependencyReady) {
return `No wake row exists for ${blockerDiagnosticLabel(
input.issue,
)} in the bounded window. All visible blockers are resolved, but the issue is still blocked; this is likely a stale blocker hold or an older wake outside the lookback window.`;
}
return null;
}
function buildIssueWakeDiagnosticsResponse(input: {
issue: IssueBlockerDiagnosticReadableIssue;
wakeRequests: Array<{
agentId: string;
source: string;
reason: string | null;
status: string;
coalescedCount: number;
runId: string | null;
requestedAt: Date | string;
claimedAt: Date | string | null;
finishedAt: Date | string | null;
error: string | null;
}>;
activityRecords: Array<{
action: string;
entityType: string;
entityId: string;
agentId: string | null;
runId: string | null;
details: Record<string, unknown> | null;
createdAt: Date | string;
}>;
blockerDiagnostics: IssueBlockerDiagnosticsResponse;
truncatedWakeRequests: boolean;
truncatedActivityRecords: boolean;
includeInternalIds: boolean;
maxWakeRequests?: number;
maxActivityRecords?: number;
lookbackDays?: number;
}): IssueWakeDiagnosticsResponse {
const issue = toIssueBlockerDiagnosticSummary(input.issue);
const events: IssueWakeDiagnosticEvent[] = [
...input.wakeRequests.map((record) =>
projectIssueWakeRequest(record, { includeInternalIds: input.includeInternalIds }),
),
...input.activityRecords.map((record) =>
projectIssueWakeActivityRecord(record, issue.id, { includeInternalIds: input.includeInternalIds }),
),
].sort((left, right) => issueWakeDiagnosticEventTimestamp(right) - issueWakeDiagnosticEventTimestamp(left));
const truncated = input.truncatedWakeRequests || input.truncatedActivityRecords;
const maxWakeRequests = input.maxWakeRequests ?? ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS;
const maxActivityRecords = input.maxActivityRecords ?? ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS;
const lookbackDays = input.lookbackDays ?? ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS;
const diagnosis = buildIssueWakeDiagnosis({
issue,
events,
blockerDiagnostics: input.blockerDiagnostics,
truncated,
maxWakeRequests,
maxActivityRecords,
lookbackDays,
});
return {
issue,
diagnosis,
likelyReason: diagnosis,
events,
wakeRequestCount: input.wakeRequests.length,
activityRecordCount: input.activityRecords.length,
truncated,
truncatedSections: {
wakeRequests: input.truncatedWakeRequests,
activityRecords: input.truncatedActivityRecords,
},
caps: {
maxWakeRequests,
maxActivityRecords,
lookbackDays,
},
};
}
type IssueSubtreeDiagnosticAuthzNode = IssueBlockerDiagnosticAuthzIssue & {
depth: number;
createdAt: Date | string;
updatedAt: Date | string;
};
type IssueSubtreeDiagnosticBlockerAuthzRow = IssueBlockerDiagnosticAuthzIssue & {
blockedIssueId: string;
relationCreatedAt: Date | string;
};
type IssueSubtreeDiagnosticWakeRequestRow = {
issueId: string;
agentId: string;
source: string;
reason: string | null;
status: string;
coalescedCount: number;
runId: string | null;
requestedAt: Date | string;
claimedAt: Date | string | null;
finishedAt: Date | string | null;
error: string | null;
};
type IssueSubtreeDiagnosticActivityRow = {
issueId: string;
action: string;
entityType: string;
entityId: string;
agentId: string | null;
runId: string | null;
details: Record<string, unknown> | null;
createdAt: Date | string;
};
function groupByIssueId<T extends { issueId: string }>(rows: T[]) {
const map = new Map<string, T[]>();
for (const row of rows) {
const issueRows = map.get(row.issueId) ?? [];
issueRows.push(row);
map.set(row.issueId, issueRows);
}
return map;
}
function groupBlockersByBlockedIssueId(rows: IssueSubtreeDiagnosticBlockerAuthzRow[]) {
const map = new Map<string, IssueSubtreeDiagnosticBlockerAuthzRow[]>();
for (const row of rows) {
const issueRows = map.get(row.blockedIssueId) ?? [];
issueRows.push(row);
map.set(row.blockedIssueId, issueRows);
}
return map;
}
function issueSubtreeEdgeTimestamp(edge: IssueSubtreeDiagnosticEdge) {
return edge.timestamp ? new Date(edge.timestamp).getTime() : 0;
}
function buildIssueSubtreeDiagnosis(input: {
issue: IssueBlockerDiagnosticIssueSummary;
nodes: IssueSubtreeDiagnosticNode[];
omittedUnauthorizedNodeCount: number | null;
truncated: boolean;
caps: IssueSubtreeDiagnosticsResponse["caps"];
}) {
if (input.truncated) {
return `Subtree diagnostics for ${blockerDiagnosticLabel(input.issue)} are bounded to depth ${
input.caps.maxDepth
} and ${input.caps.maxNodes} nodes, so the diagnosis only covers returned visible nodes.`;
}
if ((input.omittedUnauthorizedNodeCount ?? 0) > 0) {
return `One or more subtree nodes under ${blockerDiagnosticLabel(
input.issue,
)} are outside this actor's authorization boundary, so this diagnosis only covers visible nodes.`;
}
const blockedNodeWithDiagnosis = input.nodes.find((node) => node.issue.status === "blocked" && node.diagnosis);
const firstNodeWithDiagnosis = blockedNodeWithDiagnosis ?? input.nodes.find((node) => node.diagnosis);
if (!firstNodeWithDiagnosis?.diagnosis) return null;
return `${blockerDiagnosticLabel(firstNodeWithDiagnosis.issue)} appears to be the subtree stall point: ${
firstNodeWithDiagnosis.diagnosis
}`;
}
function buildIssueSubtreeDiagnosticsResponse(input: {
issue: IssueBlockerDiagnosticReadableIssue;
nodes: IssueSubtreeDiagnosticAuthzNode[];
visibleNodes: IssueSubtreeDiagnosticAuthzNode[];
blockersByIssueId: Map<string, IssueSubtreeDiagnosticBlockerAuthzRow[]>;
visibleBlockers: IssueSubtreeDiagnosticBlockerAuthzRow[];
readinessByIssueId: Map<string, {
allBlockersDone: boolean;
isDependencyReady: boolean;
unresolvedBlockerIssueIds: string[];
pendingFinalizeBlockerIssueIds: string[];
}>;
wakeRequestsByIssueId: Map<string, IssueSubtreeDiagnosticWakeRequestRow[]>;
activityRecordsByIssueId: Map<string, IssueSubtreeDiagnosticActivityRow[]>;
truncatedNodes: boolean;
truncatedDepth: boolean;
truncatedBlockerIssueIds: Set<string>;
truncatedWakeIssueIds: Set<string>;
truncatedActivityIssueIds: Set<string>;
includeInternalIds: boolean;
caps: IssueSubtreeDiagnosticsResponse["caps"];
}): IssueSubtreeDiagnosticsResponse {
const issue = toIssueBlockerDiagnosticSummary(input.issue);
const visibleNodeIds = new Set(input.visibleNodes.map((node) => node.id));
const visibleBlockerIdsByIssueId = groupBlockersByBlockedIssueId(input.visibleBlockers);
const omittedUnauthorizedNodeCount = input.truncatedNodes || input.truncatedDepth
? null
: input.nodes.filter((node) => !visibleNodeIds.has(node.id)).length;
const nodeResponses: IssueSubtreeDiagnosticNode[] = [];
const edges: IssueSubtreeDiagnosticEdge[] = [];
for (const node of input.visibleNodes) {
const rawBlockers = input.blockersByIssueId.get(node.id) ?? [];
const visibleBlockers = visibleBlockerIdsByIssueId.get(node.id) ?? [];
const blockerResponse = buildIssueBlockerDiagnosticsResponse({
issue: node,
blockers: rawBlockers,
visibleBlockers,
readiness: input.readinessByIssueId.get(node.id) ?? {
allBlockersDone: true,
isDependencyReady: true,
unresolvedBlockerIssueIds: [],
pendingFinalizeBlockerIssueIds: [],
},
truncated: input.truncatedBlockerIssueIds.has(node.id),
maxBlockers: input.caps.maxBlockersPerNode,
});
const wakeResponse = buildIssueWakeDiagnosticsResponse({
issue: node,
wakeRequests: input.wakeRequestsByIssueId.get(node.id) ?? [],
activityRecords: input.activityRecordsByIssueId.get(node.id) ?? [],
blockerDiagnostics: blockerResponse,
truncatedWakeRequests: input.truncatedWakeIssueIds.has(node.id),
truncatedActivityRecords: input.truncatedActivityIssueIds.has(node.id),
includeInternalIds: input.includeInternalIds,
maxWakeRequests: input.caps.maxWakeRequestsPerNode,
maxActivityRecords: input.caps.maxActivityRecordsPerNode,
lookbackDays: input.caps.lookbackDays,
});
const nodeDiagnosis = wakeResponse.diagnosis ?? blockerResponse.diagnosis;
if (node.parentId && visibleNodeIds.has(node.parentId)) {
edges.push({
kind: "parent",
fromIssueId: node.parentId,
toIssueId: node.id,
timestamp: dateToIso(node.createdAt),
});
}
for (const blocker of visibleBlockers) {
edges.push({
kind: "blocks",
fromIssueId: blocker.id,
toIssueId: node.id,
timestamp: dateToIso(blocker.relationCreatedAt),
});
}
for (const event of wakeResponse.events) {
if (event.kind === "wake_request") {
edges.push({
kind: "wake_request",
issueId: node.id,
agentId: event.agentId,
reason: event.reason,
status: event.status,
timestamp: event.requestedAt,
});
} else {
edges.push({
kind: "activity",
issueId: node.id,
action: event.action,
timestamp: event.createdAt,
});
}
}
nodeResponses.push({
issue: toIssueBlockerDiagnosticSummary(node),
parentId: node.parentId && visibleNodeIds.has(node.parentId) ? node.parentId : null,
depth: node.depth,
diagnosis: nodeDiagnosis,
likelyReason: nodeDiagnosis,
blockers: blockerResponse.blockers,
blockerReadiness: blockerResponse.readiness,
omittedUnauthorizedBlockerCount: blockerResponse.omittedUnauthorizedBlockerCount,
wakeEvents: wakeResponse.events,
wakeRequestCount: wakeResponse.wakeRequestCount,
activityRecordCount: wakeResponse.activityRecordCount,
truncated: blockerResponse.truncated || wakeResponse.truncated,
truncatedSections: {
blockers: blockerResponse.truncated,
wakeRequests: wakeResponse.truncatedSections.wakeRequests,
activityRecords: wakeResponse.truncatedSections.activityRecords,
},
});
}
edges.sort((left, right) => issueSubtreeEdgeTimestamp(right) - issueSubtreeEdgeTimestamp(left));
const truncatedSections = {
nodes: input.truncatedNodes,
depth: input.truncatedDepth,
blockers: input.truncatedBlockerIssueIds.size > 0,
wakeRequests: input.truncatedWakeIssueIds.size > 0,
activityRecords: input.truncatedActivityIssueIds.size > 0,
};
const truncated = Object.values(truncatedSections).some(Boolean);
const diagnosis = buildIssueSubtreeDiagnosis({
issue,
nodes: nodeResponses,
omittedUnauthorizedNodeCount,
truncated,
caps: input.caps,
});
return {
issue,
diagnosis,
likelyReason: diagnosis,
nodes: nodeResponses,
edges,
nodeCount: nodeResponses.length,
omittedUnauthorizedNodeCount,
truncated,
truncatedSections,
caps: input.caps,
};
}
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 actorMatchesExecutionParticipant(
actor: { actorType: "user" | "agent"; actorId: string },
participant: ParsedExecutionState["currentParticipant"] | null,
) {
if (!participant) return false;
// Require the actor kind to match the participant kind before comparing ids. Without this
// an agent and a user that happen to share an id value would falsely satisfy participant
// gating on the auto-approval path.
if (participant.type !== actor.actorType) return false;
return participant.type === "agent" ? participant.agentId === actor.actorId : participant.userId === actor.actorId;
}
// Negation/rejection markers that invalidate an otherwise approval-looking heading.
// Match common phrasings ("NOT APPROVED", "Do not approve", "Not approving", "Changes requested",
// "Rejected", "Denied", "Blocked") so a reviewer comment intending to reject cannot auto-complete
// the issue. We rely on the heading being a single line, so testing the heading text alone is safe.
const APPROVAL_NEGATION_REGEX =
/\b(?:NOT|REJECT(?:ED|ING|S)?|DENY|DENIED|DENYING|BLOCK(?:ED|ING|S)?|CHANGES?\s+REQUESTED)\b/i;
function isApprovalReviewComment(body: string) {
const normalized = body.replace(/\r\n?/g, "\n");
const headingMatch = normalized.match(/(?:^|\n)##\s*Review:\s*([^\n]*)/i);
if (headingMatch) {
const headingText = headingMatch[1];
if (/\bAPPROVED\b/i.test(headingText) && !APPROVAL_NEGATION_REGEX.test(headingText)) {
return true;
}
}
// Require the `kind: review` and `decision: approved` lines to appear on truly consecutive
// lines (no blank-line separation) so prose like "the previous sprint decision: approved"
// can't combine with an unrelated `kind: review` line elsewhere in the body to trigger
// auto-approval. Use `[ \t]*` between the lines so `\s*` does not silently swallow a newline.
return (
/^[ \t]*kind[ \t]*:[ \t]*review[ \t]*\n[ \t]*decision[ \t]*:[ \t]*approved[ \t]*$/im.test(normalized)
|| /^[ \t]*decision[ \t]*:[ \t]*approved[ \t]*\n[ \t]*kind[ \t]*:[ \t]*review[ \t]*$/im.test(normalized)
);
}
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");
}
async function assertCanManageIssueMonitor(
accessSvc: ReturnType<typeof accessService>,
req: Request,
companyId: string,
assigneeAgentId: string | null,
monitorChanged: boolean,
) {
if (!monitorChanged) return;
if (req.actor.type === "board") return;
const runtimeDecision = await accessSvc.decide({
actor: req.actor,
action: "runtime:manage",
resource: { type: "company", companyId },
});
if (!runtimeDecision.allowed) {
throw forbidden(runtimeDecision.explanation, authorizationDeniedDetails(runtimeDecision));
}
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;
actorRunId: string | null | undefined;
checkoutRunId: string | null | undefined;
executionRunId: string | null | undefined;
requestAddsExplicitBlockers?: boolean;
}) {
// A request that wires a non-empty blockedByIssueIds list is declaring that
// the issue is waiting on other work. The implicit reopen exists for plain
// conversational comments ("please continue"), not structured dependency
// edits — flipping to todo here would contradict the caller's stated intent
// in the same request.
if (input.requestAddsExplicitBlockers) return false;
// Local-CLI agents post comments under user auth, so the actor.type is "user"
// even though the comment originates from the same heartbeat run that owns
// the issue lock. Without this guard, an agent that closes its own issue and
// then posts a follow-up comment in the same run silently reopens it.
// Suppress the implicit move whenever the comment's source run matches the
// issue's checkout/execution run.
if (
typeof input.actorRunId === "string"
&& input.actorRunId.length > 0
&& (input.actorRunId === input.checkoutRunId || input.actorRunId === input.executionRunId)
) {
return false;
}
// 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 shouldHumanCommentResumeInProgressScheduledRetry(input: {
hasComment: boolean;
issueStatus: string | null | undefined;
assigneeAgentId: string | null | undefined;
actorType: "agent" | "user";
}) {
if (!input.hasComment) return false;
if (input.actorType !== "user") return false;
if (input.issueStatus !== "in_progress") return false;
return typeof input.assigneeAgentId === "string" && input.assigneeAgentId.length > 0;
}
function isExplicitResumeCapableStatus(status: string | null | undefined) {
return status === "done" || status === "cancelled" || status === "blocked" || status === "todo" || status === "in_progress";
}
// Log-class comment from the assignee agent on a terminal (done/cancelled)
// issue is not a reopen signal. When the caller did not pass `resume: true`,
// this forces the reopen path off even if `reopen: true` was sent.
function isAssigneeSelfCommentOnTerminalIssue(input: {
hasCommentBody: boolean;
resumeRequested: boolean;
issueStatus: string | null | undefined;
assigneeAgentId: string | null | undefined;
actorType: "agent" | "user";
actorId: string;
}) {
if (!input.hasCommentBody) return false;
if (input.resumeRequested) return false;
if (!isClosedIssueStatus(input.issueStatus)) return false;
if (typeof input.assigneeAgentId !== "string" || input.assigneeAgentId.length === 0) return false;
if (input.actorType !== "agent") return false;
return input.actorId === input.assigneeAgentId;
}
function readToolActionExecutionStatus(value: unknown) {
return value === "approved"
|| value === "executing"
|| value === "executed"
|| value === "failed"
|| value === "expired"
? value
: null;
}
function readToolActionContinuationContext(interaction: {
status: string;
payload?: unknown;
result?: unknown;
}) {
const payload = readObject(interaction.payload);
const toolActionPayload = readObject(payload.toolAction);
const toolName = readNonEmptyString(toolActionPayload.toolName);
const actionRequestId = readNonEmptyString(toolActionPayload.actionRequestId);
if (!toolName || !actionRequestId) return null;
const result = readObject(interaction.result);
const toolActionResult = readObject(result.toolAction);
const declineReason = interaction.status === "rejected"
? readNonEmptyString(result.reason)
: null;
const error = readNonEmptyString(toolActionResult.errorMessage);
const resultSummary = readNonEmptyString(toolActionResult.resultSummary);
if (interaction.status === "rejected") {
return {
toolName,
actionRequestId,
decision: "rejected",
executionStatus: "rejected",
...(declineReason ? { declineReason } : {}),
instructions: `the action was declined${declineReason ? `: ${declineReason}` : ""}; do not retry the same call — adjust your approach or mark the task blocked/in_review with the decline reason.`,
};
}
if (interaction.status !== "accepted") return null;
const executionStatus = readToolActionExecutionStatus(toolActionResult.status);
if (!executionStatus) return null;
if (executionStatus === "executed") {
return {
toolName,
actionRequestId,
decision: "accepted",
executionStatus,
...(resultSummary ? { resultSummary } : {}),
instructions: `the approved ${toolName} action already ran — do not call the tool again; continue with this result.`,
};
}
if (executionStatus === "failed") {
const failureMessage = error ?? "an unknown error";
return {
toolName,
actionRequestId,
decision: "accepted",
executionStatus,
...(error ? { error } : {}),
instructions: `the approved action ran and failed with ${failureMessage}; adjust your approach — a fresh call will open a new approval.`,
};
}
return {
toolName,
actionRequestId,
decision: "accepted",
executionStatus,
instructions: `the approved ${toolName} action is ${executionStatus}; do not call the tool again while this approval is being processed.`,
};
}
const REQUEST_ITEM_VERDICTS_WAKE_COALESCE_WINDOW_MS = 2_000;
function buildRequestItemVerdictsWakeIdempotencyKey(args: {
issueId: string;
interactionId: string;
at?: Date;
}) {
const now = args.at ?? new Date();
const bucket = Math.floor(now.getTime() / REQUEST_ITEM_VERDICTS_WAKE_COALESCE_WINDOW_MS);
return `request_item_verdicts:${args.issueId}:${args.interactionId}:${bucket}`;
}
async function queueResolvedInteractionContinuationWakeup(input: {
db: Db;
heartbeat: ReturnType<typeof heartbeatService>;
issue: { id: string; companyId: string; assigneeAgentId: string | null; status: string };
interaction: {
id: string;
kind: string;
status: string;
continuationPolicy: string;
sourceCommentId?: string | null;
sourceRunId?: string | null;
payload?: unknown;
result?: unknown;
};
actor: { actorType: "user" | "agent"; actorId: string };
source: string;
forceFreshSession?: boolean;
workspaceRefreshReason?: string | null;
newlyResolvedItemIds?: string[];
idempotencyKey?: string | null;
}) {
if (!input.issue.assigneeAgentId || isClosedIssueStatus(input.issue.status)) return;
const reviewPathLost = input.issue.status === "in_review"
&& (await issueService(input.db)
.listReviewAttention(input.issue.companyId, [input.issue])
.then((attention) => attention.get(input.issue.id)?.state === "stalled")
.catch((err) => {
logger.warn(
{ err, issueId: input.issue.id, interactionId: input.interaction.id },
"failed to classify review path after issue interaction resolution",
);
return false;
}));
const continuationPolicyAllowsWake =
input.interaction.continuationPolicy === "wake_assignee"
|| (
input.interaction.continuationPolicy === "wake_assignee_on_accept"
&& input.interaction.status === "accepted"
);
if (!continuationPolicyAllowsWake && !reviewPathLost) return;
if (input.interaction.status === "expired" && !reviewPathLost) return;
const reviewPathContext = reviewPathLost
? {
reviewPathLost: true,
reviewPathConsumedRef: input.interaction.id,
reviewPathInstruction: REVIEW_PATH_RECOVERY_INSTRUCTION,
}
: null;
const forceFreshSession = input.forceFreshSession === true;
const workspaceRefreshReason = readNonEmptyString(input.workspaceRefreshReason);
const planTarget = readPlanConfirmationTargetForIssue(input.interaction.payload, input.issue.id);
const interactionResult = readConfirmationResultForWake(input.interaction.result);
const checkboxSelection = readCheckboxSelectionForWake(input.interaction);
const toolAction = readToolActionContinuationContext(input.interaction);
const newlyResolvedItemIds = input.newlyResolvedItemIds?.filter((value) => value.length > 0) ?? [];
const itemVerdicts = newlyResolvedItemIds.length > 0
? {
newlyResolvedItemIds,
coalesceWindowMs: REQUEST_ITEM_VERDICTS_WAKE_COALESCE_WINDOW_MS,
}
: null;
const planReviewInteraction =
planTarget && input.interaction.kind === "request_confirmation"
? {
id: input.interaction.id,
kind: input.interaction.kind,
status: input.interaction.status,
target: planTarget,
acceptedTargetRevision: input.interaction.status === "accepted" ? planTarget : null,
result: interactionResult,
}
: null;
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,
...(planReviewInteraction ? { planReviewInteraction } : {}),
...(checkboxSelection ? { checkboxSelection } : {}),
...(toolAction ? { toolAction } : {}),
...(itemVerdicts ? { itemVerdicts, newlyResolvedItemIds } : {}),
...(reviewPathContext ?? {}),
mutation: "interaction",
},
idempotencyKey: input.idempotencyKey ?? `interaction:${input.interaction.id}:${input.interaction.status}`,
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,
...(planReviewInteraction ? { planReviewInteraction } : {}),
...(checkboxSelection ? { checkboxSelection } : {}),
...(toolAction ? { toolAction } : {}),
...(itemVerdicts ? { itemVerdicts, newlyResolvedItemIds } : {}),
...(reviewPathContext ?? {}),
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 readCheckboxSelectionForWake(input: {
kind: string;
payload?: unknown;
result?: unknown;
}) {
if (input.kind !== "request_checkbox_confirmation") return null;
const result = readObject(input.result);
if (result.outcome !== "accepted") return null;
const selectedOptionIds = Array.isArray(result.selectedOptionIds)
? result.selectedOptionIds.filter((value): value is string => typeof value === "string" && value.length > 0)
: [];
const payload = readObject(input.payload);
const options = Array.isArray(payload.options)
? payload.options
.map((value) => {
const option = readObject(value);
const id = readNonEmptyString(option.id);
if (!id) return null;
return {
id,
label: readNonEmptyString(option.label) ?? id,
description: readNonEmptyString(option.description),
};
})
.filter((value): value is { id: string; label: string; description: string | null } => Boolean(value))
: [];
const optionById = new Map(options.map((option) => [option.id, option]));
return {
prompt: readNonEmptyString(payload.prompt),
selectedOptionIds,
selectedOptions: selectedOptionIds.map((id) => optionById.get(id) ?? { id, label: id, description: null }),
};
}
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;
}
class AutoApprovalIssueMissingError extends Error {
constructor() {
super("Issue not found during auto-approval transaction");
this.name = "AutoApprovalIssueMissingError";
}
}
function toCompactIssue(issue: any): CompactIssue {
return {
id: issue.id,
companyId: issue.companyId,
projectId: issue.projectId,
projectWorkspaceId: issue.projectWorkspaceId,
goalId: issue.goalId,
parentId: issue.parentId,
title: issue.title,
description: issue.description,
status: issue.status,
workMode: issue.workMode,
priority: issue.priority,
reviewPolicy: issue.reviewPolicy,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
checkoutRunId: issue.checkoutRunId,
executionRunId: issue.executionRunId,
executionAgentNameKey: issue.executionAgentNameKey,
executionLockedAt: issue.executionLockedAt,
createdByAgentId: issue.createdByAgentId,
createdByUserId: issue.createdByUserId,
issueNumber: issue.issueNumber,
identifier: issue.identifier,
originKind: issue.originKind,
originId: issue.originId,
originRunId: issue.originRunId,
requestDepth: issue.requestDepth,
billingCode: issue.billingCode,
executionWorkspaceId: issue.executionWorkspaceId,
startedAt: issue.startedAt,
completedAt: issue.completedAt,
cancelledAt: issue.cancelledAt,
createdAt: issue.createdAt,
updatedAt: issue.updatedAt,
...(issue.labelIds ? { labelIds: issue.labelIds } : {}),
...(issue.labels ? { labels: issue.labels } : {}),
...(issue.blockedBy ? { blockedBy: issue.blockedBy } : {}),
...(issue.blockerAttention ? { blockerAttention: issue.blockerAttention } : {}),
...(issue.reviewAttention ? { reviewAttention: issue.reviewAttention } : {}),
...(issue.blockedInboxAttention !== undefined ? { blockedInboxAttention: issue.blockedInboxAttention } : {}),
...(issue.productivityReview ? { productivityReview: issue.productivityReview } : {}),
...(issue.scheduledRetry ? { scheduledRetry: issue.scheduledRetry } : {}),
...(issue.liveDescendantCount !== undefined ? { liveDescendantCount: issue.liveDescendantCount } : {}),
...(issue.myLastTouchAt !== undefined ? { myLastTouchAt: issue.myLastTouchAt } : {}),
...(issue.lastExternalCommentAt !== undefined ? { lastExternalCommentAt: issue.lastExternalCommentAt } : {}),
...(issue.lastActivityAt !== undefined ? { lastActivityAt: issue.lastActivityAt } : {}),
...(issue.isUnreadForMe !== undefined ? { isUnreadForMe: issue.isUnreadForMe } : {}),
activeRecoveryAction: issue.activeRecoveryAction ?? null,
successfulRunHandoff: issue.successfulRunHandoff ?? null,
};
}
function compactIssueListEtag(issues: CompactIssue[]): string {
const hash = createHash("sha256")
.update(JSON.stringify(issues))
.digest("base64url");
return `"compact-issues:${hash}"`;
}
function requestMatchesEtag(ifNoneMatchHeader: string | undefined, etag: string): boolean {
if (!ifNoneMatchHeader) return false;
return ifNoneMatchHeader
.split(",")
.map((value) => value.trim())
.some((value) => value === "*" || value === etag || value === `W/${etag}`);
}
const ISSUE_LIST_SERVER_CACHE_TTL_MS = 2_000;
const ISSUE_LIST_SERVER_CACHE_STALE_MS = 5_000;
export const ISSUE_LIST_SERVER_CACHE_MAX_ENTRIES = 256;
const ISSUE_LIST_STORM_WINDOW_MS = 500;
const ISSUE_LIST_STORM_THRESHOLD = 4;
const ISSUE_LIST_MAX_ACTOR_CLIENT_INFLIGHT = 8;
type IssueListPreparedResponse =
| {
kind: "compact";
body: CompactIssue[];
etag: string;
cacheControl: string;
}
| {
kind: "full";
body: unknown[];
};
type IssueListCacheStatus = "miss" | "hit" | "coalesced" | "stale" | "retry";
type IssueListStormEvent = {
event: "request_storm_detected";
route: string;
companyId: string;
actorType: string;
actorIdentityHash: string;
clientHash: string;
cacheKeyHash: string;
queryKeys: string[];
identicalInFlightCount: number;
windowMs: number;
referer: string | null;
visibilityHint: string | null;
};
type IssueListDiagnostics = {
onComputeStart?: (context: { companyId: string; cacheKeyHash: string }) => void | Promise<void>;
onStormDetected?: (event: IssueListStormEvent) => void;
};
type IssueListCacheEntry = {
response: IssueListPreparedResponse;
expiresAt: number;
staleUntil: number;
};
type IssueListInflightEntry = {
promise: Promise<IssueListPreparedResponse>;
startedAt: number;
waiterCount: number;
stormLogged: boolean;
};
const issueListResponseCache = new Map<string, IssueListCacheEntry>();
const issueListInflight = new Map<string, IssueListInflightEntry>();
const issueListActorClientInflight = new Map<string, number>();
export function __getIssueListResponseCacheSizeForTests() {
return issueListResponseCache.size;
}
export function __clearIssueListResponseCacheForTests() {
issueListResponseCache.clear();
}
function shortHash(value: string): string {
return createHash("sha256").update(value).digest("base64url").slice(0, 16);
}
function stableJson(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.sort()
.filter((key) => record[key] !== undefined)
.map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`)
.join(",")}}`;
}
function normalizeIssueListCacheValue(value: unknown): unknown {
if (value === undefined) return undefined;
if (value === null) return null;
if (Array.isArray(value)) {
return value.map(normalizeIssueListCacheValue).sort((a, b) => stableJson(a).localeCompare(stableJson(b)));
}
if (typeof value === "object") {
const normalized: Record<string, unknown> = {};
for (const [key, nestedValue] of Object.entries(value as Record<string, unknown>)) {
const next = normalizeIssueListCacheValue(nestedValue);
if (next !== undefined) normalized[key] = next;
}
return normalized;
}
return value;
}
function issueListActorIdentity(req: Request, companyId: string) {
if (req.actor.type === "agent") {
const onBehalfMembership = req.actor.onBehalfOfUserId
? req.actor.onBehalfOfMemberships?.find((membership) => membership.companyId === companyId) ?? null
: null;
const key = [
"agent",
companyId,
req.actor.agentId ?? "unknown-agent",
req.actor.keyId ?? req.actor.source ?? "agent-auth",
req.actor.onBehalfOfUserId ?? "no-responsible-user",
onBehalfMembership?.status ?? "no-responsible-user-status",
onBehalfMembership?.membershipRole ?? "no-responsible-user-role",
].join(":");
return { actorType: "agent", key, hash: shortHash(key) };
}
if (req.actor.type === "board") {
const sessionPart = req.actor.source === "session"
? `cookie:${shortHash(String(req.headers.cookie ?? "no-cookie"))}`
: req.actor.keyId ?? req.actor.source ?? "board";
const key = [
"board",
companyId,
req.actor.source ?? "board",
req.actor.userId ?? "unknown-user",
sessionPart,
].join(":");
return { actorType: "board", key, hash: shortHash(key) };
}
const key = ["none", companyId, req.actor.source ?? "none"].join(":");
return { actorType: "none", key, hash: shortHash(key) };
}
function issueListClientIdentity(req: Request) {
const forwardedFor = Array.isArray(req.headers["x-forwarded-for"])
? req.headers["x-forwarded-for"][0]
: req.headers["x-forwarded-for"];
const client = [
String(forwardedFor ?? req.ip ?? "unknown-ip").split(",")[0]?.trim() ?? "unknown-ip",
req.header("user-agent") ?? "unknown-agent",
].join(":");
return { key: client, hash: shortHash(client) };
}
function safeRefererPath(req: Request): string | null {
const referer = req.header("referer");
if (!referer) return null;
try {
return new URL(referer).pathname;
} catch {
return referer.split("?")[0]?.slice(0, 160) ?? null;
}
}
function issueListRequestKey(input: {
req: Request;
companyId: string;
normalizedQuery: Record<string, unknown>;
}) {
const route = "GET /api/companies/:companyId/issues";
const actor = issueListActorIdentity(input.req, input.companyId);
const client = issueListClientIdentity(input.req);
const normalizedQuery = normalizeIssueListCacheValue(input.normalizedQuery) as Record<string, unknown>;
const queryKeys = Object.keys(normalizedQuery).sort();
const key = stableJson({
actor: actor.key,
companyId: input.companyId,
query: normalizedQuery,
route,
});
return {
actor,
client,
key,
keyHash: shortHash(key),
queryKeys,
route,
};
}
function pruneIssueListResponseCache(now: number) {
for (const [key, entry] of issueListResponseCache) {
if (entry.staleUntil <= now) issueListResponseCache.delete(key);
}
}
function touchIssueListResponseCacheEntry(key: string, entry: IssueListCacheEntry) {
issueListResponseCache.delete(key);
issueListResponseCache.set(key, entry);
}
function trimIssueListResponseCache() {
while (issueListResponseCache.size > ISSUE_LIST_SERVER_CACHE_MAX_ENTRIES) {
const oldestKey = issueListResponseCache.keys().next().value as string | undefined;
if (oldestKey === undefined) return;
issueListResponseCache.delete(oldestKey);
}
}
function setIssueListResponseCacheEntry(key: string, entry: IssueListCacheEntry) {
touchIssueListResponseCacheEntry(key, entry);
trimIssueListResponseCache();
}
function decrementIssueListActorClientInflight(actorClientKey: string) {
const next = (issueListActorClientInflight.get(actorClientKey) ?? 1) - 1;
if (next <= 0) issueListActorClientInflight.delete(actorClientKey);
else issueListActorClientInflight.set(actorClientKey, next);
}
async function coordinateIssueListGet(input: {
req: Request;
companyId: string;
requestKey: ReturnType<typeof issueListRequestKey>;
allowTtlCache: boolean;
diagnostics?: IssueListDiagnostics;
compute: () => Promise<IssueListPreparedResponse>;
}): Promise<{
response: IssueListPreparedResponse | null;
cacheStatus: IssueListCacheStatus;
identicalInFlightCount: number;
retryAfterSeconds?: number;
}> {
const now = Date.now();
pruneIssueListResponseCache(now);
const cached = input.allowTtlCache ? issueListResponseCache.get(input.requestKey.key) : undefined;
if (cached && cached.expiresAt > now) {
touchIssueListResponseCacheEntry(input.requestKey.key, cached);
return { response: cached.response, cacheStatus: "hit", identicalInFlightCount: 0 };
}
const existing = issueListInflight.get(input.requestKey.key);
if (existing) {
existing.waiterCount += 1;
const identicalInFlightCount = existing.waiterCount + 1;
if (
!existing.stormLogged &&
identicalInFlightCount >= ISSUE_LIST_STORM_THRESHOLD &&
now - existing.startedAt <= ISSUE_LIST_STORM_WINDOW_MS
) {
existing.stormLogged = true;
const event: IssueListStormEvent = {
event: "request_storm_detected",
route: input.requestKey.route,
companyId: input.companyId,
actorType: input.requestKey.actor.actorType,
actorIdentityHash: input.requestKey.actor.hash,
clientHash: input.requestKey.client.hash,
cacheKeyHash: input.requestKey.keyHash,
queryKeys: input.requestKey.queryKeys,
identicalInFlightCount,
windowMs: now - existing.startedAt,
referer: safeRefererPath(input.req),
visibilityHint: input.req.header("x-paperclip-tab-visible") ?? null,
};
logger.warn(event, "request_storm_detected");
input.diagnostics?.onStormDetected?.(event);
}
const response = await existing.promise;
return { response, cacheStatus: "coalesced", identicalInFlightCount };
}
const actorClientKey = `${input.requestKey.actor.key}:${input.requestKey.client.key}`;
const actorClientInflight = issueListActorClientInflight.get(actorClientKey) ?? 0;
if (actorClientInflight >= ISSUE_LIST_MAX_ACTOR_CLIENT_INFLIGHT) {
if (cached && cached.staleUntil > now) {
touchIssueListResponseCacheEntry(input.requestKey.key, cached);
return { response: cached.response, cacheStatus: "stale", identicalInFlightCount: 0 };
}
return { response: null, cacheStatus: "retry", identicalInFlightCount: 0, retryAfterSeconds: 1 };
}
issueListActorClientInflight.set(actorClientKey, actorClientInflight + 1);
const promise = (async () => {
await input.diagnostics?.onComputeStart?.({
companyId: input.companyId,
cacheKeyHash: input.requestKey.keyHash,
});
return input.compute();
})();
const inflightEntry: IssueListInflightEntry = {
promise,
startedAt: now,
waiterCount: 0,
stormLogged: false,
};
issueListInflight.set(input.requestKey.key, inflightEntry);
try {
const response = await promise;
if (input.allowTtlCache) {
setIssueListResponseCacheEntry(input.requestKey.key, {
response,
expiresAt: Date.now() + ISSUE_LIST_SERVER_CACHE_TTL_MS,
staleUntil: Date.now() + ISSUE_LIST_SERVER_CACHE_STALE_MS,
});
}
return { response, cacheStatus: "miss", identicalInFlightCount: 1 };
} finally {
if (issueListInflight.get(input.requestKey.key) === inflightEntry) {
issueListInflight.delete(input.requestKey.key);
}
decrementIssueListActorClientInflight(actorClientKey);
}
}
function estimatedJsonBytes(value: unknown): number {
return Buffer.byteLength(JSON.stringify(value), "utf8");
}
function logIssueListRequest(input: {
req: Request;
res: Response;
companyId: string;
requestKey: ReturnType<typeof issueListRequestKey>;
startedAt: number;
cacheStatus: IssueListCacheStatus;
bodyBytes: number;
etagOutcome: "none" | "fresh" | "not_modified";
identicalInFlightCount: number;
}) {
input.res.once("finish", () => {
const contentEncoding = input.res.getHeader("content-encoding");
const contentLength = Number(input.res.getHeader("content-length"));
logger.debug({
event: "safe_get_request_observed",
route: input.requestKey.route,
companyId: input.companyId,
actorType: input.requestKey.actor.actorType,
actorIdentityHash: input.requestKey.actor.hash,
clientHash: input.requestKey.client.hash,
cacheKeyHash: input.requestKey.keyHash,
queryKeys: input.requestKey.queryKeys,
requestCount: input.identicalInFlightCount,
durationMs: Date.now() - input.startedAt,
statusCode: input.res.statusCode,
responseBytes: input.bodyBytes,
compressedBytes: contentEncoding && Number.isFinite(contentLength) ? contentLength : null,
contentEncoding: contentEncoding ? String(contentEncoding) : null,
cacheStatus: input.cacheStatus,
etagOutcome: input.etagOutcome,
referer: safeRefererPath(input.req),
visibilityHint: input.req.header("x-paperclip-tab-visible") ?? null,
}, "safe authenticated GET observed");
});
}
export function issueRoutes(
db: Db,
storage: StorageService,
opts: {
feedbackExportService?: {
flushPendingFeedbackTraces(input?: {
companyId?: string;
traceId?: string;
limit?: number;
now?: Date;
}): Promise<unknown>;
};
searchService?: CompanySearchService;
searchRateLimiter?: CompanySearchRateLimiter;
pluginWorkerManager?: PluginWorkerManager;
taskWatchdogEnqueueWakeup?: TaskWatchdogServiceDeps["enqueueWakeup"] | null;
recoveryActionEnqueueWakeup?: (
agentId: string,
options: Parameters<ReturnType<typeof heartbeatService>["wakeup"]>[1],
) => ReturnType<ReturnType<typeof heartbeatService>["wakeup"]>;
stalledReviewDecisionEnqueueWakeup?: (
agentId: string,
options: Parameters<ReturnType<typeof heartbeatService>["wakeup"]>[1],
) => ReturnType<ReturnType<typeof heartbeatService>["wakeup"]>;
issueListDiagnostics?: IssueListDiagnostics;
approveToolActionRequest?: (input: {
companyId: string;
issueId: string;
interactionId: string;
actionRequestId: string;
actor: { agentId?: string | null; userId?: string | null };
}) => Promise<unknown>;
} = {},
) {
const router = Router();
const svc = issueService(db);
const runRedactions = createRunSecretRedactionRegistry(db);
const access = accessService(db);
const budgetsSvc = budgetService(db);
const heartbeat = heartbeatService(db, {
pluginWorkerManager: opts.pluginWorkerManager,
});
const enqueueStalledReviewDecisionWakeup = opts.stalledReviewDecisionEnqueueWakeup ?? heartbeat.wakeup;
const enqueueRecoveryActionWakeup = opts.recoveryActionEnqueueWakeup ?? heartbeat.wakeup;
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 companySkillsSvc = companySkillService(db);
const documentAnnotationsSvc = documentAnnotationService(db);
const decisionTrainingSvc = decisionTrainingService(db);
const issueReferencesSvc = issueReferenceService(db);
const issueThreadInteractionsSvc = issueThreadInteractionService(db);
const memoizeIssueRead = createRequestPromiseMemo<Request, Awaited<ReturnType<typeof svc.getById>>>({
shouldCache: (issue) => issue !== null,
});
const memoizeIssueReadDecision = createRequestPromiseMemo<Request, Awaited<ReturnType<typeof decideIssueAccess>>>();
function getIssueById(req: Request, id: string) {
if (req.method !== "GET") return svc.getById(id);
return memoizeIssueRead(req, id, () => svc.getById(id));
}
const issueDetailEtag = privateJsonEtag();
router.use((req, res, next) => {
if (/^\/issues\/[^/]+(?:\/|$)/.test(req.path)) {
issueDetailEtag(req, res, next);
return;
}
next();
});
const taskWatchdogFactory: TaskWatchdogServiceFactory | undefined = Object.prototype.hasOwnProperty.call(
serviceIndex,
"taskWatchdogService",
)
? serviceIndex.taskWatchdogService
: undefined;
const taskWatchdogsSvc = taskWatchdogFactory?.(db, {
enqueueWakeup: opts.taskWatchdogEnqueueWakeup === undefined
? heartbeat.wakeup
: opts.taskWatchdogEnqueueWakeup ?? undefined,
}) ?? noopTaskWatchdogService();
const externalObjectsSvc = externalObjectService(db, {
pluginWorkerManager: opts.pluginWorkerManager,
enabled: async () => (await instanceSettings.getExperimental()).enableExternalObjects === true,
});
const routinesSvc = routineService(db, {
pluginWorkerManager: opts.pluginWorkerManager,
});
const environmentRuntime = environmentRuntimeService(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 queueTaskWatchdogEvaluation(issue: { id: string; companyId: string }, runId?: string | null) {
await taskWatchdogsSvc
.reconcileForIssueAndAncestors(issue.companyId, issue.id, { runId: runId ?? null })
.catch((err) => {
logger.warn({ err, issueId: issue.id }, "task watchdog evaluation hook failed");
});
}
async function sourceTrustForActorWrite(
issue: { id: string; companyId: string; projectId?: string | null; executionPolicy?: unknown },
actor: ReturnType<typeof getActorInfo>,
) {
return resolveActorSourceTrustForIssue({ db, issue, actor });
}
async function assertCrossIssueInfluenceWithinRunCap(
req: Request,
res: Response,
issue: { id: string; identifier?: string | null; companyId: string },
kind: CrossIssueInfluenceKind,
) {
if (req.actor.type !== "agent") return true;
if (!req.actor.agentId || !req.actor.runId) throw crossIssueInfluenceRunContextError();
// The counter transaction locks and validates the persisted run before it
// derives the source issue. Never trust the API-key run header by itself.
const decision = await observeCrossIssueInfluence(db, {
companyId: issue.companyId,
runId: req.actor.runId,
agentId: req.actor.agentId,
responsibleUserId: req.actor.onBehalfOfUserId ?? null,
targetIssueId: issue.id,
targetIssueIdentifier: issue.identifier ?? null,
kind,
});
if (!decision || decision.allowed) return true;
const labels = await issueWriteDenialLabels(req, {
identifier: issue.identifier ?? null,
assigneeAgentId: null,
});
res.status(429).json(crossIssueInfluenceLimitError(decision, {
actorLabel: labels.actorLabel,
issueIdentifier: labels.issueIdentifier,
}));
return false;
}
function hasExplicitIssueWorkspaceCreateSelection(input: Record<string, unknown>) {
return input.parentId !== undefined ||
input.inheritExecutionWorkspaceFromIssueId !== undefined ||
input.projectWorkspaceId !== undefined ||
input.executionWorkspaceId !== undefined ||
input.executionWorkspacePreference !== undefined ||
input.executionWorkspaceSettings !== undefined;
}
async function resolveRunIssueWorkspaceInheritanceSource(
companyId: string,
actor: ReturnType<typeof getActorInfo>,
): Promise<string | null> {
if (actor.actorType !== "agent" || !actor.agentId || !actor.runId) return null;
const run = await db
.select({
agentId: heartbeatRuns.agentId,
contextSnapshot: heartbeatRuns.contextSnapshot,
})
.from(heartbeatRuns)
.where(and(
eq(heartbeatRuns.id, actor.runId),
eq(heartbeatRuns.companyId, companyId),
))
.then((rows) => rows[0] ?? null);
if (!run || run.agentId !== actor.agentId) return null;
const context = run.contextSnapshot && typeof run.contextSnapshot === "object"
? run.contextSnapshot as Record<string, unknown>
: null;
if (!context || !readNonEmptyString(context.executionWorkspaceId)) return null;
const paperclipIssue = context.paperclipIssue && typeof context.paperclipIssue === "object"
? context.paperclipIssue as Record<string, unknown>
: null;
return readNonEmptyString(context.issueId) ?? readNonEmptyString(paperclipIssue?.id);
}
async function resolveAgentTrustForIssue(
input: {
agentId: string | null | undefined;
runId?: string | null;
},
companyId: string,
issue?: { companyId: string; projectId?: string | null; executionPolicy?: unknown } | null,
): Promise<TrustPresetResolution | null> {
if (!input.agentId) return null;
const [agent, run] = await Promise.all([
agentsSvc.getById(input.agentId),
input.runId
? db
.select({
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
contextSnapshot: heartbeatRuns.contextSnapshot,
})
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, companyId)))
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
]);
if (!agent || agent.companyId !== companyId) return null;
const runContext = run?.agentId === agent.id && run.contextSnapshot && typeof run.contextSnapshot === "object"
? run.contextSnapshot as Record<string, unknown>
: null;
const runExecutionPolicy = runContext?.executionPolicy && typeof runContext.executionPolicy === "object"
? runContext.executionPolicy as Record<string, unknown>
: null;
const project = issue?.projectId
? await projectsSvc.getById(issue.projectId)
: null;
return resolveCoreTrustPreset({
companyId,
agent,
project: project?.companyId === companyId ? project : null,
issue: issue
? {
companyId: issue.companyId,
executionPolicy: issue.executionPolicy,
}
: null,
run: runExecutionPolicy ? { companyId, executionPolicy: runExecutionPolicy } : null,
});
}
async function actorIsLowTrustReview(
req: Request,
companyId: string,
issue?: { companyId: string; projectId?: string | null; executionPolicy?: unknown } | null,
) {
if (req.actor.type !== "agent") return false;
const resolution = await resolveAgentTrustForIssue({
agentId: req.actor.agentId,
runId: req.actor.runId,
}, companyId, issue);
if (resolution?.kind === "denied") {
throw forbidden(resolution.detail);
}
return resolution?.kind === "low_trust_review";
}
async function directParentReportDisabledForIssue(issue: {
companyId: string;
projectId?: string | null;
executionPolicy?: unknown;
assigneeAgentId?: string | null;
checkoutRunId?: string | null;
executionRunId?: string | null;
}) {
const resolution = issue.assigneeAgentId
? await resolveAgentTrustForIssue({
agentId: issue.assigneeAgentId,
runId: issue.checkoutRunId ?? issue.executionRunId,
}, issue.companyId, issue)
: null;
if (resolution) return resolution.kind !== "standard";
const project = issue.projectId ? await projectsSvc.getById(issue.projectId) : null;
return resolveCoreTrustPreset({
companyId: issue.companyId,
project: project?.companyId === issue.companyId ? project : null,
issue: {
companyId: issue.companyId,
executionPolicy: issue.executionPolicy,
},
}).kind !== "standard";
}
async function assertLowTrustControlPlaneDenied(
req: Request,
res: Response,
companyId: string,
issue?: { companyId: string; projectId?: string | null; executionPolicy?: unknown } | null,
) {
if (!(await actorIsLowTrustReview(req, companyId, issue))) return false;
res.status(403).json({ error: "Low-trust actors cannot use this control-plane surface" });
return true;
}
async function shouldRedactLowTrustForHeartbeatContext(
issue: { id: string; companyId: string; projectId?: string | null; executionPolicy?: unknown },
actor: ReturnType<typeof getActorInfo>,
) {
// Board users are trusted reviewers and intentionally receive raw quarantined output for promotion decisions.
if (actor.actorType !== "agent") return false;
const resolution = await resolveAgentTrustForIssue({
agentId: actor.agentId,
runId: actor.runId,
}, issue.companyId, issue);
if (resolution?.kind === "denied") {
throw forbidden(resolution.detail);
}
if (resolution?.kind === "low_trust_review") return false;
return true;
}
async function lookupLowTrustSourceArtifact(input: {
issueId: string;
artifactKind: "comment" | "document" | "work_product" | "issue";
artifactId: string;
}): Promise<SourceTrustMetadata | null> {
if (input.artifactKind === "issue") {
const row = await db
.select({
id: issueRows.id,
companyId: issueRows.companyId,
parentId: issueRows.parentId,
sourceTrust: issueRows.sourceTrust,
})
.from(issueRows)
.where(eq(issueRows.id, input.artifactId))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const sourceIssue = await db
.select({ companyId: issueRows.companyId })
.from(issueRows)
.where(eq(issueRows.id, input.issueId))
.then((rows) => rows[0] ?? null);
if (!sourceIssue || row.companyId !== sourceIssue.companyId) return null;
if (row.id !== input.issueId) {
let cursor = row.parentId;
let isDescendant = false;
for (let depth = 0; cursor && depth < LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH; depth += 1) {
if (cursor === input.issueId) {
isDescendant = true;
break;
}
const parent = await db
.select({ id: issueRows.id, companyId: issueRows.companyId, parentId: issueRows.parentId })
.from(issueRows)
.where(eq(issueRows.id, cursor))
.then((rows) => rows[0] ?? null);
if (!parent || parent.companyId !== row.companyId) return null;
cursor = parent.parentId;
}
if (!isDescendant) return null;
}
return row?.sourceTrust ?? null;
}
if (input.artifactKind === "comment") {
const row = await db
.select({ sourceTrust: issueComments.sourceTrust })
.from(issueComments)
.where(and(eq(issueComments.id, input.artifactId), eq(issueComments.issueId, input.issueId)))
.then((rows) => rows[0] ?? null);
return row?.sourceTrust ?? null;
}
if (input.artifactKind === "document") {
const row = await db
.select({ sourceTrust: documents.sourceTrust })
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(and(eq(documents.id, input.artifactId), eq(issueDocuments.issueId, input.issueId)))
.then((rows) => rows[0] ?? null);
return row?.sourceTrust ?? null;
}
const row = await db
.select({ sourceTrust: issueWorkProducts.sourceTrust })
.from(issueWorkProducts)
.where(and(eq(issueWorkProducts.id, input.artifactId), eq(issueWorkProducts.issueId, input.issueId)))
.then((rows) => rows[0] ?? null);
return row?.sourceTrust ?? null;
}
async function cancelScheduledRetrySupersededByComment(input: {
scheduledRetryRunId: string | null | undefined;
issue: { id: string; companyId: string };
actor: ReturnType<typeof getActorInfo>;
}) {
const scheduledRetryRunId = readNonEmptyString(input.scheduledRetryRunId);
if (!scheduledRetryRunId) return null;
try {
const cancelled = await heartbeat.cancelRun(scheduledRetryRunId);
const cancelledRunId = cancelled?.id ?? scheduledRetryRunId;
await logActivity(db, {
companyId: input.issue.companyId,
actorType: input.actor.actorType,
actorId: input.actor.actorId,
agentId: input.actor.agentId,
runId: input.actor.runId,
agentApiKeyId: input.actor.agentApiKeyId,
action: "heartbeat.cancelled",
entityType: "heartbeat_run",
entityId: cancelledRunId,
issueId: input.issue.id,
details: {
source: "issue_comment_scheduled_retry_superseded",
issueId: input.issue.id,
},
});
return cancelledRunId;
} catch (err) {
logger.error(
{ err, issueId: input.issue.id, runId: scheduledRetryRunId },
"failed to cancel scheduled retry superseded by issue comment",
);
throw err;
}
}
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<string | null> {
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<typeof getActorInfo> | null;
activeRecoveryAction?: Awaited<ReturnType<typeof recoveryActionsSvc.getActiveForIssue>> | 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<typeof revalidateActiveSourceRecovery>[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<typeof revalidateActiveSourceRecovery>[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<T extends { id: string }>(attachment: T) {
const contentPath = `/api/attachments/${attachment.id}/content`;
return {
...attachment,
contentPath,
openPath: contentPath,
downloadPath: `${contentPath}?download=1`,
};
}
type ParsedAttachmentRange =
| { kind: "none" }
| { kind: "invalid" }
| { kind: "range"; start: number; end: number };
function parseAttachmentRangeHeader(raw: string | undefined, contentLength: number): ParsedAttachmentRange {
if (!raw) return { kind: "none" };
if (!Number.isSafeInteger(contentLength) || contentLength <= 0) return { kind: "invalid" };
const prefix = "bytes=";
if (!raw.toLowerCase().startsWith(prefix)) return { kind: "invalid" };
const spec = raw.slice(prefix.length).trim();
if (!spec || spec.includes(",")) return { kind: "invalid" };
const [startRaw, endRaw] = spec.split("-", 2);
if (endRaw === undefined) return { kind: "invalid" };
if (startRaw === "") {
const suffixLength = Number.parseInt(endRaw, 10);
if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return { kind: "invalid" };
const start = Math.max(contentLength - suffixLength, 0);
return { kind: "range", start, end: contentLength - 1 };
}
const start = Number.parseInt(startRaw, 10);
if (!Number.isSafeInteger(start) || start < 0 || start >= contentLength) return { kind: "invalid" };
const end = endRaw === "" ? contentLength - 1 : Number.parseInt(endRaw, 10);
if (!Number.isSafeInteger(end) || end < start) return { kind: "invalid" };
return { kind: "range", start, end: Math.min(end, contentLength - 1) };
}
function parseBooleanQuery(value: unknown) {
return value === true || value === "true" || value === "1";
}
function parseOptionalBooleanQuery(value: unknown) {
if (value === undefined) return undefined;
if (value === true || value === "true" || value === "1") return true;
if (value === false || value === "false" || value === "0") return false;
return null;
}
function shouldIncludeDocumentAnnotations(req: Request) {
if (req.query.includeAnnotations === "false" || req.query.includeAnnotations === "0") return false;
return req.actor.type === "agent" || parseBooleanQuery(req.query.includeAnnotations);
}
function shouldIncludeDocumentAnnotationComments(req: Request) {
return parseBooleanQuery(req.query.includeAnnotationComments);
}
function annotationActorInput(req: Request) {
const actor = getActorInfo(req);
return {
actor,
annotationActor: {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
runId: actor.runId,
},
};
}
async function canonicalizePaperclipArtifactMetadata(input: {
issue: { id: string; companyId: string };
metadata: Record<string, unknown> | null | undefined;
}) {
const parsed = attachmentArtifactMetadataInputSchema.safeParse(input.metadata);
if (!parsed.success) {
throw unprocessable("Invalid attachment artifact metadata", {
code: "invalid_attachment_artifact_metadata",
details: parsed.error.issues,
});
}
const attachment = await svc.getAttachmentById(parsed.data.attachmentId);
if (!attachment || attachment.companyId !== input.issue.companyId || attachment.issueId !== input.issue.id) {
throw unprocessable("Attachment artifact must reference an attachment on the same issue", {
code: "invalid_attachment_artifact_metadata",
attachmentId: parsed.data.attachmentId,
});
}
const contentPath = buildAttachmentContentPath(attachment.id);
return attachmentArtifactWorkProductMetadataSchema.parse({
attachmentId: attachment.id,
contentType: normalizeContentType(attachment.contentType),
byteSize: attachment.byteSize,
contentPath,
openPath: contentPath,
downloadPath: `${contentPath}?download=1`,
originalFilename: attachment.originalFilename ?? null,
});
}
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 assertInReviewReviewPath(input: {
existing: {
id: string;
companyId: string;
status: string;
assigneeUserId?: string | null;
executionState?: unknown;
monitorNextCheckAt?: Date | null;
};
updateFields: Record<string, unknown>;
actorType: "agent" | "user";
actorId: string;
actorAgentId?: string | null;
actorRunId?: string | null;
reviewInteractionId?: string;
}) {
const nextStatus = typeof input.updateFields.status === "string"
? input.updateFields.status
: input.existing.status;
if (input.existing.status === "in_review" || nextStatus !== "in_review") return null;
if (input.actorType !== "agent" && !input.reviewInteractionId) return null;
const interactions = await issueThreadInteractionService(db).listForIssue(input.existing.id);
const pendingInteractions = interactions.filter((interaction) => interaction.status === "pending");
if (input.reviewInteractionId) {
const designatedReviewConfirmation = pendingInteractions.find((interaction) =>
interaction.id === input.reviewInteractionId
&& (interaction.kind === "request_confirmation" || interaction.kind === "request_checkbox_confirmation")
&& (
input.actorType === "agent"
? interaction.createdByAgentId === input.actorAgentId
&& interaction.sourceRunId === input.actorRunId
: interaction.createdByUserId === input.actorId
)
&& !(
interaction.kind === "request_confirmation"
&& interaction.payload
&& typeof interaction.payload === "object"
&& "toolAction" in interaction.payload
&& interaction.payload.toolAction !== undefined
)
);
if (!designatedReviewConfirmation) {
const creatorDescription = input.actorType === "agent"
? "this agent run"
: "this user";
throw unprocessable(`reviewInteractionId must identify a pending non-tool confirmation created by ${creatorDescription}`, {
code: "invalid_review_interaction",
reviewInteractionId: input.reviewInteractionId,
});
}
return designatedReviewConfirmation.id;
}
if (input.actorType !== "agent") return null;
const nextAssigneeUserId = input.updateFields.assigneeUserId === undefined
? input.existing.assigneeUserId
: input.updateFields.assigneeUserId;
if (typeof nextAssigneeUserId === "string" && nextAssigneeUserId.trim().length > 0) return null;
const nextExecutionState = input.updateFields.executionState === undefined
? input.existing.executionState
: input.updateFields.executionState;
if (hasExecutionParticipant(nextExecutionState)) return null;
const nextExecutionPolicy = input.updateFields.executionPolicy;
if (hasScheduledMonitor({
existingMonitorNextCheckAt: input.existing.monitorNextCheckAt ?? null,
patchMonitorNextCheckAt: input.updateFields.monitorNextCheckAt,
executionPolicy: nextExecutionPolicy,
})) return null;
if (pendingInteractions.length > 0) return null;
const approvals = await issueApprovalsSvc.listApprovalsForIssue(input.existing.id);
if (approvals.some((approval) => ACTIVE_REVIEW_APPROVAL_STATUSES.has(String(approval.status)))) return null;
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<typeof getActorInfo>;
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,
agentApiKeyId: input.actor.agentApiKeyId,
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,
},
});
}
}
async function queueExpiredInteractionReviewPathRecovery(input: {
issue: IssueRouteSnapshot;
interactions: Array<{ id: string }>;
actor: ReturnType<typeof getActorInfo>;
source: string;
}) {
if (
input.interactions.length === 0
|| input.issue.status !== "in_review"
|| !input.issue.assigneeAgentId
) {
return null;
}
const reviewAttention = await svc
.listReviewAttention(input.issue.companyId, [input.issue])
.then((attention) => attention.get(input.issue.id));
if (!reviewAttention || reviewAttention.state !== "stalled") return null;
const interactionIds = [...new Set(input.interactions.map((interaction) => interaction.id))].sort();
const consumedPathRef = interactionIds.length === 1
? interactionIds[0]!
: `interactions:${interactionIds.join(",")}`;
const decision = decideIssueReviewPathRecovery({
issueId: input.issue.id,
sourceRunId: input.actor.runId,
assigneeAgentId: input.issue.assigneeAgentId,
contextSnapshot: {
source: input.source,
reviewPathConsumedRef: consumedPathRef,
},
reviewAttention,
existingWake: false,
});
if (decision.kind !== "enqueue") return null;
const recoveryRun = await heartbeat.wakeup(input.issue.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
reason: ISSUE_REVIEW_PATH_LOST_WAKE_REASON,
idempotencyKey: decision.idempotencyKey,
payload: decision.payload,
contextSnapshot: decision.contextSnapshot,
requestedByActorType: input.actor.actorType,
requestedByActorId: input.actor.actorId,
}).catch((error: unknown) => {
if (isReviewPathRecoveryIdempotencyConflict(error)) return null;
throw error;
});
if (!recoveryRun) return null;
await logActivity(db, {
companyId: input.issue.companyId,
actorType: "system",
actorId: "issue_route",
agentId: input.issue.assigneeAgentId,
runId: input.actor.runId,
action: "issue.review_path_recovery_queued",
entityType: "issue",
entityId: input.issue.id,
details: {
source: input.source,
recoveryRunId: recoveryRun.id,
consumedPathRef,
recoveryAttempt: 1,
maxRecoveryAttempts: 1,
},
});
return recoveryRun;
}
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<void>((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);
}
type TaskAssignmentAuthorizationScope = {
issueId?: string | null;
projectId?: string | null;
parentIssueId?: string | null;
assigneeAgentId?: string | null;
assigneeUserId?: string | null;
};
async function resolveAssignmentProjectId(input: {
companyId: string;
projectId: string | null | undefined;
parentIssueId?: string | null;
}) {
if (input.projectId !== undefined) return input.projectId;
if (!input.parentIssueId) return null;
const parent = await svc.getById(input.parentIssueId);
if (!parent || parent.companyId !== input.companyId) return null;
return parent.projectId ?? null;
}
async function assertCanAssignTasks(
req: Request,
companyId: string,
assignmentScope?: TaskAssignmentAuthorizationScope,
) {
assertCompanyAccess(req, companyId);
const decision = await access.decide({
actor: req.actor,
action: "tasks:assign",
resource: {
type: "issue",
companyId,
issueId: assignmentScope?.issueId ?? null,
projectId: assignmentScope?.projectId ?? null,
parentIssueId: assignmentScope?.parentIssueId ?? null,
assigneeAgentId: assignmentScope?.assigneeAgentId ?? null,
assigneeUserId: assignmentScope?.assigneeUserId ?? null,
},
scope: assignmentScope ?? null,
});
if (decision.allowed) return;
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
}
function isTaskBridgeKeyActor(req: Request) {
return req.actor.type === "agent" && req.actor.source === "agent_key" && req.actor.keyScope?.kind === "task_bridge";
}
function isSkillTestScopedActor(req: Request) {
return req.actor.type === "agent" && req.actor.keyScope?.kind === "skill_test";
}
function taskBridgeOriginForActor(req: Request) {
return isTaskBridgeKeyActor(req) && req.actor.keyId
? { originKind: "task_bridge", originId: req.actor.keyId }
: null;
}
async function assertTaskBridgeCreateAllowed(
req: Request,
companyId: string,
assignmentScope: TaskAssignmentAuthorizationScope,
) {
if (!isTaskBridgeKeyActor(req)) return;
await assertCanAssignTasks(req, companyId, assignmentScope);
}
async function decideIssueAccess(
req: Request,
issue: {
id: string;
companyId: string;
projectId: string | null;
parentId: string | null;
assigneeAgentId: string | null;
assigneeUserId: string | null;
status: string;
},
action: "issue:comment" | "issue:read" | "issue:mutate",
) {
return access.decide({
actor: req.actor,
action,
resource: {
type: "issue",
companyId: issue.companyId,
issueId: issue.id,
projectId: issue.projectId,
parentIssueId: issue.parentId,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
status: issue.status,
},
scope: {
issueId: issue.id,
projectId: issue.projectId,
parentIssueId: issue.parentId,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
},
});
}
/**
* Map an authorization denial onto the issue-write copy contract (plan §6).
*
* The two responsible-user ceiling codes are the most specific signal, so they
* win. Actor-class walls (low-trust, skill-test, task-bridge scopes) stay shut
* by design and get their own copy. Everything else reaching a write channel is
* a visibility denial, because the default-open rule puts `issue:read` structurally
* upstream of every standard-trust write.
*/
function issueWriteDenialCodeForDecision(
decision: Awaited<ReturnType<typeof decideIssueAccess>>,
): IssueWriteDenialCode {
if (decision.code) return issueWriteDenialCodeForResponsibleUserDenial(decision.code);
if (decision.reason === "deny_low_trust_boundary" || decision.reason === "deny_policy_restricted") {
return "issue_write_actor_class_excluded";
}
return "issue_write_not_visible";
}
/**
* Best-effort display names for denial copy. Denials are rare, so one extra
* query buys an error that names who can act instead of printing raw uuids.
* Any failure degrades to the copy contract's generic nouns.
*/
async function issueWriteDenialLabels(
req: Request,
issue: { identifier?: string | null; assigneeAgentId: string | null },
): Promise<IssueWriteDenialContext> {
const actorAgentId = req.actor.type === "agent" ? req.actor.agentId ?? null : null;
const ids = [actorAgentId, issue.assigneeAgentId].filter((id): id is string => Boolean(id));
const nameById = new Map<string, string>();
if (ids.length > 0) {
try {
const rows = await db
.select({ id: agents.id, name: agents.name })
.from(agents)
.where(inArray(agents.id, ids));
for (const row of rows) if (row.name) nameById.set(row.id, row.name);
} catch (err) {
logger.warn({ err }, "failed to resolve agent names for issue write denial copy");
}
}
return {
actorLabel: actorAgentId ? nameById.get(actorAgentId) ?? null : null,
assigneeLabel: issue.assigneeAgentId ? nameById.get(issue.assigneeAgentId) ?? null : null,
issueIdentifier: issue.identifier ?? null,
responsibleUserName: null,
};
}
/** Respond to a denied issue write with copy that names boundary, who, and path. */
async function denyIssueWrite(
req: Request,
res: Response,
issue: { identifier?: string | null; assigneeAgentId: string | null },
code: IssueWriteDenialCode,
extraDetails: Record<string, unknown> = {},
) {
const labels = await issueWriteDenialLabels(req, issue);
const { status, body } = issueWriteDenialResponse(code, labels);
res.status(status).json({
error: body.error,
details: { ...body.details, ...extraDetails },
});
return false as const;
}
async function assertIssueReadAllowed(req: Request, res: Response, issue: Parameters<typeof decideIssueAccess>[1]) {
const key = `${issue.id}:${issue.companyId}:${issue.projectId ?? ""}:${issue.parentId ?? ""}:${issue.assigneeAgentId ?? ""}:${issue.assigneeUserId ?? ""}:${issue.status}`;
const value = memoizeIssueReadDecision(req, key, () => decideIssueAccess(req, issue, "issue:read"));
const decision = await value;
if (decision.allowed) return true;
res.status(403).json({ error: "Issue is outside this actor's authorization boundary" });
return false;
}
async function assertIssueWriteInfluenceAllowed(
req: Request,
res: Response,
issue: Parameters<typeof decideIssueAccess>[1],
) {
if (req.actor.type !== "agent") return true;
// Watchdog child creation keeps its dedicated subtree/revalidation grant;
// assertTaskWatchdogCreateIssueAllowed performs that check immediately
// after this generic collaboration gate at both create call sites.
if ((await resolveTaskWatchdogMutationScope(db, req.actor)).kind !== "none") return true;
const decision = await decideIssueAccess(req, issue, "issue:mutate");
if (decision.allowed) return true;
return denyIssueWrite(req, res, issue, issueWriteDenialCodeForDecision(decision));
}
async function assertAgentIssueCommentAllowed(
req: Request,
res: Response,
issue: {
id: string;
companyId: string;
projectId: string | null;
parentId: string | null;
status: string;
assigneeAgentId: string | null;
assigneeUserId: string | null;
/** Used only to name the task in denial copy (plan §6). */
identifier?: 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;
}
const watchdogScope = await resolveTaskWatchdogMutationScope(db, req.actor);
if (watchdogScope.kind !== "none") {
const scopeResult = await taskWatchdogScopeAllowsIssueMutation(db, watchdogScope, issue);
if (scopeResult.kind === "invalid") {
res.status(403).json({
error: scopeResult.detail,
details: {
issueId: issue.id,
securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"],
},
});
return false;
}
return assertFreshTaskWatchdogSourceMutation(res, watchdogScope, issue);
}
const boundaryDecision = await decideIssueAccess(req, issue, "issue:comment");
if (!boundaryDecision.allowed) {
return denyIssueWrite(req, res, issue, issueWriteDenialCodeForDecision(boundaryDecision));
}
return boundaryDecision;
}
function isIssueMentionGrantDecision(decision: true | Awaited<ReturnType<typeof decideIssueAccess>>) {
return decision !== true && decision.reason === "allow_issue_mention_grant";
}
function isDirectParentReportDecision(decision: true | Awaited<ReturnType<typeof decideIssueAccess>>) {
return decision !== true && decision.reason === "allow_direct_parent_report";
}
function isDefaultOpenIssueWriteDecision(decision: true | Awaited<ReturnType<typeof decideIssueAccess>>) {
return decision !== true && decision.reason === "allow_visible_issue_write";
}
async function filterIssuesForActor<T extends Parameters<typeof decideIssueAccess>[1]>(req: Request, rows: T[]) {
const decisions = await Promise.all(rows.map((issue) => decideIssueAccess(req, issue, "issue:read")));
return rows.filter((_, index) => decisions[index]?.allowed);
}
async function actorCanReadCompanyScope(req: Request, companyId: string) {
const decision = await access.decide({
actor: req.actor,
action: "company_scope:read",
resource: { type: "company", companyId },
});
return decision.allowed;
}
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 decision = await access.decide({
actor: { type: "agent", agentId: actorAgentId, companyId },
action: "tasks:manage_active_checkouts",
resource: { type: "issue", companyId, assigneeAgentId },
});
return decision.allowed;
}
async function assertAgentIssueMutationAllowed(
req: Request,
res: Response,
issue: {
id: string;
companyId: string;
projectId: string | null;
parentId: string | null;
status: string;
assigneeAgentId: string | null;
assigneeUserId: string | null;
/** Used only to name the task in denial copy (plan §6). */
identifier?: string | null;
},
options: { allowVisibleIssueWrite?: boolean } = {},
) {
if (req.actor.type !== "agent") return true;
const actorAgentId = req.actor.agentId;
if (!actorAgentId) {
res.status(403).json({ error: "Agent authentication required" });
return false;
}
// Task-watchdog runs receive a scoped *grant* to mutate issues inside the
// watched subtree. This must be evaluated before the base assignee-ownership
// boundary below: that boundary denies an agent mutating an issue owned by a
// different agent, which is exactly the watchdog's primary job
// (SPEC-implementation §9.9 — comment, transition, reassign within the
// watched subtree). The watchdog scope can only widen access to the watched
// subtree; downstream status-transition, assignment, recovery, and budget
// guards in the route handlers still apply.
const watchdogScope = await resolveTaskWatchdogMutationScope(db, req.actor);
if (watchdogScope.kind !== "none") {
const scopeResult = await taskWatchdogScopeAllowsIssueMutation(db, watchdogScope, issue);
if (scopeResult.kind === "invalid") {
res.status(403).json({
error: scopeResult.detail,
details: {
issueId: issue.id,
securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"],
},
});
return false;
}
return assertFreshTaskWatchdogSourceMutation(res, watchdogScope, issue);
}
const boundaryDecision = await decideIssueAccess(req, issue, "issue:mutate");
if (!boundaryDecision.allowed) {
return denyIssueWrite(req, res, issue, issueWriteDenialCodeForDecision(boundaryDecision));
}
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") {
// Run/checkout ownership stays assignee-scoped even though writes are
// open, so this lock clears on its own — the copy routes to comments.
return denyIssueWrite(req, res, issue, "issue_write_assignee_run_lock", {
issueId: issue.id,
assigneeAgentId: issue.assigneeAgentId,
actorAgentId,
});
}
// Past the run lock the issue is idle, so only channels that have not
// adopted the default-open rule still refuse another agent's issue.
if (!options.allowVisibleIssueWrite) {
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;
}
return true;
}
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,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.checkout_lock_adopted",
entityType: "issue",
entityId: issue.id,
details: {
previousCheckoutRunId: ownership.adoptedFromRunId,
checkoutRunId: runId,
reason: "stale_checkout_run",
},
});
}
return true;
}
async function assertFreshTaskWatchdogSourceMutation(
res: Response,
scope: Awaited<ReturnType<typeof resolveTaskWatchdogMutationScope>>,
issue: { id: string },
) {
if (scope.kind !== "watchdog") return true;
if (scope.watchdogIssueId && issue.id === scope.watchdogIssueId) return true;
const revalidated = await taskWatchdogsSvc.revalidateMutationScope(scope);
if (revalidated.allowed) return true;
res.status(409).json({
error: revalidated.reason,
details: {
watchedIssueId: scope.watchedIssueId,
watchdogId: scope.watchdogId,
runStopFingerprint: scope.stopFingerprint,
currentState: revalidated.classification?.state ?? null,
currentStopFingerprint: revalidated.classification && "stopFingerprint" in revalidated.classification
? revalidated.classification.stopFingerprint
: null,
},
});
return false;
}
async function rejectTaskWatchdogConfigMutation(req: Request, res: Response) {
if (req.actor.type !== "agent") return false;
const scope = await resolveTaskWatchdogMutationScope(db, req.actor);
if (scope.kind !== "watchdog") return false;
res.status(403).json({
error: "Task-watchdog runs cannot change watchdog configuration.",
details: {
watchedIssueId: scope.watchedIssueId,
watchdogId: scope.watchdogId,
securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"],
},
});
return true;
}
async function assertTaskWatchdogIssueMutationAllowed(
req: Request,
res: Response,
issue: {
id: string;
companyId: string;
parentId?: string | null;
},
opts: { allowWatchdogIssue?: boolean } = {},
) {
if (req.actor.type !== "agent") return true;
const scope = await resolveTaskWatchdogMutationScope(db, req.actor);
if (scope.kind === "none") return true;
const result = await taskWatchdogScopeAllowsIssueMutation(db, scope, issue, opts);
if (result.kind !== "invalid") return assertFreshTaskWatchdogSourceMutation(res, scope, issue);
res.status(403).json({
error: result.detail,
details: {
issueId: issue.id,
securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"],
},
});
return false;
}
async function rejectTaskWatchdogInteractionMutation(
req: Request,
res: Response,
issue: {
id: string;
companyId: string;
parentId?: string | null;
},
) {
if (req.actor.type !== "agent") return false;
const scope = await resolveTaskWatchdogMutationScope(db, req.actor);
if (scope.kind === "none") return false;
const result = await taskWatchdogScopeAllowsIssueMutation(db, scope, issue);
if (result.kind === "invalid") {
res.status(403).json({
error: result.detail,
details: {
issueId: issue.id,
securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"],
},
});
return true;
}
res.status(403).json({ error: "Task-watchdog runs cannot mutate issue-thread interactions" });
return true;
}
async function assertIssueThreadInteractionResolutionAllowed(
req: Request,
res: Response,
issue: Parameters<typeof assertAgentIssueMutationAllowed>[2],
interaction: {
id: string;
createdByAgentId?: string | null;
createdByUserId?: string | null;
sourceRunId?: string | null;
effectiveResolverPolicy: string;
addresseeAgentId?: string | null;
kind: string;
status: string;
payload?: unknown;
},
) {
const isReviewConfirmationVerdict = await isPendingReviewConfirmationVerdict(issue, interaction);
if (req.actor.type !== "agent") {
assertBoard(req);
if (isReviewConfirmationVerdict) {
await assertPendingReviewInteractionVerdictAllowed(req, issue, interaction);
return "review_verdict" as const;
}
return "standard" as const;
}
const actorAgentId = req.actor.agentId;
const runId = requireAgentRunId(req, res);
if (!actorAgentId || !runId) return false;
const watchdogScope = await resolveTaskWatchdogMutationScope(db, req.actor);
if (watchdogScope.kind !== "none") {
res.status(403).json({ error: "Task-watchdog runs cannot resolve issue-thread interactions" });
return false;
}
if (await assertLowTrustControlPlaneDenied(req, res, issue.companyId, issue)) return false;
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return false;
const payload = interaction.payload && typeof interaction.payload === "object"
? interaction.payload as { toolAction?: unknown }
: null;
if (interaction.kind === "request_confirmation" && payload?.toolAction !== undefined) {
res.status(403).json({ error: "Tool-action confirmations are always board-only" });
return false;
}
if (isReviewConfirmationVerdict) {
if (!assertAgentInteractionActorAllowed(res, interaction, actorAgentId, runId)) return false;
await assertPendingReviewInteractionVerdictAllowed(req, issue, interaction);
return "review_verdict" as const;
}
if (interaction.effectiveResolverPolicy !== "board_or_agents") {
res.status(403).json({ error: "This issue-thread interaction is board-only" });
return false;
}
return assertAgentInteractionActorAllowed(res, interaction, actorAgentId, runId)
? "standard" as const
: false;
}
function assertAgentInteractionActorAllowed(
res: Response,
interaction: {
addresseeAgentId?: string | null;
createdByAgentId?: string | null;
sourceRunId?: string | null;
},
actorAgentId: string,
runId: string,
) {
if (interaction.addresseeAgentId && interaction.addresseeAgentId !== actorAgentId) {
res.status(403).json({ error: "Only the addressed agent or a board user may resolve this issue-thread interaction" });
return false;
}
if (interaction.createdByAgentId === actorAgentId) {
res.status(403).json({ error: "Agents cannot resolve interactions they created" });
return false;
}
if (interaction.sourceRunId === runId) {
res.status(403).json({ error: "Agents cannot resolve interactions created by the same run" });
return false;
}
return true;
}
async function isPendingReviewConfirmationVerdict(
issue: {
id: string;
companyId: string;
status: string;
createdByAgentId?: string | null;
createdByUserId?: string | null;
},
interaction: {
id: string;
kind: string;
status: string;
createdByAgentId?: string | null;
createdByUserId?: string | null;
},
) {
if (
issue.status !== "in_review"
|| interaction.status !== "pending"
|| (
interaction.kind !== "request_confirmation"
&& interaction.kind !== "request_checkbox_confirmation"
)
) return false;
return isIssueReviewVerdictInteraction(db, { issue, interaction });
}
async function assertPendingReviewInteractionVerdictAllowed(
req: Request,
issue: {
id: string;
companyId: string;
status: string;
reviewPolicy?: IssueReviewPolicy | null;
createdByAgentId?: string | null;
createdByUserId?: string | null;
},
interaction: { kind: string; status: string },
) {
if (
issue.status !== "in_review"
|| interaction.status !== "pending"
|| (
interaction.kind !== "request_confirmation"
&& interaction.kind !== "request_checkbox_confirmation"
)
|| issue.reviewPolicy == null
|| issue.reviewPolicy === "anyone"
) return;
const actor = getActorInfo(req);
await assertIssueReviewVerdictActorAllowed(db, {
issue,
actor: { type: actor.actorType, id: actor.actorId },
});
}
async function assertIssueThreadInteractionWithdrawalAllowed(
req: Request,
res: Response,
issue: Parameters<typeof assertAgentIssueMutationAllowed>[2],
interaction: { createdByAgentId?: string | null },
) {
if (req.actor.type !== "agent") {
assertBoard(req);
return true;
}
const actorAgentId = req.actor.agentId;
if (!actorAgentId || !requireAgentRunId(req, res)) return false;
const watchdogScope = await resolveTaskWatchdogMutationScope(db, req.actor);
if (watchdogScope.kind !== "none") {
res.status(403).json({ error: "Task-watchdog runs cannot withdraw issue-thread interactions" });
return false;
}
const boundaryDecision = await decideIssueAccess(req, issue, "issue:mutate");
if (!boundaryDecision.allowed) {
res.status(403).json({ error: "Issue is outside this actor's authorization boundary" });
return false;
}
if (await assertLowTrustControlPlaneDenied(req, res, issue.companyId, issue)) return false;
const isCreator = interaction.createdByAgentId === actorAgentId;
const isAssignee = issue.assigneeAgentId === actorAgentId;
if (!isCreator && !isAssignee) {
res.status(403).json({ error: "Only the interaction creator, current issue assignee, or a board user may withdraw it" });
return false;
}
if (isAssignee) return assertAgentIssueMutationAllowed(req, res, issue);
return true;
}
async function assertTaskWatchdogCreateIssueAllowed(
req: Request,
res: Response,
companyId: string,
parent: {
id: string;
companyId: string;
parentId?: string | null;
} | null,
) {
if (req.actor.type !== "agent") return true;
const scope = await resolveTaskWatchdogMutationScope(db, req.actor);
if (scope.kind === "none") return true;
if (scope.kind === "invalid") {
res.status(403).json({
error: scope.detail,
details: {
securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"],
},
});
return false;
}
if (!parent) {
res.status(403).json({
error: "Task-watchdog runs must create issues inside the watched issue subtree.",
details: {
companyId,
watchedIssueId: scope.watchedIssueId,
securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"],
},
});
return false;
}
const result = await taskWatchdogScopeAllowsIssueMutation(db, scope, parent, { allowWatchdogIssue: false });
if (result.kind !== "invalid") return assertFreshTaskWatchdogSourceMutation(res, scope, parent);
res.status(403).json({
error: result.detail,
details: {
parentIssueId: parent.id,
securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"],
},
});
return false;
}
async function resolveWatchdogFollowUpSerializationContext(
req: Request,
parent: {
id: string;
companyId: string;
status?: string | null;
originKind?: string | null;
},
) {
if (parent.originKind === TASK_WATCHDOG_ORIGIN_KIND) {
return {
enabled: true as const,
watchdogParentIssueId: parent.id,
};
}
if (req.actor.type !== "agent") return null;
const scope = await resolveTaskWatchdogMutationScope(db, req.actor);
if (scope.kind !== "watchdog") return null;
return {
enabled: true as const,
watchdogParentIssueId: scope.watchdogIssueId,
};
}
function mergeIssueBlockerIds(
existing: unknown,
blockerIssueId: string | null | undefined,
) {
const current = Array.isArray(existing)
? existing.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
: [];
return blockerIssueId ? [...new Set([...current, blockerIssueId])] : [...new Set(current)];
}
async function findCurrentSerializedWatchdogChild(parent: { id: string; companyId: string }) {
const children = await db
.select({
id: issueRows.id,
status: issueRows.status,
})
.from(issueRows)
.where(and(
eq(issueRows.companyId, parent.companyId),
eq(issueRows.parentId, parent.id),
inArray(issueRows.status, ["todo", "in_progress", "in_review", "blocked"]),
isNull(issueRows.hiddenAt),
))
.orderBy(asc(issueRows.issueNumber), asc(issueRows.createdAt), asc(issueRows.id));
return children[0] ?? null;
}
async function blockWatchdogParentOnCurrentChild(input: {
actor: ReturnType<typeof getActorInfo>;
watchdogParentIssueId: string | null | undefined;
currentChildIssueId: string | null | undefined;
}) {
if (!input.watchdogParentIssueId || !input.currentChildIssueId) return;
const watchdogParent = await svc.getById(input.watchdogParentIssueId);
if (!watchdogParent || watchdogParent.originKind !== TASK_WATCHDOG_ORIGIN_KIND) return;
if (watchdogParent.status !== "in_progress" && watchdogParent.status !== "blocked") return;
const relations = await svc.getRelationSummaries(watchdogParent.id);
const nextBlockedByIssueIds = mergeIssueBlockerIds(
relations.blockedBy?.map((relation) => relation.id) ?? [],
input.currentChildIssueId,
);
await svc.update(watchdogParent.id, {
status: "blocked",
blockedByIssueIds: nextBlockedByIssueIds,
actorAgentId: input.actor.agentId,
actorUserId: input.actor.actorType === "user" ? input.actor.actorId : null,
});
await logActivity(db, {
companyId: watchdogParent.companyId,
actorType: input.actor.actorType,
actorId: input.actor.actorId,
agentId: input.actor.agentId,
runId: input.actor.runId,
agentApiKeyId: input.actor.agentApiKeyId,
action: "issue.task_watchdog_followups_serialized",
entityType: "issue",
entityId: watchdogParent.id,
details: {
watchdogParentIssueId: watchdogParent.id,
currentChildIssueId: input.currentChildIssueId,
blockedByIssueIds: nextBlockedByIssueIds,
},
});
}
function normalizeWatchdogDiscovery(input: unknown): {
kind: IssueWatchdogDiscoveryKind;
evidenceMarkdown: string | null;
} | null {
if (!input || typeof input !== "object" || Array.isArray(input)) return null;
const record = input as Record<string, unknown>;
const kind = typeof record.kind === "string" &&
(ISSUE_WATCHDOG_DISCOVERY_KINDS as readonly string[]).includes(record.kind)
? record.kind as IssueWatchdogDiscoveryKind
: null;
if (!kind) return null;
const evidenceMarkdown =
typeof record.evidenceMarkdown === "string" && record.evidenceMarkdown.trim().length > 0
? record.evidenceMarkdown.trim()
: null;
return { kind, evidenceMarkdown };
}
function issueMarkdownLink(issue: { id: string; identifier?: string | null }) {
const identifier = issue.identifier?.trim();
if (!identifier) return `\`${issue.id}\``;
const prefix = identifier.split("-")[0] || "PAP";
return `[${identifier}](/${prefix}/issues/${identifier})`;
}
function appendWatchdogDiscoveryContext(input: {
description: string | null | undefined;
discovery: { kind: IssueWatchdogDiscoveryKind; evidenceMarkdown: string | null };
sourceIssue: { id: string; identifier?: string | null };
watchdogIssue: { id: string; identifier?: string | null } | null;
stopFingerprint: string | null;
runId: string | null;
}) {
const contextLines = [
"## Watchdog Discovery",
"",
`Kind: \`${input.discovery.kind}\``,
`Watched source issue: ${issueMarkdownLink(input.sourceIssue)}`,
input.watchdogIssue ? `Watchdog issue: ${issueMarkdownLink(input.watchdogIssue)}` : null,
input.stopFingerprint ? `Stopped fingerprint: \`${input.stopFingerprint}\`` : null,
input.runId ? `Watchdog run: \`${input.runId}\`` : null,
input.discovery.evidenceMarkdown ? "" : null,
input.discovery.evidenceMarkdown ? "Evidence:" : null,
input.discovery.evidenceMarkdown ?? null,
].filter((line): line is string => line != null);
const existing = input.description?.trim();
return existing ? `${existing}\n\n${contextLines.join("\n")}` : contextLines.join("\n");
}
async function resolveTaskWatchdogProductBugFollowUp(
req: Request,
res: Response,
companyId: string,
discovery: { kind: IssueWatchdogDiscoveryKind; evidenceMarkdown: string | null } | null,
) {
if (!discovery) return null;
if (req.actor.type !== "agent") {
res.status(403).json({
error: "Only task-watchdog agent runs can create watchdog-discovered product bug follow-ups",
});
return false;
}
const scope = await resolveTaskWatchdogMutationScope(db, req.actor);
if (scope.kind === "none") {
res.status(403).json({ error: "Only task-watchdog runs can create watchdog-discovered product bug follow-ups" });
return false;
}
if (scope.kind === "invalid") {
res.status(403).json({
error: scope.detail,
details: {
securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"],
},
});
return false;
}
if (scope.companyId !== companyId) {
res.status(403).json({ error: "Task-watchdog product bug follow-up target is outside the watchdog company" });
return false;
}
const sourceIssue = await svc.getById(scope.watchedIssueId);
if (!sourceIssue || sourceIssue.companyId !== companyId) {
res.status(404).json({ error: "Watched source issue not found" });
return false;
}
const watchdogIssue = scope.watchdogIssueId ? await svc.getById(scope.watchdogIssueId) : null;
if (watchdogIssue && watchdogIssue.companyId !== companyId) {
res.status(403).json({ error: "Task-watchdog product bug evidence issue is outside the watchdog company" });
return false;
}
return { scope, discovery, sourceIssue, watchdogIssue };
}
function isStatusOnlyCheapRecoveryContext(contextSnapshot: unknown) {
if (!contextSnapshot || typeof contextSnapshot !== "object" || Array.isArray(contextSnapshot)) return false;
const context = contextSnapshot as Record<string, unknown>;
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<string, unknown>).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;
}
function readObject(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
async function deriveRecoveryCommentPresentation(
req: Request,
companyId: string,
body: string,
): Promise<IssueCommentPresentation | null> {
const run = await loadActorRunContext(req, companyId);
if (!run) return null;
const context = readObject(run.contextSnapshot);
const paperclipWake = readObject(context.paperclipWake);
const recovery = readObject(paperclipWake.recovery);
const wakeReason = typeof context.wakeReason === "string"
? context.wakeReason
: typeof paperclipWake.reason === "string"
? paperclipWake.reason
: null;
if (wakeReason !== "source_scoped_recovery_action") return null;
const recoveryCause = typeof context.recoveryCause === "string"
? context.recoveryCause
: typeof recovery.cause === "string"
? recovery.cause
: null;
if (
recoveryCause === "successful_run_missing_state" ||
recoveryCause === "successful_run_missing_issue_disposition"
) {
return null;
}
const firstLine = body.split(/\r?\n/, 1)[0]?.trim() || "Recovery update";
const title = firstLine.length > 160 ? `${firstLine.slice(0, 159)}…` : firstLine;
return {
kind: "system_notice",
tone: "info",
title,
detailsDefaultOpen: false,
density: "compact",
};
}
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;
}
async function assertApprovalMutationAllowedByRunContext(
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 create or modify approvals",
details: {
issueId: issue.id,
runId: run.id,
modelProfile: "cheap",
recoveryIntent: "status_only",
resumeRequiresNormalModel: true,
},
});
return false;
}
async function loadWorkProductRunAttribution(runId: string) {
return await db
.select({
id: heartbeatRuns.id,
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
agentCompanyId: agents.companyId,
})
.from(heartbeatRuns)
.innerJoin(agents, eq(heartbeatRuns.agentId, agents.id))
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
}
async function resolveWorkProductCreatedByRunId(
req: Request,
res: Response,
companyId: string,
input: { createdByRunId?: string | null },
mode: "create" | "update",
): Promise<string | null | undefined> {
const hasCreatedByRunId = Object.prototype.hasOwnProperty.call(input, "createdByRunId");
if (mode === "update" && !hasCreatedByRunId) return undefined;
const requestedRunId = input.createdByRunId ?? null;
if (req.actor.type === "agent") {
const actorRunId = req.actor.runId?.trim() || null;
if (requestedRunId && requestedRunId !== actorRunId) {
res.status(403).json({ error: "createdByRunId must match the authenticated agent run" });
return undefined;
}
if (!actorRunId) return requestedRunId;
const run = await loadWorkProductRunAttribution(actorRunId);
if (!run || run.companyId !== companyId || run.agentCompanyId !== companyId || run.agentId !== req.actor.agentId) {
res.status(403).json({ error: "createdByRunId is not valid for this work product actor" });
return undefined;
}
return actorRunId;
}
if (!requestedRunId) return null;
const run = await loadWorkProductRunAttribution(requestedRunId);
if (!run || run.companyId !== companyId || run.agentCompanyId !== companyId) {
res.status(403).json({ error: "createdByRunId is not valid for this company" });
return undefined;
}
return requestedRunId;
}
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: Parameters<typeof decideIssueAccess>[1],
options: { resumeIntent?: boolean } = {},
) {
if (await assertLowTrustControlPlaneDenied(req, res, issue.companyId, issue)) return false;
// Structured resume intent is the sole comment surface that may revive a
// cancelled issue. Bare status transitions and `reopen` keep using the
// dedicated restore-flow guard.
if (issue.status === "cancelled" && options.resumeIntent !== true) {
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;
}
const boundaryDecision = await decideIssueAccess(req, issue, "issue:mutate");
if (isDefaultOpenIssueWriteDecision(boundaryDecision)) 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 requireRecoveryActionAuthority(
req: Request,
issue: { id: string; companyId: string; assigneeAgentId: string | null },
activeRecoveryAction: Awaited<ReturnType<typeof recoveryActionsSvc.getActiveForIssue>>,
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) {
throw forbidden("Agent authentication required");
}
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;
}
throw forbidden(
"Agent cannot resolve another owner's recovery action",
{
issueId: issue.id,
recoveryActionId: activeRecoveryAction.id,
actorAgentId,
assigneeAgentId: issue.assigneeAgentId,
recoveryOwnerAgentId: activeRecoveryAction.ownerAgentId,
source: input.source,
securityPrinciples: ["Least Privilege", "Complete Mediation", "Secure Defaults"],
},
);
}
function activeExecutionParticipantAgentId(issue: { executionState?: unknown }) {
const state = parseIssueExecutionState(issue.executionState);
return state?.status === "pending" && state.currentParticipant?.type === "agent"
? state.currentParticipant.agentId
: null;
}
async function requireRecoverySourceMutationAuthority(
req: Request,
issue: {
id: string;
companyId: string;
status: string;
assigneeAgentId: string | null;
checkoutRunId?: string | null;
executionRunId?: string | null;
executionState?: unknown;
},
) {
if (req.actor.type !== "agent") return;
const actorAgentId = req.actor.agentId;
if (!actorAgentId) throw forbidden("Agent authentication required");
const isSourceOwner = issue.assigneeAgentId === actorAgentId;
const isExecutionParticipant = activeExecutionParticipantAgentId(issue) === actorAgentId;
const hasPolicyGrant = Boolean(
issue.assigneeAgentId &&
await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, issue.assigneeAgentId)
);
if (!isSourceOwner && !isExecutionParticipant && !hasPolicyGrant) {
throw forbidden(
"Recovery ownership does not authorize this source issue mutation",
{
code: "recovery_source_authority_required",
issueId: issue.id,
actorAgentId,
assigneeAgentId: issue.assigneeAgentId,
currentExecutionParticipantAgentId: activeExecutionParticipantAgentId(issue),
remediation:
"Have the source owner, current execution participant, board, or a policy-authorized agent perform the source mutation.",
securityPrinciples: ["Least Privilege", "Complete Mediation", "Secure Defaults"],
},
);
}
const actorRunId = req.actor.runId?.trim() || null;
const conflictingRunId = [issue.checkoutRunId, issue.executionRunId]
.find((runId) => runId && runId !== actorRunId);
if (conflictingRunId && !hasPolicyGrant) {
throw conflict("Source issue mutation is locked by another active checkout or run", {
code: "recovery_source_run_lock",
issueId: issue.id,
actorAgentId,
actorRunId,
checkoutRunId: issue.checkoutRunId ?? null,
executionRunId: issue.executionRunId ?? null,
});
}
if (isSourceOwner && issue.status === "in_progress" && !actorRunId && !hasPolicyGrant) {
throw unauthorized("Agent run id required");
}
}
async function assertSafeRecoveryHandBackGates(input: {
req: Request;
issue: {
id: string;
companyId: string;
projectId: string | null;
assigneeAgentId: string | null;
checkoutRunId?: string | null;
executionRunId?: string | null;
executionState?: unknown;
};
recoveryAction: NonNullable<Awaited<ReturnType<typeof recoveryActionsSvc.getActiveForIssue>>>;
}) {
const returnOwnerAgentId = input.recoveryAction.returnOwnerAgentId;
if (!returnOwnerAgentId || input.issue.assigneeAgentId !== returnOwnerAgentId) {
throw forbidden(
"Safe recovery hand-back requires the recorded original owner to remain assigned",
{
code: "recovery_safe_hand_back_owner_mismatch",
issueId: input.issue.id,
assigneeAgentId: input.issue.assigneeAgentId,
returnOwnerAgentId,
},
);
}
const actorRunId = input.req.actor.type === "agent"
? input.req.actor.runId?.trim() || null
: null;
const conflictingRunId = [input.issue.checkoutRunId, input.issue.executionRunId]
.find((runId) => runId && runId !== actorRunId);
if (conflictingRunId) {
throw conflict("Safe recovery hand-back is locked by another active checkout or run", {
code: "recovery_source_run_lock",
issueId: input.issue.id,
actorRunId,
checkoutRunId: input.issue.checkoutRunId ?? null,
executionRunId: input.issue.executionRunId ?? null,
});
}
if (parseIssueExecutionState(input.issue.executionState)?.status === "pending") {
throw conflict("Safe recovery hand-back cannot bypass a pending execution review or approval stage", {
code: "recovery_governed_stage_pending",
issueId: input.issue.id,
});
}
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(input.issue.companyId, input.issue.id);
if (activePauseHold) {
throw conflict("Safe recovery hand-back blocked by active subtree pause hold", {
issueId: input.issue.id,
holdId: activePauseHold.holdId,
rootIssueId: activePauseHold.rootIssueId,
mode: activePauseHold.mode,
});
}
if (input.issue.projectId) {
const project = await projectsSvc.getById(input.issue.projectId);
if (project?.pausedAt) {
throw conflict(
project.pauseReason === "budget"
? "Project is paused because its budget hard-stop was reached"
: "Project is paused",
);
}
}
const approvals = await issueApprovalsSvc.listApprovalsForIssue(input.issue.id);
if (approvals.some((approval) => ACTIVE_REVIEW_APPROVAL_STATUSES.has(String(approval.status)))) {
throw conflict("Safe recovery hand-back cannot bypass a pending governed approval", {
code: "recovery_governed_approval_pending",
issueId: input.issue.id,
});
}
const budgetBlock = await budgetsSvc.getInvocationBlock(
input.issue.companyId,
returnOwnerAgentId,
{ issueId: input.issue.id, projectId: input.issue.projectId },
);
if (budgetBlock) {
throw conflict("Safe recovery hand-back is blocked by the source owner's budget or pause gate", {
code: "recovery_safe_hand_back_budget_blocked",
issueId: input.issue.id,
returnOwnerAgentId,
budgetBlock,
});
}
}
async function resolveActiveIssueRun(issue: {
id: string;
assigneeAgentId: string | null;
executionRunId?: string | null;
}) {
let runToInterrupt = issue.executionRunId ? await heartbeat.getRun(issue.executionRunId) : null;
if ((!runToInterrupt || runToInterrupt.status !== "running") && issue.assigneeAgentId) {
const activeRun = await heartbeat.getActiveRunForAgent(issue.assigneeAgentId);
const activeIssueId =
activeRun &&
activeRun.contextSnapshot &&
typeof activeRun.contextSnapshot === "object" &&
typeof (activeRun.contextSnapshot as Record<string, unknown>).issueId === "string"
? ((activeRun.contextSnapshot as Record<string, unknown>).issueId as string)
: null;
if (activeRun && activeRun.status === "running" && activeIssueId === issue.id) {
runToInterrupt = activeRun;
}
}
return runToInterrupt?.status === "running" ? runToInterrupt : null;
}
function operatorInterruptCancelOptions(input: { issueId: string; actor: ReturnType<typeof getActorInfo> }) {
return {
errorCode: "operator_interrupted",
resultJson: {
operatorInterrupted: true,
interruptionSource: "issue_comment_interrupt",
interruptedIssueId: input.issueId,
interruptedByActorType: input.actor.actorType,
interruptedByActorId: input.actor.actorId,
},
eventMessage: "run interrupted by board comment",
eventPayload: {
issueId: input.issueId,
source: "issue_comment_interrupt",
interruptedByActorType: input.actor.actorType,
interruptedByActorId: input.actor.actorId,
},
};
}
/**
* Refuse an agent creating a child issue assigned to the agent that created
* a still-open ancestor in the same chain. That shape is a delegation cycle
* (A delegates to B, B delegates the same work back to A): each agent lacks
* something the other assumed it had, the chain of blocked issues grows,
* and no one tells the human. Humans are unaffected, and closed ancestors
* do not count — re-engaging the creator of finished work is normal.
*/
async function assertNoAgentDelegationCycle(input: {
actorType: string;
parentIssueId: string | null | undefined;
assigneeAgentId: string | null | undefined;
}) {
if (input.actorType !== "agent") return;
if (!input.parentIssueId || !input.assigneeAgentId) return;
const ancestor = await svc.findOpenAncestorCreatedByAgent(input.parentIssueId, input.assigneeAgentId);
if (!ancestor) return;
throw conflict(
`Delegation cycle: ${ancestor.identifier ?? "an ancestor issue"} in this chain was created by the agent this child would be assigned to. ` +
"Complete the remaining work in your own issue, leave the child unassigned, or escalate to a board operator — do not delegate the work back to the agent that delegated it to you.",
{
code: "delegation_cycle",
ancestorIssueId: ancestor.id,
assigneeAgentId: input.assigneeAgentId,
},
);
}
async function normalizeIssueAssigneeAgentReference(
companyId: string,
rawAssigneeAgentId: string | null | undefined,
options: { actorType?: string } = {},
) {
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");
}
if (resolved.agent.status === "pending_approval") {
throw conflict("Cannot assign work to pending approval agents");
}
if (resolved.agent.status === "terminated") {
throw conflict("Cannot assign work to terminated agents");
}
// Agents must not route work to a paused peer/manager: the assignment is
// accepted silently, nothing will ever run it, and the issue becomes an
// invisible dead letter (e.g. escalation issues assigned to a paused
// manager via the org chart). Humans may still assign to paused agents
// deliberately — the pause state is visible in the UI and staging work
// for a later unpause is a legitimate workflow.
if (options.actorType === "agent" && resolved.agent.status === "paused") {
throw conflict(
"Cannot assign work to a paused agent. Assign an invokable agent, leave the issue unassigned, or escalate to a board operator instead.",
{ assigneeAgentId: resolved.agent.id, assigneeStatus: "paused" },
);
}
if (resolved.agent.orgChainHealth?.status === "invalid_org_chain") {
throw conflict(
resolved.agent.orgChainHealth?.repairGuidance ??
"Cannot assign work to agents with invalid org chains",
);
}
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;
}
// Reopen the closed isolated workspace that a guard found, so the request can
// continue. The return value tells the caller what happened:
// "reopened" - this request rebuilt the workspace and set the
// reopen-pending flag. The caller must install the
// consumption guard so the flag cannot leak.
// "already-open" - a concurrent request already reopened the workspace, so
// this request did not set the flag. The caller continues but
// must not install the guard, or it can clear the flag that
// the other request still owns.
// null - this function sent an error response, so the caller stops.
// The reopen is scoped to the issue company and project inside the service, and
// it runs only after the route already authorized the request on the issue.
async function reopenClosedIssueExecutionWorkspaceOrRespond(
req: Request,
res: Response,
issue: { id: string; companyId: string; projectId?: string | null },
workspace: Pick<ExecutionWorkspace, "id">,
): Promise<{ outcome: "reopened" | "already-open"; generation: number } | null> {
const actor = getActorInfo(req);
const result = await executionWorkspacesSvc.reopenClosedIsolatedExecutionWorkspaceForIssue({
workspaceId: workspace.id,
issue: { id: issue.id, companyId: issue.companyId, projectId: issue.projectId ?? null },
actor: { agentId: actor.agentId, actorType: actor.actorType },
});
if (result.ok) {
return { outcome: result.reopened ? "reopened" : "already-open", generation: result.generation };
}
if (result.code === "not_reopenable") {
res.status(409).json({ error: "This issue is linked to a closed workspace that cannot be reopened." });
} else {
res.status(503).json({ error: "Could not reopen the workspace for this issue. Please try again." });
}
return null;
}
// The keepalive re-stamps the reopen-pending flag on this interval while a
// consuming request is in flight. The interval is one fifth of the stale grace
// period, so several re-stamps land before the reaper could treat the flag as
// stranded. This keeps a live but slow request's fence against the reaper.
const REOPEN_PENDING_REFRESH_INTERVAL_MS = Math.floor(
STALE_REOPEN_PENDING_CONSUMPTION_GRACE_MS / 5,
);
// Guard a reopen against a caller that never consumes it.
// `reopenClosedIssueExecutionWorkspaceOrRespond` publishes the rebuilt worktree
// as active and sets the reopen-pending flag while the source issue is still
// terminal. The route then moves the issue out of the terminal state, and the
// terminal reaper clears the flag once it sees the non-terminal issue. If the
// route mutation returns null, throws, or leaves the issue terminal, the flag
// stays set and both the reaper and the archive route skip the row forever, so
// the rebuilt worktree leaks and no path can reclaim it.
//
// This guard runs when the response ends, so it covers every exit: a success, a
// rejected mutation, and a thrown error. It reads the final issue status through
// a getter. When the issue is null or still terminal, it clears the flag so the
// reaper can reclaim the worktree. When the issue left the terminal state, it
// does nothing and the reaper clears the flag. The guard never touches the
// response, and the underlying clear is idempotent.
function guardReopenedWorkspaceConsumption(input: {
req: Request;
res: Response;
issue: { id: string; companyId: string };
workspace: Pick<ExecutionWorkspace, "id"> | null;
generation: number | null;
finalIssueStatus: () => string | null | undefined;
}): void {
const { req, res, issue, workspace, generation, finalIssueStatus } = input;
if (!workspace || generation === null) return;
// Re-stamp the reopen-pending flag while this request is in flight. The
// request that consumes the rebuilt worktree is an HTTP request, not a
// heartbeat run, so the terminal reaper cannot see it through
// `workspaceHasActiveRun`. A request that outruns the stale grace period
// would let the reaper clear the live fence, and a later sweep would archive
// and destroy the worktree under the request. The keepalive re-stamps the
// timestamp on an interval below the grace, so the flag never looks stranded
// while the request lives. The refresh runs only while the flag is still set
// and the generation still matches, so it never revives a cleared flag and
// never refreshes a newer reopen's fence.
const keepAlive = setInterval(() => {
void executionWorkspacesSvc
.refreshReopenPendingConsumption({
workspaceId: workspace.id,
expectedGeneration: generation,
})
.then((result) => {
// The fence is no longer ours: a clear removed the flag, or a newer
// reopen or an archive raised the generation. Stop the keepalive so it
// does not re-stamp another owner's row.
if (!result.refreshed) clearInterval(keepAlive);
})
.catch((err) => {
// A transient database error must not stop the keepalive. Keep the
// interval so the next tick retries before the grace period elapses.
logger.warn(
{ err, issueId: issue.id, executionWorkspaceId: workspace.id },
"failed to refresh the reopen-pending flag for an in-flight request",
);
});
}, REOPEN_PENDING_REFRESH_INTERVAL_MS);
// Do not keep the event loop alive for the keepalive alone.
keepAlive.unref?.();
let settled = false;
const settle = () => {
if (settled) return;
settled = true;
clearInterval(keepAlive);
const status = finalIssueStatus();
if (typeof status === "string" && !isClosedIssueStatus(status)) return;
const actor = getActorInfo(req);
void clearReopenPendingConsumptionWithRetry({
workspaceId: workspace.id,
issue: { id: issue.id, companyId: issue.companyId },
actor: { agentId: actor.agentId, actorType: actor.actorType },
expectedGeneration: generation,
});
};
res.once("finish", settle);
res.once("close", settle);
}
// Clear the reopen-pending flag with a bounded retry. The response already
// ended when this runs, so it is a background best-effort. A transient database
// error must not strand the flag: while the flag stays set, the terminal reaper
// skips the workspace and the archive route rejects it, so the rebuilt worktree
// leaks. The clear is idempotent, so a retry after a partial failure is safe.
// The method returns { cleared: false } without an error when the flag is
// already clear, so that path does not retry.
async function clearReopenPendingConsumptionWithRetry(input: {
workspaceId: string;
issue: { id: string; companyId: string };
actor: { agentId: string | null; actorType: string };
expectedGeneration: number;
}): Promise<void> {
const maxAttempts = 5;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
await executionWorkspacesSvc.clearReopenPendingConsumptionForUnconsumedReopen(input);
return;
} catch (err) {
if (attempt >= maxAttempts) {
logger.error(
{ err, issueId: input.issue.id, executionWorkspaceId: input.workspaceId, attempts: attempt },
"failed to clear the reopen-pending flag after an unconsumed reopen; the rebuilt worktree may leak until the flag clears",
);
return;
}
logger.warn(
{ err, issueId: input.issue.id, executionWorkspaceId: input.workspaceId, attempt },
"retry the clear of the reopen-pending flag after an unconsumed reopen",
);
await new Promise((resolve) => setTimeout(resolve, attempt * 250));
}
}
}
async function destroyReusableSandboxLeasesForTerminalIssue(issue: {
id: string;
companyId: string;
status: string;
executionWorkspaceId?: string | null;
}) {
try {
await environmentRuntime.destroyReusableSandboxLeases({
companyId: issue.companyId,
issueId: issue.id,
executionWorkspaceId: issue.executionWorkspaceId ?? null,
failureReason: `issue_terminal_${issue.status}`,
});
} catch (err) {
logger.warn(
{ err, issueId: issue.id, executionWorkspaceId: issue.executionWorkspaceId ?? null },
"failed to destroy reusable sandbox leases for terminal issue",
);
}
}
async function resolveIssueRouteId(rawId: string): Promise<string> {
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 };
}
function compactIssueProjectWorkspace(workspace: ProjectWorkspace | null | undefined) {
if (!workspace) return null;
return {
id: workspace.id,
companyId: workspace.companyId,
projectId: workspace.projectId,
name: workspace.name,
sourceType: workspace.sourceType,
cwd: workspace.cwd,
repoUrl: workspace.repoUrl,
repoRef: workspace.repoRef,
defaultRef: workspace.defaultRef,
visibility: workspace.visibility,
setupCommand: workspace.setupCommand,
cleanupCommand: workspace.cleanupCommand,
remoteProvider: workspace.remoteProvider,
remoteWorkspaceRef: workspace.remoteWorkspaceRef,
sharedWorkspaceKey: workspace.sharedWorkspaceKey,
runtimeConfig: workspace.runtimeConfig,
isPrimary: workspace.isPrimary,
createdAt: workspace.createdAt,
updatedAt: workspace.updatedAt,
};
}
function compactIssueProject(project: Awaited<ReturnType<typeof resolveIssueProjectAndGoal>>["project"]) {
if (!project) return null;
return {
id: project.id,
companyId: project.companyId,
urlKey: project.urlKey,
goalId: project.goalId,
goalIds: project.goalIds,
goals: project.goals,
name: project.name,
description: project.description,
status: project.status,
leadAgentId: project.leadAgentId,
targetDate: project.targetDate,
color: project.color,
icon: project.icon,
env: null,
pauseReason: project.pauseReason,
pausedAt: project.pausedAt,
executionWorkspacePolicy: project.executionWorkspacePolicy,
codebase: project.codebase,
workspaces: (project.workspaces ?? []).map(compactIssueProjectWorkspace),
primaryWorkspace: compactIssueProjectWorkspace(project.primaryWorkspace),
managedByPlugin: project.managedByPlugin ?? null,
taskCount: project.taskCount,
budget: project.budget,
archivedAt: project.archivedAt,
createdAt: project.createdAt,
updatedAt: project.updatedAt,
};
}
function compactIssueRuntimeService(service: WorkspaceRuntimeService) {
return {
id: service.id,
companyId: service.companyId,
projectId: service.projectId,
projectWorkspaceId: service.projectWorkspaceId,
executionWorkspaceId: service.executionWorkspaceId,
issueId: service.issueId,
scopeType: service.scopeType,
scopeId: service.scopeId,
serviceName: service.serviceName,
status: service.status,
lifecycle: service.lifecycle,
reuseKey: service.reuseKey,
command: service.command,
cwd: service.cwd,
port: service.port,
url: service.url,
provider: service.provider,
providerRef: service.providerRef,
ownerAgentId: service.ownerAgentId,
startedByRunId: service.startedByRunId,
lastUsedAt: service.lastUsedAt,
startedAt: service.startedAt,
stoppedAt: service.stoppedAt,
healthStatus: service.healthStatus,
configIndex: service.configIndex ?? null,
};
}
function compactIssueExecutionWorkspace(workspace: ExecutionWorkspace | null) {
if (!workspace) return null;
return {
id: workspace.id,
companyId: workspace.companyId,
projectId: workspace.projectId,
projectWorkspaceId: workspace.projectWorkspaceId,
sourceIssueId: workspace.sourceIssueId,
mode: workspace.mode,
strategyType: workspace.strategyType,
name: workspace.name,
status: workspace.status,
deliveryState: workspace.deliveryState,
cwd: workspace.cwd,
repoUrl: workspace.repoUrl,
baseRef: workspace.baseRef,
branchName: workspace.branchName,
providerType: workspace.providerType,
providerRef: workspace.providerRef,
derivedFromExecutionWorkspaceId: workspace.derivedFromExecutionWorkspaceId,
lastUsedAt: workspace.lastUsedAt,
openedAt: workspace.openedAt,
closedAt: workspace.closedAt,
cleanupEligibleAt: workspace.cleanupEligibleAt,
cleanupReason: workspace.cleanupReason,
config: workspace.config
? {
environmentId: workspace.config.environmentId,
provisionCommand: workspace.config.provisionCommand,
runtimeProvisionCommand: workspace.config.runtimeProvisionCommand,
teardownCommand: workspace.config.teardownCommand,
cleanupCommand: workspace.config.cleanupCommand,
workspaceRuntime: workspace.config.workspaceRuntime,
desiredState: workspace.config.desiredState,
serviceStates: workspace.config.serviceStates,
}
: null,
metadata: null,
runtimeServices: (workspace.runtimeServices ?? [])
.filter((service) =>
service.status === "provisioning" || service.status === "starting" || service.status === "running"
)
.map(compactIssueRuntimeService),
createdAt: workspace.createdAt,
updatedAt: workspace.updatedAt,
};
}
// 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/extract", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const companyScopeDecision = await access.decide({
actor: req.actor,
action: "company_scope:read",
resource: { type: "company", companyId },
});
if (!companyScopeDecision.allowed) {
res.status(403).json({ error: "Company search is outside this actor's authorization boundary" });
return;
}
const parsedQuery = companySearchExtractQuerySchema.safeParse(req.query);
if (!parsedQuery.success) {
res.status(400).json({
error: parsedQuery.error.issues[0]?.message ?? "Invalid extract search query",
});
return;
}
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().extract(companyId, parsedQuery.data);
res.json(result);
});
router.get("/companies/:companyId/search", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const companyScopeDecision = await access.decide({
actor: req.actor,
action: "company_scope:read",
resource: { type: "company", companyId },
});
if (!companyScopeDecision.allowed) {
res.status(403).json({ error: "Company search is outside this actor's authorization boundary" });
return;
}
const parsedQuery = companySearchQuerySchema.safeParse(req.query);
if (!parsedQuery.success) {
res.status(400).json({
error: parsedQuery.error.issues[0]?.message ?? "Invalid search query",
});
return;
}
let query = parsedQuery.data;
if (query.assigneeUserId === "me") {
if (req.actor.type !== "board" || !req.actor.userId) {
res.status(403).json({ error: "assigneeUserId=me requires board authentication" });
return;
}
query = { ...query, assigneeUserId: req.actor.userId };
}
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 startedAt = Date.now();
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (isTaskBridgeKeyActor(req)) {
res.status(403).json({ error: "Task bridge keys cannot use company-wide issue list APIs" });
return;
}
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;
const sortField = req.query.sortField as string | undefined;
const sortDir = req.query.sortDir as string | undefined;
const view = req.query.view as string | undefined;
const compactView = view === "compact";
const hasPlanDocument = parseOptionalBooleanQuery(req.query.hasPlanDocument);
const includeLiveDescendantSummary = parseOptionalBooleanQuery(req.query.includeLiveDescendantSummary);
const assigneeAgentFilterRaw = req.query.assigneeAgentId;
let assigneeAgentId: string | null | undefined;
const rawUpdatedSince = req.query.updatedSince 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 (view !== undefined && view !== "compact") {
res.status(400).json({ error: "view must be 'compact' 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;
}
if (sortField !== undefined && sortField !== "updated") {
res.status(400).json({ error: "sortField must be 'updated' when provided" });
return;
}
if (sortDir !== undefined && sortDir !== "asc" && sortDir !== "desc") {
res.status(400).json({ error: "sortDir must be 'asc' or 'desc' when provided" });
return;
}
if (hasPlanDocument === null) {
res.status(400).json({ error: "hasPlanDocument must be true or false when provided" });
return;
}
if (includeLiveDescendantSummary === null) {
res.status(400).json({ error: "includeLiveDescendantSummary must be true or false when provided" });
return;
}
if (assigneeAgentFilterRaw !== undefined) {
if (typeof assigneeAgentFilterRaw !== "string") {
res.status(422).json({ error: "assigneeAgentId must be a UUID or 'null'" });
return;
}
const normalizedAssigneeAgentFilter = assigneeAgentFilterRaw.trim();
if (normalizedAssigneeAgentFilter.length === 0) {
assigneeAgentId = undefined;
} else if (normalizedAssigneeAgentFilter.toLowerCase() === "null") {
assigneeAgentId = null;
} else if (isUuidLike(normalizedAssigneeAgentFilter)) {
assigneeAgentId = normalizedAssigneeAgentFilter;
} else {
res.status(422).json({ error: "assigneeAgentId must be a UUID or 'null'" });
return;
}
}
if (rawUpdatedSince !== undefined && !Number.isFinite(new Date(rawUpdatedSince).getTime())) {
res.status(400).json({ error: "updatedSince must be a valid ISO 8601 timestamp when provided" });
return;
}
const offset = parsedOffset ?? 0;
const listFilters: IssueFilters = {
attention: attention === "blocked" ? "blocked" : undefined,
status: req.query.status as string | string[] | undefined,
assigneeAgentId,
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 ?? req.query.parentIssueId) 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",
includeLiveDescendantSummary: includeLiveDescendantSummary === true,
hasPlanDocument,
q: req.query.q as string | undefined,
limit,
offset,
sortField: sortField === "updated" ? "updated" : undefined,
sortDir: sortDir === "asc" || sortDir === "desc" ? sortDir : undefined,
updatedSince: rawUpdatedSince,
};
const requestKey = issueListRequestKey({
req,
companyId,
normalizedQuery: {
...listFilters,
view: compactView ? "compact" : undefined,
},
});
const coordinated = await coordinateIssueListGet({
req,
companyId,
requestKey,
allowTtlCache: compactView,
diagnostics: opts.issueListDiagnostics,
compute: async () => {
const rawResult = await svc.list(companyId, listFilters);
const result = await actorCanReadCompanyScope(req, companyId)
? rawResult
: await filterIssuesForActor(req, rawResult);
const issueIds = result.map((issue) => issue.id);
if (compactView) {
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);
}));
const compactResult = result.map((issue) =>
toCompactIssue({
...issue,
activeRecoveryAction: recoveryActionByIssue.get(issue.id) ?? null,
successfulRunHandoff: handoffStates.get(issue.id) ?? null,
}));
return {
kind: "compact",
body: compactResult,
etag: compactIssueListEtag(compactResult),
cacheControl: "private, must-revalidate",
};
}
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);
}));
return {
kind: "full",
body: result.map((issue) => ({
...issue,
successfulRunHandoff: handoffStates.get(issue.id) ?? null,
activeRecoveryAction: recoveryActionByIssue.get(issue.id) ?? null,
})),
};
},
});
res.setHeader("X-Paperclip-Request-Cache", coordinated.cacheStatus);
if (!coordinated.response) {
const body = {
error: "Too many concurrent issue-list requests for this actor/client",
retryAfterSeconds: coordinated.retryAfterSeconds ?? 1,
};
res.setHeader("Retry-After", String(body.retryAfterSeconds));
logIssueListRequest({
req,
res,
companyId,
requestKey,
startedAt,
cacheStatus: "retry",
bodyBytes: estimatedJsonBytes(body),
etagOutcome: "none",
identicalInFlightCount: coordinated.identicalInFlightCount,
});
res.status(429).json(body);
return;
}
if (coordinated.response.kind === "compact") {
res.setHeader("Cache-Control", coordinated.response.cacheControl);
res.setHeader("ETag", coordinated.response.etag);
const etagMatched = requestMatchesEtag(req.header("if-none-match"), coordinated.response.etag);
logIssueListRequest({
req,
res,
companyId,
requestKey,
startedAt,
cacheStatus: coordinated.cacheStatus,
bodyBytes: etagMatched ? 0 : estimatedJsonBytes(coordinated.response.body),
etagOutcome: etagMatched ? "not_modified" : "fresh",
identicalInFlightCount: coordinated.identicalInFlightCount,
});
if (etagMatched) {
res.status(304).end();
return;
}
res.json(coordinated.response.body);
return;
}
logIssueListRequest({
req,
res,
companyId,
requestKey,
startedAt,
cacheStatus: coordinated.cacheStatus,
bodyBytes: estimatedJsonBytes(coordinated.response.body),
etagOutcome: "none",
identicalInFlightCount: coordinated.identicalInFlightCount,
});
res.json(coordinated.response.body);
});
router.get("/companies/:companyId/issues/count", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (isTaskBridgeKeyActor(req)) {
res.status(403).json({ error: "Task bridge keys cannot use company-wide issue count APIs" });
return;
}
const attention = req.query.attention as string | undefined;
const hasPlanDocument = parseOptionalBooleanQuery(req.query.hasPlanDocument);
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;
}
if (hasPlanDocument === null) {
res.status(400).json({ error: "hasPlanDocument must be true or false when provided" });
return;
}
const blockedCountFilters = {
attention: "blocked",
status: req.query.status as string | 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 ?? req.query.parentIssueId) 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,
hasPlanDocument,
q: req.query.q as string | undefined,
} as const;
if (!(await actorCanReadCompanyScope(req, companyId))) {
const trustResolution = req.actor.type === "agent"
? await resolveAgentTrustForIssue({
agentId: req.actor.agentId,
runId: req.actor.runId,
}, companyId, null)
: null;
if (trustResolution?.kind === "denied") {
throw forbidden(trustResolution.detail);
}
if (trustResolution?.kind === "low_trust_review") {
const count = await svc.count(companyId, {
...blockedCountFilters,
lowTrustBoundary: trustResolution.boundary,
});
res.json({ count });
return;
}
let offset = 0;
let visibleCount = 0;
while (true) {
const rows = await svc.list(companyId, {
...blockedCountFilters,
limit: ISSUE_LIST_MAX_LIMIT,
offset,
});
visibleCount += (await filterIssuesForActor(req, rows)).length;
if (rows.length < ISSUE_LIST_MAX_LIMIT) break;
offset += rows.length;
}
res.json({ count: visibleCount });
return;
}
const count = await svc.count(companyId, blockedCountFilters);
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,
agentApiKeyId: actor.agentApiKeyId,
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 getAccessibleResource(req, res, svc.getLabelById(labelId), "Label not found");
if (!existing) return;
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,
agentApiKeyId: actor.agentApiKeyId,
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 getAccessibleResource(req, res, getIssueById(req, id), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(req, res, issue))) return;
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,
reviewAttention,
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.listReviewAttention(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,
});
const redactLowTrust = await shouldRedactLowTrustForHeartbeatContext(issue, getActorInfo(req));
const safeWakeComment =
wakeComment && wakeComment.issueId === issue.id
? redactLowTrust
? sanitizeQuarantinedCommentForHigherTrust(wakeComment)
: wakeComment
: null;
const safeContinuationSummary =
continuationSummary && redactLowTrust
? redactQuarantinedBodyForHigherTrust(continuationSummary)
: continuationSummary;
const planReviewContext = await buildPlanReviewContext({
db,
companyId: issue.companyId,
issueId: issue.id,
issueWorkMode: issue.workMode,
includeForIssueComment: wakeCommentId !== null,
});
const documentReviewContext = await buildDocumentReviewContext({
db,
companyId: issue.companyId,
issueId: issue.id,
includeForIssueComment: wakeCommentId !== null,
});
const response = {
issue: {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
description: issue.description,
status: issue.status,
workMode: issue.workMode,
...(blockerAttention ? { blockerAttention } : {}),
...(reviewAttention ? { reviewAttention } : {}),
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: safeWakeComment,
attachments: attachments.map((a) => ({
id: a.id,
filename: a.originalFilename,
contentType: a.contentType,
byteSize: a.byteSize,
contentPath: withContentPath(a).contentPath,
createdAt: a.createdAt,
})),
continuationSummary: safeContinuationSummary
? {
key: safeContinuationSummary.key,
title: safeContinuationSummary.title,
body: safeContinuationSummary.body ?? "",
latestRevisionId: safeContinuationSummary.latestRevisionId,
latestRevisionNumber: safeContinuationSummary.latestRevisionNumber,
updatedAt: safeContinuationSummary.updatedAt,
sourceTrust: safeContinuationSummary.sourceTrust ?? null,
}
: null,
planReviewContext,
documentReviewContext,
currentExecutionWorkspace: compactIssueExecutionWorkspace(currentExecutionWorkspace),
};
res.json(await runRedactions.redactForIssue(issue.companyId, issue.id, response));
});
router.get("/issues/:id/diagnostics/blockers", async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, getIssueById(req, id), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(req, res, issue))) return;
const diagnostic = await svc.getBlockerDiagnostics(issue.id);
const visibleBlockers = await filterIssuesForActor(req, diagnostic.blockers);
const response = buildIssueBlockerDiagnosticsResponse({
issue,
blockers: diagnostic.blockers,
visibleBlockers,
readiness: diagnostic.readiness,
truncated: diagnostic.truncated,
});
logger.info(
{
companyId: issue.companyId,
issueId: issue.id,
actorType: req.actor.type,
visibleBlockerCount: response.blockers.length,
omittedUnauthorizedBlockerCount: response.omittedUnauthorizedBlockerCount,
truncated: response.truncated,
},
"issue blocker diagnostics read",
);
res.json(response);
});
router.get("/issues/:id/diagnostics/wakes", async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, getIssueById(req, id), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(req, res, issue))) return;
const [wakeDiagnostic, blockerDiagnostic, includeInternalIds] = await Promise.all([
svc.getWakeDiagnostics(issue.id),
svc.getBlockerDiagnostics(issue.id),
actorCanReadCompanyScope(req, issue.companyId),
]);
const visibleBlockers = await filterIssuesForActor(req, blockerDiagnostic.blockers);
const blockerResponse = buildIssueBlockerDiagnosticsResponse({
issue,
blockers: blockerDiagnostic.blockers,
visibleBlockers,
readiness: blockerDiagnostic.readiness,
truncated: blockerDiagnostic.truncated,
});
const response = buildIssueWakeDiagnosticsResponse({
issue,
wakeRequests: wakeDiagnostic.wakeRequests,
activityRecords: wakeDiagnostic.activityRecords,
blockerDiagnostics: blockerResponse,
truncatedWakeRequests: wakeDiagnostic.truncatedWakeRequests,
truncatedActivityRecords: wakeDiagnostic.truncatedActivityRecords,
includeInternalIds,
});
logger.info(
{
companyId: issue.companyId,
issueId: issue.id,
actorType: req.actor.type,
wakeRequestCount: response.wakeRequestCount,
activityRecordCount: response.activityRecordCount,
internalIdsIncluded: includeInternalIds,
truncated: response.truncated,
},
"issue wake diagnostics read",
);
res.json(response);
});
router.get("/issues/:id/diagnostics/subtree", async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, getIssueById(req, id), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(req, res, issue))) return;
const [diagnostic, includeInternalIds] = await Promise.all([
svc.getSubtreeDiagnostics(issue.id),
actorCanReadCompanyScope(req, issue.companyId),
]);
const allBlockers = [...diagnostic.blockersByIssueId.values()].flat();
const [visibleNodes, visibleBlockers] = await Promise.all([
filterIssuesForActor(req, diagnostic.nodes),
filterIssuesForActor(req, allBlockers),
]);
const response = buildIssueSubtreeDiagnosticsResponse({
issue,
nodes: diagnostic.nodes,
visibleNodes,
blockersByIssueId: diagnostic.blockersByIssueId,
visibleBlockers,
readinessByIssueId: diagnostic.readinessByIssueId,
wakeRequestsByIssueId: diagnostic.wakeRequestsByIssueId,
activityRecordsByIssueId: diagnostic.activityRecordsByIssueId,
truncatedNodes: diagnostic.truncatedNodes,
truncatedDepth: diagnostic.truncatedDepth,
truncatedBlockerIssueIds: diagnostic.truncatedBlockerIssueIds,
truncatedWakeIssueIds: diagnostic.truncatedWakeIssueIds,
truncatedActivityIssueIds: diagnostic.truncatedActivityIssueIds,
includeInternalIds,
caps: diagnostic.caps,
});
logger.info(
{
companyId: issue.companyId,
issueId: issue.id,
actorType: req.actor.type,
nodeCount: response.nodeCount,
omittedUnauthorizedNodeCount: response.omittedUnauthorizedNodeCount,
edgeCount: response.edges.length,
internalIdsIncluded: includeInternalIds,
truncated: response.truncated,
},
"issue subtree diagnostics read",
);
res.json(response);
});
router.get("/issues/:id", async (req, res) => {
const requestStartedAt = performance.now();
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, getIssueById(req, id), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(req, res, issue))) return;
const inboxArchiveFieldsPromise = req.actor.type === "board" && req.actor.userId
? svc.getActiveInboxArchiveFields(issue, req.actor.userId)
: Promise.resolve({});
const [
{ project, goal },
ancestors,
mentionedProjectIds,
documentPayload,
relations,
blockerAttention,
reviewAttention,
productivityReview,
referenceSummary,
successfulRunHandoffStates,
scheduledRetry,
activeRecoveryAction,
linkedCases,
inboxArchiveFields,
] = 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.listReviewAttention(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),
listIssueLinkedCases(db, issue.companyId, issue.id),
inboxArchiveFieldsPromise,
]);
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.setHeader("Server-Timing", `paperclip_issue;dur=${(performance.now() - requestStartedAt).toFixed(1)}`);
res.json({
...issue,
...inboxArchiveFields,
goalId: goal?.id ?? issue.goalId,
ancestors,
...(blockerAttention ? { blockerAttention } : {}),
...(reviewAttention ? { reviewAttention } : {}),
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: compactIssueProject(project),
goal: goal ?? null,
mentionedProjects,
currentExecutionWorkspace: compactIssueExecutionWorkspace(currentExecutionWorkspace),
workProducts,
linkedCases,
});
});
router.get("/issues/:id/watchdog", async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, getIssueById(req, id), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(req, res, issue))) return;
res.json(await taskWatchdogsSvc.getActiveForIssue(issue.companyId, issue.id));
});
router.put("/issues/:id/watchdog", validate(upsertIssueWatchdogSchema), async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, getIssueById(req, id), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(req, res, issue))) return;
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
if (await rejectTaskWatchdogConfigMutation(req, res)) return;
if (await assertLowTrustControlPlaneDenied(req, res, issue.companyId, issue)) return;
const actor = getActorInfo(req);
const existingWatchdog = await taskWatchdogsSvc.getActiveForIssue(issue.companyId, issue.id);
const { watchdog, created } = await taskWatchdogsSvc.upsertForIssue(issue.companyId, issue.id, {
agentId: req.body.agentId,
instructions: req.body.instructions,
actor: {
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
runId: actor.runId,
},
});
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: created ? "issue.watchdog_created" : "issue.watchdog_updated",
entityType: "issue",
entityId: issue.id,
details: {
identifier: issue.identifier,
watchdogId: watchdog.id,
watchdogAgentId: watchdog.watchdogAgentId,
instructionsChanged: (existingWatchdog?.instructions ?? null) !== (watchdog.instructions ?? null),
},
});
await queueTaskWatchdogEvaluation(issue, actor.runId);
res.json(watchdog);
});
router.delete("/issues/:id/watchdog", async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, getIssueById(req, id), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(req, res, issue))) return;
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
if (await rejectTaskWatchdogConfigMutation(req, res)) return;
if (await assertLowTrustControlPlaneDenied(req, res, issue.companyId, issue)) return;
const actor = getActorInfo(req);
const disabled = await taskWatchdogsSvc.disableForIssue(issue.companyId, issue.id, {
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
runId: actor.runId,
});
if (disabled) {
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.watchdog_removed",
entityType: "issue",
entityId: issue.id,
details: {
identifier: issue.identifier,
watchdogId: disabled.id,
watchdogAgentId: disabled.watchdogAgentId,
},
});
}
await queueTaskWatchdogEvaluation(issue, actor.runId);
res.json({ ok: true });
});
router.get("/issues/:id/recovery-actions", async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, getIssueById(req, id), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(req, res, issue))) return;
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 getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!existing) return;
if (!(await assertIssueReadAllowed(req, res, existing))) return;
if (await assertLowTrustControlPlaneDenied(req, res, existing.companyId, existing)) return;
if (req.actor.type === "agent") {
const boundaryDecision = await decideIssueAccess(req, existing, "issue:mutate");
if (!boundaryDecision.allowed) {
await denyIssueWrite(req, res, existing, issueWriteDenialCodeForDecision(boundaryDecision));
return;
}
if (!requireAgentRunId(req, res)) return;
if (!(await assertCrossIssueInfluenceWithinRunCap(req, res, existing, "update"))) return;
}
const { actionId, outcome, sourceIssueStatus, resolutionNote } = req.body;
if (outcome === "false_positive" || outcome === "cancelled") {
assertBoard(req);
}
const actor = getActorInfo(req);
const actionStatus = outcome === "cancelled" ? "cancelled" : "resolved";
const postCommitActivityPublications: ActivityPublication[] = [];
const result = await db.transaction(async (tx) => {
const lockedIssue = await tx
.select()
.from(issueRows)
.where(and(eq(issueRows.companyId, existing.companyId), eq(issueRows.id, existing.id)))
.for("update")
.then((rows) => rows[0] ?? null);
if (!lockedIssue) throw notFound("Issue not found");
const activeRecoveryAction = await recoveryActionsSvc.getActiveForIssue(
lockedIssue.companyId,
lockedIssue.id,
tx,
);
if (!activeRecoveryAction || (actionId && activeRecoveryAction.id !== actionId)) {
throw notFound("Active recovery action not found");
}
await requireRecoveryActionAuthority(
req,
lockedIssue,
activeRecoveryAction,
{ source: "recovery_action_resolution" },
);
let issue = lockedIssue;
const sourceStatusChanged = sourceIssueStatus !== lockedIssue.status;
if (outcome === "blocked" && sourceStatusChanged) {
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 (sourceStatusChanged) {
const safeHandBack =
outcome === "restored" &&
sourceIssueStatus === "todo" &&
activeRecoveryAction.returnOwnerAgentId != null &&
lockedIssue.assigneeAgentId === activeRecoveryAction.returnOwnerAgentId;
if (safeHandBack) {
await assertSafeRecoveryHandBackGates({
req,
issue: lockedIssue,
recoveryAction: activeRecoveryAction,
});
} else {
await requireRecoverySourceMutationAuthority(req, lockedIssue);
}
if (
lockedIssue.status === "in_review" &&
(sourceIssueStatus === "done" || sourceIssueStatus === "cancelled") &&
lockedIssue.reviewPolicy != null &&
lockedIssue.reviewPolicy !== "anyone"
) {
await assertIssueReviewVerdictActorAllowed(tx as unknown as Db, {
issue: lockedIssue,
actor: { type: actor.actorType, id: actor.actorId },
});
}
const updateFields: Record<string, unknown> = { status: sourceIssueStatus };
if (!safeHandBack) {
await assertAgentInReviewReviewPath({
existing: lockedIssue,
updateFields,
actorType: req.actor.type,
actorAgentId: actor.agentId,
actorRunId: actor.runId,
});
const executionPolicy = normalizeIssueExecutionPolicy(lockedIssue.executionPolicy ?? null);
const transition = applyIssueExecutionPolicyTransition({
issue: lockedIssue,
policy: executionPolicy,
previousPolicy: executionPolicy,
requestedStatus: sourceIssueStatus,
requestedAssigneePatch: {},
actor: {
agentId: actor.agentId ?? null,
userId: actor.actorType === "user" ? actor.actorId : null,
},
allowBoardOverride: req.actor.type === "board",
commentBody: resolutionNote ?? null,
});
Object.assign(updateFields, transition.patch);
if (transition.decision) {
const decisionId = randomUUID();
const nextExecutionState = updateFields.executionState;
if (!nextExecutionState || typeof nextExecutionState !== "object") {
throw new Error("Execution policy decision patch is missing executionState");
}
updateFields.executionState = { ...nextExecutionState, lastDecisionId: decisionId };
await tx.insert(issueExecutionDecisions).values({
id: decisionId,
companyId: lockedIssue.companyId,
issueId: lockedIssue.id,
stageId: transition.decision.stageId,
stageType: transition.decision.stageType,
actorAgentId: actor.agentId ?? null,
actorUserId: actor.actorType === "user" ? actor.actorId : null,
outcome: transition.decision.outcome,
body: transition.decision.body,
createdByRunId: actor.runId ?? null,
});
}
}
const updatedIssue = await svc.update(
id,
{
...updateFields,
actorAgentId: actor.agentId ?? null,
actorUserId: actor.actorType === "user" ? actor.actorId : null,
},
tx,
postCommitActivityPublications,
);
if (!updatedIssue) throw notFound("Issue not found");
issue = updatedIssue;
}
const recordedOutcome =
outcome === "restored" && issue.status === "todo" &&
activeRecoveryAction.returnOwnerAgentId != null &&
issue.assigneeAgentId === activeRecoveryAction.returnOwnerAgentId
? "handed_back"
: outcome === "restored" && issue.status === "done"
? "owner_completed"
: outcome;
const recoveryAction = await recoveryActionsSvc.resolveActiveForIssue(
{
companyId: existing.companyId,
sourceIssueId: existing.id,
actionId: activeRecoveryAction.id,
status: actionStatus,
outcome: recordedOutcome,
resolutionNote: resolutionNote ?? null,
},
tx,
);
if (!recoveryAction) throw notFound("Active recovery action not found");
return { issue, recoveryAction };
});
for (const publication of postCommitActivityPublications) publishActivity(publication);
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,
agentApiKeyId: actor.agentApiKeyId,
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,
agentApiKeyId: actor.agentApiKeyId,
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" &&
result.issue.assigneeAgentId &&
(existing.status !== result.issue.status ||
existing.assigneeAgentId !== result.issue.assigneeAgentId)
) {
try {
await enqueueRecoveryActionWakeup(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 getAccessibleResource(req, res, getIssueById(req, id), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(req, res, issue))) return;
const workProducts = await workProductsSvc.listForIssue(issue.id);
res.json(workProducts);
});
router.get("/issues/:id/external-objects", async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, getIssueById(req, id), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(req, res, issue))) return;
const objects = await externalObjectsSvc.listForIssue(issue.id);
res.json(objects);
});
router.get("/issues/:id/external-object-summary", async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, getIssueById(req, id), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(req, res, issue))) return;
const summary = await externalObjectsSvc.getIssueSummary(issue.id);
res.json(summary);
});
router.post("/companies/:companyId/issues/external-object-summaries", validate(externalObjectSummariesSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const requestedIssueIds = [...new Set(req.body.issueIds as string[])];
const candidateIssues = requestedIssueIds.length > 0
? await db
.select({
id: issueRows.id,
companyId: issueRows.companyId,
projectId: issueRows.projectId,
parentId: issueRows.parentId,
assigneeAgentId: issueRows.assigneeAgentId,
assigneeUserId: issueRows.assigneeUserId,
status: issueRows.status,
})
.from(issueRows)
.where(and(eq(issueRows.companyId, companyId), inArray(issueRows.id, requestedIssueIds)))
: [];
const readableIssueIds = (await filterIssuesForActor(req, candidateIssues)).map((issue) => issue.id);
const summaries = await externalObjectsSvc.getIssueSummaries(companyId, readableIssueIds);
res.json({ summaries: Object.fromEntries(summaries) });
});
router.post("/issues/:id/external-objects/refresh", validate(refreshExternalObjectsSchema), async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
const actor = getActorInfo(req);
const results = await externalObjectsSvc.refreshIssueObjects(issue.id, {
companyId: issue.companyId,
objectIds: req.body.objectIds,
actor,
});
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "external_object.refresh_requested",
entityType: "issue",
entityId: issue.id,
details: {
issueId: issue.id,
objectIds: results.map((result) => result.object.id),
},
});
res.json({ refreshed: results });
});
router.get("/issues/:id/documents", async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, getIssueById(req, id), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(req, res, issue))) return;
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 getAccessibleResource(req, res, getIssueById(req, id), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(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 doc = await documentsSvc.getIssueDocumentByKey(issue.id, keyParsed.data);
if (!doc) {
res.status(404).json({ error: "Document not found" });
return;
}
if (!shouldIncludeDocumentAnnotations(req)) {
res.json(doc);
return;
}
const annotations = await documentAnnotationsSvc.listThreadsForIssueDocument(issue.id, keyParsed.data, {
status: "open",
includeComments: shouldIncludeDocumentAnnotationComments(req),
});
res.json({ ...doc, annotations });
});
router.get("/issues/:id/documents/:key/annotations", async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, getIssueById(req, id), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(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 status = req.query.status === "resolved" || req.query.status === "all" ? req.query.status : "open";
const threads = await documentAnnotationsSvc.listThreadsForIssueDocument(issue.id, keyParsed.data, {
status,
includeComments: parseBooleanQuery(req.query.includeComments),
});
res.json(threads);
});
router.post(
"/issues/:id/documents/:key/annotations",
validate(createDocumentAnnotationThreadSchema),
async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
if (!(await assertAgentIssueMutationAllowed(req, res, issue, { allowVisibleIssueWrite: true }))) 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, annotationActor } = annotationActorInput(req);
const referenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
const thread = await documentAnnotationsSvc.createThread(issue.id, keyParsed.data, req.body, annotationActor);
const firstComment = thread.comments[0];
if (firstComment) await issueReferencesSvc.syncAnnotationComment(firstComment.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,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.document_annotation_thread_created",
entityType: "issue",
entityId: issue.id,
details: {
key: thread.documentKey,
documentKey: thread.documentKey,
documentId: thread.documentId,
threadId: thread.id,
commentId: firstComment?.id ?? null,
revisionNumber: thread.currentRevisionNumber,
quote: thread.selectedText.slice(0, 240),
...summarizeIssueReferenceActivityDetails({
addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity),
removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity),
currentReferencedIssues: referenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity),
}),
},
});
res.status(201).json(thread);
},
);
router.get("/issues/:id/documents/:key/annotations/:threadId", async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, getIssueById(req, id), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(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 thread = await documentAnnotationsSvc.getThreadForIssueDocument(
issue.id,
keyParsed.data,
req.params.threadId as string,
);
if (!thread) {
res.status(404).json({ error: "Annotation thread not found" });
return;
}
res.json(thread);
});
router.post(
"/issues/:id/documents/:key/annotations/:threadId/comments",
validate(createDocumentAnnotationCommentSchema),
async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const { actor, annotationActor } = annotationActorInput(req);
const referenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
const comment = await documentAnnotationsSvc.addComment(
issue.id,
keyParsed.data,
req.params.threadId as string,
req.body,
annotationActor,
);
await issueReferencesSvc.syncAnnotationComment(comment.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,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.document_annotation_comment_added",
entityType: "issue",
entityId: issue.id,
details: {
key: keyParsed.data,
documentKey: keyParsed.data,
threadId: comment.threadId,
commentId: comment.id,
bodySnippet: comment.body.slice(0, 120),
...summarizeIssueReferenceActivityDetails({
addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity),
removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity),
currentReferencedIssues: referenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity),
}),
},
});
res.status(201).json(comment);
},
);
router.patch(
"/issues/:id/documents/:key/annotations/:threadId",
validate(updateDocumentAnnotationThreadSchema),
async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const { actor, annotationActor } = annotationActorInput(req);
const thread = await documentAnnotationsSvc.updateThread(
issue.id,
keyParsed.data,
req.params.threadId as string,
req.body,
annotationActor,
);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: thread.status === "resolved"
? "issue.document_annotation_thread_resolved"
: "issue.document_annotation_thread_reopened",
entityType: "issue",
entityId: issue.id,
details: {
key: thread.documentKey,
documentKey: thread.documentKey,
documentId: thread.documentId,
threadId: thread.id,
status: thread.status,
},
});
res.json(thread);
},
);
router.put("/issues/:id/documents/:key", validate(upsertIssueDocumentSchema), async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
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 sourceTrust = await sourceTrustForActorWrite(issue, actor);
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,
sourceTrust,
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);
await externalObjectsSvc.syncDocumentSafely(doc.id);
const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter);
const remappedAnnotations = result.created
? []
: await documentAnnotationsSvc.remapOpenThreadsForDocument({
issueId: issue.id,
key: doc.key,
documentId: doc.id,
nextRevisionId: doc.latestRevisionId,
nextRevisionNumber: doc.latestRevisionNumber,
nextBody: doc.body,
});
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
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),
}),
},
});
for (const remap of remappedAnnotations) {
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.document_annotation_remapped",
entityType: "issue",
entityId: issue.id,
details: {
key: doc.key,
documentId: doc.id,
threadId: remap.thread.id,
revisionNumber: doc.latestRevisionNumber,
anchorState: remap.thread.anchorState,
anchorConfidence: remap.thread.anchorConfidence,
snapshotId: remap.snapshot.id,
},
});
}
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 queueExpiredInteractionReviewPathRecovery({
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 getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
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,
agentApiKeyId: actor.agentApiKeyId,
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 getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
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,
agentApiKeyId: actor.agentApiKeyId,
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 getAccessibleResource(req, res, getIssueById(req, id), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(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 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 getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
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);
await externalObjectsSvc.syncDocumentSafely(result.document.id);
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter);
const remappedAnnotations = await documentAnnotationsSvc.remapOpenThreadsForDocument({
issueId: issue.id,
key: result.document.key,
documentId: result.document.id,
nextRevisionId: result.document.latestRevisionId,
nextRevisionNumber: result.document.latestRevisionNumber,
nextBody: result.document.body,
});
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
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),
}),
},
});
for (const remap of remappedAnnotations) {
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.document_annotation_remapped",
entityType: "issue",
entityId: issue.id,
details: {
key: result.document.key,
documentId: result.document.id,
threadId: remap.thread.id,
revisionNumber: result.document.latestRevisionNumber,
anchorState: remap.thread.anchorState,
anchorConfidence: remap.thread.anchorConfidence,
snapshotId: remap.snapshot.id,
},
});
}
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 queueExpiredInteractionReviewPathRecovery({
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 getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
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);
if (removed) await externalObjectsSvc.syncDocumentSafely(removed.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,
agentApiKeyId: actor.agentApiKeyId,
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 queueExpiredInteractionReviewPathRecovery({
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 getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return;
const actor = getActorInfo(req);
const createInput = {
...req.body,
projectId: req.body.projectId ?? issue.projectId ?? null,
sourceTrust: await sourceTrustForActorWrite(issue, actor),
};
const createdByRunId = await resolveWorkProductCreatedByRunId(req, res, issue.companyId, req.body, "create");
if (createdByRunId === undefined) return;
createInput.createdByRunId = createdByRunId;
if (requiresPaperclipAttachmentMetadata(createInput)) {
createInput.metadata = await canonicalizePaperclipArtifactMetadata({
issue,
metadata: req.body.metadata ?? null,
});
}
const product = await workProductsSvc.createForIssue(issue.id, issue.companyId, createInput);
if (!product) {
res.status(422).json({ error: "Invalid work product payload" });
return;
}
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
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.post("/issues/:id/low-trust/promotions", validate(promoteLowTrustOutputSchema), async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(req, res, issue))) return;
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return;
const actor = getActorInfo(req);
if (await sourceTrustForActorWrite(issue, actor)) {
res.status(403).json({ error: "Low-trust actors cannot promote quarantined output" });
return;
}
const sourceTrust = await lookupLowTrustSourceArtifact({
issueId: issue.id,
artifactKind: req.body.sourceArtifactKind,
artifactId: req.body.sourceArtifactId,
});
if (!sourceTrust) {
res.status(404).json({ error: "Low-trust source artifact not found" });
return;
}
if (!isLowTrustQuarantined(sourceTrust)) {
res.status(422).json({ error: "Source artifact is not quarantined low-trust output" });
return;
}
const promotedAt = new Date();
const promotionTrust = buildPromotedSourceTrust({
sourceIssueId: issue.id,
sourceArtifactKind: req.body.sourceArtifactKind,
sourceArtifactId: req.body.sourceArtifactId,
promotedByActorType: actor.actorType,
promotedByActorId: actor.actorId,
promotedAt,
});
const product = await db.transaction(async (tx) => {
const markPromoted = { sourceTrust: promotionTrust, updatedAt: promotedAt };
const updatedSource = await (async () => {
if (req.body.sourceArtifactKind === "issue") {
return tx
.update(issueRows)
.set(markPromoted)
.where(and(
eq(issueRows.id, req.body.sourceArtifactId),
eq(issueRows.sourceTrust, sourceTrust),
))
.returning({ id: issueRows.id });
}
if (req.body.sourceArtifactKind === "comment") {
return tx
.update(issueComments)
.set(markPromoted)
.where(and(
eq(issueComments.id, req.body.sourceArtifactId),
eq(issueComments.issueId, issue.id),
eq(issueComments.sourceTrust, sourceTrust),
))
.returning({ id: issueComments.id });
}
if (req.body.sourceArtifactKind === "document") {
return tx
.update(documents)
.set(markPromoted)
.where(and(
eq(documents.id, req.body.sourceArtifactId),
eq(documents.sourceTrust, sourceTrust),
))
.returning({ id: documents.id });
}
return tx
.update(issueWorkProducts)
.set(markPromoted)
.where(and(
eq(issueWorkProducts.id, req.body.sourceArtifactId),
eq(issueWorkProducts.issueId, issue.id),
eq(issueWorkProducts.sourceTrust, sourceTrust),
))
.returning({ id: issueWorkProducts.id });
})();
if (!updatedSource[0]) return null;
return tx
.insert(issueWorkProducts)
.values({
companyId: issue.companyId,
issueId: issue.id,
projectId: issue.projectId ?? null,
type: "artifact",
provider: "paperclip",
externalId: req.body.sourceArtifactId,
title: req.body.title,
status: "approved",
reviewState: "approved",
isPrimary: false,
healthStatus: "unknown",
summary: req.body.summary,
metadata: {
promotion: {
sourceArtifactKind: req.body.sourceArtifactKind,
sourceArtifactId: req.body.sourceArtifactId,
},
},
sourceTrust: promotionTrust,
createdByRunId: actor.runId ?? null,
})
.returning()
.then((rows) => rows[0] ?? null);
});
if (!product) {
res.status(422).json({ error: "Source artifact is not quarantined low-trust output" });
return;
}
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.low_trust_output_promoted",
entityType: "issue",
entityId: issue.id,
details: {
sourceArtifacts: [{
artifactKind: req.body.sourceArtifactKind,
artifactId: req.body.sourceArtifactId,
}],
reviewerPrincipal: {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
},
targetIssueId: issue.id,
promotedWorkProductId: product.id,
decision: "promoted",
},
});
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 getAccessibleResource(req, res, workProductsSvc.getById(id), "Work product not found");
if (!existing) return;
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 actor = getActorInfo(req);
const patch = { ...req.body };
const createdByRunId = await resolveWorkProductCreatedByRunId(req, res, existing.companyId, req.body, "update");
if (createdByRunId === undefined && Object.prototype.hasOwnProperty.call(req.body, "createdByRunId")) return;
if (createdByRunId !== undefined) patch.createdByRunId = createdByRunId;
if (requiresPaperclipAttachmentMetadata(patch, existing)) {
if (patch.metadata !== undefined) {
patch.metadata = await canonicalizePaperclipArtifactMetadata({
issue,
metadata: patch.metadata ?? null,
});
} else if (!requiresPaperclipAttachmentMetadata(existing)) {
res.status(422).json({ error: "Attachment-backed artifact metadata is required" });
return;
}
}
const sourceTrust = await sourceTrustForActorWrite(issue, actor);
const product = await workProductsSvc.update(id, {
...patch,
...(sourceTrust ? { sourceTrust } : {}),
});
if (!product) {
res.status(404).json({ error: "Work product not found" });
return;
}
await logActivity(db, {
companyId: existing.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
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 getAccessibleResource(req, res, workProductsSvc.getById(id), "Work product not found");
if (!existing) return;
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,
agentApiKeyId: actor.agentApiKeyId,
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 getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
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,
agentApiKeyId: actor.agentApiKeyId,
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 getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
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,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.read_unmarked",
entityType: "issue",
entityId: issue.id,
details: { userId: req.actor.userId },
});
res.json({ id: issue.id, removed });
});
async function resolveInboxArchiveTarget(
req: Request,
issue: { id: string; companyId: string },
) {
if (req.actor.type === "board") {
if (!req.actor.userId) throw forbidden("Board user context required", { code: "inbox_target_user_unresolved" });
return {
userId: req.actor.userId,
targetResolvedFrom: "responsible_user" as const,
policyMode: null,
};
}
if (req.actor.type !== "agent") throw unauthorized("Authentication required");
const explicitUserId = typeof req.body?.userId === "string" ? req.body.userId.trim() || null : null;
const responsibleUserId = req.actor.onBehalfOfUserId?.trim() || null;
const userId = explicitUserId ?? responsibleUserId;
if (!userId) {
throw forbidden("Inbox target user could not be resolved", { code: "inbox_target_user_unresolved" });
}
const decision = await access.decide({
actor: req.actor,
action: "inbox:manage",
resource: { type: "issue", companyId: issue.companyId, issueId: issue.id },
scope: { userId },
});
if (!decision.allowed) {
const code = decision.reason === "inbox_management_disabled"
? "inbox_management_disabled"
: decision.reason === "inbox_agent_not_allowed" || decision.reason === "deny_low_trust_boundary"
? "inbox_agent_not_allowed"
: decision.reason === "inbox_target_user_unresolved"
? "inbox_target_user_unresolved"
: userId !== responsibleUserId
? "inbox_cross_user_grant_required"
: "inbox_agent_not_allowed";
throw forbidden(decision.explanation, { code, reason: decision.reason });
}
return {
userId,
targetResolvedFrom: explicitUserId ? "explicit" as const : "responsible_user" as const,
policyMode: decision.inboxPolicyMode ?? "open",
};
}
router.post("/issues/:id/inbox-archive", validate(inboxArchiveBodySchema), async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
const target = await resolveInboxArchiveTarget(req, issue);
const actor = getActorInfo(req);
const archiveState = await svc.archiveInbox(issue.companyId, issue.id, target.userId, new Date(), {
archivedByActorType: req.actor.type === "agent" ? "agent" : "user",
archivedByAgentId: actor.agentId,
archivedByRunId: actor.runId,
});
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.inbox_archived",
entityType: "issue",
entityId: issue.id,
details: {
userId: target.userId,
archivedAt: archiveState.archivedAt,
targetResolvedFrom: target.targetResolvedFrom,
...(target.policyMode ? { policyMode: target.policyMode } : {}),
},
});
res.json(archiveState);
});
router.delete("/issues/:id/inbox-archive", validate(inboxArchiveBodySchema), async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
const target = await resolveInboxArchiveTarget(req, issue);
const removed = await svc.unarchiveInbox(issue.companyId, issue.id, target.userId);
const actor = getActorInfo(req);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.inbox_unarchived",
entityType: "issue",
entityId: issue.id,
details: {
userId: target.userId,
targetResolvedFrom: target.targetResolvedFrom,
...(target.policyMode ? { policyMode: target.policyMode } : {}),
},
});
res.json(removed ?? { ok: true, userId: target.userId });
});
router.get("/issues/:id/approvals", async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, getIssueById(req, id), "Issue not found");
if (!issue) return;
if (await assertLowTrustControlPlaneDenied(req, res, issue.companyId, issue)) return;
if (!(await assertIssueReadAllowed(req, res, issue))) return;
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 getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
if (!(await assertApprovalMutationAllowedByRunContext(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,
agentApiKeyId: actor.agentApiKeyId,
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 getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
if (!(await assertApprovalMutationAllowedByRunContext(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,
agentApiKeyId: actor.agentApiKeyId,
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);
if (isSkillTestScopedActor(req)) {
res.status(403).json({
error: "Skill-test run tokens cannot create issues.",
details: {
scopedIssueId: req.actor.keyScope?.kind === "skill_test" ? req.actor.keyScope.issueId : null,
securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"],
},
});
return;
}
if (await assertLowTrustControlPlaneDenied(req, res, companyId, null)) return;
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
const sanitizedBody = await sanitizeIssueCreateAttribution(db, req, res, companyId, req.body, {
surface: "issues.create",
});
if (!sanitizedBody) return;
const {
watchdogDiscovery: rawWatchdogDiscovery,
onboardingFirstTask: rawOnboardingFirstTask,
...rawCreateBody
} = sanitizedBody;
// The onboarding first-task marker grants privileged, server-owned behavior:
// it stamps the onboarding origin (which suppresses the seeded description in
// the UI) and seeds a comment authored *as the assigned agent*. Honor it only
// when the request is genuinely the onboarding wizard creating a company's
// very first task, verified server-side so a client marker alone cannot
// trigger it:
// 1. the caller is a human board/user session (the wizard never runs as an
// agent), and
// 2. the company has no existing issues yet — i.e. this really is the first
// task. An established company creating an ordinary issue can never reach
// the greeting/description-suppression path, so no board caller can
// fabricate a statement attributed to an assigned agent on a normal task.
// Fails closed: if it is not verifiably the first task, the flag is ignored
// and an ordinary issue is created. The zero-count read below is only a
// fast-path gate — overlapping requests could both observe zero — so the
// partial unique index issues_onboarding_first_task_uq is what atomically
// enforces at most one onboarding first task per company; the create call
// handles losing that race by degrading to an ordinary issue.
const onboardingFirstTaskRequested =
rawOnboardingFirstTask === true && req.actor.type === "board";
let isOnboardingFirstTask = onboardingFirstTaskRequested
? (await svc.count(companyId)) === 0
: false;
const watchdogDiscovery = normalizeWatchdogDiscovery(rawWatchdogDiscovery);
const watchdogProductBugFollowUp = await resolveTaskWatchdogProductBugFollowUp(
req,
res,
companyId,
watchdogDiscovery,
);
if (watchdogProductBugFollowUp === false) return;
const effectiveParentId = watchdogProductBugFollowUp ? null : rawCreateBody.parentId;
let createParent: Awaited<ReturnType<typeof svc.getById>> | null = null;
if (req.actor.type === "agent" && !effectiveParentId && !watchdogProductBugFollowUp && !isTaskBridgeKeyActor(req)) {
const companyScopeDecision = await access.decide({
actor: req.actor,
action: "company_scope:read",
resource: { type: "company", companyId },
});
if (!companyScopeDecision.allowed) {
res.status(403).json({ error: "Low-trust agents must create child issues inside their assigned boundary" });
return;
}
}
if (req.actor.type === "agent" && effectiveParentId) {
createParent = await svc.getById(effectiveParentId);
if (!createParent || createParent.companyId !== companyId) {
res.status(404).json({ error: "Parent issue not found" });
return;
}
if (!isTaskBridgeKeyActor(req) && !(await assertIssueWriteInfluenceAllowed(req, res, createParent))) return;
}
if (
!watchdogProductBugFollowUp &&
!(await assertTaskWatchdogCreateIssueAllowed(req, res, companyId, createParent))
) return;
const normalizedAssigneeAgentId = await normalizeIssueAssigneeAgentReference(
companyId,
rawCreateBody.assigneeAgentId as string | null | undefined,
{ actorType: req.actor.type },
);
await assertNoAgentDelegationCycle({
actorType: req.actor.type,
parentIssueId: typeof effectiveParentId === "string" ? effectiveParentId : null,
assigneeAgentId: normalizedAssigneeAgentId ?? null,
});
const actor = getActorInfo(req);
const runWorkspaceInheritanceSourceIssueId = hasExplicitIssueWorkspaceCreateSelection(rawCreateBody)
? null
: await resolveRunIssueWorkspaceInheritanceSource(companyId, actor);
const createBody = {
...rawCreateBody,
parentId: effectiveParentId,
...(normalizedAssigneeAgentId !== undefined ? { assigneeAgentId: normalizedAssigneeAgentId } : {}),
...(runWorkspaceInheritanceSourceIssueId
? { inheritExecutionWorkspaceFromIssueId: runWorkspaceInheritanceSourceIssueId }
: {}),
...(isOnboardingFirstTask && !watchdogProductBugFollowUp
? { originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND }
: {}),
...(watchdogProductBugFollowUp
? {
description: appendWatchdogDiscoveryContext({
description: rawCreateBody.description,
discovery: watchdogProductBugFollowUp.discovery,
sourceIssue: watchdogProductBugFollowUp.sourceIssue,
watchdogIssue: watchdogProductBugFollowUp.watchdogIssue,
stopFingerprint: watchdogProductBugFollowUp.scope.stopFingerprint,
runId: actor.runId,
}),
projectId: rawCreateBody.projectId ?? watchdogProductBugFollowUp.sourceIssue.projectId,
goalId: rawCreateBody.goalId ?? watchdogProductBugFollowUp.sourceIssue.goalId,
billingCode: rawCreateBody.billingCode ?? watchdogProductBugFollowUp.sourceIssue.billingCode,
originKind: TASK_WATCHDOG_PRODUCT_BUG_ORIGIN_KIND,
originId: watchdogProductBugFollowUp.sourceIssue.id,
originRunId: actor.runId,
originFingerprint: [
TASK_WATCHDOG_PRODUCT_BUG_ORIGIN_KIND,
watchdogProductBugFollowUp.sourceIssue.id,
actor.runId ?? randomUUID(),
].join(":"),
}
: {}),
};
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, { companyId }, createBody))) return;
const createAssignmentScope = {
projectId: await resolveAssignmentProjectId({
companyId,
projectId: createBody.projectId,
parentIssueId: createBody.parentId,
}),
parentIssueId: createBody.parentId ?? null,
assigneeAgentId: createBody.assigneeAgentId ?? null,
assigneeUserId: rawCreateBody.assigneeUserId ?? null,
};
await assertTaskBridgeCreateAllowed(req, companyId, createAssignmentScope);
if (rawCreateBody.assigneeAgentId || rawCreateBody.assigneeUserId) {
await assertCanAssignTasks(req, companyId, createAssignmentScope);
}
await assertIssueEnvironmentSelection(companyId, createBody.executionWorkspaceSettings?.environmentId);
const executionPolicy = applyActorMonitorScheduledBy(
normalizeIssueExecutionPolicy(createBody.executionPolicy),
actor.actorType,
);
await assertCanManageIssueMonitor(access, req, companyId, createBody.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor));
const issueId = randomUUID();
const sourceTrust = await sourceTrustForActorWrite({
id: issueId,
companyId,
projectId: createBody.projectId ?? null,
executionPolicy,
}, actor);
let deduplicationReason: "idempotency_key" | "recent_open_title" | null = null;
const createInput = {
...createBody,
...(taskBridgeOriginForActor(req) ?? {}),
id: issueId,
originRunId: createBody.originRunId ?? actor.runId,
executionPolicy,
...(sourceTrust ? { sourceTrust } : {}),
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
actorRunId: actor.runId,
actorResponsibleUserId: authenticatedActorResponsibleUserId(req),
trustExplicitResponsibleUserId: actor.actorType === "user",
watchdogActorRunId: actor.runId,
onDeduplicated: (reason: "idempotency_key" | "recent_open_title") => {
deduplicationReason = reason;
},
};
let issue: Awaited<ReturnType<typeof svc.create>>;
try {
issue = await svc.create(companyId, createInput);
} catch (error) {
// Concurrent onboarding creates can both pass the zero-count fast path;
// the issues_onboarding_first_task_uq index rejects the loser here. Fail
// closed: drop the privileged origin (and with it the agent-attributed
// greeting) and create an ordinary issue instead.
if (!(isOnboardingFirstTask && isOnboardingFirstTaskConflict(error))) throw error;
isOnboardingFirstTask = false;
const { originKind: _onboardingOriginKind, ...ordinaryCreateInput } = createInput;
issue = await svc.create(companyId, ordinaryCreateInput);
}
if (deduplicationReason) {
const referenceSummary = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
res.status(200).json({
...issue,
deduplicated: true,
deduplicationReason,
relatedWork: referenceSummary,
referencedIssueIdentifiers: referenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id),
});
return;
}
await issueReferencesSvc.syncIssue(issue.id);
await externalObjectsSvc.syncIssueSafely(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,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.created",
entityType: "issue",
entityId: issue.id,
details: {
title: issue.title,
identifier: issue.identifier,
...(watchdogProductBugFollowUp
? {
watchdogDiscovery: {
kind: watchdogProductBugFollowUp.discovery.kind,
sourceIssueId: watchdogProductBugFollowUp.sourceIssue.id,
sourceIssueIdentifier: watchdogProductBugFollowUp.sourceIssue.identifier,
watchdogIssueId: watchdogProductBugFollowUp.watchdogIssue?.id ?? null,
watchdogIssueIdentifier: watchdogProductBugFollowUp.watchdogIssue?.identifier ?? null,
stopFingerprint: watchdogProductBugFollowUp.scope.stopFingerprint,
},
}
: {}),
...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,
agentApiKeyId: actor.agentApiKeyId,
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,
},
});
}
if (issue.watchdog) {
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.watchdog_created",
entityType: "issue",
entityId: issue.id,
details: {
identifier: issue.identifier,
watchdogId: issue.watchdog.id,
watchdogAgentId: issue.watchdog.watchdogAgentId,
source: "issue.create",
},
});
}
// Seed the onboarding first-task greeting as an agent-authored comment so the
// user lands on a waiting greeting (instead of a right-aligned "user" bubble
// showing the seeded description). Deterministic template — no LLM call — and
// best-effort: a greeting failure must not fail issue creation.
if (isOnboardingFirstTask && issue.assigneeAgentId) {
try {
const [company, goal, assigneeAgent] = await Promise.all([
companiesSvc.getById(companyId),
createBody.goalId ? goalsSvc.getById(createBody.goalId) : Promise.resolve(null),
agentsSvc.getById(issue.assigneeAgentId),
]);
const greetingBody = buildOnboardingGreeting({
agentName: assigneeAgent?.name ?? null,
teamName: company?.name ?? null,
goals: goal?.description ?? goal?.title ?? null,
});
await svc.addComment(
issue.id,
greetingBody,
{ agentId: issue.assigneeAgentId },
{
authorType: "agent",
authorizationReason: ONBOARDING_GREETING_AUTHORIZATION_REASON,
},
);
} catch (err) {
logger.warn(
{ err, issueId: issue.id, companyId },
"failed to seed onboarding first-task greeting",
);
}
}
void queueIssueAssignmentWakeup({
heartbeat,
issue,
reason: "issue_assigned",
mutation: "create",
contextSource: "issue.create",
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
});
await queueTaskWatchdogEvaluation(issue, actor.runId);
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 getAccessibleResource(req, res, svc.getById(parentId), "Parent issue not found");
if (!parent) return;
if (!isTaskBridgeKeyActor(req) && !(await assertIssueWriteInfluenceAllowed(req, res, parent))) return;
if (!(await assertTaskWatchdogCreateIssueAllowed(req, res, parent.companyId, parent))) return;
if (await assertLowTrustControlPlaneDenied(req, res, parent.companyId, parent)) return;
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
const sanitizedBody = await sanitizeIssueCreateAttribution(db, req, res, parent.companyId, req.body, {
surface: "issues.children.create",
entityId: parent.id,
});
if (!sanitizedBody) return;
const normalizedAssigneeAgentId = await normalizeIssueAssigneeAgentReference(
parent.companyId,
sanitizedBody.assigneeAgentId as string | null | undefined,
{ actorType: req.actor.type },
);
await assertNoAgentDelegationCycle({
actorType: req.actor.type,
parentIssueId: parent.id,
assigneeAgentId: normalizedAssigneeAgentId ?? null,
});
const createBody = {
...sanitizedBody,
...(normalizedAssigneeAgentId !== undefined ? { assigneeAgentId: normalizedAssigneeAgentId } : {}),
};
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, parent, createBody))) return;
const childAssignmentScope = {
projectId: createBody.projectId ?? parent.projectId ?? null,
parentIssueId: parent.id,
assigneeAgentId: createBody.assigneeAgentId ?? null,
assigneeUserId: createBody.assigneeUserId ?? null,
};
await assertTaskBridgeCreateAllowed(req, parent.companyId, childAssignmentScope);
if (sanitizedBody.assigneeAgentId || sanitizedBody.assigneeUserId) {
await assertCanAssignTasks(req, parent.companyId, childAssignmentScope);
}
await assertIssueEnvironmentSelection(parent.companyId, createBody.executionWorkspaceSettings?.environmentId);
const actor = getActorInfo(req);
const serializationContext = await resolveWatchdogFollowUpSerializationContext(req, parent);
const currentSerializedChild = serializationContext
? await findCurrentSerializedWatchdogChild(parent)
: null;
const executionPolicy = applyActorMonitorScheduledBy(
normalizeIssueExecutionPolicy(createBody.executionPolicy),
actor.actorType,
);
await assertCanManageIssueMonitor(access, req, parent.companyId, createBody.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor));
const issueId = randomUUID();
const sourceTrust = await sourceTrustForActorWrite({
id: issueId,
companyId: parent.companyId,
projectId: createBody.projectId ?? parent.projectId ?? null,
executionPolicy,
}, actor);
const { issue, parentBlockerAdded } = await svc.createChild(parent.id, {
...createBody,
...(taskBridgeOriginForActor(req) ?? {}),
id: issueId,
executionPolicy,
...(currentSerializedChild
? {
status: "blocked",
blockedByIssueIds: mergeIssueBlockerIds(createBody.blockedByIssueIds, currentSerializedChild.id),
}
: {}),
...(sourceTrust ? { sourceTrust } : {}),
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
actorRunId: actor.runId,
actorResponsibleUserId: authenticatedActorResponsibleUserId(req),
trustExplicitResponsibleUserId: actor.actorType === "user",
actorAgentId: actor.agentId,
actorUserId: actor.actorType === "user" ? actor.actorId : null,
watchdogActorRunId: actor.runId,
});
await externalObjectsSvc.syncIssueSafely(issue.id);
await logActivity(db, {
companyId: parent.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
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 } : {}),
...(serializationContext
? {
watchdogFollowUpsSerialized: true,
serializedBehindIssueId: currentSerializedChild?.id ?? null,
}
: {}),
},
});
if (executionPolicy?.monitor) {
await logActivity(db, {
companyId: parent.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
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,
},
});
}
if (issue.watchdog) {
await logActivity(db, {
companyId: parent.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.watchdog_created",
entityType: "issue",
entityId: issue.id,
details: {
identifier: issue.identifier,
watchdogId: issue.watchdog.id,
watchdogAgentId: issue.watchdog.watchdogAgentId,
source: "issue.child_create",
parentId: parent.id,
},
});
}
if (!serializationContext || !currentSerializedChild) {
void queueIssueAssignmentWakeup({
heartbeat,
issue,
reason: "issue_assigned",
mutation: "create",
contextSource: "issue.child_create",
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
});
}
await blockWatchdogParentOnCurrentChild({
actor,
watchdogParentIssueId: serializationContext?.watchdogParentIssueId,
currentChildIssueId: currentSerializedChild?.id ?? issue.id,
});
await queueTaskWatchdogEvaluation(issue, actor.runId);
res.status(201).json(issue);
});
router.get("/issues/:id/accepted-plan-decompositions", async (req, res) => {
const sourceIssueId = req.params.id as string;
const sourceIssue = await getAccessibleResource(req, res, getIssueById(req, sourceIssueId), "Issue not found");
if (!sourceIssue) return;
const decompositions = await svc.listAcceptedPlanDecompositions(sourceIssue.id);
res.json(decompositions);
});
router.post("/issues/:id/accepted-plan-decompositions", validate(createAcceptedPlanDecompositionSchema), async (req, res) => {
const sourceIssueId = req.params.id as string;
const sourceIssue = await getAccessibleResource(req, res, svc.getById(sourceIssueId), "Issue not found");
if (!sourceIssue) return;
if (!(await assertAgentIssueMutationAllowed(req, res, sourceIssue))) return;
const requestedChildren = [];
for (const child of req.body.children as Array<typeof req.body.children[number]>) {
const sanitizedChild = await sanitizeIssueCreateAttribution(db, req, res, sourceIssue.companyId, child, {
surface: "issues.accepted_plan_decomposition",
entityId: sourceIssue.id,
});
if (!sanitizedChild) return;
const normalizedAssigneeAgentId = await normalizeIssueAssigneeAgentReference(
sourceIssue.companyId,
sanitizedChild.assigneeAgentId as string | null | undefined,
{ actorType: req.actor.type },
);
const childBody = {
...sanitizedChild,
...(normalizedAssigneeAgentId !== undefined ? { assigneeAgentId: normalizedAssigneeAgentId } : {}),
};
requestedChildren.push(childBody);
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(childBody));
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, sourceIssue, childBody))) return;
if (childBody.assigneeAgentId || childBody.assigneeUserId) {
await assertCanAssignTasks(req, sourceIssue.companyId, {
projectId: childBody.projectId ?? sourceIssue.projectId ?? null,
parentIssueId: sourceIssue.id,
assigneeAgentId: childBody.assigneeAgentId ?? null,
assigneeUserId: childBody.assigneeUserId ?? null,
});
}
await assertIssueEnvironmentSelection(sourceIssue.companyId, childBody.executionWorkspaceSettings?.environmentId);
}
const actor = getActorInfo(req);
const normalizedChildren = [];
for (const child of requestedChildren) {
const executionPolicy = applyActorMonitorScheduledBy(
normalizeIssueExecutionPolicy(child.executionPolicy),
actor.actorType,
);
await assertCanManageIssueMonitor(access, req, sourceIssue.companyId, child.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor));
const childIssueId = randomUUID();
const sourceTrust = await sourceTrustForActorWrite({
id: childIssueId,
companyId: sourceIssue.companyId,
projectId: child.projectId ?? sourceIssue.projectId ?? null,
executionPolicy,
}, actor);
normalizedChildren.push({
...child,
id: childIssueId,
executionPolicy,
...(sourceTrust ? { sourceTrust } : {}),
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
actorRunId: actor.runId,
actorResponsibleUserId: authenticatedActorResponsibleUserId(req),
trustExplicitResponsibleUserId: actor.actorType === "user",
actorAgentId: actor.agentId,
actorUserId: actor.actorType === "user" ? actor.actorId : null,
});
}
const serializationContext = await resolveWatchdogFollowUpSerializationContext(req, sourceIssue);
const existingSerializedChild = serializationContext
? await findCurrentSerializedWatchdogChild(sourceIssue)
: null;
const serializedBlockedChildIds = new Set<string>();
if (serializationContext) {
for (let index = 0; index < normalizedChildren.length; index += 1) {
const blockerIssueId: string | null = index === 0
? existingSerializedChild?.id ?? null
: normalizedChildren[index - 1]?.id ?? null;
if (!blockerIssueId) continue;
normalizedChildren[index] = {
...normalizedChildren[index],
status: "blocked",
blockedByIssueIds: mergeIssueBlockerIds(normalizedChildren[index].blockedByIssueIds, blockerIssueId),
};
serializedBlockedChildIds.add(normalizedChildren[index].id);
}
}
const result = await svc.decomposeAcceptedPlan(sourceIssue.id, {
acceptedPlanRevisionId: req.body.acceptedPlanRevisionId,
children: normalizedChildren,
actorAgentId: actor.agentId,
actorUserId: actor.actorType === "user" ? actor.actorId : null,
actorRunId: actor.runId ?? null,
});
await logActivity(db, {
companyId: sourceIssue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.accepted_plan_decomposition_updated",
entityType: "issue",
entityId: sourceIssue.id,
details: {
identifier: sourceIssue.identifier,
acceptedPlanRevisionId: req.body.acceptedPlanRevisionId,
decompositionId: result.decomposition.id,
status: result.decomposition.status,
requestedChildCount: req.body.children.length,
childIssueIds: result.childIssueIds,
newlyCreatedChildIssueIds: result.newlyCreatedIssues.map((issue) => issue.id),
...(serializationContext
? {
watchdogFollowUpsSerialized: true,
currentSerializedChildIssueId: existingSerializedChild?.id ?? result.newlyCreatedIssues[0]?.id ?? null,
serializedBlockedChildIssueIds: [...serializedBlockedChildIds],
}
: {}),
},
});
for (const issue of result.newlyCreatedIssues) {
await logActivity(db, {
companyId: sourceIssue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.child_created",
entityType: "issue",
entityId: issue.id,
details: {
parentId: sourceIssue.id,
identifier: issue.identifier,
title: issue.title,
inheritedExecutionWorkspaceFromIssueId: sourceIssue.id,
acceptedPlanRevisionId: req.body.acceptedPlanRevisionId,
...buildCreateIssueActivityStatusDetails(issue, res),
...(serializationContext
? {
watchdogFollowUpsSerialized: true,
serializedBlocked: serializedBlockedChildIds.has(issue.id),
}
: {}),
},
});
const executionPolicy = normalizeIssueExecutionPolicy(issue.executionPolicy);
if (executionPolicy?.monitor) {
await logActivity(db, {
companyId: sourceIssue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.monitor_scheduled",
entityType: "issue",
entityId: issue.id,
details: {
identifier: issue.identifier,
parentId: sourceIssue.id,
acceptedPlanRevisionId: req.body.acceptedPlanRevisionId,
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,
},
});
}
if (!serializedBlockedChildIds.has(issue.id)) {
void queueIssueAssignmentWakeup({
heartbeat,
issue,
reason: "issue_assigned",
mutation: "accepted_plan_decomposition",
contextSource: "issue.accepted_plan_decomposition",
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
});
}
await queueTaskWatchdogEvaluation(issue, actor.runId);
}
await blockWatchdogParentOnCurrentChild({
actor,
watchdogParentIssueId: serializationContext?.watchdogParentIssueId,
currentChildIssueId: existingSerializedChild?.id ?? result.newlyCreatedIssues[0]?.id,
});
res.json({
decomposition: result.decomposition,
childIssueIds: result.childIssueIds,
newlyCreatedChildIssueIds: result.newlyCreatedIssues.map((issue) => issue.id),
});
});
router.post("/issues/:id/monitor/check-now", async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
await assertCanManageIssueMonitor(access, req, issue.companyId, 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 getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
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.post(
"/issues/:id/stalled-review-decision",
validate(stalledReviewDecisionSchema),
async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
assertBoard(req);
if (req.actor.source !== "local_implicit") {
const userId = req.actor.userId?.trim();
const membership = userId
? await db
.select({ membershipRole: companyMemberships.membershipRole })
.from(companyMemberships)
.where(and(
eq(companyMemberships.companyId, issue.companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, userId),
eq(companyMemberships.status, "active"),
))
.then((rows) => rows[0] ?? null)
: null;
if (!membership?.membershipRole || membership.membershipRole === "viewer") {
throw forbidden("Active non-viewer company membership required");
}
}
const actor = getActorInfo(req);
const result = await stalledReviewDecisionService(db).decide({
issueId: issue.id,
companyId: issue.companyId,
action: req.body.action,
note: req.body.note,
actor: {
userId: actor.actorId,
runId: actor.runId,
},
});
// The decision transaction has already committed the status change. The
// remaining side effects (comment sync, resume wake) are best-effort: a
// transient failure must not fail the request, because the decision
// cannot be retried once the issue has left `in_review`, which would
// permanently strand the resume signal. This mirrors the issue-update
// wake dispatch, and the issue lands durably in `todo`/`done` regardless.
//
// Reviewed and accepted as best-effort rather than transactional (PAP-16101):
// `enqueueWakeup` writes a durable `agent_wakeup_requests` row, so the wake
// is scheduler-driven the moment that insert lands, and the catch below only
// covers a transient insert failure. In that narrow window the issue is in
// `todo` *still assigned* — an active status the normal liveness sweep picks
// up — so the worst case is a delayed resume, not the invisible
// `in_review`-with-zero-paths zombie this contract exists to kill. Making
// only this path transactional would also diverge from every other route's
// post-commit dispatch. `wakeQueued` is returned so callers can see the
// difference.
if (result.comment) {
try {
await issueReferencesSvc.syncComment(result.comment.id);
await externalObjectsSvc.syncCommentSafely(result.comment.id);
} catch (err) {
logger.warn(
{ err, issueId: result.issue.id, commentId: result.comment.id },
"failed to sync stalled-review decision comment",
);
}
}
let wakeQueued = false;
if (req.body.action !== "approve" && result.issue.assigneeAgentId) {
const userAuthoredNote = result.comment
? { commentId: result.comment.id, authorUserId: actor.actorId }
: undefined;
try {
const wake = await enqueueStalledReviewDecisionWakeup(result.issue.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_status_changed",
idempotencyKey: `stalled-review-decision:${result.issue.id}:${req.body.action}`,
requestedByActorType: "user",
requestedByActorId: actor.actorId,
payload: {
issueId: result.issue.id,
mutation: "stalled_review_decision",
reviewDecision: req.body.action,
resumeIntent: true,
...(userAuthoredNote ? { userAuthoredNote } : {}),
},
contextSnapshot: {
issueId: result.issue.id,
taskId: result.issue.id,
source: "issue.stalled_review_decision",
wakeReason: "issue_status_changed",
reviewDecision: req.body.action,
resumeIntent: true,
...(userAuthoredNote ? { userAuthoredNote } : {}),
},
});
wakeQueued = wake !== null;
} catch (err) {
logger.warn(
{ err, issueId: result.issue.id, agentId: result.issue.assigneeAgentId },
"failed to enqueue stalled-review decision resume wake",
);
}
}
res.json({
issue: result.issue,
action: req.body.action,
comment: result.comment,
wakeQueued,
});
},
);
router.patch("/issues/:id", validate(updateIssueRouteSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!existing) return;
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
if (req.actor.type === "agent" && req.body.onBehalfOfUserId != null) {
await auditAgentIssueCommentAttributionSpoof({
db,
req,
issue: existing,
surface: "issue.patch.comment",
requestedValue: readNonEmptyString(req.body.onBehalfOfUserId),
});
await denyIssueWrite(req, res, existing, "issue_write_attribution_spoof_rejected");
return;
}
const issueMutationAccess = await assertAgentIssueMutationAllowed(
req,
res,
existing,
{ allowVisibleIssueWrite: true },
);
if (!issueMutationAccess) return;
const issueMutationAuthorizationReason = req.actor.type === "agent"
? issueWriteAuthorizationReason(req, await decideIssueAccess(req, existing, "issue:mutate"))
: issueWriteAuthorizationReason(req, true);
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,
{ actorType: req.actor.type },
);
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,
reviewInteractionId: requestedReviewInteractionId,
reviewRequest,
reopen: reopenRequested,
resume: resumeRequested,
interrupt: interruptRequested,
hiddenAt: hiddenAtRaw,
onBehalfOfUserId: _requestedOnBehalfOfUserId,
...updateFields
} = req.body;
const reviewPolicyChangeRequested =
req.body.reviewPolicy !== undefined
&& req.body.reviewPolicy !== existing.reviewPolicy;
const reviewVerdictRequested =
existing.status === "in_review"
&& (updateFields.status === "done" || updateFields.status === "cancelled");
const reviewPolicySensitiveMutationRequested =
req.body.reviewPolicy !== undefined
|| updateFields.status === "done"
|| updateFields.status === "cancelled";
if (
(reviewVerdictRequested || reviewPolicyChangeRequested)
&& existing.reviewPolicy != null
&& existing.reviewPolicy !== "anyone"
) {
await assertIssueReviewVerdictActorAllowed(db, {
issue: existing,
actor: { type: actor.actorType, id: actor.actorId },
reviewPolicy: existing.reviewPolicy,
});
}
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 (
(reopenRequested === true ||
resumeRequested === true ||
Array.isArray(req.body.blockedByIssueIds)) &&
await assertLowTrustControlPlaneDenied(req, res, existing.companyId, existing)
) {
return;
}
if (
resumeRequested === true &&
!(await assertExplicitResumeIntentAllowed(req, res, existing, { resumeIntent: true }))
) return;
const agentStatusTransitionRequiresResumeAuthority =
req.actor.type === "agent" &&
typeof updateFields.status === "string" &&
updateFields.status !== existing.status &&
(isBlocked || (isClosed && !isClosedIssueStatus(updateFields.status)));
if (resumeRequested !== true && req.actor.type === "agent" && reopenRequested === true) {
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 requireRecoveryActionAuthority(
req,
existing,
activeRecoveryActionBeforeUpdate,
{ source: "issue_update" },
);
const recoveryRestrictedSourceMutationRequested =
activeRecoveryActionBeforeUpdate != null &&
(
updateFields.status === "done" ||
updateFields.status === "cancelled" ||
normalizedAssigneeAgentId !== undefined ||
req.body.assigneeUserId !== undefined ||
(
activeExecutionParticipantAgentId(existing) != null &&
typeof updateFields.status === "string" &&
updateFields.status !== existing.status
)
);
if (recoveryRestrictedSourceMutationRequested) {
await requireRecoverySourceMutationAuthority(req, existing);
}
}
if (
resumeRequested !== true &&
agentStatusTransitionRequiresResumeAuthority &&
!(await assertExplicitResumeIntentAllowed(req, res, existing))
) {
return;
}
const scheduledRetryForHumanComment =
shouldHumanCommentResumeInProgressScheduledRetry({
hasComment: !!commentBody,
issueStatus: existing.status,
assigneeAgentId: requestedAssigneeAgentId,
actorType: actor.actorType,
})
? await svc.getCurrentScheduledRetry(existing.id)
: null;
const shouldResumeInProgressScheduledRetry =
!!scheduledRetryForHumanComment &&
scheduledRetryForHumanComment.agentId === requestedAssigneeAgentId;
const assigneeSelfCommentOnTerminal = isAssigneeSelfCommentOnTerminalIssue({
hasCommentBody: !!commentBody,
resumeRequested: resumeRequested === true,
issueStatus: existing.status,
assigneeAgentId: existing.assigneeAgentId,
actorType: actor.actorType,
actorId: actor.actorId,
});
const effectiveMoveToTodoRequested =
!assigneeSelfCommentOnTerminal &&
(explicitMoveToTodoRequested ||
(!!commentBody &&
shouldImplicitlyMoveCommentedIssueToTodo({
issueStatus: existing.status,
assigneeAgentId: requestedAssigneeAgentId,
actorType: actor.actorType,
actorId: actor.actorId,
actorRunId: actor.runId,
checkoutRunId: existing.checkoutRunId,
executionRunId: existing.executionRunId,
requestAddsExplicitBlockers:
Array.isArray(req.body.blockedByIssueIds) && req.body.blockedByIssueIds.length > 0,
})) ||
shouldResumeInProgressScheduledRetry);
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 || hiddenAtRaw !== undefined);
if (
isAgentWorkUpdate &&
!(await assertCrossIssueInfluenceWithinRunCap(req, res, existing, "update"))
) return;
if (
commentBody &&
!(await assertCrossIssueInfluenceWithinRunCap(req, res, existing, "comment"))
) 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,
"Interrupted by board comment",
operatorInterruptCancelOptions({ issueId: existing.id, actor }),
);
if (cancelled) {
interruptedRunId = cancelled.id;
await logActivity(db, {
companyId: cancelled.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "heartbeat.cancelled",
entityType: "heartbeat_run",
entityId: cancelled.id,
issueId: existing.id,
details: {
agentId: cancelled.agentId,
source: "issue_comment_interrupt",
issueId: existing.id,
cancellationKind: "operator_interrupted",
operatorInterrupted: true,
},
});
}
}
}
const runToCancelForCancelledStatus = shouldCancelActiveRunForCancelledStatus
? await resolveActiveIssueRun(existing)
: null;
if (hiddenAtRaw !== undefined) {
updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null;
}
if (
commentBody &&
effectiveMoveToTodoRequested &&
(isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers) || shouldResumeInProgressScheduledRetry) &&
updateFields.status === undefined
) {
updateFields.status = "todo";
}
let cancelledScheduledRetryRunId: string | null = null;
if (
commentBody &&
shouldResumeInProgressScheduledRetry &&
updateFields.status === "todo"
) {
cancelledScheduledRetryRunId = await cancelScheduledRetrySupersededByComment({
scheduledRetryRunId: scheduledRetryForHumanComment?.runId,
issue: existing,
actor,
});
}
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;
await assertCanManageIssueMonitor(
access,
req,
existing.companyId,
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,
},
allowBoardOverride: req.actor.type === "board",
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);
const nextStatus = updateFields.status ?? existing.status;
if (updateFields.unblockDescriptor && nextStatus !== "blocked") {
throw unprocessable("unblockDescriptor requires blocked status");
}
const descriptor = updateFields.unblockDescriptor ?? null;
if (descriptor && typeof descriptor === "object") {
const owner = descriptor.owner;
if (req.actor.type === "agent" && (owner === "board" || "userId" in owner)) {
throw forbidden("Agents may only name themselves as an unblock owner");
}
if (owner !== "board" && "agentId" in owner) {
const target = await db.select({ id: agents.id }).from(agents).where(and(
eq(agents.id, owner.agentId),
eq(agents.companyId, existing.companyId),
)).limit(1).then((rows) => rows[0] ?? null);
if (!target) throw unprocessable("Unblock owner agent must belong to the issue company");
if (req.actor.type === "agent" && req.actor.agentId !== owner.agentId) {
throw forbidden("Agents may only name themselves as an unblock owner");
}
} else if (owner !== "board" && "userId" in owner) {
const member = await db.select({ id: companyMemberships.id }).from(companyMemberships).where(and(
eq(companyMemberships.companyId, existing.companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, owner.userId),
eq(companyMemberships.status, "active"),
)).limit(1).then((rows) => rows[0] ?? null);
if (!member) throw unprocessable("Unblock owner user must be an active company member");
}
}
const enteringBlocked = existing.status !== "blocked" && updateFields.status === "blocked";
if (enteringBlocked) {
const requestedBlockerIds = Array.isArray(req.body.blockedByIssueIds)
? [...new Set(req.body.blockedByIssueIds as string[])]
: null;
const hasUnresolvedBlocker = requestedBlockerIds
? requestedBlockerIds.length > 0 && await db.select({ id: issueRows.id }).from(issueRows).where(and(
eq(issueRows.companyId, existing.companyId),
inArray(issueRows.id, requestedBlockerIds),
notInArray(issueRows.status, ["done", "cancelled"]),
)).limit(1).then((rows) => rows.length > 0)
: (await svc.getDependencyReadiness(existing.id)).unresolvedBlockerCount > 0;
const [pendingInteraction, pendingApproval] = await Promise.all([
db.select({ id: issueThreadInteractions.id }).from(issueThreadInteractions).where(and(
eq(issueThreadInteractions.companyId, existing.companyId),
eq(issueThreadInteractions.issueId, existing.id),
eq(issueThreadInteractions.status, "pending"),
)).limit(1).then((rows) => rows[0] ?? null),
db.select({ id: approvals.id }).from(issueApprovals).innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id)).where(and(
eq(issueApprovals.companyId, existing.companyId),
eq(issueApprovals.issueId, existing.id),
eq(approvals.status, "pending"),
)).limit(1).then((rows) => rows[0] ?? null),
]);
if (!hasUnresolvedBlocker && !pendingInteraction && !pendingApproval && !descriptor) {
res.status(422).json({ error: "Entering blocked requires unresolved blockers, a pending interaction/approval, or unblockDescriptor" });
return;
}
}
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,
};
}
}
const reviewInteractionId = await assertInReviewReviewPath({
existing,
updateFields,
actorType: actor.actorType,
actorId: actor.actorId,
actorAgentId: actor.agentId,
actorRunId: actor.runId,
reviewInteractionId: requestedReviewInteractionId,
});
const enteringReviewRequested =
existing.status !== "in_review" && updateFields.status === "in_review";
const persistReviewActivityTransactionally =
enteringReviewRequested || Boolean(reviewInteractionId);
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, {
issueId: existing.id,
projectId: await resolveAssignmentProjectId({
companyId: existing.companyId,
projectId: updateFields.projectId === undefined
? existing.projectId
: updateFields.projectId as string | null | undefined,
parentIssueId: (updateFields.parentId === undefined
? existing.parentId
: updateFields.parentId) as string | null | undefined,
}),
parentIssueId: (updateFields.parentId === undefined
? existing.parentId
: updateFields.parentId) as string | null | undefined,
assigneeAgentId: nextAssigneeAgentId,
assigneeUserId: nextAssigneeUserId,
});
}
}
const nextParentId = updateFields.parentId === undefined
? existing.parentId
: updateFields.parentId as string | null;
const shouldRelayStop =
Boolean(nextParentId) &&
existing.status !== updateFields.status &&
(updateFields.status === "blocked" || updateFields.status === "cancelled") &&
await directParentReportDisabledForIssue({
companyId: existing.companyId,
projectId: updateFields.projectId === undefined
? existing.projectId
: updateFields.projectId as string | null,
executionPolicy: updateFields.executionPolicy === undefined
? existing.executionPolicy
: updateFields.executionPolicy,
assigneeAgentId: nextAssigneeAgentId,
checkoutRunId: existing.checkoutRunId,
executionRunId: existing.executionRunId,
});
const stopRelayResult: {
value: Awaited<ReturnType<typeof svc.addStopRelayCommentIfNeeded>>;
} = { value: null };
const postCommitActivityPublications: ActivityPublication[] = [];
const issueUpdateData = {
...updateFields,
actorAgentId: actor.agentId ?? null,
actorUserId: actor.actorType === "user" ? actor.actorId : null,
};
const shouldCollectCompletionPublication =
actor.actorType === "user" && existing.status !== "done" && updateFields.status === "done";
const updateIssue = (tx?: Parameters<typeof svc.update>[2]) => {
if (tx) {
return shouldCollectCompletionPublication
? svc.update(id, issueUpdateData, tx, postCommitActivityPublications)
: svc.update(id, issueUpdateData, tx);
}
return shouldCollectCompletionPublication
? svc.update(id, issueUpdateData, db, postCommitActivityPublications)
: svc.update(id, issueUpdateData);
};
const assertLockedReviewPolicyAllowsMutation = async (
tx: Parameters<typeof svc.update>[2],
) => {
const lockedExisting = await svc.getByIdForUpdate(id, tx);
if (!lockedExisting) return false;
const lockedPolicyChangeRequested =
req.body.reviewPolicy !== undefined
&& req.body.reviewPolicy !== lockedExisting.reviewPolicy;
const lockedReviewVerdictRequested =
lockedExisting.status === "in_review"
&& (updateFields.status === "done" || updateFields.status === "cancelled");
if (
(lockedReviewVerdictRequested || lockedPolicyChangeRequested)
&& lockedExisting.reviewPolicy != null
&& lockedExisting.reviewPolicy !== "anyone"
) {
await assertIssueReviewVerdictActorAllowed(tx as unknown as Db, {
issue: lockedExisting,
actor: { type: actor.actorType, id: actor.actorId },
reviewPolicy: lockedExisting.reviewPolicy,
});
}
return true;
};
const persistReviewTransitionActivity = async (
tx: Parameters<typeof svc.update>[2],
updated: NonNullable<Awaited<ReturnType<typeof svc.update>>>,
) => {
if (!persistReviewActivityTransactionally) return;
const changes = updated.changes ?? {};
const previous = Object.fromEntries(
Object.entries(changes).map(([key, change]) => [key, change.from]),
);
await logActivity(tx as unknown as Db, {
companyId: updated.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
responsibleUserIdOverride: authenticatedActorResponsibleUserId(req),
action: "issue.updated",
entityType: "issue",
entityId: updated.id,
details: {
...updateFields,
identifier: updated.identifier,
authorizationReason: issueMutationAuthorizationReason,
changes,
...(reviewInteractionId ? { reviewInteractionId } : {}),
...(commentBody ? { source: "comment" } : {}),
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
_previous: Object.keys(changes).length > 0 ? previous : undefined,
},
}, postCommitActivityPublications);
};
// Reopen the closed isolated workspace only after every access, validation,
// and policy gate passes, and just before the update persists. A rejected
// update must not rebuild and republish the workspace as active, because the
// issue stays terminal and the reaper then skips the leaked workspace.
let reopenedWorkspace: Pick<ExecutionWorkspace, "id"> | null = null;
let reopenedGeneration: number | null = null;
if (closedExecutionWorkspace && (commentBody || isAgentWorkUpdate)) {
const reopenOutcome = await reopenClosedIssueExecutionWorkspaceOrRespond(
req,
res,
existing,
closedExecutionWorkspace,
);
if (reopenOutcome === null) {
return;
}
// Install the guard only when this request set the reopen-pending flag. A
// concurrent request that found the workspace already open must not clear
// the flag that the actual reopener still owns.
if (reopenOutcome.outcome === "reopened") {
reopenedWorkspace = closedExecutionWorkspace;
reopenedGeneration = reopenOutcome.generation;
}
}
let issue: Awaited<ReturnType<typeof svc.update>>;
// Clear the reopen-pending flag if this update leaves the issue terminal, so
// the rebuilt worktree does not leak. The guard reads `issue` when the
// response ends, so it also covers a null return and a thrown error. It clears
// only the fence this request installed, keyed by its generation.
guardReopenedWorkspaceConsumption({
req,
res,
issue: existing,
workspace: reopenedWorkspace,
generation: reopenedGeneration,
finalIssueStatus: () => issue?.status,
});
const decision = transition.decision && decisionId ? transition.decision : null;
const shouldUseTransactionalIssueUpdate =
Boolean(decision)
|| shouldRelayStop
|| persistReviewActivityTransactionally
|| reviewPolicySensitiveMutationRequested;
try {
if (shouldUseTransactionalIssueUpdate) {
issue = await db.transaction(async (tx) => {
if (
reviewPolicySensitiveMutationRequested
&& !(await assertLockedReviewPolicyAllowsMutation(tx))
) return null;
const updated = await updateIssue(tx);
if (!updated) return null;
if (decision && decisionId) {
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,
});
}
if (shouldRelayStop) {
stopRelayResult.value = await svc.addStopRelayCommentIfNeeded(updated, tx);
}
await persistReviewTransitionActivity(tx, updated);
return updated;
});
} else {
issue = await updateIssue();
}
} 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;
}
for (const publication of postCommitActivityPublications) publishActivity(publication);
if (enteringBlocked) {
const blockedIssue = issue;
let ownerNotifiedAt: Date | null = null;
await deliverAgentUnblockNotification({
issue: blockedIssue,
wakeup: heartbeat.wakeup,
markNotified: async (blockedOwnerNotifiedAt) => {
ownerNotifiedAt = blockedOwnerNotifiedAt;
},
});
if (ownerNotifiedAt) {
await db.update(issueRows).set({ blockedOwnerNotifiedAt: ownerNotifiedAt }).where(and(
eq(issueRows.id, blockedIssue.id),
eq(issueRows.companyId, blockedIssue.companyId),
));
issue = { ...blockedIssue, blockedOwnerNotifiedAt: ownerNotifiedAt };
}
}
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,
agentApiKeyId: actor.agentApiKeyId,
action: "heartbeat.cancelled",
entityType: "heartbeat_run",
entityId: cancelled.id,
issueId: existing.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,
agentApiKeyId: actor.agentApiKeyId,
action: "heartbeat.cancel_failed",
entityType: "heartbeat_run",
entityId: runToCancelForCancelledStatus.id,
issueId: existing.id,
details: { source: "issue_status_cancelled", issueId: existing.id },
});
}
}
if (titleOrDescriptionChanged) {
await issueReferencesSvc.syncIssue(issue.id);
await externalObjectsSvc.syncIssueSafely(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<ReturnType<typeof issueReferencesSvc.listIssueReferenceSummary>>;
referencedIssueIdentifiers?: string[];
} = issue;
let updatedRelations: Awaited<ReturnType<typeof svc.getRelationSummaries>> | null = null;
if (issue && Array.isArray(req.body.blockedByIssueIds)) {
updatedRelations = await svc.getRelationSummaries(issue.id);
issueResponse = {
...issue,
blockedByIssueIds:
issue.blockedByIssueIds ?? [...new Set(req.body.blockedByIssueIds as string[])].sort(),
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"));
}
// Use the service's row-lock-backed receipt as the activity source of truth.
// Requested fields alone miss server-side effects such as cleared run locks,
// status timestamps, goal fallback, and normalized relation arrays.
const issueChanges = issue.changes ?? {};
const previous: Record<string, unknown> = Object.fromEntries(
Object.entries(issueChanges).map(([key, change]) => [key, change.from]),
);
const hasFieldChanges = Object.keys(issueChanges).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 scheduledRetrySupersededByComment =
shouldResumeInProgressScheduledRetry &&
previous.status !== undefined &&
existing.status === "in_progress" &&
issue.status === "todo";
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,
};
}
if (!persistReviewActivityTransactionally) await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
responsibleUserIdOverride: authenticatedActorResponsibleUserId(req),
action: "issue.updated",
entityType: "issue",
entityId: issue.id,
details: {
...updateFields,
identifier: issue.identifier,
authorizationReason: issueMutationAuthorizationReason,
changes: issueChanges,
...(reviewInteractionId ? { reviewInteractionId } : {}),
...(commentBody ? { source: "comment" } : {}),
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}),
...(scheduledRetrySupersededByComment
? {
scheduledRetrySupersededByComment: true,
scheduledRetryRunId: scheduledRetryForHumanComment?.runId ?? null,
...(cancelledScheduledRetryRunId ? { cancelledScheduledRetryRunId } : {}),
}
: {}),
...(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], { hydrateLiveness: false })
.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,
agentApiKeyId: actor.agentApiKeyId,
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,
agentApiKeyId: actor.agentApiKeyId,
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,
agentApiKeyId: actor.agentApiKeyId,
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,
agentApiKeyId: actor.agentApiKeyId,
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,
agentApiKeyId: actor.agentApiKeyId,
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,
agentApiKeyId: actor.agentApiKeyId,
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,
});
}
}
}
if (
issue.harnessKind === "skill_test" &&
existing.status !== issue.status &&
(issue.status === "done" || issue.status === "cancelled")
) {
const completedRun = await companySkillsSvc.completeTestRunForIssue({
companyId: issue.companyId,
issueId: issue.id,
outcome: issue.status === "done" ? "succeeded" : "cancelled",
error: issue.status === "cancelled" ? "Harness issue was cancelled" : null,
});
if (completedRun) {
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "company.skill_test_run_completed",
entityType: "company_skill_test_run",
entityId: completedRun.id,
issueId: issue.id,
details: {
issueId: issue.id,
status: completedRun.status,
outputDocumentKey: completedRun.outputDocumentKey,
},
});
}
}
let comment = null;
let lostReviewPathRef: string | null = 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,
onBehalfOfUserId: authenticatedActorResponsibleUserId(req),
}, {
authorizationReason: issueMutationAuthorizationReason,
sourceTrust: await sourceTrustForActorWrite(issue, actor),
});
await issueReferencesSvc.syncComment(comment.id);
await externalObjectsSvc.syncCommentSafely(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,
agentApiKeyId: actor.agentApiKeyId,
responsibleUserIdOverride: authenticatedActorResponsibleUserId(req),
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,
authorizationReason: issueMutationAuthorizationReason,
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
...(scheduledRetrySupersededByComment
? {
scheduledRetrySupersededByComment: true,
scheduledRetryRunId: scheduledRetryForHumanComment?.runId ?? null,
...(cancelledScheduledRetryRunId ? { cancelledScheduledRetryRunId } : {}),
}
: {}),
...(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",
});
if (issue.status === "in_review" && expiredInteractions.length > 0) {
const reviewAttention = await svc
.listReviewAttention(issue.companyId, [issue])
.then((map) => map.get(issue.id));
if (reviewAttention?.state === "stalled") {
const expiredInteractionIds = expiredInteractions.map((interaction) => interaction.id).sort();
lostReviewPathRef = expiredInteractionIds.length === 1
? expiredInteractionIds[0]!
: `interactions:${expiredInteractionIds.join(",")}`;
}
}
} 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 userResumedFromReviewToTodo =
actor.actorType === "user" &&
existing.status === "in_review" &&
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<Parameters<typeof heartbeat.wakeup>[1]>;
type DependencyReadinessProvider = {
getDependencyReadiness?: typeof svc.getDependencyReadiness;
};
const dependencyReadinessSvc = svc as DependencyReadinessProvider;
const wakeups = new Map<string, { agentId: string; wakeup: WakeupRequest }>();
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 });
};
const addDependencyResolvedWakeup = async (input: {
agentId: string;
dependentIssueId: string;
resolvedBlockerIssueId: string;
blockerIssueIds: string[];
source: string;
mutation: string;
}) => {
const idempotencyKey = buildIssueBlockersResolvedWakeIdempotencyKey({
dependentIssueId: input.dependentIssueId,
resolvedBlockerIssueId: input.resolvedBlockerIssueId,
});
try {
const existingWake = await findExistingIssueBlockersResolvedWake(db, {
companyId: issue.companyId,
idempotencyKey,
});
if (existingWake) return;
} catch (err) {
logger.warn(
{ err, issueId: input.dependentIssueId, idempotencyKey },
"failed to check existing dependency wake before issue update wake",
);
}
addWakeup(input.agentId, {
source: "automation",
triggerDetail: "system",
reason: ISSUE_BLOCKERS_RESOLVED_WAKE_REASON,
payload: {
issueId: input.dependentIssueId,
resolvedBlockerIssueId: input.resolvedBlockerIssueId,
blockerIssueIds: input.blockerIssueIds,
mutation: input.mutation,
},
idempotencyKey,
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: input.dependentIssueId,
taskId: input.dependentIssueId,
wakeReason: ISSUE_BLOCKERS_RESOLVED_WAKE_REASON,
source: input.source,
resolvedBlockerIssueId: input.resolvedBlockerIssueId,
blockerIssueIds: input.blockerIssueIds,
},
});
};
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 ||
userResumedFromReviewToTodo
) &&
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;
// Re-derive closed-ness from the post-update issue so a status change
// like in_progress -> done with a closure comment does not enqueue a
// stale issue_commented wake for an already-completed issue.
const skipAssigneeCommentWake = selfComment || isClosedIssueStatus(issue.status);
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 } : {}),
...(lostReviewPathRef
? {
reviewPathLost: true,
reviewPathConsumedRef: lostReviewPathRef,
reviewPathInstruction: REVIEW_PATH_RECOVERY_INSTRUCTION,
}
: {}),
},
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 } : {}),
...(lostReviewPathRef
? {
reviewPathLost: true,
reviewPathConsumedRef: lostReviewPathRef,
reviewPathInstruction: REVIEW_PATH_RECOVERY_INSTRUCTION,
}
: {}),
},
});
}
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) {
await addDependencyResolvedWakeup({
agentId: dependent.assigneeAgentId,
dependentIssueId: dependent.id,
resolvedBlockerIssueId: issue.id,
blockerIssueIds: dependent.blockerIssueIds,
source: "issue.blockers_resolved",
mutation: "blocker_done",
});
}
}
const restoredBlockedReadyDependency =
issue.status === "blocked" &&
issue.assigneeAgentId &&
(
existing.status !== "blocked" ||
Array.isArray(req.body.blockedByIssueIds) ||
existing.assigneeAgentId !== issue.assigneeAgentId
);
if (restoredBlockedReadyDependency && typeof dependencyReadinessSvc.getDependencyReadiness === "function") {
const readiness = await dependencyReadinessSvc.getDependencyReadiness(issue.id);
const resolvedBlockerIssueId = readiness.blockerIssueIds[0] ?? null;
if (
resolvedBlockerIssueId &&
readiness.isDependencyReady &&
readiness.blockerIssueIds.length > 0
) {
await addDependencyResolvedWakeup({
agentId: issue.assigneeAgentId!,
dependentIssueId: issue.id,
resolvedBlockerIssueId,
blockerIssueIds: readiness.blockerIssueIds,
source: "issue.blockers_restored",
mutation: "blocked_dependency_restored",
});
}
}
const stopRelay = stopRelayResult.value;
if (stopRelay) {
await logActivity(db, {
companyId: issue.companyId,
actorType: "system",
actorId: "issue_stop_relay",
agentId: null,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.comment_added",
entityType: "issue",
entityId: stopRelay.parent.id,
details: {
commentId: stopRelay.comment.id,
source: "child_stop_relay",
childIssueId: issue.id,
childIdentifier: issue.identifier,
childStatus: issue.status,
},
});
if (stopRelay.parent.assigneeAgentId && !isClosedIssueStatus(stopRelay.parent.status)) {
addWakeup(stopRelay.parent.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: {
issueId: stopRelay.parent.id,
commentId: stopRelay.comment.id,
mutation: "comment",
},
requestedByActorType: "system",
requestedByActorId: "issue_stop_relay",
contextSnapshot: {
issueId: stopRelay.parent.id,
taskId: stopRelay.parent.id,
commentId: stopRelay.comment.id,
wakeCommentId: stopRelay.comment.id,
source: "issue.stop_relay",
wakeReason: "issue_commented",
childIssueId: issue.id,
childStatus: issue.status,
},
});
}
}
const becameTerminal =
!["done", "cancelled"].includes(existing.status) && ["done", "cancelled"].includes(issue.status);
if (becameTerminal) {
const expiredInteractions = await issueThreadInteractionService(db).expirePendingInteractionsForTerminalIssue(issue, {
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
});
await logExpiredRequestConfirmations({
issue,
interactions: expiredInteractions,
actor,
source: "issue.status_transition.issue_closed",
});
await destroyReusableSandboxLeasesForTerminalIssue(issue);
}
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)
.then((wakeRun) => {
if (wakeup.reason !== ISSUE_BLOCKERS_RESOLVED_WAKE_REASON) return;
const payload = wakeup.payload && typeof wakeup.payload === "object" ? wakeup.payload : {};
const dependentIssueId = typeof payload.issueId === "string" ? payload.issueId : issue.id;
return logActivity(db, {
companyId: issue.companyId,
actorType: "system",
actorId: "issue_update",
agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.blockers_resolved_wake_emitted",
entityType: "issue",
entityId: dependentIssueId,
details: {
source: wakeup.contextSnapshot?.source ?? "issue.update",
wakeupRunId: wakeRun?.id ?? null,
idempotencyKey: wakeup.idempotencyKey ?? null,
resolvedBlockerIssueId: typeof payload.resolvedBlockerIssueId === "string"
? payload.resolvedBlockerIssueId
: null,
blockerIssueIds: Array.isArray(payload.blockerIssueIds) ? payload.blockerIssueIds : [],
},
});
})
.catch((err) => logger.warn({ err, issueId: issue.id, agentId }, "failed to wake agent on issue update"));
}
})();
await queueTaskWatchdogEvaluation(issue, actor.runId);
const changes = issueResponse.changes ?? {};
if (prefersMinimalIssueUpdateResponse(req)) {
res.setHeader("Preference-Applied", "return=minimal");
res.json({
id: issueResponse.id,
identifier: issueResponse.identifier,
updatedAt: issueResponse.updatedAt,
changes,
comment,
});
return;
}
res.json({ ...issueResponse, changes, comment });
});
router.delete("/issues/:id", async (req, res) => {
const id = req.params.id as string;
const existing = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!existing) return;
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,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.deleted",
entityType: "issue",
entityId: issue.id,
});
await queueTaskWatchdogEvaluation(existing, actor.runId);
res.json(issue);
});
router.post("/issues/:id/checkout", validate(checkoutIssueSchema), async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
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;
}
if (issue.assigneeAgentId !== req.body.agentId) {
await assertCanAssignTasks(req, issue.companyId, {
issueId: issue.id,
projectId: issue.projectId ?? null,
parentIssueId: issue.parentId ?? null,
assigneeAgentId: req.body.agentId,
assigneeUserId: null,
});
}
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue);
const checkoutRunId = requireAgentRunId(req, res);
if (req.actor.type === "agent" && !checkoutRunId) return;
// Reopen the closed isolated workspace only after the run-id gate passes. A
// rejected checkout must not rebuild and republish the workspace as active.
let reopenedWorkspace: Pick<ExecutionWorkspace, "id"> | null = null;
let reopenedGeneration: number | null = null;
if (closedExecutionWorkspace) {
const reopenOutcome = await reopenClosedIssueExecutionWorkspaceOrRespond(
req,
res,
issue,
closedExecutionWorkspace,
);
if (reopenOutcome === null) {
return;
}
// Install the guard only when this request set the reopen-pending flag. A
// concurrent request that found the workspace already open must not clear
// the flag that the actual reopener still owns.
if (reopenOutcome.outcome === "reopened") {
reopenedWorkspace = closedExecutionWorkspace;
reopenedGeneration = reopenOutcome.generation;
}
}
let updated: Awaited<ReturnType<typeof svc.checkout>> | undefined;
// Clear the reopen-pending flag if the checkout leaves the issue terminal, so
// the rebuilt worktree does not leak. The guard reads `updated` when the
// response ends, so it covers a null return and a thrown error. It clears only
// the fence this request installed, keyed by its generation.
guardReopenedWorkspaceConsumption({
req,
res,
issue,
workspace: reopenedWorkspace,
generation: reopenedGeneration,
finalIssueStatus: () => updated?.status,
});
try {
updated = await svc.checkout(id, req.body.agentId, req.body.expectedStatuses, checkoutRunId);
} catch (error) {
if (isUniqueViolation(error, "issues_open_routine_execution_uq")) {
res.status(409).json({
error: "Another execution for this routine is already in progress",
});
return;
}
throw error;
}
const actor = getActorInfo(req);
if (updated?.harnessKind === "skill_test") {
await companySkillsSvc.markTestRunRunning(updated.companyId, updated.id);
}
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
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 getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!existing) return;
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,
agentApiKeyId: actor.agentApiKeyId,
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 getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!existing) return;
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,
agentApiKeyId: actor.agentApiKeyId,
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 getAccessibleResource(req, res, getIssueById(req, id), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(req, res, issue))) return;
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(await runRedactions.redactForIssue(issue.companyId, issue.id, comments));
});
router.get("/issues/:id/interactions", async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, getIssueById(req, id), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(req, res, issue))) return;
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 getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
if (req.actor.type === "agent") {
if (!(await assertAgentIssueMutationAllowed(req, res, issue, { allowVisibleIssueWrite: true }))) return;
if (await assertLowTrustControlPlaneDenied(req, res, issue.companyId, 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;
if (
req.body.kind === "request_confirmation"
&& req.body.addresseeAgentId
&& req.body.payload?.toolAction !== undefined
) {
throw badRequest("Tool-action confirmations cannot be addressed to agents");
}
if (req.body.kind === "request_confirmation" && req.body.payload?.toolAction !== undefined) {
throw unprocessable("payload.toolAction is server-owned metadata and cannot be supplied when creating an interaction");
}
// Plan-document confirmation targets are validated authoritatively inside
// issueThreadInteractionService.create, which re-reads the plan document's
// latest revision and rejects a stale/missing target under the same insert
// transaction (see assertRequestConfirmationTargetIsCurrent). We deliberately
// do not pre-check the revision here: a separate route-level read would be
// non-atomic with the insert and only duplicate the service gate.
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,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.thread_interaction_created",
entityType: "issue",
entityId: issue.id,
details: {
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
continuationPolicy: interaction.continuationPolicy,
addresseeAgentId: interaction.addresseeAgentId ?? null,
requestedResolverPolicy: interaction.requestedResolverPolicy,
effectiveResolverPolicy: interaction.effectiveResolverPolicy,
},
});
if (interaction.addresseeAgentId) {
void heartbeat.wakeup(interaction.addresseeAgentId, {
source: "automation",
triggerDetail: "system",
reason: "interaction_pending",
payload: {
issueId: issue.id,
interactionId: interaction.id,
interactionKind: interaction.kind,
sourceCommentId: interaction.sourceCommentId ?? null,
sourceRunId: interaction.sourceRunId ?? null,
mutation: "interaction",
},
idempotencyKey: `interaction-pending:${interaction.id}`,
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: issue.id,
taskId: issue.id,
interactionId: interaction.id,
interactionKind: interaction.kind,
sourceCommentId: interaction.sourceCommentId ?? null,
sourceRunId: interaction.sourceRunId ?? null,
wakeReason: "interaction_pending",
source: "issue.interaction.created",
},
}).catch((err) => logger.warn({
err,
issueId: issue.id,
interactionId: interaction.id,
agentId: interaction.addresseeAgentId,
}, "failed to wake addressee on issue interaction creation"));
}
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 getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
if (await rejectTaskWatchdogInteractionMutation(req, res, issue)) return;
const interactionSvc = issueThreadInteractionService(db);
const current = await interactionSvc.getForIssue(issue, interactionId);
const resolutionAuthorization = await assertIssueThreadInteractionResolutionAllowed(req, res, issue, current);
if (!resolutionAuthorization) return;
const actor = getActorInfo(req);
const { interaction, createdIssues, continuationIssue } = await interactionSvc.acceptInteraction(issue, interactionId, req.body, {
agentId: actor.agentId,
runId: actor.runId,
userId: actor.actorType === "user" ? actor.actorId : null,
...(resolutionAuthorization === "review_verdict"
? { reviewVerdictAuthorized: true }
: {}),
});
const toolAction = interaction.payload && typeof interaction.payload === "object"
? (interaction.payload as { toolAction?: { actionRequestId?: unknown } }).toolAction
: null;
let continuationInteraction = interaction;
if (
interaction.kind === "request_confirmation"
&& interaction.status === "accepted"
&& typeof toolAction?.actionRequestId === "string"
&& opts.approveToolActionRequest
) {
const approvalResult = await opts.approveToolActionRequest({
companyId: issue.companyId,
issueId: issue.id,
interactionId: interaction.id,
actionRequestId: toolAction.actionRequestId,
actor: {
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
},
});
const approval = readObject(approvalResult);
const executionStatus = readToolActionExecutionStatus(approval.status);
if (executionStatus) {
const currentResult = readObject(interaction.result);
continuationInteraction = {
...interaction,
result: {
...currentResult,
toolAction: {
version: 1,
status: executionStatus,
errorMessage: readNonEmptyString(approval.error),
resultSummary: readNonEmptyString(approval.resultSummary),
updatedAt: new Date().toISOString(),
},
} as typeof interaction.result,
};
}
}
const continuationWakeIssue = continuationIssue ?? issue;
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
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,
resolutionActorKind: actor.actorType,
requestedResolverPolicy: interaction.requestedResolverPolicy,
effectiveResolverPolicy: interaction.effectiveResolverPolicy,
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,
agentApiKeyId: actor.agentApiKeyId,
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 acceptedPlanTarget = interaction.kind === "request_confirmation"
? readAcceptedPlanConfirmationTarget(interaction.payload)
: null;
const acceptedPlanConfirmation =
interaction.kind === "request_confirmation" &&
interaction.status === "accepted" &&
acceptedPlanTarget?.issueId === issue.id &&
acceptedPlanTarget.key === "plan";
await queueResolvedInteractionContinuationWakeup({
db,
heartbeat,
issue: { ...continuationWakeIssue, companyId: issue.companyId },
interaction: continuationInteraction,
actor,
source: "issue.interaction.accept",
forceFreshSession: acceptedPlanConfirmation,
workspaceRefreshReason: acceptedPlanConfirmation ? "accepted_plan_confirmation" : null,
});
res.json(continuationInteraction);
},
);
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 getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
if (await rejectTaskWatchdogInteractionMutation(req, res, issue)) return;
const interactionSvc = issueThreadInteractionService(db);
const current = await interactionSvc.getForIssue(issue, interactionId);
const resolutionAuthorization = await assertIssueThreadInteractionResolutionAllowed(req, res, issue, current);
if (!resolutionAuthorization) return;
const actor = getActorInfo(req);
const interaction = await interactionSvc.rejectInteraction(issue, interactionId, req.body, {
agentId: actor.agentId,
runId: actor.runId,
userId: actor.actorType === "user" ? actor.actorId : null,
...(resolutionAuthorization === "review_verdict"
? { reviewVerdictAuthorized: true }
: {}),
});
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
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,
resolutionActorKind: actor.actorType,
requestedResolverPolicy: interaction.requestedResolverPolicy,
effectiveResolverPolicy: interaction.effectiveResolverPolicy,
rejectionReason:
interaction.kind === "suggest_tasks"
? (interaction.result?.rejectionReason ?? null)
: interaction.kind === "request_confirmation" || interaction.kind === "request_checkbox_confirmation"
? (interaction.result?.reason ?? null)
: null,
},
});
await queueResolvedInteractionContinuationWakeup({
db,
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 getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
if (await rejectTaskWatchdogInteractionMutation(req, res, issue)) return;
const interactionSvc = issueThreadInteractionService(db);
const current = await interactionSvc.getForIssue(issue, interactionId);
if (!(await assertIssueThreadInteractionResolutionAllowed(req, res, issue, current))) return;
const actor = getActorInfo(req);
const interaction = await interactionSvc.answerQuestions(issue, interactionId, req.body, {
agentId: actor.agentId,
runId: actor.runId,
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,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.thread_interaction_answered",
entityType: "issue",
entityId: issue.id,
details: {
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
resolutionActorKind: actor.actorType,
requestedResolverPolicy: interaction.requestedResolverPolicy,
effectiveResolverPolicy: interaction.effectiveResolverPolicy,
answeredQuestionCount:
interaction.kind === "ask_user_questions"
? (interaction.result?.answers?.length ?? 0)
: 0,
},
});
await queueResolvedInteractionContinuationWakeup({
db,
heartbeat,
issue,
interaction,
actor,
source: "issue.interaction.respond",
});
res.json(interaction);
},
);
router.post(
"/issues/:id/interactions/:interactionId/verdicts",
validate(submitIssueThreadInteractionVerdictsSchema),
async (req, res) => {
const id = req.params.id as string;
const interactionId = req.params.interactionId as string;
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
if (await rejectTaskWatchdogInteractionMutation(req, res, issue)) return;
const interactionSvc = issueThreadInteractionService(db);
const current = await interactionSvc.getForIssue(issue, interactionId);
if (!(await assertIssueThreadInteractionResolutionAllowed(req, res, issue, current))) return;
const actor = getActorInfo(req);
const { interaction, newlyResolvedItemIds } = await interactionSvc.submitItemVerdicts(
issue,
interactionId,
req.body,
{
agentId: actor.agentId,
runId: actor.runId,
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,
agentApiKeyId: actor.agentApiKeyId,
action: interaction.status === "expired"
? "issue.thread_interaction_expired"
: "issue.thread_interaction_item_verdicts_submitted",
entityType: "issue",
entityId: issue.id,
details: {
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
resolutionActorKind: actor.actorType,
requestedResolverPolicy: interaction.requestedResolverPolicy,
effectiveResolverPolicy: interaction.effectiveResolverPolicy,
submittedVerdictCount: Array.isArray(req.body?.verdicts) ? req.body.verdicts.length : 0,
newlyResolvedItemCount: newlyResolvedItemIds.length,
newlyResolvedItemIds,
complete:
interaction.kind === "request_item_verdicts"
? (interaction.result?.complete ?? false)
: false,
},
});
if (newlyResolvedItemIds.length > 0) {
await queueResolvedInteractionContinuationWakeup({
db,
heartbeat,
issue,
interaction,
actor,
source: "issue.interaction.verdicts",
newlyResolvedItemIds,
idempotencyKey: buildRequestItemVerdictsWakeIdempotencyKey({
issueId: issue.id,
interactionId: interaction.id,
}),
});
}
res.json(interaction);
},
);
router.post(
"/issues/:id/interactions/:interactionId/withdraw",
validate(withdrawIssueThreadInteractionSchema),
async (req, res) => {
const id = req.params.id as string;
const interactionId = req.params.interactionId as string;
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
if (await rejectTaskWatchdogInteractionMutation(req, res, issue)) return;
const interactionSvc = issueThreadInteractionService(db);
const current = await interactionSvc.getForIssue(issue, interactionId);
if (!(await assertIssueThreadInteractionWithdrawalAllowed(req, res, issue, current))) return;
await assertPendingReviewInteractionVerdictAllowed(req, issue, current);
const actor = getActorInfo(req);
const interaction = await interactionSvc.withdrawInteraction(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,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.thread_interaction_withdrawn",
entityType: "issue",
entityId: issue.id,
details: {
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
reason: interaction.result && "reason" in interaction.result ? interaction.result.reason ?? null : null,
},
});
if (actor.agentId !== issue.assigneeAgentId) {
await queueResolvedInteractionContinuationWakeup({
db,
heartbeat,
issue,
interaction,
actor,
source: "issue.interaction.withdraw",
});
}
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 getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
if (req.actor.type === "agent") {
res.status(403).json({ error: "Agent actors cannot cancel issue-thread interactions through this board-only route" });
return;
}
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,
agentApiKeyId: actor.agentApiKeyId,
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,
},
});
await queueResolvedInteractionContinuationWakeup({
db,
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 getAccessibleResource(req, res, getIssueById(req, id), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(req, res, issue))) return;
const comment = await svc.getComment(commentId);
if (!comment || comment.issueId !== id) {
res.status(404).json({ error: "Comment not found" });
return;
}
res.json(await runRedactions.redactForIssue(issue.companyId, issue.id, 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 getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
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;
const deleteMode = req.query.mode === "cancel" ? "cancel" : "delete";
const activeRun = await resolveActiveIssueRun(issue);
const isQueuedComment = activeRun ? isQueuedIssueCommentForActiveRun({ comment, activeRun }) : false;
if (deleteMode === "cancel" || isQueuedComment) {
if (!actorOwnsComment) {
res.status(403).json({ error: "Only the comment author can cancel queued comments" });
return;
}
if (!activeRun) {
res.status(409).json({ error: "Queued comment can no longer be canceled" });
return;
}
if (!isQueuedComment) {
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,
agentApiKeyId: actor.agentApiKeyId,
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);
return;
}
if (!actorOwnsComment) {
res.status(403).json({ error: "Only the comment author can delete comments" });
return;
}
if (comment.deletedAt) {
res.json(comment);
return;
}
let annotationCleanup = { deletedCommentIds: [] as string[], resolvedThreadIds: [] as string[] };
const deleted = await svc.tombstoneComment(
commentId,
{
actorType: actor.actorType,
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
runId: actor.runId,
},
{
afterTombstone: async (deletedComment, tx) => {
await issueReferencesSvc.syncComment(deletedComment.id, tx);
await externalObjectsSvc.syncCommentSafely(deletedComment.id, tx);
annotationCleanup = await documentAnnotationsSvc.cleanupForIssueCommentDeletion(issue.id, deletedComment.id, {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
runId: actor.runId,
}, tx);
await Promise.all(
annotationCleanup.deletedCommentIds.map((annotationCommentId) =>
Promise.all([
issueReferencesSvc.deleteCommentSource(annotationCommentId, tx),
externalObjectsSvc.syncCommentSafely(annotationCommentId, tx),
])
),
);
await decisionTrainingSvc.scrubDeletedComments({
companyId: issue.companyId,
issueId: issue.id,
commentIds: [deletedComment.id, ...annotationCleanup.deletedCommentIds],
deletedAt: deletedComment.deletedAt ?? new Date(),
}, tx);
},
},
);
if (!deleted) {
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,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.comment_deleted",
entityType: "issue",
entityId: issue.id,
details: {
commentId: deleted.id,
identifier: issue.identifier,
issueTitle: issue.title,
source: "author_delete",
deletedByType: actor.actorType,
deletedByAgentId: actor.actorType === "agent" ? actor.agentId : null,
deletedByUserId: actor.actorType === "user" ? actor.actorId : null,
deletedByRunId: actor.runId,
deletedAt: deleted.deletedAt,
deletedAnnotationCommentIds: annotationCleanup.deletedCommentIds,
resolvedAnnotationThreadIds: annotationCleanup.resolvedThreadIds,
},
});
res.json(deleted);
});
router.get("/issues/:id/feedback-votes", async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, getIssueById(req, id), "Issue not found");
if (!issue) return;
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 getAccessibleResource(req, res, getIssueById(req, id), "Issue not found");
if (!issue) return;
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 getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
if (req.actor.type === "agent" && req.body.onBehalfOfUserId != null) {
await auditAgentIssueCommentAttributionSpoof({
db,
req,
issue,
surface: "issue.comment.create",
requestedValue: readNonEmptyString(req.body.onBehalfOfUserId),
});
await denyIssueWrite(req, res, issue, "issue_write_attribution_spoof_rejected");
return;
}
const commentAccessDecision = await assertAgentIssueCommentAllowed(req, res, issue);
if (!commentAccessDecision) return;
const commentAuthorizationReason = issueWriteAuthorizationReason(req, commentAccessDecision);
if (!assertStructuredCommentFieldsAllowed(req, res, {
presentation: req.body.presentation,
metadata: req.body.metadata,
})) return;
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue);
const actor = getActorInfo(req);
const commentPresentation = req.body.presentation ??
await deriveRecoveryCommentPresentation(req, issue.companyId, req.body.body);
const reopenRequested = req.body.reopen === true;
const resumeRequested = req.body.resume === true;
const interruptRequested = req.body.interrupt === true;
const isClosed = isClosedIssueStatus(issue.status);
const isBlocked = issue.status === "blocked";
const crossIssueCommentOnlyGrant =
isClosed &&
(isDirectParentReportDecision(commentAccessDecision) ||
(req.actor.type === "agent" &&
issue.assigneeAgentId !== null &&
issue.assigneeAgentId !== req.actor.agentId &&
!reopenRequested &&
!resumeRequested &&
(isIssueMentionGrantDecision(commentAccessDecision) ||
isDefaultOpenIssueWriteDecision(commentAccessDecision))));
const effectiveReopenRequested = crossIssueCommentOnlyGrant ? false : reopenRequested;
const effectiveResumeRequested = crossIssueCommentOnlyGrant ? false : resumeRequested;
if (
isClosed &&
req.actor.type === "agent" &&
issue.assigneeAgentId !== null &&
issue.assigneeAgentId !== req.actor.agentId &&
!crossIssueCommentOnlyGrant
) {
if (!(await assertAgentIssueMutationAllowed(req, res, issue, { allowVisibleIssueWrite: true }))) return;
}
if (
effectiveResumeRequested === true &&
!(await assertExplicitResumeIntentAllowed(req, res, issue, { resumeIntent: true }))
) return;
if (effectiveResumeRequested !== true && effectiveReopenRequested === true && req.actor.type === "agent") {
if (!(await assertExplicitResumeIntentAllowed(req, res, issue))) return;
}
const explicitMoveToTodoRequested = effectiveReopenRequested || effectiveResumeRequested === true;
const scheduledRetryForHumanComment =
shouldHumanCommentResumeInProgressScheduledRetry({
hasComment: true,
issueStatus: issue.status,
assigneeAgentId: issue.assigneeAgentId,
actorType: actor.actorType,
})
? await svc.getCurrentScheduledRetry(issue.id)
: null;
const shouldResumeInProgressScheduledRetry =
!!scheduledRetryForHumanComment &&
scheduledRetryForHumanComment.agentId === issue.assigneeAgentId;
const assigneeSelfCommentOnTerminal = isAssigneeSelfCommentOnTerminalIssue({
hasCommentBody: true,
resumeRequested: resumeRequested === true,
issueStatus: issue.status,
assigneeAgentId: issue.assigneeAgentId,
actorType: actor.actorType,
actorId: actor.actorId,
});
const effectiveMoveToTodoRequested =
!assigneeSelfCommentOnTerminal &&
(explicitMoveToTodoRequested ||
shouldImplicitlyMoveCommentedIssueToTodo({
issueStatus: issue.status,
assigneeAgentId: issue.assigneeAgentId,
actorType: actor.actorType,
actorId: actor.actorId,
actorRunId: actor.runId,
checkoutRunId: issue.checkoutRunId,
executionRunId: issue.executionRunId,
}) ||
shouldResumeInProgressScheduledRetry);
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;
}
if (!(await assertCrossIssueInfluenceWithinRunCap(req, res, issue, "comment"))) return;
// Reopen the closed isolated workspace only after every access, resume-intent,
// blocker, and run-cap gate passes. A rejected comment must not rebuild and
// republish the workspace as active, because the issue stays terminal and the
// reaper then skips the leaked workspace.
let reopenedWorkspace: Pick<ExecutionWorkspace, "id"> | null = null;
let reopenedGeneration: number | null = null;
if (closedExecutionWorkspace) {
const reopenOutcome = await reopenClosedIssueExecutionWorkspaceOrRespond(
req,
res,
issue,
closedExecutionWorkspace,
);
if (reopenOutcome === null) {
return;
}
// Install the guard only when this request set the reopen-pending flag. A
// concurrent request that found the workspace already open must not clear
// the flag that the actual reopener still owns.
if (reopenOutcome.outcome === "reopened") {
reopenedWorkspace = closedExecutionWorkspace;
reopenedGeneration = reopenOutcome.generation;
}
}
let reopened = false;
let reopenFromStatus: string | null = null;
let interruptedRunId: string | null = null;
let currentIssue = issue;
// Clear the reopen-pending flag if this comment leaves the issue terminal, so
// the rebuilt worktree does not leak. A comment reopens the workspace but only
// moves the issue out of the terminal state when it resumes the work. The
// guard reads `currentIssue` when the response ends, so it covers a rejected
// move, a thrown error, and a comment that keeps the issue terminal. It clears
// only the fence this request installed, keyed by its generation.
guardReopenedWorkspaceConsumption({
req,
res,
issue,
workspace: reopenedWorkspace,
generation: reopenedGeneration,
finalIssueStatus: () => currentIssue.status,
});
let issueBeforeCommentDecision = issue;
let commentDecisionStageWakeup: ReturnType<typeof buildExecutionStageWakeup> | null = null;
const commentReferenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
let scheduledRetrySupersededByComment = false;
let cancelledScheduledRetryRunId: string | null = null;
if (
effectiveMoveToTodoRequested &&
(isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers) || shouldResumeInProgressScheduledRetry)
) {
scheduledRetrySupersededByComment = shouldResumeInProgressScheduledRetry && issue.status === "in_progress";
cancelledScheduledRetryRunId = scheduledRetrySupersededByComment
? await cancelScheduledRetrySupersededByComment({
scheduledRetryRunId: scheduledRetryForHumanComment?.runId,
issue,
actor,
})
: null;
const reopenedIssue = await svc.update(id, { status: "todo" });
if (!reopenedIssue) {
res.status(404).json({ error: "Issue not found" });
return;
}
reopened = isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers);
reopenFromStatus = reopened ? issue.status : null;
currentIssue = reopenedIssue;
await logActivity(db, {
companyId: currentIssue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.updated",
entityType: "issue",
entityId: currentIssue.id,
details: {
status: "todo",
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}),
...(scheduledRetrySupersededByComment
? {
scheduledRetrySupersededByComment: true,
scheduledRetryRunId: scheduledRetryForHumanComment?.runId ?? null,
...(cancelledScheduledRetryRunId ? { cancelledScheduledRetryRunId } : {}),
}
: {}),
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,
"Interrupted by board comment",
operatorInterruptCancelOptions({ issueId: currentIssue.id, actor }),
);
if (cancelled) {
interruptedRunId = cancelled.id;
await logActivity(db, {
companyId: cancelled.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "heartbeat.cancelled",
entityType: "heartbeat_run",
entityId: cancelled.id,
issueId: currentIssue.id,
details: {
agentId: cancelled.agentId,
source: "issue_comment_interrupt",
issueId: currentIssue.id,
cancellationKind: "operator_interrupted",
operatorInterrupted: true,
},
});
}
}
}
const currentExecutionState = parseIssueExecutionState(currentIssue.executionState);
const currentExecutionPolicy = normalizeIssueExecutionPolicy(currentIssue.executionPolicy ?? null);
const shouldAutoApproveReviewComment =
currentIssue.status === "in_review" &&
currentExecutionState?.status === "pending" &&
actorMatchesExecutionParticipant(actor, currentExecutionState.currentParticipant ?? null) &&
isApprovalReviewComment(req.body.body);
// Persist the comment and the auto-approval state transition atomically when both apply.
// Without a single transaction, a 422 (or any error) thrown by the status update after the
// comment is inserted would leave an orphan comment without the corresponding state change.
let comment: Awaited<ReturnType<typeof svc.addComment>>;
if (shouldAutoApproveReviewComment) {
const transition = applyIssueExecutionPolicyTransition({
issue: currentIssue,
policy: currentExecutionPolicy,
requestedStatus: "done",
requestedAssigneePatch: {},
actor: {
agentId: actor.agentId ?? null,
userId: actor.actorType === "user" ? actor.actorId : null,
},
commentBody: req.body.body,
});
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,
};
}
issueBeforeCommentDecision = currentIssue;
const updatePatch = {
...transition.patch,
status: typeof transition.patch.status === "string" ? transition.patch.status : "done",
actorAgentId: actor.agentId ?? null,
actorUserId: actor.actorType === "user" ? actor.actorId : null,
};
const sourceTrust = await sourceTrustForActorWrite(currentIssue, actor);
const commentOptions = {
authorType: req.body.authorType ?? (actor.actorType === "agent" ? "agent" : "user"),
presentation: commentPresentation,
metadata: req.body.metadata ?? null,
sourceTrust,
};
let txResult: { comment: Awaited<ReturnType<typeof svc.addComment>>; issue: NonNullable<Awaited<ReturnType<typeof svc.update>>> };
const postCommitActivityPublications: ActivityPublication[] = [];
try {
txResult = await db.transaction(async (tx) => {
const insertedComment = await svc.addComment(
id,
req.body.body,
{
agentId: actor.agentId ?? undefined,
userId: actor.actorType === "user" ? actor.actorId : undefined,
runId: actor.runId,
onBehalfOfUserId: authenticatedActorResponsibleUserId(req),
},
{ ...commentOptions, authorizationReason: commentAuthorizationReason },
tx,
);
const updated = actor.actorType === "user" && currentIssue.status !== "done"
? await svc.update(id, updatePatch, tx, postCommitActivityPublications)
: await svc.update(id, updatePatch, tx);
// Throw (not return null) so drizzle rolls back the inserted comment when the issue
// has been concurrently deleted between the initial fetch and the in-transaction update.
if (!updated) throw new AutoApprovalIssueMissingError();
if (transition.decision && decisionId) {
await tx.insert(issueExecutionDecisions).values({
id: decisionId,
companyId: updated.companyId,
issueId: updated.id,
stageId: transition.decision.stageId,
stageType: transition.decision.stageType,
actorAgentId: actor.agentId ?? null,
actorUserId: actor.actorType === "user" ? actor.actorId : null,
outcome: transition.decision.outcome,
body: transition.decision.body,
createdByRunId: actor.runId ?? null,
});
}
return { comment: insertedComment, issue: updated };
});
} catch (err) {
if (err instanceof AutoApprovalIssueMissingError) {
res.status(404).json({ error: "Issue not found" });
return;
}
throw err;
}
for (const publication of postCommitActivityPublications) publishActivity(publication);
comment = txResult.comment;
currentIssue = txResult.issue;
// Mirror the normal status-change audit trail: every other in_review -> done path
// emits an `issue.updated` activity, so emit one here too for the auto-approval path.
if (issueBeforeCommentDecision.status !== currentIssue.status) {
await logActivity(db, {
companyId: currentIssue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.updated",
entityType: "issue",
entityId: currentIssue.id,
details: {
status: currentIssue.status,
identifier: currentIssue.identifier,
source: "auto_approval_comment",
_previous: { status: issueBeforeCommentDecision.status },
},
});
}
commentDecisionStageWakeup = buildExecutionStageWakeup({
issueId: currentIssue.id,
previousState: currentExecutionState,
nextState: parseIssueExecutionState(currentIssue.executionState),
interruptedRunId,
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
});
} else {
comment = await svc.addComment(id, req.body.body, {
agentId: actor.agentId ?? undefined,
userId: actor.actorType === "user" ? actor.actorId : undefined,
runId: actor.runId,
onBehalfOfUserId: authenticatedActorResponsibleUserId(req),
}, {
authorType: req.body.authorType ?? (actor.actorType === "agent" ? "agent" : "user"),
presentation: commentPresentation,
metadata: req.body.metadata ?? null,
authorizationReason: commentAuthorizationReason,
sourceTrust: await sourceTrustForActorWrite(currentIssue, actor),
});
}
await issueReferencesSvc.syncComment(comment.id);
await externalObjectsSvc.syncCommentSafely(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,
agentApiKeyId: actor.agentApiKeyId,
responsibleUserIdOverride: authenticatedActorResponsibleUserId(req),
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,
authorizationReason: commentAuthorizationReason,
...(isDirectParentReportDecision(commentAccessDecision)
? { directParentReportGrant: true }
: {}),
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
...(scheduledRetrySupersededByComment
? {
scheduledRetrySupersededByComment: true,
scheduledRetryRunId: scheduledRetryForHumanComment?.runId ?? null,
...(cancelledScheduledRetryRunId ? { cancelledScheduledRetryRunId } : {}),
}
: {}),
...(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",
});
let lostReviewPathRef: string | null = null;
if (currentIssue.status === "in_review" && expiredInteractions.length > 0) {
const reviewAttention = await svc
.listReviewAttention(currentIssue.companyId, [currentIssue])
.then((map) => map.get(currentIssue.id));
if (reviewAttention?.state === "stalled") {
const expiredInteractionIds = expiredInteractions.map((interaction) => interaction.id).sort();
lostReviewPathRef = expiredInteractionIds.length === 1
? expiredInteractionIds[0]!
: `interactions:${expiredInteractionIds.join(",")}`;
}
}
await revalidateActiveSourceRecoveryAfterCommittedWrite({
issue: currentIssue,
trigger: "comment",
actor,
statusChanged: reopened || scheduledRetrySupersededByComment,
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 () => {
type WakeupRequest = NonNullable<Parameters<typeof heartbeat.wakeup>[1]>;
const wakeups = new Map<string, { agentId: string; wakeup: WakeupRequest }>();
const addWakeup = (agentId: string, wakeup: WakeupRequest) => {
const wakeIssueId =
wakeup.payload && typeof wakeup.payload === "object" && typeof wakeup.payload.issueId === "string"
? wakeup.payload.issueId
: currentIssue.id;
const key = `${agentId}:${wakeIssueId}`;
if (wakeups.has(key)) return;
wakeups.set(key, { agentId, wakeup });
};
const addDependencyResolvedWakeup = async (input: {
agentId: string;
dependentIssueId: string;
resolvedBlockerIssueId: string;
blockerIssueIds: string[];
}) => {
const idempotencyKey = buildIssueBlockersResolvedWakeIdempotencyKey({
dependentIssueId: input.dependentIssueId,
resolvedBlockerIssueId: input.resolvedBlockerIssueId,
});
try {
const existingWake = await findExistingIssueBlockersResolvedWake(db, {
companyId: currentIssue.companyId,
idempotencyKey,
});
if (existingWake) return;
} catch (err) {
logger.warn(
{ err, issueId: input.dependentIssueId, idempotencyKey },
"failed to check existing dependency wake before issue comment wake",
);
}
addWakeup(input.agentId, {
source: "automation",
triggerDetail: "system",
reason: ISSUE_BLOCKERS_RESOLVED_WAKE_REASON,
payload: {
issueId: input.dependentIssueId,
resolvedBlockerIssueId: input.resolvedBlockerIssueId,
blockerIssueIds: input.blockerIssueIds,
mutation: "comment",
},
idempotencyKey,
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: input.dependentIssueId,
taskId: input.dependentIssueId,
wakeReason: ISSUE_BLOCKERS_RESOLVED_WAKE_REASON,
source: "issue.blockers_resolved",
resolvedBlockerIssueId: input.resolvedBlockerIssueId,
blockerIssueIds: input.blockerIssueIds,
},
});
};
if (commentDecisionStageWakeup) {
addWakeup(commentDecisionStageWakeup.agentId, commentDecisionStageWakeup.wakeup);
}
const assigneeId = currentIssue.assigneeAgentId;
const actorIsAgent = actor.actorType === "agent";
const selfComment = actorIsAgent && actor.actorId === assigneeId;
// Re-derive closed-ness from the post-mutation issue so the auto-approval
// transition (in_review -> done) suppresses a stale `issue_commented` wake
// to the returnAssignee for an already-completed issue.
const skipWake = selfComment || isClosedIssueStatus(currentIssue.status);
if (assigneeId && (reopened || !skipWake)) {
if (reopened) {
addWakeup(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 {
addWakeup(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 } : {}),
...(lostReviewPathRef
? {
reviewPathLost: true,
reviewPathConsumedRef: lostReviewPathRef,
reviewPathInstruction: REVIEW_PATH_RECOVERY_INSTRUCTION,
}
: {}),
},
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 } : {}),
...(lostReviewPathRef
? {
reviewPathLost: true,
reviewPathConsumedRef: lostReviewPathRef,
reviewPathInstruction: REVIEW_PATH_RECOVERY_INSTRUCTION,
}
: {}),
},
});
}
}
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 (actorIsAgent && 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 = issueBeforeCommentDecision.status !== "done" && currentIssue.status === "done";
if (becameDone) {
const dependents = await svc.listWakeableBlockedDependents(currentIssue.id);
for (const dependent of dependents) {
await addDependencyResolvedWakeup({
agentId: dependent.assigneeAgentId,
dependentIssueId: dependent.id,
resolvedBlockerIssueId: currentIssue.id,
blockerIssueIds: dependent.blockerIssueIds,
});
}
}
const becameTerminal =
!["done", "cancelled"].includes(issueBeforeCommentDecision.status) &&
["done", "cancelled"].includes(currentIssue.status);
if (becameTerminal) {
const expiredInteractions = await issueThreadInteractionService(db).expirePendingInteractionsForTerminalIssue(currentIssue, {
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
});
await logExpiredRequestConfirmations({
issue: currentIssue,
interactions: expiredInteractions,
actor,
source: "issue.status_transition.issue_closed",
});
await destroyReusableSandboxLeasesForTerminalIssue(currentIssue);
}
if (becameTerminal && currentIssue.parentId) {
const parent = await svc.getWakeableParentAfterChildCompletion(currentIssue.parentId);
if (parent) {
addWakeup(parent.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_children_completed",
payload: {
issueId: parent.id,
completedChildIssueId: currentIssue.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: currentIssue.id,
childIssueIds: parent.childIssueIds,
childIssueSummaries: parent.childIssueSummaries,
childIssueSummaryTruncated: parent.childIssueSummaryTruncated,
},
});
}
}
for (const { agentId, wakeup } of wakeups.values()) {
heartbeat
.wakeup(agentId, wakeup)
.then((wakeRun) => {
if (wakeup.reason !== ISSUE_BLOCKERS_RESOLVED_WAKE_REASON) return;
const payload = wakeup.payload && typeof wakeup.payload === "object" ? wakeup.payload : {};
const dependentIssueId = typeof payload.issueId === "string" ? payload.issueId : currentIssue.id;
return logActivity(db, {
companyId: currentIssue.companyId,
actorType: "system",
actorId: "issue_comment",
agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.blockers_resolved_wake_emitted",
entityType: "issue",
entityId: dependentIssueId,
details: {
source: wakeup.contextSnapshot?.source ?? "issue.comment",
wakeupRunId: wakeRun?.id ?? null,
idempotencyKey: wakeup.idempotencyKey ?? null,
resolvedBlockerIssueId: typeof payload.resolvedBlockerIssueId === "string"
? payload.resolvedBlockerIssueId
: null,
blockerIssueIds: Array.isArray(payload.blockerIssueIds) ? payload.blockerIssueIds : [],
},
});
})
.catch((err) => logger.warn({ err, issueId: currentIssue.id, agentId }, "failed to wake agent on issue comment"));
}
})();
await queueTaskWatchdogEvaluation(currentIssue, actor.runId);
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 getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
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,
agentApiKeyId: actor.agentApiKeyId,
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,
agentApiKeyId: actor.agentApiKeyId,
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,
agentApiKeyId: actor.agentApiKeyId,
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 getAccessibleResource(req, res, getIssueById(req, issueId), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(req, res, issue))) return;
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 = normalizeUploadAttachmentContentType({
contentType: file.mimetype,
originalFilename: file.originalname,
});
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,
agentApiKeyId: actor.agentApiKeyId,
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 getAccessibleResource(req, res, svc.getAttachmentById(attachmentId), "Attachment not found");
if (!attachment) return;
const issue = await svc.getById(attachment.issueId);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
if (!(await assertIssueReadAllowed(req, res, issue))) return;
const contentLength = attachment.byteSize;
const range = parseAttachmentRangeHeader(
typeof req.headers.range === "string" ? req.headers.range : undefined,
contentLength,
);
res.setHeader("Accept-Ranges", "bytes");
if (range.kind === "invalid") {
res.setHeader("Content-Range", `bytes */${contentLength}`);
res.status(416).end();
return;
}
const object = await storage.getObject(
attachment.companyId,
attachment.objectKey,
range.kind === "range" ? { range: { start: range.start, end: range.end } } : undefined,
);
const responseContentType = resolveAttachmentResponseContentType({
storedContentType: attachment.contentType,
objectContentType: object.contentType,
originalFilename: attachment.originalFilename,
});
res.setHeader("Content-Type", responseContentType);
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 = parseBooleanQuery(req.query.download)
? "attachment"
: isInlineAttachmentContentType(responseContentType) ? "inline" : "attachment";
res.setHeader("Content-Disposition", `${disposition}; filename=\"${filename.replaceAll("\"", "")}\"`);
object.stream.on("error", (err) => {
next(err);
});
if (range.kind === "range") {
const rangeLength = range.end - range.start + 1;
res.status(206);
res.setHeader("Content-Length", String(rangeLength));
res.setHeader("Content-Range", `bytes ${range.start}-${range.end}/${contentLength}`);
object.stream.pipe(res);
return;
}
res.setHeader("Content-Length", String(contentLength || object.contentLength || 0));
object.stream.pipe(res);
});
router.delete("/attachments/:attachmentId", async (req, res) => {
const attachmentId = req.params.attachmentId as string;
const attachment = await getAccessibleResource(req, res, svc.getAttachmentById(attachmentId), "Attachment not found");
if (!attachment) return;
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,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.attachment_removed",
entityType: "issue",
entityId: removed.issueId,
details: {
attachmentId: removed.id,
},
});
res.json({ ok: true });
});
return router;
}