mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Needs you is the board inbox. It lists the decisions that wait for a person. > - Agents often ask for the same confirmation again on one task. Needs you shows only the newest pending confirmation for each task. > - When a person approved that newest card (for example in chat), or the card expired, the older card that it replaced came back into Needs you. > - Manual onboarding tests reported this: decision cards that were already approved in chat still showed in Needs you. > - This pull request makes the newest answered or expired confirmation also hide the older pending confirmations on the same task. > - The benefit is that Needs you does not show stale cards after a person answers the current one. ## Linked Issues or Issue Description **What happened?** A person approved a decision card in chat. Needs you still showed a decision card for the same task. Expired cards also caused an older card to show again. **Steps to reproduce** 1. An agent creates a confirmation on a task. Then it creates a second confirmation on the same task. 2. Needs you shows only the second card. 3. Approve the second card in chat (or let it expire). 4. The first card now shows in Needs you. **Expected behavior** Needs you shows no card for the task, because the newest ask is settled. **Root cause** `collapsePendingConfirmationsToNewest` compared only pending rows. Chat cards use the same `issue_thread_interactions` row and the same accept service, so there is no duplicate record. The server already drops answered rows. The problem was the older pending row that the collapse hid before. ## What Changed - `server/src/services/attention-source-eligibility.ts`: the collapse takes the newest settled (accepted, rejected, answered, expired) ordinary confirmation for each issue. Older pending ordinary confirmations stay hidden. A new `loadNewestSettledConfirmationAt` helper reads that value with one grouped query. - Cancelled and failed replacements do not settle the ask, so the older card returns (the existing retention test keeps this behavior). - Tool-action confirmations and quality-review confirmations are excluded from the settled check. A settled tool action never hides a different pending tool action. - `server/src/services/attention.ts` and `server/src/services/attention-retention.ts` use the new input, so the feed and the retention worker agree. - Review follow-up: the summary and task-index payload keeps `toolAction` and `secretProposal`. A settled card only hides older asks addressed to the same recipient. Tool actions, secret proposals and app connections (`connectionAuthorization`) form one governed set. They match the set the other confirmation readers use. A settled governed card never settles an ordinary ask, and a pending governed card is never hidden. - `server/src/__tests__/attention-service.test.ts`: new tests for "approved from chat", "rejected from chat", "expired", a re-ask after a settled card, another recipient, a settled secret proposal, tool actions in `list()`, and tool actions in `summary()`/`taskIndex()`. The tests use two agents, because the service already expires a same-agent re-ask at creation. ## Verification Red on `master` (819420a60a), before the code change: ``` RUN v5.0.3 /Users/dotta/paperclipai/paperclip/.claude/worktrees/fix-n10-stale-needs-you-cards-47c895/server ❯ src/__tests__/attention-service.test.ts (36 tests | 2 failed | 34 skipped) 14165ms ❯ attention service (36) × does not resurface an older confirmation once the newest one is approved from chat 3502ms × does not resurface an older confirmation once the newest one is expired 2097ms ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯ FAIL src/__tests__/attention-service.test.ts > attention service > does not resurface an older confirmation once the newest one is approved from chat AssertionError: expected [ Array(1) ] to deeply equal [] - Expected + Received - [] + [ + "a1f2f085-e4b2-42f1-ac3c-71d85f6e164c", + ] ❯ src/__tests__/attention-service.test.ts:1609:32 1607| } 1608| 1609| expect(await pendingIds()).toEqual([]); | ^ 1610| }); 1611| ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯ FAIL src/__tests__/attention-service.test.ts > attention service > does not resurface an older confirmation once the newest one is expired AssertionError: expected [ Array(1) ] to deeply equal [] - Expected + Received - [] + [ + "8c8d01ad-1084-47b2-927a-24d18529a9a6", + ] ``` Green after the change (same tests): ``` $ npx vitest run src/__tests__/attention-service.test.ts -t "resurface an older confirmation|tool-action approval" ✓ does not resurface an older confirmation once the newest one is approved from chat ✓ does not resurface an older confirmation once the newest one is expired ✓ keeps a pending tool-action approval visible after a newer tool action is settled ``` Review follow-up red (onebe8e0c0ae, before the follow-up code change): ``` × keeps a pending tool-action approval in the summary and task index after a newer ordinary confirmation is settled × does not let another recipient's settled confirmation hide this recipient's pending one × does not let a settled secret proposal hide an older ordinary confirmation AssertionError: expected [] to include '4c636383-…' AssertionError: expected [] to deeply equal [ Array(1) ] ``` All three pass after9245bd7b17. Second review round red (beforea36923c7fc): ``` × does not let a settled app connection hide an older ordinary confirmation AssertionError: expected [] to deeply equal [ Array(1) ] ``` It passes aftera36923c7fc. Neighbouring suites (attention-service, attention-retention-work, issue-review-attention, issue-blocker-attention): ``` Tests 125 passed (125) Test Files 4 passed (4) ``` Staging check (read-only): the ONB staging company had one pending Needs you item, on a task with no newer card. Staging has no Codex company, so I could not reproduce the Codex path there. ## Risks - Low risk. The change is read-only and adds one grouped query when pending confirmations exist. - Older ordinary confirmations from the same agent were already expired at creation. This change affects asks from different agents or users on the same task. - The service already expires an older ordinary confirmation from the same agent at creation. This change affects asks from different agents or users on the same task. - Behavior change: an older ordinary confirmation that is still pending stays out of Needs you after a newer one is answered or expired. It stays visible in the task thread. - Not changed: the live-update listener does not match `issue.interaction.withdrawn` or `issue.interaction_cancelled`. Those cards can stay in the client cache until the next refetch. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2002 lines
81 KiB
TypeScript
2002 lines
81 KiB
TypeScript
import { recoveryActionAttentionCondition, ATTENTION_BUDGET_WARNING_PERCENT, ATTENTION_AGENT_ERROR_STATUS, collapsePendingConfirmationsToNewest, loadNewestSettledConfirmationAt, readRunIssueId, listFailedRunsWithNewerRun, requiresReviewAttention, terminalBlockerRequiresAttention } from "./attention-source-eligibility.js";
|
|
import { approvalExecutionIssueCondition, firstApprovalIssueId } from "./approval-visibility.js";
|
|
import { and, asc, desc, eq, inArray, isNull, notInArray, or, 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,
|
|
AttentionSummary,
|
|
AttentionTaskIndex,
|
|
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 { attentionIssueCondition, 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 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;
|
|
excludeSources?: readonly AttentionSourceKind[];
|
|
/** Count the canonical complete set without card bodies or presentation reads. */
|
|
summaryOnly?: 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, includePresentation = true) {
|
|
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(!includePresentation || 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 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`,
|
|
includeConversations = false,
|
|
) {
|
|
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),
|
|
attentionIssueCondition(includeConversations),
|
|
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;
|
|
}
|
|
|
|
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,
|
|
);
|
|
async function list(companyId: string, options: AttentionListOptions = {}, questionIssueIds?: readonly string[]): 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 summaryOnly = options.summaryOnly === true;
|
|
const loadImages = (...args: Parameters<typeof issueImageMap>) => summaryOnly
|
|
? Promise.resolve(new Map<string, AttentionDetailImage[]>()) : issueImageMap(...args);
|
|
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 });
|
|
};
|
|
|
|
if (!questionIssueIds && !options.excludeSources?.includes("approval")) {
|
|
const pendingApprovals = await db
|
|
.select({
|
|
id: approvals.id,
|
|
linkedIssueId: firstApprovalIssueId(),
|
|
type: approvals.type,
|
|
status: approvals.status,
|
|
requestedByAgentId: approvals.requestedByAgentId,
|
|
requestedByUserId: approvals.requestedByUserId,
|
|
payload: summaryOnly ? sql<Record<string, unknown>>`'{}'::jsonb` : approvals.payload,
|
|
createdAt: approvals.createdAt,
|
|
updatedAt: approvals.updatedAt,
|
|
})
|
|
.from(approvals)
|
|
.where(and(eq(approvals.companyId, companyId), eq(approvals.status, "pending"), approvalExecutionIssueCondition()))
|
|
.orderBy(desc(approvals.updatedAt), desc(approvals.id));
|
|
|
|
const readableApprovalIssues = await issueSummaryMap(
|
|
db,
|
|
companyId,
|
|
pendingApprovals.map((row) => row.linkedIssueId),
|
|
readCondition,
|
|
);
|
|
|
|
for (const approval of pendingApprovals) {
|
|
const linkedIssueId = approval.linkedIssueId ?? 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: approval.linkedIssueId ?? 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: summaryOnly ? sql<Record<string, unknown>>`jsonb_build_object('target', ${issueThreadInteractions.payload}->'target', 'qualityReview', ${issueThreadInteractions.payload}->'qualityReview', 'toolAction', ${issueThreadInteractions.payload}->'toolAction', 'secretProposal', ${issueThreadInteractions.payload}->'secretProposal', 'connectionAuthorization', ${issueThreadInteractions.payload}->'connectionAuthorization')` : 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]),
|
|
questionIssueIds ? inArray(issueThreadInteractions.issueId, [...questionIssueIds]) : undefined,
|
|
questionIssueIds ? eq(issueThreadInteractions.kind, "ask_user_questions") : undefined,
|
|
))
|
|
.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, await loadNewestSettledConfirmationAt(
|
|
db, companyId, boardInteractionRows.filter((row) => row.kind === "request_confirmation").map((row) => row.issueId),
|
|
));
|
|
const [interactionIssueMap, interactionImageMap, interactionPlanDocumentMap] = await Promise.all([
|
|
// A pending human request remains actionable in a persistent chat even
|
|
// though its idle container is intentionally absent from work queues.
|
|
issueSummaryMap(db, companyId, visibleInteractionRows.map((row) => row.issueId), readCondition, true),
|
|
loadImages(db, companyId, visibleInteractionRows.map((row) => row.issueId), readCondition),
|
|
summaryOnly ? Promise.resolve(new Map<string, PlanDocumentSummary>())
|
|
: 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 = summaryOnly ? null : interactionDetail({
|
|
kind: interaction.kind,
|
|
payload,
|
|
issue,
|
|
planDocument: interactionPlanDocumentMap.get(interaction.issueId) ?? null,
|
|
images: issueImages(interactionImageMap, interaction.issueId),
|
|
});
|
|
const isPlanTarget = interaction.kind === "request_confirmation" && isPlanDocumentTarget(payload);
|
|
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,
|
|
),
|
|
}));
|
|
}
|
|
|
|
if (!questionIssueIds) {
|
|
const openDecisionQuery = db.select({
|
|
id: decisions.id,
|
|
bundleId: decisions.bundleId,
|
|
originAgentId: decisions.originAgentId,
|
|
ruleKey: decisions.ruleKey,
|
|
title: decisions.title,
|
|
body: summaryOnly ? sql<string>`''` : 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: [] },
|
|
}));
|
|
}
|
|
|
|
if (!options.excludeSources?.includes("join_request")) {
|
|
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),
|
|
recoveryActionAttentionCondition(),
|
|
))
|
|
.orderBy(desc(issueRecoveryActions.updatedAt), desc(issueRecoveryActions.id));
|
|
const [recoveryIssueMap, recoveryImageMap] = await Promise.all([
|
|
issueSummaryMap(
|
|
db,
|
|
companyId,
|
|
recoveryRows.flatMap((row) => [row.sourceIssueId, row.recoveryIssueId]),
|
|
readCondition,
|
|
),
|
|
loadImages(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),
|
|
loadImages(
|
|
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 (!terminalBlockerRequiresAttention(blockerAttention)) 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),
|
|
loadImages(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 (!requiresReviewAttention({ hasHumanParticipant, hasUserAssignee: Boolean(review.assigneeUserId),
|
|
hasPendingApproval: Boolean(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,
|
|
),
|
|
loadImages(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 < ATTENTION_BUDGET_WARNING_PERCENT) 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, ATTENTION_AGENT_ERROR_STATUS)))
|
|
.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, !summaryOnly);
|
|
|
|
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 countsBySourceKind = emptyCounts();
|
|
for (const item of visibleItems) countsBySourceKind[item.sourceKind] += 1;
|
|
const summary: AttentionSummary = {
|
|
companyId, generatedAt: new Date().toISOString(), totalCount: visibleItems.length,
|
|
deskBadgeCount: visibleItems.filter(item => isNewToday(item, now) || isDecideNow(item, now)).length,
|
|
countsBySourceKind,
|
|
};
|
|
if (summaryOnly) return { ...summary, nextCursor: null, items: visibleItems };
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
return { ...summary, nextCursor, items };
|
|
}
|
|
return {
|
|
list: (companyId: string, options: AttentionListOptions = {}) => list(companyId, options),
|
|
taskQuestions: async (companyId: string, issueIds: readonly string[], options: { actor: AuthorizationActor; userId: string }): Promise<AttentionItem[]> => {
|
|
if (issueIds.length === 0) return [];
|
|
const feed = await list(companyId, { ...options, all: true, allowUnscopedAll: true }, issueIds);
|
|
return feed.items;
|
|
},
|
|
summary: async (companyId: string, options: Pick<AttentionListOptions, "userId" | "actor"> = {}): Promise<AttentionSummary> => {
|
|
const { items: _items, nextCursor: _cursor, ...summary } = await list(companyId, {
|
|
...options, all: true, allowUnscopedAll: true, summaryOnly: true,
|
|
});
|
|
return summary;
|
|
},
|
|
taskIndex: async (companyId: string, options: Pick<AttentionListOptions, "userId" | "actor"> = {}): Promise<AttentionTaskIndex> => {
|
|
const { items, nextCursor: _cursor, ...summary } = await list(companyId, {
|
|
...options, all: true, allowUnscopedAll: true, summaryOnly: true,
|
|
});
|
|
return { ...summary, taskIds: [...new Set(items.flatMap(item => item.relatedIssue
|
|
? [item.relatedIssue.id] : item.subject.kind === "issue" ? [item.subject.id] : []))] };
|
|
},
|
|
};
|
|
}
|