fix(server): schedule approval retention from durable source deadlines

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-24 14:43:07 -07:00
1 parent f240381fd8
commit ea3dd40cdd
27 files changed
+893 -150

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
# Approval retention without idle polling
This slice removes approval retention from the recurring attention-feed sweep.
It does not migrate the other attention source kinds or establish host sleep.
## Obligation and owner
A pending approval in an active company is eligible for automatic archiving
90 days after its last approval activity, unless kept or already archived.
This is a presentation/attention archive: it does not approve, reject, cancel,
or delete the approval. The deadline and policy are unchanged.
Approval rows are the source of truth, including those never viewed in the
attention UI. Scheduling only existing `decision_retention` rows would miss
unseen approvals because the old feed reader materializes those rows lazily.
`approval-retention.ts` discovers eligible approvals directly and registers
the earliest actual deadline with the existing delivery coordinator.
The query retains the attention feed's first-linked-issue rule and consumes
`executionIssueCondition` from the issue visibility owner. Hidden, harness and
agent-conversation tasks remain excluded. A waiting external conversation with
a linked pending approval is active under that same shared visibility policy;
its chat state alone cannot hide this approval. Tests exercise the Slack case.
Each pass visits at most 100 candidates in activity/id order. A failed row does
not prevent later pages from progressing. The failed durable record remains
eligible for recovery. Each candidate is re-read and locked before archiving,
so a resubmission committed while the worker waited cannot archive the new
activity using an old selection. Existing retention version, keep and archive
compare-and-set checks remain authoritative. This does not add cross-table
serializability for concurrent link/visibility/company policy changes.
## Producer coverage
| Supported producer | Notification boundary |
| --- | --- |
| Approval create, cancel, revision, resubmit | SQL write transaction in approval records |
| Approval resolution, including hire lifecycle | SQL write, retaining the caller's outer transaction |
| Hard budget approval and budget resolution | Existing accounting transaction |
| Formal tool approval and issue link | SQL write transaction; provider work is outside it |
| Link, unlink and bulk link | Issue approval write transaction |
| Issue visibility changes and deletion | Existing issue mutation transaction |
| Routine creation rollback cleanup | Existing transaction before cascading issue deletion |
| Company activation, pause, archive and deletion | Existing company/accounting transaction |
| Keep, manual archive, revive and accepted archive manifest | Existing retention mutation transaction |
| Retention materialization or source activity change | Signal only when inserting or changing actual state |
Approval comments do not change approval activity under the existing policy.
Company portability imports do not write approvals; restored durable records
are reconstructed at startup. Signals belong to the application DB owner and
its dedicated child pools. Independent SQL writers or another active process
need explicit integration; this change does not claim to observe them.
## Shared contracts and lifecycle
The main maintenance feed excludes the approval source before collecting it.
Public attention reads keep their existing default behavior. Other attention
sources remain in the aggregate sweep until separately migrated.
The retention service accepts the source fields it actually uses rather than
requiring fabricated full UI attention items. Its existing versioned state,
audit writes, authorization and archive-notification outbox are reused. No new
queue table is introduced. Transaction-aware notifications retain intent until
the outer transaction settles, including nested writes and rollback.
The deadline worker uses the shared scheduler's generation and drain behavior.
Startup reconstructs pending work; idle drain retains requests without passive
SQL; resume reconciles them. Failed work retains a 30-second recovery spacing.
No eligible approval means no recurring deadline or query. Completed history,
kept/archived approvals, and inactive companies are quiet until a supported
mutation changes eligibility. Shutdown joins admitted work and stops between
candidates. Notification delivery remains owned by the separate archive worker.
A pending-only `(updated_at, id)` index bounds deadline lookup independently of
resolved approval history. The migration uses concurrent, idempotent creation
and the existing migration runner's nontransactional execution list.
## Verification and limits
Real-Postgres lifecycle tests cover unseen sources, future deadlines, retained
history, keep/unkeep, archive/revive, revision/resubmission, company activity,
linked issue visibility/deletion/order, normal versus maintenance attention
reads, nested commits/rollback, startup and first-write drain, failed database
writes through drain, bounded progress past failure, shutdown, and a concurrent
resubmission. The waiting-Slack case also checks durable notification creation.
Existing approval, attention, retention, budget, tool-gateway and startup suites
are relevant consumers. Track final results and any failures in the PR and the
[stack ledger](plans/2026-10-24-recurring-server-sleep-stack.md).
The Cloud sleep guards are unchanged. An in-process deadline cannot wake stopped
compute, and this approval deadline has not been added to Cloud's durable wake
contract. This proves only the named worker's quiet/reactivation behavior.
@@ -15,8 +15,8 @@ Do not mark an item done from a timer removal or a green check alone.
| 01 | Custom-image setup expiry | Session writes, existing expiry, startup/retry/drain | [#17443](https://github.com/paperclipai/paperclip/pull/17443), all checks passed, Greptile 5/5 at `36844a458b` |
| 02 | Remaining chat maintenance | Provider runtime discovery; voice reconciliation; milestones; wake notices; interaction continuations; terminal confirmations; GitHub recovery; Teams maintenance, separately by owner | Pending |
| 03 | Run and task recovery | Orphan runs and active leases; shutdown workspace settlement; scheduled retries; queued runs; stranded assignments; resolved dependencies; task watchdogs; silent-run monitoring; stale locks | Pending |
| 04 | Decision retention and notifications | Retention deadlines and notification delivery are separate reasons | Retention pending; notification delivery: [#17475](https://github.com/paperclipai/paperclip/pull/17475), all checks passed (Paperclip Review skipped), Greptile 5/5 at `692e3ee02c` |
| 05 | Decision expiry and interrupted effects | Expiry, effect recovery, continuation recovery | Continuations: [#17478](https://github.com/paperclipai/paperclip/pull/17478), all checks passed, Greptile 5/5 at `5f96892a58`; TTL: [#17479](https://github.com/paperclipai/paperclip/pull/17479), all checks passed, Greptile 5/5 at `63b58cc26f`; effects: [#17482](https://github.com/paperclipai/paperclip/pull/17482), local verification passed at `a5b5994c1d`, CI/review running; target invalidation pending |
| 04 | Decision retention and notifications | Retention deadlines and notification delivery are separate reasons | Approval retention implemented in slice 14, verification in progress; other source owners pending; notification delivery: [#17475](https://github.com/paperclipai/paperclip/pull/17475), all checks passed (Paperclip Review skipped), Greptile 5/5 at `692e3ee02c` |
| 05 | Decision expiry and interrupted effects | Expiry, effect recovery, continuation recovery | Continuations: [#17478](https://github.com/paperclipai/paperclip/pull/17478), all checks passed, Greptile 5/5 at `5f96892a58`; TTL: [#17479](https://github.com/paperclipai/paperclip/pull/17479), all checks passed, Greptile 5/5 at `63b58cc26f`; effects: [#17482](https://github.com/paperclipai/paperclip/pull/17482), all checks passed, Greptile 5/5 at `a5b5994c1d`; target invalidation: [#17486](https://github.com/paperclipai/paperclip/pull/17486), all checks passed, Greptile 5/5 at `1bbfc69590` |
| 06 | Tool connection health | Configuration/use activation and defined proactive health deadlines | Pending |
| 07 | Secret proposals, tool reviews, gateway tokens | Separate expiry/scrub/recovery owners | Secret proposals: [#17446](https://github.com/paperclipai/paperclip/pull/17446), checks passed at `d41590ac0e`; gateway credentials: [#17450](https://github.com/paperclipai/paperclip/pull/17450), all checks passed, Greptile 5/5 at `c5c53fc43f`; pending tool-review expiry: [#17453](https://github.com/paperclipai/paperclip/pull/17453), all checks passed, Greptile 5/5 at `ce6a260a83`; approved recovery: [#17454](https://github.com/paperclipai/paperclip/pull/17454), all checks passed, Greptile 5/5 at `7a1cf03e62`; interrupted claims: [#17458](https://github.com/paperclipai/paperclip/pull/17458), all checks passed, Greptile 5/5 at `85003a7ac4`; projection: [#17468](https://github.com/paperclipai/paperclip/pull/17468), all checks passed, Greptile 5/5 at `0956883a84` |
| 08 | GitHub connection events and continuity | Event discovery/replay and credential continuity separately | Pending |
@@ -28,7 +28,8 @@ Do not mark an item done from a timer removal or a green check alone.
| 14 | Skill-version retention and native-harness backup expiration | Separate retention owners and existing retention guarantees | Pending |
| 15 | Automatic database backups | Shared scheduling, actual backup deadlines, existing checkpoint contract | Pending |
| 16 | Aggregate maintenance tick | Retire enabled and disabled branches after all constituent owners migrate | Pending |
| 17 | Grok routine dispatch | Committed reservations and feature changes; prepared-only recovery | Slice 13 implemented, local verification in progress |
| 17 | Grok routine dispatch | Committed reservations and feature changes; prepared-only recovery | [#17490](https://github.com/paperclipai/paperclip/pull/17490), all checks passed, Greptile 5/5 at `706beb01f2` |
| 18 | Abandoned import transfer spools | Creation/completion events, existing retention deadline, startup recovery | Pending; explicitly retained from the remaining-blocker checklist |
## Stack and verification contract
@@ -730,3 +731,58 @@ at this snapshot). These are verified live waits, not completed checks. Do not
rerun them merely because a later observation times out. Continue inspecting
these runs while implementing retention, and propagate any required parent
repair into the later branch. No stack PR was merged.
## Approval retention implementation in progress
Remote verification on October 24: #17486 at `1bbfc69590` and #17490 at
`706beb01f2` each have 56 completed checks, all successful or accepted
neutral/skipped outcomes. Their PR bodies now record completion. Neither was
merged; this remains a stack.
The retention audit is now split by source owner. Slice 14 migrates **approval
retention only**. Decisions, interactions, join requests, recovery actions,
blocker attention, review, failed runs, budget alerts and agent errors remain
explicitly pending. The original full recurring-work inventory remains scope.
The implementation discovers pending approvals directly, including approvals
never materialized by an attention-feed read. It uses active-company status,
the feed's first linked issue, shared execution visibility, keep/archive state,
and the approval's updated timestamp. The next obligation is updated timestamp
plus 90 days. Each bounded pass rechecks and locks the approval before using
existing versioned retention, audit and notification-outbox writes. Other
retention sources still use the aggregate sweep; approval collection is excluded
from that maintenance feed before its query, while normal UI reads are unchanged.
Signals cover approval create/cancel/revision/resubmit/resolve, hard budget
approvals, formal tool approvals, link/unlink/bulk linking, issue visibility and
cascading deletion, company activation/pause/archive/deletion, and keep/archive/
revive/accepted archive manifests. Source comments do not alter approval activity
under the existing policy. Company portability imports no approval records;
offline restore is reconstructed at startup. Independent SQL writers still
require a wake integration. The Cloud sleep proof is unchanged.
Initial real-Postgres proof: 14 lifecycle cases passed, then 19 including actual
archive-write faults, page progress past a failed row, startup drain, first-link
ordering and shutdown. The first consumer run passed retention and startup but
failed five approval unit cases because its transaction stub omitted nested
transactions; the stub was repaired without weakening production behavior.
The budget suite initially skipped due to embedded-Postgres availability. Budget
and attention suites now accept validated disposable external test databases.
Expanded consumer verification, final index/migration proof, build and PR review
remain required. No slice-14 PR has been published yet.
Slice 14 expanded proof: all 20 worker lifecycle cases pass. The shared execution
visibility predicate is used directly, and a waiting Slack conversation with a
pending approval remains eligible with its archive notification persisted. The
repaired approval stub and attention/retention/startup suites pass. Of 96 consumer
cases, one budget case initially timed out during lazy lifecycle module loading
and raced teardown; moving the import into setup preserves the original timeout
and all five budget cases pass. No assertion or production behavior was weakened.
The initial index diagnostic failed to construct a Drizzle client around the
scoped raw driver; supplying its existing driver options fixed the diagnostic.
The actual deadline query with 40,000 resolved approvals uses the pending index,
four shared buffer hits and 0.314 ms execution; test data rolled back. Migration
replay and checks pass. Workspace types, build and tool-gateway tests remain live
in the current verification run. This is not final acceptance.
+1
View File
@@ -511,6 +511,7 @@ const MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS = new Set([
"0383_clever_chamber.sql",
"0384_many_reaper.sql",
"0385_normal_blindfold.sql",
"0386_omniscient_luke_cage.sql",
"0380_funny_adam_warlock.sql",
]);
@@ -0,0 +1 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS "approvals_pending_retention_idx" ON "approvals" USING btree ("updated_at","id") WHERE "approvals"."status" = 'pending';
@@ -1,6 +1,6 @@
{
"id": "afdc5e70-19b3-416d-917a-beb9fdc1e2e1",
"prevId": "e78284d4-7983-44c9-a285-0b68f22c3326",
"id": "02c5ad8a-cdb9-41ad-acf1-5ab4f7d5bd67",
"prevId": "b8b61c60-3d1d-4f3d-9df3-b633a3244150",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -5721,6 +5721,28 @@
}
},
"indexes": {
"approvals_pending_retention_idx": {
"name": "approvals_pending_retention_idx",
"columns": [
{
"expression": "updated_at",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"approvals\".\"status\" = 'pending'",
"concurrently": false,
"method": "btree",
"with": {}
},
"approvals_company_status_type_idx": {
"name": "approvals_company_status_type_idx",
"columns": [
@@ -24030,6 +24052,66 @@
"method": "btree",
"with": {}
},
"decisions_running_recovery_idx": {
"name": "decisions_running_recovery_idx",
"columns": [
{
"expression": "updated_at",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"decisions\".\"status\" = 'decided' AND \"decisions\".\"execution_status\" = 'running'",
"concurrently": false,
"method": "btree",
"with": {}
},
"decisions_open_expiry_idx": {
"name": "decisions_open_expiry_idx",
"columns": [
{
"expression": "expires_at",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"decisions\".\"status\" = 'open'",
"concurrently": false,
"method": "btree",
"with": {}
},
"decisions_pending_continuation_idx": {
"name": "decisions_pending_continuation_idx",
"columns": [
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"decisions\".\"continuation_policy\" = 'wake_origin_agent' AND \"decisions\".\"metadata\" ->> 'continuationPending' = 'true' AND (\"decisions\".\"status\" IN ('expired', 'cancelled') OR (\"decisions\".\"status\" = 'decided' AND \"decisions\".\"execution_status\" IN ('succeeded', 'partial', 'failed')))",
"concurrently": false,
"method": "btree",
"with": {}
},
"decisions_bundle_idx": {
"name": "decisions_bundle_idx",
"columns": [
@@ -32019,6 +32101,28 @@
"method": "btree",
"with": {}
},
"grok_dispatch_prepared_idx": {
"name": "grok_dispatch_prepared_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"grok_bot_dispatches\".\"status\" = 'prepared'",
"concurrently": false,
"method": "btree",
"with": {}
},
"grok_dispatch_company_status_idx": {
"name": "grok_dispatch_company_status_idx",
"columns": [
+7
View File
@@ -2689,6 +2689,13 @@
"when": 1792875344101,
"tag": "0385_normal_blindfold",
"breakpoints": true
},
{
"idx": 386,
"version": "7",
"when": 1792877330859,
"tag": "0386_omniscient_luke_cage",
"breakpoints": true
}
]
}
+2
View File
@@ -1,3 +1,4 @@
import { sql } from "drizzle-orm";
import { pgTable, uuid, text, timestamp, jsonb, index } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { agents } from "./agents.js";
@@ -19,6 +20,7 @@ export const approvals = pgTable(
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pendingRetentionIdx: index("approvals_pending_retention_idx").on(table.updatedAt, table.id).where(sql`${table.status} = 'pending'`),
companyStatusTypeIdx: index("approvals_company_status_type_idx").on(
table.companyId,
table.status,
@@ -0,0 +1,325 @@
import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { activityLog, agents, approvals, chatConversations, chatEndpoints, companies, createDb, decisionArchiveNotificationOutbox, decisionRetention, issueApprovals, issues, toolApplications, toolConnections, type Db } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { approvalRecords } from "../services/approvals.js";
import { approvalRetentionService, registerApprovalRetentionWork } from "../services/approval-retention.js";
import { decisionRetentionService, DEFAULT_DECISION_ARCHIVE_DAYS } from "../services/decision-retention.js";
import { attentionService } from "../services/attention.js";
import { issueApprovalService } from "../services/issue-approvals.js";
import { companyService } from "../services/companies.js";
import { issueService } from "../services/issues.js";
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
import { idleWorkSnapshot } from "../services/task-admission.js";
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_APPROVAL_RETENTION_TEST_DATABASE_URL");
const support = externalUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
const delay = DEFAULT_DECISION_ARCHIVE_DAYS * 86_400_000;
(support.supported ? describe : describe.skip)("approval retention worker", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
let db: ReturnType<typeof createDb>, companyId: string;
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator> | undefined;
let draining: boolean;
const errors: unknown[] = [];
beforeAll(async () => {
if (!externalUrl) temporary = await startEmbeddedPostgresTestDatabase("approval-retention-");
db = createDb(externalUrl ?? temporary!.connectionString, { maxConnections: 6, idleTimeoutSeconds: 0, maxLifetimeSeconds: 0 });
}, 30_000);
beforeEach(async () => {
companyId = randomUUID(); draining = false; errors.length = 0;
await db.insert(companies).values({ id: companyId, name: "Approval retention", issuePrefix: `A${companyId.slice(0, 6)}` });
await Promise.all(Array.from({ length: 6 }, () => db.execute(sql`select pg_sleep(0.01)`)));
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"], shouldAdvanceTime: true });
});
afterEach(async () => {
await coordinator?.stop(); coordinator = undefined;
vi.useRealTimers(); vi.restoreAllMocks();
await db.execute(sql`drop trigger if exists approval_retention_fail on decision_retention`);
await db.execute(sql`drop function if exists approval_retention_fail()`);
await db.delete(decisionArchiveNotificationOutbox); await db.delete(decisionRetention);
await db.delete(activityLog); await db.delete(issueApprovals); await db.delete(approvals);
await db.delete(chatConversations); await db.delete(chatEndpoints);
await db.delete(toolConnections); await db.delete(toolApplications);
await db.delete(issues); await db.delete(agents); await db.delete(companies);
expect(errors).toEqual([]);
});
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await temporary?.cleanup(); });
const create = (at = new Date(), database: Db = db) => approvalRecords(database).create(companyId, {
type: "request_board_approval", payload: {}, createdAt: at, updatedAt: at,
});
const old = () => new Date(Date.now() - delay - 60_000);
const state = (id: string) => decisionRetentionService(db).getState(companyId, "approval", id);
const authActor = () => ({ type: "board" as const, source: "local_implicit" as const, userId: "board-user", companyIds: [companyId], isInstanceAdmin: true });
const actor = { actorType: "user" as const, actorId: "board-user", agentId: null, userId: "board-user", runId: null, agentApiKeyId: null, responsibleUserId: "board-user" };
const mutation = (id: string) => ({ companyId, sourceKind: "approval" as const, sourceId: id, authActor: authActor(), actor });
async function start(service = approvalRetentionService(db)) {
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => !draining, canReconcile: () => true, onError: error => errors.push(error) });
await registerApprovalRetentionWork(coordinator, service, () => !draining).ready;
return service;
}
async function settle() {
await vi.waitFor(async () => {
await vi.advanceTimersByTimeAsync(30_000);
expect(idleWorkSnapshot().active).toBe(0);
}, { timeout: 5000 });
}
async function quiet() {
await settle();
expect(coordinator!.nextWakeAt()).toBeNull();
const reads = vi.spyOn(db, "select"), executes = vi.spyOn(db, "execute"), writes = vi.spyOn(db, "transaction");
await vi.advanceTimersByTimeAsync(300_000);
expect(reads).not.toHaveBeenCalled(); expect(executes).not.toHaveBeenCalled(); expect(writes).not.toHaveBeenCalled();
reads.mockRestore(); executes.mockRestore(); writes.mockRestore();
}
async function archived(id: string) {
await vi.waitFor(async () => {
await vi.advanceTimersByTimeAsync(30_000);
expect((await state(id))?.archivedAt).toBeInstanceOf(Date);
}, { timeout: 5000 });
}
async function issue(hiddenAt: Date | null = null) {
const [row] = await db.insert(issues).values({ companyId, title: "Approval target", status: "todo", hiddenAt }).returning();
return row;
}
it("is quiet with no approvals and archives unseen durable approvals at their actual deadline", async () => {
await start(); await quiet();
const deadline = Date.now() + 120_000;
const row = await create(new Date(deadline - delay));
await settle();
expect(await state(row.id)).toBeNull();
expect(coordinator!.nextWakeAt()).toBe(deadline);
const reads = vi.spyOn(db, "select");
await vi.advanceTimersByTimeAsync(30_000);
expect(reads).not.toHaveBeenCalled(); reads.mockRestore();
await vi.advanceTimersByTimeAsync(120_000);
await archived(row.id); await quiet();
expect((await db.select().from(approvals).where(eq(approvals.id, row.id)))[0].status).toBe("pending");
});
it("recovers old unseen approvals on startup and leaves completed history quiet", async () => {
const due = await create(old()), completed = await create(old());
await approvalRecords(db).resolveApproval(completed.id, "approved", "board-user", null);
await start(); await archived(due.id); await quiet();
expect(await state(completed.id)).toBeNull();
});
it("disarms on revision and moves the deadline when resubmitted", async () => {
await start(); const row = await create(); await settle();
await approvalRecords(db).requestRevision(row.id, "board-user"); await quiet();
const resubmitted = await approvalRecords(db).resubmit(row.id); await settle();
expect(coordinator!.nextWakeAt()).toBe(resubmitted.updatedAt.getTime() + delay);
await approvalRecords(db).cancel(row.id); await quiet();
});
it("honors keep and wakes when an old approval is unkept", async () => {
const row = await create(old());
await attentionService(db).list(companyId, { all: true, allowUnscopedAll: true });
await decisionRetentionService(db).setKeep({ ...mutation(row.id), keep: true });
await start(); await quiet(); expect((await state(row.id))?.archivedAt).toBeNull();
await decisionRetentionService(db).setKeep({ ...mutation(row.id), keep: false });
await archived(row.id); await quiet();
});
it("disarms on manual archive and reactivates on revive", async () => {
const row = await create(old());
await attentionService(db).list(companyId, { all: true, allowUnscopedAll: true });
await decisionRetentionService(db).archive(mutation(row.id));
await start(); await quiet();
await decisionRetentionService(db).revive(mutation(row.id));
await archived(row.id); await quiet();
expect((await state(row.id))?.archivedReason).toBe("idle_ttl");
});
it("waits for inactive companies and wakes on company activation", async () => {
const row = await create(old());
await companyService(db).update(companyId, { status: "paused" });
await start(); await quiet(); expect(await state(row.id)).toBeNull();
await companyService(db).update(companyId, { status: "active" });
await archived(row.id); await quiet();
});
it("excludes hidden linked targets and wakes after unlink", async () => {
const row = await create(old()), target = await issue(new Date());
await issueApprovalService(db).link(target.id, row.id);
await start(); await quiet(); expect(await state(row.id)).toBeNull();
await issueApprovalService(db).unlink(target.id, row.id);
await archived(row.id); await quiet();
});
it("wakes when the linked target becomes visible", async () => {
const row = await create(old()), target = await issue(new Date());
await issueApprovalService(db).link(target.id, row.id);
await start(); await quiet();
await issueService(db).update(target.id, { hiddenAt: null });
await archived(row.id); await quiet();
});
it("wakes after deletion cascades the excluded target link", async () => {
const row = await create(old()), target = await issue(new Date());
await issueApprovalService(db).link(target.id, row.id);
await start(); await quiet();
await issueService(db).remove(target.id);
await archived(row.id); await quiet();
});
it("preserves normal attention reads while excluding approval retention maintenance", async () => {
const row = await create(old());
const excluded = await attentionService(db).list(companyId, { all: true, allowUnscopedAll: true, excludeSources: ["approval"] });
expect(excluded.items.some(item => item.sourceKind === "approval")).toBe(false);
expect(await state(row.id)).toBeNull();
const visible = await attentionService(db).list(companyId, { all: true, allowUnscopedAll: true });
expect(visible.items.some(item => item.subject.id === row.id)).toBe(true);
expect(await state(row.id)).not.toBeNull();
});
it("does not publish work before an outer transaction commits", async () => {
await start(); await quiet();
let id = "";
await db.transaction(async tx => {
id = (await create(old(), tx as unknown as Db)).id;
await vi.advanceTimersByTimeAsync(60_000);
expect(await state(id)).toBeNull();
});
await archived(id); await quiet();
});
it("discharges rolled back nested producers without archiving", async () => {
await start(); await quiet();
await expect(db.transaction(async tx => {
await create(old(), tx as unknown as Db); throw new Error("rollback");
})).rejects.toThrow("rollback");
await quiet(); expect(await db.select().from(approvals)).toEqual([]);
});
it("retains the first producer during drain and processes it after resume", async () => {
await start(); await quiet(); draining = true;
const row = await create(old());
await vi.advanceTimersByTimeAsync(90_000); expect(await state(row.id)).toBeNull();
const reads = vi.spyOn(db, "select");
await vi.advanceTimersByTimeAsync(90_000); expect(reads).not.toHaveBeenCalled(); reads.mockRestore();
draining = false; await archived(row.id); await quiet();
});
it("rechecks current source activity after waiting on an approval writer", async () => {
const row = await create(old());
let unlock!: () => void, locked!: () => void;
const barrier = new Promise<void>(resolve => { unlock = resolve; });
const admitted = new Promise<void>(resolve => { locked = resolve; });
const write = db.transaction(async tx => {
await tx.execute(sql`select id from approvals where id = ${row.id} for update`);
locked(); await barrier;
await approvalRecords(tx as unknown as Db).requestRevision(row.id, "board-user");
await approvalRecords(tx as unknown as Db).resubmit(row.id);
});
await admitted;
const sweep = approvalRetentionService(db).sweep();
await vi.advanceTimersByTimeAsync(100);
unlock(); await write; await sweep;
expect(await state(row.id)).toBeNull();
});
async function failArchives() {
await db.execute(sql`create function approval_retention_fail() returns trigger language plpgsql as $$
begin
if new.archived_at is not null and exists (
select 1 from approvals where id::text = new.source_id and payload->>'retentionTestFailure' = 'true'
) then raise exception 'injected approval archive failure'; end if;
return new;
end $$`);
await db.execute(sql`create trigger approval_retention_fail before update on decision_retention
for each row execute function approval_retention_fail()`);
}
it("retains failed durable work across drain and resumes recovery", async () => {
const row = await approvalRecords(db).create(companyId, { type: "request_board_approval",
payload: { retentionTestFailure: true }, updatedAt: old() });
await failArchives();
await start();
expect(errors.length).toBeGreaterThan(0); errors.length = 0;
draining = true;
await vi.advanceTimersByTimeAsync(60_000);
const reads = vi.spyOn(db, "select");
await vi.advanceTimersByTimeAsync(120_000); expect(reads).not.toHaveBeenCalled(); reads.mockRestore();
await db.execute(sql`drop trigger approval_retention_fail on decision_retention`);
draining = false; await archived(row.id); await quiet();
});
it("advances bounded pages past a failed row without losing its retry", async () => {
const at = old();
await db.insert(approvals).values(Array.from({ length: 101 }, (_, i) => ({ companyId,
type: "request_board_approval", payload: { retentionTestFailure: i === 0 },
createdAt: at, updatedAt: new Date(at.getTime() + i),
})));
await failArchives();
const service = approvalRetentionService(db);
await expect(service.sweep()).rejects.toThrow("Failed query");
expect(await service.sweep()).toEqual({ archived: 1 });
expect((await db.select().from(decisionRetention)).filter(row => row.archivedAt)).toHaveLength(100);
expect(await service.next()).not.toBeNull();
await db.execute(sql`drop trigger approval_retention_fail on decision_retention`);
expect(await service.sweep()).toEqual({ archived: 1 }); expect(await service.next()).toBeNull();
});
it("reconstructs startup obligations while paused and performs no passive SQL", async () => {
const row = await create(old()); draining = true;
await start();
const reads = vi.spyOn(db, "select");
await vi.advanceTimersByTimeAsync(120_000); expect(reads).not.toHaveBeenCalled(); reads.mockRestore();
expect(await state(row.id)).toBeNull();
draining = false; await archived(row.id); await quiet();
});
it("matches the feed's first linked issue rather than any visible link", async () => {
const row = await create(old()), one = await issue(), two = await issue();
const [first, second] = [one, two].sort((a, b) => a.id.localeCompare(b.id));
await db.update(issues).set({ hiddenAt: new Date() }).where(eq(issues.id, first.id));
await issueApprovalService(db).linkManyForApproval(row.id, [second.id, first.id]);
expect((await attentionService(db).list(companyId, { all: true, allowUnscopedAll: true })).items
.some(item => item.subject.id === row.id)).toBe(false);
await start(); await quiet();
await issueApprovalService(db).unlink(first.id, row.id);
await archived(row.id); await quiet();
});
it("joins an admitted sweep on shutdown and skips subsequent deadline queries", async () => {
const row = await create(old());
const service = approvalRetentionService(db), original = service.sweep;
let release!: () => void, admitted!: () => void;
const barrier = new Promise<void>(resolve => { release = resolve; });
const admission = new Promise<void>(resolve => { admitted = resolve; });
vi.spyOn(service, "sweep").mockImplementation(async (now, signal) => {
admitted(); await barrier; return original(now, signal);
});
const next = vi.spyOn(service, "next");
const starting = start(service); await admission;
let stopped = false;
const stopping = coordinator!.stop().then(() => { stopped = true; });
await vi.advanceTimersByTimeAsync(100); expect(stopped).toBe(false);
release(); await starting; await stopping; coordinator = undefined;
expect(next).not.toHaveBeenCalled(); expect(await state(row.id)).toBeNull();
await start(); await archived(row.id); await quiet();
});
it("uses the shared feed eligibility for a waiting Slack conversation with a pending approval", async () => {
const [agent] = await db.insert(agents).values({ companyId, name: "Origin", role: "engineer", status: "active", adapterType: "codex_local" }).returning();
const [app] = await db.insert(toolApplications).values({ companyId, name: "Slack", type: "chat" }).returning();
const [connection] = await db.insert(toolConnections).values({ companyId, applicationId: app.id, name: "Slack", uid: randomUUID(), transport: "chat_sdk", connectionPurpose: "channel" }).returning();
const [endpoint] = await db.insert(chatEndpoints).values({ companyId, connectionId: connection.id, provider: "slack", publicId: randomUUID(), assignedAgentId: agent.id, status: "active" }).returning();
const target = await issue();
await db.update(issues).set({ status: "in_review", assigneeAgentId: agent.id }).where(eq(issues.id, target.id));
await db.insert(chatConversations).values({ companyId, endpointId: endpoint.id, issueId: target.id, externalConversationId: randomUUID(), externalLabel: "Waiting", state: "waiting" });
const row = await approvalRecords(db).create(companyId, { type: "request_board_approval", payload: {}, requestedByAgentId: agent.id, updatedAt: old() });
await issueApprovalService(db).link(target.id, row.id);
const feed = await attentionService(db).list(companyId, { all: true, allowUnscopedAll: true });
expect(feed.items.some(item => item.subject.id === row.id)).toBe(true);
await start(); await archived(row.id); await quiet();
expect(await db.select().from(decisionArchiveNotificationOutbox)).toMatchObject([
{ sourceId: row.id, originIssueId: target.id, originAgentId: agent.id, status: "pending" },
]);
});
});
@@ -60,14 +60,12 @@ function createDbStub(selectResults: ApprovalRecord[][], updateResults: Approval
const set = vi.fn(() => ({ where: updateWhere }));
const update = vi.fn(() => ({ set }));
return {
db: { select, update, transaction: vi.fn(async (callback: (db: unknown) => Promise<unknown>) => {
inTransaction = true;
try { return await callback({ select, update }); } finally { inTransaction = false; }
}) },
selectWhere,
returning,
};
const db = { select, update, transaction: vi.fn(async (callback: (db: unknown) => Promise<unknown>) => {
const outerTransaction = inTransaction;
inTransaction = true;
try { return await callback(db); } finally { inTransaction = outerTransaction; }
}) };
return { db, selectWhere, returning };
}
describe("approvalService resolution idempotency", () => {
@@ -1,3 +1,4 @@
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { createAgentLifecycle } from "../services/agent-lifecycle.js";
import { randomUUID } from "node:crypto";
import express from "express";
@@ -47,7 +48,8 @@ import { listAttentionExhaustedRuns } from "../services/attention-exhausted-runs
import { agentService } from "../services/agents.js";
import { ROUTABLE_BLOCKED_ROLLOUT_AT } from "../services/routable-blocked.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_ATTENTION_TEST_DATABASE_URL");
const embeddedPostgresSupport = externalUrl ? { supported: true, reason: undefined } : await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
@@ -61,8 +63,8 @@ describeEmbeddedPostgres("attention service", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-attention-service-");
db = createDb(tempDb.connectionString);
if (!externalUrl) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-attention-service-");
db = createDb(externalUrl ?? tempDb!.connectionString);
}, 30_000);
afterEach(async () => {
@@ -98,6 +100,7 @@ describeEmbeddedPostgres("attention service", () => {
});
afterAll(async () => {
await db?.$client.end({ timeout: 1 });
await tempDb?.cleanup();
});
+7 -3
View File
@@ -1,3 +1,4 @@
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
@@ -26,7 +27,8 @@ vi.mock("../services/activity-log.js", () => ({
// Budget behavior is exercised against PostgreSQL below and in
// cost-accounting-reliability.test.ts, including transaction and retry paths.
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_BUDGETS_TEST_DATABASE_URL");
const embeddedPostgresSupport = externalUrl ? { supported: true, reason: undefined } : await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describeEmbeddedPostgres("budgetService release gate enforcement", () => {
@@ -34,8 +36,9 @@ describeEmbeddedPostgres("budgetService release gate enforcement", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-budgets-service-");
db = createDb(tempDb.connectionString);
await import("../services/agent-lifecycle.js");
if (!externalUrl) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-budgets-service-");
db = createDb(externalUrl ?? tempDb!.connectionString);
}, 20_000);
afterEach(async () => {
@@ -50,6 +53,7 @@ describeEmbeddedPostgres("budgetService release gate enforcement", () => {
});
afterAll(async () => {
await db?.$client.end({ timeout: 1 });
await tempDb?.cleanup();
});
@@ -1,3 +1,4 @@
import { approvalRetentionService } from "../services/approval-retention.js";
import { logger } from "../middleware/logger.js";
import { getStartupRecoveryState } from "../startup-recovery-state.js";
import { idleWorkSnapshot } from "../services/task-admission.js";
@@ -321,6 +322,11 @@ vi.mock("../realtime/live-events-ws.js", () => ({
setupLiveEventsWebSocketServer: vi.fn(),
}));
vi.mock("../services/approval-retention.js", async importOriginal => ({
...await importOriginal<typeof import("../services/approval-retention.js")>(),
approvalRetentionService: vi.fn(() => ({ sweep: vi.fn(async () => ({ archived: 0 })), next: vi.fn(async () => null) })),
}));
vi.mock("../services/index.js", () => ({
backfillLegacyToolOAuthTokens: vi.fn(async () => ({
scannedConnections: 0,
@@ -519,6 +525,18 @@ describe("startServer feedback export wiring", () => {
process.env.BETTER_AUTH_SECRET = "test-secret";
});
it.each([false, true])("registers approval retention only when maintenance is enabled (%s)", async enabled => {
loadConfigMock.mockReturnValue(buildTestConfig({ heartbeatSchedulerEnabled: enabled }));
await startServer(); await settleStartupHousekeeping();
const registration = deliveryWorkRegisterMock.mock.calls.find(([queue]) => queue === DELIVERY_QUEUES.approvalRetention);
expect(Boolean(registration)).toBe(enabled);
if (enabled) {
const service = vi.mocked(approvalRetentionService).mock.results.at(-1)!.value;
await registration![1].run(new AbortController().signal);
expect(service.sweep).toHaveBeenCalled(); expect(service.next).toHaveBeenCalled();
}
});
it("registers durable case handoffs independently of the heartbeat scheduler", async () => {
await startServer();
expect(caseHandoffDeliveryServiceFactoryMock).toHaveBeenCalledWith(createDbMock.mock.results[0]!.value, { heartbeat: heartbeatServiceMock });
+4
View File
@@ -1,3 +1,4 @@
import { approvalRetentionService, registerApprovalRetentionWork } from "./services/approval-retention.js";
import { registerDecisionTargetWork } from "./services/decision-target-work.js";
import { registerDecisionEffectRecoveryWork } from "./services/decision-effect-recovery-work.js";
import { registerDecisionExpiryWork } from "./services/decision-expiry-work.js";
@@ -1775,6 +1776,7 @@ async function startServerWithDatabaseTeardown(
// per company per tick.
const page = await attentionService(db as any).list(company.id, {
includeDismissed: true,
excludeSources: ["approval"],
all: true,
allowUnscopedAll: true,
});
@@ -1822,6 +1824,8 @@ async function startServerWithDatabaseTeardown(
await step("environment_lease_cleanup", startEnvironmentCleanup);
await step("archive_notification_worker", () => registerDecisionArchiveDeliveryWork(deliveryWork, retentionExecutor,
() => !isIdleTaskDrainActive() && !isWarmStandby() && !heartbeatSchedulerStopped).ready);
await step("approval_retention", () => registerApprovalRetentionWork(deliveryWork, approvalRetentionService(db as any),
() => !isIdleTaskDrainActive() && !isWarmStandby() && !heartbeatSchedulerStopped).ready);
await step("retention_sweep", runRetentionSweep);
};
+3 -2
View File
@@ -1,3 +1,4 @@
import { writeApprovalChange } from "../services/retention-work-notifications.js";
import { and, eq, inArray } from "drizzle-orm";
import { approvals, type Db } from "@paperclipai/db";
import { notFound, unprocessable } from "../errors.js";
@@ -37,7 +38,7 @@ export function approvalResolutionRecords(db: Db) {
}
const now = new Date();
const updated = await database
const updated = await writeApprovalChange(database, tx => tx
.update(approvals)
.set({
status: targetStatus,
@@ -48,7 +49,7 @@ export function approvalResolutionRecords(db: Db) {
})
.where(and(eq(approvals.id, id), inArray(approvals.status, resolvableStatuses)))
.returning()
.then((rows) => rows[0] ?? null);
.then((rows) => rows[0] ?? null));
if (updated) {
return { approval: updated, applied: true };
+76
View File
@@ -0,0 +1,76 @@
import { executionIssueCondition } from "./issue-visibility.js";
import { and, asc, eq, isNull, lte, or, sql, type SQL } from "drizzle-orm";
import { approvals, companies, decisionRetention, issues, type Db } from "@paperclipai/db";
import { decisionRetentionService, DEFAULT_DECISION_ARCHIVE_DAYS } from "./decision-retention.js";
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
import { registerDeadlineWork } from "./deadline-work.js";
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
const archiveDelay = DEFAULT_DECISION_ARCHIVE_DAYS * 86_400_000;
const eligibleLink = sql`not exists (
select 1 from issue_approvals linked where linked.company_id = ${approvals.companyId}
and linked.approval_id = ${approvals.id}
and linked.issue_id = (select first_link.issue_id from issue_approvals first_link
where first_link.company_id = ${approvals.companyId} and first_link.approval_id = ${approvals.id}
order by first_link.issue_id limit 1)
and not exists (select 1 from ${issues} where ${issues.id} = linked.issue_id
and ${issues.companyId} = ${approvals.companyId} and ${executionIssueCondition()})
)`;
export function approvalRetentionService(db: Db) {
let cursor: { at: Date; id: string } | undefined;
function candidates(database: Db, condition?: SQL) {
return database.select({ id: approvals.id, companyId: approvals.companyId, at: approvals.updatedAt }).from(approvals)
.innerJoin(companies, eq(companies.id, approvals.companyId))
.leftJoin(decisionRetention, and(eq(decisionRetention.companyId, approvals.companyId),
eq(decisionRetention.sourceKind, "approval"), sql`${decisionRetention.sourceId} = ${approvals.id}::text`))
.where(and(sql`${approvals.status} = 'pending'`, eq(companies.status, "active"), eligibleLink,
or(isNull(decisionRetention.id), eq(decisionRetention.keep, false)), isNull(decisionRetention.archivedAt), condition))
.orderBy(asc(approvals.updatedAt), asc(approvals.id));
}
async function sweep(now = new Date(), signal?: AbortSignal) {
const cutoff = new Date(now.getTime() - archiveDelay);
const rows = await candidates(db, and(lte(approvals.updatedAt, cutoff), cursor
? sql`(${approvals.updatedAt}, ${approvals.id}) > (${cursor.at.toISOString()}::timestamptz, ${cursor.id}::uuid)` : undefined)).limit(100);
let archived = 0;
const errors: unknown[] = [];
for (const candidate of rows) {
if (signal?.aborted) break;
try {
archived += await db.transaction(async tx => {
const database = tx as unknown as Db;
const [current] = await candidates(database, and(eq(approvals.id, candidate.id), lte(approvals.updatedAt, cutoff)))
.for("update", { of: approvals });
if (!current) return 0;
const retention = decisionRetentionService(database);
const source = { sourceKind: "approval" as const, subject: { id: current.id }, activityAt: current.at.toISOString() };
const states = await retention.syncItems(current.companyId, [source]);
const state = states.get(`approval:${current.id}`)!;
return retention.autoArchive({ companyId: current.companyId, now, items: [{ ...source,
keep: state.keep, archivedAt: state.archivedAt?.toISOString() ?? null, retentionVersion: state.version,
}] });
});
} catch (error) { errors.push(error); }
cursor = { at: candidate.at, id: candidate.id };
}
if (rows.length < 100) cursor = undefined;
if (errors.length === 1) throw errors[0];
if (errors.length) throw new AggregateError(errors, "Approval retention failed");
return { archived };
}
async function next() {
const [row] = await candidates(db).limit(1);
return row ? row.at.getTime() + archiveDelay : null;
}
return { sweep, next };
}
export function registerApprovalRetentionWork(
coordinator: ReturnType<typeof createDeliveryWorkCoordinator>,
service: ReturnType<typeof approvalRetentionService>,
canRun: () => boolean,
) {
return registerDeadlineWork(coordinator, DELIVERY_QUEUES.approvalRetention, {
run: signal => service.sweep(new Date(), signal), next: service.next, canRun, retryMs: 30_000,
});
}
+9 -8
View File
@@ -1,3 +1,4 @@
import { writeApprovalChange } from "./retention-work-notifications.js";
import { approvalResolutionRecords, resolvableApprovalStatuses as resolvableStatuses } from "../lib/approval-records.js";
import { notifyHireApproved } from "./hire-hook.js";
@@ -53,18 +54,18 @@ export function approvalRecords(db: Db) {
},
create: (companyId: string, data: Omit<typeof approvals.$inferInsert, "companyId">) =>
db
writeApprovalChange(db, tx => tx
.insert(approvals)
.values({ ...data, companyId })
.returning()
.then((rows) => rows[0]),
.then((rows) => rows[0])),
// Cancel an open (pending/revision_requested) approval without a board
// decision — e.g. when its paired agent is terminated during duplicate
// cleanup. Idempotent: a no-op on already-resolved approvals.
cancel: async (id: string, reason?: string | null) => {
const now = new Date();
const updated = await db
const updated = await writeApprovalChange(db, tx => tx
.update(approvals)
.set({
status: "cancelled",
@@ -74,7 +75,7 @@ export function approvalRecords(db: Db) {
})
.where(and(eq(approvals.id, id), inArray(approvals.status, resolvableStatuses)))
.returning()
.then((rows) => rows[0] ?? null);
.then((rows) => rows[0] ?? null));
return updated;
},
@@ -85,7 +86,7 @@ export function approvalRecords(db: Db) {
}
const now = new Date();
return db
return writeApprovalChange(db, tx => tx
.update(approvals)
.set({
status: "revision_requested",
@@ -96,7 +97,7 @@ export function approvalRecords(db: Db) {
})
.where(eq(approvals.id, id))
.returning()
.then((rows) => rows[0]);
.then((rows) => rows[0]));
},
resubmit: async (id: string, payload?: Record<string, unknown>) => {
@@ -106,7 +107,7 @@ export function approvalRecords(db: Db) {
}
const now = new Date();
return db
return writeApprovalChange(db, tx => tx
.update(approvals)
.set({
status: "pending",
@@ -118,7 +119,7 @@ export function approvalRecords(db: Db) {
})
.where(eq(approvals.id, id))
.returning()
.then((rows) => rows[0]);
.then((rows) => rows[0]));
},
listComments: async (approvalId: string) => {
+77 -73
View File
@@ -161,6 +161,7 @@ type AttentionListOptions = AttentionFeedQuery & {
actor?: AuthorizationActor;
/** Internal-only escape hatch for callers that need one stable, unpaginated feed snapshot. */
allowUnscopedAll?: boolean;
excludeSources?: readonly AttentionSourceKind[];
};
type AttentionServiceOptions = {
@@ -1126,83 +1127,86 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
collected.push({ ...item, dismissal });
};
const pendingApprovals = await db
.select({
id: approvals.id,
type: approvals.type,
status: approvals.status,
requestedByAgentId: approvals.requestedByAgentId,
requestedByUserId: approvals.requestedByUserId,
payload: approvals.payload,
createdAt: approvals.createdAt,
updatedAt: approvals.updatedAt,
})
.from(approvals)
.where(and(eq(approvals.companyId, companyId), eq(approvals.status, "pending")))
.orderBy(desc(approvals.updatedAt), desc(approvals.id));
if (!options.excludeSources?.includes("approval")) {
const pendingApprovals = await db
.select({
id: approvals.id,
type: approvals.type,
status: approvals.status,
requestedByAgentId: approvals.requestedByAgentId,
requestedByUserId: approvals.requestedByUserId,
payload: approvals.payload,
createdAt: approvals.createdAt,
updatedAt: approvals.updatedAt,
})
.from(approvals)
.where(and(eq(approvals.companyId, companyId), eq(approvals.status, "pending")))
.orderBy(desc(approvals.updatedAt), desc(approvals.id));
const pendingApprovalIds = pendingApprovals.map((approval) => approval.id);
const approvalIssueRows = pendingApprovalIds.length > 0
? await db
.select({ approvalId: issueApprovals.approvalId, issueId: issueApprovals.issueId })
.from(issueApprovals)
.where(and(
eq(issueApprovals.companyId, companyId),
inArray(issueApprovals.approvalId, pendingApprovalIds),
))
.orderBy(asc(issueApprovals.approvalId), asc(issueApprovals.issueId))
: [];
const approvalIssueMap = new Map<string, string>();
for (const row of approvalIssueRows) {
if (!approvalIssueMap.has(row.approvalId)) approvalIssueMap.set(row.approvalId, row.issueId);
}
const readableApprovalIssues = await issueSummaryMap(
db,
companyId,
approvalIssueRows.map((row) => row.issueId),
readCondition,
);
for (const approval of pendingApprovals) {
const linkedIssueId = approvalIssueMap.get(approval.id) ?? null;
if (linkedIssueId && !readableApprovalIssues.has(linkedIssueId)) continue;
const dedupKey = `approval:${approval.id}`;
const title = approvalTitle(approval.type, approval.payload);
add(createItem({
const pendingApprovalIds = pendingApprovals.map((approval) => approval.id);
const approvalIssueRows = pendingApprovalIds.length > 0
? await db
.select({ approvalId: issueApprovals.approvalId, issueId: issueApprovals.issueId })
.from(issueApprovals)
.where(and(
eq(issueApprovals.companyId, companyId),
inArray(issueApprovals.approvalId, pendingApprovalIds),
))
.orderBy(asc(issueApprovals.approvalId), asc(issueApprovals.issueId))
: [];
const approvalIssueMap = new Map<string, string>();
for (const row of approvalIssueRows) {
if (!approvalIssueMap.has(row.approvalId)) approvalIssueMap.set(row.approvalId, row.issueId);
}
const readableApprovalIssues = await issueSummaryMap(
db,
companyId,
sourceKind: "approval",
subject: {
kind: "approval",
id: approval.id,
approvalIssueRows.map((row) => row.issueId),
readCondition,
);
for (const approval of pendingApprovals) {
const linkedIssueId = approvalIssueMap.get(approval.id) ?? null;
if (linkedIssueId && !readableApprovalIssues.has(linkedIssueId)) continue;
const dedupKey = `approval:${approval.id}`;
const title = approvalTitle(approval.type, approval.payload);
add(createItem({
companyId,
title,
identifier: null,
status: approval.status,
href: `/${prefix}/approvals/${approval.id}`,
metadata: {
type: approval.type,
requestedByAgentId: approval.requestedByAgentId,
requestedByUserId: approval.requestedByUserId,
issueId: approvalIssueMap.get(approval.id) ?? null,
sourceKind: "approval",
subject: {
kind: "approval",
id: approval.id,
companyId,
title,
identifier: null,
status: approval.status,
href: `/${prefix}/approvals/${approval.id}`,
metadata: {
type: approval.type,
requestedByAgentId: approval.requestedByAgentId,
requestedByUserId: approval.requestedByUserId,
issueId: approvalIssueMap.get(approval.id) ?? null,
},
},
},
whyNow: "Approval is pending a board decision.",
decisionVerbs: decisionVerbs(
{ id: "approve", label: "Approve", description: "Approve the request." },
{ id: "reject", label: "Reject", description: "Reject the request." },
{ id: "request_revision", label: "Request revision", description: "Send the request back for changes." },
),
inlineResolvable: approval.type !== "request_board_approval",
entryRule: "approvals.status = 'pending'",
exitRule: "Approval leaves pending status.",
dedupKey,
severity: "medium",
activityAt: toIso(approval.updatedAt),
createdAt: toIso(approval.createdAt),
updatedAt: toIso(approval.updatedAt),
relatedIssue: null,
detail: approvalDetail(approval.type, approval.payload),
}));
whyNow: "Approval is pending a board decision.",
decisionVerbs: decisionVerbs(
{ id: "approve", label: "Approve", description: "Approve the request." },
{ id: "reject", label: "Reject", description: "Reject the request." },
{ id: "request_revision", label: "Request revision", description: "Send the request back for changes." },
),
inlineResolvable: approval.type !== "request_board_approval",
entryRule: "approvals.status = 'pending'",
exitRule: "Approval leaves pending status.",
dedupKey,
severity: "medium",
activityAt: toIso(approval.updatedAt),
createdAt: toIso(approval.createdAt),
updatedAt: toIso(approval.updatedAt),
relatedIssue: null,
detail: approvalDetail(approval.type, approval.payload),
}));
}
}
const interactionRows = await db
+5
View File
@@ -1,3 +1,4 @@
import { notifyRetentionWork } from "./retention-work-notifications.js";
import { addCents, compareCents, normalizeCents } from "@paperclipai/shared";
import { and, desc, eq, inArray, sql, or, isNull } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
@@ -258,6 +259,7 @@ async function markApprovalStatus(
decidedByUserId: string,
) {
if (!approvalId) return;
await notifyRetentionWork(db, "approval");
await db
.update(approvals)
.set({
@@ -295,6 +297,7 @@ export function budgetServiceInTransaction(db: Db, publications: ActivityPublica
const scope = await resolveScopeRecord(db, "company", policy.scopeId);
if (scope.paused) return;
await notifyRetentionWork(db, "approval");
await notifyDeliveryWork(db, DELIVERY_QUEUES.agentLifecycle, policy.companyId);
await notifyDeliveryWork(db, DELIVERY_QUEUES.skillSourceSync);
await db
@@ -351,6 +354,7 @@ export function budgetServiceInTransaction(db: Db, publications: ActivityPublica
await notifyDeliveryWork(db, DELIVERY_QUEUES.skillSourceSync);
const scope = await resolveScopeRecord(db, "company", policy.scopeId);
if (scope.pauseReason !== "budget") return;
await notifyRetentionWork(db, "approval");
await notifyDeliveryWork(db, DELIVERY_QUEUES.agentLifecycle, policy.companyId);
await db
.update(companies)
@@ -453,6 +457,7 @@ export function budgetServiceInTransaction(db: Db, publications: ActivityPublica
windowEnd: end,
});
if (thresholdType === "hard") await notifyRetentionWork(db, "approval");
const approval = thresholdType === "hard"
? await db
.insert(approvals)
+3
View File
@@ -1,3 +1,4 @@
import { notifyRetentionWork } from "./retention-work-notifications.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import { isRunnerApiResponseAsset } from "./asset-content-access.js";
import { deleteCompany } from "./company-deletion.js";
@@ -330,6 +331,7 @@ export function companyService(db: Db, _budgetHooks: BudgetServiceHooks = {}) {
if (companyPatch.status !== undefined && companyPatch.status !== existing.status) {
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
await notifyRetentionWork(tx, "approval");
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentLifecycle, id);
}
const updated = await tx
@@ -481,6 +483,7 @@ export function companyService(db: Db, _budgetHooks: BudgetServiceHooks = {}) {
if (!wasAlreadyArchived) {
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
await notifyRetentionWork(tx, "approval");
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentLifecycle, id);
await tx
.update(companies)
+2
View File
@@ -1,3 +1,4 @@
import { notifyRetentionWork } from "./retention-work-notifications.js";
import { notifyDecisionTargetWork } from "./decision-target-work.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import { agentObservabilityService } from "./agent-observability.js";
@@ -94,6 +95,7 @@ export async function deleteCompany(db: Db, id: string) {
await tx.delete(agentRuntimeState).where(eq(agentRuntimeState.companyId, id));
await tx.delete(issueComments).where(eq(issueComments.companyId, id));
await tx.delete(approvalComments).where(eq(approvalComments.companyId, id));
await notifyRetentionWork(tx, "approval");
await tx.delete(approvals).where(eq(approvals.companyId, id));
await tx.delete(companySecrets).where(eq(companySecrets.companyId, id));
await tx.delete(joinRequests).where(eq(joinRequests.companyId, id));
+47 -37
View File
@@ -1,3 +1,4 @@
import { notifyRetentionWork } from "./retention-work-notifications.js";
import { decisionArchiveDeliveryService, type ArchiveNotificationBatch } from "./decision-archive-delivery.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import { createHash } from "node:crypto";
@@ -32,6 +33,9 @@ export const DEFAULT_DECISION_SHELF_DAYS = 30;
export const DEFAULT_DECISION_ARCHIVE_DAYS = 90;
const DAY_MS = 86_400_000;
type RetentionSource = Pick<AttentionItem, "sourceKind" | "activityAt"> & { subject: Pick<AttentionItem["subject"], "id"> };
type ArchiveSource = RetentionSource & Pick<AttentionItem, "keep" | "archivedAt" | "retentionVersion">;
export type DecisionRetentionState = typeof decisionRetention.$inferSelect;
export type { ArchiveNotificationBatch } from "./decision-archive-delivery.js";
@@ -128,45 +132,49 @@ export function decisionRetentionService(
db: Db,
options: { notifyOriginAgent?: (batch: ArchiveNotificationBatch) => Promise<unknown> } = {},
) {
async function syncItems(companyId: string, items: readonly AttentionItem[]) {
async function syncItems(companyId: string, items: readonly RetentionSource[]) {
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],
const unique = [...new Map(items.map(item => [itemSourceKey(item), item])).values()];
return db.transaction(async tx => {
const select = () => tx.select().from(decisionRetention).where(and(
eq(decisionRetention.companyId, companyId),
inArray(decisionRetention.sourceId, unique.map(item => item.subject.id)),
));
let rows = await select();
const existing = new Set(rows.map(row => sourceKey(row.sourceKind, row.sourceId)));
const missing = unique.filter(item => !existing.has(itemSourceKey(item)));
if (missing.length) {
for (const kind of new Set(missing.map(item => item.sourceKind))) await notifyRetentionWork(tx, kind);
await tx.insert(decisionRetention).values(missing.map(item => ({
companyId, sourceKind: item.sourceKind, sourceId: item.subject.id, sourceActivityAt: new Date(item.activityAt),
}))).onConflictDoNothing({ target: [decisionRetention.companyId, decisionRetention.sourceKind, decisionRetention.sourceId] });
rows = await select();
}
const byKey = new Map(rows.map(row => [sourceKey(row.sourceKind, row.sourceId), row]));
for (const item of unique) {
const key = itemSourceKey(item), row = byKey.get(key), activityAt = new Date(item.activityAt);
if (!row || row.sourceActivityAt.getTime() === activityAt.getTime()) continue;
await notifyRetentionWork(tx, item.sourceKind);
const [updated] = await tx.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();
if (updated) byKey.set(key, updated);
}
return byKey;
});
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) {
function itemSourceKey(item: RetentionSource) {
return sourceKey(item.sourceKind, item.subject.id);
}
async function writeRetention<T>(kind: AttentionSourceKind, write: (tx: Db) => Promise<T>) {
return db.transaction(async tx => {
await notifyRetentionWork(tx, kind);
return write(tx as unknown as Db);
});
}
async function getState(companyId: string, sourceKind: AttentionSourceKind, sourceId: string) {
return db.select().from(decisionRetention).where(and(
eq(decisionRetention.companyId, companyId),
@@ -186,7 +194,7 @@ export function decisionRetentionService(
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({
const updated = await writeRetention(input.sourceKind, tx => tx.update(decisionRetention).set({
keep: input.keep,
version: sql`${decisionRetention.version} + 1`,
updatedAt: new Date(),
@@ -194,7 +202,7 @@ export function decisionRetentionService(
eq(decisionRetention.companyId, input.companyId),
eq(decisionRetention.sourceKind, input.sourceKind),
eq(decisionRetention.sourceId, input.sourceId),
)).returning().then((rows) => rows[0] ?? null);
)).returning().then((rows) => rows[0] ?? null));
if (!updated) throw notFound("Attention source not found");
await logActivity(db, {
companyId: input.companyId,
@@ -224,7 +232,7 @@ export function decisionRetentionService(
throw notFound("Attention source not found");
}
const now = new Date();
const updated = await db.update(decisionRetention).set(input.archived ? {
const updated = await writeRetention(input.sourceKind, tx => tx.update(decisionRetention).set(input.archived ? {
archivedAt: now,
archivedReason: "manual",
...archiveActorColumns(input.actor),
@@ -245,7 +253,7 @@ export function decisionRetentionService(
eq(decisionRetention.sourceKind, input.sourceKind),
eq(decisionRetention.sourceId, input.sourceId),
input.archived ? isNull(decisionRetention.archivedAt) : isNotNull(decisionRetention.archivedAt),
)).returning().then((rows) => rows[0] ?? null);
)).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");
@@ -298,6 +306,7 @@ export function decisionRetentionService(
}
const now = new Date();
for (const entry of input.manifest) {
await notifyRetentionWork(txDb, entry.sourceKind as AttentionSourceKind);
const archived = await txDb.update(decisionRetention).set({
archivedAt: now,
archivedReason: "accepted_proposal",
@@ -333,7 +342,7 @@ export function decisionRetentionService(
});
}
async function autoArchive(input: { companyId: string; items: AttentionItem[]; now?: Date }) {
async function autoArchive(input: { companyId: string; items: readonly ArchiveSource[]; 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);
@@ -341,6 +350,7 @@ export function decisionRetentionService(
await db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
for (const item of candidates) {
await notifyRetentionWork(txDb, item.sourceKind);
const updated = await txDb.update(decisionRetention).set({
archivedAt: now,
archivedReason: "idle_ttl",
@@ -8,6 +8,7 @@ export const DELIVERY_QUEUES = {
executionStatus: "execution-status",
executionDisposition: "execution-disposition",
environmentCleanup: "environment-cleanup",
approvalRetention: "approval-retention",
decisionTargets: "decision-targets",
decisionEffectRecovery: "decision-effect-recovery",
decisionExpiry: "decision-expiry",
+7 -6
View File
@@ -1,3 +1,4 @@
import { writeApprovalChange } from "./retention-work-notifications.js";
import { and, desc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { approvals, issueApprovals, issues } from "@paperclipai/db";
@@ -107,7 +108,7 @@ export function issueApprovalService(db: Db) {
link: async (issueId: string, approvalId: string, actor?: LinkActor) => {
const { issue } = await assertIssueAndApprovalSameCompany(issueId, approvalId);
await db
await writeApprovalChange(db, tx => tx
.insert(issueApprovals)
.values({
companyId: issue.companyId,
@@ -116,7 +117,7 @@ export function issueApprovalService(db: Db) {
linkedByAgentId: actor?.agentId ?? null,
linkedByUserId: actor?.userId ?? null,
})
.onConflictDoNothing();
.onConflictDoNothing());
return db
.select()
@@ -127,9 +128,9 @@ export function issueApprovalService(db: Db) {
unlink: async (issueId: string, approvalId: string) => {
await assertIssueAndApprovalSameCompany(issueId, approvalId);
await db
await writeApprovalChange(db, tx => tx
.delete(issueApprovals)
.where(and(eq(issueApprovals.issueId, issueId), eq(issueApprovals.approvalId, approvalId)));
.where(and(eq(issueApprovals.issueId, issueId), eq(issueApprovals.approvalId, approvalId))));
},
linkManyForApproval: async (approvalId: string, issueIds: string[], actor?: LinkActor) => {
@@ -157,7 +158,7 @@ export function issueApprovalService(db: Db) {
}
}
await db
await writeApprovalChange(db, tx => tx
.insert(issueApprovals)
.values(
uniqueIssueIds.map((issueId) => ({
@@ -168,7 +169,7 @@ export function issueApprovalService(db: Db) {
linkedByUserId: actor?.userId ?? null,
})),
)
.onConflictDoNothing();
.onConflictDoNothing());
},
};
}
+5
View File
@@ -1,3 +1,4 @@
import { notifyRetentionWork } from "./retention-work-notifications.js";
import { notifyDecisionTargetWork } from "./decision-target-work.js";
import { insertIssueComment } from "./issue-comment-writes.js";
import { measureRunTransaction } from "./execution-transaction-timing.js";
@@ -11300,6 +11301,9 @@ export function issueService(db: Db) {
if (patch.status === "done" || patch.status === "cancelled") {
await notifyDecisionTargetWork(tx, `issue:${id}`);
}
if (patch.hiddenAt !== undefined || patch.harnessKind !== undefined || patch.conversationAgentId !== undefined) {
await notifyRetentionWork(tx, "approval");
}
const updated = await tx
.update(issues)
.set(patch)
@@ -11707,6 +11711,7 @@ export function issueService(db: Db) {
try {
await notifyTerminalWorkspaceWork(tx, `issue:${id}`);
await requestRestorePointDeletion(tx, { sourceIssueId: id });
await notifyRetentionWork(tx, "approval");
await notifyDecisionTargetWork(tx);
removedIssue = await tx
.delete(issues)
@@ -0,0 +1,14 @@
import type { Db } from "@paperclipai/db";
import type { AttentionSourceKind } from "@paperclipai/shared";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
export async function notifyRetentionWork(transaction: object, kind: AttentionSourceKind) {
if (kind === "approval") await notifyDeliveryWork(transaction, DELIVERY_QUEUES.approvalRetention);
}
export function writeApprovalChange<T>(db: Db, write: (transaction: Db) => Promise<T>): Promise<T> {
return db.transaction(async tx => {
await notifyRetentionWork(tx, "approval");
return write(tx as unknown as Db);
});
}
+2
View File
@@ -1,3 +1,4 @@
import { notifyRetentionWork } from "./retention-work-notifications.js";
import { notifyDecisionTargetWork } from "./decision-target-work.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import { verifyAppWebhook } from "./app-webhook.js";
@@ -1965,6 +1966,7 @@ export function routineService(
return updated ?? createdRun;
} catch (error) {
if (createdIssue) {
await notifyRetentionWork(txDb, "approval");
await notifyDecisionTargetWork(txDb);
await txDb.delete(issues).where(eq(issues.id, createdIssue.id));
}
+7 -5
View File
@@ -1,3 +1,4 @@
import { writeApprovalChange } from "./retention-work-notifications.js";
import { toolReviewProjectionService } from "./tool-review-projection.js";
import { toolReviewInterruptionService } from "./tool-review-interruption.js";
import { claimToolTestApproval, persistToolTestOutcome, startToolTestInvocation, type ToolTestApprovalClaim } from "./tool-test-approval.js";
@@ -2577,7 +2578,7 @@ export function createToolGatewayService(
let formalApprovalId: string | null = null;
if (toolRequiresFormalApproval(input.tool)) {
const [approval] = await db
const [approval] = await writeApprovalChange(db, tx => tx
.insert(approvals)
.values({
companyId: input.session.companyId,
@@ -2600,17 +2601,18 @@ export function createToolGatewayService(
argumentsHash: canonicalArgumentsHash,
},
})
.returning();
.returning());
formalApprovalId = approval.id;
await db
const approvalIssueId = input.session.issueId;
await writeApprovalChange(db, tx => tx
.insert(issueApprovals)
.values({
companyId: input.session.companyId,
issueId: input.session.issueId,
issueId: approvalIssueId,
approvalId: approval.id,
linkedByAgentId: input.session.agentId,
})
.onConflictDoNothing();
.onConflictDoNothing());
}
const interaction = await interactions.create(