import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, notInArray, sql, type SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, approvals, assets, companies, decisionBundles, decisionQueueItems, decisionQueues, decisionTrainingExamples, decisionTriage, decisions, heartbeatRuns, inboxDismissals, invites, issueApprovals, issueAttachments, issueDocuments, issueRecoveryActions, issueRelations, issueThreadInteractions, issues, joinRequests, documents, projects, projectWorkspaces, } from "@paperclipai/db"; import { deriveProjectUrlKey } from "@paperclipai/shared"; import type { AttentionDecisionVerb, AttentionFeed, AttentionFeedQuery, AttentionDetailImage, AttentionItem, AttentionItemDetail, AttentionProjectRef, AttentionQueueRef, AttentionResolverAudience, AttentionSeverity, AttentionSortMode, AttentionSourceKind, AttentionSubject, AttentionTriageAttribution, AttentionWorkspaceRef, IssueThreadInteractionEffectiveResolverPolicySource, IssueThreadInteractionResolverPolicyProvenance, IssueReviewPolicy, } from "@paperclipai/shared"; import { badRequest } from "../errors.js"; import { listAttentionExhaustedRuns } from "./attention-exhausted-runs.js"; import { budgetService } from "./budgets.js"; import { BLOCKER_ATTENTION_MAX_DEPTH, BLOCKER_ATTENTION_MAX_NODES, issueService, } from "./issues.js"; import { executionIssueCondition } from "./issue-visibility.js"; import { parseIssueExecutionState } from "./issue-execution-policy.js"; import { isProspectiveBlockedTransition } from "./routable-blocked.js"; import { issueReadSqlCondition, type AuthorizationActor } from "./authorization.js"; import { evaluateAgentInvokability, type AgentOrgRow } from "./agent-invokability.js"; import { canonicalizeStoredResolverPolicy } from "./issue-thread-interaction-resolution.js"; import { decisionQueueService } from "./decision-queues.js"; import { decisionRetentionService, DEFAULT_DECISION_SHELF_DAYS, } from "./decision-retention.js"; const ATTENTION_SOURCE_KINDS: AttentionSourceKind[] = [ "approval", "decision", "issue_thread_interaction", "join_request", "recovery_action", "productivity_review", "blocker_attention", "review", "failed_run", "budget_alert", "agent_error_alert", ]; const SEVERITY_RANK: Record = { critical: 0, high: 1, medium: 2, low: 3, }; const SOURCE_RANK: Record = { failed_run: 0, recovery_action: 1, blocker_attention: 2, budget_alert: 3, agent_error_alert: 4, approval: 5, decision: 6, issue_thread_interaction: 7, review: 8, productivity_review: 9, join_request: 10, }; const PENDING_INTERACTION_STATUSES = ["pending"] as const; const OPEN_RECOVERY_STATUSES = ["active", "escalated"] as const; const HUMAN_RECOVERY_OWNER_TYPES = ["user", "board"] as const; const DETAIL_EXCERPT_LENGTH = 160; const DETAIL_IMAGE_LIMIT = 3; const OPEN_DECISION_DEFAULT_LIMIT = 500; const OPEN_DECISION_MAX_LIMIT = 1_000; const ATTENTION_PAGE_DEFAULT_LIMIT = 50; const ATTENTION_PAGE_MAX_LIMIT = 100; const ATTENTION_GRAPH_QUERY_CHUNK_SIZE = 500; function chunkValues(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; } type IssueSummaryRow = { id: string; companyId: string; identifier: string | null; title: string; status: string; priority: string; /** Who may give the `in_review` verdict; `null`/absent ≡ "anyone" (PAP-16506). */ reviewPolicy?: IssueReviewPolicy | null; assigneeAgentId: string | null; assigneeUserId: string | null; createdAt: Date; updatedAt: Date; project: AttentionProjectRef | null; workspace: AttentionWorkspaceRef | null; }; type IssueSubjectRow = Omit; type DismissalState = { kind: "dismiss" | "snooze"; dismissedAt: Date; snoozedUntil: Date | null; }; type PlanDocumentSummary = { title: string | null; body: string; }; type BlockingIssueSummary = { id: string | null; identifier: string | null; title: string | null; }; type AttentionListOptions = AttentionFeedQuery & { userId?: string | null; includeDismissed?: boolean; actor?: AuthorizationActor; /** Internal-only escape hatch for callers that need one stable, unpaginated feed snapshot. */ allowUnscopedAll?: boolean; }; type AttentionServiceOptions = { openDecisionLimit?: number; now?: () => number; }; function emptyCounts(): Record { return Object.fromEntries(ATTENTION_SOURCE_KINDS.map((kind) => [kind, 0])) as Record; } function toIso(value: Date | string | null | undefined): string { if (!value) return new Date(0).toISOString(); return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); } function timestamp(value: Date | string | null | undefined): number { if (!value) return 0; const time = new Date(value).getTime(); return Number.isFinite(time) ? time : 0; } function activeDismissalState( dismissalByKey: ReadonlyMap, dismissalKey: string, activityAt: string, now: number, ) { const dismissal = dismissalByKey.get(dismissalKey); if (!dismissal) return null; const dismissedAt = toIso(dismissal.dismissedAt); const snoozedUntil = dismissal.snoozedUntil ? toIso(dismissal.snoozedUntil) : null; const isActive = dismissal.kind === "snooze" ? dismissal.snoozedUntil != null && timestamp(dismissal.snoozedUntil) > now : timestamp(dismissal.dismissedAt) >= timestamp(activityAt); return { kind: dismissal.kind, dismissedAt, snoozedUntil, isActive, }; } function stripMarkdown(value: string) { return value .replace(/```[\s\S]*?```/g, " ") .replace(/`([^`]+)`/g, "$1") .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") .replace(/^#{1,6}\s+/gm, "") .replace(/[>*_~#-]+/g, " ") .replace(/\s+/g, " ") .trim(); } function excerpt(value: unknown, maxLength = DETAIL_EXCERPT_LENGTH) { if (typeof value !== "string") return null; const cleaned = stripMarkdown(value); if (!cleaned) return null; if (cleaned.length <= maxLength) return cleaned; return `${cleaned.slice(0, Math.max(0, maxLength - 1)).trimEnd()}...`; } function readRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } function readArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } function readString(value: unknown) { return typeof value === "string" && value.trim() ? value.trim() : null; } function isPlanDocumentTarget(payload: Record) { const target = readRecord(payload.target); return target.type === "issue_document" && target.key === "plan"; } function issueContext(issue: IssueSummaryRow | IssueSubjectRow | null | undefined) { const summary = issue && "project" in issue ? issue : null; return { project: summary?.project ?? null, workspace: summary?.workspace ?? null, }; } function issueImages(imageMap: ReadonlyMap, issueId: string | null | undefined) { return issueId ? imageMap.get(issueId) ?? [] : []; } function genericDetail(summary: unknown, images: AttentionDetailImage[]): AttentionItemDetail { return { kind: "generic", summaryExcerpt: excerpt(summary), images }; } function approvalDetail(type: string, payload: Record): AttentionItemDetail { return { kind: "approval", approvalType: type, summaryExcerpt: excerpt(payload.summary ?? payload.title ?? payload.recommendedAction), images: [], }; } function interactionDetail(input: { kind: string; payload: Record; issue: IssueSummaryRow | null; planDocument: PlanDocumentSummary | null; images: AttentionDetailImage[]; }): AttentionItemDetail { if (input.kind === "request_confirmation" && isPlanDocumentTarget(input.payload)) { return { kind: "plan_approval", issueTitle: input.issue?.title ?? null, planTitle: input.planDocument?.title ?? "Plan", summaryExcerpt: excerpt(input.planDocument?.body ?? input.payload.detailsMarkdown ?? input.payload.prompt), images: input.images, }; } if (input.kind === "ask_user_questions") { const questions = readArray(input.payload.questions).map(readRecord); return { kind: "questions", questionCount: questions.length, firstQuestionText: readString(questions[0]?.prompt), images: input.images, }; } if (input.kind === "suggest_tasks") { const tasks = readArray(input.payload.tasks).map(readRecord); return { kind: "suggested_tasks", taskCount: tasks.length, firstTaskTitle: readString(tasks[0]?.title), images: input.images, }; } if (input.kind === "request_checkbox_confirmation") { return { kind: "checkbox_confirmation", optionCount: readArray(input.payload.options).length, promptExcerpt: excerpt(input.payload.prompt), images: input.images, }; } if (input.kind === "request_item_verdicts") { return { kind: "item_verdicts", itemCount: readArray(input.payload.items).length, promptExcerpt: excerpt(input.payload.prompt), images: input.images, }; } return { kind: "confirmation", promptExcerpt: excerpt(input.payload.prompt ?? input.payload.detailsMarkdown), isPlanTarget: false, images: input.images, }; } function issueHref(prefix: string, issue: Pick) { return `/${prefix}/issues/${issue.identifier ?? issue.id}`; } function issueSubject(prefix: string, issue: IssueSubjectRow): AttentionSubject { return { kind: "issue", id: issue.id, companyId: issue.companyId, title: issue.title, identifier: issue.identifier, status: issue.status, href: issueHref(prefix, issue), metadata: { priority: issue.priority, assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId, // Only present when the row was selected with the column, so subjects // built from narrower selects do not claim a policy they never read. ...(issue.reviewPolicy !== undefined ? { reviewPolicy: issue.reviewPolicy } : {}), }, }; } function itemId(sourceKind: AttentionSourceKind, dedupKey: string) { return `${sourceKind}:${dedupKey}`; } function decisionVerbs(...verbs: AttentionDecisionVerb[]): AttentionDecisionVerb[] { return verbs; } type CreateAttentionItemInput = Omit & { project?: AttentionProjectRef | null; workspace?: AttentionWorkspaceRef | null; expiresAt?: string | null; ruleKey?: string | null; originAgentName?: string | null; detail?: AttentionItemDetail | null; }; function createItem(input: CreateAttentionItemInput): AttentionItem { return { ...input, id: itemId(input.sourceKind, input.dedupKey), dismissalKey: `attention:${input.dedupKey}`, dismissal: null, project: input.project ?? null, workspace: input.workspace ?? null, expiresAt: input.expiresAt ?? null, ruleKey: input.ruleKey ?? null, originAgentName: input.originAgentName ?? null, queues: [], shelf: false, retentionDays: DEFAULT_DECISION_SHELF_DAYS, keep: false, archivedAt: null, retentionVersion: 0, decideBy: null, decideByAttribution: null, snoozedUntil: null, detail: input.detail ?? null, trainingExampleId: null, resolverAudience: input.resolverAudience ?? null, rank: 0, }; } function compareAttentionItems(left: AttentionItem, right: AttentionItem) { const timeDiff = timestamp(right.activityAt) - timestamp(left.activityAt); if (timeDiff !== 0) return timeDiff; const severityDiff = SEVERITY_RANK[left.severity] - SEVERITY_RANK[right.severity]; if (severityDiff !== 0) return severityDiff; const sourceDiff = SOURCE_RANK[left.sourceKind] - SOURCE_RANK[right.sourceKind]; if (sourceDiff !== 0) return sourceDiff; return left.dedupKey.localeCompare(right.dedupKey); } function blockedTaskCount(item: AttentionItem) { return item.sourceKind === "blocker_attention" && item.detail?.kind === "blocker" && typeof item.detail.blockedTaskCount === "number" ? item.detail.blockedTaskCount : null; } /** Preserve the selected desk sort for every row slot while ordering blocker * rows by the amount of work they hold up. */ function orderBlockedAttentionByWeight( items: AttentionItem[], fallback: (left: AttentionItem, right: AttentionItem) => number, ) { const blockers = items .filter((item) => blockedTaskCount(item) !== null) .sort((left, right) => { const weightDiff = (blockedTaskCount(right) ?? 0) - (blockedTaskCount(left) ?? 0); return weightDiff !== 0 ? weightDiff : fallback(left, right); }); if (blockers.length < 2) return items; let blockerIndex = 0; return items.map((item) => blockedTaskCount(item) === null ? item : blockers[blockerIndex++]!); } function sourceKey(sourceKind: AttentionSourceKind, sourceId: string) { return `${sourceKind}:${sourceId}`; } function itemSourceKey(item: AttentionItem) { return sourceKey(item.sourceKind, item.subject.id); } function readMetadataAgentId(item: AttentionItem) { const metadata = item.subject.metadata; const value = metadata?.originAgentId ?? metadata?.createdByAgentId ?? metadata?.requestedByAgentId ?? metadata?.agentId ?? (item.subject.kind === "agent" ? item.subject.id : null); return typeof value === "string" && value.length > 0 ? value : null; } function startOfUtcDay(now: number) { const value = new Date(now); return Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()); } function endOfUtcDay(now: number) { return startOfUtcDay(now) + 24 * 60 * 60 * 1_000 - 1; } function endOfUtcWeek(now: number) { const start = startOfUtcDay(now); const weekday = new Date(start).getUTCDay(); // Use an ISO-style Monday-Sunday week. Sunday (0) is already the last // day of the current week; every other day advances only to that Sunday. const daysUntilSunday = weekday === 0 ? 0 : 7 - weekday; return start + (daysUntilSunday + 1) * 24 * 60 * 60 * 1_000 - 1; } function decideOrder(item: AttentionItem, now: number): [number, number] { if (item.decideBy === "today") return [0, endOfUtcDay(now)]; if (item.decideBy === "this_week") return [0, endOfUtcWeek(now)]; if (item.decideBy && /^\d{4}-\d{2}-\d{2}$/.test(item.decideBy)) { const deadline = Date.parse(`${item.decideBy}T23:59:59.999Z`); if (Number.isFinite(deadline)) return [0, deadline]; } if (item.decideBy === "whenever") return [1, Number.MAX_SAFE_INTEGER]; return [2, Number.MAX_SAFE_INTEGER]; } function isDecideNow(item: AttentionItem, now: number) { const [bucket, deadline] = decideOrder(item, now); return bucket === 0 && deadline <= endOfUtcDay(now); } /** Surfaced today (arrival). Mirrors `attentionIsNewToday` in `ui/src/lib/attention.ts`. */ function isNewToday(item: AttentionItem, now: number) { const ts = timestamp(item.createdAt); return ts > 0 && ts >= startOfUtcDay(now); } function compareDecideItems(left: AttentionItem, right: AttentionItem, now: number) { const [leftBucket, leftDeadline] = decideOrder(left, now); const [rightBucket, rightDeadline] = decideOrder(right, now); if (leftBucket !== rightBucket) return leftBucket - rightBucket; if (leftDeadline !== rightDeadline) return leftDeadline - rightDeadline; const leftExpiry = left.expiresAt ? timestamp(left.expiresAt) : Number.MAX_SAFE_INTEGER; const rightExpiry = right.expiresAt ? timestamp(right.expiresAt) : Number.MAX_SAFE_INTEGER; if (leftExpiry !== rightExpiry) return leftExpiry - rightExpiry; const severityDiff = SEVERITY_RANK[left.severity] - SEVERITY_RANK[right.severity]; if (severityDiff !== 0) return severityDiff; return compareAttentionItems(left, right); } function encodeCursor(sort: AttentionSortMode, item: AttentionItem) { return Buffer.from(JSON.stringify({ v: 1, sort, id: item.id }), "utf8").toString("base64url"); } function decodeCursor(cursor: string, sort: AttentionSortMode) { try { const decoded = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as Record; if (decoded.v !== 1 || decoded.sort !== sort || typeof decoded.id !== "string" || !decoded.id) { throw new Error("invalid cursor shape"); } return decoded.id; } catch { throw badRequest("Invalid attention cursor"); } } function parseActivityBoundary(value: string | undefined, field: "activitySince" | "activityUntil") { if (value === undefined) return null; if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(value)) { throw badRequest(`${field} must be an ISO timestamp`); } const parsed = Date.parse(value); if (!Number.isFinite(parsed)) throw badRequest(`${field} must be an ISO timestamp`); return parsed; } async function enrichAttentionItems(db: Db, companyId: string, items: AttentionItem[], now: number) { if (items.length === 0) return items; const sourceIds = [...new Set(items.map((item) => item.subject.id))]; const queueRows = await db .select({ sourceKind: decisionQueueItems.sourceKind, sourceId: decisionQueueItems.sourceId, key: decisionQueues.key, title: decisionQueues.title, retentionDays: decisionQueues.retentionDays, }) .from(decisionQueueItems) .innerJoin(decisionQueues, and( eq(decisionQueueItems.queueId, decisionQueues.id), eq(decisionQueues.companyId, companyId), )) .where(and( eq(decisionQueueItems.companyId, companyId), inArray(decisionQueueItems.sourceId, sourceIds), )) .orderBy(asc(decisionQueues.title), asc(decisionQueues.key)); const queuesBySource = new Map(); const retentionDaysBySource = new Map(); for (const row of queueRows) { const key = sourceKey(row.sourceKind as AttentionSourceKind, row.sourceId); const queues = queuesBySource.get(key) ?? []; queues.push({ key: row.key, title: row.title }); queuesBySource.set(key, queues); if (row.retentionDays != null) { const values = retentionDaysBySource.get(key) ?? []; values.push(row.retentionDays); retentionDaysBySource.set(key, values); } } const triageRows = await db .select() .from(decisionTriage) .where(and( eq(decisionTriage.companyId, companyId), inArray(decisionTriage.sourceId, sourceIds), )); const triageBySource = new Map(triageRows.map((row) => [ sourceKey(row.sourceKind as AttentionSourceKind, row.sourceId), row, ])); const agentIds = [...new Set([ ...items.map(readMetadataAgentId), ...triageRows.map((row) => row.setByAgentId), ].filter((value): value is string => Boolean(value)))]; const agentNameById = new Map(agentIds.length === 0 ? [] : await db .select({ id: agents.id, name: agents.name }) .from(agents) .where(and(eq(agents.companyId, companyId), inArray(agents.id, agentIds))) .then((rows) => rows.map((row) => [row.id, row.name] as const))); const enriched = items.map((item) => { const triage = triageBySource.get(itemSourceKey(item)); const decideBy = triage?.decideBy === "date" ? triage.decideByDate : triage?.decideBy ?? null; const decideByAttribution: AttentionTriageAttribution | null = triage ? { type: triage.setByType as AttentionTriageAttribution["type"], agentId: triage.setByAgentId ?? null, agentName: triage.setByAgentId ? agentNameById.get(triage.setByAgentId) ?? null : null, userId: triage.setByUserId ?? null, runId: triage.setByRunId ?? null, responsibleUserId: triage.responsibleUserId ?? null, updatedAt: toIso(triage.updatedAt), } : null; const originAgentId = readMetadataAgentId(item); return { ...item, originAgentName: originAgentId ? agentNameById.get(originAgentId) ?? null : null, queues: queuesBySource.get(itemSourceKey(item)) ?? [], decideBy, decideByAttribution, snoozedUntil: triage?.snoozedUntil ? toIso(triage.snoozedUntil) : null, }; }); const retentionBySource = await decisionRetentionService(db).syncItems(companyId, enriched); return enriched.map((item) => { const key = itemSourceKey(item); const retention = retentionBySource.get(key); const overrides = retentionDaysBySource.get(key) ?? []; const retentionDays = overrides.length > 0 ? Math.min(...overrides) : DEFAULT_DECISION_SHELF_DAYS; return { ...item, shelf: timestamp(item.activityAt) <= now - retentionDays * 86_400_000, retentionDays, keep: retention?.keep ?? false, archivedAt: retention?.archivedAt ? toIso(retention.archivedAt) : null, retentionVersion: retention?.version ?? 0, }; }); } function betterDuplicate(left: AttentionItem, right: AttentionItem) { return compareAttentionItems(left, right) <= 0 ? left : right; } function approvalTitle(type: string, payload: Record) { const title = typeof payload.title === "string" ? payload.title.trim() : ""; if (title) return title; const summary = typeof payload.summary === "string" ? payload.summary.trim() : ""; if (summary) return summary; return type.replaceAll("_", " "); } function interactionLabel(kind: string) { switch (kind) { case "request_confirmation": return "Confirmation requested"; case "request_checkbox_confirmation": return "Selection confirmation requested"; case "ask_user_questions": return "Questions need answers"; case "suggest_tasks": return "Suggested tasks need a decision"; case "request_item_verdicts": return "Item verdicts need a decision"; default: return "Interaction needs a decision"; } } function interactionVerbs(kind: string, payload: Record) { if (kind === "ask_user_questions") { return decisionVerbs({ id: "respond", label: "Respond", description: "Submit answers to the pending questions.", }); } if (kind === "request_confirmation") { const acceptLabel = typeof payload.acceptLabel === "string" && payload.acceptLabel.trim() ? payload.acceptLabel.trim() : "Confirm"; const rejectLabel = typeof payload.rejectLabel === "string" && payload.rejectLabel.trim() ? payload.rejectLabel.trim() : "Decline"; return decisionVerbs( { id: "accept", label: acceptLabel, description: "Accept the pending confirmation.", }, { id: "reject", label: rejectLabel, description: "Decline the pending confirmation.", }, ); } return decisionVerbs( { id: "accept", label: "Accept", description: "Accept the pending interaction.", }, { id: "reject", label: "Reject", description: "Reject the pending interaction and provide a reason when required.", }, ); } /** * The resolver audience carried by an `issue_thread_interaction` feed row * (PAP-17287). A collapsed queue row offers Accept/Reject long before anything * fetches the interaction itself, so the audience the server will enforce has * to ride along with the item — the queue must never ask for a decision without * saying whose decision it is. * * Facts only. The stored columns are canonicalized through the same helper the * resolution evaluator uses, so a pre-migration row cannot read as `Anyone` * here while the API still treats it as `not_creator`. */ export function interactionResolverAudience( row: { addresseeAgentId: string | null; addresseeUserId?: string | null; createdByAgentId: string | null; requestedResolverPolicy: string; effectiveResolverPolicy: string; resolverPolicyProvenance: string | null; effectiveResolverPolicySource: string | null; }, agentName: (agentId: string) => string | null, ): AttentionResolverAudience { const provenance = (row.resolverPolicyProvenance ?? (row.requestedResolverPolicy === "board_only" || row.requestedResolverPolicy === "board_or_agents" ? "legacy_inherited_restriction" : "inherited")) as IssueThreadInteractionResolverPolicyProvenance; return { requestedResolverPolicy: canonicalizeStoredResolverPolicy(row.requestedResolverPolicy, provenance), effectiveResolverPolicy: canonicalizeStoredResolverPolicy(row.effectiveResolverPolicy, provenance), effectiveResolverPolicySource: (row.effectiveResolverPolicySource ?? "requested") as IssueThreadInteractionEffectiveResolverPolicySource, resolverPolicyProvenance: provenance, addresseeAgentId: row.addresseeAgentId, addresseeUserId: row.addresseeUserId ?? null, addresseeName: row.addresseeAgentId ? agentName(row.addresseeAgentId) : null, createdByAgentId: row.createdByAgentId, createdByAgentName: row.createdByAgentId ? agentName(row.createdByAgentId) : null, }; } function collapsePendingConfirmationsToNewest(rows: T[]) { const newestByGroup = new Map(); for (const row of rows) { if (row.kind !== "request_confirmation") continue; const groupKey = `${row.issueId}:${row.kind}`; const newest = newestByGroup.get(groupKey); if ( !newest || row.createdAt.getTime() > newest.createdAt.getTime() || (row.createdAt.getTime() === newest.createdAt.getTime() && row.id > newest.id) ) { newestByGroup.set(groupKey, row); } } return rows.filter((row) => ( row.kind !== "request_confirmation" || newestByGroup.get(`${row.issueId}:${row.kind}`)?.id === row.id )); } function budgetObservedPercent(amountObserved: number, amountLimit: number) { return amountLimit > 0 ? Math.round((amountObserved / amountLimit) * 10_000) / 100 : 0; } async function companyPrefix(db: Db, companyId: string) { const row = await db .select({ issuePrefix: companies.issuePrefix }) .from(companies) .where(eq(companies.id, companyId)) .then((rows) => rows[0] ?? null); return row?.issuePrefix ?? "PAP"; } async function dismissalByKey(db: Db, companyId: string, userId: string | null | undefined) { if (!userId) return new Map(); const rows = await db .select({ itemKey: inboxDismissals.itemKey, kind: inboxDismissals.kind, dismissedAt: inboxDismissals.dismissedAt, snoozedUntil: inboxDismissals.snoozedUntil, }) .from(inboxDismissals) .where(and(eq(inboxDismissals.companyId, companyId), eq(inboxDismissals.userId, userId))); return new Map(rows.map((row) => [row.itemKey, { kind: row.kind, dismissedAt: row.dismissedAt, snoozedUntil: row.snoozedUntil, }])); } async function issueSummaryMap( db: Db, companyId: string, issueIds: Array, readCondition: SQL = sql`true`, ) { const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))]; if (ids.length === 0) return new Map(); const rows = await db .select({ id: issues.id, companyId: issues.companyId, identifier: issues.identifier, title: issues.title, status: issues.status, priority: issues.priority, reviewPolicy: issues.reviewPolicy, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, createdAt: issues.createdAt, updatedAt: issues.updatedAt, projectId: projects.id, projectName: projects.name, projectColor: projects.color, projectIcon: projects.icon, workspaceId: projectWorkspaces.id, workspaceName: projectWorkspaces.name, }) .from(issues) .leftJoin(projects, and(eq(issues.projectId, projects.id), eq(projects.companyId, companyId))) .leftJoin(projectWorkspaces, and( eq(issues.projectWorkspaceId, projectWorkspaces.id), eq(projectWorkspaces.companyId, companyId), )) .where(and( eq(issues.companyId, companyId), inArray(issues.id, ids), executionIssueCondition(), readCondition, )); return new Map(rows.map((row) => [row.id, { id: row.id, companyId: row.companyId, identifier: row.identifier, title: row.title, status: row.status, priority: row.priority, reviewPolicy: row.reviewPolicy ?? null, assigneeAgentId: row.assigneeAgentId, assigneeUserId: row.assigneeUserId, createdAt: row.createdAt, updatedAt: row.updatedAt, project: row.projectId && row.projectName ? { id: row.projectId, name: row.projectName, urlKey: deriveProjectUrlKey(row.projectName, row.projectId), color: row.projectColor, icon: row.projectIcon, } : null, workspace: row.workspaceId && row.workspaceName ? { id: row.workspaceId, name: row.workspaceName, } : null, }])); } async function issueImageMap( db: Db, companyId: string, issueIds: Array, readCondition: SQL = sql`true`, ) { const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))]; if (ids.length === 0) return new Map(); const rows = await db .select({ issueId: issueAttachments.issueId, assetId: issueAttachments.assetId, originalFilename: assets.originalFilename, }) .from(issueAttachments) .innerJoin(assets, eq(issueAttachments.assetId, assets.id)) .innerJoin(issues, and(eq(issueAttachments.issueId, issues.id), eq(issues.companyId, companyId))) .where(and( eq(issueAttachments.companyId, companyId), eq(assets.companyId, companyId), inArray(issueAttachments.issueId, ids), sql`${assets.contentType} like 'image/%'`, readCondition, )) .orderBy(asc(issueAttachments.issueId), asc(issueAttachments.createdAt), asc(issueAttachments.id)); const map = new Map(); for (const row of rows) { const images = map.get(row.issueId) ?? []; if (images.length >= DETAIL_IMAGE_LIMIT) continue; images.push({ assetId: row.assetId, alt: row.originalFilename ?? null }); map.set(row.issueId, images); } return map; } async function planDocumentMap( db: Db, companyId: string, issueIds: Array, readCondition: SQL = sql`true`, ) { const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))]; if (ids.length === 0) return new Map(); const rows = await db .select({ issueId: issueDocuments.issueId, title: documents.title, body: documents.latestBody, }) .from(issueDocuments) .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) .innerJoin(issues, and(eq(issueDocuments.issueId, issues.id), eq(issues.companyId, companyId))) .where(and( eq(issueDocuments.companyId, companyId), eq(documents.companyId, companyId), eq(issueDocuments.key, "plan"), inArray(issueDocuments.issueId, ids), readCondition, )); return new Map(rows.map((row) => [row.issueId, { title: row.title, body: row.body }])); } async function blockingIssueMap( db: Db, companyId: string, blockedIssueIds: Array, readCondition: SQL = sql`true`, ) { const ids = [...new Set(blockedIssueIds.filter((value): value is string => Boolean(value)))]; if (ids.length === 0) return new Map(); const rows = await db .select({ blockedIssueId: issueRelations.relatedIssueId, id: issues.id, identifier: issues.identifier, title: issues.title, }) .from(issueRelations) .innerJoin(issues, eq(issueRelations.issueId, issues.id)) .where(and( eq(issueRelations.companyId, companyId), eq(issues.companyId, companyId), eq(issueRelations.type, "blocks"), inArray(issueRelations.relatedIssueId, ids), isNull(issues.hiddenAt), readCondition, )) .orderBy(asc(issueRelations.relatedIssueId), asc(issueRelations.createdAt), asc(issueRelations.id)); const map = new Map(); for (const row of rows) { if (!map.has(row.blockedIssueId)) { map.set(row.blockedIssueId, { id: row.id, identifier: row.identifier, title: row.title }); } } return map; } type BlockedWorkEdge = { fromIssueId: string | null; issueId: string; }; /** * Counts open work held behind each blocker. The walk starts with explicit * dependents, then follows both further dependency edges and issue children. * Per-root visited sets make corrupt cycles harmless; the blocker analyzer's * existing traversal caps bound unusually large graphs. */ async function blockedWorkCountMap(db: Db, companyId: string, blockerIssueIds: string[]) { const rootIds = [...new Set(blockerIssueIds)]; const seenByRoot = new Map(rootIds.map((rootId) => [rootId, new Set()])); if (rootIds.length === 0) return new Map(); const loadEdges = async (fromIssueIds: string[], includeChildren: boolean) => { const rows: BlockedWorkEdge[] = []; for (const chunk of chunkValues(fromIssueIds, ATTENTION_GRAPH_QUERY_CHUNK_SIZE)) { const dependentRowsPromise: Promise = db .select({ fromIssueId: issueRelations.issueId, issueId: issues.id, }) .from(issueRelations) .innerJoin(issues, eq(issueRelations.relatedIssueId, issues.id)) .where(and( eq(issueRelations.companyId, companyId), eq(issueRelations.type, "blocks"), inArray(issueRelations.issueId, chunk), eq(issues.companyId, companyId), isNull(issues.hiddenAt), notInArray(issues.status, ["done", "cancelled"]), )); const childRowsPromise: Promise = includeChildren ? db .select({ fromIssueId: issues.parentId, issueId: issues.id, }) .from(issues) .where(and( eq(issues.companyId, companyId), inArray(issues.parentId, chunk), isNull(issues.hiddenAt), notInArray(issues.status, ["done", "cancelled"]), )) : Promise.resolve([]); const [dependentRows, childRows] = await Promise.all([dependentRowsPromise, childRowsPromise]); rows.push(...dependentRows, ...childRows); } return rows; }; let rootsByFrontierId = new Map>(); for (const edge of await loadEdges(rootIds, false)) { if (!edge.fromIssueId || edge.issueId === edge.fromIssueId) continue; const seen = seenByRoot.get(edge.fromIssueId); if (!seen || seen.size >= BLOCKER_ATTENTION_MAX_NODES || seen.has(edge.issueId)) continue; seen.add(edge.issueId); const roots = rootsByFrontierId.get(edge.issueId) ?? new Set(); roots.add(edge.fromIssueId); rootsByFrontierId.set(edge.issueId, roots); } for (let depth = 1; rootsByFrontierId.size > 0 && depth < BLOCKER_ATTENTION_MAX_DEPTH; depth += 1) { const nextRootsByFrontierId = new Map>(); const edges = await loadEdges([...rootsByFrontierId.keys()], true); for (const edge of edges) { if (!edge.fromIssueId) continue; const roots = rootsByFrontierId.get(edge.fromIssueId); if (!roots) continue; for (const rootId of roots) { if (edge.issueId === rootId) continue; const seen = seenByRoot.get(rootId); if (!seen || seen.size >= BLOCKER_ATTENTION_MAX_NODES || seen.has(edge.issueId)) continue; seen.add(edge.issueId); const nextRoots = nextRootsByFrontierId.get(edge.issueId) ?? new Set(); nextRoots.add(rootId); nextRootsByFrontierId.set(edge.issueId, nextRoots); } } rootsByFrontierId = nextRootsByFrontierId; } return new Map([...seenByRoot].map(([rootId, seen]) => [rootId, seen.size])); } /** * The task that blocks `issue` — never `issue` itself. * * Both blocker_attention call sites used to fall back to the blocked task's own * identity when no `blocks` relation was loaded, so every such row reported * "PAP-23 — Blocked by PAP-23". The UI renders that as a real dependency, which * tells an operator nothing and reads as a bug. * * Order: the loaded `blocks` relation, then an identifier sampled by * blockerAttention, and otherwise nothing — a null lets the row fall back to * its `whyNow` line, which is honest about not knowing the blocker. */ function resolveBlockingIssue( issue: { id: string; identifier: string | null }, fromRelation: BlockingIssueSummary | undefined, sampledIdentifier?: string | null, ): BlockingIssueSummary | null { // A self-referential relation row would be corrupt data; treat it as unknown. if (fromRelation && fromRelation.id !== issue.id) return fromRelation; if (sampledIdentifier && sampledIdentifier !== issue.identifier && sampledIdentifier !== issue.id) { return { id: null, identifier: sampledIdentifier, title: null }; } return null; } function readRunIssueId(contextSnapshot: Record | null) { const issueId = contextSnapshot?.issueId ?? contextSnapshot?.taskId; return typeof issueId === "string" && issueId.length > 0 ? issueId : null; } export function attentionService(db: Db, serviceOptions: AttentionServiceOptions = {}) { const openDecisionLimit = Math.min( Math.max(Math.trunc(serviceOptions.openDecisionLimit ?? OPEN_DECISION_DEFAULT_LIMIT), 1), OPEN_DECISION_MAX_LIMIT, ); return { list: async (companyId: string, options: AttentionListOptions = {}): Promise => { if (options.all && !options.queue && !options.allowUnscopedAll) { throw badRequest("all requires a queue filter"); } const readCondition = options.actor ? await issueReadSqlCondition(db, options.actor) : sql`true`; const [prefix, dismissals] = await Promise.all([ companyPrefix(db, companyId), dismissalByKey(db, companyId, options.userId), ]); const includeDismissed = options.includeDismissed === true; const now = serviceOptions.now?.() ?? Date.now(); const collected: AttentionItem[] = []; const add = (item: AttentionItem) => { const dismissal = activeDismissalState(dismissals, item.dismissalKey, item.activityAt, now); if (!includeDismissed && dismissal?.isActive) return; collected.push({ ...item, dismissal }); }; const pendingApprovals = await db .select({ id: approvals.id, type: approvals.type, status: approvals.status, requestedByAgentId: approvals.requestedByAgentId, requestedByUserId: approvals.requestedByUserId, payload: approvals.payload, createdAt: approvals.createdAt, updatedAt: approvals.updatedAt, }) .from(approvals) .where(and(eq(approvals.companyId, companyId), eq(approvals.status, "pending"))) .orderBy(desc(approvals.updatedAt), desc(approvals.id)); const pendingApprovalIds = pendingApprovals.map((approval) => approval.id); const approvalIssueRows = pendingApprovalIds.length > 0 ? await db .select({ approvalId: issueApprovals.approvalId, issueId: issueApprovals.issueId }) .from(issueApprovals) .where(and( eq(issueApprovals.companyId, companyId), inArray(issueApprovals.approvalId, pendingApprovalIds), )) .orderBy(asc(issueApprovals.approvalId), asc(issueApprovals.issueId)) : []; const approvalIssueMap = new Map(); for (const row of approvalIssueRows) { if (!approvalIssueMap.has(row.approvalId)) approvalIssueMap.set(row.approvalId, row.issueId); } const readableApprovalIssues = await issueSummaryMap( db, companyId, approvalIssueRows.map((row) => row.issueId), readCondition, ); for (const approval of pendingApprovals) { const linkedIssueId = approvalIssueMap.get(approval.id) ?? null; if (linkedIssueId && !readableApprovalIssues.has(linkedIssueId)) continue; const dedupKey = `approval:${approval.id}`; const title = approvalTitle(approval.type, approval.payload); add(createItem({ companyId, sourceKind: "approval", subject: { kind: "approval", id: approval.id, companyId, title, identifier: null, status: approval.status, href: `/${prefix}/approvals/${approval.id}`, metadata: { type: approval.type, requestedByAgentId: approval.requestedByAgentId, requestedByUserId: approval.requestedByUserId, issueId: approvalIssueMap.get(approval.id) ?? null, }, }, whyNow: "Approval is pending a board decision.", decisionVerbs: decisionVerbs( { id: "approve", label: "Approve", description: "Approve the request." }, { id: "reject", label: "Reject", description: "Reject the request." }, { id: "request_revision", label: "Request revision", description: "Send the request back for changes." }, ), inlineResolvable: approval.type !== "request_board_approval", entryRule: "approvals.status = 'pending'", exitRule: "Approval leaves pending status.", dedupKey, severity: "medium", activityAt: toIso(approval.updatedAt), createdAt: toIso(approval.createdAt), updatedAt: toIso(approval.updatedAt), relatedIssue: null, detail: approvalDetail(approval.type, approval.payload), })); } const interactionRows = await db .select({ id: issueThreadInteractions.id, issueId: issueThreadInteractions.issueId, kind: issueThreadInteractions.kind, status: issueThreadInteractions.status, title: issueThreadInteractions.title, summary: issueThreadInteractions.summary, payload: issueThreadInteractions.payload, addresseeAgentId: issueThreadInteractions.addresseeAgentId, addresseeUserId: issueThreadInteractions.addresseeUserId, createdByAgentId: issueThreadInteractions.createdByAgentId, requestedResolverPolicy: issueThreadInteractions.requestedResolverPolicy, effectiveResolverPolicy: issueThreadInteractions.effectiveResolverPolicy, resolverPolicyProvenance: issueThreadInteractions.resolverPolicyProvenance, effectiveResolverPolicySource: issueThreadInteractions.effectiveResolverPolicySource, createdAt: issueThreadInteractions.createdAt, updatedAt: issueThreadInteractions.updatedAt, }) .from(issueThreadInteractions) .where(and( eq(issueThreadInteractions.companyId, companyId), inArray(issueThreadInteractions.status, [...PENDING_INTERACTION_STATUSES]), )) .orderBy(desc(issueThreadInteractions.updatedAt), desc(issueThreadInteractions.id)); // Addressee invokability needs the org graph; the audience line also needs // the creator's name whenever the effective policy excludes it, so a // creator-excluding row pulls the roster in too (PAP-17287). const needsCompanyAgents = interactionRows.some((row) => row.addresseeAgentId !== null || canonicalizeStoredResolverPolicy(row.effectiveResolverPolicy, row.resolverPolicyProvenance) === "not_creator" ); const companyAgentRows: AgentOrgRow[] = needsCompanyAgents ? await db .select({ id: agents.id, companyId: agents.companyId, name: agents.name, reportsTo: agents.reportsTo, status: agents.status, }) .from(agents) .where(eq(agents.companyId, companyId)) : []; const companyAgentMap = new Map(companyAgentRows.map((agent) => [agent.id, agent])); const boardInteractionRows = interactionRows.filter((row) => (row.addresseeAgentId === null || !evaluateAgentInvokability(companyAgentMap.get(row.addresseeAgentId), companyAgentRows).invokable) && (row.addresseeUserId === null || row.addresseeUserId === options.userId) ); const visibleInteractionRows = collapsePendingConfirmationsToNewest(boardInteractionRows); const [interactionIssueMap, interactionImageMap, interactionPlanDocumentMap] = await Promise.all([ issueSummaryMap(db, companyId, visibleInteractionRows.map((row) => row.issueId), readCondition), issueImageMap(db, companyId, visibleInteractionRows.map((row) => row.issueId), readCondition), planDocumentMap(db, companyId, visibleInteractionRows.map((row) => row.issueId), readCondition), ]); for (const interaction of visibleInteractionRows) { const issue = interactionIssueMap.get(interaction.issueId) ?? null; if (!issue) continue; const payload = readRecord(interaction.payload); const detail = interactionDetail({ kind: interaction.kind, payload, issue, planDocument: interactionPlanDocumentMap.get(interaction.issueId) ?? null, images: issueImages(interactionImageMap, interaction.issueId), }); const isPlanTarget = detail.kind === "plan_approval"; const dedupKey = `interaction:${interaction.id}`; add(createItem({ companyId, sourceKind: "issue_thread_interaction", subject: { kind: "interaction", id: interaction.id, companyId, title: isPlanTarget && issue ? `Plan approval - ${issue.title}` : interaction.title ?? interaction.summary ?? interactionLabel(interaction.kind), identifier: null, status: interaction.status, href: issue ? `${issueHref(prefix, issue)}#interaction-${interaction.id}` : null, metadata: { kind: interaction.kind, issueId: interaction.issueId, createdByAgentId: interaction.createdByAgentId, isPlanTarget, targetDocumentKey: isPlanTarget ? "plan" : null, }, }, whyNow: `${interactionLabel(interaction.kind)} on an issue thread.`, decisionVerbs: interactionVerbs(interaction.kind, payload), inlineResolvable: true, entryRule: "issue_thread_interactions.status = 'pending'", exitRule: "Interaction resolves, expires, fails, or is cancelled.", dedupKey, severity: "medium", activityAt: toIso(interaction.updatedAt), createdAt: toIso(interaction.createdAt), updatedAt: toIso(interaction.updatedAt), relatedIssue: issue ? issueSubject(prefix, issue) : null, ...issueContext(issue), detail, resolverAudience: interactionResolverAudience( interaction, (agentId) => companyAgentMap.get(agentId)?.name ?? null, ), })); } const openDecisionQuery = db.select({ id: decisions.id, bundleId: decisions.bundleId, originAgentId: decisions.originAgentId, ruleKey: decisions.ruleKey, title: decisions.title, body: decisions.body, status: decisions.status, expiresAt: decisions.expiresAt, originIssueId: decisions.originIssueId, createdAt: decisions.createdAt, updatedAt: decisions.updatedAt, }).from(decisions).where(and(eq(decisions.companyId, companyId), eq(decisions.status, "open"))) .orderBy(desc(decisions.updatedAt), desc(decisions.id)); const openDecisions = options.all ? await openDecisionQuery : await openDecisionQuery.limit(openDecisionLimit); // Bundle titles let the feed render a single "Agent proposed N decisions" // group header over sibling decisions (v1 still decides each independently). const bundleIds = [...new Set(openDecisions.map((decision) => decision.bundleId).filter((value): value is string => Boolean(value)))]; const bundleTitleMap = new Map(); const [decisionIssueMap, bundleRows] = await Promise.all([ issueSummaryMap(db, companyId, openDecisions.map((decision) => decision.originIssueId), readCondition), bundleIds.length > 0 ? db.select({ id: decisionBundles.id, title: decisionBundles.title }) .from(decisionBundles).where(and(eq(decisionBundles.companyId, companyId), inArray(decisionBundles.id, bundleIds))) : Promise.resolve([]), ]); for (const row of bundleRows) bundleTitleMap.set(row.id, row.title); for (const decision of openDecisions) { const issue = decisionIssueMap.get(decision.originIssueId) ?? null; if (decision.originIssueId && !issue) continue; add(createItem({ companyId, sourceKind: "decision", subject: { kind: "decision", id: decision.id, companyId, title: decision.title, identifier: null, status: decision.status, href: `/${prefix}/decisions?decisionId=${decision.id}`, metadata: { originIssueId: decision.originIssueId, originAgentId: decision.originAgentId, bundleId: decision.bundleId, bundleTitle: decision.bundleId ? bundleTitleMap.get(decision.bundleId) ?? null : null } }, whyNow: "An agent decision is waiting for a board response.", decisionVerbs: decisionVerbs({ id: "decide", label: "Review", description: "Review and choose an option." }), inlineResolvable: true, entryRule: "decisions.status = 'open'", exitRule: "Decision is decided, expired, or cancelled.", dedupKey: `decision:${decision.id}`, severity: "medium", expiresAt: decision.expiresAt ? toIso(decision.expiresAt) : null, ruleKey: decision.ruleKey, activityAt: toIso(decision.updatedAt), createdAt: toIso(decision.createdAt), updatedAt: toIso(decision.updatedAt), relatedIssue: issue ? issueSubject(prefix, issue) : null, ...issueContext(issue), detail: { kind: "generic", summaryExcerpt: decision.body.slice(0, DETAIL_EXCERPT_LENGTH), images: [] }, })); } const pendingJoins = await db .select({ id: joinRequests.id, requestType: joinRequests.requestType, status: joinRequests.status, requestingUserId: joinRequests.requestingUserId, requestEmailSnapshot: joinRequests.requestEmailSnapshot, agentName: joinRequests.agentName, adapterType: joinRequests.adapterType, createdAt: joinRequests.createdAt, updatedAt: joinRequests.updatedAt, }) .from(joinRequests) .innerJoin(invites, eq(joinRequests.inviteId, invites.id)) .where(and( eq(joinRequests.companyId, companyId), eq(invites.companyId, companyId), eq(joinRequests.status, "pending_approval"), )) .orderBy(desc(joinRequests.updatedAt), desc(joinRequests.id)); for (const join of pendingJoins) { const label = join.requestType === "agent" ? join.agentName ?? "Agent join request" : join.requestEmailSnapshot ?? join.requestingUserId ?? "Human join request"; const dedupKey = `join:${join.id}`; add(createItem({ companyId, sourceKind: "join_request", subject: { kind: "join_request", id: join.id, companyId, title: label, identifier: null, status: join.status, href: `/${prefix}/settings/access`, metadata: { requestType: join.requestType, requestingUserId: join.requestingUserId, adapterType: join.adapterType, }, }, whyNow: "Join request is pending approval.", decisionVerbs: decisionVerbs( { id: "approve", label: "Approve", description: "Approve this join request." }, { id: "reject", label: "Reject", description: "Reject this join request." }, ), inlineResolvable: true, entryRule: "join_requests.status = 'pending_approval'", exitRule: "Join request is approved or rejected.", dedupKey, severity: "medium", activityAt: toIso(join.updatedAt), createdAt: toIso(join.createdAt), updatedAt: toIso(join.updatedAt), relatedIssue: null, detail: genericDetail(label, []), })); } const recoveryRows = await db .select() .from(issueRecoveryActions) .where(and( eq(issueRecoveryActions.companyId, companyId), inArray(issueRecoveryActions.status, [...OPEN_RECOVERY_STATUSES]), inArray(issueRecoveryActions.ownerType, [...HUMAN_RECOVERY_OWNER_TYPES]), )) .orderBy(desc(issueRecoveryActions.updatedAt), desc(issueRecoveryActions.id)); const [recoveryIssueMap, recoveryImageMap] = await Promise.all([ issueSummaryMap( db, companyId, recoveryRows.flatMap((row) => [row.sourceIssueId, row.recoveryIssueId]), readCondition, ), issueImageMap(db, companyId, recoveryRows.map((row) => row.sourceIssueId), readCondition), ]); for (const recovery of recoveryRows) { const sourceIssue = recoveryIssueMap.get(recovery.sourceIssueId) ?? null; if (!sourceIssue) continue; const recoveryIssue = recovery.recoveryIssueId ? recoveryIssueMap.get(recovery.recoveryIssueId) ?? null : null; const dedupKey = `recovery:${recovery.kind}:${recovery.sourceIssueId}:${recovery.cause}:${recovery.fingerprint}`; add(createItem({ companyId, sourceKind: "recovery_action", subject: { kind: "recovery_action", id: recovery.id, companyId, title: recovery.nextAction, identifier: null, status: recovery.status, href: recoveryIssue ? issueHref(prefix, recoveryIssue) : sourceIssue ? issueHref(prefix, sourceIssue) : null, metadata: { kind: recovery.kind, cause: recovery.cause, ownerType: recovery.ownerType, ownerUserId: recovery.ownerUserId, sourceIssueId: recovery.sourceIssueId, recoveryIssueId: recovery.recoveryIssueId, }, }, whyNow: recovery.status === "escalated" ? "Recovery action escalated to a human owner." : "Recovery action is assigned to a human owner.", decisionVerbs: decisionVerbs( { id: "resolve", label: "Resolve", description: "Record the recovery outcome." }, { id: "reassign", label: "Reassign", description: "Move the recovery to another owner." }, { id: "cancel", label: "Cancel", description: "Cancel the recovery action." }, ), inlineResolvable: false, entryRule: "issue_recovery_actions.status in ('active','escalated') and owner_type in ('user','board')", exitRule: "Recovery action resolves, is cancelled, or moves back to an agent/system owner.", dedupKey, severity: recovery.status === "escalated" ? "high" : "medium", activityAt: toIso(recovery.updatedAt), createdAt: toIso(recovery.createdAt), updatedAt: toIso(recovery.updatedAt), relatedIssue: sourceIssue ? issueSubject(prefix, sourceIssue) : null, ...issueContext(sourceIssue), detail: genericDetail(recovery.nextAction, issueImages(recoveryImageMap, recovery.sourceIssueId)), })); } const blockedIssues = await issueService(db).list(companyId, { status: "blocked", includeBlockedBy: true, readCondition }); type BlockedAttentionIssue = IssueSubjectRow & { blockerAttention?: { state?: string; sampleStalledBlockerIdentifier?: string | null; sampleBlockerIdentifier?: string | null; blockingTreeLive?: boolean; terminalBlockerIssueId?: string | null; } | null; unblockDescriptor?: { owner: { userId: string } | { agentId: string } | "board"; action: string } | null; blockedTransitionAt?: Date | null; }; const typedBlockedIssues = blockedIssues as BlockedAttentionIssue[]; const terminalBlockerIssueIds = typedBlockedIssues .map((issue) => issue.blockerAttention?.terminalBlockerIssueId) .filter((issueId): issueId is string => Boolean(issueId)); const [blockedIssueSummaries, terminalBlockerSummaries, blockerImageMap, blockingIssues] = await Promise.all([ issueSummaryMap(db, companyId, blockedIssues.map((issue) => issue.id), readCondition), issueSummaryMap(db, companyId, terminalBlockerIssueIds, readCondition), issueImageMap( db, companyId, [...blockedIssues.map((issue) => issue.id), ...terminalBlockerIssueIds], readCondition, ), blockingIssueMap(db, companyId, blockedIssues.map((issue) => issue.id), readCondition), ]); const terminalCandidates = new Map(); for (const issue of typedBlockedIssues) { const descriptor = issue.unblockDescriptor; const humanOwnerMatches = descriptor?.owner === "board" || (descriptor?.owner && "userId" in descriptor.owner && descriptor.owner.userId === options.userId); if (descriptor && humanOwnerMatches && isProspectiveBlockedTransition(issue)) { const issueSummary = blockedIssueSummaries.get(issue.id) ?? null; add(createItem({ companyId, sourceKind: "blocker_attention", subject: issueSubject(prefix, issueSummary ?? issue), whyNow: descriptor.action, decisionVerbs: decisionVerbs( { id: "unblock", label: "Unblock", description: descriptor.action }, { id: "reassign", label: "Reassign", description: "Route this blocked issue to another owner." }, ), inlineResolvable: false, entryRule: "blocked issue has a human-owned unblockDescriptor", exitRule: "Issue leaves blocked status.", dedupKey: `blocked-owner:${issue.id}:${issue.blockedTransitionAt.toISOString()}`, severity: "high", activityAt: toIso(issue.blockedTransitionAt), createdAt: toIso(issue.createdAt), updatedAt: toIso(issue.updatedAt), relatedIssue: null, ...issueContext(issueSummary), detail: { kind: "blocker", blockingIssue: resolveBlockingIssue(issue, blockingIssues.get(issue.id)), images: issueImages(blockerImageMap, issue.id), }, })); } const blockerAttention = issue.blockerAttention; if (blockerAttention?.state !== "stalled" && blockerAttention?.state !== "needs_attention") continue; if (blockerAttention.blockingTreeLive) continue; const issueSummary = blockedIssueSummaries.get(issue.id) ?? null; const terminalIssueId = blockerAttention.terminalBlockerIssueId ?? issue.id; const terminalSummary = terminalBlockerSummaries.get(terminalIssueId) ?? (terminalIssueId === issue.id ? issueSummary ?? issue : null); if (!terminalSummary) continue; const current = terminalCandidates.get(terminalIssueId); if (!current || issue.updatedAt > current.issue.updatedAt) { terminalCandidates.set(terminalIssueId, { issue, issueSummary, terminalSummary, state: blockerAttention.state, }); } } const blockedWorkCounts = await blockedWorkCountMap(db, companyId, [...terminalCandidates.keys()]); for (const [terminalIssueId, candidate] of terminalCandidates) { const blockedTaskCount = blockedWorkCounts.get(terminalIssueId) ?? 0; const taskLabel = blockedTaskCount === 1 ? "task" : "tasks"; const dedupKey = `blocker:${terminalIssueId}`; add(createItem({ companyId, sourceKind: "blocker_attention", subject: issueSubject(prefix, candidate.terminalSummary), whyNow: candidate.state === "needs_attention" ? `Blocks ${blockedTaskCount} ${taskLabel} and needs human attention.` : `Blocks ${blockedTaskCount} ${taskLabel}; choose the next owner or action.`, decisionVerbs: decisionVerbs( { id: "unblock", label: "Unblock", description: "Repair or replace the stalled blocker path." }, { id: "reassign", label: "Reassign", description: "Assign the stalled blocker to a live owner." }, { id: "nudge", label: "Nudge", description: "Wake or prompt the current owner." }, ), inlineResolvable: false, entryRule: `terminal blocker has a non-live blockerAttention.state = '${candidate.state}'`, exitRule: "The blocking tree becomes live or no open work remains blocked.", dedupKey, severity: "high", activityAt: toIso(candidate.terminalSummary.updatedAt), createdAt: toIso(candidate.terminalSummary.createdAt), updatedAt: toIso(candidate.terminalSummary.updatedAt), relatedIssue: candidate.issueSummary ? issueSubject(prefix, candidate.issueSummary) : null, ...issueContext(candidate.terminalSummary), detail: { kind: "blocker", blockingIssue: null, blockedTaskCount, images: issueImages(blockerImageMap, terminalIssueId), }, })); } const reviewRows = await db .select({ id: issues.id, companyId: issues.companyId, identifier: issues.identifier, title: issues.title, status: issues.status, priority: issues.priority, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, executionState: issues.executionState, createdAt: issues.createdAt, updatedAt: issues.updatedAt, }) .from(issues) .where(and( eq(issues.companyId, companyId), eq(issues.status, "in_review"), executionIssueCondition(), readCondition, )) .orderBy(desc(issues.updatedAt), desc(issues.id)); const reviewIssueIds = reviewRows.map((row) => row.id); const pendingReviewApprovalRows = reviewIssueIds.length === 0 ? [] : await db .select({ issueId: issueApprovals.issueId, approvalId: approvals.id }) .from(issueApprovals) .innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id)) .where(and( eq(issueApprovals.companyId, companyId), eq(approvals.companyId, companyId), inArray(issueApprovals.issueId, reviewIssueIds), eq(approvals.status, "pending"), )); const pendingApprovalByIssueId = new Map(pendingReviewApprovalRows.map((row) => [row.issueId, row.approvalId])); const [reviewAttentionByIssueId, reviewIssueMap, reviewImageMap] = await Promise.all([ issueService(db).listReviewAttention(companyId, reviewRows), issueSummaryMap(db, companyId, reviewIssueIds, readCondition), issueImageMap(db, companyId, reviewIssueIds, readCondition), ]); for (const review of reviewRows) { const state = parseIssueExecutionState(review.executionState); const currentParticipant = state?.status === "pending" ? state.currentParticipant : null; const hasHumanParticipant = currentParticipant?.type === "user"; const pendingApprovalId = pendingApprovalByIssueId.get(review.id) ?? null; const reviewAttention = reviewAttentionByIssueId.get(review.id); const stalled = reviewAttention?.state === "stalled"; if (!hasHumanParticipant && !review.assigneeUserId && !pendingApprovalId && !stalled) continue; const issue = reviewIssueMap.get(review.id); if (!issue) continue; const dedupKey = `review:${review.id}`; // A stalled review carries no interaction/approval/monitor to open, so // it is resolved in-row with the three review verbs (PAP-16080 §4.4). // Covered reviews still deep-link — their real action lives elsewhere // (the pending interaction/approval card, a monitor, a live run). const reviewSubject = issueSubject(prefix, issue); add(createItem({ companyId, sourceKind: "review", subject: stalled ? { ...reviewSubject, metadata: { ...reviewSubject.metadata, reviewAttentionState: "stalled" } } : reviewSubject, whyNow: stalled ? "Issue is in review without a maintained reviewer, interaction, approval, monitor, run, wake, or recovery path." : pendingApprovalId ? "Issue is in review with a linked pending approval." : hasHumanParticipant ? "Issue is in review and the current execution participant is a user." : "Issue is in review and assigned to a user.", decisionVerbs: stalled ? decisionVerbs( { id: "choose_review_path", label: "Choose review path", description: "Add a reviewer or waiting path, return the issue to work, or accept it." }, { id: "request_changes", label: "Request changes", description: "Return the issue to the assignee with changes requested." }, ) : decisionVerbs( { id: "approve", label: "Approve", description: "Approve the review and advance the issue." }, { id: "request_changes", label: "Request changes", description: "Return the issue to the assignee with changes requested." }, ), inlineResolvable: stalled, entryRule: stalled ? "issues.status = 'in_review' and reviewAttention.state = 'stalled'." : "issues.status = 'in_review' and human reviewer, user assignee, or linked pending approval exists.", exitRule: "Issue leaves in_review or the human review path resolves.", dedupKey, severity: stalled ? "high" : "medium", activityAt: toIso(review.updatedAt), createdAt: toIso(review.createdAt), updatedAt: toIso(review.updatedAt), relatedIssue: null, ...issueContext(issue), detail: genericDetail(review.title, issueImages(reviewImageMap, review.id)), })); } const failedRows = await listAttentionExhaustedRuns(db, companyId); const failedIssueIds = failedRows.map((row) => readRunIssueId(row.contextSnapshot)); const failedAgentIds = [...new Set(failedRows.map((row) => row.agentId))]; const oldestFailedRunCreatedAt = failedRows.reduce((oldest, row) => { if (!oldest || row.createdAt < oldest) return row.createdAt; return oldest; }, null); const [failedIssueMap, failedImageMap, newerRuns] = await Promise.all([ issueSummaryMap( db, companyId, failedIssueIds, readCondition, ), issueImageMap(db, companyId, failedIssueIds, readCondition), oldestFailedRunCreatedAt && failedAgentIds.length > 0 ? db .select({ agentId: heartbeatRuns.agentId, createdAt: heartbeatRuns.createdAt, // Project just the ids readRunIssueId needs; pulling the whole // context_snapshot detoasts megabytes per feed build. runIssueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, runTaskId: sql`${heartbeatRuns.contextSnapshot} ->> 'taskId'`, }) .from(heartbeatRuns) .where(and( eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.agentId, failedAgentIds), gt(heartbeatRuns.createdAt, oldestFailedRunCreatedAt), )) : Promise.resolve([]), ]); const latestRunCreatedAtByKey = new Map(); for (const newerRun of newerRuns) { const newerRunIssueId = readRunIssueId({ issueId: newerRun.runIssueId, taskId: newerRun.runTaskId }); const newerRunKey = `${newerRun.agentId}:${newerRunIssueId ?? ""}`; const latestCreatedAt = latestRunCreatedAtByKey.get(newerRunKey); if (!latestCreatedAt || newerRun.createdAt > latestCreatedAt) { latestRunCreatedAtByKey.set(newerRunKey, newerRun.createdAt); } } for (const run of failedRows) { const issueId = readRunIssueId(run.contextSnapshot); const runKey = `${run.agentId}:${issueId ?? ""}`; const hasNewerRun = (latestRunCreatedAtByKey.get(runKey)?.getTime() ?? 0) > run.createdAt.getTime(); if (hasNewerRun) continue; const issue = issueId ? failedIssueMap.get(issueId) ?? null : null; if (issueId && !issue) continue; const dedupKey = `run:${run.id}`; add(createItem({ companyId, sourceKind: "failed_run", subject: { kind: "run", id: run.id, companyId, title: `${run.agentName} run ${run.status}`, identifier: null, status: run.status, href: `/${prefix}/agents/${run.agentId}/runs/${run.id}`, metadata: { agentId: run.agentId, agentName: run.agentName, issueId, errorCode: run.errorCode, error: run.error, retryExhaustedReason: run.exhaustionMessage, }, }, whyNow: "Run failed after automatic retries were exhausted.", decisionVerbs: decisionVerbs( { id: "retry", label: "Retry", description: "Retry the failed run or issue." }, { id: "reassign", label: "Reassign", description: "Move the work to another owner." }, { id: "dismiss", label: "Dismiss", description: "Dismiss this failed-run attention row." }, ), inlineResolvable: true, entryRule: "latest failed/timed_out run has a Bounded retry exhausted lifecycle event.", exitRule: "A newer run exists for the same issue/agent pair or the row is dismissed.", dedupKey, severity: "high", activityAt: toIso(run.finishedAt ?? run.updatedAt ?? run.createdAt), createdAt: toIso(run.createdAt), updatedAt: toIso(run.updatedAt), relatedIssue: issue ? issueSubject(prefix, issue) : null, ...issueContext(issue), detail: { kind: "failed_run", agentName: run.agentName, failureReasonExcerpt: excerpt(run.error ?? run.exhaustionMessage ?? run.errorCode), images: issueImages(failedImageMap, issueId), }, })); } const budgetOverview = await budgetService(db).overview(companyId); for (const incident of budgetOverview.activeIncidents) { const observedPercent = budgetObservedPercent(incident.amountObserved, incident.amountLimit); if (incident.thresholdType !== "hard" && observedPercent < 85) continue; const dedupKey = `budget:${incident.policyId}:${toIso(incident.windowStart)}:${incident.thresholdType}`; add(createItem({ companyId, sourceKind: "budget_alert", subject: { kind: "budget_incident", id: incident.id, companyId, title: `${incident.scopeName} budget ${incident.thresholdType === "hard" ? "hard stop" : "warning"}`, identifier: null, status: incident.status, href: `/${prefix}/costs`, metadata: { policyId: incident.policyId, scopeType: incident.scopeType, scopeId: incident.scopeId, thresholdType: incident.thresholdType, amountObserved: incident.amountObserved, amountLimit: incident.amountLimit, observedPercent, approvalId: incident.approvalId, approvalStatus: incident.approvalStatus, }, }, whyNow: incident.thresholdType === "hard" ? "Budget hard stop was reached." : "Budget crossed the 85% warning threshold.", decisionVerbs: decisionVerbs( { id: "raise_budget_and_resume", label: "Raise budget", description: "Raise the budget and resume paused work." }, { id: "keep_paused", label: "Keep paused", description: "Dismiss or keep the budget stop in place." }, ), inlineResolvable: true, entryRule: "open budget incident is hard, or soft with observed spend >= 85% of limit.", exitRule: "Budget incident is resolved or dismissed.", dedupKey, severity: incident.thresholdType === "hard" ? "high" : "medium", activityAt: toIso(incident.updatedAt), createdAt: toIso(incident.createdAt), updatedAt: toIso(incident.updatedAt), relatedIssue: null, detail: { kind: "budget", observedPercent, amountObserved: incident.amountObserved, amountLimit: incident.amountLimit, images: [], }, })); } const erroredAgents = await db .select({ id: agents.id, companyId: agents.companyId, name: agents.name, role: agents.role, status: agents.status, errorReason: agents.errorReason, createdAt: agents.createdAt, updatedAt: agents.updatedAt, }) .from(agents) .where(and(eq(agents.companyId, companyId), eq(agents.status, "error"))) .orderBy(desc(agents.updatedAt), desc(agents.id)); for (const agent of erroredAgents) { const dedupKey = `agent_error:${agent.id}`; add(createItem({ companyId, sourceKind: "agent_error_alert", subject: { kind: "agent", id: agent.id, companyId, title: agent.name, identifier: null, status: agent.status, href: `/${prefix}/agents/${agent.id}`, metadata: { role: agent.role, errorReason: agent.errorReason }, }, whyNow: "Agent is in error status and needs operator action or dismissal.", decisionVerbs: decisionVerbs( { id: "inspect", label: "Inspect", description: "Inspect the agent error." }, { id: "dismiss", label: "Dismiss", description: "Dismiss this alert." }, ), inlineResolvable: true, entryRule: "agents.status = 'error'", exitRule: "Agent leaves error status or the row is dismissed.", dedupKey, severity: "high", activityAt: toIso(agent.updatedAt), createdAt: toIso(agent.createdAt), updatedAt: toIso(agent.updatedAt), relatedIssue: null, detail: { kind: "agent_error", agentName: agent.name, failureReasonExcerpt: excerpt(agent.errorReason), images: [], }, })); } const deduped = new Map(); for (const item of collected) { const current = deduped.get(item.dedupKey); deduped.set(item.dedupKey, current ? betterDuplicate(current, item) : item); } const collectedItems = [...deduped.values()].sort(compareAttentionItems); await decisionQueueService(db).materializeSeededQueues(companyId, collectedItems); const enrichedItems = await enrichAttentionItems(db, companyId, collectedItems, now); const activitySince = parseActivityBoundary(options.activitySince, "activitySince"); const activityUntil = parseActivityBoundary(options.activityUntil, "activityUntil"); if (activitySince != null && activityUntil != null && activitySince > activityUntil) { throw badRequest("activitySince must be before or equal to activityUntil"); } const queueKey = options.queue?.trim() || null; const visibleItems = enrichedItems.filter((item) => { if (options.archived === true ? !item.archivedAt : Boolean(item.archivedAt)) return false; if (!includeDismissed && item.snoozedUntil && timestamp(item.snoozedUntil) > now) return false; const activity = timestamp(item.activityAt); if (activitySince != null && activity < activitySince) return false; if (activityUntil != null && activity > activityUntil) return false; if (queueKey && !item.queues.some((queue) => queue.key === queueKey)) return false; return true; }); const sort = options.sort ?? "activity"; if (sort !== "activity" && sort !== "decide") throw badRequest("sort must be 'activity' or 'decide'"); const selectedComparator = sort === "decide" ? (left: AttentionItem, right: AttentionItem) => compareDecideItems(left, right, now) : compareAttentionItems; const rankedItems = orderBlockedAttentionByWeight( visibleItems.sort(selectedComparator), selectedComparator, ) .map((item, index) => ({ ...item, rank: index + 1 })); let items: AttentionItem[]; let nextCursor: string | null; if (options.all) { if (options.cursor || options.limit !== undefined) { throw badRequest("all cannot be combined with cursor or limit"); } items = rankedItems; nextCursor = null; } else { const limit = options.limit ?? ATTENTION_PAGE_DEFAULT_LIMIT; if (!Number.isInteger(limit) || limit < 1 || limit > ATTENTION_PAGE_MAX_LIMIT) { throw badRequest(`limit must be an integer between 1 and ${ATTENTION_PAGE_MAX_LIMIT}`); } let pageStart = 0; if (options.cursor) { const cursorItemId = decodeCursor(options.cursor, sort); const cursorIndex = rankedItems.findIndex((item) => item.id === cursorItemId); if (cursorIndex < 0) throw badRequest("Attention cursor no longer matches the filtered feed"); pageStart = cursorIndex + 1; } items = rankedItems.slice(pageStart, pageStart + limit); const hasNextPage = pageStart + items.length < rankedItems.length; nextCursor = hasNextPage && items.length > 0 ? encodeCursor(sort, items[items.length - 1]!) : null; } if (options.userId) { const trainable: Array<{ sourceKind: "approval" | "interaction"; sourceId: string }> = []; for (const item of items) { if (item.sourceKind === "approval") { trainable.push({ sourceKind: "approval", sourceId: item.subject.id }); } if (item.sourceKind === "issue_thread_interaction") { trainable.push({ sourceKind: "interaction", sourceId: item.subject.id }); } } if (trainable.length > 0) { const examples = await db .select({ id: decisionTrainingExamples.id, sourceKind: decisionTrainingExamples.sourceKind, sourceId: decisionTrainingExamples.sourceId, }) .from(decisionTrainingExamples) .where(and( eq(decisionTrainingExamples.companyId, companyId), eq(decisionTrainingExamples.createdByUserId, options.userId), inArray(decisionTrainingExamples.sourceId, trainable.map((item) => item.sourceId)), )); const exampleBySource = new Map(examples.map((row) => [`${row.sourceKind}:${row.sourceId}`, row.id])); for (const item of items) { const sourceKind = item.sourceKind === "approval" ? "approval" : item.sourceKind === "issue_thread_interaction" ? "interaction" : null; item.trainingExampleId = sourceKind ? exampleBySource.get(`${sourceKind}:${item.subject.id}`) ?? null : null; } } } const countsBySourceKind = emptyCounts(); for (const item of rankedItems) countsBySourceKind[item.sourceKind] += 1; return { companyId, generatedAt: new Date().toISOString(), totalCount: rankedItems.length, // Desk badge: distinct items that surfaced // today OR carry an explicit decide-by deadline due today/past. Counted // over the full ranked set (pre-pagination) so the sidebar badge stays // company-wide accurate even on a small first page. deskBadgeCount: rankedItems.filter((item) => isNewToday(item, now) || isDecideNow(item, now)).length, nextCursor, countsBySourceKind, items, }; }, }; }