mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 05:16:50 +01:00
fix(server): schedule approval retention from durable source deadlines
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
f240381fd8
commit
ea3dd40cdd
27 files changed
+893
-150
No files matched your search
@@ -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.
|
||||
@@ -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';
|
||||
+106
-2
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user