mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 01:27:09 +01:00
## Thinking Path > - Paperclip lets people manage work done by agents. > - Tasks, Inbox, and Needs You must stay usable as work history grows. > - The Coordinator views bypassed some existing collection optimizations. > - Navigation fetched complete decision cards for a count, and Tasks mounted every loaded row. > - This PR uses the canonical attention rules for small count and task-ID responses. It also restores bounded rendering and the optimized Inbox selection. > - The benefit is less data and less initial rendering work without a second definition of pending work. ## Linked Issues or Issue Description **What happened?** Coordinator navigation fetched the full attention feed on every cold collection visit. Tasks rendered the entire loaded collection. Coordinator could also select the old Inbox loader when Streamlined UI was off. **Expected behavior** Fetch only the projection needed by each surface. Start with a bounded set of rows. Keep complete task membership, accurate counts, current permissions, and stable loaded rows. **Steps to reproduce** 1. Enable Coordinator and open Tasks or Inbox with an empty browser query cache. 2. Observe a complete attention-card download for the navigation badge. 3. Open a large task list and observe that every loaded row mounts at once. 4. Disable Streamlined UI while keeping Coordinator enabled, then open Mine and observe three overlapping compact task reads. Related work: #17756, #3744, #17983, and #18312. ## What Changed - Add board-only attention count and complete task-ID projections. Reuse canonical company, privacy, addressee, deduplication, dismissal, snooze, and archive rules. Skip card bodies, plan/image reads, and presentation roster lookup. - Share the small count read across desktop and mobile navigation. Reuse the page-owned response on Needs You and Needs attention. Keep the newest known count through navigation, including zero. - Use the task-ID index for Needs attention and keep task row hydration in batches of at most 100. - Render 100 task rows initially, across the whole expanded tree. Reveal 150 more at a time through scrolling or a button. Keep existing rows through refresh and append. Defer offscreen leaf layout with shared tokens. - Keep Coordinator on the optimized Inbox when Streamlined UI is off. Use index reads for expanded project recents. - Add real-database, component, and production-build browser regression checks. Update the access and feature maps. ## Verification - All required CI checks pass on `1c68ac8957490a6c8585ba0939355f536c8120bf`: typecheck, build, server/workspace tests, serialized database suites, and eight E2E shards. Greptile is **5/5**, with its row-retention finding fixed and the review thread resolved. Storybook checks were intentionally skipped; Paperclip Review completed neutral. - Workspace typecheck and production build passed locally. The final rebased source also passed UI typecheck and production build. Token gates and map checks passed. - The attention service suite passed all 34 tests on disposable PostgreSQL. Final targeted checks cover compact projections, complete-set counts, quality approvals, snoozes, private tasks, and board/company access. Database response capture confirms compact projections do not read plans or images. All 31 OpenAPI tests passed after registering both endpoint contracts. - Six production-build browser journeys passed: desktop, mobile, Coordinator Inbox with Streamlined UI off, Needs You, Needs attention, and an older server page followed by task open/Back. They verify the initial 100-row budget, scrolling, retained DOM identity/height, one owner per attention read, complete task membership, 220 retained rows on Back, and less than 2 px of return-position difference. - The row-budget and Inbox regressions fail against original source and pass with this change. The final three affected UI suites passed 40 tests, and the broader Inbox/IssuesList suites passed 101 tests serially. ### Controlled production-build comparison Baseline `b31ec0a2dc079e32272a8f618425fe92b68bbaec` versus PR head `1c68ac8957490a6c8585ba0939355f536c8120bf`. Isolated baseline asset `index-CS9uPxMx.js`; candidate asset `index-BxJeTQ30.js`. Both UI builds used the same warm disposable API server and database, with 220 tasks and 110 pending board approvals. Chromium **151.0.7922.34**, 1440 × 900 viewport, **4× CPU throttle**, loopback with no added network latency, alternating baseline/candidate order. Cold cases use a new browser context with network cache disabled; warm cases navigate within the same context. Other host workloads are uncontrolled. Readiness requires the destination collection's first visible task row, then two animation frames. Inbox readiness waits for its canonical Mine route so previous-page rows cannot satisfy the measurement. Five paired samples per case; cold Inbox has a second five-pair confirmation wave because the first result was slower. Times below are **milliseconds**; parentheses show min–max. Paired savings are the median of individual before-minus-after pairs, not the difference between medians. | Journey | Pairs | Baseline median (range) | Candidate median (range) | Median paired saving | Faster pairs | | --- | ---: | ---: | ---: | ---: | ---: | | Cold Tasks | 5 | 2752 (2542–3025) | 2300 (2275–2524) | 328 | 5/5 | | Cold Inbox | 10 | 2536 (2283–2927) | 2582 (2117–2856) | 37 | 5/10 | | Warm Tasks navigation | 5 | 2054 (1826–2479) | 1448 (1419–1548) | 635 | 5/5 | | Warm Inbox navigation | 5 | 2020 (1485–2254) | 1568 (1289–1848) | 350 | 5/5 | | Task → Back to Tasks | 5 | 1747 (1609–1948) | 1327 (1238–1355) | 472 | 5/5 | Cold Tasks, warm Tasks, warm Inbox, and Back improved in every measured pair. **Cold Inbox latency is inconclusive:** the two waves pointed in opposite directions, with the candidate faster in 5/10 pairs. This PR claims less badge work there, not a proven cold Inbox latency improvement. The canonical badge response shrank from **200,039 to 359 decoded bytes** (encoded HTTP transfer: **8,125 to 598 bytes**). The compact response also skips card payloads, plans, images, and presentation-only roster work. These are measured mechanism reductions, separate from end-to-end latency. Needs You still owns its full card feed; Needs attention owns its complete task-ID projection. Reproduction setup and assertions are in `tests/perf/collections/README.md`. <details> <summary>Individual paired readiness measurements (ms)</summary> | Journey | Pair | Baseline | Candidate | | --- | ---: | ---: | ---: | | Cold Tasks | 1 | 3025 | 2283 | | Cold Tasks | 2 | 2542 | 2275 | | Cold Tasks | 3 | 2752 | 2424 | | Cold Tasks | 4 | 2783 | 2300 | | Cold Tasks | 5 | 2641 | 2524 | | Cold Inbox | 1 | 2927 | 2702 | | Cold Inbox | 2 | 2422 | 2629 | | Cold Inbox | 3 | 2409 | 2606 | | Cold Inbox | 4 | 2621 | 2769 | | Cold Inbox | 5 | 2539 | 2856 | | Cold Inbox | 6 | 2283 | 2117 | | Cold Inbox | 7 | 2466 | 2558 | | Cold Inbox | 8 | 2674 | 2286 | | Cold Inbox | 9 | 2696 | 2389 | | Cold Inbox | 10 | 2533 | 2183 | | Warm Tasks navigation | 1 | 2479 | 1423 | | Warm Tasks navigation | 2 | 2228 | 1548 | | Warm Tasks navigation | 3 | 2054 | 1419 | | Warm Tasks navigation | 4 | 1826 | 1448 | | Warm Tasks navigation | 5 | 1954 | 1518 | | Warm Inbox navigation | 1 | 2020 | 1661 | | Warm Inbox navigation | 2 | 2198 | 1848 | | Warm Inbox navigation | 3 | 1733 | 1497 | | Warm Inbox navigation | 4 | 2254 | 1568 | | Warm Inbox navigation | 5 | 1485 | 1289 | | Task → Back to Tasks | 1 | 1721 | 1330 | | Task → Back to Tasks | 2 | 1747 | 1275 | | Task → Back to Tasks | 3 | 1948 | 1355 | | Task → Back to Tasks | 4 | 1609 | 1327 | | Task → Back to Tasks | 5 | 1790 | 1238 | </details> ### Failed attempts and limits - The local full repository command hit embedded PostgreSQL startup failures and 16 failures in the untouched native session executor suite; it was stopped. The full local suite is not claimed green. CI on the final revision provides the broad test result. - A concurrent local UI run hit timing failures under host contention. Inbox/IssuesList subsequently passed all 101 tests serially. No tests were weakened to address these timeouts. - CI caught two integration gaps introduced during this work: missing OpenAPI entries for the new endpoints, and an attention fixture incompatible with the routine filters added on master. Both were fixed; the affected 31 OpenAPI and 40 UI tests pass. - The final revision's first CI attempt failed two untouched tests: scheduled-retry batch cancellation and the tool-access audience/archive race. Both failed jobs passed when rerun without changing the revision. Their original failed attempts remain visible in [the workflow history](https://github.com/paperclipai/paperclip/actions/runs/41083379875). - Browser evidence uses synthetic disposable data. This PR is not deployed staging or production verification. ## Risks - New attention sources must preserve canonical eligibility. Both projections call the existing collector, so they share its permissions and complete-set behavior. - The count and task-ID caches must not stand in for full decision cards. Each projection has its own key under the existing attention invalidation prefix. - A row budget can omit descendants if applied per parent. The tests cover one global budget across a large expanded tree, refresh, append, reset, and incremental loading. - Local browser evidence uses synthetic data and is not staging deployment proof. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, tool use, and code execution. The exact backend model deployment ID and context window were not exposed in this session. ## 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 (affected suites; broad-run limitations are listed above) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
2091 lines
84 KiB
TypeScript
2091 lines
84 KiB
TypeScript
import { approvalExecutionIssueCondition, firstApprovalIssueId } from "./approval-visibility.js";
|
|
import { and, asc, desc, eq, gt, inArray, isNotNull, 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 { executionIssueCondition, visibleIssueCondition } 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;
|
|
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 collapsePendingConfirmationsToNewest<T extends {
|
|
id: string;
|
|
issueId: string;
|
|
kind: string;
|
|
createdAt: Date;
|
|
payload: unknown;
|
|
}>(rows: T[]) {
|
|
// Quality gates bind independent outputs; a newer visual approval must not
|
|
// hide a still-pending copy approval (or affect its badge count).
|
|
const independent = (row: T) => Boolean(row.payload && typeof row.payload === "object" && "qualityReview" in row.payload && row.payload.qualityReview);
|
|
const newestByGroup = new Map<string, T>();
|
|
for (const row of rows) {
|
|
if (row.kind !== "request_confirmation" || independent(row)) 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"
|
|
|| independent(row)
|
|
|| 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`,
|
|
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),
|
|
includeConversations
|
|
? or(executionIssueCondition(), and(visibleIssueCondition(), isNotNull(issues.conversationAgentId)))
|
|
: 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,
|
|
);
|
|
const service = {
|
|
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 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 (!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')` : 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([
|
|
// 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,
|
|
),
|
|
}));
|
|
}
|
|
|
|
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),
|
|
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,
|
|
),
|
|
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 (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),
|
|
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 (!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,
|
|
),
|
|
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 < 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, !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 {
|
|
...service,
|
|
summary: async (companyId: string, options: Pick<AttentionListOptions, "userId" | "actor"> = {}): Promise<AttentionSummary> => {
|
|
const { items: _items, nextCursor: _cursor, ...summary } = await service.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 service.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] : []))] };
|
|
},
|
|
};
|
|
}
|