Files
PaperClipAI/server/src/services/issues.ts
T
DottaandPaperclip 95d33e1788 fix(inbox): archive tasks completed by human users (#10668)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The Mine inbox gives each human user a personal work queue.
> - A human user can complete a task from the board.
> - The status update did not update the user's inbox archive state.
> - The completed task therefore stayed in the user's Mine inbox.
> - This pull request archives the task for the human user who completes
it.
> - Agent completion does not change another user's inbox archive state.
> - The benefit is that manual completion removes the task from Mine
without an extra action or comment.

## Linked Issues or Issue Description

**What happened?**

When a human user changed a task status to `done`, the task stayed in
that user's Mine inbox.

**Expected behavior**

The status change should archive the task from that user's Mine inbox.
The operation should not add a task comment.

**Steps to reproduce**

1. Open a task that appears in Mine.
2. Change the task status to `done`.
3. Return to Mine.
4. Observe that the completed task is still present.

**Paperclip version or commit**

`master` before this change.

**Deployment mode**

All deployment modes with a board user and the Mine inbox.

**Access context**

Board (human operator).

## What Changed

- Archive the completed task for the board user who changes its status
to `done`.
- Persist the status update, inbox archive, and archive audit
atomically.
- Publish live and plugin activity only after the owning transaction
commits, including recovery, decision, and approval completion paths.
- Keep agent-driven completion from changing a human user's inbox
archive state.
- Add database-backed regression tests for the archive, audit, Mine
filter, rollback, event publication, recovery, and agent paths.

## Verification

- `pnpm exec vitest run
server/src/__tests__/inbox-archive-routes.test.ts` passed all 7 tests.
- `pnpm exec vitest run
server/src/__tests__/issue-recovery-actions.test.ts` passed all 44
tests.
- Five directly affected server test files passed all 74 tests; decision
and comment-route suites passed all 105 tests.
- `pnpm --filter @paperclipai/server typecheck` passed after the final
fix.
- `pnpm -r typecheck` and `pnpm build` passed.
- The initial repo-wide `pnpm test:run` passed 3,223 tests; one
unrelated plugin orchestration wake-reason test failed and reproduced in
isolation.
- All latest-head GitHub Actions gates are green, including all server
and E2E shards.
- Greptile reviewed the latest commit at 5/5 with no unresolved review
threads.
- Public GitHub search found no duplicate issue or pull request.

## Risks

- Low risk. The behavior only runs on a board user's transition into
`done`.
- Reopening and completing the task again updates the existing per-user
archive row.
- Agent status updates do not archive a human user's inbox.
- Transactional callers must supply a post-commit activity queue;
covered completion paths do so and tests exercise rollback and
publication order.
- This bug fix does not duplicate planned core work in `ROADMAP.md`.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex from the GPT-5 model family assisted with this change. The
runtime does not expose the exact serving model ID or context window.
Reasoning, repository tools, code execution, GitHub CLI, and Paperclip
API access were enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-02 10:43:36 -05:00

8547 lines
321 KiB
TypeScript

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,
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 { 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 { 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, 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";
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<string>(["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<typeof issues.$inferInsert>,
): Partial<typeof issues.$inferInsert> {
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<string, unknown>)[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<typeof parseIssueExecutionWorkspaceSettings>,
) {
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<string, unknown> | 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<string, unknown> = {};
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<string | null> {
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;
}
/**
* 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<string, DerivedIssueCommentAttribution>();
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";
}
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<Db, "select">;
type DbReader = Pick<Db, "select">;
type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId"> & {
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<string, unknown> | 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<T>(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<string, unknown>;
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<string, unknown>).target;
if (!target || typeof target !== "object" || Array.isArray(target)) return null;
const record = target as Record<string, unknown>;
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<Db, "select">;
claim: Pick<typeof issuePlanDecompositions.$inferSelect, "ownerAgentId" | "ownerUserId" | "ownerRunId">;
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<Db, "select">,
input: {
companyId: string;
sourceIssueId: string;
acceptedPlanRevisionId: string;
},
): Promise<AcceptedPlanDocumentInteraction | null> {
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<Db, "select">,
companyId: string,
executionWorkspaceIds: string[],
): Promise<Set<string>> {
const unfinalized = new Set<string>();
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<string, { phase: string; status: string; startedAt: Date }>();
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<Db, "select">,
companyId: string,
blockerWorkspacePairs: Array<{ blockerIssueId: string; executionWorkspaceId: string }>,
): Promise<Set<string>> {
const pending = new Set<string>();
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<string, { phase: string; status: string; startedAt: Date }>();
const latestUnattributedByWorkspace = new Map<string, { phase: string; status: string; startedAt: Date }>();
const latestSuccessfulFinalizeByWorkspace = new Map<string, Date>();
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<Db, "select">,
runId: string,
): Promise<boolean> {
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<Db, "select">,
companyId: string,
executionWorkspaceId: string,
runId: string,
): Promise<boolean> {
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<Db, "select">,
companyId: string,
issueIds: string[],
) {
const uniqueIssueIds = [...new Set(issueIds.filter(Boolean))];
const readinessMap = new Map<string, IssueDependencyReadiness>();
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<Db, "select">,
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<Db, "select">,
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<boolean>`
(
${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<boolean>`
(
${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<Date | null>`
(
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<Date | null>`
(
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<Date | null>`
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<Date | null>`
(
SELECT MAX(${issueComments.createdAt})
FROM ${issueComments}
WHERE ${issueComments.issueId} = ${issues.id}
AND ${issueComments.companyId} = ${companyId}
)
`;
}
function issueLatestLogAtExpr(companyId: string) {
return sql<Date | null>`
(
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<Date>`
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<boolean>`
(
${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<boolean>`
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<boolean>`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 | string | null | undefined>): 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<InboxArchiveAttributionRow[]> {
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<Map<string, IssueLabelRow[]>> {
const map = new Map<string, IssueLabelRow[]>();
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<IssueWithLabels[]> {
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<Map<string, IssueWatchdogSummary>> {
const map = new Map<string, IssueWatchdogSummary>();
if (rows.length === 0) return map;
const byCompany = new Map<string, string[]>();
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<boolean>`
${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<boolean>`false`;
return or(...clauses);
}
const BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES = ["done", "cancelled"];
const BLOCKER_ATTENTION_MAX_DEPTH = 8;
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<Map<string, IssueActiveRunRow>> {
const map = new Map<string, IssueActiveRunRow>();
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<Map<string, number>> {
const uniqueIssueIds = [...new Set(issueIds)];
const map = new Map<string, number>();
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<unknown>);
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> = {}): 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,
};
}
function blockerSampleIdentifier(node: IssueBlockerAttentionNode | null | undefined) {
return node?.identifier ?? node?.id ?? null;
}
function appendBlockerAttentionEdges(
edgesByIssueId: Map<string, IssueBlockerAttentionEdge[]>,
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<Map<string, IssueRelationIssueSummary[]>> {
const rootIds = [...new Set(roots.map((root) => root.id))];
const terminalByRoot = new Map<string, IssueRelationIssueSummary[]>();
if (rootIds.length === 0) return terminalByRoot;
const nodesById = new Map<string, IssueRelationIssueSummary>();
const edgesByIssueId = new Map<string, string[]>();
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<string>();
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<string>): 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<string, IssueRelationIssueSummary>();
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<Map<string, IssueProductivityReview>> {
const map = new Map<string, IssueProductivityReview>();
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<string, unknown> | 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<Map<string, IssueBlockerAttention>> {
const roots = issueRows.filter((row) => row.companyId === companyId && row.status === "blocked");
const attentionMap = new Map<string, IssueBlockerAttention>();
for (const row of issueRows) {
if (row.status !== "blocked") {
attentionMap.set(row.id, createIssueBlockerAttention());
}
}
if (roots.length === 0) return attentionMap;
const nodesById = new Map<string, IssueBlockerAttentionNode>();
const edgesByIssueId = new Map<string, IssueBlockerAttentionEdge[]>();
for (const root of roots) nodesById.set(root.id, { ...root });
let frontier = roots.map((root) => root.id);
let truncated = false;
const pendingFinalizeBlockerIssueIds = new Set<string>();
for (let depth = 0; frontier.length > 0 && depth < BLOCKER_ATTENTION_MAX_DEPTH; depth += 1) {
const nextFrontier = new Set<string>();
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<IssueBlockerAttentionQueryRow[]> = 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<IssueBlockerAttentionQueryRow[]> = 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<string>();
const agentIds = new Set<string>();
const issueIdByExecutionRunId = new Map<string, string>();
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<IssueBlockerAttentionActivePathRow[]> = dbOrTx
.select({
issueId: sql<string | null>`${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<string>`${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<string>();
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;
};
const classifyPath = (
nodeId: string,
seen: Set<string>,
): 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 };
}
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 };
}
if (node.status === "backlog" && node.assigneeAgentId) {
return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
}
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);
if (hardAttention) {
return {
covered: false,
stalled: false,
sampleBlockerIdentifier: hardAttention.sampleBlockerIdentifier,
sampleStalledBlockerIdentifier: sampleStalled,
};
}
const stalledEntry = classified.find((result) => result.stalled);
if (stalledEntry) {
return {
covered: false,
stalled: true,
sampleBlockerIdentifier: stalledEntry.sampleBlockerIdentifier,
sampleStalledBlockerIdentifier: sampleStalled,
};
}
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 };
}
}
return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: 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",
}));
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);
const stalledEntry = 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);
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,
}));
}
return attentionMap;
}
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<string | null>`
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
`,
status: issues.status,
workMode: issues.workMode,
harnessKind: issues.harnessKind,
priority: issues.priority,
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>`null`,
executionState: sql<null>`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>`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<string, IssueActiveRunRow>,
): 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<IssueUserCommentStats[]> {
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<Date | null>`
MAX(CASE WHEN ${issueComments.authorUserId} = ${userId} THEN ${issueComments.createdAt} END)
`,
lastExternalCommentAt: sql<Date | null>`
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<IssueReadStat[]> {
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<IssueLastActivityStat[]> {
const byIssueId = new Map<string, IssueLastActivityStat>();
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<Date | null>`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<Date | null>`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<Map<string, IssueRelationIssueSummary[]>> {
const map = new Map<string, IssueRelationIssueSummary[]>();
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<IssueRow, "id" | "identifier" | "title" | "status" | "priority" | "assigneeAgentId" | "assigneeUserId"> | 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<boolean>`
EXISTS (
SELECT 1
FROM ${issueDocuments}
WHERE ${issueDocuments.companyId} = ${companyId}
AND ${issueDocuments.issueId} = ${issues.id}
AND ${issueDocuments.key} = 'plan'
)
`;
return hasPlanDocument ? existsPlanDocument : sql<boolean>`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<string, unknown> | null;
createdAt: Date;
}): SuccessfulRunHandoffState | null {
const details = row.details ?? {};
const state =
row.action === "issue.successful_run_handoff_required"
? "required"
: row.action === "issue.successful_run_handoff_resolved"
? "resolved"
: row.action === "issue.successful_run_handoff_escalated"
? "escalated"
: null;
if (!state) return null;
const detectedProgressSummary =
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<Map<string, SuccessfulRunHandoffState>> {
const uniqueIssueIds = [...new Set(issueIds)];
const states = new Map<string, SuccessfulRunHandoffState>();
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<string, unknown> | 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<Map<string, IssueBlockedInboxAttention>> {
const rowIssueIds = [...new Set(issueRows.map((row) => row.id))];
const result = new Map<string, IssueBlockedInboxAttention>();
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<string, IssueRow>(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<string | null>`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<string>`coalesce(
${heartbeatRuns.contextSnapshot} ->> 'issueId',
${heartbeatRuns.contextSnapshot} ->> 'taskId'
)`, graphIssueIds),
)),
graphIssueIds.length === 0
? Promise.resolve([])
: dbOrTx
.select({
companyId: agentWakeupRequests.companyId,
issueId: sql<string | null>`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<string>`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<string | null>`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<string>`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<string, IssueLivenessFinding>();
for (const finding of findings) {
if (!findingByIssueId.has(finding.issueId)) findingByIssueId.set(finding.issueId, finding);
}
const interactionByIssueId = new Map<string, BlockedInboxInteractionRow>();
for (const row of interactionRows as BlockedInboxInteractionRow[]) {
if (!interactionByIssueId.has(row.issueId)) interactionByIssueId.set(row.issueId, row);
}
const approvalByIssueId = new Map<string, BlockedInboxApprovalRow>();
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"
&& (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<boolean>`
${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<boolean>`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<Array<IssueWithLabelsAndRun & {
blockedBy?: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention;
blockedInboxAttention: IssueBlockedInboxAttention;
productivityReview?: IssueProductivityReview | null;
liveDescendantCount?: number;
lastActivityAt: Date;
myLastTouchAt?: Date | null;
lastExternalCommentAt?: Date | null;
isUnreadForMe?: boolean;
}>> {
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,
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),
listIssueProductivityReviewMap(dbOrTx, companyId, issueIds),
listIssueBlockedInboxAttentionMap(dbOrTx, companyId, withRuns),
includeLiveDescendantSummary
? liveDescendantCountMapForIssues(dbOrTx, companyId, issueIds)
: Promise.resolve(new Map<string, number>()),
]);
const rawSearchInput = filters?.q?.trim() ?? "";
const rawSearch = rawSearchInput.toLowerCase();
const commentSearchMatchIssueIds = new Set<string>();
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<boolean>`${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) } : {}),
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<number> {
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<string>();
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<boolean>`${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<IssueScheduledRetryRow | null> {
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<T extends {
body: string;
authorType?: string | null;
authorAgentId?: string | null;
authorUserId?: string | null;
presentation?: unknown;
metadata?: unknown;
deletedAt?: Date | string | null;
deletedByType?: "agent" | "user" | null;
deletedByAgentId?: string | null;
deletedByUserId?: string | null;
deletedByRunId?: string | null;
}>(
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<string, DerivedIssueCommentAttribution>,
) {
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<string>();
// `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<number | null>((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<number | null>((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<string, DerivedIssueCommentAttribution>(
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<string>();
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<string, string>();
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<string, unknown> | 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<Map<string, IssueRelationSummaryMap>> {
const uniqueIssueIds = [...new Set(issueIds)];
const empty = new Map<string, IssueRelationSummaryMap>();
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<T extends { id: string }>(
companyId: string,
rows: T[],
dbOrTx: DbReader = db,
): Promise<Array<T & IssueRelationSummaryMap>> {
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<string, string[]>();
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<string>([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<boolean> {
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<boolean> {
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<boolean>`${issues.title} ILIKE ${startsWithPattern} ESCAPE '\\'`;
const titleContainsMatch = sql<boolean>`${issues.title} ILIKE ${containsPattern} ESCAPE '\\'`;
const identifierStartsWithMatch = sql<boolean>`${issues.identifier} ILIKE ${startsWithPattern} ESCAPE '\\'`;
const identifierContainsMatch = sql<boolean>`${issues.identifier} ILIKE ${containsPattern} ESCAPE '\\'`;
const descriptionContainsMatch = sql<boolean>`${issues.description} ILIKE ${containsPattern} ESCAPE '\\'`;
const commentContainsMatch = sql<boolean>`
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<boolean>`
${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?.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<number>`
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<string, IssueRelationIssueSummary[]>()),
includeLiveDescendantSummary
? liveDescendantCountMapForIssues(db, companyId, issueIds)
: Promise.resolve(new Map<string, number>()),
]);
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,
productivityReviewByIssueId,
blockedInboxAttentionByIssueId,
] = await Promise.all([
listIssueBlockerAttentionMap(db, companyId, withRuns),
listIssueProductivityReviewMap(db, companyId, issueIds),
includeBlockedInboxAttention
? listIssueBlockedInboxAttentionMap(db, companyId, withRuns)
: Promise.resolve(new Map<string, IssueBlockedInboxAttention>()),
]);
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) } : {}),
...(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) } : {}),
...(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<number>`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<number>`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<void> => {
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<IssueRow, "id" | "companyId" | "updatedAt">,
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<string, unknown> | 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);
},
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<IssueSubtreeDiagnosticsIssueRow>`
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<string, IssueDependencyReadiness>();
const blockersByIssueId = new Map<string, IssueSubtreeDiagnosticsBlockerRow[]>();
const wakeRequestsByIssueId = new Map<string, IssueSubtreeDiagnosticsWakeRequestRow[]>();
const activityRecordsByIssueId = new Map<string, IssueSubtreeDiagnosticsActivityRow[]>();
const truncatedBlockerIssueIds = new Set<string>();
const truncatedWakeIssueIds = new Set<string>();
const truncatedActivityIssueIds = new Set<string>();
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<string>`
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<string>`
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);
},
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<string, string>();
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<number>`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<string, unknown>[],
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<typeof issues.$inferSelect> = [];
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<string>();
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<typeof entry> => 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<string, unknown> | 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<string, unknown> | 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<typeof parseProjectExecutionWorkspacePolicy> | 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<string, unknown> | 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<number>`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<void> => {
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<number>`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<string, unknown> | 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<string, unknown> | null;
const derived = { goalId: projectRow?.goalId ?? null, defaultProjectWorkspaceId, defaultExecutionWorkspaceSettings };
projectDerivedCache.set(projectId, derived);
return derived;
};
const validatedAgentIds = new Set<string>();
const validatedWorkspaceKeys = new Set<string>();
const issueRows: Array<Record<string, unknown>> = [];
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",
startedAt: row.status === "in_progress" ? new Date() : null,
completedAt: row.status === "done" ? new Date() : null,
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<void> => {
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.
const issueIds = [...new Set(rows.map((row) => row.issueId))];
if (issueIds.length > 0) {
await tx.update(issues).set({ updatedAt: new Date() }).where(inArray(issues.id, issueIds));
}
});
},
/**
* 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<void> => {
if (rows.length === 0) return;
await db.transaction(async (tx) => {
const assetRows: Array<Record<string, unknown>> = [];
const attachmentRows: Array<Record<string, unknown>> = [];
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<typeof issues.$inferInsert> & {
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<typeof issues.$inferSelect>) => 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<typeof issues.$inferInsert> = {
...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<typeof issues.$inferSelect>) => rows[0] ?? null);
if (!receiptExisting) return null;
const [previousLabelsByIssueId, previousRelationSummaries] = await Promise.all([
nextLabelIds !== undefined
? labelMapForIssues(tx, [id])
: Promise.resolve(new Map<string, IssueLabelRow[]>()),
blockedByIssueIds !== undefined
? getIssueRelationSummaryMap(existing.companyId, [id], tx)
: Promise.resolve(new Map<string, IssueRelationSummaryMap>()),
]);
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<typeof issues.$inferSelect>) => rows[0] ?? null);
if (!updated) return null;
if (existing.status !== updated.status) {
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<string, unknown> | 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<string, unknown>,
updated as unknown as Record<string, unknown>,
{
...(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));
const removedIssue = await tx
.delete(issues)
.where(eq(issues.id, id))
.returning()
.then((rows) => rows[0] ?? null);
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<string, unknown> = {
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<typeof issues.$inferInsert> = {
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<typeof labels.$inferInsert, "name" | "color">) => {
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<number>`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<void>;
},
) => {
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 },
options?: {
authorType?: IssueCommentAuthorType | null;
presentation?: IssueCommentPresentation | null;
metadata?: IssueCommentMetadata | 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 metadata = issueCommentMetadataSchema.nullable().parse(options?.metadata ?? 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 [comment] = await dbOrTx
.insert(issueComments)
.values({
companyId: issue.companyId,
issueId,
authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? null,
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));
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<string>();
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<string>([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<string, {
id: string;
name: string;
description: string | null;
status: string;
goalId: string | null;
workspaces: Array<{
id: string;
companyId: string;
projectId: string;
name: string;
cwd: string | null;
repoUrl: string | null;
repoRef: string | null;
metadata: Record<string, unknown> | 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<string, unknown> | null;
isPrimary: boolean;
createdAt: Date;
updatedAt: Date;
} | null;
}>();
const goalMap = new Map<string, { id: string; title: string; description: string | null; level: string; status: string }>();
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<string, Array<(typeof workspaceRows)[number]>>();
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<string, unknown> | 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,
}));
},
};
}