import { Buffer } from "node:buffer"; import { createHash, randomUUID } from "node:crypto"; import { and, asc, desc, eq, gt, gte, inArray, isNull, like, lt, ne, notInArray, or, sql, type SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { activityLog, agentWakeupRequests, agents, authUsers, approvals, assets, companies, companyMemberships, documentRevisions, documents, goals, heartbeatRuns, routineRuns, executionWorkspaces, issueApprovals, issueAttachments, issueCreateIdempotencyKeys, issueInboxArchives, issueLabels, issueWatchdogs, issuePlanDecompositions, issueRecoveryActions, issueRelations, issueComments, issueDocuments, issueReadStates, issueThreadInteractions, issues, labels, projectWorkspaces, projects, workspaceOperations, } from "@paperclipai/db"; import type { AcceptedPlanDecomposition, IssueComment, IssueCommentAuthorType, IssueCommentDerivedAuthorSource, IssueCommentMetadata, IssueCommentPresentation, IssueBlockerAttention, IssueReviewAttention, IssueReviewAttentionPath, IssueBlockedInboxAttention, IssueBlockedInboxIssueRef, IssueProductivityReview, IssueProductivityReviewTrigger, IssueRelationIssueSummary, IssueWatchdogSummary, LowTrustBoundary, SuccessfulRunHandoffState, } from "@paperclipai/shared"; import { clampIssueRequestDepth, extractAgentMentionIds, extractProjectMentionIds, issueCommentAuthorTypeSchema, issueCommentMetadataSchema, issueCommentPresentationSchema, isUuidLike, normalizeIssueIdentifier as normalizeIssueReferenceIdentifier, } from "@paperclipai/shared"; import { conflict, HttpError, notFound, unprocessable } from "../errors.js"; import { isForeignKeyViolation } from "../db-errors.js"; import { logger } from "../middleware/logger.js"; import { parseObject } from "../adapters/utils.js"; import { hydrateSuccessfulRunHandoffLiveness, SUCCESSFUL_RUN_HANDOFF_LIVE_WAKE_STATUSES, } from "./successful-run-handoff-state.js"; import { defaultIssueExecutionWorkspaceSettingsForProject, gateProjectExecutionWorkspacePolicy, issueExecutionWorkspaceModeForPersistedWorkspace, isUnrunnableWorktreeCombo, parseIssueExecutionWorkspaceSettings, parseProjectExecutionWorkspacePolicy, resolvePinnedIssueWorkspaceStrategyType, WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE, WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE, WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION, type ParsedExecutionWorkspaceMode, } from "./execution-workspace-policy.js"; import { mergeExecutionWorkspaceConfig } from "./execution-workspaces.js"; import { buildInitialIssueMonitorFields, normalizeIssueExecutionPolicy } from "./issue-execution-policy.js"; import { instanceSettingsService } from "./instance-settings.js"; import { redactCurrentUserText } from "../log-redaction.js"; import { redactSensitiveText } from "../redaction.js"; import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js"; import { getRunLogStore } from "./run-log-store.js"; import { getDefaultCompanyGoal } from "./goals.js"; import { assertAssignableAgent } from "./agent-assignability.js"; import { DEFAULT_INSERT_CHUNK_ROWS, insertRowsInChunks } from "./batch-insert.js"; import type { ImportIssueRow, ImportIssueCommentRow, ImportIssueAttachmentRow, } from "./import-write-types.js"; import { summarizeIssueWatchdog, upsertIssueWatchdogForIssue, } from "./task-watchdogs.js"; import { isVerifiedIssueTreeControlInteractionWake, issueTreeControlService, type ActiveIssueTreePauseHoldGate, } from "./issue-tree-control.js"; import { parseIssueGraphLivenessIncidentKey, RECOVERY_ORIGIN_KINDS, } from "./recovery/origins.js"; import { classifyIssueGraphLiveness, classifyIssueReviewPaths, type IssueGraphLivenessInput, type IssueLivenessFinding, } from "./recovery/issue-graph-liveness.js"; import { visibleIssueCondition } from "./issue-visibility.js"; import { finalizeStatusCardsForStalledGeneration } from "./status-card-finalization.js"; import { finalizeSummarySlotsForTerminalIssue } from "./summary-slot-finalization.js"; import { logActivity, persistActivity, publishActivity, type ActivityPublication, } from "./activity-log.js"; import { buildIssueChanges } from "./issue-change-receipt.js"; import { issueThreadInteractionAttentionAgentAllowed } from "./issue-thread-interaction-resolution.js"; const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"]; const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500; export const ISSUE_LIST_DEFAULT_LIMIT = 500; export const ISSUE_LIST_MAX_LIMIT = 1000; export const ISSUE_BLOCKER_DIAGNOSTICS_MAX_BLOCKERS = 100; export const ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS = 50; export const ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS = 50; export const ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS = 14; export const ISSUE_SUBTREE_DIAGNOSTICS_MAX_DEPTH = 8; export const ISSUE_SUBTREE_DIAGNOSTICS_MAX_NODES = 100; export const ISSUE_SUBTREE_DIAGNOSTICS_MAX_BLOCKERS_PER_NODE = 20; export const ISSUE_SUBTREE_DIAGNOSTICS_MAX_WAKE_REQUESTS_PER_NODE = 5; export const ISSUE_SUBTREE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS_PER_NODE = 5; const ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE = 500; export const MAX_CHILD_ISSUES_CREATED_BY_HELPER = 25; const MAX_CHILD_COMPLETION_SUMMARIES = 20; const CHILD_COMPLETION_SUMMARY_BODY_MAX_CHARS = 500; // Non-human author sentinels that agents post under. These ARE eligible for // agent-attribution derivation even though `local-board` is also materialized // as a row in the `user` table (it is the implicit board admin). Genuine human // users — real signups with their own ids — are never reattributed. const NON_HUMAN_SENTINEL_AUTHOR_USER_IDS = new Set(["local-board"]); const ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES = 2_000_000; const ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES = 256_000; const ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS = 60_000; const ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS = 8; export const ISSUE_CREATE_IDEMPOTENCY_KEY_RETENTION_DAYS = 7; const ISSUE_CREATE_IDEMPOTENCY_KEY_RETENTION_MS = ISSUE_CREATE_IDEMPOTENCY_KEY_RETENTION_DAYS * 24 * 60 * 60 * 1000; const ISSUE_CREATE_IDEMPOTENCY_KEY_CLEANUP_BATCH_SIZE = 500; const DELETED_ISSUE_COMMENT_BODY = ""; const ISSUE_WAKE_DIAGNOSTICS_ACTIVITY_ACTIONS = ["issue.tree_hold_wakeup_deferred"] as const; function wakeRequestTargetsIssue(issueId: string) { return sql`( ${agentWakeupRequests.payload} ->> 'issueId' = ${issueId} or ${agentWakeupRequests.payload} ->> 'taskId' = ${issueId} or ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'issueId' = ${issueId} or ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'taskId' = ${issueId} )`; } function wakeDiagnosticActivityTargetsIssue(issueId: string) { return sql`( (${activityLog.entityType} = 'issue' and ${activityLog.entityId} = ${issueId}) or ${activityLog.details} ->> 'issueId' = ${issueId} or ${activityLog.details} ->> 'rootIssueId' = ${issueId} )`; } function assertTransition(from: string, to: string) { if (from === to) return; if (!ALL_ISSUE_STATUSES.includes(to)) { throw conflict(`Unknown issue status: ${to}`); } } function applyStatusSideEffects( status: string | undefined, patch: Partial, ): Partial { if (!status) return patch; if (status === "in_progress" && !patch.startedAt) { patch.startedAt = new Date(); } if (status === "done") { patch.completedAt = new Date(); } if (status === "cancelled") { patch.cancelledAt = new Date(); } return patch; } function workspaceWorktreeRequiresProjectDetails() { return { code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE, remediation: WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION, }; } function assertExplicitPinnedWorktreeIssueRunnable(input: { projectId: string | null | undefined; projectWorkspaceId: string | null | undefined; executionWorkspaceId: string | null | undefined; executionWorkspacePreference: string | null | undefined; executionWorkspaceSettings: unknown; }) { const settings = parseIssueExecutionWorkspaceSettings(input.executionWorkspaceSettings); const mode = settings?.mode; if (mode !== "isolated_workspace" && mode !== "operator_branch") return; const resolvedMode = mode as ParsedExecutionWorkspaceMode; if ( isUnrunnableWorktreeCombo({ issue: { projectId: input.projectId ?? null, projectWorkspaceId: input.projectWorkspaceId ?? null, executionWorkspaceId: input.executionWorkspaceId ?? null, executionWorkspacePreference: input.executionWorkspacePreference ?? null, }, resolvedMode, resolvedStrategy: resolvePinnedIssueWorkspaceStrategyType({ mode: resolvedMode, issueSettings: settings, }), hasResolvablePriorSessionWorkspace: false, }) ) { throw unprocessable( WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE, workspaceWorktreeRequiresProjectDetails(), ); } } function readStringFromRecord(record: unknown, key: string) { if (!record || typeof record !== "object") return null; const value = (record as Record)[key]; return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } async function resolveResponsibleUserIdForIssueCreate( reader: DbReader, companyId: string, input: { explicitResponsibleUserId?: string | null; createdByUserId?: string | null; parentId?: string | null; originKind?: string | null; originRunId?: string | null; actorRunId?: string | null; actorResponsibleUserId?: string | null; trustExplicitResponsibleUserId?: boolean; }, ) { const explicitResponsibleUserId = readStringFromRecord(input, "explicitResponsibleUserId"); if (explicitResponsibleUserId && input.trustExplicitResponsibleUserId === true) return explicitResponsibleUserId; if (input.originKind === "routine_execution" && input.originRunId) { const routineRun = await reader .select({ responsibleUserId: routineRuns.responsibleUserId }) .from(routineRuns) .where(and(eq(routineRuns.companyId, companyId), eq(routineRuns.id, input.originRunId))) .then((rows) => rows[0] ?? null); if (routineRun?.responsibleUserId) return routineRun.responsibleUserId; } const actorResponsibleUserId = readStringFromRecord(input, "actorResponsibleUserId"); if (actorResponsibleUserId) return actorResponsibleUserId; if (input.actorRunId) { const actorRun = await reader .select({ responsibleUserId: heartbeatRuns.responsibleUserId }) .from(heartbeatRuns) .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, input.actorRunId))) .then((rows) => rows[0] ?? null); if (actorRun?.responsibleUserId) return actorRun.responsibleUserId; } if (input.parentId) { const parent = await reader .select({ responsibleUserId: issues.responsibleUserId, createdByUserId: issues.createdByUserId, }) .from(issues) .where(and(eq(issues.companyId, companyId), eq(issues.id, input.parentId))) .then((rows) => rows[0] ?? null); if (parent?.responsibleUserId) return parent.responsibleUserId; if (parent?.createdByUserId) return parent.createdByUserId; } return input.createdByUserId ?? null; } function buildReusedExecutionWorkspaceConfigPatchFromIssueSettings( settings: ReturnType, ) { return { environmentId: settings?.environmentId ?? null, provisionCommand: settings?.workspaceStrategy?.provisionCommand ?? null, runtimeProvisionCommand: settings?.workspaceStrategy?.runtimeProvisionCommand ?? null, teardownCommand: settings?.workspaceStrategy?.teardownCommand ?? null, workspaceRuntime: settings?.workspaceRuntime ?? null, }; } // Accepted-plan children are not realized yet, so carry only unresolved // workspace intent and let the first child run render/persist its own branch. function buildPreRealizationExecutionWorkspaceSettings(raw: unknown): Record | null { const settings = parseIssueExecutionWorkspaceSettings(raw, { includeEnvironmentId: true }); if (!settings) return null; const mode = settings.mode && settings.mode !== "inherit" && settings.mode !== "reuse_existing" ? settings.mode : null; const next: Record = {}; if (mode) next.mode = mode; if (settings.environmentId !== undefined) next.environmentId = settings.environmentId; if (settings.workspaceRuntime) next.workspaceRuntime = settings.workspaceRuntime; if (settings.workspaceStrategy) { next.workspaceStrategy = { type: settings.workspaceStrategy.type, ...(settings.workspaceStrategy.baseRef ? { baseRef: settings.workspaceStrategy.baseRef } : {}), ...(settings.workspaceStrategy.branchTemplate ? { branchTemplate: settings.workspaceStrategy.branchTemplate } : {}), ...(settings.workspaceStrategy.worktreeParentDir ? { worktreeParentDir: settings.workspaceStrategy.worktreeParentDir } : {}), ...(settings.workspaceStrategy.provisionCommand ? { provisionCommand: settings.workspaceStrategy.provisionCommand } : {}), ...(settings.workspaceStrategy.runtimeProvisionCommand ? { runtimeProvisionCommand: settings.workspaceStrategy.runtimeProvisionCommand } : {}), ...(settings.workspaceStrategy.teardownCommand ? { teardownCommand: settings.workspaceStrategy.teardownCommand } : {}), }; } return Object.keys(next).length > 0 ? next : null; } function toTimestampMs(value: Date | string | null | undefined) { if (!value) return null; const date = value instanceof Date ? value : new Date(value); const timestamp = date.getTime(); return Number.isFinite(timestamp) ? timestamp : null; } type IssueCommentRunLogAttributionCandidate = { id: string; createdAt: Date | string; authorAgentId?: string | null; authorUserId?: string | null; createdByRunId?: string | null; }; type IssueCommentRunLogAttributionRun = { runId: string; agentId: string; createdAt: Date | string; startedAt?: Date | string | null; finishedAt?: Date | string | null; // Best-effort run log text. May be empty when logs were not read for a tier // that does not need them (run-id / run-window-unique); only the // `run_log_comment_post` tier consults this. logContent: string; }; type DerivedIssueCommentAttribution = { derivedAuthorAgentId: string; derivedCreatedByRunId: string; derivedAuthorSource: IssueCommentDerivedAuthorSource; }; /** * Resolve a `created_by_run_id` safe for the heartbeat_runs FK; returns null for * missing/invalid ids so an unknown run id never 500s a comment insert. */ async function resolveCommentCreatedByRunId( dbOrTx: any, companyId: string, runId: string | null | undefined, ): Promise { const normalized = typeof runId === "string" ? runId.trim() : ""; if (!normalized || !isUuidLike(normalized)) return null; const existing = await dbOrTx .select({ id: heartbeatRuns.id }) .from(heartbeatRuns) .where(and(eq(heartbeatRuns.id, normalized), eq(heartbeatRuns.companyId, companyId))) .then((rows: Array<{ id: string }>) => rows[0] ?? null); return existing?.id ?? null; } async function resolveCommentResponsibleUserId( dbOrTx: any, companyId: string, createdByRunId: string | null, actorResponsibleUserId: string | null | undefined, ): Promise { const actorValue = actorResponsibleUserId?.trim() || null; if (actorValue) return actorValue; if (!createdByRunId) return null; return dbOrTx .select({ responsibleUserId: heartbeatRuns.responsibleUserId }) .from(heartbeatRuns) .where(and(eq(heartbeatRuns.id, createdByRunId), eq(heartbeatRuns.companyId, companyId))) .then((rows: Array<{ responsibleUserId: string | null }>) => rows[0]?.responsibleUserId?.trim() || null); } function withAgentCommentAuthorizationMetadata( metadata: IssueCommentMetadata | null, authorizationReason: string | null | undefined, ): IssueCommentMetadata { const reason = authorizationReason?.trim() || "internal_agent_write"; return { version: 1, ...(metadata?.sourceRunId !== undefined ? { sourceRunId: metadata.sourceRunId } : {}), authorizationReason: reason, sections: metadata?.sections.length ? metadata.sections : [{ title: "Authorization", rows: [{ type: "key_value", label: "Reason", value: reason }], }], }; } /** * Best-effort agent attribution for comments whose stored author is a non-human * sentinel (e.g. `local-board`). Callers MUST pre-filter `comments` to drop any * comment whose `authorUserId` maps to a genuine user profile so a real board / * user comment is never reattributed. * * Only LOSSLESS signals are used — a comment is reattributed solely when a run * provably authored it. Pure run-window timing overlap is intentionally NOT a * signal: because agents post through the `local-board` subprocess, an agent * comment and a genuine human board comment are indistinguishable rows, so any * timing-based guess mis-attributes human board comments that merely coincided * with an agent run (Option A). * * Tiers, in descending confidence (first match wins per comment): * 1. `run_id` — the comment's own `createdByRunId` resolves to an agent run * (lossless: that run authored the comment). * 2. `run_log_comment_post` — an overlapping run log contains the explicit * `comment id: {id}` post marker (lossless: the run recorded posting it). */ export function deriveIssueCommentRunLogAttribution( comments: readonly IssueCommentRunLogAttributionCandidate[], runs: readonly IssueCommentRunLogAttributionRun[], ) { const derivedByCommentId = new Map(); const runById = new Map(runs.map((run) => [run.runId, run] as const)); for (const comment of comments) { if (comment.authorAgentId || !comment.authorUserId) continue; // Tier 1: the comment carries the run that authored it. Lossless even when // the author was recorded as the `local-board` sentinel. if (comment.createdByRunId) { const ownRun = runById.get(comment.createdByRunId); if (ownRun?.agentId) { derivedByCommentId.set(comment.id, { derivedAuthorAgentId: ownRun.agentId, derivedCreatedByRunId: ownRun.runId, derivedAuthorSource: "run_id", }); continue; } } const commentCreatedAtMs = toTimestampMs(comment.createdAt); if (commentCreatedAtMs === null) continue; const overlappingRuns: Array<{ run: IssueCommentRunLogAttributionRun; runEndMs: number }> = []; for (const run of runs) { const runStartMs = toTimestampMs(run.startedAt ?? run.createdAt); const runEndMs = toTimestampMs(run.finishedAt ?? run.createdAt); if (runStartMs === null || runEndMs === null) continue; if ( commentCreatedAtMs < runStartMs || commentCreatedAtMs > runEndMs + ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS ) { continue; } overlappingRuns.push({ run, runEndMs }); } // Tier 2: an overlapping run log explicitly recorded posting this comment. let bestLogMatch: { runId: string; agentId: string; distanceMs: number } | null = null; for (const { run, runEndMs } of overlappingRuns) { if (!run.logContent.includes(`comment id: ${comment.id}`)) continue; const distanceMs = Math.abs(runEndMs - commentCreatedAtMs); if (!bestLogMatch || distanceMs < bestLogMatch.distanceMs) { bestLogMatch = { runId: run.runId, agentId: run.agentId, distanceMs }; } } if (bestLogMatch) { derivedByCommentId.set(comment.id, { derivedAuthorAgentId: bestLogMatch.agentId, derivedCreatedByRunId: bestLogMatch.runId, derivedAuthorSource: "run_log_comment_post", }); continue; } // No lossless signal — leave unresolved. A pure run-window timing overlap is // deliberately NOT enough to reattribute (it cannot tell an agent comment // from a human board comment that happened during the run). } return derivedByCommentId; } // Express's default `qs` parser binds repeated query keys to a `string[]`, // so a request like `?status=todo&status=in_progress` arrives here as an // array. Single-key + comma-separated forms remain valid too; normalize the // supported shapes once so the service contract matches runtime reality. export function parseStatusFilter(input: string | readonly string[] | undefined): string[] { if (input === undefined || input === null) return []; const entries = Array.isArray(input) ? input : typeof input === "string" ? [input] : []; return entries .flatMap((entry) => (typeof entry === "string" ? entry.split(",") : [])) .map((status) => status.trim()) .filter(Boolean); } export interface IssueFilters { attention?: "blocked"; status?: string | readonly string[]; /** * Filter by assignee agent ID. * - `string` (UUID): match issues assigned to that agent. * - `null`: match unassigned issues (IS NULL). * - The literal string `"null"` is also accepted as a sentinel for `null` * so that query-string callers can pass `?assigneeAgentId=null` directly. * The route layer normalises it before calling the service, but the service * also normalises it for direct callers. */ assigneeAgentId?: string | null; participantAgentId?: string; assigneeUserId?: string; touchedByUserId?: string; inboxArchivedByUserId?: string; unreadForUserId?: string; projectId?: string; workspaceId?: string; executionWorkspaceId?: string; parentId?: string; descendantOf?: string; labelId?: string; originKind?: string; originKindPrefix?: string; originId?: string; includeRoutineExecutions?: boolean; excludeRoutineExecutions?: boolean; includePluginOperations?: boolean; includeBlockedBy?: boolean; includeBlockedInboxAttention?: boolean; includeLiveDescendantSummary?: boolean; hasPlanDocument?: boolean; lowTrustBoundary?: LowTrustBoundary & { companyId: string }; q?: string; limit?: number; offset?: number; sortField?: "updated"; sortDir?: "asc" | "desc"; /** ISO 8601 timestamp — only return issues with updatedAt strictly after this value. */ updatedSince?: string; } type IssueRow = typeof issues.$inferSelect; type IssueLabelRow = typeof labels.$inferSelect; type IssuePlanDecompositionRow = typeof issuePlanDecompositions.$inferSelect; type IssueActiveRunRow = { id: string; status: string; agentId: string; invocationSource: string; triggerDetail: string | null; startedAt: Date | null; finishedAt: Date | null; createdAt: Date; }; type IssueScheduledRetryRow = { runId: string; status: "scheduled_retry" | "queued" | "running" | "cancelled"; agentId: string; agentName: string | null; retryOfRunId: string | null; scheduledRetryAt: Date | null; scheduledRetryAttempt: number; scheduledRetryReason: string | null; retryExhaustedReason?: string | null; error?: string | null; errorCode?: string | null; }; type IssueWithLabels = IssueRow & { labels: IssueLabelRow[]; labelIds: string[]; watchdog?: IssueWatchdogSummary | null; }; type IssueWithLabelsAndRun = IssueWithLabels & { activeRun: IssueActiveRunRow | null }; type IssueUserCommentStats = { issueId: string; myLastCommentAt: Date | null; lastExternalCommentAt: Date | null; }; type IssueReadStat = { issueId: string; myLastReadAt: Date | null; }; type IssueLastActivityStat = { issueId: string; latestCommentAt: Date | null; latestLogAt: Date | null; }; function serializeAcceptedPlanDecomposition( decomposition: IssuePlanDecompositionRow, ): AcceptedPlanDecomposition { return { id: decomposition.id, companyId: decomposition.companyId, sourceIssueId: decomposition.sourceIssueId, acceptedPlanRevisionId: decomposition.acceptedPlanRevisionId, acceptedInteractionId: decomposition.acceptedInteractionId, status: decomposition.status as AcceptedPlanDecomposition["status"], requestFingerprint: decomposition.requestFingerprint, // Intentionally omit requestedChildren here; the API only needs stable counts // and child ids, while the durable table keeps the full child draft payload. requestedChildCount: decomposition.requestedChildCount, childIssueIds: normalizeIssuePlanDecompositionChildIds(decomposition.childIssueIds), ownerAgentId: decomposition.ownerAgentId, ownerUserId: decomposition.ownerUserId, ownerRunId: decomposition.ownerRunId, completedAt: decomposition.completedAt, createdAt: decomposition.createdAt, updatedAt: decomposition.updatedAt, }; } type IssueUserContextInput = { createdByUserId: string | null; assigneeUserId: string | null; createdAt: Date | string; updatedAt: Date | string; }; type ProjectGoalReader = Pick; type DbReader = Pick; type IssueCreateInput = Omit & { labelIds?: string[]; blockedByIssueIds?: string[]; inheritExecutionWorkspaceFromIssueId?: string | null; skipExecutionWorkspaceInheritance?: boolean; watchdog?: { agentId: string; instructions?: string | null } | null; watchdogActorRunId?: string | null; actorRunId?: string | null; actorResponsibleUserId?: string | null; trustExplicitResponsibleUserId?: boolean; idempotencyKey?: string | null; allowDuplicate?: boolean; onDeduplicated?: (reason: "idempotency_key" | "recent_open_title") => void; }; type IssueChildCreateInput = IssueCreateInput & { acceptanceCriteria?: string[]; blockParentUntilDone?: boolean; executionWorkspaceInheritanceMode?: "linkage" | "strategy_only"; actorAgentId?: string | null; actorUserId?: string | null; }; type AcceptedPlanDecompositionInput = { acceptedPlanRevisionId: string; children: IssueChildCreateInput[]; actorAgentId?: string | null; actorUserId?: string | null; actorRunId?: string | null; }; type AcceptedPlanDocumentInteraction = { id: string; }; type IssueRelationSummaryMap = { blockedBy: IssueRelationIssueSummary[]; blocks: IssueRelationIssueSummary[]; }; type IssueBlockerDiagnosticsIssueRow = { id: string; companyId: string; projectId: string | null; parentId: string | null; identifier: string | null; title: string; status: typeof ALL_ISSUE_STATUSES[number]; priority: string; assigneeAgentId: string | null; assigneeUserId: string | null; }; type IssueWakeDiagnosticsWakeRequestRow = { agentId: string; source: string; reason: string | null; status: string; coalescedCount: number; runId: string | null; requestedAt: Date; claimedAt: Date | null; finishedAt: Date | null; error: string | null; }; type IssueWakeDiagnosticsActivityRow = { action: string; entityType: string; entityId: string; agentId: string | null; runId: string | null; details: Record | null; createdAt: Date; }; type IssueSubtreeDiagnosticsIssueRow = IssueBlockerDiagnosticsIssueRow & { depth: number; createdAt: Date; updatedAt: Date; }; type IssueSubtreeDiagnosticsBlockerRow = IssueBlockerDiagnosticsIssueRow & { blockedIssueId: string; relationCreatedAt: Date; }; type IssueSubtreeDiagnosticsWakeRequestRow = IssueWakeDiagnosticsWakeRequestRow & { issueId: string; }; type IssueSubtreeDiagnosticsActivityRow = IssueWakeDiagnosticsActivityRow & { issueId: string; }; type IssueSubtreeDiagnosticsBlockerResultRow = IssueSubtreeDiagnosticsBlockerRow & { rowNumber: number | string; }; type IssueSubtreeDiagnosticsWakeRequestResultRow = IssueSubtreeDiagnosticsWakeRequestRow & { rowNumber: number | string; }; type IssueSubtreeDiagnosticsActivityResultRow = IssueSubtreeDiagnosticsActivityRow & { rowNumber: number | string; }; export type IssueDependencyReadiness = { issueId: string; blockerIssueIds: string[]; unresolvedBlockerIssueIds: string[]; unresolvedBlockerCount: number; /** Blockers whose status is `done` but whose execution workspace has not yet finalized. */ pendingFinalizeBlockerIssueIds: string[]; allBlockersDone: boolean; isDependencyReady: boolean; }; export type ChildIssueCompletionSummary = { id: string; identifier: string | null; title: string; status: string; priority: string; assigneeAgentId: string | null; assigneeUserId: string | null; updatedAt: Date; summary: string | null; }; function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) { if (actorRunId) return checkoutRunId === actorRunId; return checkoutRunId == null; } export const TERMINAL_HEARTBEAT_RUN_STATUSES = new Set(["succeeded", "interrupted", "failed", "cancelled", "timed_out"]); const ISSUE_LIST_DESCRIPTION_MAX_CHARS = 1200; const ISSUE_LIST_DESCRIPTION_MAX_BYTES = ISSUE_LIST_DESCRIPTION_MAX_CHARS * 4; function escapeLikePattern(value: string): string { return value.replace(/[\\%_]/g, "\\$&"); } export function clampIssueListLimit(limit: number): number { return Math.min(ISSUE_LIST_MAX_LIMIT, Math.max(1, Math.floor(limit))); } function chunkList(values: T[], size: number): T[][] { const chunks: T[][] = []; for (let index = 0; index < values.length; index += size) { chunks.push(values.slice(index, index + size)); } return chunks; } function truncateInlineSummary(value: string | null | undefined, maxChars = CHILD_COMPLETION_SUMMARY_BODY_MAX_CHARS) { const normalized = value?.trim(); if (!normalized) return null; return normalized.length > maxChars ? `${normalized.slice(0, Math.max(0, maxChars - 15)).trimEnd()} [truncated]` : normalized; } function truncateByCodePoint(value: string, maxChars: number): string { if (value.length <= maxChars) return value; return Array.from(value).slice(0, maxChars).join(""); } function decodeDatabaseTextPreview(value: string | null | undefined, maxChars: number): string | null { if (value == null) return null; return truncateByCodePoint(Buffer.from(value, "base64").toString("utf8"), maxChars); } function appendAcceptanceCriteriaToDescription(description: string | null | undefined, acceptanceCriteria: string[] | undefined) { const criteria = (acceptanceCriteria ?? []).map((item) => item.trim()).filter(Boolean); if (criteria.length === 0) return description ?? null; const base = description?.trim() ?? ""; const criteriaMarkdown = ["## Acceptance Criteria", "", ...criteria.map((item) => `- ${item}`)].join("\n"); return base ? `${base}\n\n${criteriaMarkdown}` : criteriaMarkdown; } function normalizeAcceptedPlanDecompositionFingerprintValue(value: unknown): unknown { if (value === undefined) return null; if ( value == null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" ) { return value; } if (value instanceof Date) return value.toISOString(); if (Array.isArray(value)) { return value.map((item) => normalizeAcceptedPlanDecompositionFingerprintValue(item)); } if (typeof value === "object") { const record = value as Record; return Object.fromEntries( Object.keys(record) .sort() .map((key) => [key, normalizeAcceptedPlanDecompositionFingerprintValue(record[key])]), ); } return String(value); } const ACCEPTED_PLAN_DECOMPOSITION_FINGERPRINT_CHILD_METADATA_KEYS = new Set([ "id", "companyId", "parentId", "identifier", "checkoutRunId", "executionRunId", "executionLockedAt", "startedAt", "completedAt", "cancelledAt", "hiddenAt", "createdAt", "updatedAt", "createdByAgentId", "createdByUserId", "updatedByAgentId", "updatedByUserId", "actorAgentId", "actorUserId", "executionWorkspaceInheritanceMode", "skipExecutionWorkspaceInheritance", ]); function normalizeAcceptedPlanDecompositionFingerprintChild(child: IssueChildCreateInput) { return Object.fromEntries( Object.entries(child).filter(([key]) => !ACCEPTED_PLAN_DECOMPOSITION_FINGERPRINT_CHILD_METADATA_KEYS.has(key)), ); } function createAcceptedPlanDecompositionRequestFingerprint(input: { acceptedPlanRevisionId: string; children: IssueChildCreateInput[]; }) { const canonical = JSON.stringify(normalizeAcceptedPlanDecompositionFingerprintValue({ acceptedPlanRevisionId: input.acceptedPlanRevisionId, children: input.children.map(normalizeAcceptedPlanDecompositionFingerprintChild), })); return createHash("sha256").update(canonical).digest("hex"); } function normalizeIssuePlanDecompositionChildIds(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.filter((item): item is string => typeof item === "string" && item.length > 0); } export function readAcceptedPlanConfirmationTarget(payload: unknown): { revisionId: string; key: string; issueId: string; } | null { if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; const target = (payload as Record).target; if (!target || typeof target !== "object" || Array.isArray(target)) return null; const record = target as Record; if (record.type !== "issue_document") return null; const revisionId = readStringFromRecord(record, "revisionId"); const key = readStringFromRecord(record, "key"); const issueId = readStringFromRecord(record, "issueId"); if (!revisionId || !key || !issueId) return null; return { revisionId, key, issueId }; } async function resolveAcceptedPlanClaimOwner(input: { dbOrTx: Pick; claim: Pick; actorAgentId?: string | null; actorUserId?: string | null; actorRunId?: string | null; }) { const nextOwner = { ownerAgentId: input.actorAgentId ?? null, ownerUserId: input.actorUserId ?? null, ownerRunId: input.actorRunId ?? null, }; if ( input.claim.ownerAgentId === nextOwner.ownerAgentId && input.claim.ownerUserId === nextOwner.ownerUserId && input.claim.ownerRunId === nextOwner.ownerRunId ) { return nextOwner; } if (!input.claim.ownerRunId) { return nextOwner; } const existingOwnerRun = await input.dbOrTx .select({ status: heartbeatRuns.status }) .from(heartbeatRuns) .where(eq(heartbeatRuns.id, input.claim.ownerRunId)) .then((rows) => rows[0] ?? null); if (existingOwnerRun && !TERMINAL_HEARTBEAT_RUN_STATUSES.has(existingOwnerRun.status)) { return { ownerAgentId: input.claim.ownerAgentId, ownerUserId: input.claim.ownerUserId, ownerRunId: input.claim.ownerRunId, }; } return nextOwner; } async function findAcceptedPlanDocumentInteraction( dbOrTx: Pick, input: { companyId: string; sourceIssueId: string; acceptedPlanRevisionId: string; }, ): Promise { const rows = await dbOrTx .select({ id: issueThreadInteractions.id, payload: issueThreadInteractions.payload, }) .from(issueThreadInteractions) .where(and( eq(issueThreadInteractions.companyId, input.companyId), eq(issueThreadInteractions.issueId, input.sourceIssueId), eq(issueThreadInteractions.kind, "request_confirmation"), eq(issueThreadInteractions.status, "accepted"), )) .orderBy(desc(issueThreadInteractions.resolvedAt), desc(issueThreadInteractions.createdAt)); for (const row of rows) { const target = readAcceptedPlanConfirmationTarget(row.payload); if ( target?.issueId === input.sourceIssueId && target.key === "plan" && target.revisionId === input.acceptedPlanRevisionId ) { return { id: row.id }; } } return null; } function createIssueDependencyReadiness(issueId: string): IssueDependencyReadiness { return { issueId, blockerIssueIds: [], unresolvedBlockerIssueIds: [], unresolvedBlockerCount: 0, pendingFinalizeBlockerIssueIds: [], allBlockersDone: true, isDependencyReady: true, }; } /** * Returns the set of execution-workspace ids whose most recent workspace operation * is NOT a successful `workspace_finalize`. These workspaces have either an in-flight * run, a failed finalize, or never reached the finalize barrier — dependents that * read this workspace must wait until finalize succeeds. * * Workspaces with no recorded operations are considered finalized (nothing has * touched them since they were realized). */ export async function listUnfinalizedExecutionWorkspaceIds( dbOrTx: Pick, companyId: string, executionWorkspaceIds: string[], ): Promise> { const unfinalized = new Set(); if (executionWorkspaceIds.length === 0) return unfinalized; // Pull every workspace op for the candidate workspaces and pick the latest per // workspace in memory. Per-workspace LATERAL queries would be tighter, but the // candidate set is tiny in practice (one workspace per blocker per readiness call). const rows = await dbOrTx .select({ executionWorkspaceId: workspaceOperations.executionWorkspaceId, phase: workspaceOperations.phase, status: workspaceOperations.status, startedAt: workspaceOperations.startedAt, }) .from(workspaceOperations) .where( and( eq(workspaceOperations.companyId, companyId), inArray(workspaceOperations.executionWorkspaceId, executionWorkspaceIds), ), ); const latestByWorkspace = new Map(); for (const row of rows) { if (!row.executionWorkspaceId) continue; const current = latestByWorkspace.get(row.executionWorkspaceId); if (!current || row.startedAt > current.startedAt) { latestByWorkspace.set(row.executionWorkspaceId, { phase: row.phase, status: row.status, startedAt: row.startedAt, }); } } for (const workspaceId of executionWorkspaceIds) { const latest = latestByWorkspace.get(workspaceId); if (!latest) continue; // no ops recorded → treat as finalized if (latest.phase === "workspace_finalize" && latest.status === "succeeded") continue; unfinalized.add(workspaceId); } return unfinalized; } async function listPendingFinalizeBlockerIssueIds( dbOrTx: Pick, companyId: string, blockerWorkspacePairs: Array<{ blockerIssueId: string; executionWorkspaceId: string }>, ): Promise> { const pending = new Set(); const blockerIssueIds = [...new Set(blockerWorkspacePairs.map((pair) => pair.blockerIssueId))]; const executionWorkspaceIds = [...new Set(blockerWorkspacePairs.map((pair) => pair.executionWorkspaceId))]; if (blockerIssueIds.length === 0 || executionWorkspaceIds.length === 0) return pending; const blockerWorkspaceKeys = new Set( blockerWorkspacePairs.map((pair) => `${pair.blockerIssueId}:${pair.executionWorkspaceId}`), ); const rows = await dbOrTx .select({ issueId: workspaceOperations.issueId, executionWorkspaceId: workspaceOperations.executionWorkspaceId, phase: workspaceOperations.phase, status: workspaceOperations.status, startedAt: workspaceOperations.startedAt, }) .from(workspaceOperations) .where( and( eq(workspaceOperations.companyId, companyId), inArray(workspaceOperations.executionWorkspaceId, executionWorkspaceIds), ), ); const latestAttributedByBlockerWorkspace = new Map(); const latestUnattributedByWorkspace = new Map(); const latestSuccessfulFinalizeByWorkspace = new Map(); for (const row of rows) { if (!row.executionWorkspaceId) continue; if (row.phase === "workspace_finalize" && row.status === "succeeded") { const current = latestSuccessfulFinalizeByWorkspace.get(row.executionWorkspaceId); if (!current || row.startedAt > current) { latestSuccessfulFinalizeByWorkspace.set(row.executionWorkspaceId, row.startedAt); } } if (row.issueId) { const key = `${row.issueId}:${row.executionWorkspaceId}`; if (!blockerWorkspaceKeys.has(key)) continue; const current = latestAttributedByBlockerWorkspace.get(key); if (!current || row.startedAt > current.startedAt) { latestAttributedByBlockerWorkspace.set(key, { phase: row.phase, status: row.status, startedAt: row.startedAt, }); } continue; } const current = latestUnattributedByWorkspace.get(row.executionWorkspaceId); if (!current || row.startedAt > current.startedAt) { latestUnattributedByWorkspace.set(row.executionWorkspaceId, { phase: row.phase, status: row.status, startedAt: row.startedAt, }); } } for (const pair of blockerWorkspacePairs) { const latest = latestAttributedByBlockerWorkspace.get(`${pair.blockerIssueId}:${pair.executionWorkspaceId}`) ?? latestUnattributedByWorkspace.get(pair.executionWorkspaceId); if (!latest) continue; // no ops recorded -> nothing to finalize for this blocker if (latest.phase === "workspace_finalize" && latest.status === "succeeded") continue; const laterSuccessfulFinalize = latestSuccessfulFinalizeByWorkspace.get(pair.executionWorkspaceId); if (laterSuccessfulFinalize && laterSuccessfulFinalize > latest.startedAt) continue; pending.add(pair.blockerIssueId); } return pending; } /** * Whether a heartbeat run has reached a terminal state or no longer exists. * A terminal/missing run can make no further progress on its execution * workspace, so callers must not wait on it to advance an in-flight operation. */ export async function heartbeatRunIsTerminalOrMissing( dbOrTx: Pick, runId: string, ): Promise { const run = await dbOrTx .select({ status: heartbeatRuns.status }) .from(heartbeatRuns) .where(eq(heartbeatRuns.id, runId)) .then((rows: Array<{ status: string }>) => rows[0] ?? null); if (!run) return true; return TERMINAL_HEARTBEAT_RUN_STATUSES.has(run.status); } /** * Returns whether a specific run's sync-back on a specific execution workspace * has settled — i.e. the accept/review gates that guard against a still-in-flight * worktree sync no longer need to block on this run. * * Semantics: * - No operations recorded → settled. The run never touched the workspace state * the gates protect. * - Earlier phases recorded but no `workspace_finalize` yet → NOT settled. The * sync-back hasn't been attempted; the gate should wait for it. * - Latest `workspace_finalize` reached a terminal status (`succeeded`, `failed`, * or `skipped`) → settled. A finalize that ran and finished is done even if it * failed: it will not retry within this run, so continuing to block would wedge * the gate forever — a failed sync-back must not permanently block a * confirmation accept behind a misleading "still syncing" error. * - Latest `workspace_finalize` is still `running` → in flight, so NOT settled — * unless the owning run has itself ended, in which case the `running` record is * stale (the process died mid-finalize) and we treat it as settled rather than * wait on a run that can never make progress. */ export async function runWorkspaceIsFinalized( dbOrTx: Pick, companyId: string, executionWorkspaceId: string, runId: string, ): Promise { const rows = await dbOrTx .select({ phase: workspaceOperations.phase, status: workspaceOperations.status, startedAt: workspaceOperations.startedAt, }) .from(workspaceOperations) .where( and( eq(workspaceOperations.companyId, companyId), eq(workspaceOperations.executionWorkspaceId, executionWorkspaceId), eq(workspaceOperations.heartbeatRunId, runId), ), ); if (rows.length === 0) return true; let latestFinalize: { status: string; startedAt: Date } | null = null; for (const row of rows) { if (row.phase !== "workspace_finalize") continue; if (!latestFinalize || row.startedAt > latestFinalize.startedAt) latestFinalize = row; } // The run touched the workspace but hasn't reached the sync-back phase yet. if (!latestFinalize) return false; // A finalize that reached any terminal status is settled — including `failed` // and `skipped`. It will not retry within this run, so gates must stop waiting. if (latestFinalize.status !== "running") return true; // Finalize is still marked `running`. It is only genuinely in flight while the // owning run is alive; a `running` record left behind by an ended run is stale. return heartbeatRunIsTerminalOrMissing(dbOrTx, runId); } async function listIssueDependencyReadinessMap( dbOrTx: Pick, companyId: string, issueIds: string[], ) { const uniqueIssueIds = [...new Set(issueIds.filter(Boolean))]; const readinessMap = new Map(); for (const issueId of uniqueIssueIds) { readinessMap.set(issueId, createIssueDependencyReadiness(issueId)); } if (uniqueIssueIds.length === 0) return readinessMap; const blockerRows = await dbOrTx .select({ issueId: issueRelations.relatedIssueId, blockerIssueId: issueRelations.issueId, blockerStatus: issues.status, blockerExecutionWorkspaceId: issues.executionWorkspaceId, }) .from(issueRelations) .innerJoin(issues, eq(issueRelations.issueId, issues.id)) .where( and( eq(issueRelations.companyId, companyId), eq(issueRelations.type, "blocks"), inArray(issueRelations.relatedIssueId, uniqueIssueIds), ), ); // Collect issue/workspace pairs of "done" blockers — these are the only ones // subject to the workspace-finalize barrier. Blockers that aren't done already // mark the dependent as not-ready and don't need a finalize check. const doneBlockerWorkspacePairs: Array<{ blockerIssueId: string; executionWorkspaceId: string }> = []; for (const row of blockerRows) { if (row.blockerStatus === "done" && row.blockerExecutionWorkspaceId) { doneBlockerWorkspacePairs.push({ blockerIssueId: row.blockerIssueId, executionWorkspaceId: row.blockerExecutionWorkspaceId, }); } } const pendingFinalizeBlockerIssueIds = await listPendingFinalizeBlockerIssueIds( dbOrTx, companyId, doneBlockerWorkspacePairs, ); for (const row of blockerRows) { const current = readinessMap.get(row.issueId) ?? createIssueDependencyReadiness(row.issueId); current.blockerIssueIds.push(row.blockerIssueId); // Only done blockers resolve dependents; cancelled blockers stay unresolved // until an operator removes or replaces the blocker relationship explicitly. if (row.blockerStatus !== "done") { current.unresolvedBlockerIssueIds.push(row.blockerIssueId); current.unresolvedBlockerCount += 1; current.allBlockersDone = false; current.isDependencyReady = false; } else if ( row.blockerExecutionWorkspaceId && pendingFinalizeBlockerIssueIds.has(row.blockerIssueId) ) { // Workspace-finalize barrier: the blocker's most recent run on its // execution workspace hasn't recorded a successful workspace_finalize. // Treat the dependent as not-ready until sync-back lands (or the run // finalizes); a subsequent finalize wake will re-evaluate readiness. // `allBlockersDone` is cleared too so that callers using it as a // proxy for "this dependent can proceed" still see the gate. current.unresolvedBlockerIssueIds.push(row.blockerIssueId); current.unresolvedBlockerCount += 1; current.pendingFinalizeBlockerIssueIds.push(row.blockerIssueId); current.allBlockersDone = false; current.isDependencyReady = false; } readinessMap.set(row.issueId, current); } return readinessMap; } async function listUnresolvedBlockerDetails( dbOrTx: Pick, companyId: string, unresolvedBlockerIssueIds: string[], pendingFinalizeBlockerIssueIds: string[] = [], ) { if (unresolvedBlockerIssueIds.length === 0) return []; const pendingFinalizeIds = new Set(pendingFinalizeBlockerIssueIds); const rows = await dbOrTx .select({ issueId: issues.id, identifier: issues.identifier, title: issues.title, }) .from(issues) .where(and(eq(issues.companyId, companyId), inArray(issues.id, unresolvedBlockerIssueIds))); const rowsById = new Map(rows.map((row) => [row.issueId, row])); return unresolvedBlockerIssueIds.map((issueId) => { const row = rowsById.get(issueId); return { issueId, identifier: row?.identifier ?? null, title: row?.title ?? null, reason: pendingFinalizeIds.has(issueId) ? "pending_finalize" as const : "not_done" as const, }; }); } async function listUnresolvedBlockerIssueIds( dbOrTx: Pick, companyId: string, blockerIssueIds: string[], ) { const uniqueBlockerIssueIds = [...new Set(blockerIssueIds.filter(Boolean))]; if (uniqueBlockerIssueIds.length === 0) return []; return dbOrTx .select({ id: issues.id }) .from(issues) .where( and( eq(issues.companyId, companyId), inArray(issues.id, uniqueBlockerIssueIds), // Cancelled blockers intentionally remain unresolved until the relation changes. ne(issues.status, "done"), ), ) .then((rows) => rows.map((row) => row.id)); } async function getProjectDefaultGoalId( db: ProjectGoalReader, companyId: string, projectId: string | null | undefined, ) { if (!projectId) return null; const row = await db .select({ goalId: projects.goalId }) .from(projects) .where(and(eq(projects.id, projectId), eq(projects.companyId, companyId))) .then((rows) => rows[0] ?? null); return row?.goalId ?? null; } async function getWorkspaceInheritanceIssue( db: DbReader, companyId: string, issueId: string, ) { const issue = await db .select({ id: issues.id, projectId: issues.projectId, projectWorkspaceId: issues.projectWorkspaceId, executionWorkspaceId: issues.executionWorkspaceId, executionWorkspaceSettings: issues.executionWorkspaceSettings, }) .from(issues) .where(and(eq(issues.id, issueId), eq(issues.companyId, companyId))) .then((rows) => rows[0] ?? null); if (!issue) { throw notFound("Workspace inheritance issue not found"); } return issue; } // Mine participation fails closed. Add new user-authored issue mutation actions // here instead of admitting every issue activity, because reads, previews, and // denied resource requests are audited too. const ISSUE_USER_PARTICIPATION_ACTIVITY_ACTIONS = [ "issue.accepted_plan_decomposition_updated", "issue.admin_force_release", "issue.approval_linked", "issue.approval_unlinked", "issue.approvers_updated", "issue.assigned", "issue.attachment_added", "issue.attachment_removed", "issue.blockers.updated", "issue.blockers_updated", "issue.checked_out", "issue.checkout", "issue.child_created", "issue.comment_cancelled", "issue.document_annotation_comment_added", "issue.document_annotation_remapped", "issue.document_annotation_thread_created", "issue.document_annotation_thread_resolved", "issue.document_deleted", "issue.document_locked", "issue.document_restored", "issue.document_unlocked", "issue.document_updated", "issue.document_upserted", "issue.feedback_vote_saved", "issue.inbox_touched", "issue.low_trust_output_promoted", "issue.monitor_cleared", "issue.monitor_scheduled", "issue.recovery_action_resolved", "issue.relations.updated", "issue.released", "issue.reviewers_updated", "issue.scheduled_retry_retry_now", "issue.successful_run_handoff_resolved", "issue.task_watchdog_fingerprint_reviewed", "issue.thread_interaction_accepted", "issue.thread_interaction_answered", "issue.thread_interaction_cancelled", "issue.thread_interaction_created", "issue.thread_interaction_item_verdicts_submitted", "issue.thread_interaction_withdrawn", "issue.tree_cancel_status_updated", "issue.tree_hold_created", "issue.tree_hold_released", "issue.tree_restore_status_updated", "issue.updated", "issue.watchdog_created", "issue.watchdog_removed", "issue.work_product_created", "issue.work_product_deleted", "issue.work_product_updated", ] as const; function touchedByUserCondition(companyId: string, userId: string) { return sql` ( ${issues.createdByUserId} = ${userId} OR ${issues.assigneeUserId} = ${userId} OR EXISTS ( SELECT 1 FROM ${activityLog} WHERE ${activityLog.entityType} = 'issue' AND ${activityLog.entityId} = ${issues.id}::text AND ${activityLog.companyId} = ${companyId} AND ${activityLog.actorType} = 'user' AND ${activityLog.actorId} = ${userId} AND ${activityLog.action} IN (${sql.join( ISSUE_USER_PARTICIPATION_ACTIVITY_ACTIONS.map((action) => sql`${action}`), sql`, `, )}) ) OR EXISTS ( SELECT 1 FROM ${issueComments} WHERE ${issueComments.issueId} = ${issues.id} AND ${issueComments.companyId} = ${companyId} AND ${issueComments.authorUserId} = ${userId} ) ) `; } function participatedByAgentCondition(companyId: string, agentId: string) { return sql` ( ${issues.createdByAgentId} = ${agentId} OR ${issues.assigneeAgentId} = ${agentId} OR EXISTS ( SELECT 1 FROM ${issueComments} WHERE ${issueComments.issueId} = ${issues.id} AND ${issueComments.companyId} = ${companyId} AND ${issueComments.authorAgentId} = ${agentId} ) OR EXISTS ( SELECT 1 FROM ${activityLog} WHERE ${activityLog.companyId} = ${companyId} AND ${activityLog.entityType} = 'issue' AND ${activityLog.entityId} = ${issues.id}::text AND ${activityLog.agentId} = ${agentId} ) ) `; } function myLastCommentAtExpr(companyId: string, userId: string) { return sql` ( SELECT MAX(${issueComments.createdAt}) FROM ${issueComments} WHERE ${issueComments.issueId} = ${issues.id} AND ${issueComments.companyId} = ${companyId} AND ${issueComments.authorUserId} = ${userId} ) `; } function myLastReadAtExpr(companyId: string, userId: string) { return sql` ( SELECT MAX(${issueReadStates.lastReadAt}) FROM ${issueReadStates} WHERE ${issueReadStates.issueId} = ${issues.id} AND ${issueReadStates.companyId} = ${companyId} AND ${issueReadStates.userId} = ${userId} ) `; } function myLastTouchAtExpr(companyId: string, userId: string) { const myLastCommentAt = myLastCommentAtExpr(companyId, userId); const myLastReadAt = myLastReadAtExpr(companyId, userId); return sql` GREATEST( COALESCE(${myLastCommentAt}, to_timestamp(0)), COALESCE(${myLastReadAt}, to_timestamp(0)), COALESCE(CASE WHEN ${issues.createdByUserId} = ${userId} THEN ${issues.createdAt} ELSE NULL END, to_timestamp(0)), COALESCE(CASE WHEN ${issues.assigneeUserId} = ${userId} THEN ${issues.updatedAt} ELSE NULL END, to_timestamp(0)) ) `; } const ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS = [ "issue.read_marked", "issue.read_unmarked", "issue.inbox_archived", "issue.inbox_unarchived", ] as const; function issueLatestCommentAtExpr(companyId: string) { return sql` ( SELECT MAX(${issueComments.createdAt}) FROM ${issueComments} WHERE ${issueComments.issueId} = ${issues.id} AND ${issueComments.companyId} = ${companyId} ) `; } function issueLatestLogAtExpr(companyId: string) { return sql` ( SELECT MAX(${activityLog.createdAt}) FROM ${activityLog} WHERE ${activityLog.companyId} = ${companyId} AND ${activityLog.entityType} = 'issue' AND ${activityLog.entityId} = ${issues.id}::text AND ${activityLog.action} NOT IN (${sql.join( ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map((action) => sql`${action}`), sql`, `, )}) ) `; } function issueCanonicalLastActivityAtExpr(companyId: string) { const latestCommentAt = issueLatestCommentAtExpr(companyId); const latestLogAt = issueLatestLogAtExpr(companyId); return sql` GREATEST( ${issues.updatedAt}, COALESCE(${latestCommentAt}, to_timestamp(0)), COALESCE(${latestLogAt}, to_timestamp(0)) ) `; } function unreadForUserCondition(companyId: string, userId: string) { const touchedCondition = touchedByUserCondition(companyId, userId); const myLastTouchAt = myLastTouchAtExpr(companyId, userId); return sql` ( ${touchedCondition} AND EXISTS ( SELECT 1 FROM ${issueComments} WHERE ${issueComments.issueId} = ${issues.id} AND ${issueComments.companyId} = ${companyId} AND ( ${issueComments.authorUserId} IS NULL OR ${issueComments.authorUserId} <> ${userId} ) AND ${issueComments.createdAt} > ${myLastTouchAt} ) ) `; } function inboxVisibleForUserCondition(companyId: string, userId: string) { return sql` NOT EXISTS ( SELECT 1 FROM ${issueInboxArchives} WHERE ${issueInboxArchives.issueId} = ${issues.id} AND ${issueInboxArchives.companyId} = ${companyId} AND ${issueInboxArchives.userId} = ${userId} AND NOT ( EXISTS ( SELECT 1 FROM ${issueThreadInteractions} WHERE ${issueThreadInteractions.issueId} = ${issues.id} AND ${issueThreadInteractions.companyId} = ${companyId} AND ${issueThreadInteractions.kind} IN ( 'suggest_tasks', 'ask_user_questions', 'request_confirmation' ) AND ${issueThreadInteractions.createdAt} > ${issueInboxArchives.archivedAt} ) OR EXISTS ( SELECT 1 FROM ${activityLog} WHERE ${activityLog.companyId} = ${companyId} AND ${activityLog.entityType} = 'issue' AND ${activityLog.entityId} = ${issues.id}::text AND ${activityLog.action} = 'issue.updated' AND ${activityLog.createdAt} > ${issueInboxArchives.archivedAt} AND ${activityLog.details}->>'status' IN ('in_review', 'blocked', 'done') AND ${activityLog.details}->'_previous'->>'status' IS DISTINCT FROM ${activityLog.details}->>'status' AND NOT ( ${activityLog.details}->>'status' = 'done' AND ${issues.completedAt} IS NOT NULL AND ${issueInboxArchives.archivedAt} >= ${issues.completedAt} ) ) OR EXISTS ( SELECT 1 FROM ${issueComments} WHERE ${issueComments.issueId} = ${issues.id} AND ${issueComments.companyId} = ${companyId} AND ${issueComments.createdAt} > ${issueInboxArchives.archivedAt} AND ${issueComments.deletedAt} IS NULL AND ( ( ${issueComments.authorUserId} IS NOT NULL AND ${issueComments.authorUserId} <> ${userId} AND ${issueComments.authorAgentId} IS NULL AND ${issueComments.derivedAuthorAgentId} IS NULL ) OR POSITION(${`](user://${userId})`} IN ${issueComments.body}) > 0 ) ) ) ) `; } const LEGACY_PLUGIN_OPERATION_ORIGIN_KINDS = [ "plugin:paperclipai.content-machine:case", "plugin:paperclipai.content-machine:evaluation", "plugin:paperclipai.content-machine:source-sync", ] as const; function nonPluginOperationIssueCondition() { return sql`NOT ( ${issues.originKind} LIKE 'plugin:%:operation' OR ${issues.originKind} LIKE 'plugin:%:operation:%' OR ${inArray(issues.originKind, LEGACY_PLUGIN_OPERATION_ORIGIN_KINDS)} )`; } function shouldIncludePluginOperationIssues(filters: IssueFilters | undefined) { return Boolean( filters?.includePluginOperations || filters?.originKind || filters?.originKindPrefix || filters?.originId || filters?.projectId, ); } export function deriveIssueUserContext( issue: IssueUserContextInput, userId: string, stats: | { myLastCommentAt: Date | string | null; myLastReadAt: Date | string | null; lastExternalCommentAt: Date | string | null; } | null | undefined, ) { const normalizeDate = (value: Date | string | null | undefined) => { if (!value) return null; if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? null : parsed; }; const myLastCommentAt = normalizeDate(stats?.myLastCommentAt); const myLastReadAt = normalizeDate(stats?.myLastReadAt); const createdTouchAt = issue.createdByUserId === userId ? normalizeDate(issue.createdAt) : null; const assignedTouchAt = issue.assigneeUserId === userId ? normalizeDate(issue.updatedAt) : null; const myLastTouchAt = [myLastCommentAt, myLastReadAt, createdTouchAt, assignedTouchAt] .filter((value): value is Date => value instanceof Date) .sort((a, b) => b.getTime() - a.getTime())[0] ?? null; const lastExternalCommentAt = normalizeDate(stats?.lastExternalCommentAt); const isUnreadForMe = Boolean( myLastTouchAt && lastExternalCommentAt && lastExternalCommentAt.getTime() > myLastTouchAt.getTime(), ); return { myLastTouchAt, lastExternalCommentAt, isUnreadForMe, }; } function latestIssueActivityAt(...values: Array): Date | null { const normalized = values .map((value) => { if (!value) return null; if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? null : parsed; }) .filter((value): value is Date => value instanceof Date) .sort((a, b) => b.getTime() - a.getTime()); return normalized[0] ?? null; } type InboxArchiveAttributionRow = { issueId: string; archivedAt: Date; archivedByActorType: "user" | "agent"; archivedByAgentId: string | null; archivedByRunId: string | null; }; async function inboxArchiveRowsForIssues( dbOrTx: Db, companyId: string, userId: string, issueIds: string[], ): Promise { if (issueIds.length === 0) return []; return dbOrTx .select({ issueId: issueInboxArchives.issueId, archivedAt: issueInboxArchives.archivedAt, archivedByActorType: issueInboxArchives.archivedByActorType, archivedByAgentId: issueInboxArchives.archivedByAgentId, archivedByRunId: issueInboxArchives.archivedByRunId, }) .from(issueInboxArchives) .where(and( eq(issueInboxArchives.companyId, companyId), eq(issueInboxArchives.userId, userId), inArray(issueInboxArchives.issueId, issueIds), )); } function activeInboxArchiveFields( archive: InboxArchiveAttributionRow | undefined, lastActivityAt: Date, ) { if (!archive || archive.archivedAt.getTime() < lastActivityAt.getTime()) return {}; return { archivedAt: archive.archivedAt, archivedByActorType: archive.archivedByActorType, archivedByAgentId: archive.archivedByAgentId, archivedByRunId: archive.archivedByRunId, }; } function issueListOrderBy( companyId: string, { hasSearch, priorityOrder, searchOrder, sortField, sortDir, }: { hasSearch: boolean; priorityOrder: SQL; searchOrder: SQL; sortField?: IssueFilters["sortField"]; sortDir?: IssueFilters["sortDir"]; }, ) { const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId); if (sortField === "updated") { const activityOrder = sortDir === "asc" ? asc(canonicalLastActivityAt) : desc(canonicalLastActivityAt); const updatedOrder = sortDir === "asc" ? asc(issues.updatedAt) : desc(issues.updatedAt); const idOrder = sortDir === "asc" ? asc(issues.id) : desc(issues.id); return hasSearch ? [asc(searchOrder), activityOrder, updatedOrder, idOrder] : [activityOrder, updatedOrder, idOrder]; } return [ hasSearch ? asc(searchOrder) : asc(priorityOrder), asc(priorityOrder), desc(canonicalLastActivityAt), desc(issues.updatedAt), desc(issues.id), ]; } async function labelMapForIssues(dbOrTx: any, issueIds: string[]): Promise> { const map = new Map(); if (issueIds.length === 0) return map; for (const issueIdChunk of chunkList(issueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { const rows = await dbOrTx .select({ issueId: issueLabels.issueId, label: labels, }) .from(issueLabels) .innerJoin(labels, eq(issueLabels.labelId, labels.id)) .where(inArray(issueLabels.issueId, issueIdChunk)) .orderBy(asc(labels.name), asc(labels.id)); for (const row of rows) { const existing = map.get(row.issueId); if (existing) existing.push(row.label); else map.set(row.issueId, [row.label]); } } return map; } async function withIssueLabels(dbOrTx: any, rows: IssueRow[]): Promise { if (rows.length === 0) return []; const issueIds = rows.map((row) => row.id); const [labelsByIssueId, watchdogByIssueId] = await Promise.all([ labelMapForIssues(dbOrTx, issueIds), watchdogMapForIssues(dbOrTx, rows), ]); return rows.map((row) => { const issueLabels = labelsByIssueId.get(row.id) ?? []; return { ...row, labels: issueLabels, labelIds: issueLabels.map((label) => label.id), watchdog: watchdogByIssueId.get(row.id) ?? null, }; }); } async function watchdogMapForIssues(dbOrTx: any, rows: IssueRow[]): Promise> { const map = new Map(); if (rows.length === 0) return map; const byCompany = new Map(); for (const row of rows) { const ids = byCompany.get(row.companyId) ?? []; ids.push(row.id); byCompany.set(row.companyId, ids); } for (const [companyId, issueIds] of byCompany.entries()) { for (const issueIdChunk of chunkList([...new Set(issueIds)], ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { const watchdogRows = await dbOrTx .select() .from(issueWatchdogs) .where(and( eq(issueWatchdogs.companyId, companyId), inArray(issueWatchdogs.issueId, issueIdChunk), eq(issueWatchdogs.status, "active"), )); for (const row of watchdogRows) { map.set(row.issueId, summarizeIssueWatchdog(row)); } } } return map; } const ACTIVE_RUN_STATUSES = ["queued", "running"]; const BLOCKER_ATTENTION_ACTIVE_RUN_STATUSES = ["queued", "running"]; const BLOCKER_ATTENTION_ACTIVE_WAKE_STATUSES = ["queued", "deferred_issue_execution"]; const BLOCKER_ATTENTION_PENDING_INTERACTION_STATUSES = ["pending"]; const BLOCKER_ATTENTION_PENDING_APPROVAL_STATUSES = ["pending", "revision_requested"]; const BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND = "harness_liveness_escalation"; const BLOCKER_ATTENTION_CHILD_TERMINAL_STATUSES = ["done", "cancelled"]; const PRODUCTIVITY_REVIEW_ORIGIN_KIND = "issue_productivity_review"; const PRODUCTIVITY_REVIEW_TERMINAL_STATUSES = ["done", "cancelled"]; const PRODUCTIVITY_REVIEW_ACTIVITY_ACTIONS = [ "issue.productivity_review_created", "issue.productivity_review_updated", ]; const PRODUCTIVITY_REVIEW_TRIGGERS: readonly IssueProductivityReviewTrigger[] = [ "no_comment_streak", "long_active_duration", "high_churn", ]; function lowTrustBoundaryIssueCondition( companyId: string, boundary: (LowTrustBoundary & { companyId: string }) | null | undefined, ) { if (!boundary || boundary.companyId !== companyId) return null; const clauses: SQL[] = []; const issueIds = [...new Set(boundary.issueIds ?? [])]; const projectIds = [...new Set(boundary.projectIds ?? [])]; if (issueIds.length > 0) clauses.push(inArray(issues.id, issueIds)); if (projectIds.length > 0) clauses.push(inArray(issues.projectId, projectIds)); if (boundary.rootIssueId) { clauses.push(sql` ${issues.id} IN ( WITH RECURSIVE descendants(id) AS ( SELECT ${issues.id} FROM ${issues} WHERE ${issues.companyId} = ${companyId} AND ${issues.id} = ${boundary.rootIssueId} UNION SELECT ${issues.id} FROM ${issues} JOIN descendants ON ${issues.parentId} = descendants.id WHERE ${issues.companyId} = ${companyId} ) SELECT id FROM descendants ) `); } if (clauses.length === 0) return sql`false`; return or(...clauses); } const BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES = ["done", "cancelled"]; export const BLOCKER_ATTENTION_MAX_DEPTH = 8; export const BLOCKER_ATTENTION_MAX_NODES = 2000; const BLOCKER_ATTENTION_INVOKABLE_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]); type IssueBlockerAttentionNode = { id: string; companyId: string; parentId: string | null; identifier: string | null; title: string; status: string; executionRunId?: string | null; assigneeAgentId: string | null; assigneeUserId: string | null; }; type IssueBlockerAttentionInputNode = Pick< IssueBlockerAttentionNode, "id" | "companyId" | "parentId" | "identifier" | "title" | "status" | "assigneeAgentId" | "assigneeUserId" > & { executionRunId?: string | null }; type IssueBlockerAttentionEdge = { issueId: string; blockerIssueId: string; }; type IssueBlockerAttentionQueryRow = IssueBlockerAttentionNode & { issueId: string | null; blockerIssueId: string; }; type IssueBlockerAttentionActivePathRow = { issueId: string | null; }; type IssueBlockerAttentionAgentRow = { id: string; companyId: string; status: string; }; async function activeRunMapForIssues( dbOrTx: any, issueRows: IssueWithLabels[], ): Promise> { const map = new Map(); const runIds = issueRows .map((row) => row.executionRunId) .filter((id): id is string => id != null); if (runIds.length === 0) return map; for (const runIdChunk of chunkList([...new Set(runIds)], ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { const rows = await dbOrTx .select({ id: heartbeatRuns.id, status: heartbeatRuns.status, agentId: heartbeatRuns.agentId, invocationSource: heartbeatRuns.invocationSource, triggerDetail: heartbeatRuns.triggerDetail, startedAt: heartbeatRuns.startedAt, finishedAt: heartbeatRuns.finishedAt, createdAt: heartbeatRuns.createdAt, }) .from(heartbeatRuns) .where( and( inArray(heartbeatRuns.id, runIdChunk), inArray(heartbeatRuns.status, ACTIVE_RUN_STATUSES), ), ); for (const row of rows) { map.set(row.id, row); } } return map; } async function liveDescendantCountMapForIssues( dbOrTx: any, companyId: string, issueIds: string[], ): Promise> { const uniqueIssueIds = [...new Set(issueIds)]; const map = new Map(); if (uniqueIssueIds.length === 0) return map; for (const issueIdChunk of chunkList(uniqueIssueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { const targetRows = issueIdChunk.map((issueId) => sql`(${issueId}::uuid)`); const rows = await dbOrTx.execute(sql<{ issueId: string; liveDescendantCount: number; }>` WITH RECURSIVE target_issues(issue_id) AS ( VALUES ${sql.join(targetRows, sql`, `)} ), live_issues(live_issue_id, parent_id) AS ( SELECT DISTINCT live_issue.id, live_issue.parent_id FROM issues live_issue JOIN heartbeat_runs live_run ON live_run.id = live_issue.execution_run_id WHERE live_issue.company_id = ${companyId} AND live_issue.hidden_at IS NULL AND live_issue.harness_kind IS NULL AND live_run.company_id = ${companyId} AND live_run.status IN ('queued', 'running') UNION SELECT DISTINCT live_issue.id, live_issue.parent_id FROM heartbeat_runs live_run JOIN issues live_issue ON live_issue.id::text = (live_run.context_snapshot ->> 'issueId') WHERE live_issue.company_id = ${companyId} AND live_issue.hidden_at IS NULL AND live_issue.harness_kind IS NULL AND live_run.company_id = ${companyId} AND live_run.status IN ('queued', 'running') ), live_ancestors(live_issue_id, ancestor_id, next_parent_id, visited_issue_ids) AS ( SELECT live_issues.live_issue_id, parent.id, parent.parent_id, ARRAY[live_issues.live_issue_id, parent.id] FROM live_issues JOIN issues parent ON parent.id = live_issues.parent_id WHERE parent.company_id = ${companyId} AND parent.hidden_at IS NULL AND parent.harness_kind IS NULL UNION ALL SELECT live_ancestors.live_issue_id, parent.id, parent.parent_id, live_ancestors.visited_issue_ids || parent.id FROM live_ancestors JOIN issues parent ON parent.id = live_ancestors.next_parent_id WHERE parent.company_id = ${companyId} AND parent.hidden_at IS NULL AND parent.harness_kind IS NULL AND NOT parent.id = ANY(live_ancestors.visited_issue_ids) ) SELECT live_ancestors.ancestor_id::text AS "issueId", count(DISTINCT live_ancestors.live_issue_id)::int AS "liveDescendantCount" FROM live_ancestors JOIN target_issues ON target_issues.issue_id = live_ancestors.ancestor_id WHERE live_ancestors.ancestor_id <> live_ancestors.live_issue_id GROUP BY live_ancestors.ancestor_id `); const resultRows = Array.isArray(rows) ? rows : Array.from(rows as Iterable); for (const row of resultRows) { if (typeof row !== "object" || row === null) continue; const issueId = (row as { issueId?: unknown }).issueId; const liveDescendantCount = (row as { liveDescendantCount?: unknown }).liveDescendantCount; if (typeof issueId !== "string") continue; const count = typeof liveDescendantCount === "number" ? liveDescendantCount : Number(liveDescendantCount); if (Number.isFinite(count)) map.set(issueId, count); } } return map; } function createIssueBlockerAttention(input: Partial = {}): IssueBlockerAttention { return { state: input.state ?? "none", reason: input.reason ?? null, unresolvedBlockerCount: input.unresolvedBlockerCount ?? 0, coveredBlockerCount: input.coveredBlockerCount ?? 0, stalledBlockerCount: input.stalledBlockerCount ?? 0, attentionBlockerCount: input.attentionBlockerCount ?? 0, pendingFinalizeBlockerIssueIds: input.pendingFinalizeBlockerIssueIds ?? [], sampleBlockerIdentifier: input.sampleBlockerIdentifier ?? null, sampleStalledBlockerIdentifier: input.sampleStalledBlockerIdentifier ?? null, blockingTreeLive: input.blockingTreeLive ?? false, directBlockerIssueId: input.directBlockerIssueId ?? null, terminalBlockerIssueId: input.terminalBlockerIssueId ?? null, terminalBlocker: input.terminalBlocker ?? null, }; } function blockerSampleIdentifier(node: IssueBlockerAttentionNode | null | undefined) { return node?.identifier ?? node?.id ?? null; } function appendBlockerAttentionEdges( edgesByIssueId: Map, rows: IssueBlockerAttentionEdge[], ) { for (const row of rows) { const existing = edgesByIssueId.get(row.issueId) ?? []; if (!existing.some((edge) => edge.blockerIssueId === row.blockerIssueId)) { existing.push(row); edgesByIssueId.set(row.issueId, existing); } } } type IssueRelationSummaryRow = { relatedId: string; identifier: string | null; title: string; status: string; priority: string; assigneeAgentId: string | null; assigneeUserId: string | null; }; function summarizeIssueRelationRow(row: IssueRelationSummaryRow): IssueRelationIssueSummary { return { id: row.relatedId, identifier: row.identifier, title: row.title, status: row.status as IssueRelationIssueSummary["status"], priority: row.priority as IssueRelationIssueSummary["priority"], assigneeAgentId: row.assigneeAgentId, assigneeUserId: row.assigneeUserId, }; } async function terminalExplicitBlockersByRoot( companyId: string, roots: IssueRelationIssueSummary[], dbOrTx: DbReader, ): Promise> { const rootIds = [...new Set(roots.map((root) => root.id))]; const terminalByRoot = new Map(); if (rootIds.length === 0) return terminalByRoot; const nodesById = new Map(); const edgesByIssueId = new Map(); for (const root of roots) nodesById.set(root.id, root); let frontier = rootIds; for (let depth = 0; frontier.length > 0 && depth < BLOCKER_ATTENTION_MAX_DEPTH; depth += 1) { const nextFrontier = new Set(); for (const chunk of chunkList([...new Set(frontier)], ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { const rows = await dbOrTx .select({ currentIssueId: issueRelations.relatedIssueId, relatedId: issues.id, identifier: issues.identifier, title: issues.title, status: issues.status, priority: issues.priority, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, }) .from(issueRelations) .innerJoin(issues, eq(issueRelations.issueId, issues.id)) .where( and( eq(issueRelations.companyId, companyId), eq(issueRelations.type, "blocks"), inArray(issueRelations.relatedIssueId, chunk), eq(issues.companyId, companyId), ne(issues.status, "done"), ), ); for (const row of rows) { const existingEdges = edgesByIssueId.get(row.currentIssueId) ?? []; if (!existingEdges.includes(row.relatedId)) { existingEdges.push(row.relatedId); edgesByIssueId.set(row.currentIssueId, existingEdges); } if (!nodesById.has(row.relatedId)) { nodesById.set(row.relatedId, summarizeIssueRelationRow(row)); nextFrontier.add(row.relatedId); } } } if (nodesById.size > BLOCKER_ATTENTION_MAX_NODES) break; frontier = [...nextFrontier]; } const collectTerminal = (issueId: string, seen: Set): IssueRelationIssueSummary[] => { if (seen.has(issueId)) return []; const node = nodesById.get(issueId); if (!node || node.status === "done") return []; const nextSeen = new Set(seen); nextSeen.add(issueId); const downstreamIds = edgesByIssueId.get(issueId) ?? []; if (downstreamIds.length === 0) return [node]; return downstreamIds.flatMap((downstreamId) => collectTerminal(downstreamId, nextSeen)); }; for (const rootId of rootIds) { const deduped = new Map(); for (const blocker of collectTerminal(rootId, new Set())) { if (blocker.id !== rootId) deduped.set(blocker.id, blocker); } if (deduped.size > 0) { terminalByRoot.set(rootId, [...deduped.values()].sort((a, b) => a.title.localeCompare(b.title))); } } return terminalByRoot; } function readProductivityReviewTrigger(value: unknown): IssueProductivityReviewTrigger | null { if (typeof value !== "string") return null; return PRODUCTIVITY_REVIEW_TRIGGERS.includes(value as IssueProductivityReviewTrigger) ? (value as IssueProductivityReviewTrigger) : null; } function readProductivityReviewStreak(value: unknown): number | null { if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return null; return Math.floor(value); } async function listIssueProductivityReviewMap( dbOrTx: any, companyId: string, sourceIssueIds: string[], ): Promise> { const map = new Map(); if (sourceIssueIds.length === 0) return map; const reviewRows: Array<{ sourceIssueId: string | null; reviewIssueId: string; reviewIdentifier: string | null; status: string; priority: string; createdAt: Date; updatedAt: Date; }> = []; for (const chunk of chunkList([...new Set(sourceIssueIds)], ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { const rows = await dbOrTx .select({ sourceIssueId: issues.originId, reviewIssueId: issues.id, reviewIdentifier: issues.identifier, status: issues.status, priority: issues.priority, createdAt: issues.createdAt, updatedAt: issues.updatedAt, }) .from(issues) .where( and( eq(issues.companyId, companyId), eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND), inArray(issues.originId, chunk), visibleIssueCondition(), notInArray(issues.status, PRODUCTIVITY_REVIEW_TERMINAL_STATUSES), ), ) .orderBy(desc(issues.createdAt), desc(issues.id)); reviewRows.push(...rows); } if (reviewRows.length === 0) return map; const reviewIssueIds = reviewRows.map((row) => row.reviewIssueId); const triggerByReviewIssueId = new Map< string, { trigger: IssueProductivityReviewTrigger | null; noCommentStreak: number | null } >(); for (const chunk of chunkList(reviewIssueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { const detailRows = await dbOrTx .select({ entityId: activityLog.entityId, details: activityLog.details, createdAt: activityLog.createdAt, }) .from(activityLog) .where( and( eq(activityLog.companyId, companyId), eq(activityLog.entityType, "issue"), inArray(activityLog.entityId, chunk), inArray(activityLog.action, PRODUCTIVITY_REVIEW_ACTIVITY_ACTIONS), ), ) .orderBy(desc(activityLog.createdAt)); for (const row of detailRows as Array<{ entityId: string; details: Record | null; createdAt: Date; }>) { if (triggerByReviewIssueId.has(row.entityId)) continue; triggerByReviewIssueId.set(row.entityId, { trigger: readProductivityReviewTrigger(row.details?.trigger), noCommentStreak: readProductivityReviewStreak(row.details?.noCommentStreak), }); } } for (const row of reviewRows) { if (!row.sourceIssueId) continue; if (map.has(row.sourceIssueId)) continue; const detail = triggerByReviewIssueId.get(row.reviewIssueId); map.set(row.sourceIssueId, { reviewIssueId: row.reviewIssueId, reviewIdentifier: row.reviewIdentifier, status: row.status as IssueProductivityReview["status"], priority: row.priority as IssueProductivityReview["priority"], trigger: detail?.trigger ?? null, noCommentStreak: detail?.noCommentStreak ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, }); } return map; } async function listIssueBlockerAttentionMap( dbOrTx: any, companyId: string, issueRows: IssueBlockerAttentionInputNode[], ): Promise> { const roots = issueRows.filter((row) => row.companyId === companyId && row.status === "blocked"); const attentionMap = new Map(); for (const row of issueRows) { if (row.status !== "blocked") { attentionMap.set(row.id, createIssueBlockerAttention()); } } if (roots.length === 0) return attentionMap; const nodesById = new Map(); const edgesByIssueId = new Map(); for (const root of roots) nodesById.set(root.id, { ...root }); let frontier = roots.map((root) => root.id); let truncated = false; const pendingFinalizeBlockerIssueIds = new Set(); for (let depth = 0; frontier.length > 0 && depth < BLOCKER_ATTENTION_MAX_DEPTH; depth += 1) { const nextFrontier = new Set(); for (const chunk of chunkList([...new Set(frontier)], ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { const readinessByIssueId = await listIssueDependencyReadinessMap(dbOrTx, companyId, chunk); for (const readiness of readinessByIssueId.values()) { for (const blockerIssueId of readiness.pendingFinalizeBlockerIssueIds) { pendingFinalizeBlockerIssueIds.add(blockerIssueId); } } const explicitBlockerRowsPromise: Promise = dbOrTx .select({ issueId: issueRelations.relatedIssueId, blockerIssueId: issues.id, id: issues.id, companyId: issues.companyId, parentId: issues.parentId, identifier: issues.identifier, title: issues.title, status: issues.status, executionRunId: issues.executionRunId, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, }) .from(issueRelations) .innerJoin(issues, eq(issueRelations.issueId, issues.id)) .where( and( eq(issueRelations.companyId, companyId), eq(issueRelations.type, "blocks"), inArray(issueRelations.relatedIssueId, chunk), eq(issues.companyId, companyId), ), ); const childRowsPromise: Promise = dbOrTx .select({ issueId: issues.parentId, blockerIssueId: issues.id, id: issues.id, companyId: issues.companyId, parentId: issues.parentId, identifier: issues.identifier, title: issues.title, status: issues.status, executionRunId: issues.executionRunId, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, }) .from(issues) .where( and( eq(issues.companyId, companyId), inArray(issues.parentId, chunk), notInArray(issues.status, BLOCKER_ATTENTION_CHILD_TERMINAL_STATUSES), ), ); const [explicitBlockerRows, childRows] = await Promise.all([ explicitBlockerRowsPromise, childRowsPromise, ]); const unresolvedExplicitBlockerRows = explicitBlockerRows.filter( (row) => row.status !== "done" || pendingFinalizeBlockerIssueIds.has(row.blockerIssueId), ); appendBlockerAttentionEdges(edgesByIssueId, [ ...unresolvedExplicitBlockerRows .filter((row): row is IssueBlockerAttentionQueryRow & { issueId: string } => row.issueId !== null) .map((row) => ({ issueId: row.issueId, blockerIssueId: row.blockerIssueId })), ...childRows .filter((row): row is IssueBlockerAttentionQueryRow & { issueId: string } => row.issueId !== null) .map((row) => ({ issueId: row.issueId, blockerIssueId: row.blockerIssueId })), ]); for (const row of [...unresolvedExplicitBlockerRows, ...childRows]) { if (!row.issueId || nodesById.has(row.blockerIssueId)) continue; nodesById.set(row.blockerIssueId, { id: row.blockerIssueId, companyId: row.companyId, parentId: row.parentId, identifier: row.identifier, title: row.title, status: row.status, executionRunId: row.executionRunId, assigneeAgentId: row.assigneeAgentId, assigneeUserId: row.assigneeUserId, }); nextFrontier.add(row.blockerIssueId); } } if (nodesById.size > BLOCKER_ATTENTION_MAX_NODES) { truncated = true; break; } frontier = [...nextFrontier]; } if (frontier.length > 0) truncated = true; const nodeIds = [...nodesById.keys()]; const activeIssueIds = new Set(); const agentIds = new Set(); const issueIdByExecutionRunId = new Map(); for (const node of nodesById.values()) { if (node.assigneeAgentId) agentIds.add(node.assigneeAgentId); if (node.executionRunId) issueIdByExecutionRunId.set(node.executionRunId, node.id); } for (const chunk of chunkList([...issueIdByExecutionRunId.keys()], ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { const runRows: Array<{ id: string }> = await dbOrTx .select({ id: heartbeatRuns.id, }) .from(heartbeatRuns) .where( and( eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.status, BLOCKER_ATTENTION_ACTIVE_RUN_STATUSES), inArray(heartbeatRuns.id, chunk), ), ); for (const row of runRows) { const issueId = issueIdByExecutionRunId.get(row.id); if (issueId) activeIssueIds.add(issueId); } } for (const chunk of chunkList(nodeIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { const wakeRowsPromise: Promise = dbOrTx .select({ issueId: sql`${agentWakeupRequests.payload} ->> 'issueId'`, }) .from(agentWakeupRequests) .where( and( eq(agentWakeupRequests.companyId, companyId), inArray(agentWakeupRequests.status, BLOCKER_ATTENTION_ACTIVE_WAKE_STATUSES), sql`${agentWakeupRequests.runId} is null`, inArray(sql`${agentWakeupRequests.payload} ->> 'issueId'`, chunk), ), ); const wakeRows = await wakeRowsPromise; for (const row of wakeRows) { if (row.issueId) activeIssueIds.add(row.issueId); } } const explicitWaitCandidateIds = [...nodesById.values()] .filter((node) => node.status !== "done") .map((node) => node.id); const explicitWaitingIssueIds = new Set(); if (explicitWaitCandidateIds.length > 0) { for (const chunk of chunkList(explicitWaitCandidateIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { const interactionRows: Array<{ issueId: string }> = await dbOrTx .select({ issueId: issueThreadInteractions.issueId }) .from(issueThreadInteractions) .where( and( eq(issueThreadInteractions.companyId, companyId), inArray(issueThreadInteractions.status, BLOCKER_ATTENTION_PENDING_INTERACTION_STATUSES), inArray(issueThreadInteractions.issueId, chunk), ), ); for (const row of interactionRows) explicitWaitingIssueIds.add(row.issueId); const approvalRows: Array<{ issueId: string }> = await dbOrTx .select({ issueId: issueApprovals.issueId }) .from(issueApprovals) .innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id)) .where( and( eq(issueApprovals.companyId, companyId), inArray(approvals.status, BLOCKER_ATTENTION_PENDING_APPROVAL_STATUSES), inArray(issueApprovals.issueId, chunk), ), ); for (const row of approvalRows) explicitWaitingIssueIds.add(row.issueId); } // Recovery rows are intentionally company-wide: a liveness escalation for // the same leaf blocker represents an active waiting path even when that // blocker is reached through another blocked graph. const recoveryRows: Array<{ id: string; originId: string | null }> = await dbOrTx .select({ id: issues.id, originId: issues.originId }) .from(issues) .where( and( eq(issues.companyId, companyId), eq(issues.originKind, BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND), visibleIssueCondition(), notInArray(issues.status, BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES), ), ); for (const row of recoveryRows) { const parsed = parseIssueGraphLivenessIncidentKey(row.originId); if (!parsed || parsed.companyId !== companyId) continue; explicitWaitingIssueIds.add(row.id); explicitWaitingIssueIds.add(parsed.issueId); explicitWaitingIssueIds.add(parsed.leafIssueId); } const recoveryActionRows: Array<{ sourceIssueId: string }> = await dbOrTx .select({ sourceIssueId: issueRecoveryActions.sourceIssueId }) .from(issueRecoveryActions) .where( and( eq(issueRecoveryActions.companyId, companyId), inArray(issueRecoveryActions.status, ["active", "escalated"]), inArray(issueRecoveryActions.sourceIssueId, explicitWaitCandidateIds), ), ); for (const row of recoveryActionRows) explicitWaitingIssueIds.add(row.sourceIssueId); } const agentRows: IssueBlockerAttentionAgentRow[] = agentIds.size > 0 ? await dbOrTx .select({ id: agents.id, companyId: agents.companyId, status: agents.status, }) .from(agents) .where(and(eq(agents.companyId, companyId), inArray(agents.id, [...agentIds]))) : []; const agentsById = new Map(agentRows.map((agent) => [agent.id, agent])); type PathClassification = { covered: boolean; stalled: boolean; sampleBlockerIdentifier: string | null; sampleStalledBlockerIdentifier: string | null; terminalBlockerIssueId?: string | null; }; const classifyPath = ( nodeId: string, seen: Set, ): PathClassification => { const sample = blockerSampleIdentifier(nodesById.get(nodeId)); if (truncated || seen.has(nodeId)) { return { covered: false, stalled: false, sampleBlockerIdentifier: sample, sampleStalledBlockerIdentifier: null }; } const node = nodesById.get(nodeId); if (!node || node.companyId !== companyId) { return { covered: false, stalled: false, sampleBlockerIdentifier: nodeId, sampleStalledBlockerIdentifier: null }; } const nodeSample = blockerSampleIdentifier(node); if (node.status === "done" && !pendingFinalizeBlockerIssueIds.has(node.id)) { return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null }; } if (explicitWaitingIssueIds.has(node.id)) { return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null }; } if (node.assigneeUserId && node.status !== "cancelled") { return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null }; } if (node.status === "in_review") { const hasWaitingPath = activeIssueIds.has(node.id) || Boolean(node.assigneeUserId); if (hasWaitingPath) { return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null }; } return { covered: false, stalled: true, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: nodeSample, terminalBlockerIssueId: node.id, }; } if (activeIssueIds.has(node.id)) { return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null }; } if (node.status === "cancelled") { return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null, terminalBlockerIssueId: node.id, }; } if (node.status === "backlog" && node.assigneeAgentId) { return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null, terminalBlockerIssueId: node.id, }; } const downstream = (edgesByIssueId.get(node.id) ?? []).filter((edge) => { const blocker = nodesById.get(edge.blockerIssueId); return blocker?.status !== "done" || pendingFinalizeBlockerIssueIds.has(edge.blockerIssueId); }); if (downstream.length > 0) { const nextSeen = new Set(seen); nextSeen.add(nodeId); const classified = downstream.map((edge) => classifyPath(edge.blockerIssueId, nextSeen)); const stalledChild = classified.find((result) => result.stalled || result.sampleStalledBlockerIdentifier); const sampleStalled = stalledChild?.sampleStalledBlockerIdentifier ?? null; const hardAttention = classified.find((result) => !result.covered && !result.stalled && result.terminalBlockerIssueId ) ?? classified.find((result) => !result.covered && !result.stalled); if (hardAttention) { return { covered: false, stalled: false, sampleBlockerIdentifier: hardAttention.sampleBlockerIdentifier, sampleStalledBlockerIdentifier: sampleStalled, terminalBlockerIssueId: hardAttention.terminalBlockerIssueId ?? null, }; } const stalledEntry = classified.find((result) => result.stalled); if (stalledEntry) { return { covered: false, stalled: true, sampleBlockerIdentifier: stalledEntry.sampleBlockerIdentifier, sampleStalledBlockerIdentifier: sampleStalled, terminalBlockerIssueId: stalledEntry.terminalBlockerIssueId ?? null, }; } return { covered: true, stalled: false, sampleBlockerIdentifier: classified[0]?.sampleBlockerIdentifier ?? nodeSample, sampleStalledBlockerIdentifier: null, }; } if (node.assigneeAgentId) { const assignee = agentsById.get(node.assigneeAgentId); if (!assignee || assignee.companyId !== companyId || !BLOCKER_ATTENTION_INVOKABLE_AGENT_STATUSES.has(assignee.status)) { return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null, terminalBlockerIssueId: node.id, }; } } return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null, terminalBlockerIssueId: node.id, }; }; const pathHasLiveWork = (nodeId: string, seen: Set): boolean => { if (seen.has(nodeId)) return false; const node = nodesById.get(nodeId); if (!node || node.companyId !== companyId) return false; if (node.status === "in_progress" || activeIssueIds.has(node.id)) return true; const nextSeen = new Set(seen); nextSeen.add(nodeId); return (edgesByIssueId.get(node.id) ?? []).some((edge) => { const blocker = nodesById.get(edge.blockerIssueId); if (blocker?.status === "done" && !pendingFinalizeBlockerIssueIds.has(edge.blockerIssueId)) return false; return pathHasLiveWork(edge.blockerIssueId, nextSeen); }); }; const issueIdForSample = (sample: string | null | undefined) => { if (!sample) return null; for (const node of nodesById.values()) { if (node.id === sample || node.identifier === sample) return node.id; } return null; }; for (const root of roots) { const topLevelEdges = (edgesByIssueId.get(root.id) ?? []).filter((edge) => { const blocker = nodesById.get(edge.blockerIssueId); return blocker?.status !== "done" || pendingFinalizeBlockerIssueIds.has(edge.blockerIssueId); }); if (topLevelEdges.length === 0) { attentionMap.set(root.id, createIssueBlockerAttention({ state: "needs_attention", reason: "attention_required", terminalBlockerIssueId: root.id, })); continue; } const classified = topLevelEdges.map((edge) => ({ edge, result: classifyPath(edge.blockerIssueId, new Set([root.id])), })); const coveredBlockerCount = classified.filter((entry) => entry.result.covered).length; const stalledBlockerCount = classified.filter((entry) => entry.result.stalled).length; const attentionBlockerCount = classified.length - coveredBlockerCount - stalledBlockerCount; const hardAttentionEntry = classified.find((entry) => !entry.result.covered && !entry.result.stalled && entry.result.terminalBlockerIssueId ) ?? classified.find((entry) => !entry.result.covered && !entry.result.stalled); const stalledEntry = classified.find((entry) => entry.result.stalled && entry.result.terminalBlockerIssueId) ?? classified.find((entry) => entry.result.stalled); const sampleEntry = hardAttentionEntry ?? stalledEntry ?? classified[0] ?? null; const sampleNode = sampleEntry ? nodesById.get(sampleEntry.edge.blockerIssueId) : null; const sampleStalledFromChain = classified .map((entry) => entry.result.sampleStalledBlockerIdentifier) .find((value) => value); const sampledTerminalIdentifier = sampleEntry?.result.stalled ? sampleEntry.result.sampleStalledBlockerIdentifier ?? sampleEntry.result.sampleBlockerIdentifier : sampleEntry?.result.sampleBlockerIdentifier ?? blockerSampleIdentifier(sampleNode); const terminalBlockerIssueId = sampleEntry?.result.terminalBlockerIssueId ?? issueIdForSample(sampledTerminalIdentifier); const terminalBlockerNode = terminalBlockerIssueId ? nodesById.get(terminalBlockerIssueId) ?? null : null; let state: IssueBlockerAttention["state"]; let reason: IssueBlockerAttention["reason"]; if (attentionBlockerCount > 0) { state = "needs_attention"; reason = "attention_required"; } else if (stalledBlockerCount > 0) { state = "stalled"; reason = "stalled_review"; } else { state = "covered"; reason = topLevelEdges.every((edge) => nodesById.get(edge.blockerIssueId)?.parentId === root.id) ? "active_child" : "active_dependency"; } attentionMap.set(root.id, createIssueBlockerAttention({ state, reason, unresolvedBlockerCount: topLevelEdges.length, coveredBlockerCount, stalledBlockerCount, attentionBlockerCount, pendingFinalizeBlockerIssueIds: topLevelEdges .map((edge) => edge.blockerIssueId) .filter((blockerIssueId) => pendingFinalizeBlockerIssueIds.has(blockerIssueId)), sampleBlockerIdentifier: sampleEntry?.result.sampleBlockerIdentifier ?? blockerSampleIdentifier(sampleNode), sampleStalledBlockerIdentifier: stalledEntry?.result.sampleStalledBlockerIdentifier ?? sampleStalledFromChain ?? null, blockingTreeLive: topLevelEdges.some((edge) => pathHasLiveWork(edge.blockerIssueId, new Set([root.id]))), directBlockerIssueId: sampleEntry?.edge.blockerIssueId ?? null, terminalBlockerIssueId, terminalBlocker: terminalBlockerNode ? { id: terminalBlockerNode.id, identifier: terminalBlockerNode.identifier, title: terminalBlockerNode.title, } : null, })); } return attentionMap; } type IssueReviewAttentionInput = Pick< IssueRow, "id" | "companyId" | "status" >; function reviewPathLabel(kind: IssueReviewAttentionPath["kind"], detail?: string | null) { switch (kind) { case "execution_participant": return "Execution review participant"; case "interaction": return detail ? `Pending ${detail.replaceAll("_", " ")}` : "Pending issue interaction"; case "approval": return "Linked approval"; case "monitor": return "Scheduled review monitor"; case "human_reviewer": return "Human reviewer"; case "active_run": return "Active review run"; case "queued_wake": return detail ? `Queued ${detail.replaceAll("_", " ")} wake` : "Queued review wake"; case "recovery": return "Open review recovery"; } } function reviewAttentionNone(): IssueReviewAttention { return { state: "none", paths: [], reason: null }; } async function listIssueReviewAttentionMap( dbOrTx: any, companyId: string, issueRows: IssueReviewAttentionInput[], ): Promise> { const result = new Map(); for (const row of issueRows) result.set(row.id, reviewAttentionNone()); const reviewIds = issueRows .filter((row) => row.companyId === companyId && row.status === "in_review") .map((row) => row.id); if (reviewIds.length === 0) return result; const reviewIssues: IssueRow[] = []; for (const chunk of chunkList(reviewIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { reviewIssues.push(...await dbOrTx .select() .from(issues) .where(and(eq(issues.companyId, companyId), inArray(issues.id, chunk)))); } if (reviewIssues.length === 0) return result; const [agentRows, activeRunRows, wakeRows, interactionRows, approvalRows, recoveryActionRows, recoveryIssueRows] = await Promise.all([ dbOrTx .select({ id: agents.id, companyId: agents.companyId, name: agents.name, role: agents.role, title: agents.title, status: agents.status, reportsTo: agents.reportsTo, }) .from(agents) .where(eq(agents.companyId, companyId)), dbOrTx .select({ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, issueId: sql`coalesce(${heartbeatRuns.contextSnapshot} ->> 'issueId', ${heartbeatRuns.contextSnapshot} ->> 'taskId')`, agentId: heartbeatRuns.agentId, status: heartbeatRuns.status, createdAt: heartbeatRuns.createdAt, }) .from(heartbeatRuns) .where(and( eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.status, ["queued", "running"]), inArray(sql`coalesce(${heartbeatRuns.contextSnapshot} ->> 'issueId', ${heartbeatRuns.contextSnapshot} ->> 'taskId')`, reviewIds), )), dbOrTx .select({ id: agentWakeupRequests.id, companyId: agentWakeupRequests.companyId, issueId: sql`coalesce( ${agentWakeupRequests.payload} ->> 'issueId', ${agentWakeupRequests.payload} ->> 'taskId', ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'issueId', ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'taskId' )`, agentId: agentWakeupRequests.agentId, status: agentWakeupRequests.status, reason: agentWakeupRequests.reason, createdAt: agentWakeupRequests.requestedAt, }) .from(agentWakeupRequests) .where(and( eq(agentWakeupRequests.companyId, companyId), inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution", "claimed"]), inArray(sql`coalesce( ${agentWakeupRequests.payload} ->> 'issueId', ${agentWakeupRequests.payload} ->> 'taskId', ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'issueId', ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'taskId' )`, reviewIds), )), dbOrTx .select({ id: issueThreadInteractions.id, companyId: issueThreadInteractions.companyId, issueId: issueThreadInteractions.issueId, status: issueThreadInteractions.status, kind: issueThreadInteractions.kind, createdByAgentId: issueThreadInteractions.createdByAgentId, sourceRunId: issueThreadInteractions.sourceRunId, addresseeAgentId: issueThreadInteractions.addresseeAgentId, effectiveResolverPolicy: issueThreadInteractions.effectiveResolverPolicy, resolverPolicyProvenance: issueThreadInteractions.resolverPolicyProvenance, createdAt: issueThreadInteractions.createdAt, }) .from(issueThreadInteractions) .where(and( eq(issueThreadInteractions.companyId, companyId), eq(issueThreadInteractions.status, "pending"), inArray(issueThreadInteractions.issueId, reviewIds), )), dbOrTx .select({ id: approvals.id, companyId: issueApprovals.companyId, issueId: issueApprovals.issueId, status: approvals.status, createdAt: approvals.createdAt, }) .from(issueApprovals) .innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id)) .where(and( eq(issueApprovals.companyId, companyId), eq(approvals.companyId, companyId), inArray(approvals.status, ["pending", "revision_requested"]), inArray(issueApprovals.issueId, reviewIds), )), dbOrTx .select({ id: issueRecoveryActions.id, companyId: issueRecoveryActions.companyId, issueId: issueRecoveryActions.sourceIssueId, status: issueRecoveryActions.status, createdAt: issueRecoveryActions.createdAt, }) .from(issueRecoveryActions) .where(and( eq(issueRecoveryActions.companyId, companyId), inArray(issueRecoveryActions.status, ["active", "escalated"]), inArray(issueRecoveryActions.sourceIssueId, reviewIds), )), dbOrTx .select({ id: issues.id, companyId: issues.companyId, originKind: issues.originKind, originId: issues.originId, status: issues.status, createdAt: issues.createdAt, }) .from(issues) .where(and( eq(issues.companyId, companyId), inArray(issues.originKind, [RECOVERY_ORIGIN_KINDS.strandedIssueRecovery, RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation]), visibleIssueCondition(), notInArray(issues.status, ["done", "cancelled"]), )), ]); const recoveryPaths = [ ...(recoveryActionRows as Array<{ id: string; companyId: string; issueId: string; status: string; createdAt: Date }>), ]; for (const recovery of recoveryIssueRows as Array<{ id: string; companyId: string; originKind: string; originId: string | null; status: string; createdAt: Date; }>) { if (recovery.originKind === RECOVERY_ORIGIN_KINDS.strandedIssueRecovery && recovery.originId && reviewIds.includes(recovery.originId)) { recoveryPaths.push({ ...recovery, issueId: recovery.originId }); continue; } const parsed = parseIssueGraphLivenessIncidentKey(recovery.originId); if (parsed?.companyId === companyId && reviewIds.includes(parsed.issueId)) { recoveryPaths.push({ ...recovery, issueId: parsed.issueId }); } } const livenessInput: IssueGraphLivenessInput = { issues: reviewIssues.map((issue) => ({ id: issue.id, companyId: issue.companyId, identifier: issue.identifier, title: issue.title, status: issue.status, projectId: issue.projectId, goalId: issue.goalId, parentId: issue.parentId, assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId, createdByAgentId: issue.createdByAgentId, createdByUserId: issue.createdByUserId, executionPolicy: issue.executionPolicy, executionState: issue.executionState, monitorNextCheckAt: issue.monitorNextCheckAt, monitorAttemptCount: issue.monitorAttemptCount, })), relations: [], agents: agentRows, activeRuns: activeRunRows, queuedWakeRequests: wakeRows, pendingInteractions: interactionRows, pendingApprovals: approvalRows, openRecoveryIssues: recoveryPaths, now: new Date(), }; const findingsByIssueId = new Map( classifyIssueGraphLiveness(livenessInput).map((finding) => [finding.issueId, finding]), ); const agentNameById = new Map((agentRows as Array<{ id: string; name: string }>).map((agent) => [agent.id, agent.name])); const userIds = new Set(); for (const issue of reviewIssues) { if (issue.assigneeUserId) userIds.add(issue.assigneeUserId); const participant = parseObject(issue.executionState).currentParticipant; if (participant && typeof participant === "object" && !Array.isArray(participant)) { const userId = (participant as Record).userId; if (typeof userId === "string") userIds.add(userId); } } const userRows = userIds.size > 0 ? await dbOrTx.select({ id: authUsers.id, name: authUsers.name }).from(authUsers).where(inArray(authUsers.id, [...userIds])) : []; const userNameById = new Map((userRows as Array<{ id: string; name: string }>).map((user) => [user.id, user.name])); const interactionKindById = new Map((interactionRows as Array<{ id: string; kind: string }>).map((row) => [row.id, row.kind])); const interactionAudienceById = new Map((interactionRows as Array<{ id: string; createdByAgentId: string | null; sourceRunId: string | null; addresseeAgentId: string | null; effectiveResolverPolicy: string; resolverPolicyProvenance: string | null; }>).map((row) => [row.id, row])); const wakeReasonById = new Map((wakeRows as Array<{ id: string; reason: string | null }>).map((row) => [row.id, row.reason])); for (const issue of reviewIssues) { const pathFacts = classifyIssueReviewPaths(livenessInput, livenessInput.issues.find((entry) => entry.id === issue.id)!); const paths: IssueReviewAttentionPath[] = pathFacts.map((path) => { const interactionAudience = path.kind === "interaction" && path.ref ? interactionAudienceById.get(path.ref) ?? null : null; const candidateAgentId = interactionAudience?.addresseeAgentId ?? issue.assigneeAgentId; const interactionResponderAgentId = interactionAudience && candidateAgentId && issueThreadInteractionAttentionAgentAllowed({ agentId: candidateAgentId, interaction: interactionAudience, }) ? candidateAgentId : null; return { kind: path.kind, label: reviewPathLabel( path.kind, path.kind === "interaction" && path.ref ? interactionKindById.get(path.ref) ?? null : path.kind === "queued_wake" && path.ref ? wakeReasonById.get(path.ref) ?? null : null, ), responder: path.agentId ? agentNameById.get(path.agentId) ?? path.agentId : path.userId ? userNameById.get(path.userId) ?? path.userId : path.kind === "interaction" && interactionResponderAgentId ? agentNameById.get(interactionResponderAgentId) ?? interactionResponderAgentId : path.kind === "interaction" || path.kind === "approval" ? "Board" : null, since: path.since ? (path.since instanceof Date ? path.since : new Date(path.since)).toISOString() : issue.updatedAt.toISOString(), ref: path.ref, }; }); if (paths.length > 0) { result.set(issue.id, { state: "covered", paths, reason: paths.length === 1 ? "Review has a maintained action path." : `Review has ${paths.length} maintained action paths.`, }); continue; } const finding = findingsByIssueId.get(issue.id); result.set(issue.id, { state: "stalled", paths: [], reason: finding?.reason ?? "Issue is in review without a maintained action path.", }); } return result; } const issueListSelect = { id: issues.id, companyId: issues.companyId, projectId: issues.projectId, projectWorkspaceId: issues.projectWorkspaceId, goalId: issues.goalId, parentId: issues.parentId, title: issues.title, description: sql` CASE WHEN ${issues.description} IS NULL THEN NULL ELSE encode( substring( convert_to(${issues.description}, current_setting('server_encoding')) FROM 1 FOR ${ISSUE_LIST_DESCRIPTION_MAX_BYTES} ), 'base64' ) END `, descriptionTruncated: sql` CASE WHEN ${issues.description} IS NULL THEN false ELSE length(${issues.description}) > ${ISSUE_LIST_DESCRIPTION_MAX_CHARS} END `, status: issues.status, workMode: issues.workMode, harnessKind: issues.harnessKind, priority: issues.priority, reviewPolicy: issues.reviewPolicy, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, checkoutRunId: issues.checkoutRunId, executionRunId: issues.executionRunId, executionAgentNameKey: issues.executionAgentNameKey, executionLockedAt: issues.executionLockedAt, createdByAgentId: issues.createdByAgentId, createdByUserId: issues.createdByUserId, responsibleUserId: issues.responsibleUserId, issueNumber: issues.issueNumber, identifier: issues.identifier, originKind: issues.originKind, originId: issues.originId, originRunId: issues.originRunId, originFingerprint: issues.originFingerprint, requestDepth: issues.requestDepth, billingCode: issues.billingCode, assigneeAdapterOverrides: issues.assigneeAdapterOverrides, executionPolicy: sql`null`, executionState: sql`null`, monitorNextCheckAt: issues.monitorNextCheckAt, monitorWakeRequestedAt: issues.monitorWakeRequestedAt, monitorLastTriggeredAt: issues.monitorLastTriggeredAt, monitorAttemptCount: issues.monitorAttemptCount, monitorNotes: issues.monitorNotes, monitorScheduledBy: issues.monitorScheduledBy, executionWorkspaceId: issues.executionWorkspaceId, executionWorkspacePreference: issues.executionWorkspacePreference, executionWorkspaceSettings: sql`null`, sourceTrust: issues.sourceTrust, unblockDescriptor: issues.unblockDescriptor, blockedTransitionAt: issues.blockedTransitionAt, blockedOwnerNotifiedAt: issues.blockedOwnerNotifiedAt, startedAt: issues.startedAt, completedAt: issues.completedAt, cancelledAt: issues.cancelledAt, hiddenAt: issues.hiddenAt, createdAt: issues.createdAt, updatedAt: issues.updatedAt, }; function withActiveRuns( issueRows: IssueWithLabels[], runMap: Map, ): IssueWithLabelsAndRun[] { return issueRows.map((row) => ({ ...row, activeRun: row.executionRunId ? (runMap.get(row.executionRunId) ?? null) : null, })); } async function userCommentStatsForIssues( dbOrTx: any, companyId: string, userId: string, issueIds: string[], ): Promise { const stats: IssueUserCommentStats[] = []; for (const issueIdChunk of chunkList(issueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { const rows = await dbOrTx .select({ issueId: issueComments.issueId, myLastCommentAt: sql` MAX(CASE WHEN ${issueComments.authorUserId} = ${userId} THEN ${issueComments.createdAt} END) `, lastExternalCommentAt: sql` MAX( CASE WHEN ${issueComments.authorUserId} IS NULL OR ${issueComments.authorUserId} <> ${userId} THEN ${issueComments.createdAt} END ) `, }) .from(issueComments) .where( and( eq(issueComments.companyId, companyId), inArray(issueComments.issueId, issueIdChunk), ), ) .groupBy(issueComments.issueId); stats.push(...rows); } return stats; } async function userReadStatsForIssues( dbOrTx: any, companyId: string, userId: string, issueIds: string[], ): Promise { const stats: IssueReadStat[] = []; for (const issueIdChunk of chunkList(issueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { const rows = await dbOrTx .select({ issueId: issueReadStates.issueId, myLastReadAt: issueReadStates.lastReadAt, }) .from(issueReadStates) .where( and( eq(issueReadStates.companyId, companyId), eq(issueReadStates.userId, userId), inArray(issueReadStates.issueId, issueIdChunk), ), ); stats.push(...rows); } return stats; } async function lastActivityStatsForIssues( dbOrTx: any, companyId: string, issueIds: string[], ): Promise { const byIssueId = new Map(); for (const issueIdChunk of chunkList(issueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { const [commentRows, logRows] = await Promise.all([ dbOrTx .select({ issueId: issueComments.issueId, latestCommentAt: sql`MAX(${issueComments.createdAt})`, }) .from(issueComments) .where( and( eq(issueComments.companyId, companyId), inArray(issueComments.issueId, issueIdChunk), ), ) .groupBy(issueComments.issueId), dbOrTx .select({ issueId: activityLog.entityId, latestLogAt: sql`MAX(${activityLog.createdAt})`, }) .from(activityLog) .where( and( eq(activityLog.companyId, companyId), eq(activityLog.entityType, "issue"), inArray(activityLog.entityId, issueIdChunk), sql`${activityLog.action} NOT IN (${sql.join( ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map((action) => sql`${action}`), sql`, `, )})`, ), ) .groupBy(activityLog.entityId), ]); for (const row of commentRows) { byIssueId.set(row.issueId, { issueId: row.issueId, latestCommentAt: row.latestCommentAt, latestLogAt: null, }); } for (const row of logRows) { const existing = byIssueId.get(row.issueId); if (existing) existing.latestLogAt = row.latestLogAt; else { byIssueId.set(row.issueId, { issueId: row.issueId, latestCommentAt: null, latestLogAt: row.latestLogAt, }); } } } return [...byIssueId.values()]; } async function blockedByMapForIssues( dbOrTx: any, companyId: string, issueIds: string[], ): Promise> { const map = new Map(); const uniqueIssueIds = [...new Set(issueIds)]; if (uniqueIssueIds.length === 0) return map; for (const issueId of uniqueIssueIds) { map.set(issueId, []); } for (const issueIdChunk of chunkList(uniqueIssueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { const rows = await dbOrTx .select({ currentIssueId: issueRelations.relatedIssueId, relatedId: issues.id, identifier: issues.identifier, title: issues.title, status: issues.status, priority: issues.priority, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, }) .from(issueRelations) .innerJoin(issues, eq(issueRelations.issueId, issues.id)) .where( and( eq(issueRelations.companyId, companyId), eq(issueRelations.type, "blocks"), inArray(issueRelations.relatedIssueId, issueIdChunk), ), ); for (const row of rows) { const blockedBy = map.get(row.currentIssueId); if (!blockedBy) continue; blockedBy.push({ id: row.relatedId, identifier: row.identifier, title: row.title, status: row.status as IssueRelationIssueSummary["status"], priority: row.priority as IssueRelationIssueSummary["priority"], assigneeAgentId: row.assigneeAgentId, assigneeUserId: row.assigneeUserId, }); } } for (const blockedBy of map.values()) { blockedBy.sort((a, b) => a.title.localeCompare(b.title)); } return map; } const BLOCKED_INBOX_TERMINAL_STATUSES = ["done", "cancelled"] as const; const BLOCKED_INBOX_ACTIVE_RUN_STATUSES = ["queued", "running"] as const; const BLOCKED_INBOX_ACTIVE_WAKE_STATUSES = SUCCESSFUL_RUN_HANDOFF_LIVE_WAKE_STATUSES; const BLOCKED_INBOX_PENDING_INTERACTION_STATUSES = ["pending"] as const; const BLOCKED_INBOX_PENDING_APPROVAL_STATUSES = ["pending", "revision_requested"] as const; const BLOCKED_INBOX_RECOVERY_ORIGIN_KINDS = ["harness_liveness_escalation", "stranded_issue_recovery"] as const; const BLOCKED_INBOX_SUCCESSFUL_RUN_HANDOFF_ACTIONS = [ "issue.successful_run_handoff_required", "issue.successful_run_handoff_resolved", "issue.successful_run_handoff_escalated", ] as const; type BlockedInboxIssueRow = IssueRow & { labels?: IssueLabelRow[]; labelIds?: string[] }; type BlockedInboxInteractionRow = { id: string; issueId: string; kind: string; createdAt: Date; }; type BlockedInboxApprovalRow = { approvalId: string; issueId: string; createdAt: Date; }; function issueRef(row: Pick | null | undefined): IssueBlockedInboxIssueRef | null { if (!row) return null; return { id: row.id, identifier: row.identifier, title: row.title, status: row.status as IssueBlockedInboxIssueRef["status"], priority: row.priority as IssueBlockedInboxIssueRef["priority"], assigneeAgentId: row.assigneeAgentId, assigneeUserId: row.assigneeUserId, }; } function hasPlanDocumentCondition(companyId: string, hasPlanDocument: boolean): SQL { const existsPlanDocument = sql` EXISTS ( SELECT 1 FROM ${issueDocuments} WHERE ${issueDocuments.companyId} = ${companyId} AND ${issueDocuments.issueId} = ${issues.id} AND ${issueDocuments.key} = 'plan' ) `; return hasPlanDocument ? existsPlanDocument : sql`NOT ${existsPlanDocument}`; } function isoDate(value: Date | string | null | undefined): string | null { if (!value) return null; const date = value instanceof Date ? value : new Date(value); return Number.isNaN(date.getTime()) ? null : date.toISOString(); } function attentionBase(input: { state: IssueBlockedInboxAttention["state"]; reason: IssueBlockedInboxAttention["reason"]; severity: IssueBlockedInboxAttention["severity"]; stoppedSinceAt: Date | string | null | undefined; owner: IssueBlockedInboxAttention["owner"]; action: IssueBlockedInboxAttention["action"]; sourceIssue: IssueBlockedInboxIssueRef | null; leafIssue?: IssueBlockedInboxIssueRef | null; recoveryIssue?: IssueBlockedInboxIssueRef | null; approvalId?: string | null; interactionId?: string | null; sampleIssueIdentifier?: string | null; externalDetailsRedacted?: boolean; }): IssueBlockedInboxAttention { return { kind: "blocked", state: input.state, reason: input.reason, severity: input.severity, stoppedSinceAt: isoDate(input.stoppedSinceAt), owner: input.owner, action: input.action, sourceIssue: input.sourceIssue, leafIssue: input.leafIssue ?? null, recoveryIssue: input.recoveryIssue ?? null, approvalId: input.approvalId ?? null, interactionId: input.interactionId ?? null, sampleIssueIdentifier: input.sampleIssueIdentifier ?? input.leafIssue?.identifier ?? input.recoveryIssue?.identifier ?? input.sourceIssue?.identifier ?? null, redaction: { externalDetailsRedacted: input.externalDetailsRedacted ?? false, secretFieldsOmitted: true, }, }; } function readSuccessfulRunHandoffFromActivity(row: { action: string; agentId: string | null; runId: string | null; details: Record | null; createdAt: Date; }): SuccessfulRunHandoffState | null { const details = row.details ?? {}; const state = row.action === "issue.successful_run_handoff_required" ? "required" : row.action === "issue.successful_run_handoff_resolved" ? "resolved" : row.action === "issue.successful_run_handoff_escalated" ? "escalated" : null; if (!state) return null; const detectedProgressSummary = readStringFromRecord(details, "detectedProgressSummary") ?? readStringFromRecord(details, "detected_progress_summary") ?? null; return { state, required: state === "required", hasLiveContinuation: false, sourceRunId: readStringFromRecord(details, "sourceRunId") ?? readStringFromRecord(details, "source_run_id") ?? readStringFromRecord(details, "resumeFromRunId") ?? row.runId ?? null, correctiveRunId: readStringFromRecord(details, "correctiveRunId") ?? readStringFromRecord(details, "corrective_run_id") ?? (state !== "required" ? row.runId : null), assigneeAgentId: readStringFromRecord(details, "assigneeAgentId") ?? readStringFromRecord(details, "agentId") ?? row.agentId ?? null, detectedProgressSummary: detectedProgressSummary ? redactSensitiveText(detectedProgressSummary) : null, createdAt: row.createdAt, }; } async function listSuccessfulRunHandoffMapForIssues( dbOrTx: any, companyId: string, issueIds: string[], options?: { hydrateLiveness?: boolean }, ): Promise> { const uniqueIssueIds = [...new Set(issueIds)]; const states = new Map(); if (uniqueIssueIds.length === 0) return states; for (const issueIdChunk of chunkList(uniqueIssueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { const rows = await dbOrTx .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, issueIdChunk), inArray(activityLog.action, [...BLOCKED_INBOX_SUCCESSFUL_RUN_HANDOFF_ACTIONS]), )) .orderBy(activityLog.entityId, desc(activityLog.createdAt), desc(activityLog.id)); for (const row of rows as Array<{ entityId: string; action: string; agentId: string | null; runId: string | null; details: Record | null; createdAt: Date; }>) { if (states.has(row.entityId)) continue; const state = readSuccessfulRunHandoffFromActivity(row); if (state) states.set(row.entityId, state); } } return options?.hydrateLiveness === false ? states : hydrateSuccessfulRunHandoffLiveness(dbOrTx, companyId, states); } function externalWaitFromDescription(description: string | null): { owner: string; action: string } | null { if (!description) return null; const owner = description.match(/^\s*external owner\s*:\s*(.+)$/im)?.[1]?.trim(); const action = description.match(/^\s*external action\s*:\s*(.+)$/im)?.[1]?.trim(); if (!owner || !action) return null; return { owner: owner.slice(0, 120), action: action.slice(0, 240), }; } function escapeRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function redactExternalWaitDescription( description: string | null | undefined, external: { owner: string; action: string } | null, ) { if (!description) return null; let redacted = description .split(/\r?\n/) .filter((line) => !/^\s*external\s+(?:owner|action)\s*:/i.test(line)) .join("\n"); for (const value of [external?.owner, external?.action]) { if (!value) continue; redacted = redacted.replace(new RegExp(escapeRegExp(value), "gi"), "[redacted external wait detail]"); } redacted = redacted.replace(/\n{3,}/g, "\n\n").trim(); return redacted.length > 0 ? redacted : null; } function blockedInboxResponseDescription(attention: IssueBlockedInboxAttention, row: BlockedInboxIssueRow) { if (!attention.redaction.externalDetailsRedacted) return row.description; return redactExternalWaitDescription(row.description, externalWaitFromDescription(row.description)); } function blockedInboxSearchText(attention: IssueBlockedInboxAttention, row: BlockedInboxIssueRow) { return [ row.identifier, row.title, blockedInboxResponseDescription(attention, row), attention.sourceIssue?.identifier, attention.sourceIssue?.title, attention.leafIssue?.identifier, attention.leafIssue?.title, attention.recoveryIssue?.identifier, attention.recoveryIssue?.title, attention.action.label, attention.action.detail, ] .filter((value): value is string => typeof value === "string" && value.length > 0) .join(" ") .toLowerCase(); } function blockedInboxSeverityRank(severity: IssueBlockedInboxAttention["severity"]) { switch (severity) { case "critical": return 0; case "high": return 1; case "medium": return 2; case "low": return 3; } } function issuePriorityRank(priority: string) { switch (priority) { case "critical": return 0; case "high": return 1; case "medium": return 2; case "low": return 3; default: return 4; } } function compareBlockedInboxRows( left: BlockedInboxIssueRow & { blockedInboxAttention: IssueBlockedInboxAttention; lastActivityAt?: Date | null }, right: BlockedInboxIssueRow & { blockedInboxAttention: IssueBlockedInboxAttention; lastActivityAt?: Date | null }, ) { const leftAttention = left.blockedInboxAttention; const rightAttention = right.blockedInboxAttention; const severity = blockedInboxSeverityRank(leftAttention.severity) - blockedInboxSeverityRank(rightAttention.severity); if (severity !== 0) return severity; const leftStopped = leftAttention.stoppedSinceAt ? new Date(leftAttention.stoppedSinceAt).getTime() : Number.POSITIVE_INFINITY; const rightStopped = rightAttention.stoppedSinceAt ? new Date(rightAttention.stoppedSinceAt).getTime() : Number.POSITIVE_INFINITY; if (leftStopped !== rightStopped) return leftStopped - rightStopped; const priority = issuePriorityRank(left.priority) - issuePriorityRank(right.priority); if (priority !== 0) return priority; const leftActivity = left.lastActivityAt ? new Date(left.lastActivityAt).getTime() : new Date(left.updatedAt).getTime(); const rightActivity = right.lastActivityAt ? new Date(right.lastActivityAt).getTime() : new Date(right.updatedAt).getTime(); if (leftActivity !== rightActivity) return rightActivity - leftActivity; return right.id.localeCompare(left.id); } async function listIssueBlockedInboxAttentionMap( dbOrTx: any, companyId: string, issueRows: BlockedInboxIssueRow[], ): Promise> { const rowIssueIds = [...new Set(issueRows.map((row) => row.id))]; const result = new Map(); if (rowIssueIds.length === 0) return result; const [graphIssueRows, graphRelationRows, companyAgentRows] = await Promise.all([ dbOrTx .select() .from(issues) .where(and( eq(issues.companyId, companyId), visibleIssueCondition(), ne(issues.status, "done"), )), dbOrTx .select({ companyId: issueRelations.companyId, blockerIssueId: issueRelations.issueId, blockedIssueId: issueRelations.relatedIssueId, }) .from(issueRelations) .where(and(eq(issueRelations.companyId, companyId), eq(issueRelations.type, "blocks"))), dbOrTx .select({ id: agents.id, companyId: agents.companyId, name: agents.name, role: agents.role, title: agents.title, status: agents.status, reportsTo: agents.reportsTo, }) .from(agents) .where(eq(agents.companyId, companyId)), ]); const graphIssues = graphIssueRows as IssueRow[]; const graphRelations = graphRelationRows as Array<{ companyId: string; blockerIssueId: string; blockedIssueId: string }>; const companyAgents = companyAgentRows as Array<{ id: string; companyId: string; name: string; role: string; title: string | null; status: string; reportsTo: string | null; }>; const graphIssueIds = graphIssues.map((issue) => issue.id); const issuesById = new Map(graphIssues.map((issue) => [issue.id, issue])); const [activeRunRows, wakeRows, scheduledRetryRows, interactionRows, approvalRows, handoffMap] = await Promise.all([ graphIssueIds.length === 0 ? Promise.resolve([]) : dbOrTx .select({ companyId: heartbeatRuns.companyId, issueId: sql`coalesce( ${heartbeatRuns.contextSnapshot} ->> 'issueId', ${heartbeatRuns.contextSnapshot} ->> 'taskId' )`, agentId: heartbeatRuns.agentId, status: heartbeatRuns.status, }) .from(heartbeatRuns) .where(and( eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.status, [...BLOCKED_INBOX_ACTIVE_RUN_STATUSES]), inArray(sql`coalesce( ${heartbeatRuns.contextSnapshot} ->> 'issueId', ${heartbeatRuns.contextSnapshot} ->> 'taskId' )`, graphIssueIds), )), graphIssueIds.length === 0 ? Promise.resolve([]) : dbOrTx .select({ companyId: agentWakeupRequests.companyId, issueId: sql`coalesce( ${agentWakeupRequests.payload} ->> 'issueId', ${agentWakeupRequests.payload} ->> 'taskId', ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'issueId', ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'taskId' )`, agentId: agentWakeupRequests.agentId, status: agentWakeupRequests.status, }) .from(agentWakeupRequests) .where(and( eq(agentWakeupRequests.companyId, companyId), inArray(agentWakeupRequests.status, [...BLOCKED_INBOX_ACTIVE_WAKE_STATUSES]), inArray(sql`coalesce( ${agentWakeupRequests.payload} ->> 'issueId', ${agentWakeupRequests.payload} ->> 'taskId', ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'issueId', ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'taskId' )`, graphIssueIds), )), graphIssueIds.length === 0 ? Promise.resolve([]) : dbOrTx .select({ companyId: heartbeatRuns.companyId, issueId: sql`coalesce( ${heartbeatRuns.contextSnapshot} ->> 'issueId', ${heartbeatRuns.contextSnapshot} ->> 'taskId' )`, agentId: heartbeatRuns.agentId, status: heartbeatRuns.status, }) .from(heartbeatRuns) .where(and( eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.status, "scheduled_retry"), inArray(sql`coalesce( ${heartbeatRuns.contextSnapshot} ->> 'issueId', ${heartbeatRuns.contextSnapshot} ->> 'taskId' )`, graphIssueIds), )), graphIssueIds.length === 0 ? Promise.resolve([]) : dbOrTx .select({ id: issueThreadInteractions.id, issueId: issueThreadInteractions.issueId, kind: issueThreadInteractions.kind, createdAt: issueThreadInteractions.createdAt, }) .from(issueThreadInteractions) .where(and( eq(issueThreadInteractions.companyId, companyId), inArray(issueThreadInteractions.status, [...BLOCKED_INBOX_PENDING_INTERACTION_STATUSES]), inArray(issueThreadInteractions.issueId, graphIssueIds), )), graphIssueIds.length === 0 ? Promise.resolve([]) : dbOrTx .select({ approvalId: approvals.id, issueId: issueApprovals.issueId, createdAt: approvals.createdAt, }) .from(issueApprovals) .innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id)) .where(and( eq(issueApprovals.companyId, companyId), eq(approvals.companyId, companyId), inArray(approvals.status, [...BLOCKED_INBOX_PENDING_APPROVAL_STATUSES]), inArray(issueApprovals.issueId, graphIssueIds), )), listSuccessfulRunHandoffMapForIssues(dbOrTx, companyId, rowIssueIds, { hydrateLiveness: false }), ]); const pendingInteractions = (interactionRows as BlockedInboxInteractionRow[]).map((row) => ({ companyId, issueId: row.issueId, status: "pending", })); const pendingApprovals = (approvalRows as BlockedInboxApprovalRow[]).map((row) => ({ companyId, issueId: row.issueId, status: "pending", })); const openRecoveryIssues = graphIssues .filter((issue) => BLOCKED_INBOX_RECOVERY_ORIGIN_KINDS.includes(issue.originKind as typeof BLOCKED_INBOX_RECOVERY_ORIGIN_KINDS[number])) .filter((issue) => !BLOCKED_INBOX_TERMINAL_STATUSES.includes(issue.status as typeof BLOCKED_INBOX_TERMINAL_STATUSES[number])) .flatMap((issue) => { const entries = [{ companyId, issueId: issue.id, status: issue.status }]; if (issue.originKind === "harness_liveness_escalation") { const parsed = parseIssueGraphLivenessIncidentKey(issue.originId); if (parsed?.companyId === companyId) { entries.push({ companyId, issueId: parsed.issueId, status: issue.status }); entries.push({ companyId, issueId: parsed.leafIssueId, status: issue.status }); } } else if (issue.originKind === "stranded_issue_recovery" && issue.originId) { entries.push({ companyId, issueId: issue.originId, status: issue.status }); } return entries; }); const findings = classifyIssueGraphLiveness({ issues: graphIssues.map((issue) => ({ id: issue.id, companyId: issue.companyId, identifier: issue.identifier, title: issue.title, status: issue.status, projectId: issue.projectId, goalId: issue.goalId, parentId: issue.parentId, assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId, createdByAgentId: issue.createdByAgentId, createdByUserId: issue.createdByUserId, executionPolicy: issue.executionPolicy, executionState: issue.executionState, monitorNextCheckAt: issue.monitorNextCheckAt, monitorAttemptCount: issue.monitorAttemptCount, })), relations: graphRelations, agents: companyAgents, activeRuns: (activeRunRows as Array<{ companyId: string; issueId: string | null; agentId: string | null; status: string }>) .flatMap((row) => row.issueId ? [{ companyId: row.companyId, issueId: row.issueId, agentId: row.agentId, status: row.status }] : []), queuedWakeRequests: [ ...(wakeRows as Array<{ companyId: string; issueId: string | null; agentId: string | null; status: string }>), ...(scheduledRetryRows as Array<{ companyId: string; issueId: string | null; agentId: string | null; status: string }>), ] .flatMap((row) => row.issueId ? [{ companyId: row.companyId, issueId: row.issueId, agentId: row.agentId, status: row.status }] : []), pendingInteractions, pendingApprovals, openRecoveryIssues, now: new Date(), }); const findingByIssueId = new Map(); for (const finding of findings) { if (!findingByIssueId.has(finding.issueId)) findingByIssueId.set(finding.issueId, finding); } const interactionByIssueId = new Map(); for (const row of interactionRows as BlockedInboxInteractionRow[]) { if (!interactionByIssueId.has(row.issueId)) interactionByIssueId.set(row.issueId, row); } const approvalByIssueId = new Map(); for (const row of approvalRows as BlockedInboxApprovalRow[]) { if (!approvalByIssueId.has(row.issueId)) approvalByIssueId.set(row.issueId, row); } const liveHandoffRunIssueIds = new Set([ ...(activeRunRows as Array<{ issueId: string | null }>), ...(scheduledRetryRows as Array<{ issueId: string | null }>), ].flatMap((row) => row.issueId ? [row.issueId] : [])); const liveHandoffWakeIssueIds = new Set( (wakeRows as Array<{ issueId: string | null }>).flatMap((row) => row.issueId ? [row.issueId] : []), ); for (const row of issueRows) { if (row.companyId !== companyId || BLOCKED_INBOX_TERMINAL_STATUSES.includes(row.status as typeof BLOCKED_INBOX_TERMINAL_STATUSES[number]) || row.hiddenAt) { continue; } const source = issueRef(row); const handoff = handoffMap.get(row.id); const hasLiveHandoffContinuation = Boolean( (handoff?.state === "required" || handoff?.state === "escalated") && (liveHandoffRunIssueIds.has(row.id) || liveHandoffWakeIssueIds.has(row.id)) ); if (handoff && !hasLiveHandoffContinuation && (handoff.required || handoff.state === "escalated")) { result.set(row.id, attentionBase({ state: "missing_disposition", reason: "missing_successful_run_disposition", severity: "high", stoppedSinceAt: handoff.createdAt ?? row.updatedAt, owner: { type: row.assigneeAgentId ? "agent" : row.assigneeUserId ? "user" : "unknown", agentId: row.assigneeAgentId, userId: row.assigneeUserId, label: null, }, action: { label: "Choose disposition", detail: "Choose exactly one final disposition: done, cancelled, review/input, blocked with owner, delegated follow-up, or queued continuation.", }, sourceIssue: source, })); continue; } if (BLOCKED_INBOX_RECOVERY_ORIGIN_KINDS.includes(row.originKind as typeof BLOCKED_INBOX_RECOVERY_ORIGIN_KINDS[number])) { let sourceIssue: IssueBlockedInboxIssueRef | null = null; let leafIssue: IssueBlockedInboxIssueRef | null = null; if (row.originKind === "harness_liveness_escalation") { const parsed = parseIssueGraphLivenessIncidentKey(row.originId); if (parsed?.companyId === companyId) { sourceIssue = issueRef(issuesById.get(parsed.issueId)); leafIssue = issueRef(issuesById.get(parsed.leafIssueId)); } } else if (row.originKind === "stranded_issue_recovery" && row.originId) { sourceIssue = issueRef(issuesById.get(row.originId)); } result.set(row.id, attentionBase({ state: "recovery_open", reason: "open_recovery_issue", severity: "high", stoppedSinceAt: row.createdAt, owner: { type: row.assigneeAgentId ? "agent" : row.assigneeUserId ? "user" : "unknown", agentId: row.assigneeAgentId, userId: row.assigneeUserId, label: null, }, action: { label: "Resolve recovery", detail: "Restore a live path for the source work or record why this recovery issue is a false positive.", }, sourceIssue: sourceIssue ?? source, leafIssue, recoveryIssue: source, })); continue; } const interaction = interactionByIssueId.get(row.id); if (interaction) { const isUserQuestion = interaction.kind === "ask_user_questions" && Boolean(row.assigneeUserId); result.set(row.id, attentionBase({ state: "awaiting_decision", reason: isUserQuestion ? "pending_user_decision" : "pending_board_decision", severity: "medium", stoppedSinceAt: interaction.createdAt, owner: isUserQuestion ? { type: "user", agentId: null, userId: row.assigneeUserId, label: null } : { type: "board", agentId: null, userId: null, label: "Board" }, action: { label: isUserQuestion ? "Answer question" : "Answer confirmation", detail: "Respond to the pending issue-thread interaction so the assignee has a live next action.", }, sourceIssue: source, interactionId: interaction.id, })); continue; } const approval = approvalByIssueId.get(row.id); if (approval) { result.set(row.id, attentionBase({ state: "awaiting_decision", reason: "pending_board_decision", severity: "medium", stoppedSinceAt: approval.createdAt, owner: { type: "board", agentId: null, userId: null, label: "Board" }, action: { label: "Decide approval", detail: "Approve, reject, or request revision on the linked approval.", }, sourceIssue: source, approvalId: approval.approvalId, })); continue; } const finding = findingByIssueId.get(row.id); if (finding) { const leaf = finding.dependencyPath.length > 1 ? issuesById.get(finding.dependencyPath[finding.dependencyPath.length - 1]!.issueId) : issuesById.get(finding.recoveryIssueId); const ownerAgentId = finding.state === "blocked_by_unassigned_issue" ? null : finding.recommendedOwnerAgentId ?? row.assigneeAgentId ?? leaf?.assigneeAgentId ?? null; result.set(row.id, attentionBase({ state: "needs_attention", reason: finding.state as IssueBlockedInboxAttention["reason"], severity: finding.state === "blocked_by_assigned_backlog_issue" || finding.state === "in_review_without_action_path" ? "high" : finding.severity === "critical" ? "critical" : "high", stoppedSinceAt: leaf?.updatedAt ?? row.updatedAt, owner: { type: ownerAgentId ? "agent" : leaf?.assigneeUserId ? "user" : "unknown", agentId: ownerAgentId, userId: leaf?.assigneeUserId ?? null, label: null, }, action: { label: (() => { switch (finding.state) { case "blocked_by_unassigned_issue": return "Assign blocker"; case "blocked_by_assigned_backlog_issue": return "Resume parked blocker"; case "blocked_by_uninvokable_assignee": return "Assign active owner"; case "blocked_by_cancelled_issue": return "Replace blocker"; case "invalid_review_participant": return "Repair review participant"; case "in_review_without_action_path": return "Choose review path"; } })(), detail: finding.recommendedAction, }, sourceIssue: source, leafIssue: issueRef(leaf), recoveryIssue: issueRef(issuesById.get(finding.recoveryIssueId)), sampleIssueIdentifier: leaf?.identifier ?? finding.identifier, })); continue; } const hasMonitor = Boolean(row.monitorNextCheckAt && row.monitorNextCheckAt.getTime() > Date.now()); const external = row.status === "blocked" && !hasMonitor ? externalWaitFromDescription(row.description) : null; if (external) { result.set(row.id, attentionBase({ state: "external_wait", reason: "external_owner_action", severity: "medium", stoppedSinceAt: row.updatedAt, owner: { type: "external", agentId: null, userId: null, label: null }, action: { label: "External owner action", detail: null, }, sourceIssue: source, externalDetailsRedacted: true, })); continue; } const blockerAttention = await listIssueBlockerAttentionMap(dbOrTx, companyId, [row]); const blockerState = blockerAttention.get(row.id); if (row.status === "blocked" && (blockerState?.state === "needs_attention" || blockerState?.state === "stalled")) { result.set(row.id, attentionBase({ state: "needs_attention", reason: "blocked_chain_stalled", severity: "high", stoppedSinceAt: row.updatedAt, owner: { type: "unknown", agentId: null, userId: null, label: null }, action: { label: "Inspect blocker chain", detail: "Inspect the stalled blocker or review leaf and make the next owner/action explicit.", }, sourceIssue: source, sampleIssueIdentifier: blockerState.sampleStalledBlockerIdentifier ?? blockerState.sampleBlockerIdentifier, })); } } return result; } function parseIssueAssigneeAgentFilter( assigneeAgentId: IssueFilters["assigneeAgentId"], ): string | null | undefined { const normalizedRaw = typeof assigneeAgentId === "string" ? assigneeAgentId.trim() : assigneeAgentId; const normalized = normalizedRaw === "" ? undefined : normalizedRaw; if (typeof normalized !== "string") return normalized; return normalized.toLowerCase() === "null" ? null : normalized; } function assertValidAssigneeAgentFilter(assigneeAgentFilter: string | null | undefined) { if (typeof assigneeAgentFilter === "string" && !isUuidLike(assigneeAgentFilter)) { throw unprocessable("assigneeAgentId must be a UUID or 'null'"); } } async function blockedInboxIssueConditions( dbOrTx: any, companyId: string, filters?: IssueFilters, ) { const conditions = [ eq(issues.companyId, companyId), visibleIssueCondition(), notInArray(issues.status, [...BLOCKED_INBOX_TERMINAL_STATUSES]), ]; const touchedByUserId = filters?.touchedByUserId?.trim() || undefined; const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined; const unreadForUserId = filters?.unreadForUserId?.trim() || undefined; const contextUserId = unreadForUserId ?? touchedByUserId ?? inboxArchivedByUserId; if (filters?.descendantOf) { conditions.push(sql` ${issues.id} IN ( WITH RECURSIVE descendants(id) AS ( SELECT ${issues.id} FROM ${issues} WHERE ${issues.companyId} = ${companyId} AND ${issues.parentId} = ${filters.descendantOf} UNION SELECT ${issues.id} FROM ${issues} JOIN descendants ON ${issues.parentId} = descendants.id WHERE ${issues.companyId} = ${companyId} ) SELECT id FROM descendants ) `); } const lowTrustCondition = lowTrustBoundaryIssueCondition(companyId, filters?.lowTrustBoundary); if (lowTrustCondition) conditions.push(lowTrustCondition); const statuses = parseStatusFilter(filters?.status); if (statuses.length > 0) { conditions.push(statuses.length === 1 ? eq(issues.status, statuses[0]!) : inArray(issues.status, statuses)); } const assigneeAgentFilter = parseIssueAssigneeAgentFilter(filters?.assigneeAgentId); assertValidAssigneeAgentFilter(assigneeAgentFilter); if (assigneeAgentFilter === null) { conditions.push(isNull(issues.assigneeAgentId)); } else if (assigneeAgentFilter) { conditions.push(eq(issues.assigneeAgentId, assigneeAgentFilter)); } if (filters?.participantAgentId) conditions.push(participatedByAgentCondition(companyId, filters.participantAgentId)); if (filters?.assigneeUserId) conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId)); if (touchedByUserId) conditions.push(touchedByUserCondition(companyId, touchedByUserId)); if (inboxArchivedByUserId) conditions.push(inboxVisibleForUserCondition(companyId, inboxArchivedByUserId)); if (unreadForUserId) conditions.push(unreadForUserCondition(companyId, unreadForUserId)); if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId)); if (filters?.workspaceId) { conditions.push(or( eq(issues.executionWorkspaceId, filters.workspaceId), eq(issues.projectWorkspaceId, filters.workspaceId), )!); } if (filters?.executionWorkspaceId) conditions.push(eq(issues.executionWorkspaceId, filters.executionWorkspaceId)); if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId)); if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind)); if (filters?.originKindPrefix) conditions.push(like(issues.originKind, `${filters.originKindPrefix}%`)); if (filters?.originId) conditions.push(eq(issues.originId, filters.originId)); if (filters?.hasPlanDocument !== undefined) { conditions.push(hasPlanDocumentCondition(companyId, filters.hasPlanDocument)); } if (!shouldIncludePluginOperationIssues(filters)) conditions.push(nonPluginOperationIssueCondition()); if (filters?.labelId) { const labeledIssueIds = await dbOrTx .select({ issueId: issueLabels.issueId }) .from(issueLabels) .where(and(eq(issueLabels.companyId, companyId), eq(issueLabels.labelId, filters.labelId))); if (labeledIssueIds.length === 0) return { conditions: [sql`false`], contextUserId }; conditions.push(inArray(issues.id, labeledIssueIds.map((row: { issueId: string }) => row.issueId))); } if (filters?.excludeRoutineExecutions && !filters?.originKind && !filters?.originId) { conditions.push(ne(issues.originKind, "routine_execution")); } return { conditions, contextUserId }; } async function listBlockedInboxIssues( dbOrTx: any, companyId: string, filters?: IssueFilters, ): Promise> { const { conditions, contextUserId } = await blockedInboxIssueConditions(dbOrTx, companyId, filters); const rows = (await dbOrTx .select(issueListSelect) .from(issues) .where(and(...conditions)) .orderBy(desc(issueCanonicalLastActivityAtExpr(companyId)), desc(issues.updatedAt), desc(issues.id))) .map((row: any) => ({ ...row, description: decodeDatabaseTextPreview(row.description, ISSUE_LIST_DESCRIPTION_MAX_CHARS), })); const withLabels = await withIssueLabels(dbOrTx, rows); const withRuns = withActiveRuns(withLabels, await activeRunMapForIssues(dbOrTx, withLabels)); if (withRuns.length === 0) return []; const issueIds = withRuns.map((row) => row.id); const includeLiveDescendantSummary = filters?.includeLiveDescendantSummary === true; const [ statsRows, readRows, lastActivityRows, blockedByMap, blockerAttentionByIssueId, reviewAttentionByIssueId, productivityReviewByIssueId, blockedInboxAttentionByIssueId, liveDescendantCountByIssueId, ] = await Promise.all([ contextUserId ? userCommentStatsForIssues(dbOrTx, companyId, contextUserId, issueIds) : Promise.resolve([]), contextUserId ? userReadStatsForIssues(dbOrTx, companyId, contextUserId, issueIds) : Promise.resolve([]), lastActivityStatsForIssues(dbOrTx, companyId, issueIds), blockedByMapForIssues(dbOrTx, companyId, issueIds), listIssueBlockerAttentionMap(dbOrTx, companyId, withRuns), listIssueReviewAttentionMap(dbOrTx, companyId, withRuns), listIssueProductivityReviewMap(dbOrTx, companyId, issueIds), listIssueBlockedInboxAttentionMap(dbOrTx, companyId, withRuns), includeLiveDescendantSummary ? liveDescendantCountMapForIssues(dbOrTx, companyId, issueIds) : Promise.resolve(new Map()), ]); const rawSearchInput = filters?.q?.trim() ?? ""; const rawSearch = rawSearchInput.toLowerCase(); const commentSearchMatchIssueIds = new Set(); if (rawSearchInput) { const containsPattern = `%${escapeLikePattern(rawSearchInput)}%`; for (const issueIdChunk of chunkList(issueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { const rows = await dbOrTx .select({ issueId: issueComments.issueId }) .from(issueComments) .where(and( eq(issueComments.companyId, companyId), inArray(issueComments.issueId, issueIdChunk), isNull(issueComments.deletedAt), sql`${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'`, )); for (const row of rows as Array<{ issueId: string }>) commentSearchMatchIssueIds.add(row.issueId); } } const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row])); const readByIssueId = new Map(readRows.map((row) => [row.issueId, row.myLastReadAt])); const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row])); const enriched = withRuns.flatMap((row) => { const blockedInboxAttention = blockedInboxAttentionByIssueId.get(row.id); if (!blockedInboxAttention) return []; if ( rawSearch && !blockedInboxSearchText(blockedInboxAttention, row).includes(rawSearch) && !commentSearchMatchIssueIds.has(row.id) ) return []; const activity = lastActivityByIssueId.get(row.id); const lastActivityAt = latestIssueActivityAt( row.updatedAt, activity?.latestCommentAt ?? null, activity?.latestLogAt ?? null, ) ?? row.updatedAt; return [{ ...row, description: blockedInboxResponseDescription(blockedInboxAttention, row), blockedBy: blockedByMap.get(row.id) ?? [], lastActivityAt, ...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}), reviewAttention: reviewAttentionByIssueId.get(row.id) ?? reviewAttentionNone(), blockedInboxAttention, ...(productivityReviewByIssueId.has(row.id) ? { productivityReview: productivityReviewByIssueId.get(row.id) } : {}), ...(includeLiveDescendantSummary ? { liveDescendantCount: liveDescendantCountByIssueId.get(row.id) ?? 0 } : {}), ...(contextUserId ? deriveIssueUserContext(row, contextUserId, { myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null, myLastReadAt: readByIssueId.get(row.id) ?? null, lastExternalCommentAt: statsByIssueId.get(row.id)?.lastExternalCommentAt ?? null, }) : {}), }]; }).sort(compareBlockedInboxRows); const offset = typeof filters?.offset === "number" && Number.isFinite(filters.offset) ? Math.max(0, Math.floor(filters.offset)) : 0; const limit = typeof filters?.limit === "number" && Number.isFinite(filters.limit) ? Math.max(1, Math.floor(filters.limit)) : undefined; return limit === undefined ? enriched.slice(offset) : enriched.slice(offset, offset + limit); } async function countBlockedInboxIssues(dbOrTx: any, companyId: string, filters?: IssueFilters): Promise { const { conditions } = await blockedInboxIssueConditions(dbOrTx, companyId, filters); const rows = (await dbOrTx .select() .from(issues) .where(and(...conditions))) as IssueRow[]; if (rows.length === 0) return 0; const blockedInboxAttentionByIssueId = await listIssueBlockedInboxAttentionMap(dbOrTx, companyId, rows); const rawSearchInput = filters?.q?.trim() ?? ""; const rawSearch = rawSearchInput.toLowerCase(); const commentSearchMatchIssueIds = new Set(); if (rawSearchInput) { const issueIds = rows.map((row) => row.id); const containsPattern = `%${escapeLikePattern(rawSearchInput)}%`; for (const issueIdChunk of chunkList(issueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { const commentRows = await dbOrTx .select({ issueId: issueComments.issueId }) .from(issueComments) .where(and( eq(issueComments.companyId, companyId), inArray(issueComments.issueId, issueIdChunk), isNull(issueComments.deletedAt), sql`${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'`, )); for (const row of commentRows as Array<{ issueId: string }>) commentSearchMatchIssueIds.add(row.issueId); } } return rows.reduce((count: number, row: IssueRow) => { const attention = blockedInboxAttentionByIssueId.get(row.id); if (!attention) return count; if ( rawSearch && !blockedInboxSearchText(attention, row).includes(rawSearch) && !commentSearchMatchIssueIds.has(row.id) ) return count; return count + 1; }, 0); } export function issueService(db: Db) { const instanceSettings = instanceSettingsService(db); const treeControlSvc = issueTreeControlService(db); function normalizeCreateIssueTitle(title: string) { return title.trim().replace(/\s+/g, " ").toLowerCase(); } async function getIssueByUuid(id: string) { const row = await db .select() .from(issues) .where(eq(issues.id, id)) .then((rows) => rows[0] ?? null); if (!row) return null; const [enriched] = await withIssueLabels(db, [row]); return enriched; } async function getIssueByIdentifier(identifier: string) { const row = await db .select() .from(issues) .where(eq(issues.identifier, identifier.toUpperCase())) .then((rows) => rows[0] ?? null); if (!row) return null; const [enriched] = await withIssueLabels(db, [row]); return enriched; } async function getCurrentScheduledRetryForIssue(issueId: string, companyId: string): Promise { const row = await db .select({ runId: heartbeatRuns.id, status: heartbeatRuns.status, agentId: heartbeatRuns.agentId, agentName: agents.name, retryOfRunId: heartbeatRuns.retryOfRunId, scheduledRetryAt: heartbeatRuns.scheduledRetryAt, scheduledRetryAttempt: heartbeatRuns.scheduledRetryAttempt, scheduledRetryReason: heartbeatRuns.scheduledRetryReason, error: heartbeatRuns.error, errorCode: heartbeatRuns.errorCode, }) .from(heartbeatRuns) .innerJoin(agents, eq(heartbeatRuns.agentId, agents.id)) .where( and( eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.status, "scheduled_retry"), sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`, ), ) .orderBy(asc(heartbeatRuns.scheduledRetryAt), asc(heartbeatRuns.createdAt), asc(heartbeatRuns.id)) .limit(1) .then((rows) => rows[0] ?? null); return row ? { ...row, status: "scheduled_retry" } : null; } function deriveIssueCommentAuthorType(comment: { authorType?: string | null; authorAgentId?: string | null; authorUserId?: string | null; }): IssueCommentAuthorType { const explicit = issueCommentAuthorTypeSchema.safeParse(comment.authorType); if (explicit.success) return explicit.data; if (comment.authorAgentId) return "agent"; if (comment.authorUserId) return "user"; return "system"; } function assertIssueCommentAuthorTypeAllowed( actor: { agentId?: string | null; userId?: string | null }, authorType: IssueCommentAuthorType, ) { if (actor.agentId && authorType !== "agent") { throw unprocessable("Comment authorType must match authenticated actor"); } if (actor.userId && authorType !== "user") { throw unprocessable("Comment authorType must match authenticated actor"); } if (!actor.agentId && !actor.userId && authorType !== "system") { throw unprocessable("System comments cannot use user or agent authorType without an author id"); } } function redactIssueComment( comment: T, censorUsernameInLogs: boolean, ): T & { authorType: IssueCommentAuthorType; presentation: IssueCommentPresentation | null; metadata: IssueCommentMetadata | null; } { const deletedAt = comment.deletedAt ?? null; if (deletedAt) { return { ...comment, authorType: deriveIssueCommentAuthorType(comment), body: "", presentation: null, metadata: null, deletedAt, deletedByType: comment.deletedByType ?? null, deletedByAgentId: comment.deletedByAgentId ?? null, deletedByUserId: comment.deletedByUserId ?? null, deletedByRunId: comment.deletedByRunId ?? null, }; } return { ...comment, authorType: deriveIssueCommentAuthorType(comment), body: redactCurrentUserText(comment.body, { enabled: censorUsernameInLogs }), presentation: issueCommentPresentationSchema.nullable().catch(null).parse(comment.presentation ?? null), metadata: issueCommentMetadataSchema.nullable().catch(null).parse(comment.metadata ?? null), }; } async function readRunLogText(run: { runId?: string | null; logStore: string | null; logRef: string | null; logBytes: number | null; }) { if (run.logStore !== "local_file" || !run.logRef) return ""; const logBytes = Number(run.logBytes ?? 0); if (!Number.isFinite(logBytes) || logBytes <= 0) return ""; const store = getRunLogStore(); let offset = 0; let content = ""; let nextOffset: number | undefined = 0; try { while (nextOffset !== undefined) { const remainingBytes = ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES - Buffer.byteLength(content, "utf8"); if (remainingBytes <= 0) break; const chunk = await store.read( { store: "local_file", logRef: run.logRef }, { offset, limitBytes: Math.min(ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES, remainingBytes), }, ); content += chunk.content; nextOffset = chunk.nextOffset; offset = chunk.nextOffset ?? 0; } } catch (err) { if (err instanceof HttpError && err.status === 404) { logger.warn( { err, runId: run.runId ?? undefined, logRef: run.logRef }, "missing heartbeat run log while deriving issue comment metadata", ); return content; } throw err; } return content; } // Persist a resolved attribution so subsequent reads stop re-scanning run // logs (and old "Board" threads stay fixed durably). Best-effort: a write // failure must never break the read path. The `IS NULL` guard keeps this // idempotent and avoids clobbering a value another reader just stored. async function persistDerivedIssueCommentAttribution( derivedByCommentId: ReadonlyMap, ) { if (derivedByCommentId.size === 0) return; // One bulk `UPDATE ... FROM (VALUES ...)` so the read path is never blocked // on N sequential round-trips for a large legacy thread. The `IS NULL` guard // keeps this idempotent and avoids clobbering a value another reader just // stored. Best-effort: a write failure must never break the read path. const rows = [...derivedByCommentId].map( ([commentId, derived]) => sql`(${commentId}::uuid, ${derived.derivedAuthorAgentId}::uuid, ${derived.derivedCreatedByRunId}::uuid, ${derived.derivedAuthorSource}::text)`, ); try { await db.execute(sql` UPDATE ${issueComments} AS c SET derived_author_agent_id = v.agent_id, derived_created_by_run_id = v.run_id, derived_author_source = v.source FROM (VALUES ${sql.join(rows, sql`, `)}) AS v(comment_id, agent_id, run_id, source) WHERE c.id = v.comment_id AND c.derived_author_agent_id IS NULL `); } catch (err) { logger.warn( { err, commentIds: [...derivedByCommentId.keys()] }, "failed to persist derived issue-comment attribution", ); } } async function enrichCommentsWithDerivedAgentAttribution< T extends { id: string; companyId: string; issueId: string; authorAgentId?: string | null; authorUserId?: string | null; createdByRunId?: string | null; derivedAuthorAgentId?: string | null; createdAt: Date | string; }, >(comments: readonly T[]) { // Candidates: a non-human author, no stored agent, and not already resolved // by a previous read / the backfill migration. const preliminary = comments.filter((comment) => !comment.authorAgentId && !!comment.authorUserId && !comment.derivedAuthorAgentId, ); if (preliminary.length === 0) return comments; const companyId = comments[0]?.companyId ?? null; const issueId = comments[0]?.issueId ?? null; if (!companyId || !issueId) return comments; // Guard: never reattribute a comment whose author maps to a genuine user // profile. Only the non-human sentinels agents post under (e.g. // `local-board`) are eligible — even though `local-board` is itself a row in // the `user` table, so a plain "exists in user table" check would wrongly // exclude every mis-attributed agent comment. const nonSentinelAuthorUserIds = [ ...new Set( preliminary .map((comment) => comment.authorUserId) .filter((id): id is string => !!id && !NON_HUMAN_SENTINEL_AUTHOR_USER_IDS.has(id)), ), ]; const genuineUserIds = nonSentinelAuthorUserIds.length ? new Set( ( await db .select({ id: authUsers.id }) .from(authUsers) .where(inArray(authUsers.id, nonSentinelAuthorUserIds)) ).map((row) => row.id), ) : new Set(); // `preliminary` already guarantees a truthy `authorUserId`, so only the two // "not a genuine user" arms are live: the explicit non-human sentinel, or an // author id absent from the `user` table. const candidates = preliminary.filter( (comment) => NON_HUMAN_SENTINEL_AUTHOR_USER_IDS.has(comment.authorUserId!) || !genuineUserIds.has(comment.authorUserId!), ); if (candidates.length === 0) return comments; const minCommentCreatedAtMs = candidates.reduce((min, comment) => { const timestamp = toTimestampMs(comment.createdAt); if (timestamp === null) return min; return min === null ? timestamp : Math.min(min, timestamp); }, null); const maxCommentCreatedAtMs = candidates.reduce((max, comment) => { const timestamp = toTimestampMs(comment.createdAt); if (timestamp === null) return max; return max === null ? timestamp : Math.max(max, timestamp); }, null); if (minCommentCreatedAtMs === null || maxCommentCreatedAtMs === null) return comments; const minCommentCreatedAt = new Date(minCommentCreatedAtMs).toISOString(); const maxCommentCreatedAt = new Date( maxCommentCreatedAtMs + ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS, ).toISOString(); // The runs the comments' own `createdByRunId` point at — fetched // unconditionally so the lossless run-id tier resolves even when a run is // not otherwise associated with the issue. const ownRunIds = [ ...new Set(candidates.map((comment) => comment.createdByRunId).filter((id): id is string => !!id)), ]; const runs = await db .select({ runId: heartbeatRuns.id, agentId: heartbeatRuns.agentId, createdAt: heartbeatRuns.createdAt, startedAt: heartbeatRuns.startedAt, finishedAt: heartbeatRuns.finishedAt, logStore: heartbeatRuns.logStore, logRef: heartbeatRuns.logRef, logBytes: heartbeatRuns.logBytes, }) .from(heartbeatRuns) .where( and( eq(heartbeatRuns.companyId, companyId), or( and( or( sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`, sql`exists ( select 1 from ${activityLog} where ${activityLog.companyId} = ${companyId} and ${activityLog.entityType} = 'issue' and ${activityLog.entityId} = ${issueId} and ${activityLog.runId} = ${heartbeatRuns.id} )`, ), sql`coalesce(${heartbeatRuns.finishedAt}, ${heartbeatRuns.createdAt}) >= ${minCommentCreatedAt}::timestamptz`, sql`coalesce(${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) <= ${maxCommentCreatedAt}::timestamptz`, ), ownRunIds.length > 0 ? inArray(heartbeatRuns.id, ownRunIds) : sql`false`, ), ), ) .orderBy(desc(heartbeatRuns.createdAt)); if (runs.length === 0) return comments; // Pass 1: resolve the run-id tier, which never reads log bodies. Most // comments resolve here, so we avoid object-storage reads entirely. const runsWithoutLogs = runs.map((run) => ({ ...run, logContent: "" })); const derivedByCommentId = new Map( deriveIssueCommentRunLogAttribution(candidates, runsWithoutLogs), ); // Pass 2: for comments still unresolved after the run-id tier, read the logs // of any run whose window overlaps such a comment, to look for the explicit // `comment id:` post marker. The marker is a lossless signal regardless of // how many runs overlap, so we do not short-circuit on the single-run case. const unresolved = candidates.filter((comment) => !derivedByCommentId.has(comment.id)); if (unresolved.length > 0) { const runIdsToRead = new Set(); for (const run of runs) { const runStartMs = toTimestampMs(run.startedAt ?? run.createdAt); const runEndMs = toTimestampMs(run.finishedAt ?? run.createdAt); if (runStartMs === null || runEndMs === null) continue; for (const comment of unresolved) { const commentCreatedAtMs = toTimestampMs(comment.createdAt); if (commentCreatedAtMs === null) continue; if ( commentCreatedAtMs >= runStartMs && commentCreatedAtMs <= runEndMs + ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS ) { runIdsToRead.add(run.runId); break; } } } if (runIdsToRead.size > 0) { const runsToRead = runs.filter((run) => runIdsToRead.has(run.runId)); const logByRunId = new Map(); for (let index = 0; index < runsToRead.length; index += ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS) { const batch = runsToRead.slice(index, index + ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS); await Promise.all( batch.map(async (run) => { logByRunId.set(run.runId, await readRunLogText(run)); }), ); } const runsWithLogs = runs.map((run) => ({ ...run, logContent: logByRunId.get(run.runId) ?? "" })); for (const [commentId, derived] of deriveIssueCommentRunLogAttribution(unresolved, runsWithLogs)) { derivedByCommentId.set(commentId, derived); } } } if (derivedByCommentId.size === 0) return comments; await persistDerivedIssueCommentAttribution(derivedByCommentId); return comments.map((comment) => { const derived = derivedByCommentId.get(comment.id); return derived ? { ...comment, ...derived } : comment; }); } async function isTreeHoldInteractionCheckoutAllowed( companyId: string, checkoutRunId: string | null, _gate: ActiveIssueTreePauseHoldGate, ) { if (!checkoutRunId) return false; const run = await db .select({ id: heartbeatRuns.id, agentId: heartbeatRuns.agentId, wakeupRequestId: heartbeatRuns.wakeupRequestId, contextSnapshot: heartbeatRuns.contextSnapshot, }) .from(heartbeatRuns) .where(and(eq(heartbeatRuns.id, checkoutRunId), eq(heartbeatRuns.companyId, companyId))) .then((rows) => rows[0] ?? null); const issueId = readStringFromRecord(run?.contextSnapshot, "issueId"); if (!run || !issueId) return false; return isVerifiedIssueTreeControlInteractionWake(db, { companyId, issueId, agentId: run.agentId, runId: run.id, wakeupRequestId: run.wakeupRequestId, contextSnapshot: run.contextSnapshot as Record | null | undefined, }); } async function assertAssignableUser(companyId: string, userId: string) { const membership = await db .select({ id: companyMemberships.id }) .from(companyMemberships) .where( and( eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId), eq(companyMemberships.status, "active"), ), ) .then((rows) => rows[0] ?? null); if (!membership) { throw notFound("Assignee user not found"); } } async function assertValidProjectWorkspace( companyId: string, projectId: string | null | undefined, projectWorkspaceId: string, dbOrTx: DbReader = db, ) { const workspace = await dbOrTx .select({ id: projectWorkspaces.id, companyId: projectWorkspaces.companyId, projectId: projectWorkspaces.projectId, }) .from(projectWorkspaces) .where(eq(projectWorkspaces.id, projectWorkspaceId)) .then((rows) => rows[0] ?? null); if (!workspace) throw notFound("Project workspace not found"); if (workspace.companyId !== companyId) throw unprocessable("Project workspace must belong to same company"); if (projectId && workspace.projectId !== projectId) { throw unprocessable("Project workspace must belong to the selected project"); } return workspace; } async function assertValidExecutionWorkspace( companyId: string, projectId: string | null | undefined, executionWorkspaceId: string, dbOrTx: DbReader = db, ) { const workspace = await dbOrTx .select({ id: executionWorkspaces.id, companyId: executionWorkspaces.companyId, projectId: executionWorkspaces.projectId, }) .from(executionWorkspaces) .where(eq(executionWorkspaces.id, executionWorkspaceId)) .then((rows) => rows[0] ?? null); if (!workspace) throw notFound("Execution workspace not found"); if (workspace.companyId !== companyId) throw unprocessable("Execution workspace must belong to same company"); if (projectId && workspace.projectId !== projectId) { throw unprocessable("Execution workspace must belong to the selected project"); } return workspace; } async function assertValidLabelIds(companyId: string, labelIds: string[], dbOrTx: any = db) { if (labelIds.length === 0) return; const existing = await dbOrTx .select({ id: labels.id }) .from(labels) .where(and(eq(labels.companyId, companyId), inArray(labels.id, labelIds))); if (existing.length !== new Set(labelIds).size) { throw unprocessable("One or more labels are invalid for this company"); } } async function syncIssueLabels( issueId: string, companyId: string, labelIds: string[], dbOrTx: any = db, ) { const deduped = [...new Set(labelIds)]; await assertValidLabelIds(companyId, deduped, dbOrTx); await dbOrTx.delete(issueLabels).where(eq(issueLabels.issueId, issueId)); if (deduped.length === 0) return; await dbOrTx.insert(issueLabels).values( deduped.map((labelId) => ({ issueId, labelId, companyId, })), ); } async function getIssueRelationSummaryMap( companyId: string, issueIds: string[], dbOrTx: DbReader = db, ): Promise> { const uniqueIssueIds = [...new Set(issueIds)]; const empty = new Map(); for (const issueId of uniqueIssueIds) { empty.set(issueId, { blockedBy: [], blocks: [] }); } if (uniqueIssueIds.length === 0) return empty; const [blockedByRows, blockingRows] = await Promise.all([ dbOrTx .select({ currentIssueId: issueRelations.relatedIssueId, relatedId: issues.id, identifier: issues.identifier, title: issues.title, status: issues.status, priority: issues.priority, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, }) .from(issueRelations) .innerJoin(issues, eq(issueRelations.issueId, issues.id)) .where( and( eq(issueRelations.companyId, companyId), eq(issueRelations.type, "blocks"), inArray(issueRelations.relatedIssueId, uniqueIssueIds), ), ), dbOrTx .select({ currentIssueId: issueRelations.issueId, relatedId: issues.id, identifier: issues.identifier, title: issues.title, status: issues.status, priority: issues.priority, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, }) .from(issueRelations) .innerJoin(issues, eq(issueRelations.relatedIssueId, issues.id)) .where( and( eq(issueRelations.companyId, companyId), eq(issueRelations.type, "blocks"), inArray(issueRelations.issueId, uniqueIssueIds), ), ), ]); for (const row of blockedByRows) { empty.get(row.currentIssueId)?.blockedBy.push(summarizeIssueRelationRow(row)); } for (const row of blockingRows) { empty.get(row.currentIssueId)?.blocks.push(summarizeIssueRelationRow(row)); } const terminalByRoot = await terminalExplicitBlockersByRoot( companyId, [...empty.values()].flatMap((relations) => relations.blockedBy), dbOrTx, ); for (const relations of empty.values()) { relations.blockedBy.sort((a, b) => a.title.localeCompare(b.title)); for (const blocker of relations.blockedBy) { const terminalBlockers = terminalByRoot.get(blocker.id); if (terminalBlockers && terminalBlockers.length > 0) { blocker.terminalBlockers = terminalBlockers; } } relations.blocks.sort((a, b) => a.title.localeCompare(b.title)); } return empty; } async function withIssueRelationSummaries( companyId: string, rows: T[], dbOrTx: DbReader = db, ): Promise> { if (rows.length === 0) return []; const relationMap = await getIssueRelationSummaryMap( companyId, rows.map((row) => row.id), dbOrTx, ); return rows.map((row) => ({ ...row, ...(relationMap.get(row.id) ?? { blockedBy: [], blocks: [] }), })); } async function assertNoBlockingCycles( companyId: string, issueId: string, blockerIssueIds: string[], dbOrTx: DbReader = db, ) { if (blockerIssueIds.length === 0) return; const rows = await dbOrTx .select({ blockerIssueId: issueRelations.issueId, blockedIssueId: issueRelations.relatedIssueId, }) .from(issueRelations) .where(and(eq(issueRelations.companyId, companyId), eq(issueRelations.type, "blocks"))); const adjacency = new Map(); for (const row of rows) { const list = adjacency.get(row.blockerIssueId) ?? []; list.push(row.blockedIssueId); adjacency.set(row.blockerIssueId, list); } for (const blockerIssueId of blockerIssueIds) { const queue = [...(adjacency.get(issueId) ?? [])]; const visited = new Set([issueId]); while (queue.length > 0) { const current = queue.shift()!; if (current === blockerIssueId) { throw unprocessable("Blocking relations cannot contain cycles"); } if (visited.has(current)) continue; visited.add(current); queue.push(...(adjacency.get(current) ?? [])); } } } async function syncBlockedByIssueIds( issueId: string, companyId: string, blockedByIssueIds: string[], actor: { agentId?: string | null; userId?: string | null } = {}, dbOrTx: any = db, ) { const deduped = [...new Set(blockedByIssueIds)]; if (deduped.some((candidate) => candidate === issueId)) { throw unprocessable("Issue cannot be blocked by itself"); } if (deduped.length > 0) { const lockedIssueIds = [issueId, ...deduped].sort(); await dbOrTx.execute( sql`SELECT ${issues.id} FROM ${issues} WHERE ${and(eq(issues.companyId, companyId), inArray(issues.id, lockedIssueIds))} ORDER BY ${issues.id} FOR UPDATE`, ); const relatedIssues = await dbOrTx .select({ id: issues.id }) .from(issues) .where(and(eq(issues.companyId, companyId), inArray(issues.id, deduped))); if (relatedIssues.length !== deduped.length) { throw unprocessable("Blocked-by issues must belong to the same company"); } await assertNoBlockingCycles(companyId, issueId, deduped, dbOrTx); } await dbOrTx .delete(issueRelations) .where( and( eq(issueRelations.companyId, companyId), eq(issueRelations.relatedIssueId, issueId), eq(issueRelations.type, "blocks"), ), ); if (deduped.length === 0) return; await dbOrTx.insert(issueRelations).values( deduped.map((blockerIssueId) => ({ companyId, issueId: blockerIssueId, relatedIssueId: issueId, type: "blocks", createdByAgentId: actor.agentId ?? null, createdByUserId: actor.userId ?? null, })), ); } async function isTerminalOrMissingHeartbeatRun(runId: string, dbOrTx: DbReader = db) { return heartbeatRunIsTerminalOrMissing(dbOrTx, runId); } async function adoptStaleCheckoutRun(input: { issueId: string; actorAgentId: string; actorRunId: string; expectedCheckoutRunId: string; }) { return db.transaction(async (tx) => { const lockedIssue = await tx .select({ id: issues.id, status: issues.status, assigneeAgentId: issues.assigneeAgentId, checkoutRunId: issues.checkoutRunId, executionRunId: issues.executionRunId, }) .from(issues) .where(eq(issues.id, input.issueId)) .for("update") .then((rows) => rows[0] ?? null); if (!lockedIssue) { return { adopted: null, latest: null }; } if ( lockedIssue.status !== "in_progress" || lockedIssue.assigneeAgentId !== input.actorAgentId || lockedIssue.checkoutRunId !== input.expectedCheckoutRunId ) { return { adopted: null, latest: lockedIssue }; } await Promise.all([ tx.execute( sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${input.expectedCheckoutRunId} for update`, ), tx.execute( sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${input.actorRunId} for update`, ), ]); const [existingRun, actorRun] = await Promise.all([ tx .select({ status: heartbeatRuns.status }) .from(heartbeatRuns) .where(eq(heartbeatRuns.id, input.expectedCheckoutRunId)) .then((rows) => rows[0] ?? null), tx .select({ status: heartbeatRuns.status }) .from(heartbeatRuns) .where(eq(heartbeatRuns.id, input.actorRunId)) .then((rows) => rows[0] ?? null), ]); const stale = !existingRun || TERMINAL_HEARTBEAT_RUN_STATUSES.has(existingRun.status); const actorLive = actorRun && !TERMINAL_HEARTBEAT_RUN_STATUSES.has(actorRun.status); if (!stale || !actorLive) { return { adopted: null, latest: lockedIssue }; } const now = new Date(); const adopted = await tx .update(issues) .set({ checkoutRunId: input.actorRunId, executionRunId: input.actorRunId, executionLockedAt: now, updatedAt: now, }) .where( and( eq(issues.id, input.issueId), eq(issues.status, "in_progress"), eq(issues.assigneeAgentId, input.actorAgentId), eq(issues.checkoutRunId, input.expectedCheckoutRunId), ), ) .returning({ id: issues.id, status: issues.status, assigneeAgentId: issues.assigneeAgentId, checkoutRunId: issues.checkoutRunId, executionRunId: issues.executionRunId, }) .then((rows) => rows[0] ?? null); if (adopted) { return { adopted, latest: adopted }; } const latest = await tx .select({ id: issues.id, status: issues.status, assigneeAgentId: issues.assigneeAgentId, checkoutRunId: issues.checkoutRunId, executionRunId: issues.executionRunId, }) .from(issues) .where(eq(issues.id, input.issueId)) .then((rows) => rows[0] ?? null); return { adopted: null, latest }; }); } async function adoptUnownedCheckoutRun(input: { issueId: string; actorAgentId: string; actorRunId: string; }) { return db.transaction(async (tx) => { await tx.execute( sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${input.actorRunId} for update`, ); const actorRun = await tx .select({ status: heartbeatRuns.status }) .from(heartbeatRuns) .where(eq(heartbeatRuns.id, input.actorRunId)) .then((rows) => rows[0] ?? null); if (!actorRun || TERMINAL_HEARTBEAT_RUN_STATUSES.has(actorRun.status)) return null; const now = new Date(); const adopted = await tx .update(issues) .set({ checkoutRunId: input.actorRunId, executionRunId: input.actorRunId, executionLockedAt: now, updatedAt: now, }) .where( and( eq(issues.id, input.issueId), eq(issues.status, "in_progress"), eq(issues.assigneeAgentId, input.actorAgentId), isNull(issues.checkoutRunId), or(isNull(issues.executionRunId), eq(issues.executionRunId, input.actorRunId)), ), ) .returning({ id: issues.id, status: issues.status, assigneeAgentId: issues.assigneeAgentId, checkoutRunId: issues.checkoutRunId, executionRunId: issues.executionRunId, }) .then((rows) => rows[0] ?? null); return adopted; }); } async function clearExecutionRunIfTerminal(issueId: string): Promise { return db.transaction(async (tx) => { await tx.execute( sql`select ${issues.id} from ${issues} where ${issues.id} = ${issueId} for update`, ); const issue = await tx .select({ executionRunId: issues.executionRunId }) .from(issues) .where(eq(issues.id, issueId)) .then((rows) => rows[0] ?? null); if (!issue?.executionRunId) return false; await tx.execute( sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${issue.executionRunId} for update`, ); const run = await tx .select({ status: heartbeatRuns.status }) .from(heartbeatRuns) .where(eq(heartbeatRuns.id, issue.executionRunId)) .then((rows) => rows[0] ?? null); if (run && !TERMINAL_HEARTBEAT_RUN_STATUSES.has(run.status)) return false; const updated = await tx .update(issues) .set({ executionRunId: null, executionAgentNameKey: null, executionLockedAt: null, updatedAt: new Date(), }) .where( and( eq(issues.id, issueId), eq(issues.executionRunId, issue.executionRunId), ), ) .returning({ id: issues.id }) .then((rows) => rows[0] ?? null); return Boolean(updated); }); } // Symmetric to clearExecutionRunIfTerminal. Clears checkoutRunId (and the // bundled execution lock cols) when the row's checkoutRunId points at a // heartbeat run that is terminal or no longer exists. No assignee/status // precondition: a terminal run holds no real claim regardless of who is // assigned or what status the issue is currently in. async function clearCheckoutRunIfTerminal(issueId: string): Promise { return db.transaction(async (tx) => { await tx.execute( sql`select ${issues.id} from ${issues} where ${issues.id} = ${issueId} for update`, ); const issue = await tx .select({ checkoutRunId: issues.checkoutRunId, executionRunId: issues.executionRunId }) .from(issues) .where(eq(issues.id, issueId)) .then((rows) => rows[0] ?? null); if (!issue?.checkoutRunId) return false; await tx.execute( sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${issue.checkoutRunId} for update`, ); const run = await tx .select({ status: heartbeatRuns.status }) .from(heartbeatRuns) .where(eq(heartbeatRuns.id, issue.checkoutRunId)) .then((rows) => rows[0] ?? null); if (run && !TERMINAL_HEARTBEAT_RUN_STATUSES.has(run.status)) return false; if (issue.executionRunId && issue.executionRunId !== issue.checkoutRunId) { await tx.execute( sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${issue.executionRunId} for update`, ); const executionRun = await tx .select({ status: heartbeatRuns.status }) .from(heartbeatRuns) .where(eq(heartbeatRuns.id, issue.executionRunId)) .then((rows) => rows[0] ?? null); if (executionRun && !TERMINAL_HEARTBEAT_RUN_STATUSES.has(executionRun.status)) return false; } const updated = await tx .update(issues) .set({ checkoutRunId: null, executionRunId: null, executionAgentNameKey: null, executionLockedAt: null, updatedAt: new Date(), }) .where( and( eq(issues.id, issueId), eq(issues.checkoutRunId, issue.checkoutRunId), issue.executionRunId ? eq(issues.executionRunId, issue.executionRunId) : isNull(issues.executionRunId), ), ) .returning({ id: issues.id }) .then((rows) => rows[0] ?? null); return Boolean(updated); }); } async function addStopRelayCommentIfNeeded( child: typeof issues.$inferSelect, dbOrTx: any = db, ) { if (!child.parentId || (child.status !== "blocked" && child.status !== "cancelled")) return null; const relayKey = `issue-stop-relay:${child.id}:${child.status}`; await dbOrTx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${relayKey}, 0))`); const childIdentifier = child.identifier?.trim() || child.id; const childPrefix = childIdentifier.split("-")[0] || "PAP"; const body = `System relay: [${childIdentifier}](/${childPrefix}/issues/${childIdentifier}) transitioned to \`${child.status}\`.`; const existingRelay = await dbOrTx .select({ id: issueComments.id }) .from(issueComments) .where(and( eq(issueComments.companyId, child.companyId), eq(issueComments.issueId, child.parentId), eq(issueComments.authorType, "system"), eq(issueComments.body, body), )) .limit(1) .then((rows: Array<{ id: string }>) => rows[0] ?? null); if (existingRelay) return null; const parent = await dbOrTx .select({ id: issues.id, companyId: issues.companyId, assigneeAgentId: issues.assigneeAgentId, status: issues.status, }) .from(issues) .where(and(eq(issues.id, child.parentId), eq(issues.companyId, child.companyId))) .then((rows: Array<{ id: string; companyId: string; assigneeAgentId: string | null; status: string; }>) => rows[0] ?? null); if (!parent) return null; const [comment] = await dbOrTx .insert(issueComments) .values({ companyId: child.companyId, issueId: parent.id, authorType: "system", body, }) .returning(); await dbOrTx.update(issues).set({ updatedAt: new Date() }).where(eq(issues.id, parent.id)); return { comment, parent }; } async function archiveInbox( companyId: string, issueId: string, userId: string, archivedAt: Date = new Date(), attribution?: { archivedByActorType: "user" | "agent"; archivedByAgentId?: string | null; archivedByRunId?: string | null; }, dbOrTx: any = db, ) { const now = new Date(); const [row] = await dbOrTx .insert(issueInboxArchives) .values({ companyId, issueId, userId, archivedByActorType: attribution?.archivedByActorType ?? "user", archivedByAgentId: attribution?.archivedByAgentId ?? null, archivedByRunId: attribution?.archivedByRunId ?? null, archivedAt, updatedAt: now, }) .onConflictDoUpdate({ target: [issueInboxArchives.companyId, issueInboxArchives.issueId, issueInboxArchives.userId], set: { archivedAt, archivedByActorType: attribution?.archivedByActorType ?? "user", archivedByAgentId: attribution?.archivedByAgentId ?? null, archivedByRunId: attribution?.archivedByRunId ?? null, updatedAt: now, }, }) .returning(); return row; } return { clearExecutionRunIfTerminal, clearCheckoutRunIfTerminal, addStopRelayCommentIfNeeded, list: async (companyId: string, filters?: IssueFilters) => { if (filters?.attention === "blocked") { return listBlockedInboxIssues(db, companyId, { ...filters, includeBlockedBy: true, includeBlockedInboxAttention: true, }); } const conditions = [eq(issues.companyId, companyId), visibleIssueCondition()]; const assigneeAgentFilter = parseIssueAssigneeAgentFilter(filters?.assigneeAgentId); assertValidAssigneeAgentFilter(assigneeAgentFilter); const limit = typeof filters?.limit === "number" && Number.isFinite(filters.limit) ? Math.max(1, Math.floor(filters.limit)) : undefined; const offset = typeof filters?.offset === "number" && Number.isFinite(filters.offset) ? Math.max(0, Math.floor(filters.offset)) : 0; const touchedByUserId = filters?.touchedByUserId?.trim() || undefined; const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined; const unreadForUserId = filters?.unreadForUserId?.trim() || undefined; const contextUserId = unreadForUserId ?? touchedByUserId ?? inboxArchivedByUserId; const includeBlockedBy = filters?.includeBlockedBy === true; const includeBlockedInboxAttention = filters?.includeBlockedInboxAttention === true; const includeLiveDescendantSummary = filters?.includeLiveDescendantSummary === true; const rawSearch = filters?.q?.trim() ?? ""; const hasSearch = rawSearch.length > 0; const escapedSearch = hasSearch ? escapeLikePattern(rawSearch) : ""; const startsWithPattern = `${escapedSearch}%`; const containsPattern = `%${escapedSearch}%`; const titleStartsWithMatch = sql`${issues.title} ILIKE ${startsWithPattern} ESCAPE '\\'`; const titleContainsMatch = sql`${issues.title} ILIKE ${containsPattern} ESCAPE '\\'`; const identifierStartsWithMatch = sql`${issues.identifier} ILIKE ${startsWithPattern} ESCAPE '\\'`; const identifierContainsMatch = sql`${issues.identifier} ILIKE ${containsPattern} ESCAPE '\\'`; const descriptionContainsMatch = sql`${issues.description} ILIKE ${containsPattern} ESCAPE '\\'`; const commentContainsMatch = sql` EXISTS ( SELECT 1 FROM ${issueComments} WHERE ${issueComments.issueId} = ${issues.id} AND ${issueComments.companyId} = ${companyId} AND ${issueComments.deletedAt} IS NULL AND ${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\' ) `; if (filters?.descendantOf) { conditions.push(sql` ${issues.id} IN ( WITH RECURSIVE descendants(id) AS ( SELECT ${issues.id} FROM ${issues} WHERE ${issues.companyId} = ${companyId} AND ${issues.parentId} = ${filters.descendantOf} UNION SELECT ${issues.id} FROM ${issues} JOIN descendants ON ${issues.parentId} = descendants.id WHERE ${issues.companyId} = ${companyId} ) SELECT id FROM descendants ) `); } const lowTrustCondition = lowTrustBoundaryIssueCondition(companyId, filters?.lowTrustBoundary); if (lowTrustCondition) conditions.push(lowTrustCondition); const statuses = parseStatusFilter(filters?.status); if (statuses.length === 1) { conditions.push(eq(issues.status, statuses[0])); } else if (statuses.length > 1) { conditions.push(inArray(issues.status, statuses)); } if (assigneeAgentFilter === null) { conditions.push(isNull(issues.assigneeAgentId)); } else if (assigneeAgentFilter) { conditions.push(eq(issues.assigneeAgentId, assigneeAgentFilter)); } if (filters?.participantAgentId) { conditions.push(participatedByAgentCondition(companyId, filters.participantAgentId)); } if (filters?.assigneeUserId) { conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId)); } if (touchedByUserId) { conditions.push(touchedByUserCondition(companyId, touchedByUserId)); } if (inboxArchivedByUserId) { conditions.push(inboxVisibleForUserCondition(companyId, inboxArchivedByUserId)); } if (unreadForUserId) { conditions.push(unreadForUserCondition(companyId, unreadForUserId)); } if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId)); if (filters?.workspaceId) { conditions.push(or( eq(issues.executionWorkspaceId, filters.workspaceId), eq(issues.projectWorkspaceId, filters.workspaceId), )!); } if (filters?.executionWorkspaceId) { conditions.push(eq(issues.executionWorkspaceId, filters.executionWorkspaceId)); } if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId)); if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind)); if (filters?.originKindPrefix) conditions.push(like(issues.originKind, `${filters.originKindPrefix}%`)); if (filters?.originId) conditions.push(eq(issues.originId, filters.originId)); if (filters?.hasPlanDocument !== undefined) { conditions.push(hasPlanDocumentCondition(companyId, filters.hasPlanDocument)); } if (!shouldIncludePluginOperationIssues(filters)) { conditions.push(nonPluginOperationIssueCondition()); } if (filters?.labelId) { const labeledIssueIds = await db .select({ issueId: issueLabels.issueId }) .from(issueLabels) .where(and(eq(issueLabels.companyId, companyId), eq(issueLabels.labelId, filters.labelId))); if (labeledIssueIds.length === 0) return []; conditions.push(inArray(issues.id, labeledIssueIds.map((row) => row.issueId))); } if (hasSearch) { conditions.push( or( titleContainsMatch, identifierContainsMatch, descriptionContainsMatch, commentContainsMatch, )!, ); } if (filters?.updatedSince) { const since = new Date(filters.updatedSince); if (Number.isFinite(since.getTime())) { conditions.push(gt(issues.updatedAt, since)); } } if (filters?.excludeRoutineExecutions && !filters?.originKind && !filters?.originId) { conditions.push(ne(issues.originKind, "routine_execution")); } const priorityOrder = sql`CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`; const searchOrder = sql` CASE WHEN ${titleStartsWithMatch} THEN 0 WHEN ${titleContainsMatch} THEN 1 WHEN ${identifierStartsWithMatch} THEN 2 WHEN ${identifierContainsMatch} THEN 3 WHEN ${commentContainsMatch} THEN 4 WHEN ${descriptionContainsMatch} THEN 5 ELSE 6 END `; const baseQuery = db .select(issueListSelect) .from(issues) .where(and(...conditions)) .orderBy(...issueListOrderBy(companyId, { hasSearch, priorityOrder, searchOrder, sortField: filters?.sortField, sortDir: filters?.sortDir, })); const pageQuery = offset > 0 ? (limit === undefined ? baseQuery.offset(offset) : baseQuery.limit(limit).offset(offset)) : (limit === undefined ? baseQuery : baseQuery.limit(limit)); const rows = (await pageQuery).map((row) => ({ ...row, description: decodeDatabaseTextPreview(row.description, ISSUE_LIST_DESCRIPTION_MAX_CHARS), })); const withLabels = await withIssueLabels(db, rows); const runMap = await activeRunMapForIssues(db, withLabels); const withRuns = withActiveRuns(withLabels, runMap); if (withRuns.length === 0) { return withRuns; } const issueIds = withRuns.map((row) => row.id); const [statsRows, readRows, lastActivityRows, archiveRows, blockedByMap, liveDescendantCountByIssueId] = await Promise.all([ contextUserId ? userCommentStatsForIssues(db, companyId, contextUserId, issueIds) : Promise.resolve([]), contextUserId ? userReadStatsForIssues(db, companyId, contextUserId, issueIds) : Promise.resolve([]), lastActivityStatsForIssues(db, companyId, issueIds), contextUserId ? inboxArchiveRowsForIssues(db, companyId, contextUserId, issueIds) : Promise.resolve([]), includeBlockedBy ? blockedByMapForIssues(db, companyId, issueIds) : Promise.resolve(new Map()), includeLiveDescendantSummary ? liveDescendantCountMapForIssues(db, companyId, issueIds) : Promise.resolve(new Map()), ]); const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row])); const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row])); const archiveByIssueId = new Map(archiveRows.map((row) => [row.issueId, row])); const [ blockerAttentionByIssueId, reviewAttentionByIssueId, productivityReviewByIssueId, blockedInboxAttentionByIssueId, ] = await Promise.all([ listIssueBlockerAttentionMap(db, companyId, withRuns), listIssueReviewAttentionMap(db, companyId, withRuns), listIssueProductivityReviewMap(db, companyId, issueIds), includeBlockedInboxAttention ? listIssueBlockedInboxAttentionMap(db, companyId, withRuns) : Promise.resolve(new Map()), ]); if (!contextUserId) { return withRuns.map((row) => { const activity = lastActivityByIssueId.get(row.id); const lastActivityAt = latestIssueActivityAt( row.updatedAt, activity?.latestCommentAt ?? null, activity?.latestLogAt ?? null, ) ?? row.updatedAt; return { ...row, ...(includeBlockedBy ? { blockedBy: blockedByMap.get(row.id) ?? [] } : {}), lastActivityAt, ...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}), reviewAttention: reviewAttentionByIssueId.get(row.id) ?? reviewAttentionNone(), ...(includeBlockedInboxAttention ? { blockedInboxAttention: blockedInboxAttentionByIssueId.get(row.id) ?? null } : {}), ...(includeLiveDescendantSummary ? { liveDescendantCount: liveDescendantCountByIssueId.get(row.id) ?? 0 } : {}), ...(productivityReviewByIssueId.has(row.id) ? { productivityReview: productivityReviewByIssueId.get(row.id) } : {}), }; }); } const readByIssueId = new Map(readRows.map((row) => [row.issueId, row.myLastReadAt])); return withRuns.map((row) => { const activity = lastActivityByIssueId.get(row.id); const lastActivityAt = latestIssueActivityAt( row.updatedAt, activity?.latestCommentAt ?? null, activity?.latestLogAt ?? null, ) ?? row.updatedAt; return { ...row, ...activeInboxArchiveFields(archiveByIssueId.get(row.id), lastActivityAt), ...(includeBlockedBy ? { blockedBy: blockedByMap.get(row.id) ?? [] } : {}), lastActivityAt, ...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}), reviewAttention: reviewAttentionByIssueId.get(row.id) ?? reviewAttentionNone(), ...(includeBlockedInboxAttention ? { blockedInboxAttention: blockedInboxAttentionByIssueId.get(row.id) ?? null } : {}), ...(includeLiveDescendantSummary ? { liveDescendantCount: liveDescendantCountByIssueId.get(row.id) ?? 0 } : {}), ...(productivityReviewByIssueId.has(row.id) ? { productivityReview: productivityReviewByIssueId.get(row.id) } : {}), ...deriveIssueUserContext(row, contextUserId, { myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null, myLastReadAt: readByIssueId.get(row.id) ?? null, lastExternalCommentAt: statsByIssueId.get(row.id)?.lastExternalCommentAt ?? null, }), }; }); }, count: async (companyId: string, filters?: IssueFilters) => { if (filters?.attention === "blocked") { return countBlockedInboxIssues(db, companyId, filters); } const conditions = [eq(issues.companyId, companyId), visibleIssueCondition()]; const statuses = parseStatusFilter(filters?.status); if (statuses.length === 1) conditions.push(eq(issues.status, statuses[0]!)); else if (statuses.length > 1) conditions.push(inArray(issues.status, statuses)); const assigneeAgentFilter = parseIssueAssigneeAgentFilter(filters?.assigneeAgentId); assertValidAssigneeAgentFilter(assigneeAgentFilter); if (assigneeAgentFilter === null) { conditions.push(isNull(issues.assigneeAgentId)); } else if (assigneeAgentFilter) { conditions.push(eq(issues.assigneeAgentId, assigneeAgentFilter)); } if (filters?.assigneeUserId) conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId)); if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId)); if (filters?.workspaceId) { conditions.push(or( eq(issues.executionWorkspaceId, filters.workspaceId), eq(issues.projectWorkspaceId, filters.workspaceId), )!); } if (filters?.executionWorkspaceId) conditions.push(eq(issues.executionWorkspaceId, filters.executionWorkspaceId)); if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId)); if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind)); if (filters?.originKindPrefix) conditions.push(like(issues.originKind, `${filters.originKindPrefix}%`)); if (filters?.originId) conditions.push(eq(issues.originId, filters.originId)); if (filters?.hasPlanDocument !== undefined) { conditions.push(hasPlanDocumentCondition(companyId, filters.hasPlanDocument)); } if (!shouldIncludePluginOperationIssues(filters)) conditions.push(nonPluginOperationIssueCondition()); const [row] = await db .select({ count: sql`count(*)` }) .from(issues) .where(and(...conditions)); return Number(row?.count ?? 0); }, countUnreadTouchedByUser: async ( companyId: string, userId: string, status?: string | readonly string[], ) => { const conditions = [ eq(issues.companyId, companyId), visibleIssueCondition(), nonPluginOperationIssueCondition(), unreadForUserCondition(companyId, userId), ]; const statuses = parseStatusFilter(status); if (statuses.length === 1) { conditions.push(eq(issues.status, statuses[0])); } else if (statuses.length > 1) { conditions.push(inArray(issues.status, statuses)); } const [row] = await db .select({ count: sql`count(*)` }) .from(issues) .where(and(...conditions)); return Number(row?.count ?? 0); }, markRead: async (companyId: string, issueId: string, userId: string, readAt: Date = new Date()) => { const now = new Date(); const [row] = await db .insert(issueReadStates) .values({ companyId, issueId, userId, lastReadAt: readAt, updatedAt: now, }) .onConflictDoUpdate({ target: [issueReadStates.companyId, issueReadStates.issueId, issueReadStates.userId], set: { lastReadAt: readAt, updatedAt: now, }, }) .returning(); return row; }, markUnread: async (companyId: string, issueId: string, userId: string) => { const deleted = await db .delete(issueReadStates) .where( and( eq(issueReadStates.companyId, companyId), eq(issueReadStates.issueId, issueId), eq(issueReadStates.userId, userId), ), ) .returning(); return deleted.length > 0; }, archiveInbox, /** * Seed inbox archives for a batch of freshly imported issues so a company * import does not flood the importing user's inbox. Imported issues are * historical work, not new inbox items, but the inbox "mine" query surfaces * every touched-and-not-archived issue; a 1000-task import would otherwise * bury the inbox. Seeding a per-user inbox archive keeps them hidden via * `inboxVisibleForUserCondition`, while genuine new activity on an imported * issue still resurfaces it. This runs only on import (mirroring how * `pauseAutomations` threads an import-only suppression) and never touches * normal issue creation. Rows carry the same "user"-attributed shape a * manual inbox archive writes, batched to stay under Postgres bind limits. */ archiveImportedInbox: async ( companyId: string, issueIds: string[], userId: string, archivedAt: Date = new Date(), ): Promise => { if (issueIds.length === 0) return; const now = new Date(); const rows = issueIds.map((issueId) => ({ companyId, issueId, userId, archivedByActorType: "user" as const, archivedByAgentId: null, archivedByRunId: null, archivedAt, createdAt: now, updatedAt: now, })); await insertRowsInChunks(db, issueInboxArchives, rows); }, unarchiveInbox: async (companyId: string, issueId: string, userId: string) => { const [row] = await db .delete(issueInboxArchives) .where( and( eq(issueInboxArchives.companyId, companyId), eq(issueInboxArchives.issueId, issueId), eq(issueInboxArchives.userId, userId), ), ) .returning(); return row ?? null; }, getActiveInboxArchiveFields: async ( issue: Pick, userId: string, ) => { const [[activity], [archive]] = await Promise.all([ lastActivityStatsForIssues(db, issue.companyId, [issue.id]), inboxArchiveRowsForIssues(db, issue.companyId, userId, [issue.id]), ]); const lastActivityAt = latestIssueActivityAt( issue.updatedAt, activity?.latestCommentAt ?? null, activity?.latestLogAt ?? null, ) ?? issue.updatedAt; return activeInboxArchiveFields(archive, lastActivityAt); }, /** * Walk the full parent chain from `parentIssueId` (inclusive) looking for * a still-open ancestor created by `agentId`. Used to refuse agent * delegation cycles: an agent assigning a new child to the agent that * created an open ancestor is handing the same work back to its own * delegator (A→B→A hot-potato). One recursive query covers the entire * chain regardless of depth; `UNION` (not `UNION ALL`) deduplicates * revisited rows, so a parent graph corrupted into a loop terminates * instead of recursing forever. */ findOpenAncestorCreatedByAgent: async ( parentIssueId: string, agentId: string, ): Promise<{ id: string; identifier: string | null; parentId: string | null; createdByAgentId: string | null; status: string; } | null> => { const rows = await db.execute(sql` WITH RECURSIVE ancestors(id, parent_id) AS ( SELECT id, parent_id FROM issues WHERE id = ${parentIssueId} UNION SELECT parent.id, parent.parent_id FROM issues parent JOIN ancestors ON parent.id = ancestors.parent_id ) SELECT i.id, i.identifier, i.parent_id, i.created_by_agent_id, i.status FROM issues i JOIN ancestors a ON a.id = i.id WHERE i.created_by_agent_id = ${agentId} AND i.status NOT IN ('done', 'cancelled') LIMIT 1 `); const first = (Array.isArray(rows) ? rows[0] : null) as Record | null; if (!first) return null; return { id: String(first.id), identifier: typeof first.identifier === "string" ? first.identifier : null, parentId: typeof first.parent_id === "string" ? first.parent_id : null, createdByAgentId: typeof first.created_by_agent_id === "string" ? first.created_by_agent_id : null, status: String(first.status), }; }, getById: async (raw: string) => { const id = raw.trim(); const identifier = normalizeIssueReferenceIdentifier(id); if (identifier) { return getIssueByIdentifier(identifier); } if (!isUuidLike(id)) { return null; } return getIssueByUuid(id); }, getByIdForUpdate: async (id: string, dbOrTx: any) => { return dbOrTx .select() .from(issues) .where(eq(issues.id, id)) .for("update") .then((rows: Array) => rows[0] ?? null); }, getByIdentifier: async (identifier: string) => { return getIssueByIdentifier(identifier); }, getCurrentScheduledRetry: async (issueId: string) => { const issue = await db .select({ id: issues.id, companyId: issues.companyId }) .from(issues) .where(eq(issues.id, issueId)) .then((rows) => rows[0] ?? null); if (!issue) throw notFound("Issue not found"); return getCurrentScheduledRetryForIssue(issue.id, issue.companyId); }, getRelationSummaries: async (issueId: string) => { const issue = await db .select({ id: issues.id, companyId: issues.companyId }) .from(issues) .where(eq(issues.id, issueId)) .then((rows) => rows[0] ?? null); if (!issue) throw notFound("Issue not found"); const relations = await getIssueRelationSummaryMap(issue.companyId, [issueId], db); return relations.get(issueId) ?? { blockedBy: [], blocks: [] }; }, getBlockerDiagnostics: async ( issueId: string, maxBlockers = ISSUE_BLOCKER_DIAGNOSTICS_MAX_BLOCKERS, ) => { const issue = await db .select({ id: issues.id, companyId: issues.companyId }) .from(issues) .where(eq(issues.id, issueId)) .then((rows) => rows[0] ?? null); if (!issue) throw notFound("Issue not found"); const cappedMax = Math.max(0, Math.min(maxBlockers, ISSUE_BLOCKER_DIAGNOSTICS_MAX_BLOCKERS)); const blockerRows = await db .select({ id: issues.id, companyId: issues.companyId, projectId: issues.projectId, parentId: issues.parentId, identifier: issues.identifier, title: issues.title, status: issues.status, priority: issues.priority, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, }) .from(issueRelations) .innerJoin(issues, eq(issueRelations.issueId, issues.id)) .where( and( eq(issueRelations.companyId, issue.companyId), eq(issueRelations.type, "blocks"), eq(issueRelations.relatedIssueId, issue.id), eq(issues.companyId, issue.companyId), ), ) .orderBy(asc(issues.title), asc(issues.id)) .limit(cappedMax + 1); const readiness = await listIssueDependencyReadinessMap(db, issue.companyId, [issue.id]); return { blockers: blockerRows.slice(0, cappedMax) as IssueBlockerDiagnosticsIssueRow[], readiness: readiness.get(issue.id) ?? createIssueDependencyReadiness(issue.id), truncated: blockerRows.length > cappedMax, }; }, getWakeDiagnostics: async ( issueId: string, opts?: { maxWakeRequests?: number; maxActivityRecords?: number; lookbackDays?: number; }, ) => { const issue = await db .select({ id: issues.id, companyId: issues.companyId }) .from(issues) .where(eq(issues.id, issueId)) .then((rows) => rows[0] ?? null); if (!issue) throw notFound("Issue not found"); const maxWakeRequests = Math.max( 0, Math.min( opts?.maxWakeRequests ?? ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS, ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS, ), ); const maxActivityRecords = Math.max( 0, Math.min( opts?.maxActivityRecords ?? ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS, ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS, ), ); const lookbackDays = Math.max( 1, Math.min( opts?.lookbackDays ?? ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS, ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS, ), ); const since = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000); const wakeRows = await db .select({ agentId: agentWakeupRequests.agentId, source: agentWakeupRequests.source, reason: agentWakeupRequests.reason, status: agentWakeupRequests.status, coalescedCount: agentWakeupRequests.coalescedCount, runId: agentWakeupRequests.runId, requestedAt: agentWakeupRequests.requestedAt, claimedAt: agentWakeupRequests.claimedAt, finishedAt: agentWakeupRequests.finishedAt, error: agentWakeupRequests.error, }) .from(agentWakeupRequests) .where( and( eq(agentWakeupRequests.companyId, issue.companyId), gte(agentWakeupRequests.requestedAt, since), wakeRequestTargetsIssue(issue.id), ), ) .orderBy(desc(agentWakeupRequests.requestedAt), desc(agentWakeupRequests.createdAt)) .limit(maxWakeRequests + 1); const activityRows = await db .select({ action: activityLog.action, entityType: activityLog.entityType, entityId: activityLog.entityId, agentId: activityLog.agentId, runId: activityLog.runId, details: activityLog.details, createdAt: activityLog.createdAt, }) .from(activityLog) .where( and( eq(activityLog.companyId, issue.companyId), gte(activityLog.createdAt, since), inArray(activityLog.action, [...ISSUE_WAKE_DIAGNOSTICS_ACTIVITY_ACTIONS]), wakeDiagnosticActivityTargetsIssue(issue.id), ), ) .orderBy(desc(activityLog.createdAt)) .limit(maxActivityRecords + 1); return { wakeRequests: wakeRows.slice(0, maxWakeRequests) as IssueWakeDiagnosticsWakeRequestRow[], activityRecords: activityRows.slice(0, maxActivityRecords) as IssueWakeDiagnosticsActivityRow[], truncatedWakeRequests: wakeRows.length > maxWakeRequests, truncatedActivityRecords: activityRows.length > maxActivityRecords, caps: { maxWakeRequests: ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS, maxActivityRecords: ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS, lookbackDays: ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS, }, }; }, getSubtreeDiagnostics: async ( issueId: string, opts?: { maxDepth?: number; maxNodes?: number; maxBlockersPerNode?: number; maxWakeRequestsPerNode?: number; maxActivityRecordsPerNode?: number; lookbackDays?: number; }, ) => { const issue = await db .select({ id: issues.id, companyId: issues.companyId }) .from(issues) .where(eq(issues.id, issueId)) .then((rows) => rows[0] ?? null); if (!issue) throw notFound("Issue not found"); const maxDepth = Math.max( 0, Math.min(opts?.maxDepth ?? ISSUE_SUBTREE_DIAGNOSTICS_MAX_DEPTH, ISSUE_SUBTREE_DIAGNOSTICS_MAX_DEPTH), ); const maxNodes = Math.max( 1, Math.min(opts?.maxNodes ?? ISSUE_SUBTREE_DIAGNOSTICS_MAX_NODES, ISSUE_SUBTREE_DIAGNOSTICS_MAX_NODES), ); const maxBlockersPerNode = Math.max( 0, Math.min( opts?.maxBlockersPerNode ?? ISSUE_SUBTREE_DIAGNOSTICS_MAX_BLOCKERS_PER_NODE, ISSUE_SUBTREE_DIAGNOSTICS_MAX_BLOCKERS_PER_NODE, ), ); const maxWakeRequestsPerNode = Math.max( 0, Math.min( opts?.maxWakeRequestsPerNode ?? ISSUE_SUBTREE_DIAGNOSTICS_MAX_WAKE_REQUESTS_PER_NODE, ISSUE_SUBTREE_DIAGNOSTICS_MAX_WAKE_REQUESTS_PER_NODE, ), ); const maxActivityRecordsPerNode = Math.max( 0, Math.min( opts?.maxActivityRecordsPerNode ?? ISSUE_SUBTREE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS_PER_NODE, ISSUE_SUBTREE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS_PER_NODE, ), ); const lookbackDays = Math.max( 1, Math.min(opts?.lookbackDays ?? ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS, ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS), ); const since = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000); const sinceIso = since.toISOString(); const rawSubtreeRows = await db.execute(sql` WITH RECURSIVE issue_tree AS ( SELECT id, company_id, project_id, parent_id, identifier, title, status, priority, assignee_agent_id, assignee_user_id, created_at, updated_at, 0 AS depth, ARRAY[id] AS path FROM issues WHERE company_id = ${issue.companyId} AND id = ${issue.id} AND hidden_at IS NULL AND harness_kind IS NULL UNION ALL SELECT child.id, child.company_id, child.project_id, child.parent_id, child.identifier, child.title, child.status, child.priority, child.assignee_agent_id, child.assignee_user_id, child.created_at, child.updated_at, issue_tree.depth + 1, issue_tree.path || child.id FROM issues child JOIN issue_tree ON child.parent_id = issue_tree.id WHERE child.company_id = ${issue.companyId} AND child.hidden_at IS NULL AND child.harness_kind IS NULL AND issue_tree.depth < ${maxDepth + 1} AND NOT child.id = ANY(issue_tree.path) ) SELECT id, company_id AS "companyId", project_id AS "projectId", parent_id AS "parentId", identifier, title, status, priority, assignee_agent_id AS "assigneeAgentId", assignee_user_id AS "assigneeUserId", created_at AS "createdAt", updated_at AS "updatedAt", depth::int AS depth FROM issue_tree ORDER BY depth ASC, created_at ASC, id ASC LIMIT ${maxNodes + 1} `); const subtreeRows = Array.from(rawSubtreeRows) .map((row) => ({ ...row, depth: Number(row.depth) })); const rowsWithinDepth = subtreeRows.filter((row) => row.depth <= maxDepth); const nodes = rowsWithinDepth.slice(0, maxNodes) as IssueSubtreeDiagnosticsIssueRow[]; const truncatedNodes = rowsWithinDepth.length > maxNodes; const truncatedDepth = truncatedNodes || subtreeRows.some((row) => row.depth > maxDepth); const nodeIds = nodes.map((node) => node.id); const readiness = nodeIds.length > 0 ? await listIssueDependencyReadinessMap(db, issue.companyId, nodeIds) : new Map(); const blockersByIssueId = new Map(); const wakeRequestsByIssueId = new Map(); const activityRecordsByIssueId = new Map(); const truncatedBlockerIssueIds = new Set(); const truncatedWakeIssueIds = new Set(); const truncatedActivityIssueIds = new Set(); if (nodeIds.length > 0) { const nodeIdValues = sql.join(nodeIds.map((id) => sql`${id}`), sql`, `); const rawBlockerRows = Array.from(await db.execute(sql` WITH blocker_rows AS ( SELECT blocker.id, blocker.company_id AS "companyId", blocker.project_id AS "projectId", blocker.parent_id AS "parentId", blocker.identifier, blocker.title, blocker.status, blocker.priority, blocker.assignee_agent_id AS "assigneeAgentId", blocker.assignee_user_id AS "assigneeUserId", relation.related_issue_id AS "blockedIssueId", relation.created_at AS "relationCreatedAt", row_number() OVER ( PARTITION BY relation.related_issue_id ORDER BY blocker.title ASC, blocker.id ASC )::int AS "rowNumber" FROM issue_relations relation INNER JOIN issues blocker ON blocker.id = relation.issue_id WHERE relation.company_id = ${issue.companyId} AND relation.type = 'blocks' AND blocker.company_id = ${issue.companyId} AND blocker.hidden_at IS NULL AND blocker.harness_kind IS NULL AND relation.related_issue_id::text IN (${nodeIdValues}) ) SELECT * FROM blocker_rows WHERE "rowNumber" <= ${maxBlockersPerNode + 1} ORDER BY "blockedIssueId" ASC, "rowNumber" ASC `)) as IssueSubtreeDiagnosticsBlockerResultRow[]; for (const row of rawBlockerRows) { const normalized = { ...row, rowNumber: Number(row.rowNumber) }; if (normalized.rowNumber > maxBlockersPerNode) { truncatedBlockerIssueIds.add(normalized.blockedIssueId); continue; } const rows = blockersByIssueId.get(normalized.blockedIssueId) ?? []; rows.push(normalized); blockersByIssueId.set(normalized.blockedIssueId, rows); } const wakeTargetIssueIdSql = sql` coalesce( wake.payload ->> 'issueId', wake.payload ->> 'taskId', wake.payload -> '_paperclipWakeContext' ->> 'issueId', wake.payload -> '_paperclipWakeContext' ->> 'taskId' ) `; const rawWakeRows = Array.from(await db.execute(sql` WITH wake_rows AS ( SELECT ${wakeTargetIssueIdSql} AS "issueId", wake.agent_id AS "agentId", wake.source, wake.reason, wake.status, wake.coalesced_count AS "coalescedCount", wake.run_id AS "runId", wake.requested_at AS "requestedAt", wake.claimed_at AS "claimedAt", wake.finished_at AS "finishedAt", wake.error, row_number() OVER ( PARTITION BY ${wakeTargetIssueIdSql} ORDER BY wake.requested_at DESC, wake.created_at DESC )::int AS "rowNumber" FROM agent_wakeup_requests wake WHERE wake.company_id = ${issue.companyId} AND wake.requested_at >= ${sinceIso}::timestamptz AND ${wakeTargetIssueIdSql} IN (${nodeIdValues}) ) SELECT * FROM wake_rows WHERE "rowNumber" <= ${maxWakeRequestsPerNode + 1} ORDER BY "issueId" ASC, "requestedAt" DESC `)) as IssueSubtreeDiagnosticsWakeRequestResultRow[]; for (const row of rawWakeRows) { const normalized = { ...row, rowNumber: Number(row.rowNumber) }; if (normalized.rowNumber > maxWakeRequestsPerNode) { truncatedWakeIssueIds.add(normalized.issueId); continue; } const rows = wakeRequestsByIssueId.get(normalized.issueId) ?? []; rows.push(normalized); wakeRequestsByIssueId.set(normalized.issueId, rows); } const activityTargetIssueIdSql = sql` coalesce( CASE WHEN activity.entity_type = 'issue' THEN activity.entity_id ELSE NULL END, activity.details ->> 'issueId', activity.details ->> 'rootIssueId' ) `; const activityActionValues = sql.join( ISSUE_WAKE_DIAGNOSTICS_ACTIVITY_ACTIONS.map((action) => sql`${action}`), sql`, `, ); const rawActivityRows = Array.from(await db.execute(sql` WITH activity_rows AS ( SELECT ${activityTargetIssueIdSql} AS "issueId", activity.action, activity.entity_type AS "entityType", activity.entity_id AS "entityId", activity.agent_id AS "agentId", activity.run_id AS "runId", activity.details, activity.created_at AS "createdAt", row_number() OVER ( PARTITION BY ${activityTargetIssueIdSql} ORDER BY activity.created_at DESC, activity.id DESC )::int AS "rowNumber" FROM activity_log activity WHERE activity.company_id = ${issue.companyId} AND activity.created_at >= ${sinceIso}::timestamptz AND activity.action IN (${activityActionValues}) AND ${activityTargetIssueIdSql} IN (${nodeIdValues}) ) SELECT * FROM activity_rows WHERE "rowNumber" <= ${maxActivityRecordsPerNode + 1} ORDER BY "issueId" ASC, "createdAt" DESC `)) as IssueSubtreeDiagnosticsActivityResultRow[]; for (const row of rawActivityRows) { const normalized = { ...row, rowNumber: Number(row.rowNumber) }; if (normalized.rowNumber > maxActivityRecordsPerNode) { truncatedActivityIssueIds.add(normalized.issueId); continue; } const rows = activityRecordsByIssueId.get(normalized.issueId) ?? []; rows.push(normalized); activityRecordsByIssueId.set(normalized.issueId, rows); } } return { nodes, blockersByIssueId, readinessByIssueId: readiness, wakeRequestsByIssueId, activityRecordsByIssueId, truncatedNodes, truncatedDepth, truncatedBlockerIssueIds, truncatedWakeIssueIds, truncatedActivityIssueIds, caps: { maxDepth, maxNodes, maxBlockersPerNode, maxWakeRequestsPerNode, maxActivityRecordsPerNode, lookbackDays, }, }; }, getDependencyReadiness: async (issueId: string, dbOrTx: any = db) => { const issue = await dbOrTx .select({ id: issues.id, companyId: issues.companyId }) .from(issues) .where(eq(issues.id, issueId)) .then((rows: Array<{ id: string; companyId: string }>) => rows[0] ?? null); if (!issue) throw notFound("Issue not found"); const readiness = await listIssueDependencyReadinessMap(dbOrTx, issue.companyId, [issueId]); return readiness.get(issueId) ?? createIssueDependencyReadiness(issueId); }, listDependencyReadiness: async (companyId: string, issueIds: string[], dbOrTx: any = db) => { return listIssueDependencyReadinessMap(dbOrTx, companyId, issueIds); }, listBlockerAttention: async ( companyId: string, issueRows: IssueBlockerAttentionInputNode[], dbOrTx: any = db, ) => { return listIssueBlockerAttentionMap(dbOrTx, companyId, issueRows); }, listReviewAttention: async ( companyId: string, issueRows: IssueReviewAttentionInput[], dbOrTx: any = db, ) => { return listIssueReviewAttentionMap(dbOrTx, companyId, issueRows); }, listProductivityReviews: async ( companyId: string, sourceIssueIds: string[], dbOrTx: any = db, ) => { return listIssueProductivityReviewMap(dbOrTx, companyId, sourceIssueIds); }, listWakeableBlockedDependents: async (blockerIssueId: string) => { const blockerIssue = await db .select({ id: issues.id, companyId: issues.companyId }) .from(issues) .where(eq(issues.id, blockerIssueId)) .then((rows) => rows[0] ?? null); if (!blockerIssue) return []; const candidates = await db .select({ id: issues.id, assigneeAgentId: issues.assigneeAgentId, status: issues.status, }) .from(issueRelations) .innerJoin(issues, eq(issueRelations.relatedIssueId, issues.id)) .where( and( eq(issueRelations.companyId, blockerIssue.companyId), eq(issueRelations.type, "blocks"), eq(issueRelations.issueId, blockerIssueId), ), ); if (candidates.length === 0) return []; const wakeableCandidates = candidates.filter( (candidate) => candidate.assigneeAgentId && !["backlog", "done", "cancelled"].includes(candidate.status), ); if (wakeableCandidates.length === 0) return []; // Defer to the unified readiness check so that a dependent only fires when // (a) every blocker is done AND (b) every done blocker's workspace has // recorded a successful workspace_finalize. The finalize hook also calls // this function on completion, so a wake initially gated by an in-flight // sync-back will re-fire once the restore lands locally. const readinessMap = await listIssueDependencyReadinessMap( db, blockerIssue.companyId, wakeableCandidates.map((candidate) => candidate.id), ); return wakeableCandidates .map((candidate) => { const readiness = readinessMap.get(candidate.id) ?? createIssueDependencyReadiness(candidate.id); return { candidate, readiness }; }) .filter(({ readiness }) => readiness.isDependencyReady && readiness.blockerIssueIds.length > 0) .map(({ candidate, readiness }) => ({ id: candidate.id, assigneeAgentId: candidate.assigneeAgentId!, blockerIssueIds: readiness.blockerIssueIds, })); }, getWakeableParentAfterChildCompletion: async (parentIssueId: string) => { const parent = await db .select({ id: issues.id, assigneeAgentId: issues.assigneeAgentId, status: issues.status, companyId: issues.companyId, }) .from(issues) .where(eq(issues.id, parentIssueId)) .then((rows) => rows[0] ?? null); if (!parent || !parent.assigneeAgentId || ["backlog", "done", "cancelled"].includes(parent.status)) { return null; } const children = await db .select({ id: issues.id, identifier: issues.identifier, title: issues.title, status: issues.status, priority: issues.priority, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, updatedAt: issues.updatedAt, }) .from(issues) .where(and(eq(issues.companyId, parent.companyId), eq(issues.parentId, parentIssueId))) .orderBy(asc(issues.issueNumber), asc(issues.createdAt)); if (children.length === 0) return null; if (!children.every((child) => child.status === "done" || child.status === "cancelled")) { return null; } const childIdsForSummaries = children.slice(0, MAX_CHILD_COMPLETION_SUMMARIES).map((child) => child.id); const commentRows = childIdsForSummaries.length > 0 ? await db .select({ issueId: issueComments.issueId, body: issueComments.body, createdAt: issueComments.createdAt, }) .from(issueComments) .where(and( eq(issueComments.companyId, parent.companyId), inArray(issueComments.issueId, childIdsForSummaries), isNull(issueComments.deletedAt), )) .orderBy(desc(issueComments.createdAt), desc(issueComments.id)) : []; const latestCommentByIssueId = new Map(); for (const comment of commentRows) { if (!latestCommentByIssueId.has(comment.issueId)) { latestCommentByIssueId.set(comment.issueId, comment.body); } } const childIssueSummaries: ChildIssueCompletionSummary[] = children .slice(0, MAX_CHILD_COMPLETION_SUMMARIES) .map((child) => ({ ...child, summary: truncateInlineSummary(latestCommentByIssueId.get(child.id)), })); return { id: parent.id, assigneeAgentId: parent.assigneeAgentId, childIssueIds: children.map((child) => child.id), childIssueSummaries, childIssueSummaryTruncated: children.length > childIssueSummaries.length, }; }, createChild: async ( parentIssueId: string, data: IssueChildCreateInput, ) => { const parent = await db .select() .from(issues) .where(eq(issues.id, parentIssueId)) .then((rows) => rows[0] ?? null); if (!parent) throw notFound("Parent issue not found"); const [{ childCount }] = await db .select({ childCount: sql`count(*)::int` }) .from(issues) .where(and(eq(issues.companyId, parent.companyId), eq(issues.parentId, parent.id))); if (childCount >= MAX_CHILD_ISSUES_CREATED_BY_HELPER) { throw unprocessable(`Parent issue already has the maximum ${MAX_CHILD_ISSUES_CREATED_BY_HELPER} child issues for this helper`); } const { acceptanceCriteria, blockParentUntilDone, executionWorkspaceInheritanceMode = "linkage", actorAgentId, actorUserId, ...issueData } = data; const inheritStrategyOnly = executionWorkspaceInheritanceMode === "strategy_only"; const hasExplicitExecutionWorkspaceOverride = issueData.executionWorkspaceId !== undefined || issueData.executionWorkspacePreference !== undefined || issueData.executionWorkspaceSettings !== undefined; const inheritedPreRealizationWorkspaceSettings = inheritStrategyOnly && !hasExplicitExecutionWorkspaceOverride ? buildPreRealizationExecutionWorkspaceSettings(parent.executionWorkspaceSettings) : null; let child = await issueService(db).create(parent.companyId, { ...issueData, parentId: parent.id, projectId: issueData.projectId ?? parent.projectId, projectWorkspaceId: issueData.projectWorkspaceId ?? (inheritStrategyOnly ? parent.projectWorkspaceId : undefined), goalId: issueData.goalId ?? parent.goalId, actorResponsibleUserId: issueData.actorResponsibleUserId ?? null, trustExplicitResponsibleUserId: issueData.trustExplicitResponsibleUserId === true, requestDepth: clampIssueRequestDepth( Math.max(clampIssueRequestDepth(parent.requestDepth) + 1, issueData.requestDepth ?? 0), ), description: appendAcceptanceCriteriaToDescription(issueData.description, acceptanceCriteria), ...(inheritedPreRealizationWorkspaceSettings ? { executionWorkspaceSettings: inheritedPreRealizationWorkspaceSettings } : {}), ...(inheritStrategyOnly ? { skipExecutionWorkspaceInheritance: true } : { inheritExecutionWorkspaceFromIssueId: parent.id }), }); if (blockParentUntilDone) { const existingBlockers = await db .select({ blockerIssueId: issueRelations.issueId }) .from(issueRelations) .where(and(eq(issueRelations.companyId, parent.companyId), eq(issueRelations.relatedIssueId, parent.id), eq(issueRelations.type, "blocks"))); await syncBlockedByIssueIds( parent.id, parent.companyId, [...new Set([...existingBlockers.map((row) => row.blockerIssueId), child.id])], { agentId: actorAgentId ?? null, userId: actorUserId ?? null }, ); [child] = await withIssueRelationSummaries(parent.companyId, [child], db); } return { issue: child, parentBlockerAdded: Boolean(blockParentUntilDone), }; }, decomposeAcceptedPlan: async ( sourceIssueId: string, data: AcceptedPlanDecompositionInput, ) => { const sourceIssue = await db .select({ id: issues.id, companyId: issues.companyId, projectId: issues.projectId, goalId: issues.goalId, }) .from(issues) .where(eq(issues.id, sourceIssueId)) .then((rows) => rows[0] ?? null); if (!sourceIssue) throw notFound("Source issue not found"); const requestFingerprint = createAcceptedPlanDecompositionRequestFingerprint({ acceptedPlanRevisionId: data.acceptedPlanRevisionId, children: data.children, }); const initialClaim = await db.transaction(async (tx) => { await tx.execute(sql`select ${issues.id} from ${issues} where ${issues.id} = ${sourceIssue.id} for update`); const belongsToPlanDocument = await tx .select({ revisionId: documentRevisions.id }) .from(issueDocuments) .innerJoin(documentRevisions, eq(issueDocuments.documentId, documentRevisions.documentId)) .where(and( eq(issueDocuments.companyId, sourceIssue.companyId), eq(issueDocuments.issueId, sourceIssue.id), eq(issueDocuments.key, "plan"), eq(documentRevisions.id, data.acceptedPlanRevisionId), )) .then((rows) => rows[0] ?? null); if (!belongsToPlanDocument) { throw unprocessable("acceptedPlanRevisionId must belong to the source issue's plan document"); } const acceptedInteraction = await findAcceptedPlanDocumentInteraction(tx, { companyId: sourceIssue.companyId, sourceIssueId: sourceIssue.id, acceptedPlanRevisionId: data.acceptedPlanRevisionId, }); if (!acceptedInteraction) { throw unprocessable("acceptedPlanRevisionId must have an accepted plan confirmation"); } const existing = await tx .select() .from(issuePlanDecompositions) .where(and( eq(issuePlanDecompositions.companyId, sourceIssue.companyId), eq(issuePlanDecompositions.sourceIssueId, sourceIssue.id), eq(issuePlanDecompositions.acceptedPlanRevisionId, data.acceptedPlanRevisionId), )) .then((rows) => rows[0] ?? null); const now = new Date(); if (!existing) { const [created] = await tx .insert(issuePlanDecompositions) .values({ companyId: sourceIssue.companyId, sourceIssueId: sourceIssue.id, acceptedPlanRevisionId: data.acceptedPlanRevisionId, acceptedInteractionId: acceptedInteraction.id, status: "in_flight", requestFingerprint, requestedChildCount: data.children.length, requestedChildren: data.children as unknown as Record[], childIssueIds: [], ownerAgentId: data.actorAgentId ?? null, ownerUserId: data.actorUserId ?? null, ownerRunId: data.actorRunId ?? null, updatedAt: now, }) .returning(); if (!created) throw new Error("Failed to create accepted-plan decomposition claim"); return created; } if (existing.requestFingerprint !== requestFingerprint) { throw conflict("Accepted-plan decomposition already exists for this revision with a different child set"); } return existing; }); let currentClaim = initialClaim; const newlyCreatedIssues: Array = []; while (true) { const step = await db.transaction(async (tx) => { await tx.execute( sql`select ${issuePlanDecompositions.id} from ${issuePlanDecompositions} where ${issuePlanDecompositions.id} = ${currentClaim.id} for update`, ); const claim = await tx .select() .from(issuePlanDecompositions) .where(eq(issuePlanDecompositions.id, currentClaim.id)) .then((rows) => rows[0] ?? null); if (!claim) throw notFound("Accepted-plan decomposition claim not found"); if (claim.requestFingerprint !== requestFingerprint) { throw conflict("Accepted-plan decomposition already exists for this revision with a different child set"); } const existingChildIssueIds = normalizeIssuePlanDecompositionChildIds(claim.childIssueIds); if (claim.status === "completed" || existingChildIssueIds.length >= data.children.length) { const nextIds = existingChildIssueIds.slice(0, data.children.length); if (claim.status === "completed" && nextIds.length === data.children.length) { return { claim, createdIssue: null, }; } const completedAt = claim.completedAt ?? new Date(); const ownerPatch = await resolveAcceptedPlanClaimOwner({ dbOrTx: tx, claim, actorAgentId: data.actorAgentId, actorUserId: data.actorUserId, actorRunId: data.actorRunId, }); const [completed] = await tx .update(issuePlanDecompositions) .set({ status: "completed", childIssueIds: nextIds, completedAt, ...ownerPatch, updatedAt: completedAt, }) .where(eq(issuePlanDecompositions.id, claim.id)) .returning(); if (!completed) throw new Error("Failed to complete accepted-plan decomposition claim"); return { claim: completed, createdIssue: null, }; } const nextChildInput = data.children[existingChildIssueIds.length]; if (!nextChildInput) { throw new Error("Accepted-plan decomposition child cursor moved past the requested children"); } const createdChild = await issueService(tx as unknown as Db).createChild(sourceIssue.id, { ...nextChildInput, executionWorkspaceInheritanceMode: "strategy_only", }); const nextIds = [...existingChildIssueIds, createdChild.issue.id]; const now = new Date(); const nextStatus = nextIds.length === data.children.length ? "completed" : "in_flight"; const ownerPatch = await resolveAcceptedPlanClaimOwner({ dbOrTx: tx, claim, actorAgentId: data.actorAgentId, actorUserId: data.actorUserId, actorRunId: data.actorRunId, }); const [updatedClaim] = await tx .update(issuePlanDecompositions) .set({ status: nextStatus, childIssueIds: nextIds, completedAt: nextStatus === "completed" ? now : null, ...ownerPatch, updatedAt: now, }) .where(eq(issuePlanDecompositions.id, claim.id)) .returning(); if (!updatedClaim) throw new Error("Failed to persist accepted-plan decomposition progress"); return { claim: updatedClaim, createdIssue: createdChild.issue, }; }); currentClaim = step.claim; if (step.createdIssue) { newlyCreatedIssues.push(step.createdIssue); } if (step.claim.status === "completed") break; } const childIssueIds = normalizeIssuePlanDecompositionChildIds(currentClaim.childIssueIds); const childIssueRows = childIssueIds.length > 0 ? await db .select() .from(issues) .where(and(eq(issues.companyId, sourceIssue.companyId), inArray(issues.id, childIssueIds))) : []; const childIssueMap = new Map(childIssueRows.map((row) => [row.id, row])); const orderedChildIssues = childIssueIds .map((childIssueId) => childIssueMap.get(childIssueId)) .filter((row): row is typeof issues.$inferSelect => Boolean(row)); const decomposition = serializeAcceptedPlanDecomposition(currentClaim); return { decomposition, childIssueIds: decomposition.childIssueIds, childIssues: orderedChildIssues, newlyCreatedIssues, }; }, listAcceptedPlanDecompositions: async (sourceIssueId: string) => { const sourceIssue = await db .select({ id: issues.id, companyId: issues.companyId }) .from(issues) .where(eq(issues.id, sourceIssueId)) .then((rows) => rows[0] ?? null); if (!sourceIssue) return []; const rows = await db .select({ decomposition: issuePlanDecompositions, revisionNumber: documentRevisions.revisionNumber, }) .from(issuePlanDecompositions) .leftJoin( documentRevisions, eq(documentRevisions.id, issuePlanDecompositions.acceptedPlanRevisionId), ) .where(and( eq(issuePlanDecompositions.companyId, sourceIssue.companyId), eq(issuePlanDecompositions.sourceIssueId, sourceIssue.id), )) .orderBy(desc(issuePlanDecompositions.createdAt)); if (rows.length === 0) return []; const allChildIds = new Set(); for (const row of rows) { for (const childId of normalizeIssuePlanDecompositionChildIds(row.decomposition.childIssueIds)) { allChildIds.add(childId); } } const childIssueRows = allChildIds.size > 0 ? await db .select({ id: issues.id, identifier: issues.identifier, title: issues.title, status: issues.status, priority: issues.priority, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, }) .from(issues) .where(and(eq(issues.companyId, sourceIssue.companyId), inArray(issues.id, Array.from(allChildIds)))) : []; const childIssueMap = new Map(childIssueRows.map((row) => [row.id, row])); return rows.map((row) => { const decomposition = serializeAcceptedPlanDecomposition(row.decomposition); const childIds = decomposition.childIssueIds; return { ...decomposition, acceptedPlanRevisionNumber: row.revisionNumber ?? null, childIssues: childIds .map((childId) => childIssueMap.get(childId) ?? null) .filter((entry): entry is NonNullable => entry !== null), }; }); }, create: async (companyId: string, data: IssueCreateInput) => { const { labelIds: inputLabelIds, blockedByIssueIds, inheritExecutionWorkspaceFromIssueId, skipExecutionWorkspaceInheritance, watchdog, watchdogActorRunId, actorRunId, actorResponsibleUserId, trustExplicitResponsibleUserId, idempotencyKey: rawIdempotencyKey, allowDuplicate, onDeduplicated, ...issueData } = data; const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces; if (!isolatedWorkspacesEnabled) { delete issueData.executionWorkspaceId; delete issueData.executionWorkspacePreference; delete issueData.executionWorkspaceSettings; } if (data.assigneeAgentId && data.assigneeUserId) { throw unprocessable("Issue can only have one assignee"); } if (data.assigneeAgentId) { await assertAssignableAgent(db, companyId, data.assigneeAgentId, { kind: "work" }); } if (data.assigneeUserId) { await assertAssignableUser(companyId, data.assigneeUserId); } if (data.status === "in_progress" && !data.assigneeAgentId && !data.assigneeUserId) { throw unprocessable("in_progress issues require an assignee"); } return db.transaction(async (tx) => { const idempotencyKey = rawIdempotencyKey?.trim() || null; const normalizedTitle = normalizeCreateIssueTitle(issueData.title); if (allowDuplicate === false) { const titleGuardKey = `issue-create:title:${companyId}:${issueData.parentId ?? "root"}:${normalizedTitle}`; await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${titleGuardKey}, 0))`); } if (idempotencyKey) { const idempotencyGuardKey = `issue-create:idempotency:${companyId}:${idempotencyKey}`; await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${idempotencyGuardKey}, 0))`); } let existingIssue: typeof issues.$inferSelect | undefined; let deduplicationReason: "idempotency_key" | "recent_open_title" | null = null; if (idempotencyKey) { const idempotencyKeyRetentionCutoff = new Date(Date.now() - ISSUE_CREATE_IDEMPOTENCY_KEY_RETENTION_MS); await tx.execute(sql` delete from ${issueCreateIdempotencyKeys} where ${issueCreateIdempotencyKeys.id} in ( select ${issueCreateIdempotencyKeys.id} from ${issueCreateIdempotencyKeys} where ${issueCreateIdempotencyKeys.companyId} = ${companyId} and ${issueCreateIdempotencyKeys.createdAt} < ${idempotencyKeyRetentionCutoff.toISOString()}::timestamptz order by ${issueCreateIdempotencyKeys.createdAt} asc, ${issueCreateIdempotencyKeys.id} asc limit ${ISSUE_CREATE_IDEMPOTENCY_KEY_CLEANUP_BATCH_SIZE} ) `); [existingIssue] = await tx .select() .from(issueCreateIdempotencyKeys) .innerJoin(issues, eq(issueCreateIdempotencyKeys.issueId, issues.id)) .where(and( eq(issueCreateIdempotencyKeys.companyId, companyId), eq(issueCreateIdempotencyKeys.idempotencyKey, idempotencyKey), )) .limit(1) .then((rows) => rows.map((row) => row.issues)); if (existingIssue) deduplicationReason = "idempotency_key"; } if (!existingIssue && allowDuplicate === false) { [existingIssue] = await tx .select() .from(issues) .where(and( eq(issues.companyId, companyId), issueData.parentId ? eq(issues.parentId, issueData.parentId) : isNull(issues.parentId), isNull(issues.hiddenAt), notInArray(issues.status, ["done", "cancelled"]), gte(issues.createdAt, new Date(Date.now() - 48 * 60 * 60 * 1000)), sql`lower(regexp_replace(btrim(${issues.title}), '\\s+', ' ', 'g')) = ${normalizedTitle}`, )) .orderBy(asc(issues.createdAt), asc(issues.id)) .limit(1); if (existingIssue) deduplicationReason = "recent_open_title"; } if (existingIssue) { if (idempotencyKey) { await tx .insert(issueCreateIdempotencyKeys) .values({ companyId, idempotencyKey, issueId: existingIssue.id }) .onConflictDoNothing(); } if (deduplicationReason) onDeduplicated?.(deduplicationReason); const [enriched] = await withIssueLabels(tx, [existingIssue]); const [withRelations] = await withIssueRelationSummaries(companyId, [enriched], tx); return withRelations; } const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId); let projectWorkspaceId = issueData.projectWorkspaceId ?? null; let executionWorkspaceId = issueData.executionWorkspaceId ?? null; let executionWorkspacePreference = issueData.executionWorkspacePreference ?? null; let executionWorkspaceSettings = (issueData.executionWorkspaceSettings as Record | null | undefined) ?? null; const workspaceInheritanceIssueId = skipExecutionWorkspaceInheritance ? null : inheritExecutionWorkspaceFromIssueId ?? issueData.parentId ?? null; const hasExplicitExecutionWorkspaceOverride = issueData.executionWorkspaceId !== undefined || issueData.executionWorkspacePreference !== undefined || issueData.executionWorkspaceSettings !== undefined; if (workspaceInheritanceIssueId) { const workspaceSource = await getWorkspaceInheritanceIssue(tx, companyId, workspaceInheritanceIssueId); if (issueData.projectId == null && workspaceSource.projectId) { issueData.projectId = workspaceSource.projectId; } if (projectWorkspaceId == null && workspaceSource.projectWorkspaceId) { projectWorkspaceId = workspaceSource.projectWorkspaceId; } if ( isolatedWorkspacesEnabled && !hasExplicitExecutionWorkspaceOverride && workspaceSource.executionWorkspaceId ) { const sourceWorkspace = await tx .select({ id: executionWorkspaces.id, mode: executionWorkspaces.mode, }) .from(executionWorkspaces) .where(eq(executionWorkspaces.id, workspaceSource.executionWorkspaceId)) .then((rows) => rows[0] ?? null); if (sourceWorkspace) { executionWorkspaceId = sourceWorkspace.id; executionWorkspacePreference = "reuse_existing"; executionWorkspaceSettings = { ...((workspaceSource.executionWorkspaceSettings as Record | null | undefined) ?? {}), mode: issueExecutionWorkspaceModeForPersistedWorkspace(sourceWorkspace.mode), }; } } } if (issueData.projectId == null && projectWorkspaceId) { const workspace = await assertValidProjectWorkspace(companyId, null, projectWorkspaceId, tx); issueData.projectId = workspace.projectId; } if (issueData.projectId == null && executionWorkspaceId) { const workspace = await assertValidExecutionWorkspace(companyId, null, executionWorkspaceId, tx); issueData.projectId = workspace.projectId; } const projectGoalId = await getProjectDefaultGoalId(tx, companyId, issueData.projectId); // Cache the project policy lookup for this insert so the default // workspace-settings block does not re-query the project row. let projectPolicyCached: ReturnType | null = null; let projectPolicyLoaded = false; const loadProjectPolicyOnce = async () => { if (projectPolicyLoaded) return projectPolicyCached; projectPolicyLoaded = true; if (!issueData.projectId) return null; const projectRow = await tx .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy }) .from(projects) .where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, companyId))) .then((rows) => rows[0] ?? null); projectPolicyCached = parseProjectExecutionWorkspacePolicy(projectRow?.executionWorkspacePolicy); return projectPolicyCached; }; if ( executionWorkspaceSettings == null && executionWorkspaceId == null && issueData.projectId ) { executionWorkspaceSettings = defaultIssueExecutionWorkspaceSettingsForProject( gateProjectExecutionWorkspacePolicy( await loadProjectPolicyOnce(), isolatedWorkspacesEnabled, ), ) as Record | null; } if (!projectWorkspaceId && issueData.projectId) { const project = await tx .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy, }) .from(projects) .where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, companyId))) .then((rows) => rows[0] ?? null); const projectPolicy = parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy); projectWorkspaceId = projectPolicy?.defaultProjectWorkspaceId ?? null; if (!projectWorkspaceId) { projectWorkspaceId = await tx .select({ id: projectWorkspaces.id }) .from(projectWorkspaces) .where(and(eq(projectWorkspaces.projectId, issueData.projectId), eq(projectWorkspaces.companyId, companyId))) .orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)) .then((rows) => rows[0]?.id ?? null); } } if (projectWorkspaceId) { await assertValidProjectWorkspace(companyId, issueData.projectId, projectWorkspaceId, tx); } if (executionWorkspaceId) { await assertValidExecutionWorkspace(companyId, issueData.projectId, executionWorkspaceId, tx); } if (isolatedWorkspacesEnabled && issueData.executionWorkspaceSettings !== undefined) { assertExplicitPinnedWorktreeIssueRunnable({ projectId: issueData.projectId ?? null, projectWorkspaceId, executionWorkspaceId, executionWorkspacePreference, executionWorkspaceSettings: issueData.executionWorkspaceSettings, }); } // Self-correcting counter: use MAX(issue_number) + 1 if the counter // has drifted below the actual max, preventing identifier collisions. const [maxRow] = await tx .select({ maxNum: sql`coalesce(max(${issues.issueNumber}), 0)` }) .from(issues) .where(eq(issues.companyId, companyId)); const currentMax = maxRow?.maxNum ?? 0; const [company] = await tx .update(companies) .set({ issueCounter: sql`greatest(${companies.issueCounter}, ${currentMax}) + 1`, }) .where(eq(companies.id, companyId)) .returning({ issueCounter: companies.issueCounter, issuePrefix: companies.issuePrefix }); const issueNumber = company.issueCounter; const identifier = `${company.issuePrefix}-${issueNumber}`; const responsibleUserId = await resolveResponsibleUserIdForIssueCreate(tx, companyId, { explicitResponsibleUserId: issueData.responsibleUserId ?? null, createdByUserId: issueData.createdByUserId ?? null, parentId: issueData.parentId ?? null, originKind: issueData.originKind ?? "manual", originRunId: issueData.originRunId ?? null, actorRunId: actorRunId ?? null, actorResponsibleUserId: actorResponsibleUserId ?? null, trustExplicitResponsibleUserId: trustExplicitResponsibleUserId === true, }); const values = { ...issueData, responsibleUserId, requestDepth: clampIssueRequestDepth(issueData.requestDepth), originKind: issueData.originKind ?? "manual", goalId: resolveIssueGoalId({ projectId: issueData.projectId, goalId: issueData.goalId, projectGoalId, defaultGoalId: defaultCompanyGoal?.id ?? null, }), ...(projectWorkspaceId ? { projectWorkspaceId } : {}), ...(executionWorkspaceId ? { executionWorkspaceId } : {}), ...(executionWorkspacePreference ? { executionWorkspacePreference } : {}), ...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}), companyId, issueNumber, identifier, } as typeof issues.$inferInsert; if (values.status === "in_progress" && !values.startedAt) { values.startedAt = new Date(); } if (values.status === "done") { values.completedAt = new Date(); } if (values.status === "cancelled") { values.cancelledAt = new Date(); } Object.assign( values, buildInitialIssueMonitorFields({ policy: normalizeIssueExecutionPolicy(issueData.executionPolicy ?? null), status: values.status ?? "backlog", assigneeAgentId: values.assigneeAgentId ?? null, assigneeUserId: values.assigneeUserId ?? null, }), ); const [issue] = await tx.insert(issues).values(values).returning(); if (idempotencyKey) { await tx.insert(issueCreateIdempotencyKeys).values({ companyId, idempotencyKey, issueId: issue.id, }); } if (watchdog) { await upsertIssueWatchdogForIssue(tx, companyId, issue.id, { agentId: watchdog.agentId, instructions: watchdog.instructions, actor: { agentId: issueData.createdByAgentId ?? null, userId: issueData.createdByUserId ?? null, runId: watchdogActorRunId ?? null, }, }); } if (inputLabelIds) { await syncIssueLabels(issue.id, companyId, inputLabelIds, tx); } if (blockedByIssueIds !== undefined) { await syncBlockedByIssueIds( issue.id, companyId, blockedByIssueIds, { agentId: issueData.createdByAgentId ?? null, userId: issueData.createdByUserId ?? null, }, tx, ); } const [enriched] = await withIssueLabels(tx, [issue]); const [withRelations] = await withIssueRelationSummaries(companyId, [enriched], tx); return withRelations; }); }, /** * Batched issue insert for company import. * * Company import used to call {@link create} once per issue — each call a * separate network round-trip that inserted the issue, then serialized the * issue's comments/documents behind the returned id. This inserts a whole * bundle of pre-resolved issues (ids already generated by the caller) in * chunked multi-row statements, so a thousand-issue import issues a handful * of statements instead of thousands. * * The per-issue derivations {@link create} performs are reproduced for the * import subset: a single contiguous identifier range is allocated from the * company counter, per-project goal/workspace/policy defaults are computed * once and cached, assignable-agent and workspace validation run per * distinct id, and monitor notes land un-armed on the row. Dedup, * idempotency, watchdogs, workspace inheritance and blocked-by wiring — none * of which import uses — are intentionally omitted. */ importIssues: async (companyId: string, rows: ImportIssueRow[]): Promise => { if (rows.length === 0) return; const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces; await db.transaction(async (tx) => { // Self-correcting counter: seed from max(issue_number) so a drifted // company counter cannot mint colliding identifiers, then reserve the // whole range in one bump instead of one-per-issue. const [maxRow] = await tx .select({ maxNum: sql`coalesce(max(${issues.issueNumber}), 0)` }) .from(issues) .where(eq(issues.companyId, companyId)); const currentMax = maxRow?.maxNum ?? 0; const [company] = await tx .select({ issueCounter: companies.issueCounter, issuePrefix: companies.issuePrefix }) .from(companies) .where(eq(companies.id, companyId)); if (!company) throw notFound("Target company not found"); const base = Math.max(company.issueCounter ?? 0, currentMax); await tx .update(companies) .set({ issueCounter: base + rows.length }) .where(eq(companies.id, companyId)); const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId); const defaultGoalId = defaultCompanyGoal?.id ?? null; // Project-scoped derivations depend only on the project, so resolve each // distinct project once and reuse it across that project's issues. const projectDerivedCache = new Map< string, { goalId: string | null; defaultProjectWorkspaceId: string | null; defaultExecutionWorkspaceSettings: Record | null; } >(); const loadProjectDerived = async (projectId: string) => { const cached = projectDerivedCache.get(projectId); if (cached) return cached; const projectRow = await tx .select({ goalId: projects.goalId, executionWorkspacePolicy: projects.executionWorkspacePolicy, }) .from(projects) .where(and(eq(projects.id, projectId), eq(projects.companyId, companyId))) .then((r) => r[0] ?? null); const policy = parseProjectExecutionWorkspacePolicy(projectRow?.executionWorkspacePolicy); let defaultProjectWorkspaceId = policy?.defaultProjectWorkspaceId ?? null; if (!defaultProjectWorkspaceId) { defaultProjectWorkspaceId = await tx .select({ id: projectWorkspaces.id }) .from(projectWorkspaces) .where(and(eq(projectWorkspaces.projectId, projectId), eq(projectWorkspaces.companyId, companyId))) .orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)) .then((r) => r[0]?.id ?? null); } const defaultExecutionWorkspaceSettings = defaultIssueExecutionWorkspaceSettingsForProject( gateProjectExecutionWorkspacePolicy(policy, isolatedWorkspacesEnabled), ) as Record | null; const derived = { goalId: projectRow?.goalId ?? null, defaultProjectWorkspaceId, defaultExecutionWorkspaceSettings }; projectDerivedCache.set(projectId, derived); return derived; }; const validatedAgentIds = new Set(); const validatedWorkspaceKeys = new Set(); const issueRows: Array> = []; const labelRows: Array<{ issueId: string; labelId: string; companyId: string }> = []; let counter = base; for (const row of rows) { counter += 1; const issueNumber = counter; const identifier = `${company.issuePrefix}-${issueNumber}`; if (row.assigneeAgentId) { if (!validatedAgentIds.has(row.assigneeAgentId)) { await assertAssignableAgent(tx as unknown as Db, companyId, row.assigneeAgentId, { kind: "work" }); validatedAgentIds.add(row.assigneeAgentId); } } if (row.status === "in_progress" && !row.assigneeAgentId) { throw unprocessable("in_progress issues require an assignee"); } const projectId = row.projectId ?? null; let projectWorkspaceId = row.projectWorkspaceId ?? null; // Isolated-workspace fields are gated the same way create() gates them: // when the experiment is off the imported settings are dropped. let executionWorkspaceSettings = isolatedWorkspacesEnabled ? (row.executionWorkspaceSettings ?? null) : null; let projectGoalId: string | null = null; if (projectId) { const derived = await loadProjectDerived(projectId); projectGoalId = derived.goalId; if (!projectWorkspaceId) projectWorkspaceId = derived.defaultProjectWorkspaceId; if (executionWorkspaceSettings == null) { executionWorkspaceSettings = derived.defaultExecutionWorkspaceSettings; } } if (projectWorkspaceId) { const workspaceKey = `${projectId ?? ""}:${projectWorkspaceId}`; if (!validatedWorkspaceKeys.has(workspaceKey)) { await assertValidProjectWorkspace(companyId, projectId, projectWorkspaceId, tx); validatedWorkspaceKeys.add(workspaceKey); } } const goalId = resolveIssueGoalId({ projectId, goalId: null, projectGoalId, defaultGoalId, }); issueRows.push({ id: row.id, companyId, issueNumber, identifier, title: row.title, description: row.description ?? null, assigneeAgentId: row.assigneeAgentId ?? null, status: row.status, priority: row.priority, billingCode: row.billingCode ?? null, assigneeAdapterOverrides: row.assigneeAdapterOverrides ?? null, projectId, projectWorkspaceId, executionWorkspaceSettings, goalId, responsibleUserId: null, requestDepth: clampIssueRequestDepth(undefined), originKind: "manual", // The caller resolves parentId against ids in this same batch, so a // parent always lands in the same insert (rows arrive parents-first). parentId: row.parentId ?? null, // Preserved bundle timestamps win; without them createdAt/updatedAt // fall back to the insert time (the old defaultNow() behavior). createdAt: row.createdAt ?? new Date(), updatedAt: row.updatedAt ?? new Date(), // Imported in-progress work did not start at import time; fabricating // startedAt here trips duration-based sweeps (e.g. productivity // review). Only a bundle-carried startedAt is written. startedAt: row.startedAt ?? null, completedAt: row.completedAt ?? (row.status === "done" ? new Date() : null), cancelledAt: row.cancelledAt ?? (row.status === "cancelled" ? new Date() : null), monitorNotes: row.monitorNotes ?? null, monitorScheduledBy: row.monitorScheduledBy ?? null, }); for (const labelId of new Set(row.labelIds ?? [])) { labelRows.push({ issueId: row.id, labelId, companyId }); } } await insertRowsInChunks(tx, issues, issueRows); await insertRowsInChunks(tx, issueLabels, labelRows); }); }, /** * Batched comment insert for company import. Comment ids are pre-generated * by the caller so attachments can reference them without a round-trip. */ addImportedComments: async (rows: ImportIssueCommentRow[]): Promise => { if (rows.length === 0) return; const censorUsernameInLogs = (await instanceSettings.getGeneral()).censorUsernameInLogs; await db.transaction(async (tx) => { const commentRows = rows.map((row) => { const createdAt = row.createdAt ? new Date(row.createdAt) : null; return { id: row.id, companyId: row.companyId, issueId: row.issueId, authorAgentId: row.authorAgentId ?? null, authorUserId: row.authorUserId ?? null, authorType: row.authorType, createdByRunId: null, body: redactCurrentUserText(row.body, { enabled: censorUsernameInLogs }), presentation: row.presentation ?? null, metadata: row.metadata ?? null, sourceTrust: null, createdAt: createdAt && !Number.isNaN(createdAt.getTime()) ? createdAt : new Date(), }; }); await insertRowsInChunks(tx, issueComments, commentRows); // Mirror addComment's recency bump, once per affected issue — but never // backwards. An issue imported with a preserved updatedAt keeps it // unless a newer imported comment outdates it; issues without preserved // timestamps carry an insert-time updatedAt, so GREATEST reproduces the // old "bump to now" behavior for them. const bumpAtByIssueId = new Map(); for (const row of commentRows) { const existing = bumpAtByIssueId.get(row.issueId); if (!existing || row.createdAt > existing) bumpAtByIssueId.set(row.issueId, row.createdAt); } const bumpEntries = [...bumpAtByIssueId.entries()]; for (let start = 0; start < bumpEntries.length; start += DEFAULT_INSERT_CHUNK_ROWS) { const chunk = bumpEntries.slice(start, start + DEFAULT_INSERT_CHUNK_ROWS); await tx.execute(sql` update ${issues} set updated_at = greatest(${issues.updatedAt}, bumps.bump_at) from (values ${sql.join( chunk.map(([issueId, bumpAt]) => sql`(${issueId}::uuid, ${bumpAt.toISOString()}::timestamptz)`), sql`, `, )}) as bumps(issue_id, bump_at) where ${issues.id} = bumps.issue_id `); } }); }, /** * Batched attachment insert for company import: each row mints an asset and * links it to its issue (and optionally comment) in two chunked statements. */ addImportedAttachments: async (rows: ImportIssueAttachmentRow[]): Promise => { if (rows.length === 0) return; await db.transaction(async (tx) => { const assetRows: Array> = []; const attachmentRows: Array> = []; for (const row of rows) { const assetId = randomUUID(); assetRows.push({ id: assetId, companyId: row.companyId, provider: row.provider, objectKey: row.objectKey, contentType: row.contentType, byteSize: row.byteSize, sha256: row.sha256, originalFilename: row.originalFilename ?? null, createdByAgentId: row.createdByAgentId ?? null, createdByUserId: row.createdByUserId ?? null, }); attachmentRows.push({ companyId: row.companyId, issueId: row.issueId, assetId, issueCommentId: row.issueCommentId ?? null, }); } await insertRowsInChunks(tx, assets, assetRows); await insertRowsInChunks(tx, issueAttachments, attachmentRows); }); }, update: async ( id: string, data: Partial & { labelIds?: string[]; blockedByIssueIds?: string[]; actorAgentId?: string | null; actorUserId?: string | null; }, dbOrTx: any = db, postCommitActivityPublications?: ActivityPublication[], ) => { const ownedActivityPublications: ActivityPublication[] = []; const activityPublications = postCommitActivityPublications ?? ownedActivityPublications; const existing = await dbOrTx .select() .from(issues) .where(eq(issues.id, id)) .then((rows: Array) => rows[0] ?? null); if (!existing) return null; const { labelIds: nextLabelIds, blockedByIssueIds, actorAgentId, actorUserId, ...issueData } = data; const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces; if (!isolatedWorkspacesEnabled) { delete issueData.executionWorkspaceId; delete issueData.executionWorkspacePreference; delete issueData.executionWorkspaceSettings; } if (issueData.status) { assertTransition(existing.status, issueData.status); } const patch: Partial = { ...issueData, updatedAt: new Date(), }; if (existing.status !== "blocked" && issueData.status === "blocked") { patch.blockedTransitionAt = patch.updatedAt; patch.blockedOwnerNotifiedAt = null; } else if (existing.status === "blocked" && issueData.status && issueData.status !== "blocked") { patch.unblockDescriptor = null; patch.blockedTransitionAt = null; patch.blockedOwnerNotifiedAt = null; } if (issueData.requestDepth !== undefined) { patch.requestDepth = clampIssueRequestDepth(issueData.requestDepth); } const nextAssigneeAgentId = issueData.assigneeAgentId !== undefined ? issueData.assigneeAgentId : existing.assigneeAgentId; const nextAssigneeUserId = issueData.assigneeUserId !== undefined ? issueData.assigneeUserId : existing.assigneeUserId; if (nextAssigneeAgentId && nextAssigneeUserId) { throw unprocessable("Issue can only have one assignee"); } if (patch.status === "in_progress" && !nextAssigneeAgentId && !nextAssigneeUserId) { throw unprocessable("in_progress issues require an assignee"); } if (patch.status === "in_progress") { const dependencyReadiness = blockedByIssueIds === undefined ? (await listIssueDependencyReadinessMap(dbOrTx, existing.companyId, [id])).get(id) : null; const unresolvedBlockerIssueIds = blockedByIssueIds !== undefined ? await listUnresolvedBlockerIssueIds(dbOrTx, existing.companyId, blockedByIssueIds) : dependencyReadiness?.unresolvedBlockerIssueIds ?? []; if (unresolvedBlockerIssueIds.length > 0) { const unresolvedBlockers = await listUnresolvedBlockerDetails( dbOrTx, existing.companyId, unresolvedBlockerIssueIds, dependencyReadiness?.pendingFinalizeBlockerIssueIds, ); throw unprocessable("Issue is blocked by unresolved blockers", { unresolvedBlockerIssueIds, unresolvedBlockers, }); } } const shouldValidateNextAssignee = Boolean(nextAssigneeAgentId) && (issueData.assigneeAgentId !== undefined || patch.status === "in_progress"); if (shouldValidateNextAssignee) { await assertAssignableAgent(dbOrTx as Db, existing.companyId, nextAssigneeAgentId, { kind: "work" }); } if (issueData.assigneeUserId) { await assertAssignableUser(existing.companyId, issueData.assigneeUserId); } let nextProjectId = issueData.projectId !== undefined ? issueData.projectId : existing.projectId; const nextProjectWorkspaceId = issueData.projectWorkspaceId !== undefined ? issueData.projectWorkspaceId : existing.projectWorkspaceId; const nextExecutionWorkspaceId = issueData.executionWorkspaceId !== undefined ? issueData.executionWorkspaceId : existing.executionWorkspaceId; const nextExecutionWorkspacePreference = issueData.executionWorkspacePreference !== undefined ? issueData.executionWorkspacePreference : existing.executionWorkspacePreference; const nextExecutionWorkspaceSettings = issueData.executionWorkspaceSettings !== undefined ? parseIssueExecutionWorkspaceSettings(issueData.executionWorkspaceSettings) : parseIssueExecutionWorkspaceSettings(existing.executionWorkspaceSettings); if (issueData.executionWorkspaceSettings !== undefined) { patch.executionWorkspaceSettings = nextExecutionWorkspaceSettings ? { ...nextExecutionWorkspaceSettings } : null; } let validatedProjectWorkspace: { projectId: string } | null = null; let validatedExecutionWorkspace: { projectId: string } | null = null; if (!nextProjectId && nextProjectWorkspaceId) { const workspace = await assertValidProjectWorkspace(existing.companyId, null, nextProjectWorkspaceId); validatedProjectWorkspace = workspace; nextProjectId = workspace.projectId; patch.projectId = workspace.projectId; } if (!nextProjectId && nextExecutionWorkspaceId) { const workspace = await assertValidExecutionWorkspace(existing.companyId, null, nextExecutionWorkspaceId); validatedExecutionWorkspace = workspace; nextProjectId = workspace.projectId; patch.projectId = workspace.projectId; } if (nextProjectWorkspaceId) { if (!validatedProjectWorkspace) { await assertValidProjectWorkspace(existing.companyId, nextProjectId, nextProjectWorkspaceId); } } if (nextExecutionWorkspaceId) { if (!validatedExecutionWorkspace) { await assertValidExecutionWorkspace(existing.companyId, nextProjectId, nextExecutionWorkspaceId); } } if (isolatedWorkspacesEnabled && issueData.executionWorkspaceSettings !== undefined) { assertExplicitPinnedWorktreeIssueRunnable({ projectId: nextProjectId ?? null, projectWorkspaceId: nextProjectWorkspaceId ?? null, executionWorkspaceId: nextExecutionWorkspaceId ?? null, executionWorkspacePreference: nextExecutionWorkspacePreference ?? null, executionWorkspaceSettings: issueData.executionWorkspaceSettings, }); } applyStatusSideEffects(issueData.status, patch); if (issueData.status && issueData.status !== "done") { patch.completedAt = null; } if (issueData.status && issueData.status !== "cancelled") { patch.cancelledAt = null; } if (issueData.status && issueData.status !== "in_progress") { patch.checkoutRunId = null; patch.executionRunId = null; patch.executionAgentNameKey = null; patch.executionLockedAt = null; } if ( (issueData.assigneeAgentId !== undefined && issueData.assigneeAgentId !== existing.assigneeAgentId) || (issueData.assigneeUserId !== undefined && issueData.assigneeUserId !== existing.assigneeUserId) ) { patch.checkoutRunId = null; patch.executionRunId = null; patch.executionAgentNameKey = null; patch.executionLockedAt = null; } const runUpdate = async (tx: any) => { // The receipt baseline must be read under the same row lock as the // write. Otherwise a concurrent update can be mistaken for a change // made by this request. const receiptExisting = await tx .select() .from(issues) .where(eq(issues.id, id)) .for("update") .then((rows: Array) => rows[0] ?? null); if (!receiptExisting) return null; const [previousLabelsByIssueId, previousRelationSummaries] = await Promise.all([ nextLabelIds !== undefined ? labelMapForIssues(tx, [id]) : Promise.resolve(new Map()), blockedByIssueIds !== undefined ? getIssueRelationSummaryMap(existing.companyId, [id], tx) : Promise.resolve(new Map()), ]); const defaultCompanyGoal = await getDefaultCompanyGoal(tx, existing.companyId); const [currentProjectGoalId, nextProjectGoalId] = await Promise.all([ getProjectDefaultGoalId(tx, existing.companyId, existing.projectId), getProjectDefaultGoalId( tx, existing.companyId, issueData.projectId !== undefined ? issueData.projectId : existing.projectId, ), ]); patch.goalId = resolveNextIssueGoalId({ currentProjectId: existing.projectId, currentGoalId: existing.goalId, currentProjectGoalId, projectId: issueData.projectId, goalId: issueData.goalId, projectGoalId: nextProjectGoalId, defaultGoalId: defaultCompanyGoal?.id ?? null, }); const updated = await tx .update(issues) .set(patch) .where(eq(issues.id, id)) .returning() .then((rows: Array) => rows[0] ?? null); if (!updated) return null; if (existing.status !== updated.status) { if ( (existing.status === "done" || existing.status === "cancelled") && updated.status !== "done" && updated.status !== "cancelled" ) { const terminalWorkspaces = await tx .select({ id: executionWorkspaces.id }) .from(executionWorkspaces) .where(and( eq(executionWorkspaces.companyId, updated.companyId), eq(executionWorkspaces.sourceIssueId, updated.id), eq(executionWorkspaces.status, "archived"), like(executionWorkspaces.cleanupReason, "issue_terminal%"), )); for (const workspace of terminalWorkspaces) { await logActivity(tx as unknown as Db, { companyId: updated.companyId, actorType: actorAgentId ? "agent" : actorUserId ? "user" : "system", actorId: actorAgentId ?? actorUserId ?? "issue_service", agentId: actorAgentId ?? null, action: "execution_workspace.source_issue_reopened", entityType: "execution_workspace", entityId: workspace.id, details: { sourceIssueId: updated.id, previousIssueStatus: existing.status, nextIssueStatus: updated.status, workspaceAction: "left_archived", }, }); } } if (updated.status === "done" || updated.status === "cancelled") { await finalizeSummarySlotsForTerminalIssue(tx, updated); // Every terminal transition funnels through here, including direct // service callers (tree control, recovery, pipelines, status cards) // that never touch the HTTP routes, so pending interaction cards // cannot outlive their issue. Dynamic import breaks the module // cycle (issue-thread-interactions.js imports issueService). const { issueThreadInteractionService } = await import("./issue-thread-interactions.js"); const expiredInteractions = await issueThreadInteractionService(tx).expirePendingInteractionsForTerminalIssue( updated, { agentId: actorAgentId ?? null, userId: actorUserId ?? null }, ); for (const interaction of expiredInteractions) { await logActivity(tx as unknown as Db, { companyId: updated.companyId, actorType: actorAgentId ? "agent" : actorUserId ? "user" : "system", actorId: actorAgentId ?? actorUserId ?? "issue_service", agentId: actorAgentId ?? null, action: "issue.thread_interaction_expired", entityType: "issue", entityId: updated.id, details: { identifier: updated.identifier ?? null, interactionId: interaction.id, interactionKind: interaction.kind, interactionStatus: interaction.status, source: "issue.status_transition.issue_closed", result: interaction.result ?? null, }, }); } } // A status-card generation task that goes done/cancelled/blocked stops // making progress; release the card's generation claim so the board tile // stops spinning and offers "Run now" again (blocked = stuck on a human). if ( updated.status === "done" || updated.status === "cancelled" || updated.status === "blocked" ) { await finalizeStatusCardsForStalledGeneration(tx, updated); } } if (nextLabelIds !== undefined) { await syncIssueLabels(updated.id, existing.companyId, nextLabelIds, tx); } if (blockedByIssueIds !== undefined) { await syncBlockedByIssueIds( updated.id, existing.companyId, blockedByIssueIds, { agentId: actorAgentId ?? null, userId: actorUserId ?? null, }, tx, ); } if ( issueData.executionWorkspaceSettings !== undefined && nextExecutionWorkspaceId && nextExecutionWorkspacePreference === "reuse_existing" ) { const workspace = await tx .select({ id: executionWorkspaces.id, metadata: executionWorkspaces.metadata, }) .from(executionWorkspaces) .where( and( eq(executionWorkspaces.id, nextExecutionWorkspaceId), eq(executionWorkspaces.companyId, existing.companyId), ), ) .then((rows: Array<{ id: string; metadata: unknown }>) => rows[0] ?? null); if (workspace) { await tx .update(executionWorkspaces) .set({ metadata: mergeExecutionWorkspaceConfig( (workspace.metadata as Record | null) ?? null, buildReusedExecutionWorkspaceConfigPatchFromIssueSettings(nextExecutionWorkspaceSettings), ), updatedAt: new Date(), }) .where(eq(executionWorkspaces.id, workspace.id)); } } const [enriched] = await withIssueLabels(tx, [updated]); const nextBlockedByIssueIds = blockedByIssueIds === undefined ? undefined : [...new Set(blockedByIssueIds)].sort(); const changes = buildIssueChanges( receiptExisting as unknown as Record, updated as unknown as Record, { ...(nextLabelIds !== undefined ? { labelIds: { from: (previousLabelsByIssueId.get(id) ?? []).map((label) => label.id), to: enriched.labelIds, }, } : {}), ...(nextBlockedByIssueIds !== undefined ? { blockedByIssueIds: { from: (previousRelationSummaries.get(id)?.blockedBy ?? []).map((relation) => relation.id), to: nextBlockedByIssueIds, }, } : {}), }, ); if ( (issueData.status === "done" || issueData.status === "cancelled") && existing.status !== issueData.status && existing.originKind === RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation ) { const parsedIncident = parseIssueGraphLivenessIncidentKey(existing.originId); if (parsedIncident?.issueId && parsedIncident.companyId === existing.companyId) { await tx .delete(issueRelations) .where( and( eq(issueRelations.companyId, existing.companyId), eq(issueRelations.issueId, existing.id), eq(issueRelations.relatedIssueId, parsedIncident.issueId), eq(issueRelations.type, "blocks"), ), ); } } if (actorUserId && receiptExisting.status !== "done" && updated.status === "done") { if (dbOrTx !== db && !postCommitActivityPublications) { throw new Error("Human completion in an external transaction requires a post-commit activity queue"); } const now = new Date(); const archiveState = await archiveInbox( updated.companyId, updated.id, actorUserId, now, undefined, tx, ); const { publication } = await persistActivity(tx as unknown as Db, { companyId: updated.companyId, actorType: "user", actorId: actorUserId, action: "issue.inbox_archived", entityType: "issue", entityId: updated.id, details: { userId: actorUserId, archivedAt: archiveState.archivedAt, targetResolvedFrom: "responsible_user", source: "issue_status_done", }, }); activityPublications.push(publication); } return { ...enriched, ...(nextBlockedByIssueIds !== undefined ? { blockedByIssueIds: nextBlockedByIssueIds } : {}), changes, }; }; const result = await (dbOrTx === db ? db.transaction(runUpdate) : runUpdate(dbOrTx)); if (dbOrTx === db && !postCommitActivityPublications) { for (const publication of ownedActivityPublications) publishActivity(publication); } return result; }, clearExecutionWorkspaceEnvironmentSelection: async (companyId: string, environmentId: string) => { const rows = await db .select({ id: issues.id, executionWorkspaceSettings: issues.executionWorkspaceSettings, }) .from(issues) .where(eq(issues.companyId, companyId)); let cleared = 0; for (const row of rows) { const settings = parseIssueExecutionWorkspaceSettings( row.executionWorkspaceSettings, { includeEnvironmentId: true }, ); if (settings?.environmentId !== environmentId) continue; await db .update(issues) .set({ executionWorkspaceSettings: { ...settings, environmentId: null, }, updatedAt: new Date(), }) .where(eq(issues.id, row.id)); cleared += 1; } return cleared; }, remove: (id: string) => db.transaction(async (tx) => { const attachmentAssetIds = await tx .select({ assetId: issueAttachments.assetId }) .from(issueAttachments) .where(eq(issueAttachments.issueId, id)); const issueDocumentIds = await tx .select({ documentId: issueDocuments.documentId }) .from(issueDocuments) .where(eq(issueDocuments.issueId, id)); let removedIssue; try { removedIssue = await tx .delete(issues) .where(eq(issues.id, id)) .returning() .then((rows) => rows[0] ?? null); } catch (err) { // A foreign key to issues.id without a delete policy blocks the delete // and raises SQLSTATE 23503. Map it to a clear 409 instead of a bare // 500. This also covers the decisions table, whose NOT NULL references // to issues.id stay restricted on purpose. if (isForeignKeyViolation(err)) { throw conflict("Issue cannot be deleted because another record still references it."); } throw err; } if (removedIssue && attachmentAssetIds.length > 0) { await tx .delete(assets) .where(inArray(assets.id, attachmentAssetIds.map((row) => row.assetId))); } if (removedIssue && issueDocumentIds.length > 0) { await tx .delete(documents) .where(inArray(documents.id, issueDocumentIds.map((row) => row.documentId))); } if (!removedIssue) return null; const [enriched] = await withIssueLabels(tx, [removedIssue]); return enriched; }), checkout: async (id: string, agentId: string, expectedStatuses: string[], checkoutRunId: string | null) => { const issueCompany = await db .select({ companyId: issues.companyId }) .from(issues) .where(eq(issues.id, id)) .then((rows) => rows[0] ?? null); if (!issueCompany) throw notFound("Issue not found"); await assertAssignableAgent(db, issueCompany.companyId, agentId, { kind: "work" }); const now = new Date(); const activePauseHold = await treeControlSvc.getActivePauseHoldGate(issueCompany.companyId, id); if ( activePauseHold && !(await isTreeHoldInteractionCheckoutAllowed(issueCompany.companyId, checkoutRunId, activePauseHold)) ) { throw conflict("Issue checkout blocked by active subtree pause hold", { issueId: id, holdId: activePauseHold.holdId, rootIssueId: activePauseHold.rootIssueId, mode: activePauseHold.mode, securityPrinciples: ["Complete Mediation", "Fail Securely", "Secure Defaults"], }); } await clearExecutionRunIfTerminal(id); await clearCheckoutRunIfTerminal(id); const dependencyReadiness = await listIssueDependencyReadinessMap(db, issueCompany.companyId, [id]); const readiness = dependencyReadiness.get(id); const unresolvedBlockerIssueIds = readiness?.unresolvedBlockerIssueIds ?? []; if (unresolvedBlockerIssueIds.length > 0) { const unresolvedBlockers = await listUnresolvedBlockerDetails( db, issueCompany.companyId, unresolvedBlockerIssueIds, readiness?.pendingFinalizeBlockerIssueIds, ); throw unprocessable("Issue is blocked by unresolved blockers", { unresolvedBlockerIssueIds, unresolvedBlockers, }); } const sameRunAssigneeCondition = checkoutRunId ? and( eq(issues.assigneeAgentId, agentId), or(isNull(issues.checkoutRunId), eq(issues.checkoutRunId, checkoutRunId)), ) : and(eq(issues.assigneeAgentId, agentId), isNull(issues.checkoutRunId)); const executionLockCondition = checkoutRunId ? or(isNull(issues.executionRunId), eq(issues.executionRunId, checkoutRunId)) : isNull(issues.executionRunId); const updated = await db .update(issues) .set({ assigneeAgentId: agentId, assigneeUserId: null, checkoutRunId, executionRunId: checkoutRunId, status: "in_progress", startedAt: now, updatedAt: now, }) .where( and( eq(issues.id, id), inArray(issues.status, expectedStatuses), or(isNull(issues.assigneeAgentId), sameRunAssigneeCondition), executionLockCondition, ), ) .returning() .then((rows) => rows[0] ?? null); if (updated) { const [enriched] = await withIssueLabels(db, [updated]); return enriched; } const current = await db .select({ id: issues.id, status: issues.status, assigneeAgentId: issues.assigneeAgentId, checkoutRunId: issues.checkoutRunId, executionRunId: issues.executionRunId, }) .from(issues) .where(eq(issues.id, id)) .then((rows) => rows[0] ?? null); if (!current) throw notFound("Issue not found"); if ( current.assigneeAgentId === agentId && current.status === "in_progress" && current.checkoutRunId == null && (current.executionRunId == null || current.executionRunId === checkoutRunId) && checkoutRunId ) { const adopted = await db .update(issues) .set({ checkoutRunId, executionRunId: checkoutRunId, updatedAt: new Date(), }) .where( and( eq(issues.id, id), eq(issues.status, "in_progress"), eq(issues.assigneeAgentId, agentId), isNull(issues.checkoutRunId), or(isNull(issues.executionRunId), eq(issues.executionRunId, checkoutRunId)), ), ) .returning() .then((rows) => rows[0] ?? null); if (adopted) return adopted; } if ( checkoutRunId && current.assigneeAgentId === agentId && current.status === "in_progress" && current.checkoutRunId && current.checkoutRunId !== checkoutRunId ) { const staleAdoption = await adoptStaleCheckoutRun({ issueId: id, actorAgentId: agentId, actorRunId: checkoutRunId, expectedCheckoutRunId: current.checkoutRunId, }); if (staleAdoption.adopted) { const row = await db.select().from(issues).where(eq(issues.id, id)).then((rows) => rows[0] ?? null); if (!row) throw notFound("Issue not found"); const [enriched] = await withIssueLabels(db, [row]); return enriched; } } // Adopt stale executionRunId — if the execution lock points to a terminal/missing run, clear it and proceed. // Only adopts when the caller's expectedStatuses guard still holds; preserves any existing assigneeUserId // and preserves the original startedAt when the issue is already in_progress. if ( checkoutRunId && current.executionRunId && current.executionRunId !== checkoutRunId && (current.assigneeAgentId === agentId || current.assigneeAgentId == null) ) { const stale = await isTerminalOrMissingHeartbeatRun(current.executionRunId); if (stale) { const now = new Date(); const adoptionSet: Record = { assigneeAgentId: agentId, checkoutRunId, executionRunId: checkoutRunId, executionAgentNameKey: null, executionLockedAt: now, status: "in_progress", updatedAt: now, }; if (current.status !== "in_progress") { adoptionSet.startedAt = now; } const adopted = await db .update(issues) .set(adoptionSet) .where( and( eq(issues.id, id), inArray(issues.status, expectedStatuses), eq(issues.executionRunId, current.executionRunId), or(isNull(issues.assigneeAgentId), eq(issues.assigneeAgentId, agentId)), ), ) .returning() .then((rows) => rows[0] ?? null); if (adopted) { const [enriched] = await withIssueLabels(db, [adopted]); return enriched; } } } // If this run already owns it and it's in_progress, return it (no self-409) if ( current.assigneeAgentId === agentId && current.status === "in_progress" && sameRunLock(current.checkoutRunId, checkoutRunId) ) { const row = await db.select().from(issues).where(eq(issues.id, id)).then((rows) => rows[0] ?? null); if (!row) throw notFound("Issue not found"); const [enriched] = await withIssueLabels(db, [row]); return enriched; } throw conflict("Issue checkout conflict", { issueId: current.id, status: current.status, assigneeAgentId: current.assigneeAgentId, checkoutRunId: current.checkoutRunId, executionRunId: current.executionRunId, }); }, assertCheckoutOwner: async (id: string, actorAgentId: string, actorRunId: string | null) => { await clearExecutionRunIfTerminal(id); await clearCheckoutRunIfTerminal(id); const loadCurrent = () => db .select({ id: issues.id, status: issues.status, assigneeAgentId: issues.assigneeAgentId, checkoutRunId: issues.checkoutRunId, executionRunId: issues.executionRunId, }) .from(issues) .where(eq(issues.id, id)) .then((rows) => rows[0] ?? null); const current = await loadCurrent(); if (!current) throw notFound("Issue not found"); const resolveSameRunOwnership = (candidate: { id: string; status: string; assigneeAgentId: string | null; checkoutRunId: string | null; executionRunId: string | null; }) => { if ( candidate.status === "in_progress" && candidate.assigneeAgentId === actorAgentId && sameRunLock(candidate.checkoutRunId, actorRunId) ) { return { ...candidate, adoptedFromRunId: null as string | null }; } return null; }; const canAdoptUnownedCheckout = (candidate: { status: string; assigneeAgentId: string | null; checkoutRunId: string | null; executionRunId: string | null; }) => ( actorRunId && candidate.status === "in_progress" && candidate.assigneeAgentId === actorAgentId && candidate.checkoutRunId == null && (candidate.executionRunId == null || candidate.executionRunId === actorRunId) ); const resolveOwnership = async ( candidate: { id: string; status: string; assigneeAgentId: string | null; checkoutRunId: string | null; executionRunId: string | null; }, ) => { const sameRunOwnership = resolveSameRunOwnership(candidate); if (sameRunOwnership) return { ownership: sameRunOwnership, latest: null }; if (canAdoptUnownedCheckout(candidate)) { const adopted = await adoptUnownedCheckoutRun({ issueId: id, actorAgentId, actorRunId: actorRunId!, }); if (adopted) { return { ownership: { ...adopted, adoptedFromRunId: null as string | null, }, latest: null, }; } } if ( actorRunId && candidate.status === "in_progress" && candidate.assigneeAgentId === actorAgentId && candidate.checkoutRunId && candidate.checkoutRunId !== actorRunId ) { const previousCheckoutRunId = candidate.checkoutRunId; const staleAdoption = await adoptStaleCheckoutRun({ issueId: id, actorAgentId, actorRunId, expectedCheckoutRunId: previousCheckoutRunId, }); if (staleAdoption.adopted) { return { ownership: { ...staleAdoption.adopted, adoptedFromRunId: previousCheckoutRunId, }, latest: null, }; } if (staleAdoption.latest) { const latestOwnership = resolveSameRunOwnership(staleAdoption.latest); if (latestOwnership) return { ownership: latestOwnership, latest: staleAdoption.latest }; return { ownership: null, latest: staleAdoption.latest }; } } return { ownership: null, latest: null }; }; const resolved = await resolveOwnership(current); if (resolved.ownership) return resolved.ownership; const latest = resolved.latest ?? await loadCurrent(); if (!latest) throw notFound("Issue not found"); const resolvedLatest = await resolveOwnership(latest); if (resolvedLatest.ownership) return resolvedLatest.ownership; if (resolvedLatest.latest) { throw conflict("Issue run ownership conflict", { issueId: resolvedLatest.latest.id, status: resolvedLatest.latest.status, assigneeAgentId: resolvedLatest.latest.assigneeAgentId, checkoutRunId: resolvedLatest.latest.checkoutRunId, executionRunId: resolvedLatest.latest.executionRunId, actorAgentId, actorRunId, }); } throw conflict("Issue run ownership conflict", { issueId: latest.id, status: latest.status, assigneeAgentId: latest.assigneeAgentId, checkoutRunId: latest.checkoutRunId, executionRunId: latest.executionRunId, actorAgentId, actorRunId, }); }, release: async (id: string, actorAgentId?: string, actorRunId?: string | null) => db.transaction(async (tx) => { await tx.execute( sql`select ${issues.id} from ${issues} where ${issues.id} = ${id} for update`, ); const existing = await tx .select() .from(issues) .where(eq(issues.id, id)) .then((rows) => rows[0] ?? null); if (!existing) return null; if (actorAgentId && existing.assigneeAgentId && existing.assigneeAgentId !== actorAgentId) { throw conflict("Only assignee can release issue"); } if ( actorAgentId && existing.status === "in_progress" && existing.assigneeAgentId === actorAgentId && existing.checkoutRunId && !sameRunLock(existing.checkoutRunId, actorRunId ?? null) ) { const stale = await isTerminalOrMissingHeartbeatRun(existing.checkoutRunId, tx); if (!stale) { throw conflict("Only checkout run can release issue", { issueId: existing.id, assigneeAgentId: existing.assigneeAgentId, checkoutRunId: existing.checkoutRunId, actorRunId: actorRunId ?? null, }); } } // Release clears checkout/assignee locks; only in_progress work re-queues to todo. const releaseStatus = existing.status === "in_progress" ? "todo" : existing.status; const updated = await tx .update(issues) .set({ status: releaseStatus, assigneeAgentId: null, checkoutRunId: null, executionRunId: null, executionAgentNameKey: null, executionLockedAt: null, updatedAt: new Date(), }) .where(eq(issues.id, id)) .returning() .then((rows) => rows[0] ?? null); if (!updated) return null; const [enriched] = await withIssueLabels(tx, [updated]); return enriched; }), adminForceRelease: async (id: string, options: { clearAssignee?: boolean } = {}) => db.transaction(async (tx) => { await tx.execute( sql`select ${issues.id} from ${issues} where ${issues.id} = ${id} for update`, ); const existing = await tx .select({ id: issues.id, checkoutRunId: issues.checkoutRunId, executionRunId: issues.executionRunId, }) .from(issues) .where(eq(issues.id, id)) .then((rows) => rows[0] ?? null); if (!existing) return null; const patch: Partial = { checkoutRunId: null, executionRunId: null, executionAgentNameKey: null, executionLockedAt: null, updatedAt: new Date(), }; if (options.clearAssignee) { patch.assigneeAgentId = null; } const updated = await tx .update(issues) .set(patch) .where(eq(issues.id, id)) .returning() .then((rows) => rows[0] ?? null); if (!updated) return null; const [enriched] = await withIssueLabels(tx, [updated]); return { issue: enriched, previous: { checkoutRunId: existing.checkoutRunId, executionRunId: existing.executionRunId, }, }; }), listLabels: (companyId: string) => db.select().from(labels).where(eq(labels.companyId, companyId)).orderBy(asc(labels.name), asc(labels.id)), getLabelById: (id: string) => db .select() .from(labels) .where(eq(labels.id, id)) .then((rows) => rows[0] ?? null), createLabel: async (companyId: string, data: Pick) => { const [created] = await db .insert(labels) .values({ companyId, name: data.name.trim(), color: data.color, }) .returning(); return created; }, deleteLabel: async (id: string) => db .delete(labels) .where(eq(labels.id, id)) .returning() .then((rows) => rows[0] ?? null), listComments: async ( issueId: string, opts?: { afterCommentId?: string | null; order?: "asc" | "desc"; limit?: number | null; }, ) => { const order = opts?.order === "asc" ? "asc" : "desc"; const afterCommentId = opts?.afterCommentId?.trim() || null; const limit = opts?.limit && opts.limit > 0 ? Math.min(Math.floor(opts.limit), MAX_ISSUE_COMMENT_PAGE_LIMIT) : null; const conditions = [eq(issueComments.issueId, issueId)]; if (afterCommentId) { const anchor = await db .select({ id: issueComments.id, createdAt: issueComments.createdAt, }) .from(issueComments) .where(and(eq(issueComments.issueId, issueId), eq(issueComments.id, afterCommentId))) .then((rows) => rows[0] ?? null); if (!anchor) return []; const anchorCreatedAt = anchor.createdAt instanceof Date ? anchor.createdAt : new Date(String(anchor.createdAt)); conditions.push( order === "asc" ? or( gt(issueComments.createdAt, anchorCreatedAt), and( eq(issueComments.createdAt, anchorCreatedAt), gt(issueComments.id, anchor.id), ), )! : or( lt(issueComments.createdAt, anchorCreatedAt), and( eq(issueComments.createdAt, anchorCreatedAt), lt(issueComments.id, anchor.id), ), )!, ); } const query = db .select() .from(issueComments) .where(and(...conditions)) .orderBy( order === "asc" ? asc(issueComments.createdAt) : desc(issueComments.createdAt), order === "asc" ? asc(issueComments.id) : desc(issueComments.id), ); const comments = limit ? await query.limit(limit) : await query; const { censorUsernameInLogs } = await instanceSettings.getGeneral(); const enrichedComments = await enrichCommentsWithDerivedAgentAttribution(comments); return enrichedComments.map((comment) => redactIssueComment(comment, censorUsernameInLogs)); }, getCommentCursor: async (issueId: string) => { const [latest, countRow] = await Promise.all([ db .select({ latestCommentId: issueComments.id, latestCommentAt: issueComments.createdAt, }) .from(issueComments) .where(eq(issueComments.issueId, issueId)) .orderBy(desc(issueComments.createdAt), desc(issueComments.id)) .limit(1) .then((rows) => rows[0] ?? null), db .select({ totalComments: sql`count(*)::int`, }) .from(issueComments) .where(eq(issueComments.issueId, issueId)) .then((rows) => rows[0] ?? null), ]); return { totalComments: Number(countRow?.totalComments ?? 0), latestCommentId: latest?.latestCommentId ?? null, latestCommentAt: latest?.latestCommentAt ?? null, }; }, getComment: async (commentId: string) => { const { censorUsernameInLogs } = await instanceSettings.getGeneral(); const comment = await db .select() .from(issueComments) .where(eq(issueComments.id, commentId)) .then((rows) => rows[0] ?? null); if (!comment) return null; const [enrichedComment] = await enrichCommentsWithDerivedAgentAttribution([comment]); return redactIssueComment(enrichedComment ?? comment, censorUsernameInLogs); }, removeComment: async (commentId: string) => { const currentUserRedactionOptions = { enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs, }; return db.transaction(async (tx) => { const [comment] = await tx .delete(issueComments) .where(eq(issueComments.id, commentId)) .returning(); if (!comment) return null; await tx .update(issues) .set({ updatedAt: new Date() }) .where(eq(issues.id, comment.issueId)); return redactIssueComment(comment, currentUserRedactionOptions.enabled); }); }, tombstoneComment: async ( commentId: string, actor: { actorType: "agent" | "user"; agentId?: string | null; userId?: string | null; runId?: string | null; }, options?: { afterTombstone?: (comment: IssueComment, tx: any) => Promise; }, ) => { const currentUserRedactionOptions = { enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs, }; return db.transaction(async (tx) => { const now = new Date(); const [comment] = await tx .update(issueComments) .set({ body: DELETED_ISSUE_COMMENT_BODY, presentation: null, metadata: null, deletedAt: now, deletedByType: actor.actorType, deletedByAgentId: actor.actorType === "agent" ? actor.agentId ?? null : null, deletedByUserId: actor.actorType === "user" ? actor.userId ?? null : null, deletedByRunId: actor.runId ?? null, updatedAt: now, }) .where(and(eq(issueComments.id, commentId), isNull(issueComments.deletedAt))) .returning(); if (!comment) return null; await tx .update(issues) .set({ updatedAt: now }) .where(eq(issues.id, comment.issueId)); const redacted = redactIssueComment(comment, currentUserRedactionOptions.enabled); await options?.afterTombstone?.(redacted, tx); return redacted; }); }, addComment: async ( issueId: string, body: string, actor: { agentId?: string; userId?: string; runId?: string | null; onBehalfOfUserId?: string | null; }, options?: { authorType?: IssueCommentAuthorType | null; presentation?: IssueCommentPresentation | null; metadata?: IssueCommentMetadata | null; authorizationReason?: string | null; sourceTrust?: typeof issueComments.$inferInsert.sourceTrust; createdAt?: Date | string | null; }, dbOrTx: any = db, ) => { const issue = await dbOrTx .select({ companyId: issues.companyId }) .from(issues) .where(eq(issues.id, issueId)) .then((rows: Array<{ companyId: string }>) => rows[0] ?? null); if (!issue) throw notFound("Issue not found"); const currentUserRedactionOptions = { enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs, }; const redactedBody = redactCurrentUserText(body, currentUserRedactionOptions); const authorType = issueCommentAuthorTypeSchema.parse( options?.authorType ?? (actor.agentId ? "agent" : actor.userId ? "user" : "system"), ); assertIssueCommentAuthorTypeAllowed(actor, authorType); const presentation = issueCommentPresentationSchema.nullable().parse(options?.presentation ?? null); const createdAt = options?.createdAt ? new Date(options.createdAt) : null; // Invalid/stale run ids must not 500 the insert — null out unknowns. const createdByRunId = await resolveCommentCreatedByRunId(dbOrTx, issue.companyId, actor.runId); if (actor.runId && !createdByRunId) { logger.warn( { issueId, companyId: issue.companyId, runId: actor.runId }, "dropping invalid createdByRunId for issue comment insert", ); } const onBehalfOfUserId = actor.agentId ? await resolveCommentResponsibleUserId( dbOrTx, issue.companyId, createdByRunId, actor.onBehalfOfUserId, ) : null; const metadata = issueCommentMetadataSchema.nullable().parse( actor.agentId ? withAgentCommentAuthorizationMetadata(options?.metadata ?? null, options?.authorizationReason) : options?.metadata ?? null, ); const [comment] = await dbOrTx .insert(issueComments) .values({ companyId: issue.companyId, issueId, authorAgentId: actor.agentId ?? null, authorUserId: actor.userId ?? null, onBehalfOfUserId, authorType, createdByRunId, body: redactedBody, presentation, metadata, sourceTrust: options?.sourceTrust ?? null, ...(createdAt && !Number.isNaN(createdAt.getTime()) ? { createdAt } : {}), }) .returning(); // Update issue's updatedAt so comment activity is reflected in recency sorting await dbOrTx .update(issues) .set({ updatedAt: new Date() }) .where(eq(issues.id, issueId)); if ( authorType === "user" && actor.userId && actor.userId !== "board-concierge" && !createdByRunId ) { const { issueThreadInteractionService } = await import("./issue-thread-interactions.js"); const expiredInteractions = await issueThreadInteractionService(dbOrTx) .expireRequestConfirmationsSupersededByComment( { id: issueId, companyId: issue.companyId }, comment, { agentId: actor.agentId, userId: actor.userId }, ); for (const interaction of expiredInteractions) { await logActivity(dbOrTx, { companyId: issue.companyId, actorType: "user", actorId: actor.userId, agentId: actor.agentId ?? null, runId: createdByRunId, action: "issue.thread_interaction_expired", entityType: "issue", entityId: issueId, details: { interactionId: interaction.id, interactionKind: interaction.kind, interactionStatus: interaction.status, source: "issue.comment.service", result: interaction.result ?? null, }, }); } } return redactIssueComment(comment, currentUserRedactionOptions.enabled); }, createAttachment: async (input: { issueId: string; issueCommentId?: string | null; provider: string; objectKey: string; contentType: string; byteSize: number; sha256: string; originalFilename?: string | null; createdByAgentId?: string | null; createdByUserId?: string | null; }) => { const issue = await db .select({ id: issues.id, companyId: issues.companyId }) .from(issues) .where(eq(issues.id, input.issueId)) .then((rows) => rows[0] ?? null); if (!issue) throw notFound("Issue not found"); if (input.issueCommentId) { const comment = await db .select({ id: issueComments.id, companyId: issueComments.companyId, issueId: issueComments.issueId }) .from(issueComments) .where(eq(issueComments.id, input.issueCommentId)) .then((rows) => rows[0] ?? null); if (!comment) throw notFound("Issue comment not found"); if (comment.companyId !== issue.companyId || comment.issueId !== issue.id) { throw unprocessable("Attachment comment must belong to same issue and company"); } } return db.transaction(async (tx) => { const [asset] = await tx .insert(assets) .values({ companyId: issue.companyId, provider: input.provider, objectKey: input.objectKey, contentType: input.contentType, byteSize: input.byteSize, sha256: input.sha256, originalFilename: input.originalFilename ?? null, createdByAgentId: input.createdByAgentId ?? null, createdByUserId: input.createdByUserId ?? null, }) .returning(); const [attachment] = await tx .insert(issueAttachments) .values({ companyId: issue.companyId, issueId: issue.id, assetId: asset.id, issueCommentId: input.issueCommentId ?? null, }) .returning(); return { id: attachment.id, companyId: attachment.companyId, issueId: attachment.issueId, issueCommentId: attachment.issueCommentId, assetId: attachment.assetId, provider: asset.provider, objectKey: asset.objectKey, contentType: asset.contentType, byteSize: asset.byteSize, sha256: asset.sha256, originalFilename: asset.originalFilename, createdByAgentId: asset.createdByAgentId, createdByUserId: asset.createdByUserId, createdAt: attachment.createdAt, updatedAt: attachment.updatedAt, }; }); }, listAttachments: async (issueId: string) => db .select({ id: issueAttachments.id, companyId: issueAttachments.companyId, issueId: issueAttachments.issueId, issueCommentId: issueAttachments.issueCommentId, assetId: issueAttachments.assetId, provider: assets.provider, objectKey: assets.objectKey, contentType: assets.contentType, byteSize: assets.byteSize, sha256: assets.sha256, originalFilename: assets.originalFilename, createdByAgentId: assets.createdByAgentId, createdByUserId: assets.createdByUserId, createdAt: issueAttachments.createdAt, updatedAt: issueAttachments.updatedAt, }) .from(issueAttachments) .innerJoin(assets, eq(issueAttachments.assetId, assets.id)) .where(eq(issueAttachments.issueId, issueId)) .orderBy(desc(issueAttachments.createdAt)), getAttachmentById: async (id: string) => db .select({ id: issueAttachments.id, companyId: issueAttachments.companyId, issueId: issueAttachments.issueId, issueCommentId: issueAttachments.issueCommentId, assetId: issueAttachments.assetId, provider: assets.provider, objectKey: assets.objectKey, contentType: assets.contentType, byteSize: assets.byteSize, sha256: assets.sha256, originalFilename: assets.originalFilename, createdByAgentId: assets.createdByAgentId, createdByUserId: assets.createdByUserId, createdAt: issueAttachments.createdAt, updatedAt: issueAttachments.updatedAt, }) .from(issueAttachments) .innerJoin(assets, eq(issueAttachments.assetId, assets.id)) .where(eq(issueAttachments.id, id)) .then((rows) => rows[0] ?? null), removeAttachment: async (id: string) => db.transaction(async (tx) => { const existing = await tx .select({ id: issueAttachments.id, companyId: issueAttachments.companyId, issueId: issueAttachments.issueId, issueCommentId: issueAttachments.issueCommentId, assetId: issueAttachments.assetId, provider: assets.provider, objectKey: assets.objectKey, contentType: assets.contentType, byteSize: assets.byteSize, sha256: assets.sha256, originalFilename: assets.originalFilename, createdByAgentId: assets.createdByAgentId, createdByUserId: assets.createdByUserId, createdAt: issueAttachments.createdAt, updatedAt: issueAttachments.updatedAt, }) .from(issueAttachments) .innerJoin(assets, eq(issueAttachments.assetId, assets.id)) .where(eq(issueAttachments.id, id)) .then((rows) => rows[0] ?? null); if (!existing) return null; await tx.delete(issueAttachments).where(eq(issueAttachments.id, id)); await tx.delete(assets).where(eq(assets.id, existing.assetId)); return existing; }), findMentionedAgents: async (companyId: string, body: string) => { const explicitAgentMentionIds = extractAgentMentionIds(body); if (explicitAgentMentionIds.length === 0) return []; const rows = await db.select({ id: agents.id }) .from(agents).where(eq(agents.companyId, companyId)); const companyAgentIds = new Set(rows.map((agent) => agent.id)); return explicitAgentMentionIds.filter((agentId) => companyAgentIds.has(agentId)); }, findMentionedProjectIds: async ( issueId: string, opts?: { includeCommentBodies?: boolean }, ) => { const issue = await db .select({ companyId: issues.companyId, title: issues.title, description: issues.description, }) .from(issues) .where(eq(issues.id, issueId)) .then((rows) => rows[0] ?? null); if (!issue) return []; const mentionedIds = new Set(); for (const source of [issue.title, issue.description ?? ""]) { for (const projectId of extractProjectMentionIds(source)) { mentionedIds.add(projectId); } } if (opts?.includeCommentBodies !== false) { const comments = await db .select({ body: issueComments.body }) .from(issueComments) .where(and(eq(issueComments.issueId, issueId), isNull(issueComments.deletedAt))); for (const comment of comments) { for (const projectId of extractProjectMentionIds(comment.body)) { mentionedIds.add(projectId); } } } if (mentionedIds.size === 0) return []; const rows = await db .select({ id: projects.id }) .from(projects) .where( and( eq(projects.companyId, issue.companyId), inArray(projects.id, [...mentionedIds]), ), ); const valid = new Set(rows.map((row) => row.id)); return [...mentionedIds].filter((projectId) => valid.has(projectId)); }, getAncestors: async (issueId: string) => { const raw: Array<{ id: string; identifier: string | null; title: string; description: string | null; status: string; priority: string; assigneeAgentId: string | null; projectId: string | null; goalId: string | null; }> = []; const visited = new Set([issueId]); const start = await db.select().from(issues).where(eq(issues.id, issueId)).then(r => r[0] ?? null); let currentId = start?.parentId ?? null; while (currentId && !visited.has(currentId) && raw.length < 50) { visited.add(currentId); const parent = await db.select({ id: issues.id, identifier: issues.identifier, title: issues.title, description: issues.description, status: issues.status, priority: issues.priority, assigneeAgentId: issues.assigneeAgentId, projectId: issues.projectId, goalId: issues.goalId, parentId: issues.parentId, }).from(issues).where(eq(issues.id, currentId)).then(r => r[0] ?? null); if (!parent) break; raw.push({ id: parent.id, identifier: parent.identifier ?? null, title: parent.title, description: parent.description ?? null, status: parent.status, priority: parent.priority, assigneeAgentId: parent.assigneeAgentId ?? null, projectId: parent.projectId ?? null, goalId: parent.goalId ?? null, }); currentId = parent.parentId ?? null; } // Batch-fetch referenced projects and goals const projectIds = [...new Set(raw.map(a => a.projectId).filter((id): id is string => id != null))]; const goalIds = [...new Set(raw.map(a => a.goalId).filter((id): id is string => id != null))]; const projectMap = new Map | null; isPrimary: boolean; createdAt: Date; updatedAt: Date; }>; primaryWorkspace: { id: string; companyId: string; projectId: string; name: string; cwd: string | null; repoUrl: string | null; repoRef: string | null; metadata: Record | null; isPrimary: boolean; createdAt: Date; updatedAt: Date; } | null; }>(); const goalMap = new Map(); if (projectIds.length > 0) { const workspaceRows = await db .select() .from(projectWorkspaces) .where(inArray(projectWorkspaces.projectId, projectIds)) .orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)); const workspaceMap = new Map>(); for (const workspace of workspaceRows) { const existing = workspaceMap.get(workspace.projectId); if (existing) existing.push(workspace); else workspaceMap.set(workspace.projectId, [workspace]); } const rows = await db.select({ id: projects.id, name: projects.name, description: projects.description, status: projects.status, goalId: projects.goalId, }).from(projects).where(inArray(projects.id, projectIds)); for (const r of rows) { const projectWorkspaceRows = workspaceMap.get(r.id) ?? []; const workspaces = projectWorkspaceRows.map((workspace) => ({ id: workspace.id, companyId: workspace.companyId, projectId: workspace.projectId, name: workspace.name, cwd: workspace.cwd, repoUrl: workspace.repoUrl ?? null, repoRef: workspace.repoRef ?? null, metadata: (workspace.metadata as Record | null) ?? null, isPrimary: workspace.isPrimary, createdAt: workspace.createdAt, updatedAt: workspace.updatedAt, })); const primaryWorkspace = workspaces.find((workspace) => workspace.isPrimary) ?? workspaces[0] ?? null; projectMap.set(r.id, { ...r, workspaces, primaryWorkspace, }); // Also collect goalIds from projects if (r.goalId && !goalIds.includes(r.goalId)) goalIds.push(r.goalId); } } if (goalIds.length > 0) { const rows = await db.select({ id: goals.id, title: goals.title, description: goals.description, level: goals.level, status: goals.status, }).from(goals).where(inArray(goals.id, goalIds)); for (const r of rows) goalMap.set(r.id, r); } return raw.map(a => ({ ...a, project: a.projectId ? projectMap.get(a.projectId) ?? null : null, goal: a.goalId ? goalMap.get(a.goalId) ?? null : null, })); }, }; }