mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
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>
This commit is contained in:
46 files changed
+41814
-71
No files matched your search
@@ -14,6 +14,8 @@ import {
|
||||
createDb,
|
||||
decisionQueueItems,
|
||||
decisionQueues,
|
||||
decisionArchiveNotificationOutbox,
|
||||
decisionRetention,
|
||||
decisions,
|
||||
decisionTriage,
|
||||
decisionTriageEvents,
|
||||
@@ -62,6 +64,8 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(inboxDismissals);
|
||||
await db.delete(decisionArchiveNotificationOutbox);
|
||||
await db.delete(decisionRetention);
|
||||
await db.delete(decisionTriageEvents);
|
||||
await db.delete(decisionTriage);
|
||||
await db.delete(decisionQueueItems);
|
||||
@@ -1237,6 +1241,15 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
expect(secondPage.items.map((item) => item.subject.id)).toEqual([expiringLaterId]);
|
||||
expect(secondPage.nextCursor).toBeNull();
|
||||
|
||||
const completeSnapshot = await attentionService(db).list(companyId, {
|
||||
userId: "board-user",
|
||||
queue: "urgent-releases",
|
||||
sort: "decide",
|
||||
all: true,
|
||||
});
|
||||
expect(completeSnapshot.items.map((item) => item.subject.id)).toEqual([expiringSoonId, expiringLaterId]);
|
||||
expect(completeSnapshot.nextCursor).toBeNull();
|
||||
|
||||
const dateFiltered = await attentionService(db).list(companyId, {
|
||||
userId: "board-user",
|
||||
queue: "urgent-releases",
|
||||
@@ -1254,6 +1267,73 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
.toBe(new Date(now + 60 * 60_000).toISOString());
|
||||
});
|
||||
|
||||
it("returns a complete queue snapshot beyond the normal page limit", async () => {
|
||||
const { companyId } = await seedCompany("ATS");
|
||||
const queueId = randomUUID();
|
||||
await db.insert(decisionQueues).values({
|
||||
id: queueId,
|
||||
companyId,
|
||||
key: "bulk-review",
|
||||
title: "Bulk review",
|
||||
createdByType: "user",
|
||||
createdByUserId: "board-user",
|
||||
});
|
||||
|
||||
const approvalRows = Array.from({ length: 101 }, (_, index) => ({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
type: "hire_agent",
|
||||
status: "pending",
|
||||
payload: { title: `Review ${index + 1}` },
|
||||
createdAt: new Date(Date.UTC(2026, 7, 2, 12, 0, index)),
|
||||
updatedAt: new Date(Date.UTC(2026, 7, 2, 12, 0, index)),
|
||||
}));
|
||||
await db.insert(approvals).values(approvalRows);
|
||||
await db.insert(decisionQueueItems).values(approvalRows.map((approval) => ({
|
||||
companyId,
|
||||
queueId,
|
||||
sourceKind: "approval",
|
||||
sourceId: approval.id,
|
||||
addedByType: "user",
|
||||
addedByUserId: "board-user",
|
||||
})));
|
||||
|
||||
const firstPage = await attentionService(db).list(companyId, {
|
||||
userId: "board-user",
|
||||
queue: "bulk-review",
|
||||
});
|
||||
expect(firstPage.items).toHaveLength(50);
|
||||
expect(firstPage.nextCursor).toBeTruthy();
|
||||
|
||||
const completeSnapshot = await attentionService(db).list(companyId, {
|
||||
userId: "board-user",
|
||||
queue: "bulk-review",
|
||||
all: true,
|
||||
});
|
||||
expect(completeSnapshot.items).toHaveLength(101);
|
||||
expect(new Set(completeSnapshot.items.map((item) => item.id)).size).toBe(101);
|
||||
expect(completeSnapshot.nextCursor).toBeNull();
|
||||
|
||||
const internalSnapshot = await attentionService(db).list(companyId, {
|
||||
userId: "board-user",
|
||||
all: true,
|
||||
allowUnscopedAll: true,
|
||||
});
|
||||
const internalSnapshotIds = new Set(internalSnapshot.items.map((item) => item.subject.id));
|
||||
expect(approvalRows.every((approval) => internalSnapshotIds.has(approval.id))).toBe(true);
|
||||
expect(internalSnapshot.items.length).toBeGreaterThan(100);
|
||||
expect(internalSnapshot.nextCursor).toBeNull();
|
||||
|
||||
await expect(attentionService(db).list(companyId, { userId: "board-user", all: true }))
|
||||
.rejects.toThrow("all requires a queue filter");
|
||||
await expect(attentionService(db).list(companyId, {
|
||||
userId: "board-user",
|
||||
queue: "bulk-review",
|
||||
all: true,
|
||||
limit: 25,
|
||||
})).rejects.toThrow("all cannot be combined with cursor or limit");
|
||||
});
|
||||
|
||||
it("keeps this-week deadlines in the current UTC week", async () => {
|
||||
const { companyId, workerId } = await seedCompany("ATW");
|
||||
const now = Date.parse("2026-08-02T12:00:00.000Z"); // Sunday in an ISO Monday-Sunday week.
|
||||
@@ -1370,4 +1450,93 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
.expect(400, { error: "sort must be 'activity' or 'decide'" });
|
||||
await request(app(agent)).get(`/api/companies/${companyId}/attention`).expect(403);
|
||||
});
|
||||
|
||||
it("computes the aging shelf uniformly across approval, interaction, and review sources", async () => {
|
||||
const { companyId, workerId } = await seedCompany("AGE");
|
||||
const now = Date.parse("2026-08-02T12:00:00.000Z");
|
||||
const idleAt = new Date("2026-06-30T12:00:00.000Z");
|
||||
const interactionIssueId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "AGE-1",
|
||||
title: "Old questions",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: workerId,
|
||||
createdAt: idleAt,
|
||||
updatedAt: idleAt,
|
||||
});
|
||||
const reviewIssueId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "AGE-2",
|
||||
title: "Old review",
|
||||
status: "in_review",
|
||||
assigneeUserId: "board-user",
|
||||
createdAt: idleAt,
|
||||
updatedAt: idleAt,
|
||||
});
|
||||
const approvalId = randomUUID();
|
||||
const queueApprovalId = randomUUID();
|
||||
const interactionId = randomUUID();
|
||||
const queueIdleAt = new Date("2026-07-18T12:00:00.000Z");
|
||||
await db.insert(approvals).values([
|
||||
{
|
||||
id: approvalId,
|
||||
companyId,
|
||||
type: "request_board_approval",
|
||||
requestedByAgentId: workerId,
|
||||
status: "pending",
|
||||
payload: { title: "Old approval" },
|
||||
createdAt: idleAt,
|
||||
updatedAt: idleAt,
|
||||
},
|
||||
{
|
||||
id: queueApprovalId,
|
||||
companyId,
|
||||
type: "request_board_approval",
|
||||
requestedByAgentId: workerId,
|
||||
status: "pending",
|
||||
payload: { title: "Queue-retained approval" },
|
||||
createdAt: queueIdleAt,
|
||||
updatedAt: queueIdleAt,
|
||||
},
|
||||
]);
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
id: interactionId,
|
||||
companyId,
|
||||
issueId: interactionIssueId,
|
||||
kind: "ask_user_questions",
|
||||
status: "pending",
|
||||
createdByAgentId: workerId,
|
||||
payload: { version: 1, questions: [] },
|
||||
createdAt: idleAt,
|
||||
updatedAt: idleAt,
|
||||
});
|
||||
const [queue] = await db.insert(decisionQueues).values({
|
||||
companyId,
|
||||
key: "fast-aging",
|
||||
title: "Fast aging",
|
||||
retentionDays: 10,
|
||||
createdByType: "system",
|
||||
}).returning();
|
||||
await db.insert(decisionQueueItems).values({
|
||||
companyId,
|
||||
queueId: queue!.id,
|
||||
sourceKind: "approval",
|
||||
sourceId: queueApprovalId,
|
||||
addedByType: "system",
|
||||
});
|
||||
|
||||
const feed = await attentionService(db, { now: () => now }).list(companyId, {
|
||||
userId: "board-user",
|
||||
limit: 100,
|
||||
});
|
||||
const byKey = new Map(feed.items.map((item) => [`${item.sourceKind}:${item.subject.id}`, item]));
|
||||
for (const key of [
|
||||
`approval:${approvalId}`,
|
||||
`issue_thread_interaction:${interactionId}`,
|
||||
`review:${reviewIssueId}`,
|
||||
]) {
|
||||
expect(byKey.get(key)).toMatchObject({ shelf: true, retentionDays: 30, archivedAt: null });
|
||||
}
|
||||
expect(byKey.get(`approval:${queueApprovalId}`)).toMatchObject({ shelf: true, retentionDays: 10 });
|
||||
});
|
||||
});
|
||||
@@ -230,11 +230,28 @@ describeEmbeddedPostgres("decision queue routes", () => {
|
||||
|
||||
await request(app(board))
|
||||
.delete(`/api/companies/${companyId}/decision-queues/triage/items/review/${issueId}`)
|
||||
.send({ reason: "Handled in the release review" })
|
||||
.expect(200);
|
||||
expect(await db.select().from(decisionQueueItems).where(and(
|
||||
eq(decisionQueueItems.companyId, companyId),
|
||||
eq(decisionQueueItems.sourceKind, "review"),
|
||||
))).toHaveLength(0);
|
||||
const removalAudit = await db.select().from(activityLog).where(and(
|
||||
eq(activityLog.companyId, companyId),
|
||||
eq(activityLog.action, "decision_queue_item.removed"),
|
||||
)).then((rows) => rows[0]);
|
||||
expect(removalAudit?.details).toMatchObject({
|
||||
sourceKind: "review",
|
||||
sourceId: issueId,
|
||||
reason: "Handled in the release review",
|
||||
});
|
||||
const removalEvent = await db.select().from(decisionTriageEvents).where(and(
|
||||
eq(decisionTriageEvents.companyId, companyId),
|
||||
eq(decisionTriageEvents.sourceKind, "review"),
|
||||
eq(decisionTriageEvents.sourceId, issueId),
|
||||
eq(decisionTriageEvents.action, "queue_item.removed"),
|
||||
)).then((rows) => rows[0]);
|
||||
expect(removalEvent?.details).toEqual({ reason: "Handled in the release review" });
|
||||
|
||||
await db.delete(approvals).where(eq(approvals.id, approvalId));
|
||||
await request(app(board))
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
decisionArchiveNotificationOutbox,
|
||||
decisionRetention,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { attentionService } from "../services/attention.js";
|
||||
import { decisionRetentionService } from "../services/decision-retention.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describePg = support.supported ? describe : describe.skip;
|
||||
|
||||
describePg("decision retention", () => {
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-decision-retention-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 30_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(decisionArchiveNotificationOutbox);
|
||||
await db.delete(decisionRetention);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issues);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => tempDb?.cleanup());
|
||||
|
||||
async function seedReviewItems(count: number) {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Retention",
|
||||
issuePrefix: `R${companyId.slice(0, 6)}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Origin",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
const old = new Date("2026-04-01T00:00:00.000Z");
|
||||
const issueIds = Array.from({ length: count }, () => randomUUID());
|
||||
await db.insert(issues).values(issueIds.map((id, index) => ({
|
||||
id,
|
||||
companyId,
|
||||
identifier: `RET-${index + 1}`,
|
||||
title: `Old review ${index + 1}`,
|
||||
status: "in_review",
|
||||
priority: "medium",
|
||||
assigneeUserId: "board-user",
|
||||
createdByAgentId: agentId,
|
||||
createdAt: old,
|
||||
updatedAt: old,
|
||||
})));
|
||||
return { companyId, agentId, issueIds };
|
||||
}
|
||||
|
||||
const boardActor = (companyId: string) => ({
|
||||
type: "board" as const,
|
||||
source: "local_implicit" as const,
|
||||
userId: "board-user",
|
||||
companyIds: [companyId],
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
const mutationActor = {
|
||||
actorType: "user" as const,
|
||||
actorId: "board-user",
|
||||
agentId: null,
|
||||
userId: "board-user",
|
||||
runId: null,
|
||||
agentApiKeyId: null,
|
||||
responsibleUserId: "board-user",
|
||||
};
|
||||
|
||||
it("auto-archives, excludes by default, returns archived rows, and revives", async () => {
|
||||
const { companyId, issueIds } = await seedReviewItems(1);
|
||||
const now = new Date("2026-08-02T00:00:00.000Z");
|
||||
const svc = decisionRetentionService(db);
|
||||
const before = await attentionService(db, { now: () => now.getTime() }).list(companyId, { limit: 100 });
|
||||
expect(before.items).toHaveLength(1);
|
||||
|
||||
expect(await svc.autoArchive({ companyId, items: before.items, now })).toBe(1);
|
||||
const current = await attentionService(db, { now: () => now.getTime() }).list(companyId, { limit: 100 });
|
||||
expect(current.items).toHaveLength(0);
|
||||
const archived = await attentionService(db, { now: () => now.getTime() }).list(companyId, { archived: true, limit: 100 });
|
||||
expect(archived.items[0]).toMatchObject({ subject: { id: issueIds[0] }, archivedAt: now.toISOString() });
|
||||
|
||||
await svc.revive({
|
||||
companyId,
|
||||
sourceKind: "review",
|
||||
sourceId: issueIds[0]!,
|
||||
authActor: boardActor(companyId),
|
||||
actor: mutationActor,
|
||||
});
|
||||
const revived = await attentionService(db, { now: () => now.getTime() }).list(companyId, { limit: 100 });
|
||||
expect(revived.items[0]).toMatchObject({ subject: { id: issueIds[0] }, archivedAt: null });
|
||||
});
|
||||
|
||||
it("exempts Keep rows and batches one notification per origin agent", async () => {
|
||||
const { companyId, agentId, issueIds } = await seedReviewItems(3);
|
||||
const now = new Date("2026-08-02T00:00:00.000Z");
|
||||
const notifications: Array<Record<string, unknown>> = [];
|
||||
const svc = decisionRetentionService(db, { notifyOriginAgent: async (batch) => notifications.push(batch) });
|
||||
await attentionService(db, { now: () => now.getTime() }).list(companyId, { limit: 100 });
|
||||
await svc.setKeep({
|
||||
companyId,
|
||||
sourceKind: "review",
|
||||
sourceId: issueIds[0]!,
|
||||
keep: true,
|
||||
authActor: boardActor(companyId),
|
||||
actor: mutationActor,
|
||||
});
|
||||
const refreshed = await attentionService(db, { now: () => now.getTime() }).list(companyId, { limit: 100 });
|
||||
expect(await svc.autoArchive({ companyId, items: refreshed.items, now })).toBe(2);
|
||||
expect(await svc.deliverNotifications()).toEqual({ notifiedAgents: 1, delivered: 2 });
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0]).toMatchObject({ companyId, agentId, items: expect.arrayContaining([
|
||||
expect.objectContaining({ issueId: issueIds[1] }),
|
||||
expect.objectContaining({ issueId: issueIds[2] }),
|
||||
]) });
|
||||
expect((await svc.getState(companyId, "review", issueIds[0]!))?.archivedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("fails the whole reviewed manifest when one expected version changes", async () => {
|
||||
const { companyId, issueIds } = await seedReviewItems(2);
|
||||
const now = Date.parse("2026-08-02T00:00:00.000Z");
|
||||
const svc = decisionRetentionService(db);
|
||||
const feed = await attentionService(db, { now: () => now }).list(companyId, { limit: 100 });
|
||||
const manifest = feed.items.map((item) => ({
|
||||
companyId,
|
||||
sourceKind: item.sourceKind,
|
||||
sourceId: item.subject.id,
|
||||
expectedVersion: item.retentionVersion,
|
||||
activityAt: item.activityAt,
|
||||
reason: "Reviewed as stale",
|
||||
}));
|
||||
await svc.setKeep({
|
||||
companyId,
|
||||
sourceKind: "review",
|
||||
sourceId: issueIds[0]!,
|
||||
keep: true,
|
||||
authActor: boardActor(companyId),
|
||||
actor: mutationActor,
|
||||
});
|
||||
await expect(svc.archiveReviewedManifest({
|
||||
companyId,
|
||||
manifest,
|
||||
originActor: boardActor(companyId),
|
||||
decidingActor: boardActor(companyId),
|
||||
decidedByUserId: "board-user",
|
||||
})).rejects.toThrow("archive_proposal_stale");
|
||||
expect((await db.select().from(decisionRetention)).every((row) => row.archivedAt === null)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
companyMemberships,
|
||||
createDb,
|
||||
decisionEffectExecutions,
|
||||
decisionRetention,
|
||||
decisions,
|
||||
decisionTargetIssues,
|
||||
heartbeatRuns,
|
||||
@@ -22,6 +23,8 @@ import {
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { attentionService } from "../services/attention.js";
|
||||
import { decisionService } from "../services/decisions.js";
|
||||
import { hashAttentionArchiveManifest } from "../services/decision-retention.js";
|
||||
import type { AttentionArchiveManifestEntry, AttentionArchiveTargetSnapshot } from "@paperclipai/shared";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describePg = support.supported ? describe : describe.skip;
|
||||
@@ -69,7 +72,7 @@ describePg("decisionService", () => {
|
||||
delete process.env.PAPERCLIP_DECISIONS_SWEEP_BATCH_SIZE;
|
||||
delete process.env.PAPERCLIP_DECISIONS_RECOVERY_GRACE_MS;
|
||||
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
await db.delete(decisionEffectExecutions); await db.delete(decisionTargetIssues); await db.delete(decisions); await db.delete(activityLog);
|
||||
await db.delete(decisionEffectExecutions); await db.delete(decisionTargetIssues); await db.delete(decisions); await db.delete(decisionRetention); await db.delete(activityLog);
|
||||
await db.delete(issueComments); await db.delete(issueRelations); await db.delete(heartbeatRuns); await db.delete(issues); await db.delete(agents); await db.delete(companyMemberships); await db.delete(authUsers); await db.delete(companies);
|
||||
});
|
||||
afterAll(async () => tempDb?.cleanup());
|
||||
@@ -119,6 +122,64 @@ describePg("decisionService", () => {
|
||||
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, targetIssueId))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("binds bulk archive acceptance to the exact signed, versioned manifest", async () => {
|
||||
const extraIssueId = randomUUID();
|
||||
const unreviewedIssueId = randomUUID();
|
||||
await db.insert(issues).values([
|
||||
{ id: extraIssueId, companyId, title: "Second aging item", status: "in_review", priority: "medium", createdByAgentId: agentId, assigneeUserId: decidedByUserId },
|
||||
{ id: unreviewedIssueId, companyId, title: "Not reviewed", status: "in_review", priority: "medium", createdByAgentId: agentId, assigneeUserId: decidedByUserId },
|
||||
]);
|
||||
const activityAt = new Date("2026-04-01T00:00:00.000Z");
|
||||
await db.insert(decisionRetention).values([targetIssueId, extraIssueId, unreviewedIssueId].map((sourceId) => ({
|
||||
companyId,
|
||||
sourceKind: "review",
|
||||
sourceId,
|
||||
sourceActivityAt: activityAt,
|
||||
})));
|
||||
const manifest: AttentionArchiveManifestEntry[] = [targetIssueId, extraIssueId].map((sourceId) => ({
|
||||
companyId,
|
||||
sourceKind: "review",
|
||||
sourceId,
|
||||
expectedVersion: 1,
|
||||
activityAt: activityAt.toISOString(),
|
||||
reason: `Archive ${sourceId}`,
|
||||
}));
|
||||
const snapshots = Object.fromEntries(manifest.map((entry) => [
|
||||
`attention:${entry.sourceKind}:${entry.sourceId}`,
|
||||
{
|
||||
status: "attention",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
updatedAt: entry.activityAt,
|
||||
attentionArchive: entry,
|
||||
} satisfies AttentionArchiveTargetSnapshot,
|
||||
]));
|
||||
const created = await service().create({
|
||||
companyId,
|
||||
actor: agentActor(),
|
||||
agentId,
|
||||
runId,
|
||||
title: "Archive two?",
|
||||
body: "Reviewed exact set",
|
||||
options: [
|
||||
{ id: "archive", label: "Archive", style: "destructive", effects: [] },
|
||||
{ id: "keep", label: "Keep", effects: [] },
|
||||
],
|
||||
metadata: { kind: "attention_archive_proposal", manifestHash: hashAttentionArchiveManifest(manifest) },
|
||||
additionalTargetSnapshots: snapshots,
|
||||
});
|
||||
const result = await service().decide({
|
||||
id: created.id,
|
||||
optionId: "archive",
|
||||
decidedByUserId,
|
||||
userActor: boardActor(),
|
||||
});
|
||||
expect(result.executionStatus).toBe("succeeded");
|
||||
const states = await db.select().from(decisionRetention);
|
||||
expect(states.filter((row) => row.archivedAt).map((row) => row.sourceId).sort()).toEqual([extraIssueId, targetIssueId].sort());
|
||||
expect(states.find((row) => row.sourceId === unreviewedIssueId)?.archivedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("skips strict stale targets and fails closed on intersection denial", async () => {
|
||||
const stale = await createCommentDecision("strict");
|
||||
await db.update(issues).set({ updatedAt: new Date(Date.now() + 1_000) }).where(eq(issues.id, targetIssueId));
|
||||
|
||||
@@ -29,7 +29,11 @@ const {
|
||||
} = vi.hoisted(() => {
|
||||
const createAppMock = vi.fn(async () => ((_: unknown, __: unknown) => {}) as never);
|
||||
const createBetterAuthInstanceMock = vi.fn(() => ({}));
|
||||
const createDbMock = vi.fn(() => ({}) as never);
|
||||
const createDbMock = vi.fn(() => ({
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({ where: vi.fn(async () => []) })),
|
||||
})),
|
||||
}) as never);
|
||||
const detectPortMock = vi.fn(async (port: number) => port);
|
||||
const deriveAuthTrustedOriginsMock = vi.fn(() => []);
|
||||
const resolveHeartbeatSchedulingSuppressionMock = vi.fn(() => ({
|
||||
@@ -207,9 +211,16 @@ vi.mock("../services/index.js", () => ({
|
||||
agentMembershipsInserted: 0,
|
||||
humanGrantsInserted: 0,
|
||||
})),
|
||||
attentionService: vi.fn(() => ({
|
||||
list: vi.fn(async () => ({ items: [], nextCursor: null })),
|
||||
})),
|
||||
decisionService: vi.fn(() => ({
|
||||
sweepExpired: vi.fn(async () => ({ expired: 0 })),
|
||||
})),
|
||||
decisionRetentionService: vi.fn(() => ({
|
||||
autoArchive: vi.fn(async () => 0),
|
||||
deliverNotifications: vi.fn(async () => ({ notifiedAgents: 0, delivered: 0 })),
|
||||
})),
|
||||
feedbackService: feedbackServiceFactoryMock,
|
||||
bootstrapExecutionPolicyFromEnv: vi.fn(async () => null),
|
||||
applyManagedEnvironments: vi.fn(async () => null),
|
||||
|
||||
+30
-1
@@ -42,11 +42,13 @@ import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
|
||||
import {
|
||||
feedbackService,
|
||||
applyManagedEnvironments,
|
||||
attentionService,
|
||||
backfillPrincipalAccessCompatibility,
|
||||
backfillLegacyToolOAuthTokens,
|
||||
bootstrapExecutionPolicyFromEnv,
|
||||
environmentCustomImageService,
|
||||
decisionService,
|
||||
decisionRetentionService,
|
||||
heartbeatService,
|
||||
issueService,
|
||||
instanceSettingsService,
|
||||
@@ -73,7 +75,7 @@ import { maybePersistWorktreeRuntimePorts } from "./worktree-config.js";
|
||||
import { initTelemetry, getTelemetryClient } from "./telemetry.js";
|
||||
import { conflict } from "./errors.js";
|
||||
import { ensureDecisionSigningSecret } from "./services/decision-signing.js";
|
||||
import { createDecisionWakeOriginAgent } from "./services/decision-wakeup.js";
|
||||
import { createDecisionRetentionNotifyOriginAgent, createDecisionWakeOriginAgent } from "./services/decision-wakeup.js";
|
||||
import { coordinateHeartbeatSchedulerShutdown } from "./shutdown.js";
|
||||
import { systemdNotify } from "./services/systemd-notify.js";
|
||||
import { flushInFlightRunLogMirrors } from "./services/run-log-store.js";
|
||||
@@ -913,6 +915,9 @@ export async function startServer(): Promise<StartedServer> {
|
||||
|
||||
if (heartbeat) {
|
||||
const decisionExecutor = decisionService(db as any, decisionServiceOptions);
|
||||
const retentionExecutor = decisionRetentionService(db as any, {
|
||||
notifyOriginAgent: createDecisionRetentionNotifyOriginAgent(heartbeat.wakeup),
|
||||
});
|
||||
drainHeartbeatRunsForShutdown = heartbeat.drainRunningRunsForShutdown;
|
||||
prepareHotRestartShutdown = heartbeat.prepareHotRestartShutdown;
|
||||
const environmentCustomImages = environmentCustomImageService(db as any, { pluginWorkerManager });
|
||||
@@ -1046,6 +1051,27 @@ export async function startServer(): Promise<StartedServer> {
|
||||
logger.warn({ ...toolHealthSweep }, "startup tool connection health sweep found failing connections");
|
||||
}
|
||||
await decisionExecutor.sweepExpired();
|
||||
const runRetentionSweep = async () => {
|
||||
const activeCompanies = await db.select({ id: companies.id }).from(companies).where(eq(companies.status, "active"));
|
||||
let archived = 0;
|
||||
for (const company of activeCompanies) {
|
||||
const items = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await attentionService(db as any).list(company.id, {
|
||||
includeDismissed: true,
|
||||
limit: 100,
|
||||
cursor,
|
||||
});
|
||||
items.push(...page.items);
|
||||
cursor = page.nextCursor ?? undefined;
|
||||
} while (cursor);
|
||||
archived += await retentionExecutor.autoArchive({ companyId: company.id, items });
|
||||
}
|
||||
const notifications = await retentionExecutor.deliverNotifications();
|
||||
return { archived, ...notifications };
|
||||
};
|
||||
await runRetentionSweep();
|
||||
|
||||
heartbeatSchedulerInterval = setInterval(() => {
|
||||
// Async so the suppression checks below can honor the override-aware
|
||||
@@ -1056,6 +1082,9 @@ export async function startServer(): Promise<StartedServer> {
|
||||
trackHeartbeatSchedulerWork(decisionExecutor.sweepExpired().catch((err: unknown) => {
|
||||
logger.error({ err }, "decision expiry sweep failed");
|
||||
}));
|
||||
trackHeartbeatSchedulerWork(runRetentionSweep().catch((err: unknown) => {
|
||||
logger.error({ err }, "decision retention sweep failed");
|
||||
}));
|
||||
const sweptRuntimeStatuses = heartbeat.sweepExpiredRuntimeStatuses();
|
||||
if (sweptRuntimeStatuses > 0) {
|
||||
logger.info(
|
||||
|
||||
@@ -25,6 +25,8 @@ export function attentionRoutes(db: Db) {
|
||||
}
|
||||
|
||||
const includeDismissed = req.query.includeDismissed === "true";
|
||||
const archived = req.query.archived === "true";
|
||||
const all = req.query.all === "true";
|
||||
const activitySince = optionalQueryString(req.query.activitySince, "activitySince");
|
||||
const activityUntil = optionalQueryString(req.query.activityUntil, "activityUntil");
|
||||
const queue = optionalQueryString(req.query.queue, "queue");
|
||||
@@ -39,6 +41,8 @@ export function attentionRoutes(db: Db) {
|
||||
const feed = await svc.list(companyId, {
|
||||
userId: req.actor.userId,
|
||||
includeDismissed,
|
||||
archived,
|
||||
all,
|
||||
activitySince,
|
||||
activityUntil,
|
||||
queue,
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
addDecisionQueueItemSchema,
|
||||
createDecisionQueueSchema,
|
||||
decisionAttentionSourceKindSchema,
|
||||
removeDecisionQueueItemSchema,
|
||||
updateDecisionQueueSchema,
|
||||
updateDecisionRetentionSchema,
|
||||
updateDecisionTriageSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { forbidden } from "../errors.js";
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
type DecisionMutationActor,
|
||||
} from "../services/decision-queues.js";
|
||||
import { assertBoardOrAgent, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { decisionRetentionService } from "../services/decision-retention.js";
|
||||
|
||||
function mutationActor(req: Parameters<typeof getActorInfo>[0]): DecisionMutationActor {
|
||||
const actor = getActorInfo(req);
|
||||
@@ -63,6 +66,7 @@ function sourceParams(req: Parameters<typeof getActorInfo>[0]) {
|
||||
export function decisionQueueRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = decisionQueueService(db);
|
||||
const retention = decisionRetentionService(db);
|
||||
|
||||
router.get("/companies/:companyId/decision-queue-seed-rules", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
@@ -124,22 +128,27 @@ export function decisionQueueRoutes(db: Db) {
|
||||
},
|
||||
);
|
||||
|
||||
router.delete("/companies/:companyId/decision-queues/:key/items/:sourceKind/:sourceId", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertDecisionAccess(db, req, companyId, "decision_queue:manage");
|
||||
const source = sourceParams(req);
|
||||
if (!source) {
|
||||
res.status(400).json({ error: "Invalid attention source identity" });
|
||||
return;
|
||||
}
|
||||
res.json(await svc.removeItem({
|
||||
companyId,
|
||||
key: req.params.key as string,
|
||||
...source,
|
||||
authActor: req.actor,
|
||||
actor: mutationActor(req),
|
||||
}));
|
||||
});
|
||||
router.delete(
|
||||
"/companies/:companyId/decision-queues/:key/items/:sourceKind/:sourceId",
|
||||
validate(removeDecisionQueueItemSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertDecisionAccess(db, req, companyId, "decision_queue:manage");
|
||||
const source = sourceParams(req);
|
||||
if (!source) {
|
||||
res.status(400).json({ error: "Invalid attention source identity" });
|
||||
return;
|
||||
}
|
||||
res.json(await svc.removeItem({
|
||||
companyId,
|
||||
key: req.params.key as string,
|
||||
...source,
|
||||
reason: req.body.reason,
|
||||
authActor: req.actor,
|
||||
actor: mutationActor(req),
|
||||
}));
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/companies/:companyId/decision-triage/:sourceKind/:sourceId", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
@@ -173,5 +182,48 @@ export function decisionQueueRoutes(db: Db) {
|
||||
},
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/companies/:companyId/decision-retention/:sourceKind/:sourceId",
|
||||
validate(updateDecisionRetentionSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertDecisionAccess(db, req, companyId, "decision_triage:manage");
|
||||
const source = sourceParams(req);
|
||||
if (!source) {
|
||||
res.status(400).json({ error: "Invalid attention source identity" });
|
||||
return;
|
||||
}
|
||||
res.json(await retention.setKeep({
|
||||
companyId,
|
||||
...source,
|
||||
keep: req.body.keep,
|
||||
authActor: req.actor,
|
||||
actor: mutationActor(req),
|
||||
}));
|
||||
},
|
||||
);
|
||||
|
||||
router.post("/companies/:companyId/decision-retention/:sourceKind/:sourceId/archive", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertDecisionAccess(db, req, companyId, "decision_triage:manage");
|
||||
const source = sourceParams(req);
|
||||
if (!source) {
|
||||
res.status(400).json({ error: "Invalid attention source identity" });
|
||||
return;
|
||||
}
|
||||
res.json(await retention.archive({ companyId, ...source, authActor: req.actor, actor: mutationActor(req) }));
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/decision-retention/:sourceKind/:sourceId/revive", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertDecisionAccess(db, req, companyId, "decision_triage:manage");
|
||||
const source = sourceParams(req);
|
||||
if (!source) {
|
||||
res.status(400).json({ error: "Invalid attention source identity" });
|
||||
return;
|
||||
}
|
||||
res.json(await retention.revive({ companyId, ...source, authActor: req.actor, actor: mutationActor(req) }));
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,10 +1,23 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { decisionInputsSchema, decisionOptionsSchema } from "@paperclipai/shared";
|
||||
import {
|
||||
createDecisionArchiveProposalSchema,
|
||||
decisionInputsSchema,
|
||||
decisionOptionsSchema,
|
||||
type AttentionArchiveManifestEntry,
|
||||
type AttentionArchiveTargetSnapshot,
|
||||
type AttentionItem,
|
||||
type CreateDecisionArchiveProposalInput,
|
||||
} from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { decisionService, type DecisionServiceOptions } from "../services/decisions.js";
|
||||
import { assertBoard, assertBoardOrAgent, assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js";
|
||||
import { attentionService } from "../services/attention.js";
|
||||
import { authorizationDeniedDetails, authorizationService } from "../services/authorization.js";
|
||||
import { canReadDecisionSource } from "../services/decision-queues.js";
|
||||
import { hashAttentionArchiveManifest } from "../services/decision-retention.js";
|
||||
import { forbidden, unprocessable } from "../errors.js";
|
||||
|
||||
const createSchema = z.object({
|
||||
title: z.string().trim().min(1).max(500),
|
||||
@@ -39,6 +52,89 @@ function boardUserId(req: Parameters<typeof getActorInfo>[0]) {
|
||||
export function decisionRoutes(db: Db, options: DecisionServiceOptions) {
|
||||
const router = Router();
|
||||
const svc = decisionService(db, options);
|
||||
router.post(
|
||||
"/companies/:companyId/decision-archive-proposals",
|
||||
validate(createDecisionArchiveProposalSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const agent = agentContext(req);
|
||||
if (!agent) {
|
||||
res.status(403).json({ error: "Agent run context required" });
|
||||
return;
|
||||
}
|
||||
const access = await authorizationService(db).decide({
|
||||
actor: req.actor,
|
||||
action: "decision_triage:manage",
|
||||
resource: { type: "company", companyId },
|
||||
});
|
||||
if (!access.allowed) throw forbidden(access.explanation, authorizationDeniedDetails(access));
|
||||
|
||||
const proposal = req.body as CreateDecisionArchiveProposalInput;
|
||||
const requested = new Map<string, CreateDecisionArchiveProposalInput["items"][number]>(proposal.items.map((item) => [
|
||||
`${item.sourceKind}:${item.sourceId}`,
|
||||
item,
|
||||
]));
|
||||
const found = new Map<string, AttentionItem>();
|
||||
const snapshot = await db.transaction(async (tx) => attentionService(tx as unknown as Db).list(companyId, {
|
||||
includeDismissed: true,
|
||||
all: true,
|
||||
allowUnscopedAll: true,
|
||||
}), { isolationLevel: "repeatable read" });
|
||||
for (const item of snapshot.items) {
|
||||
const key = `${item.sourceKind}:${item.subject.id}`;
|
||||
if (requested.has(key)) found.set(key, item);
|
||||
}
|
||||
|
||||
const manifest: AttentionArchiveManifestEntry[] = [];
|
||||
for (const [key, item] of [...requested.entries()].sort(([left], [right]) => left.localeCompare(right))) {
|
||||
const attentionItem = found.get(key);
|
||||
if (!attentionItem || !attentionItem.shelf || attentionItem.archivedAt) {
|
||||
throw unprocessable("Every archive proposal item must be on the current aging shelf");
|
||||
}
|
||||
if (!(await canReadDecisionSource(db, req.actor, companyId, attentionItem.sourceKind, attentionItem.subject.id))) {
|
||||
throw unprocessable("Every archive proposal item must be on the current aging shelf");
|
||||
}
|
||||
manifest.push({
|
||||
companyId,
|
||||
sourceKind: attentionItem.sourceKind,
|
||||
sourceId: attentionItem.subject.id,
|
||||
expectedVersion: attentionItem.retentionVersion,
|
||||
activityAt: attentionItem.activityAt,
|
||||
reason: item.reason,
|
||||
});
|
||||
}
|
||||
const manifestHash = hashAttentionArchiveManifest(manifest);
|
||||
const targetSnapshots = Object.fromEntries(manifest.map((entry) => [
|
||||
`attention:${entry.sourceKind}:${entry.sourceId}`,
|
||||
{
|
||||
status: "attention",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
updatedAt: entry.activityAt,
|
||||
attentionArchive: entry,
|
||||
} satisfies AttentionArchiveTargetSnapshot,
|
||||
]));
|
||||
const body = manifest.map((entry) => `- **${entry.sourceKind}:${entry.sourceId}** — ${entry.reason}`).join("\n");
|
||||
const created = await svc.create({
|
||||
companyId,
|
||||
actor: req.actor,
|
||||
...agent,
|
||||
title: `Archive ${manifest.length} aging decision${manifest.length === 1 ? "" : "s"}?`,
|
||||
body,
|
||||
ruleKey: "attention.bulk_archive",
|
||||
idempotencyKey: proposal.idempotencyKey ?? `attention-archive:${manifestHash}:${agent.runId}`,
|
||||
continuationPolicy: "wake_origin_agent",
|
||||
options: [
|
||||
{ id: "archive", label: "Archive reviewed items", style: "destructive", effects: [] },
|
||||
{ id: "keep", label: "Keep items", effects: [] },
|
||||
],
|
||||
metadata: { kind: "attention_archive_proposal", manifestHash },
|
||||
additionalTargetSnapshots: targetSnapshots,
|
||||
});
|
||||
res.status(201).json(created);
|
||||
},
|
||||
);
|
||||
router.post("/companies/:companyId/decisions", validate(createSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId);
|
||||
const agent = agentContext(req); if (!agent) { res.status(403).json({ error: "Agent run context required" }); return; }
|
||||
|
||||
@@ -50,11 +50,14 @@ import {
|
||||
// Decisions
|
||||
addDecisionQueueItemSchema,
|
||||
createDecisionQueueSchema,
|
||||
createDecisionArchiveProposalSchema,
|
||||
decisionAttentionSourceKindSchema,
|
||||
decisionInputsSchema,
|
||||
decisionOptionsSchema,
|
||||
removeDecisionQueueItemSchema,
|
||||
updateDecisionQueueSchema,
|
||||
updateDecisionTriageSchema,
|
||||
updateDecisionRetentionSchema,
|
||||
// Routine
|
||||
createRoutineSchema,
|
||||
updateRoutineSchema,
|
||||
@@ -3276,7 +3279,20 @@ registry.registerPath({
|
||||
path: "/api/companies/{companyId}/attention",
|
||||
tags: ["inbox"],
|
||||
summary: "List decision-only attention feed items",
|
||||
request: { params: z.object({ companyId: z.string() }) },
|
||||
request: {
|
||||
params: z.object({ companyId: z.string() }),
|
||||
query: z.object({
|
||||
includeDismissed: z.enum(["true", "false"]).optional(),
|
||||
archived: z.enum(["true", "false"]).optional(),
|
||||
all: z.enum(["true", "false"]).optional(),
|
||||
activitySince: z.string().datetime().optional(),
|
||||
activityUntil: z.string().datetime().optional(),
|
||||
queue: z.string().min(1).optional(),
|
||||
sort: z.enum(["activity", "decide"]).optional(),
|
||||
cursor: z.string().min(1).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).optional(),
|
||||
}),
|
||||
},
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden },
|
||||
});
|
||||
|
||||
@@ -3343,6 +3359,25 @@ const decisionTriageSchema = z.object({
|
||||
updatedAt: z.string().datetime(),
|
||||
}).strict();
|
||||
|
||||
const decisionRetentionSchema = z.object({
|
||||
id: z.string(),
|
||||
companyId: z.string(),
|
||||
sourceKind: decisionAttentionSourceKindSchema,
|
||||
sourceId: z.string(),
|
||||
sourceActivityAt: z.string().datetime(),
|
||||
keep: z.boolean(),
|
||||
archivedAt: z.string().datetime().nullable(),
|
||||
archivedReason: z.string().nullable(),
|
||||
archivedByType: z.enum(["agent", "user", "system"]).nullable(),
|
||||
archivedByAgentId: z.string().nullable(),
|
||||
archivedByUserId: z.string().nullable(),
|
||||
archivedByRunId: z.string().nullable(),
|
||||
version: z.number().int().positive(),
|
||||
archiveVersion: z.number().int().nonnegative(),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
}).strict();
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/decision-queue-seed-rules",
|
||||
@@ -3423,6 +3458,7 @@ registerCurrentRoute({
|
||||
path: "/api/companies/{companyId}/decision-queues/{key}/items/{sourceKind}/{sourceId}",
|
||||
tags: ["decision-queues"],
|
||||
summary: "Remove an item from a decision queue",
|
||||
body: removeDecisionQueueItemSchema,
|
||||
responses: {
|
||||
200: r.ok(decisionQueueItemSchema),
|
||||
400: r.badRequest,
|
||||
@@ -3462,6 +3498,34 @@ registerCurrentRoute({
|
||||
},
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "patch",
|
||||
path: "/api/companies/{companyId}/decision-retention/{sourceKind}/{sourceId}",
|
||||
tags: ["decision-queues"],
|
||||
summary: "Set Keep for an attention source",
|
||||
body: updateDecisionRetentionSchema,
|
||||
responses: { 200: r.ok(decisionRetentionSchema), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
|
||||
for (const action of ["archive", "revive"] as const) {
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: `/api/companies/{companyId}/decision-retention/{sourceKind}/{sourceId}/${action}`,
|
||||
tags: ["decision-queues"],
|
||||
summary: action === "archive" ? "Archive an attention source" : "Revive an archived attention source",
|
||||
responses: { 200: r.ok(decisionRetentionSchema), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
}
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/api/companies/{companyId}/decision-archive-proposals",
|
||||
tags: ["decisions"],
|
||||
summary: "Propose one signed bulk archive decision",
|
||||
body: createDecisionArchiveProposalSchema,
|
||||
responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 409: r.conflict, 422: r.unprocessable },
|
||||
});
|
||||
|
||||
// Decisions
|
||||
|
||||
const createDecisionBodySchema = z.object({
|
||||
|
||||
@@ -51,6 +51,10 @@ import { issueService } from "./issues.js";
|
||||
import { parseIssueExecutionState } from "./issue-execution-policy.js";
|
||||
import { isProspectiveBlockedTransition } from "./routable-blocked.js";
|
||||
import { decisionQueueService } from "./decision-queues.js";
|
||||
import {
|
||||
decisionRetentionService,
|
||||
DEFAULT_DECISION_SHELF_DAYS,
|
||||
} from "./decision-retention.js";
|
||||
|
||||
const ATTENTION_SOURCE_KINDS: AttentionSourceKind[] = [
|
||||
"approval",
|
||||
@@ -135,6 +139,8 @@ type BlockingIssueSummary = {
|
||||
|
||||
type AttentionListOptions = AttentionFeedQuery & {
|
||||
userId?: string | null;
|
||||
/** Internal-only escape hatch for callers that need one stable, unpaginated feed snapshot. */
|
||||
allowUnscopedAll?: boolean;
|
||||
};
|
||||
|
||||
type AttentionServiceOptions = {
|
||||
@@ -344,6 +350,11 @@ type CreateAttentionItemInput = Omit<AttentionItem,
|
||||
| "ruleKey"
|
||||
| "originAgentName"
|
||||
| "queues"
|
||||
| "shelf"
|
||||
| "retentionDays"
|
||||
| "keep"
|
||||
| "archivedAt"
|
||||
| "retentionVersion"
|
||||
| "decideBy"
|
||||
| "decideByAttribution"
|
||||
| "snoozedUntil"
|
||||
@@ -370,6 +381,11 @@ function createItem(input: CreateAttentionItemInput): AttentionItem {
|
||||
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,
|
||||
@@ -479,7 +495,7 @@ function parseActivityBoundary(value: string | undefined, field: "activitySince"
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function enrichAttentionItems(db: Db, companyId: string, items: AttentionItem[]) {
|
||||
async function enrichAttentionItems(db: Db, companyId: string, items: AttentionItem[], now: number) {
|
||||
if (items.length === 0) return items;
|
||||
const sourceIds = [...new Set(items.map((item) => item.subject.id))];
|
||||
const queueRows = await db
|
||||
@@ -488,6 +504,7 @@ async function enrichAttentionItems(db: Db, companyId: string, items: AttentionI
|
||||
sourceId: decisionQueueItems.sourceId,
|
||||
key: decisionQueues.key,
|
||||
title: decisionQueues.title,
|
||||
retentionDays: decisionQueues.retentionDays,
|
||||
})
|
||||
.from(decisionQueueItems)
|
||||
.innerJoin(decisionQueues, and(
|
||||
@@ -500,11 +517,17 @@ async function enrichAttentionItems(db: Db, companyId: string, items: AttentionI
|
||||
))
|
||||
.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
|
||||
@@ -529,7 +552,7 @@ async function enrichAttentionItems(db: Db, companyId: string, items: AttentionI
|
||||
.where(and(eq(agents.companyId, companyId), inArray(agents.id, agentIds)))
|
||||
.then((rows) => rows.map((row) => [row.id, row.name] as const)));
|
||||
|
||||
return items.map((item) => {
|
||||
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 ? {
|
||||
@@ -551,6 +574,21 @@ async function enrichAttentionItems(db: Db, companyId: string, items: AttentionI
|
||||
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) {
|
||||
@@ -824,6 +862,9 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
||||
);
|
||||
return {
|
||||
list: async (companyId: string, options: AttentionListOptions = {}): Promise<AttentionFeed> => {
|
||||
if (options.all && !options.queue && !options.allowUnscopedAll) {
|
||||
throw badRequest("all requires a queue filter");
|
||||
}
|
||||
const prefix = await companyPrefix(db, companyId);
|
||||
const dismissals = await dismissalByKey(db, companyId, options.userId);
|
||||
const includeDismissed = options.includeDismissed === true;
|
||||
@@ -1592,7 +1633,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
||||
|
||||
const collectedItems = [...deduped.values()].sort(compareAttentionItems);
|
||||
await decisionQueueService(db).materializeSeededQueues(companyId, collectedItems);
|
||||
const enrichedItems = await enrichAttentionItems(db, companyId, collectedItems);
|
||||
const enrichedItems = await enrichAttentionItems(db, companyId, collectedItems, now);
|
||||
|
||||
const activitySince = parseActivityBoundary(options.activitySince, "activitySince");
|
||||
const activityUntil = parseActivityBoundary(options.activityUntil, "activityUntil");
|
||||
@@ -1601,6 +1642,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
||||
}
|
||||
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;
|
||||
@@ -1616,20 +1658,30 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
||||
? (left, right) => compareDecideItems(left, right, now)
|
||||
: compareAttentionItems)
|
||||
.map((item, index) => ({ ...item, rank: index + 1 }));
|
||||
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 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
const items = rankedItems.slice(pageStart, pageStart + limit);
|
||||
const hasNextPage = pageStart + items.length < rankedItems.length;
|
||||
const nextCursor = hasNextPage && items.length > 0 ? encodeCursor(sort, items[items.length - 1]!) : null;
|
||||
|
||||
if (options.userId) {
|
||||
const trainable: Array<{ sourceKind: "approval" | "interaction"; sourceId: string }> = [];
|
||||
|
||||
@@ -534,6 +534,7 @@ export function decisionQueueService(db: Db) {
|
||||
key: string;
|
||||
sourceKind: AttentionSourceKind;
|
||||
sourceId: string;
|
||||
reason?: string;
|
||||
authActor: AuthorizationActor;
|
||||
actor: DecisionMutationActor;
|
||||
}) => {
|
||||
@@ -569,6 +570,7 @@ export function decisionQueueService(db: Db) {
|
||||
sourceKind: input.sourceKind,
|
||||
sourceId: input.sourceId,
|
||||
action: "queue_item.removed",
|
||||
details: input.reason ? { reason: input.reason } : {},
|
||||
...eventActorColumns(input.actor),
|
||||
});
|
||||
await recordActivity(txDb, input.actor, {
|
||||
@@ -576,7 +578,11 @@ export function decisionQueueService(db: Db) {
|
||||
action: "decision_queue_item.removed",
|
||||
entityType: "decision_queue",
|
||||
entityId: queue.id,
|
||||
details: { sourceKind: input.sourceKind, sourceId: input.sourceId },
|
||||
details: {
|
||||
sourceKind: input.sourceKind,
|
||||
sourceId: input.sourceId,
|
||||
...(input.reason ? { reason: input.reason } : {}),
|
||||
},
|
||||
});
|
||||
return toQueueItem(removed);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, asc, eq, inArray, isNotNull, isNull, lte, ne, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
approvals,
|
||||
decisionArchiveNotificationOutbox,
|
||||
decisionRetention,
|
||||
decisions,
|
||||
heartbeatRuns,
|
||||
issueApprovals,
|
||||
issueRecoveryActions,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
AttentionArchiveManifestEntry,
|
||||
AttentionItem,
|
||||
AttentionSourceKind,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import type { AuthorizationActor } from "./authorization.js";
|
||||
import {
|
||||
canReadDecisionSource,
|
||||
type DecisionMutationActor,
|
||||
} from "./decision-queues.js";
|
||||
|
||||
export const DEFAULT_DECISION_SHELF_DAYS = 30;
|
||||
export const DEFAULT_DECISION_ARCHIVE_DAYS = 90;
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
export type DecisionRetentionState = typeof decisionRetention.$inferSelect;
|
||||
export type ArchiveNotificationBatch = {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
items: Array<{
|
||||
sourceKind: AttentionSourceKind;
|
||||
sourceId: string;
|
||||
archiveVersion: number;
|
||||
issueId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function sourceKey(sourceKind: string, sourceId: string) {
|
||||
return `${sourceKind}:${sourceId}`;
|
||||
}
|
||||
|
||||
function canonicalManifest(manifest: readonly AttentionArchiveManifestEntry[]) {
|
||||
return [...manifest].sort((left, right) => {
|
||||
const leftKey = `${left.companyId}:${left.sourceKind}:${left.sourceId}`;
|
||||
const rightKey = `${right.companyId}:${right.sourceKind}:${right.sourceId}`;
|
||||
return leftKey.localeCompare(rightKey);
|
||||
}).map((entry) => ({
|
||||
companyId: entry.companyId,
|
||||
sourceKind: entry.sourceKind,
|
||||
sourceId: entry.sourceId,
|
||||
expectedVersion: entry.expectedVersion,
|
||||
activityAt: entry.activityAt,
|
||||
reason: entry.reason,
|
||||
}));
|
||||
}
|
||||
|
||||
export function hashAttentionArchiveManifest(manifest: readonly AttentionArchiveManifestEntry[]) {
|
||||
return createHash("sha256").update(JSON.stringify(canonicalManifest(manifest))).digest("hex");
|
||||
}
|
||||
|
||||
function archiveActorColumns(actor: DecisionMutationActor) {
|
||||
return {
|
||||
archivedByType: actor.actorType,
|
||||
archivedByAgentId: actor.agentId,
|
||||
archivedByUserId: actor.userId,
|
||||
archivedByRunId: actor.runId,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveOrigin(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
sourceKind: AttentionSourceKind,
|
||||
sourceId: string,
|
||||
) {
|
||||
let originAgentId: string | null = null;
|
||||
let originIssueId: string | null = null;
|
||||
if (sourceKind === "approval") {
|
||||
const row = await db.select({ agentId: approvals.requestedByAgentId, 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);
|
||||
originAgentId = row?.agentId ?? null;
|
||||
originIssueId = row?.issueId ?? null;
|
||||
} else if (sourceKind === "decision") {
|
||||
const row = await db.select({ agentId: decisions.originAgentId, issueId: decisions.originIssueId })
|
||||
.from(decisions).where(and(eq(decisions.companyId, companyId), eq(decisions.id, sourceId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
originAgentId = row?.agentId ?? null;
|
||||
originIssueId = row?.issueId ?? null;
|
||||
} else if (sourceKind === "issue_thread_interaction") {
|
||||
const row = await db.select({ agentId: issueThreadInteractions.createdByAgentId, issueId: issueThreadInteractions.issueId })
|
||||
.from(issueThreadInteractions)
|
||||
.where(and(eq(issueThreadInteractions.companyId, companyId), eq(issueThreadInteractions.id, sourceId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
originAgentId = row?.agentId ?? null;
|
||||
originIssueId = row?.issueId ?? null;
|
||||
} else if (sourceKind === "failed_run") {
|
||||
const row = await db.select({ agentId: heartbeatRuns.agentId, context: heartbeatRuns.contextSnapshot })
|
||||
.from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, sourceId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const context = row?.context && typeof row.context === "object" ? row.context as Record<string, unknown> : {};
|
||||
originAgentId = row?.agentId ?? null;
|
||||
originIssueId = typeof context.issueId === "string" ? context.issueId : typeof context.taskId === "string" ? context.taskId : null;
|
||||
} else {
|
||||
const issueId = sourceKind === "recovery_action"
|
||||
? await db.select({ issueId: issueRecoveryActions.sourceIssueId }).from(issueRecoveryActions)
|
||||
.where(and(eq(issueRecoveryActions.companyId, companyId), eq(issueRecoveryActions.id, sourceId)))
|
||||
.then((rows) => rows[0]?.issueId ?? null)
|
||||
: (["productivity_review", "blocker_attention", "review"] as string[]).includes(sourceKind) ? sourceId : null;
|
||||
if (issueId) {
|
||||
const row = await db.select({ agentId: issues.createdByAgentId, issueId: issues.id }).from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.id, issueId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
originAgentId = row?.agentId ?? null;
|
||||
originIssueId = row?.issueId ?? null;
|
||||
}
|
||||
}
|
||||
if (!originAgentId || !originIssueId) return null;
|
||||
const activeAgent = await db.select({ id: agents.id }).from(agents)
|
||||
.where(and(eq(agents.companyId, companyId), eq(agents.id, originAgentId), eq(agents.status, "active")))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return activeAgent ? { originAgentId, originIssueId } : null;
|
||||
}
|
||||
|
||||
export function decisionRetentionService(
|
||||
db: Db,
|
||||
options: { notifyOriginAgent?: (batch: ArchiveNotificationBatch) => Promise<unknown> } = {},
|
||||
) {
|
||||
async function syncItems(companyId: string, items: readonly AttentionItem[]) {
|
||||
if (items.length === 0) return new Map<string, DecisionRetentionState>();
|
||||
const unique = [...new Map(items.map((item) => [itemSourceKey(item), item])).values()];
|
||||
await db.insert(decisionRetention).values(unique.map((item) => ({
|
||||
companyId,
|
||||
sourceKind: item.sourceKind,
|
||||
sourceId: item.subject.id,
|
||||
sourceActivityAt: new Date(item.activityAt),
|
||||
}))).onConflictDoNothing({
|
||||
target: [decisionRetention.companyId, decisionRetention.sourceKind, decisionRetention.sourceId],
|
||||
});
|
||||
let rows = await db.select().from(decisionRetention).where(and(
|
||||
eq(decisionRetention.companyId, companyId),
|
||||
inArray(decisionRetention.sourceId, unique.map((item) => item.subject.id)),
|
||||
));
|
||||
const byKey = new Map(rows.map((row) => [sourceKey(row.sourceKind, row.sourceId), row]));
|
||||
for (const item of unique) {
|
||||
const key = itemSourceKey(item);
|
||||
const row = byKey.get(key);
|
||||
const activityAt = new Date(item.activityAt);
|
||||
if (!row || row.sourceActivityAt.getTime() === activityAt.getTime()) continue;
|
||||
const updated = await db.update(decisionRetention).set({
|
||||
sourceActivityAt: activityAt,
|
||||
version: sql`${decisionRetention.version} + 1`,
|
||||
updatedAt: new Date(),
|
||||
}).where(and(
|
||||
eq(decisionRetention.id, row.id),
|
||||
ne(decisionRetention.sourceActivityAt, activityAt),
|
||||
)).returning().then((values) => values[0] ?? null);
|
||||
if (updated) byKey.set(key, updated);
|
||||
}
|
||||
rows = [...byKey.values()];
|
||||
return new Map(rows.map((row) => [sourceKey(row.sourceKind, row.sourceId), row]));
|
||||
}
|
||||
|
||||
function itemSourceKey(item: AttentionItem) {
|
||||
return sourceKey(item.sourceKind, item.subject.id);
|
||||
}
|
||||
|
||||
async function getState(companyId: string, sourceKind: AttentionSourceKind, sourceId: string) {
|
||||
return db.select().from(decisionRetention).where(and(
|
||||
eq(decisionRetention.companyId, companyId),
|
||||
eq(decisionRetention.sourceKind, sourceKind),
|
||||
eq(decisionRetention.sourceId, sourceId),
|
||||
)).then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function setKeep(input: {
|
||||
companyId: string;
|
||||
sourceKind: AttentionSourceKind;
|
||||
sourceId: string;
|
||||
keep: boolean;
|
||||
authActor: AuthorizationActor;
|
||||
actor: DecisionMutationActor;
|
||||
}) {
|
||||
if (!(await canReadDecisionSource(db, input.authActor, input.companyId, input.sourceKind, input.sourceId))) {
|
||||
throw notFound("Attention source not found");
|
||||
}
|
||||
const updated = await db.update(decisionRetention).set({
|
||||
keep: input.keep,
|
||||
version: sql`${decisionRetention.version} + 1`,
|
||||
updatedAt: new Date(),
|
||||
}).where(and(
|
||||
eq(decisionRetention.companyId, input.companyId),
|
||||
eq(decisionRetention.sourceKind, input.sourceKind),
|
||||
eq(decisionRetention.sourceId, input.sourceId),
|
||||
)).returning().then((rows) => rows[0] ?? null);
|
||||
if (!updated) throw notFound("Attention source not found");
|
||||
await logActivity(db, {
|
||||
companyId: input.companyId,
|
||||
actorType: input.actor.actorType,
|
||||
actorId: input.actor.actorId,
|
||||
agentId: input.actor.agentId,
|
||||
runId: input.actor.runId,
|
||||
agentApiKeyId: input.actor.agentApiKeyId,
|
||||
responsibleUserIdOverride: input.actor.responsibleUserId,
|
||||
action: "decision_retention.keep_updated",
|
||||
entityType: "attention_source",
|
||||
entityId: input.sourceId,
|
||||
details: { sourceKind: input.sourceKind, keep: input.keep },
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function changeArchived(input: {
|
||||
companyId: string;
|
||||
sourceKind: AttentionSourceKind;
|
||||
sourceId: string;
|
||||
archived: boolean;
|
||||
authActor: AuthorizationActor;
|
||||
actor: DecisionMutationActor;
|
||||
}) {
|
||||
if (!(await canReadDecisionSource(db, input.authActor, input.companyId, input.sourceKind, input.sourceId))) {
|
||||
throw notFound("Attention source not found");
|
||||
}
|
||||
const now = new Date();
|
||||
const updated = await db.update(decisionRetention).set(input.archived ? {
|
||||
archivedAt: now,
|
||||
archivedReason: "manual",
|
||||
...archiveActorColumns(input.actor),
|
||||
archiveVersion: sql`${decisionRetention.archiveVersion} + 1`,
|
||||
version: sql`${decisionRetention.version} + 1`,
|
||||
updatedAt: now,
|
||||
} : {
|
||||
archivedAt: null,
|
||||
archivedReason: null,
|
||||
archivedByType: null,
|
||||
archivedByAgentId: null,
|
||||
archivedByUserId: null,
|
||||
archivedByRunId: null,
|
||||
version: sql`${decisionRetention.version} + 1`,
|
||||
updatedAt: now,
|
||||
}).where(and(
|
||||
eq(decisionRetention.companyId, input.companyId),
|
||||
eq(decisionRetention.sourceKind, input.sourceKind),
|
||||
eq(decisionRetention.sourceId, input.sourceId),
|
||||
input.archived ? isNull(decisionRetention.archivedAt) : isNotNull(decisionRetention.archivedAt),
|
||||
)).returning().then((rows) => rows[0] ?? null);
|
||||
if (!updated) {
|
||||
const current = await getState(input.companyId, input.sourceKind, input.sourceId);
|
||||
if (!current) throw notFound("Attention source not found");
|
||||
return current;
|
||||
}
|
||||
await logActivity(db, {
|
||||
companyId: input.companyId,
|
||||
actorType: input.actor.actorType,
|
||||
actorId: input.actor.actorId,
|
||||
agentId: input.actor.agentId,
|
||||
runId: input.actor.runId,
|
||||
agentApiKeyId: input.actor.agentApiKeyId,
|
||||
responsibleUserIdOverride: input.actor.responsibleUserId,
|
||||
action: input.archived ? "decision_retention.archived" : "decision_retention.revived",
|
||||
entityType: "attention_source",
|
||||
entityId: input.sourceId,
|
||||
details: { sourceKind: input.sourceKind },
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function archiveReviewedManifest(input: {
|
||||
companyId: string;
|
||||
manifest: AttentionArchiveManifestEntry[];
|
||||
originActor: AuthorizationActor;
|
||||
decidingActor: AuthorizationActor;
|
||||
decidedByUserId: string;
|
||||
}) {
|
||||
const keys = input.manifest.map((entry) => sourceKey(entry.sourceKind, entry.sourceId));
|
||||
if (new Set(keys).size !== keys.length || input.manifest.some((entry) => entry.companyId !== input.companyId)) {
|
||||
throw unprocessable("Archive manifest must contain one exact company-scoped set");
|
||||
}
|
||||
return db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
for (const entry of input.manifest) {
|
||||
const kind = entry.sourceKind as AttentionSourceKind;
|
||||
if (!(await canReadDecisionSource(txDb, input.originActor, input.companyId, kind, entry.sourceId))
|
||||
|| !(await canReadDecisionSource(txDb, input.decidingActor, input.companyId, kind, entry.sourceId))) {
|
||||
throw conflict("archive_proposal_authority_changed", { code: "archive_proposal_authority_changed" });
|
||||
}
|
||||
await txDb.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`decision-retention:${input.companyId}:${entry.sourceKind}:${entry.sourceId}`}, 0))`);
|
||||
const state = await txDb.select().from(decisionRetention).where(and(
|
||||
eq(decisionRetention.companyId, input.companyId),
|
||||
eq(decisionRetention.sourceKind, entry.sourceKind),
|
||||
eq(decisionRetention.sourceId, entry.sourceId),
|
||||
)).then((rows) => rows[0] ?? null);
|
||||
if (!state || state.version !== entry.expectedVersion || state.sourceActivityAt.toISOString() !== entry.activityAt || state.archivedAt) {
|
||||
throw conflict("archive_proposal_stale", { code: "archive_proposal_stale" });
|
||||
}
|
||||
}
|
||||
const now = new Date();
|
||||
for (const entry of input.manifest) {
|
||||
const archived = await txDb.update(decisionRetention).set({
|
||||
archivedAt: now,
|
||||
archivedReason: "accepted_proposal",
|
||||
archivedByType: "user",
|
||||
archivedByAgentId: null,
|
||||
archivedByUserId: input.decidedByUserId,
|
||||
archivedByRunId: null,
|
||||
archiveVersion: sql`${decisionRetention.archiveVersion} + 1`,
|
||||
version: sql`${decisionRetention.version} + 1`,
|
||||
updatedAt: now,
|
||||
}).where(and(
|
||||
eq(decisionRetention.companyId, input.companyId),
|
||||
eq(decisionRetention.sourceKind, entry.sourceKind),
|
||||
eq(decisionRetention.sourceId, entry.sourceId),
|
||||
eq(decisionRetention.version, entry.expectedVersion),
|
||||
isNull(decisionRetention.archivedAt),
|
||||
)).returning({ id: decisionRetention.id });
|
||||
if (archived.length !== 1) {
|
||||
throw conflict("archive_proposal_stale", { code: "archive_proposal_stale" });
|
||||
}
|
||||
await logActivity(txDb, {
|
||||
companyId: input.companyId,
|
||||
actorType: "system",
|
||||
actorId: "decision-archive-proposal",
|
||||
responsibleUserIdOverride: input.decidedByUserId,
|
||||
action: "decision_retention.archived",
|
||||
entityType: "attention_source",
|
||||
entityId: entry.sourceId,
|
||||
details: { sourceKind: entry.sourceKind, reason: "accepted_proposal" },
|
||||
});
|
||||
}
|
||||
return { archived: input.manifest.length };
|
||||
});
|
||||
}
|
||||
|
||||
async function autoArchive(input: { companyId: string; items: AttentionItem[]; now?: Date }) {
|
||||
const now = input.now ?? new Date();
|
||||
const cutoff = new Date(now.getTime() - DEFAULT_DECISION_ARCHIVE_DAYS * DAY_MS);
|
||||
const candidates = input.items.filter((item) => new Date(item.activityAt) <= cutoff && !item.keep && !item.archivedAt);
|
||||
let archived = 0;
|
||||
await db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
for (const item of candidates) {
|
||||
const updated = await txDb.update(decisionRetention).set({
|
||||
archivedAt: now,
|
||||
archivedReason: "idle_ttl",
|
||||
archivedByType: "system",
|
||||
archivedByAgentId: null,
|
||||
archivedByUserId: null,
|
||||
archivedByRunId: null,
|
||||
archiveVersion: sql`${decisionRetention.archiveVersion} + 1`,
|
||||
version: sql`${decisionRetention.version} + 1`,
|
||||
updatedAt: now,
|
||||
}).where(and(
|
||||
eq(decisionRetention.companyId, input.companyId),
|
||||
eq(decisionRetention.sourceKind, item.sourceKind),
|
||||
eq(decisionRetention.sourceId, item.subject.id),
|
||||
eq(decisionRetention.version, item.retentionVersion),
|
||||
eq(decisionRetention.keep, false),
|
||||
isNull(decisionRetention.archivedAt),
|
||||
lte(decisionRetention.sourceActivityAt, cutoff),
|
||||
)).returning().then((rows) => rows[0] ?? null);
|
||||
if (!updated) continue;
|
||||
archived += 1;
|
||||
const origin = await resolveOrigin(txDb, input.companyId, item.sourceKind, item.subject.id);
|
||||
if (origin) {
|
||||
await txDb.insert(decisionArchiveNotificationOutbox).values({
|
||||
companyId: input.companyId,
|
||||
sourceKind: item.sourceKind,
|
||||
sourceId: item.subject.id,
|
||||
archiveVersion: updated.archiveVersion,
|
||||
originAgentId: origin.originAgentId,
|
||||
originIssueId: origin.originIssueId,
|
||||
}).onConflictDoNothing();
|
||||
}
|
||||
await logActivity(txDb, {
|
||||
companyId: input.companyId,
|
||||
actorType: "system",
|
||||
actorId: "decision-retention-sweeper",
|
||||
action: "decision_retention.auto_archived",
|
||||
entityType: "attention_source",
|
||||
entityId: item.subject.id,
|
||||
details: { sourceKind: item.sourceKind, archiveVersion: updated.archiveVersion },
|
||||
});
|
||||
}
|
||||
});
|
||||
return archived;
|
||||
}
|
||||
|
||||
async function deliverNotifications(limit = 500) {
|
||||
if (!options.notifyOriginAgent) return { notifiedAgents: 0, delivered: 0 };
|
||||
const claimAt = new Date();
|
||||
const staleClaimAt = new Date(claimAt.getTime() - 5 * 60_000);
|
||||
await db.update(decisionArchiveNotificationOutbox).set({ status: "pending", updatedAt: claimAt })
|
||||
.where(and(
|
||||
eq(decisionArchiveNotificationOutbox.status, "delivering"),
|
||||
or(isNull(decisionArchiveNotificationOutbox.lastAttemptAt), lte(decisionArchiveNotificationOutbox.lastAttemptAt, staleClaimAt)),
|
||||
));
|
||||
const rows = await db.transaction(async (tx) => {
|
||||
const pendingIds = await tx.select({ id: decisionArchiveNotificationOutbox.id })
|
||||
.from(decisionArchiveNotificationOutbox)
|
||||
.where(eq(decisionArchiveNotificationOutbox.status, "pending"))
|
||||
.orderBy(asc(decisionArchiveNotificationOutbox.createdAt), asc(decisionArchiveNotificationOutbox.id))
|
||||
.limit(limit)
|
||||
.then((values) => values.map((value) => value.id));
|
||||
if (pendingIds.length === 0) return [];
|
||||
return tx.update(decisionArchiveNotificationOutbox).set({
|
||||
status: "delivering",
|
||||
lastAttemptAt: claimAt,
|
||||
attemptCount: sql`${decisionArchiveNotificationOutbox.attemptCount} + 1`,
|
||||
updatedAt: claimAt,
|
||||
}).where(and(
|
||||
inArray(decisionArchiveNotificationOutbox.id, pendingIds),
|
||||
eq(decisionArchiveNotificationOutbox.status, "pending"),
|
||||
)).returning();
|
||||
});
|
||||
const groups = new Map<string, typeof rows>();
|
||||
for (const row of rows) {
|
||||
const key = `${row.companyId}:${row.originAgentId}`;
|
||||
const grouped = groups.get(key) ?? [];
|
||||
grouped.push(row);
|
||||
groups.set(key, grouped);
|
||||
}
|
||||
let notifiedAgents = 0;
|
||||
let delivered = 0;
|
||||
for (const grouped of groups.values()) {
|
||||
const first = grouped[0]!;
|
||||
try {
|
||||
await options.notifyOriginAgent({
|
||||
companyId: first.companyId,
|
||||
agentId: first.originAgentId,
|
||||
items: grouped.map((row) => ({
|
||||
sourceKind: row.sourceKind as AttentionSourceKind,
|
||||
sourceId: row.sourceId,
|
||||
archiveVersion: row.archiveVersion,
|
||||
issueId: row.originIssueId,
|
||||
})),
|
||||
});
|
||||
const deliveredAt = new Date();
|
||||
await db.update(decisionArchiveNotificationOutbox).set({
|
||||
status: "delivered",
|
||||
deliveredAt,
|
||||
lastAttemptAt: deliveredAt,
|
||||
updatedAt: deliveredAt,
|
||||
}).where(and(
|
||||
inArray(decisionArchiveNotificationOutbox.id, grouped.map((row) => row.id)),
|
||||
eq(decisionArchiveNotificationOutbox.status, "delivering"),
|
||||
));
|
||||
notifiedAgents += 1;
|
||||
delivered += grouped.length;
|
||||
} catch {
|
||||
await db.update(decisionArchiveNotificationOutbox).set({
|
||||
status: "pending",
|
||||
lastAttemptAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}).where(and(
|
||||
inArray(decisionArchiveNotificationOutbox.id, grouped.map((row) => row.id)),
|
||||
eq(decisionArchiveNotificationOutbox.status, "delivering"),
|
||||
));
|
||||
}
|
||||
}
|
||||
return { notifiedAgents, delivered };
|
||||
}
|
||||
|
||||
return {
|
||||
syncItems,
|
||||
getState,
|
||||
setKeep,
|
||||
archive: (input: Omit<Parameters<typeof changeArchived>[0], "archived">) => changeArchived({ ...input, archived: true }),
|
||||
revive: (input: Omit<Parameters<typeof changeArchived>[0], "archived">) => changeArchived({ ...input, archived: false }),
|
||||
archiveReviewedManifest,
|
||||
autoArchive,
|
||||
deliverNotifications,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DecisionServiceOptions } from "./decisions.js";
|
||||
import type { ArchiveNotificationBatch } from "./decision-retention.js";
|
||||
|
||||
type HeartbeatWakeup = (
|
||||
agentId: string,
|
||||
@@ -30,3 +31,22 @@ export function createDecisionWakeOriginAgent(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createDecisionRetentionNotifyOriginAgent(
|
||||
wakeup: HeartbeatWakeup | null,
|
||||
): (batch: ArchiveNotificationBatch) => Promise<unknown> {
|
||||
if (!wakeup) return async () => null;
|
||||
return async (batch) => wakeup(batch.agentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "attention_auto_archived",
|
||||
payload: {
|
||||
issueIds: [...new Set(batch.items.map((item) => item.issueId))],
|
||||
archives: batch.items.map((item) => ({
|
||||
sourceKind: item.sourceKind,
|
||||
sourceId: item.sourceId,
|
||||
archiveVersion: item.archiveVersion,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, asc, count, desc, eq, gt, gte, inArray, lte, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companyMemberships, decisionBundles, decisionEffectExecutions, decisions, decisionTargetIssues, heartbeatRuns, issueRelations, issues } from "@paperclipai/db";
|
||||
import type { DecisionEffect, DecisionInput, DecisionOption, DecisionStatsCounts, DecisionStatsResponse } from "@paperclipai/shared";
|
||||
import { companyMemberships, decisionBundles, decisionEffectExecutions, decisionRetention, decisions, decisionTargetIssues, heartbeatRuns, issueRelations, issues } from "@paperclipai/db";
|
||||
import { ATTENTION_SOURCE_KINDS } from "@paperclipai/shared";
|
||||
import type { AttentionArchiveManifestEntry, DecisionEffect, DecisionInput, DecisionOption, DecisionStatsCounts, DecisionStatsResponse } from "@paperclipai/shared";
|
||||
import { conflict, forbidden, notFound, tooManyRequests, unprocessable } from "../errors.js";
|
||||
import { authorizationService, type AuthorizationActor } from "./authorization.js";
|
||||
import { logActivity, publishActivity, type ActivityPublication } from "./activity-log.js";
|
||||
import { signDecisionSpec, verifyDecisionSpec } from "./decision-signing.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { decisionRetentionService, hashAttentionArchiveManifest } from "./decision-retention.js";
|
||||
|
||||
type Snapshot = { status: string; assigneeAgentId: string | null; assigneeUserId: string | null; updatedAt: string;
|
||||
descendantCount?: number; descendantIds?: string[]; childCount?: number };
|
||||
descendantCount?: number; descendantIds?: string[]; childCount?: number; attentionArchive?: unknown };
|
||||
type Wake = (input: { companyId: string; agentId: string; issueId: string; decisionId: string; outcome: "decided" | "expired" | "cancelled" }) => Promise<unknown>;
|
||||
export type DecisionServiceOptions = { wakeOriginAgent: Wake };
|
||||
const DAY = 86_400_000;
|
||||
@@ -78,6 +80,43 @@ function canonicalJson(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function parseAttentionArchiveManifest(
|
||||
companyId: string,
|
||||
targetSnapshots: Record<string, Snapshot>,
|
||||
): AttentionArchiveManifestEntry[] | null {
|
||||
const entries: AttentionArchiveManifestEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const [key, snapshot] of Object.entries(targetSnapshots)) {
|
||||
if (!key.startsWith("attention:")) return null;
|
||||
const entry = snapshot.attentionArchive;
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
|
||||
const candidate = entry as Record<string, unknown>;
|
||||
if (candidate.companyId !== companyId
|
||||
|| typeof candidate.sourceKind !== "string"
|
||||
|| !ATTENTION_SOURCE_KINDS.includes(candidate.sourceKind as (typeof ATTENTION_SOURCE_KINDS)[number])
|
||||
|| typeof candidate.sourceId !== "string"
|
||||
|| !candidate.sourceId
|
||||
|| !Number.isInteger(candidate.expectedVersion)
|
||||
|| Number(candidate.expectedVersion) < 1
|
||||
|| typeof candidate.activityAt !== "string"
|
||||
|| !Number.isFinite(Date.parse(candidate.activityAt))
|
||||
|| typeof candidate.reason !== "string"
|
||||
|| !candidate.reason.trim()) return null;
|
||||
const expectedKey = `attention:${candidate.sourceKind}:${candidate.sourceId}`;
|
||||
if (key !== expectedKey || seen.has(expectedKey)) return null;
|
||||
seen.add(expectedKey);
|
||||
entries.push({
|
||||
companyId,
|
||||
sourceKind: candidate.sourceKind,
|
||||
sourceId: candidate.sourceId,
|
||||
expectedVersion: Number(candidate.expectedVersion),
|
||||
activityAt: candidate.activityAt,
|
||||
reason: candidate.reason,
|
||||
});
|
||||
}
|
||||
return entries.length > 0 ? entries : null;
|
||||
}
|
||||
|
||||
function boardCanActDirectly(actor: AuthorizationActor, companyId: string) {
|
||||
if (actor.type !== "board") return false;
|
||||
if (actor.source === "local_implicit" || actor.isInstanceAdmin) return true;
|
||||
@@ -91,6 +130,7 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
|
||||
type CreateInput = { companyId: string; actor: AuthorizationActor; agentId: string; runId: string; bundleId?: string | null;
|
||||
ruleKey?: string | null; title: string; body: string; options: DecisionOption[]; inputs?: DecisionInput[] | null; expiresAt?: Date | null;
|
||||
idempotencyKey?: string | null; continuationPolicy?: "none" | "wake_origin_agent"; metadata?: Record<string, unknown> };
|
||||
type CreateInputWithSnapshots = CreateInput & { additionalTargetSnapshots?: Record<string, Snapshot> };
|
||||
|
||||
async function origin(companyId: string, agentId: string, runId: string) {
|
||||
const run = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, agentId)))
|
||||
@@ -155,7 +195,7 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
|
||||
return result;
|
||||
}
|
||||
|
||||
async function createInStore(input: CreateInput, dbOrTx: Db) {
|
||||
async function createInStore(input: CreateInputWithSnapshots, dbOrTx: Db) {
|
||||
if (input.idempotencyKey) {
|
||||
const lockKey = `decision-create:${input.companyId}:${input.idempotencyKey}`;
|
||||
await dbOrTx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
|
||||
@@ -165,8 +205,14 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
|
||||
const existing = await dbOrTx.select().from(decisions).where(and(eq(decisions.companyId, input.companyId), eq(decisions.idempotencyKey, input.idempotencyKey)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existing) {
|
||||
const archiveEquivalent = input.additionalTargetSnapshots === undefined || (
|
||||
canonicalJson(existing.metadata ?? {}) === canonicalJson(input.metadata ?? {}) &&
|
||||
canonicalJson(Object.fromEntries(Object.entries(existing.targetSnapshots as Record<string, Snapshot>)
|
||||
.filter(([key]) => key.startsWith("attention:")))) === canonicalJson(input.additionalTargetSnapshots)
|
||||
);
|
||||
const equivalent = existing.title === input.title && existing.body === input.body &&
|
||||
canonicalJson(existing.options) === canonicalJson(input.options) && canonicalJson(existing.inputs ?? null) === canonicalJson(input.inputs ?? null);
|
||||
canonicalJson(existing.options) === canonicalJson(input.options) && canonicalJson(existing.inputs ?? null) === canonicalJson(input.inputs ?? null) &&
|
||||
archiveEquivalent;
|
||||
if (!equivalent) throw conflict("Decision idempotency key already used with a different payload");
|
||||
return existing;
|
||||
}
|
||||
@@ -179,7 +225,10 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
|
||||
const ids = targetIds(input.options);
|
||||
const cancellationTargetIds = new Set(input.options.flatMap((option) => option.effects
|
||||
.filter((effect) => effect.type === "cancel_issue_tree").map((effect) => effect.targetIssueId)));
|
||||
const targetSnapshots = await snapshots(input.companyId, ids, input.actor, targetActions(input.options), cancellationTargetIds, dbOrTx);
|
||||
const targetSnapshots = {
|
||||
...await snapshots(input.companyId, ids, input.actor, targetActions(input.options), cancellationTargetIds, dbOrTx),
|
||||
...(input.additionalTargetSnapshots ?? {}),
|
||||
};
|
||||
const id = randomUUID();
|
||||
const [created] = await dbOrTx.insert(decisions).values({ id, companyId: input.companyId, bundleId: input.bundleId ?? null,
|
||||
originAgentId: input.agentId, originIssueId: provenance.issueId, originRunId: input.runId, ruleKey: input.ruleKey ?? null,
|
||||
@@ -190,8 +239,14 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
|
||||
const existing = input.idempotencyKey
|
||||
? await dbOrTx.select().from(decisions).where(and(eq(decisions.companyId, input.companyId), eq(decisions.idempotencyKey, input.idempotencyKey))).then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
const archiveEquivalent = !existing || input.additionalTargetSnapshots === undefined || (
|
||||
canonicalJson(existing.metadata ?? {}) === canonicalJson(input.metadata ?? {}) &&
|
||||
canonicalJson(Object.fromEntries(Object.entries(existing.targetSnapshots as Record<string, Snapshot>)
|
||||
.filter(([key]) => key.startsWith("attention:")))) === canonicalJson(input.additionalTargetSnapshots)
|
||||
);
|
||||
const equivalent = existing && existing.title === input.title && existing.body === input.body &&
|
||||
canonicalJson(existing.options) === canonicalJson(input.options) && canonicalJson(existing.inputs ?? null) === canonicalJson(input.inputs ?? null);
|
||||
canonicalJson(existing.options) === canonicalJson(input.options) && canonicalJson(existing.inputs ?? null) === canonicalJson(input.inputs ?? null) &&
|
||||
archiveEquivalent;
|
||||
if (equivalent) return existing;
|
||||
throw conflict("Decision idempotency key already used with a different payload");
|
||||
}
|
||||
@@ -202,7 +257,7 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
|
||||
return created;
|
||||
}
|
||||
|
||||
async function create(input: CreateInput, dbOrTx?: Db) {
|
||||
async function create(input: CreateInputWithSnapshots, dbOrTx?: Db) {
|
||||
if (dbOrTx) return createInStore(input, dbOrTx);
|
||||
return db.transaction((tx) => createInStore(input, tx as unknown as Db));
|
||||
}
|
||||
@@ -226,13 +281,29 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
|
||||
}
|
||||
const rows = await db.select().from(decisions).where(and(...conditions)).orderBy(desc(decisions.createdAt)).limit(Math.min(filter.limit ?? 50, 100));
|
||||
const openDecisionIds = rows.filter((decision) => decision.status === "open").map((decision) => decision.id);
|
||||
const currentTargets = openDecisionIds.length
|
||||
const currentIssueTargetIds = rows.flatMap((decision) => Object.keys(decision.targetSnapshots as Record<string, Snapshot>))
|
||||
.filter((id) => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id));
|
||||
const currentTargets = openDecisionIds.length && currentIssueTargetIds.length
|
||||
? await db.select({ id: issues.id, updatedAt: issues.updatedAt })
|
||||
.from(decisionTargetIssues)
|
||||
.innerJoin(issues, and(eq(issues.companyId, companyId), eq(issues.id, decisionTargetIssues.issueId)))
|
||||
.where(and(eq(decisionTargetIssues.companyId, companyId), inArray(decisionTargetIssues.decisionId, openDecisionIds)))
|
||||
.where(and(eq(decisionTargetIssues.companyId, companyId), inArray(decisionTargetIssues.decisionId, openDecisionIds), inArray(issues.id, currentIssueTargetIds)))
|
||||
: [];
|
||||
const currentTargetsById = new Map(currentTargets.map((target) => [target.id, target.updatedAt]));
|
||||
const attentionSnapshotIds = rows.flatMap((decision) => Object.values(decision.targetSnapshots as Record<string, Snapshot>))
|
||||
.map((snapshot) => snapshot.attentionArchive as AttentionArchiveManifestEntry | undefined)
|
||||
.filter((entry): entry is AttentionArchiveManifestEntry => Boolean(entry))
|
||||
.map((entry) => entry.sourceId);
|
||||
const currentAttentionStates = attentionSnapshotIds.length
|
||||
? await db.select().from(decisionRetention).where(and(
|
||||
eq(decisionRetention.companyId, companyId),
|
||||
inArray(decisionRetention.sourceId, attentionSnapshotIds),
|
||||
))
|
||||
: [];
|
||||
const currentAttentionByKey = new Map(currentAttentionStates.map((state) => [
|
||||
`attention:${state.sourceKind}:${state.sourceId}`,
|
||||
state,
|
||||
]));
|
||||
const terminalDecisionIds = rows.filter((decision) => decision.status !== "open").map((decision) => decision.id);
|
||||
const terminalExecutions = terminalDecisionIds.length
|
||||
? await db.select().from(decisionEffectExecutions)
|
||||
@@ -248,6 +319,13 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
|
||||
return rows.map((decision) => {
|
||||
const changed: Record<string, boolean> = {};
|
||||
if (decision.status === "open") for (const [id, snapshot] of Object.entries(decision.targetSnapshots as Record<string, Snapshot>)) {
|
||||
if (id.startsWith("attention:")) {
|
||||
const entry = snapshot.attentionArchive as AttentionArchiveManifestEntry | undefined;
|
||||
const current = currentAttentionByKey.get(id);
|
||||
changed[id] = !entry || !current || current.version !== entry.expectedVersion
|
||||
|| current.sourceActivityAt.toISOString() !== entry.activityAt || Boolean(current.archivedAt);
|
||||
continue;
|
||||
}
|
||||
const currentUpdatedAt = currentTargetsById.get(id);
|
||||
changed[id] = !currentUpdatedAt || currentUpdatedAt.toISOString() !== snapshot.updatedAt;
|
||||
}
|
||||
@@ -468,6 +546,58 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
|
||||
const option = decision.options.find((item) => item.id === decision.chosenOptionId);
|
||||
if (!option || !decision.decidedByUserId) throw unprocessable("Stored decision outcome is invalid");
|
||||
const run = await db.select({ responsibleUserId: heartbeatRuns.responsibleUserId }).from(heartbeatRuns).where(eq(heartbeatRuns.id, decision.originRunId)).then((rows) => rows[0] ?? null);
|
||||
const metadata = decision.metadata as Record<string, unknown>;
|
||||
if (metadata.kind === "attention_archive_proposal") {
|
||||
if (!verifyDecisionSpec(spec({
|
||||
id: decision.id,
|
||||
options: decision.options,
|
||||
targetSnapshots: decision.targetSnapshots as Record<string, Snapshot>,
|
||||
}), decision.signedSpec)) {
|
||||
await db.update(decisions).set({ executionStatus: "failed", updatedAt: new Date(), metadata: { ...metadata, archiveProposalError: "invalid_signature" } })
|
||||
.where(eq(decisions.id, decision.id));
|
||||
return outcome(decision.id);
|
||||
}
|
||||
if (option.id === "archive") {
|
||||
const manifest = parseAttentionArchiveManifest(
|
||||
decision.companyId,
|
||||
decision.targetSnapshots as Record<string, Snapshot>,
|
||||
);
|
||||
const manifestHash = manifest ? hashAttentionArchiveManifest(manifest) : null;
|
||||
if (!manifest || manifestHash !== metadata.manifestHash) {
|
||||
await db.update(decisions).set({ executionStatus: "failed", updatedAt: new Date(), metadata: { ...metadata, archiveProposalError: "manifest_mismatch" } })
|
||||
.where(eq(decisions.id, decision.id));
|
||||
return outcome(decision.id);
|
||||
}
|
||||
const originActor: AuthorizationActor = {
|
||||
type: "agent",
|
||||
agentId: decision.originAgentId,
|
||||
companyId: decision.companyId,
|
||||
runId: decision.originRunId,
|
||||
onBehalfOfUserId: run?.responsibleUserId ?? null,
|
||||
source: "agent_jwt",
|
||||
};
|
||||
try {
|
||||
await decisionRetentionService(db).archiveReviewedManifest({
|
||||
companyId: decision.companyId,
|
||||
manifest,
|
||||
originActor,
|
||||
decidingActor: userActor,
|
||||
decidedByUserId: decision.decidedByUserId,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "archive_proposal_failed";
|
||||
await db.update(decisions).set({ executionStatus: "failed", updatedAt: new Date(), metadata: { ...metadata, archiveProposalError: message } })
|
||||
.where(eq(decisions.id, decision.id));
|
||||
return outcome(decision.id);
|
||||
}
|
||||
}
|
||||
await db.update(decisions).set({
|
||||
executionStatus: "succeeded",
|
||||
updatedAt: new Date(),
|
||||
metadata: { ...metadata, ...(decision.continuationPolicy === "wake_origin_agent" ? { continuationPending: true } : {}) },
|
||||
}).where(eq(decisions.id, decision.id));
|
||||
return outcome(decision.id);
|
||||
}
|
||||
for (let index = 0; index < option.effects.length; index += 1) await executeEffect(decision, option.effects[index]!, index, userActor, decision.decidedByUserId, run?.responsibleUserId ?? null);
|
||||
const rows = await db.select().from(decisionEffectExecutions).where(eq(decisionEffectExecutions.decisionId, decision.id));
|
||||
const successful = rows.filter((row) => row.status === "executed").length;
|
||||
|
||||
@@ -66,6 +66,7 @@ export { workTimelineService, normalizeTimelineWindow } from "./work-timeline.js
|
||||
export { attentionService } from "./attention.js";
|
||||
export { captureDecisionSnapshot, decisionTrainingService } from "./decision-training.js";
|
||||
export { decisionService } from "./decisions.js";
|
||||
export { decisionRetentionService } from "./decision-retention.js";
|
||||
export type {
|
||||
WorkTimelineActor,
|
||||
WorkTimelineEdge,
|
||||
|
||||
Reference in new issue
Block a user