Files
PaperClipAI/server/src/services/attention.ts
T
DottaandPaperclip 3a726e676f feat: enforce private task permissions across execution and data (#10633)
Enforce private task and project access across direct reads, search, execution, files, plugins, live delivery, and sharing mutations. Preserve downward-only sharing, current responsible-user authorization, and audited emergency access. Bind historical draft assets with migration 0314.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-07 07:06:40 -05:00

2037 lines
81 KiB
TypeScript

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<AttentionSeverity, number> = {
critical: 0,
high: 1,
medium: 2,
low: 3,
};
const SOURCE_RANK: Record<AttentionSourceKind, number> = {
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<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;
}
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<IssueSummaryRow, "project" | "workspace">;
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<AttentionSourceKind, number> {
return Object.fromEntries(ATTENTION_SOURCE_KINDS.map((kind) => [kind, 0])) as Record<AttentionSourceKind, number>;
}
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<string, DismissalState>,
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<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
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<string, unknown>) {
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<string, AttentionDetailImage[]>, 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<string, unknown>): AttentionItemDetail {
return {
kind: "approval",
approvalType: type,
summaryExcerpt: excerpt(payload.summary ?? payload.title ?? payload.recommendedAction),
images: [],
};
}
function interactionDetail(input: {
kind: string;
payload: Record<string, unknown>;
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<IssueSubjectRow, "id" | "identifier">) {
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<AttentionItem,
| "id"
| "dismissalKey"
| "rank"
| "dismissal"
| "project"
| "workspace"
| "expiresAt"
| "ruleKey"
| "originAgentName"
| "queues"
| "shelf"
| "retentionDays"
| "keep"
| "archivedAt"
| "retentionVersion"
| "decideBy"
| "decideByAttribution"
| "snoozedUntil"
| "detail"
| "trainingExampleId"
> & {
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<string, unknown>;
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<string, AttentionQueueRef[]>();
const retentionDaysBySource = new Map<string, number[]>();
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<string, unknown>) {
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<string, unknown>) {
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<T extends {
id: string;
issueId: string;
kind: string;
createdAt: Date;
}>(rows: T[]) {
const newestByGroup = new Map<string, T>();
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<string, DismissalState>();
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<string | null | undefined>,
readCondition: SQL<boolean> = sql<boolean>`true`,
) {
const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))];
if (ids.length === 0) return new Map<string, IssueSummaryRow>();
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<string | null | undefined>,
readCondition: SQL<boolean> = sql<boolean>`true`,
) {
const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))];
if (ids.length === 0) return new Map<string, AttentionDetailImage[]>();
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<string, AttentionDetailImage[]>();
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<string | null | undefined>,
readCondition: SQL<boolean> = sql<boolean>`true`,
) {
const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))];
if (ids.length === 0) return new Map<string, PlanDocumentSummary>();
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<string | null | undefined>,
readCondition: SQL<boolean> = sql<boolean>`true`,
) {
const ids = [...new Set(blockedIssueIds.filter((value): value is string => Boolean(value)))];
if (ids.length === 0) return new Map<string, BlockingIssueSummary>();
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<string, BlockingIssueSummary>();
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<string>()]));
if (rootIds.length === 0) return new Map<string, number>();
const loadEdges = async (fromIssueIds: string[], includeChildren: boolean) => {
const rows: BlockedWorkEdge[] = [];
for (const chunk of chunkValues(fromIssueIds, ATTENTION_GRAPH_QUERY_CHUNK_SIZE)) {
const dependentRowsPromise: Promise<BlockedWorkEdge[]> = 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<BlockedWorkEdge[]> = 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<string, Set<string>>();
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<string>();
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<string, Set<string>>();
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<string>();
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<string, unknown> | 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<AttentionFeed> => {
if (options.all && !options.queue && !options.allowUnscopedAll) {
throw badRequest("all requires a queue filter");
}
const readCondition = options.actor
? await issueReadSqlCondition(db, options.actor)
: sql<boolean>`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<string, string>();
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<string, string>();
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<string, {
issue: BlockedAttentionIssue;
issueSummary: IssueSummaryRow | null;
terminalSummary: IssueSummaryRow | IssueSubjectRow;
state: "stalled" | "needs_attention";
}>();
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<Date | null>((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<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`,
runTaskId: sql<string | null>`${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<string, Date>();
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<string, AttentionItem>();
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,
};
},
};
}