Files
PaperClipAI/server/src/services/attention.ts
T
Naeem AkhtarandPaperclip 2a45fd95df Reduce newer-run history reads for attention (#15697)
Replace broad newer-run responses with bounded company-scoped existence probes. Read keyless history once per unique agent and preserve existing matching and millisecond rules. Keep all stored run data.

Verify with 31 real PostgreSQL attention tests, workspace typecheck, server build, response-budget counterfactuals, full green CI, and Apex 5/5 review.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 14:15:03 -07:00

2069 lines
82 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;
}
type FailedRunProbe = { id: string; agentId: string; issueId: string | null; createdAt: Date };
const FAILED_RUN_PROBE_BATCH_SIZE = 1000;
async function listFailedRunsWithNewerRun(db: Db, companyId: string, failedRuns: FailedRunProbe[]) {
const withNewerRun = new Set<string>();
const keyed = failedRuns.filter(run => run.issueId !== null);
for (let start = 0; start < keyed.length; start += FAILED_RUN_PROBE_BATCH_SIZE) {
const batch = keyed.slice(start, start + FAILED_RUN_PROBE_BATCH_SIZE);
for (const id of await listFailedRunBatchWithNewerRun(db, companyId, batch)) withNewerRun.add(id);
}
const unkeyed = failedRuns.filter(run => run.issueId === null);
if (unkeyed.length === 0) return withNewerRun;
const agentIds = [...new Set(unkeyed.map(run => run.agentId))];
const oldestCreatedAt = unkeyed.reduce((oldest, run) => run.createdAt < oldest ? run.createdAt : oldest,
unkeyed[0]!.createdAt);
const latestByAgent = new Map<string, Date>();
for (let start = 0; start < agentIds.length; start += FAILED_RUN_PROBE_BATCH_SIZE) {
const rows = await db.select({
agentId: heartbeatRuns.agentId,
createdAt: sql<Date>`max(${heartbeatRuns.createdAt})`.mapWith(heartbeatRuns.createdAt),
}).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, companyId),
inArray(heartbeatRuns.agentId, agentIds.slice(start, start + FAILED_RUN_PROBE_BATCH_SIZE)),
gt(heartbeatRuns.createdAt, oldestCreatedAt),
sql`coalesce(${heartbeatRuns.contextSnapshot} ->> 'issueId', ${heartbeatRuns.contextSnapshot} ->> 'taskId', '') = ''`,
)).groupBy(heartbeatRuns.agentId);
for (const row of rows) latestByAgent.set(row.agentId, row.createdAt);
}
for (const run of unkeyed) {
if ((latestByAgent.get(run.agentId)?.getTime() ?? 0) > run.createdAt.getTime()) withNewerRun.add(run.id);
}
return withNewerRun;
}
async function listFailedRunBatchWithNewerRun(db: Db, companyId: string, failedRuns: FailedRunProbe[]) {
if (failedRuns.length === 0) return [];
const failed = sql.join(
failedRuns.map((run) => sql`(${run.id}::uuid, ${run.agentId}::uuid, ${run.issueId}::text, ${new Date(run.createdAt.getTime() + 1).toISOString()}::timestamptz)`),
sql`, `,
);
const runIssueId = sql`(${heartbeatRuns.contextSnapshot} ->> 'issueId')`;
const runTaskId = sql`(${heartbeatRuns.contextSnapshot} ->> 'taskId')`;
const newerRunExists = (match: SQL) => sql`exists (
select 1 from ${heartbeatRuns}
where ${heartbeatRuns.companyId} = ${companyId}
and ${heartbeatRuns.agentId} = failed.agent_id
and ${heartbeatRuns.createdAt} >= failed.newer_from
and ${match}
)`;
const rows = await db.execute(sql`
select failed.id
from (values ${failed}) as failed (id, agent_id, issue_id, newer_from)
where ${newerRunExists(sql`${runIssueId} = failed.issue_id`)}
or ${newerRunExists(sql`${runTaskId} = failed.issue_id and ${runIssueId} is null`)}
`) as unknown as Array<{ id: string }>;
return rows.map((row) => row.id);
}
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 [failedIssueMap, failedImageMap, failedRunsWithNewerRun] = await Promise.all([
issueSummaryMap(
db,
companyId,
failedIssueIds,
readCondition,
),
issueImageMap(db, companyId, failedIssueIds, readCondition),
listFailedRunsWithNewerRun(db, companyId, failedRows.map((row, index) => ({
id: row.id,
agentId: row.agentId,
issueId: failedIssueIds[index],
createdAt: row.createdAt,
}))),
]);
for (const run of failedRows) {
const issueId = readRunIssueId(run.contextSnapshot);
if (failedRunsWithNewerRun.has(run.id)) 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,
};
},
};
}