Files
PaperClipAI/server/src/services/decision-queues.ts
T
0a09e4d975 feat(decisions): add desk workflow and retention (#10672)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The Decisions desk shows work that needs a human decision
> - The queue foundation can group and rank decision work
> - Operators also need a focused daily view and a safe way to handle
old work
> - This pull request adds the desk controls, the aging shelf, and
reversible retention
> - It also binds bulk archive decisions to the exact reviewed item set
> - The benefit is a smaller daily queue without lost or orphaned work

## Linked Issues or Issue Description

**Subsystem affected**

Cross-cutting: server, UI, database, and shared contracts.

**Problem or motivation**

Decision work can grow into one large company list. Operators need quick
queue and date controls. Old items also need a safe retention path that
does not delete work.

**Proposed solution**

Add queue and date controls to the Decisions desk. Compute the aging
shelf on the server. Archive idle items after 90 days unless an operator
keeps them. Keep archived items searchable and revivable. Notify origin
agents in one batch per sweep. Bind bulk archive proposals to a signed,
exact item manifest.

**Alternatives considered**

Client-only aging can drift across browsers and source kinds. Deleting
old rows removes audit and recovery paths. An unsigned dynamic bulk
query can archive items that the reviewer did not inspect.

**Roadmap alignment**

This work supports the Work Queues and decision-memory directions in
`ROADMAP.md`. It extends the Decisions and attention-feed foundation
from #10651. Related earlier work includes #9380, #10010, and #10474.

## What Changed

- Added the queue rail, date chips, decide split, triage strip, queue
page, and aging shelf UI.
- Added server-owned shelf state with per-queue retention overrides.
- Added reversible retention state, archive history, and an idempotent
notification outbox.
- Added the 90-day archive sweeper, Keep exemption, archived feed query,
and revive actions.
- Added one origin-agent notification per agent and sweep.
- Added signed bulk archive proposals with exact-set and version checks.
- Persisted queue-exclusion reasons atomically and kept cross-domain
source resolution per-item until it has an exact-set transaction
contract.
- Added API contracts, OpenAPI entries, migration coverage, focused
tests, and Storybook screens.

## Verification

- `pnpm -r typecheck`
- `pnpm test:run` (server: 329 files and 3,453 tests passed; UI: 408
files and 3,362 tests passed)
- `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY pnpm exec vitest
run cli/src/__tests__/secrets.test.ts`
- `pnpm build`
- `pnpm check:token-gates`
- Focused retention, attention, decisions, migration replay, startup,
and UI API tests.
- Complete queue snapshot regression with 51 items across the normal
50-item page boundary.

The unmodified CLI test reports one warning assertion in this runtime
because the harness injects static AWS credential variables. The
isolated test passes when those two variables are removed.

## Risks

- The migration adds retention and notification outbox tables. It uses
idempotent table, index, and foreign-key creation.
- Retention runs on the heartbeat scheduler interval. Compare-and-set
version checks prevent stale archive writes.
- Bulk archive acceptance fails closed when authority, activity,
version, or the reviewed set changes.
- Archive is reversible and does not delete source records.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex with `gpt-5.6-sol`. The model used tool calls, code
execution, database migration generation, and test execution. The
context-window size is not exposed in this runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [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: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-02 10:47:03 -05:00

782 lines
30 KiB
TypeScript

import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
approvals,
budgetIncidents,
decisionQueueItems,
decisionQueues,
decisions,
decisionTriage,
decisionTriageEvents,
heartbeatRuns,
issueApprovals,
issueRecoveryActions,
issueThreadInteractions,
issueWorkProducts,
issues,
joinRequests,
} from "@paperclipai/db";
import type {
AttentionItem,
AttentionSourceKind,
DecisionQueue,
DecisionQueueItem,
DecisionQueueSeedRule,
DecisionTriage,
} from "@paperclipai/shared";
import { notFound, unprocessable } from "../errors.js";
import { logActivity } from "./activity-log.js";
import {
authorizationService,
type AuthorizationActor,
type AuthorizationResource,
} from "./authorization.js";
export type DecisionMutationActor = {
actorType: "agent" | "user" | "system";
actorId: string;
agentId: string | null;
userId: string | null;
runId: string | null;
agentApiKeyId: string | null;
responsibleUserId: string | null;
};
type SeedDefinition = {
key: string;
title: string;
description: string;
rules: DecisionQueueSeedRule[];
};
export const DECISION_QUEUE_SEEDS: readonly SeedDefinition[] = [
{
key: "prs",
title: "PRs",
description: "Pull-request and merge decisions detected from issue work products.",
rules: [{
key: "issue-pull-request-work-product",
signal: "issue_has_pull_request_work_product",
description: "Attention items whose issue has a pull_request work product.",
}],
},
{
key: "plans",
title: "Plans",
description: "Plan revisions waiting for confirmation.",
rules: [{
key: "plan-document-confirmation",
signal: "plan_document_confirmation",
description: "Pending request_confirmation interactions bound to the issue's plan document.",
}],
},
{
key: "questions",
title: "Questions",
description: "Structured questions waiting for a board response.",
rules: [{
key: "ask-user-questions",
signal: "ask_user_questions",
description: "Pending ask_user_questions interactions.",
}],
},
] as const;
const SYSTEM_ACTOR: DecisionMutationActor = {
actorType: "system",
actorId: "decision_queue_seed",
agentId: null,
userId: null,
runId: null,
agentApiKeyId: null,
responsibleUserId: null,
};
function creatorColumns(actor: DecisionMutationActor) {
return {
createdByType: actor.actorType,
createdByAgentId: actor.agentId,
createdByUserId: actor.userId,
createdByRunId: actor.runId,
createdByAgentApiKeyId: actor.agentApiKeyId,
};
}
function addedByColumns(actor: DecisionMutationActor) {
return {
addedByType: actor.actorType,
addedByAgentId: actor.agentId,
addedByUserId: actor.userId,
addedByRunId: actor.runId,
addedByAgentApiKeyId: actor.agentApiKeyId,
};
}
function eventActorColumns(actor: DecisionMutationActor) {
return {
actorType: actor.actorType,
actorAgentId: actor.agentId,
actorUserId: actor.userId,
actorRunId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
responsibleUserId: actor.responsibleUserId,
};
}
function toQueue(row: typeof decisionQueues.$inferSelect, itemCount: number): DecisionQueue {
return {
id: row.id,
companyId: row.companyId,
key: row.key,
title: row.title,
description: row.description ?? null,
createdByType: row.createdByType as DecisionQueue["createdByType"],
createdByAgentId: row.createdByAgentId ?? null,
createdByUserId: row.createdByUserId ?? null,
createdByRunId: row.createdByRunId ?? null,
retentionDays: row.retentionDays ?? null,
seedRules: row.seedRules ?? [],
seedRulesEnabled: row.seedRulesEnabled,
itemCount,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function toQueueItem(row: typeof decisionQueueItems.$inferSelect): DecisionQueueItem {
return {
id: row.id,
companyId: row.companyId,
queueId: row.queueId,
sourceKind: row.sourceKind as AttentionSourceKind,
sourceId: row.sourceId,
addedByType: row.addedByType as DecisionQueueItem["addedByType"],
addedByAgentId: row.addedByAgentId ?? null,
addedByUserId: row.addedByUserId ?? null,
addedByRunId: row.addedByRunId ?? null,
responsibleUserId: row.responsibleUserId ?? null,
createdAt: row.createdAt,
};
}
function toTriage(row: typeof decisionTriage.$inferSelect): DecisionTriage {
return {
id: row.id,
companyId: row.companyId,
sourceKind: row.sourceKind as AttentionSourceKind,
sourceId: row.sourceId,
decideBy: row.decideBy === "date" ? row.decideByDate : row.decideBy,
snoozedUntil: row.snoozedUntil ?? null,
setByType: row.setByType as DecisionTriage["setByType"],
setByAgentId: row.setByAgentId ?? null,
setByUserId: row.setByUserId ?? null,
setByRunId: row.setByRunId ?? null,
responsibleUserId: row.responsibleUserId ?? null,
version: row.version,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
async function loadIssueResource(db: Db, companyId: string, issueId: string): Promise<AuthorizationResource | null> {
return db
.select({
issueId: issues.id,
companyId: issues.companyId,
projectId: issues.projectId,
parentIssueId: issues.parentId,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
originKind: issues.originKind,
originId: issues.originId,
status: issues.status,
})
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.id, issueId), isNull(issues.hiddenAt)))
.then((rows) => rows[0] ? ({ type: "issue", ...rows[0] } as const) : null);
}
async function sourceIssueId(
db: Db,
companyId: string,
sourceKind: AttentionSourceKind,
sourceId: string,
): Promise<{ exists: boolean; issueId: string | null; ownerAgentId?: string | null; agentId?: string | null }> {
switch (sourceKind) {
case "approval": {
const row = await db.select({ id: approvals.id, issueId: issueApprovals.issueId })
.from(approvals)
.leftJoin(issueApprovals, and(
eq(issueApprovals.companyId, companyId),
eq(issueApprovals.approvalId, approvals.id),
))
.where(and(eq(approvals.companyId, companyId), eq(approvals.id, sourceId)))
.then((rows) => rows[0] ?? null);
return { exists: Boolean(row), issueId: row?.issueId ?? null };
}
case "decision": {
const row = await db.select({ issueId: decisions.originIssueId })
.from(decisions)
.where(and(eq(decisions.companyId, companyId), eq(decisions.id, sourceId)))
.then((rows) => rows[0] ?? null);
return { exists: Boolean(row), issueId: row?.issueId ?? null };
}
case "issue_thread_interaction": {
const row = await db.select({ issueId: issueThreadInteractions.issueId })
.from(issueThreadInteractions)
.where(and(eq(issueThreadInteractions.companyId, companyId), eq(issueThreadInteractions.id, sourceId)))
.then((rows) => rows[0] ?? null);
return { exists: Boolean(row), issueId: row?.issueId ?? null };
}
case "recovery_action": {
const row = await db.select({ issueId: issueRecoveryActions.sourceIssueId })
.from(issueRecoveryActions)
.where(and(eq(issueRecoveryActions.companyId, companyId), eq(issueRecoveryActions.id, sourceId)))
.then((rows) => rows[0] ?? null);
return { exists: Boolean(row), issueId: row?.issueId ?? null };
}
case "productivity_review":
case "blocker_attention":
case "review": {
const row = await db.select({ id: issues.id })
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.id, sourceId), isNull(issues.hiddenAt)))
.then((rows) => rows[0] ?? null);
return { exists: Boolean(row), issueId: row?.id ?? null };
}
case "failed_run": {
const row = await db.select({ agentId: heartbeatRuns.agentId, contextSnapshot: heartbeatRuns.contextSnapshot })
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, sourceId)))
.then((rows) => rows[0] ?? null);
const snapshot = row?.contextSnapshot && typeof row.contextSnapshot === "object"
? row.contextSnapshot as Record<string, unknown>
: {};
const issueId = typeof snapshot.issueId === "string"
? snapshot.issueId
: typeof snapshot.taskId === "string" ? snapshot.taskId : null;
return { exists: Boolean(row), issueId, ownerAgentId: row?.agentId ?? null };
}
case "agent_error_alert": {
const row = await db.select({ id: agents.id })
.from(agents)
.where(and(eq(agents.companyId, companyId), eq(agents.id, sourceId)))
.then((rows) => rows[0] ?? null);
return { exists: Boolean(row), issueId: null, agentId: row?.id ?? null };
}
case "join_request": {
const row = await db.select({ id: joinRequests.id })
.from(joinRequests)
.where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.id, sourceId)))
.then((rows) => rows[0] ?? null);
return { exists: Boolean(row), issueId: null };
}
case "budget_alert": {
const row = await db.select({ id: budgetIncidents.id })
.from(budgetIncidents)
.where(and(eq(budgetIncidents.companyId, companyId), eq(budgetIncidents.id, sourceId)))
.then((rows) => rows[0] ?? null);
return { exists: Boolean(row), issueId: null };
}
}
}
export async function canReadDecisionSource(
db: Db,
actor: AuthorizationActor,
companyId: string,
sourceKind: AttentionSourceKind,
sourceId: string,
) {
const source = await sourceIssueId(db, companyId, sourceKind, sourceId);
if (!source.exists) return false;
const authz = authorizationService(db);
if (source.issueId) {
const resource = await loadIssueResource(db, companyId, source.issueId);
if (!resource) return false;
return (await authz.decide({ actor, action: "issue:read", resource })).allowed;
}
if (sourceKind === "agent_error_alert" && source.agentId) {
return (await authz.decide({
actor,
action: "agent:read",
resource: { type: "agent", companyId, agentId: source.agentId },
})).allowed;
}
if (sourceKind === "failed_run" && actor.type === "agent") {
return source.ownerAgentId === actor.agentId;
}
// Join requests, unlinked approvals, and budget incidents are board-only
// governance data. Same-company existence is deliberately not authority.
if (actor.type !== "board") return false;
return (await authz.decide({
actor,
action: "company_scope:read",
resource: { type: "company", companyId },
})).allowed;
}
async function requireSourceRead(
db: Db,
actor: AuthorizationActor,
companyId: string,
sourceKind: AttentionSourceKind,
sourceId: string,
) {
if (!(await canReadDecisionSource(db, actor, companyId, sourceKind, sourceId))) {
throw notFound("Attention source not found");
}
}
async function recordActivity(
db: Db,
actor: DecisionMutationActor,
input: { companyId: string; action: string; entityType: string; entityId: string; details?: Record<string, unknown> },
) {
await logActivity(db, {
companyId: input.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
responsibleUserIdOverride: actor.responsibleUserId,
action: input.action,
entityType: input.entityType,
entityId: input.entityId,
details: input.details ?? null,
});
}
function itemIssueId(item: AttentionItem) {
if (item.subject.kind === "issue") return item.subject.id;
const related = item.relatedIssue?.kind === "issue" ? item.relatedIssue.id : null;
if (related) return related;
const metadataIssueId = item.subject.metadata?.issueId ?? item.subject.metadata?.originIssueId;
return typeof metadataIssueId === "string" ? metadataIssueId : null;
}
export function decisionQueueService(db: Db) {
async function getQueue(companyId: string, key: string) {
return db.select().from(decisionQueues)
.where(and(eq(decisionQueues.companyId, companyId), eq(decisionQueues.key, key)))
.then((rows) => rows[0] ?? null);
}
async function visibleItems(companyId: string, queueId: string, authActor: AuthorizationActor) {
const rows = await db.select().from(decisionQueueItems)
.where(and(eq(decisionQueueItems.companyId, companyId), eq(decisionQueueItems.queueId, queueId)))
.orderBy(desc(decisionQueueItems.createdAt), desc(decisionQueueItems.id));
const visible: DecisionQueueItem[] = [];
for (const row of rows) {
if (await canReadDecisionSource(db, authActor, companyId, row.sourceKind as AttentionSourceKind, row.sourceId)) {
visible.push(toQueueItem(row));
}
}
return visible;
}
return {
create: async (input: {
companyId: string;
key: string;
title: string;
description?: string | null;
retentionDays?: number | null;
authActor: AuthorizationActor;
actor: DecisionMutationActor;
}) => {
const result = await db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const inserted = await txDb.insert(decisionQueues).values({
companyId: input.companyId,
key: input.key,
title: input.title,
description: input.description ?? null,
retentionDays: input.retentionDays ?? null,
...creatorColumns(input.actor),
}).onConflictDoNothing({ target: [decisionQueues.companyId, decisionQueues.key] }).returning();
const row = inserted[0] ?? await txDb.select().from(decisionQueues)
.where(and(eq(decisionQueues.companyId, input.companyId), eq(decisionQueues.key, input.key)))
.then((rows) => rows[0] ?? null);
if (!row) throw new Error("Decision queue create did not return a row");
if (inserted[0]) {
await txDb.insert(decisionTriageEvents).values({
companyId: input.companyId,
queueId: row.id,
action: "queue.created",
...eventActorColumns(input.actor),
details: { key: row.key },
});
await recordActivity(txDb, input.actor, {
companyId: input.companyId,
action: "decision_queue.created",
entityType: "decision_queue",
entityId: row.id,
details: { key: row.key },
});
}
return { row, created: Boolean(inserted[0]) };
});
const itemCount = result.created
? 0
: (await visibleItems(input.companyId, result.row.id, input.authActor)).length;
return { queue: toQueue(result.row, itemCount), created: result.created };
},
list: async (companyId: string, authActor: AuthorizationActor) => {
const rows = await db.select().from(decisionQueues)
.where(eq(decisionQueues.companyId, companyId))
.orderBy(desc(decisionQueues.updatedAt), desc(decisionQueues.id));
const result: DecisionQueue[] = [];
for (const row of rows) {
const items = await visibleItems(companyId, row.id, authActor);
result.push(toQueue(row, items.length));
}
return result;
},
update: async (input: {
companyId: string;
key: string;
patch: { title?: string; description?: string | null; retentionDays?: number | null; seedRulesEnabled?: boolean };
authActor: AuthorizationActor;
actor: DecisionMutationActor;
}) => {
const row = await db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const updated = await txDb.update(decisionQueues).set({ ...input.patch, updatedAt: new Date() })
.where(and(eq(decisionQueues.companyId, input.companyId), eq(decisionQueues.key, input.key)))
.returning().then((rows) => rows[0] ?? null);
if (!updated) throw notFound("Decision queue not found");
await txDb.insert(decisionTriageEvents).values({
companyId: input.companyId,
queueId: updated.id,
action: "queue.updated",
...eventActorColumns(input.actor),
details: { fields: Object.keys(input.patch) },
});
await recordActivity(txDb, input.actor, {
companyId: input.companyId,
action: "decision_queue.updated",
entityType: "decision_queue",
entityId: updated.id,
details: { key: updated.key, fields: Object.keys(input.patch) },
});
return updated;
});
return toQueue(row, (await visibleItems(input.companyId, row.id, input.authActor)).length);
},
listItems: async (companyId: string, key: string, authActor: AuthorizationActor) => {
const queue = await getQueue(companyId, key);
if (!queue) throw notFound("Decision queue not found");
return visibleItems(companyId, queue.id, authActor);
},
addItem: async (input: {
companyId: string;
key: string;
sourceKind: AttentionSourceKind;
sourceId: string;
authActor: AuthorizationActor;
actor: DecisionMutationActor;
}) => {
const queue = await getQueue(input.companyId, input.key);
if (!queue) throw notFound("Decision queue not found");
await requireSourceRead(db, input.authActor, input.companyId, input.sourceKind, input.sourceId);
return db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const inserted = await txDb.insert(decisionQueueItems).values({
companyId: input.companyId,
queueId: queue.id,
sourceKind: input.sourceKind,
sourceId: input.sourceId,
responsibleUserId: input.actor.responsibleUserId,
...addedByColumns(input.actor),
}).onConflictDoNothing({
target: [decisionQueueItems.queueId, decisionQueueItems.sourceKind, decisionQueueItems.sourceId],
}).returning();
const row = inserted[0] ?? await txDb.select().from(decisionQueueItems).where(and(
eq(decisionQueueItems.queueId, queue.id),
eq(decisionQueueItems.sourceKind, input.sourceKind),
eq(decisionQueueItems.sourceId, input.sourceId),
)).then((rows) => rows[0] ?? null);
if (!row) throw new Error("Decision queue item create did not return a row");
if (inserted[0]) {
await txDb.update(decisionQueues).set({ updatedAt: new Date() })
.where(and(eq(decisionQueues.id, queue.id), eq(decisionQueues.companyId, input.companyId)));
await txDb.insert(decisionTriageEvents).values({
companyId: input.companyId,
queueId: queue.id,
sourceKind: input.sourceKind,
sourceId: input.sourceId,
action: "queue_item.added",
...eventActorColumns(input.actor),
});
await recordActivity(txDb, input.actor, {
companyId: input.companyId,
action: "decision_queue_item.added",
entityType: "decision_queue",
entityId: queue.id,
details: { sourceKind: input.sourceKind, sourceId: input.sourceId },
});
}
return { item: toQueueItem(row), created: Boolean(inserted[0]) };
});
},
removeItem: async (input: {
companyId: string;
key: string;
sourceKind: AttentionSourceKind;
sourceId: string;
reason?: string;
authActor: AuthorizationActor;
actor: DecisionMutationActor;
}) => {
const queue = await getQueue(input.companyId, input.key);
if (!queue) throw notFound("Decision queue not found");
const sourceReadable = await canReadDecisionSource(
db,
input.authActor,
input.companyId,
input.sourceKind,
input.sourceId,
);
// Board operators may clean up an existing sidecar after its source has
// disappeared. Agents still need source-level read authority so removal
// cannot be used to probe hidden membership.
if (!sourceReadable && input.authActor.type !== "board") {
throw notFound("Attention source not found");
}
return db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const removed = await txDb.delete(decisionQueueItems).where(and(
eq(decisionQueueItems.companyId, input.companyId),
eq(decisionQueueItems.queueId, queue.id),
eq(decisionQueueItems.sourceKind, input.sourceKind),
eq(decisionQueueItems.sourceId, input.sourceId),
)).returning().then((rows) => rows[0] ?? null);
if (!removed) throw notFound("Decision queue item not found");
await txDb.update(decisionQueues).set({ updatedAt: new Date() })
.where(and(eq(decisionQueues.id, queue.id), eq(decisionQueues.companyId, input.companyId)));
await txDb.insert(decisionTriageEvents).values({
companyId: input.companyId,
queueId: queue.id,
sourceKind: input.sourceKind,
sourceId: input.sourceId,
action: "queue_item.removed",
details: input.reason ? { reason: input.reason } : {},
...eventActorColumns(input.actor),
});
await recordActivity(txDb, input.actor, {
companyId: input.companyId,
action: "decision_queue_item.removed",
entityType: "decision_queue",
entityId: queue.id,
details: {
sourceKind: input.sourceKind,
sourceId: input.sourceId,
...(input.reason ? { reason: input.reason } : {}),
},
});
return toQueueItem(removed);
});
},
getTriage: async (
companyId: string,
sourceKind: AttentionSourceKind,
sourceId: string,
authActor: AuthorizationActor,
) => {
await requireSourceRead(db, authActor, companyId, sourceKind, sourceId);
const row = await db.select().from(decisionTriage).where(and(
eq(decisionTriage.companyId, companyId),
eq(decisionTriage.sourceKind, sourceKind),
eq(decisionTriage.sourceId, sourceId),
)).then((rows) => rows[0] ?? null);
return row ? toTriage(row) : null;
},
updateTriage: async (input: {
companyId: string;
sourceKind: AttentionSourceKind;
sourceId: string;
decideBy?: string | null;
snoozedUntil?: string | null;
authActor: AuthorizationActor;
actor: DecisionMutationActor;
}) => {
await requireSourceRead(db, input.authActor, input.companyId, input.sourceKind, input.sourceId);
return db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const lockKey = `decision-triage:${input.companyId}:${input.sourceKind}:${input.sourceId}`;
await txDb.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
const current = await txDb.select().from(decisionTriage).where(and(
eq(decisionTriage.companyId, input.companyId),
eq(decisionTriage.sourceKind, input.sourceKind),
eq(decisionTriage.sourceId, input.sourceId),
)).then((rows) => rows[0] ?? null);
const decideByDate = input.decideBy && /^\d{4}-\d{2}-\d{2}$/.test(input.decideBy) ? input.decideBy : null;
const decideBy = input.decideBy === undefined
? current?.decideBy ?? null
: decideByDate ? "date" : input.decideBy;
const finalDecideByDate = input.decideBy === undefined ? current?.decideByDate ?? null : decideByDate;
const snoozedUntil = input.snoozedUntil === undefined
? current?.snoozedUntil ?? null
: input.snoozedUntil === null ? null : new Date(input.snoozedUntil);
if (snoozedUntil && snoozedUntil.getTime() > Date.now() + 5 * 366 * 24 * 60 * 60 * 1_000) {
throw unprocessable("snoozedUntil must be within five years");
}
const now = new Date();
const values = {
decideBy,
decideByDate: finalDecideByDate,
snoozedUntil,
setByType: input.actor.actorType as "agent" | "user",
setByAgentId: input.actor.agentId,
setByUserId: input.actor.userId,
setByRunId: input.actor.runId,
setByAgentApiKeyId: input.actor.agentApiKeyId,
responsibleUserId: input.actor.responsibleUserId,
version: (current?.version ?? 0) + 1,
updatedAt: now,
};
const row = await txDb.insert(decisionTriage).values({
companyId: input.companyId,
sourceKind: input.sourceKind,
sourceId: input.sourceId,
...values,
}).onConflictDoUpdate({
target: [decisionTriage.companyId, decisionTriage.sourceKind, decisionTriage.sourceId],
set: values,
}).returning().then((rows) => rows[0]!);
await txDb.insert(decisionTriageEvents).values({
companyId: input.companyId,
sourceKind: input.sourceKind,
sourceId: input.sourceId,
action: "triage.updated",
...eventActorColumns(input.actor),
details: {
previousDecideBy: current ? toTriage(current).decideBy : null,
decideBy: toTriage(row).decideBy,
previousSnoozedUntil: current?.snoozedUntil?.toISOString() ?? null,
snoozedUntil: row.snoozedUntil?.toISOString() ?? null,
version: row.version,
},
});
await recordActivity(txDb, input.actor, {
companyId: input.companyId,
action: "decision_triage.updated",
entityType: "attention_source",
entityId: `${input.sourceKind}:${input.sourceId}`,
details: { sourceKind: input.sourceKind, sourceId: input.sourceId, version: row.version },
});
return toTriage(row);
});
},
materializeSeededQueues: async (companyId: string, items: AttentionItem[]) => {
if (items.length === 0) return;
const issueIds = [...new Set(items.map(itemIssueId).filter((id): id is string => Boolean(id)))];
const prIssueIds = new Set(issueIds.length === 0 ? [] : await db
.select({ issueId: issueWorkProducts.issueId })
.from(issueWorkProducts)
.where(and(
eq(issueWorkProducts.companyId, companyId),
eq(issueWorkProducts.type, "pull_request"),
inArray(issueWorkProducts.issueId, issueIds),
)).then((rows) => rows.map((row) => row.issueId)));
const matches = new Map<string, AttentionItem[]>();
for (const item of items) {
if (prIssueIds.has(itemIssueId(item) ?? "")) {
matches.set("prs", [...(matches.get("prs") ?? []), item]);
}
if (item.sourceKind === "issue_thread_interaction" && item.subject.metadata?.isPlanTarget === true) {
matches.set("plans", [...(matches.get("plans") ?? []), item]);
}
if (item.sourceKind === "issue_thread_interaction" && item.subject.metadata?.kind === "ask_user_questions") {
matches.set("questions", [...(matches.get("questions") ?? []), item]);
}
}
for (const seed of DECISION_QUEUE_SEEDS) {
const matchingItems = matches.get(seed.key) ?? [];
if (matchingItems.length === 0) continue;
await db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const insertedQueue = await txDb.insert(decisionQueues).values({
companyId,
key: seed.key,
title: seed.title,
description: seed.description,
createdByType: "system",
seedRules: seed.rules,
seedRulesEnabled: true,
}).onConflictDoNothing({ target: [decisionQueues.companyId, decisionQueues.key] }).returning();
const queue = insertedQueue[0] ?? await txDb.select().from(decisionQueues)
.where(and(eq(decisionQueues.companyId, companyId), eq(decisionQueues.key, seed.key)))
.then((rows) => rows[0] ?? null);
if (!queue || !queue.seedRulesEnabled) return;
if (insertedQueue[0]) {
await txDb.insert(decisionTriageEvents).values({
companyId,
queueId: queue.id,
action: "queue.seeded",
...eventActorColumns(SYSTEM_ACTOR),
details: { key: seed.key, rules: seed.rules.map((rule) => rule.key) },
});
await recordActivity(txDb, SYSTEM_ACTOR, {
companyId,
action: "decision_queue.seeded",
entityType: "decision_queue",
entityId: queue.id,
details: { key: seed.key },
});
}
let insertedAnyItem = false;
for (const item of matchingItems) {
const inserted = await txDb.insert(decisionQueueItems).values({
companyId,
queueId: queue.id,
sourceKind: item.sourceKind,
sourceId: item.subject.id,
addedByType: "system",
}).onConflictDoNothing({
target: [decisionQueueItems.queueId, decisionQueueItems.sourceKind, decisionQueueItems.sourceId],
}).returning();
if (!inserted[0]) continue;
insertedAnyItem = true;
await txDb.insert(decisionTriageEvents).values({
companyId,
queueId: queue.id,
sourceKind: item.sourceKind,
sourceId: item.subject.id,
action: "queue_item.seeded",
...eventActorColumns(SYSTEM_ACTOR),
details: { seedKey: seed.key },
});
await recordActivity(txDb, SYSTEM_ACTOR, {
companyId,
action: "decision_queue_item.seeded",
entityType: "decision_queue",
entityId: queue.id,
details: { sourceKind: item.sourceKind, sourceId: item.subject.id, seedKey: seed.key },
});
}
if (insertedAnyItem) {
await txDb.update(decisionQueues).set({ updatedAt: new Date() })
.where(and(eq(decisionQueues.companyId, companyId), eq(decisionQueues.id, queue.id)));
}
});
}
},
};
}