mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +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,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