Files
PaperClipAI/server/src/services/decision-retention.ts
T
DottaandPaperclip 8406047b4e fix(server): schedule join request retention from durable deadlines (#17508)
## Thinking Path

> - Paperclip manages agent companies and their access requests.
> - Pending join requests appear in the attention feed and can age into
automatic archival.
> - The maintenance tick reads those requests even when no archive is
due.
> - Existing request rows already store the activity timestamp needed
for retention.
> - Committed changes can wake a worker that registers the next actual
deadline.
> - This PR gives join-request retention its own bounded worker and
removes its recurring feed scan.
> - Idle and resolved history stop producing retention traffic without
changing access approval.

## Linked Issues or Issue Description

**What happened?**

Every retention maintenance pass reads pending join requests through the
attention feed. Empty, resolved, kept, and future-due states still
participate in recurring discovery.

**Expected behavior**

Committed request and retention changes wake the owner. It waits until
the next actual archive deadline and stops querying when no eligible
request remains. Existing approval and visibility behavior is preserved.

**Steps to reproduce**

Start an idle company with no pending join requests, or with requests
younger than the 90-day archive threshold. Observe the maintenance feed
repeatedly select join_requests. No state change is needed to cause
those reads.

This PR targets master after #17505 merged. Approval retention is
#17497. Other attention source kinds remain separate migration duties.
No duplicate open join-retention PR was found.

## What Changed

- Add a join-request retention owner that preserves pending status,
active-company visibility, matching invite ownership, and keep/archive
controls. Invite expiry or revocation retains the existing feed policy.
- Reuse one `createDecisionRetentionSweep` for approval and join paging,
atomic retention and finite scan cycles that revisit failures despite
continuing arrivals. Eligibility and source-row locks stay with each
owner.
- Register real saved deadlines, rounded upward in SQL to millisecond
precision, with the existing coordinator. Preserve 100-row pages and
thirty-second notification coalescing without idle polling.
- Materialize unseen join state inside the existing authorized
keep/archive/revive transaction, preserving archive choices and source
locking.
- Notify before request inserts and updates in the existing acceptance
and Slack transactions. Use a small SQL-only helper for replay and
approval/rejection updates. Company status, budget pause/resume,
deletion, and retention controls notify this owner too.
- Preserve the approved-only API-key consumption path without a new
wrapper; it cannot produce pending retention work.
- Exclude join requests from maintenance feed collection while retaining
normal UI reads.
- Add an idempotent concurrent partial pending index and prune old
schema snapshots through the existing generator.
- Document producer coverage, boundaries, failure evidence, and
remaining work.

## Verification

Test revision: `df1815fbba`. Normal integration of landed workspace
precision `d23dd19750` has an identical complete source tree. This
remains only the join-retention duty and its shared approval-retention
mechanics, targeting master.

- **55 worker cases pass**, including two new PostgreSQL regressions
that failed before repair: continuing overdue arrivals no longer
postpone a recovered failure indefinitely, while an entire failed page
still allows later healthy rows to progress.
- The nine complete suites passed 341 cases but failed an attention
cleanup hook after its 16,400-run case. After vacuum/analyze of only
that disposable fixture, the full 31-case attention suite passes
unchanged. Together with the original eight passing suites (311 cases),
all 342 distinct cases pass across complete-suite executions. Recovery
is established; the original cleanup slowdown is not claimed explained.
Server typecheck and production build pass.
- Earlier CI at `df1d4d0` failed the unchanged decision-expiry restart
deadline assertion (a thirty-second retry appeared before the saved
expiry). All twenty unchanged expiry cases pass locally, but the CI
timing cause remains unproven. No retry, timeout relaxation or assertion
change is used to dismiss it; fresh final-head CI remains required.

- **Five regressions failed before repair:** three unseen join controls
returned 404, and both owners advertised a microsecond deadline one
millisecond early. All pass. Two old real-producer expectations failed
after the correct rounding change; they now derive exact expected
milliseconds independently from database integer microseconds instead of
truncating a JavaScript Date.
- The initial timing-only concurrency test still passed a missing-lock
mutation. It now observes the actual PostgreSQL wait before releasing
the writer. Production passes; removing the lock fails with zero waiters
instead of one. Source is restored before commit. The archive-first
committed-order case confirms that later activity stays archived until
explicit revival; no broader race guarantee is claimed.
- Generated concurrent/idempotent `0393_open_grandmaster.sql` adds only
the pending join index. All 391 parent journal entries and historical
SQL files remain unchanged. Five generated snapshots remain (0389–0393).
Migration safety/numbering, schema drift and actual apply/replay on six
isolated PostgreSQL fixtures pass.
- Existing lifecycle cases retain real HTTP invite
acceptance/approval/rejection, Slack identity linking, rollback/outer
commit, source visibility, failure rotation, drain/resume and shutdown
coverage. Earlier stack setup and CI failures remain in the owning audit
and stack ledger; they are historical evidence, not this head's
acceptance.

Commands: server Vitest for `join-request-retention-work`,
`approval-retention-work`, `approvals-service`, `attention-service`,
`decision-retention-service`, `budgets-service`, `tool-gateway-service`,
`server-startup-feedback-export`, `scoped-deadline-work`; server
typecheck/build; DB migration checks, snapshot drift and actual
apply/replay. Fresh exact-head CI, security and Greptile/Ponytail 5/5
remain required. No full local repository-suite, deployed suspension or
cost claim is made.

## Risks

- A future supported producer must signal its transaction. Independent
SQL writers and another active server process are outside the
process-local notification contract.
- A writer that wins the source lock first is rechecked before archival.
If archival commits first, later activity preserves the archived state
until explicit revival. Existing invite replay expires before the
retention cutoff and Slack replay deduplicates. Future producers needing
renewed visibility must request revival. Cross-table visibility retains
the existing contract.
- The worker registers only under the existing maintenance enablement
switch. The internal deadline does not add Cloud alarm coverage or relax
conservative sleep blockers.
- New eligibility can wait within the thirty-second aggregate
notification window; known deadlines preempt it.
- Archival preserves pending approval status. Other attention sources
and aggregate maintenance remain separate work.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning and code execution. The exact
serving model identifier and context window were not exposed.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-27 20:03:16 -05:00

433 lines
19 KiB
TypeScript

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";
import { and, eq, inArray, isNotNull, isNull, lte, ne, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
approvals,
decisionArchiveNotificationOutbox,
decisionRetention,
decisions,
heartbeatRuns,
issueApprovals,
issueRecoveryActions,
issueThreadInteractions,
issues,
joinRequests,
} from "@paperclipai/db";
import type {
AttentionArchiveManifestEntry,
AttentionItem,
AttentionSourceKind,
} from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import { logActivity } from "./activity-log.js";
import type { AuthorizationActor } from "./authorization.js";
import {
canReadDecisionSource,
type DecisionMutationActor,
} from "./decision-queues.js";
export const DEFAULT_DECISION_SHELF_DAYS = 30;
export const DEFAULT_DECISION_ARCHIVE_DAYS = 90;
const DAY_MS = 86_400_000;
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";
function sourceKey(sourceKind: string, sourceId: string) {
return `${sourceKind}:${sourceId}`;
}
function canonicalManifest(manifest: readonly AttentionArchiveManifestEntry[]) {
return [...manifest].sort((left, right) => {
const leftKey = `${left.companyId}:${left.sourceKind}:${left.sourceId}`;
const rightKey = `${right.companyId}:${right.sourceKind}:${right.sourceId}`;
return leftKey.localeCompare(rightKey);
}).map((entry) => ({
companyId: entry.companyId,
sourceKind: entry.sourceKind,
sourceId: entry.sourceId,
expectedVersion: entry.expectedVersion,
activityAt: entry.activityAt,
reason: entry.reason,
}));
}
export function hashAttentionArchiveManifest(manifest: readonly AttentionArchiveManifestEntry[]) {
return createHash("sha256").update(JSON.stringify(canonicalManifest(manifest))).digest("hex");
}
function archiveActorColumns(actor: DecisionMutationActor) {
return {
archivedByType: actor.actorType,
archivedByAgentId: actor.agentId,
archivedByUserId: actor.userId,
archivedByRunId: actor.runId,
};
}
async function resolveOrigin(
db: Db,
companyId: string,
sourceKind: AttentionSourceKind,
sourceId: string,
) {
let originAgentId: string | null = null;
let originIssueId: string | null = null;
if (sourceKind === "approval") {
const row = await db.select({ agentId: approvals.requestedByAgentId, issueId: issueApprovals.issueId })
.from(approvals)
.leftJoin(issueApprovals, and(eq(issueApprovals.companyId, companyId), eq(issueApprovals.approvalId, approvals.id)))
.where(and(eq(approvals.companyId, companyId), eq(approvals.id, sourceId)))
.then((rows) => rows[0] ?? null);
originAgentId = row?.agentId ?? null;
originIssueId = row?.issueId ?? null;
} else if (sourceKind === "decision") {
const row = await db.select({ agentId: decisions.originAgentId, issueId: decisions.originIssueId })
.from(decisions).where(and(eq(decisions.companyId, companyId), eq(decisions.id, sourceId)))
.then((rows) => rows[0] ?? null);
originAgentId = row?.agentId ?? null;
originIssueId = row?.issueId ?? null;
} else if (sourceKind === "issue_thread_interaction") {
const row = await db.select({ agentId: issueThreadInteractions.createdByAgentId, issueId: issueThreadInteractions.issueId })
.from(issueThreadInteractions)
.where(and(eq(issueThreadInteractions.companyId, companyId), eq(issueThreadInteractions.id, sourceId)))
.then((rows) => rows[0] ?? null);
originAgentId = row?.agentId ?? null;
originIssueId = row?.issueId ?? null;
} else if (sourceKind === "failed_run") {
const row = await db.select({ agentId: heartbeatRuns.agentId, context: heartbeatRuns.contextSnapshot })
.from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, sourceId)))
.then((rows) => rows[0] ?? null);
const context = row?.context && typeof row.context === "object" ? row.context as Record<string, unknown> : {};
originAgentId = row?.agentId ?? null;
originIssueId = typeof context.issueId === "string" ? context.issueId : typeof context.taskId === "string" ? context.taskId : null;
} else {
const issueId = sourceKind === "recovery_action"
? await db.select({ issueId: issueRecoveryActions.sourceIssueId }).from(issueRecoveryActions)
.where(and(eq(issueRecoveryActions.companyId, companyId), eq(issueRecoveryActions.id, sourceId)))
.then((rows) => rows[0]?.issueId ?? null)
: (["productivity_review", "blocker_attention", "review"] as string[]).includes(sourceKind) ? sourceId : null;
if (issueId) {
const row = await db.select({ agentId: issues.createdByAgentId, issueId: issues.id }).from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.id, issueId)))
.then((rows) => rows[0] ?? null);
originAgentId = row?.agentId ?? null;
originIssueId = row?.issueId ?? null;
}
}
if (!originAgentId || !originIssueId) return null;
const activeAgent = await db.select({ id: agents.id }).from(agents)
.where(and(eq(agents.companyId, companyId), eq(agents.id, originAgentId), eq(agents.status, "active")))
.then((rows) => rows[0] ?? null);
return activeAgent ? { originAgentId, originIssueId } : null;
}
export function decisionRetentionService(
db: Db,
options: { notifyOriginAgent?: (batch: ArchiveNotificationBatch) => Promise<unknown> } = {},
) {
async function syncItems(companyId: string, items: readonly RetentionSource[]) {
if (items.length === 0) return new Map<string, DecisionRetentionState>();
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;
});
}
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),
eq(decisionRetention.sourceKind, sourceKind),
eq(decisionRetention.sourceId, sourceId),
)).then((rows) => rows[0] ?? null);
}
async function materializeSourceState(database: Db, input: { companyId: string; sourceKind: AttentionSourceKind; sourceId: string }) {
const table = input.sourceKind === "approval" ? approvals : input.sourceKind === "join_request" ? joinRequests : null;
if (!table) return;
const [source] = await database.select({ updatedAt: table.updatedAt }).from(table)
.where(and(eq(table.companyId, input.companyId), eq(table.id, input.sourceId))).for("update");
if (!source) throw notFound("Attention source not found");
await decisionRetentionService(database).syncItems(input.companyId, [{
sourceKind: input.sourceKind, subject: { id: input.sourceId }, activityAt: source.updatedAt.toISOString(),
}]);
}
async function setKeep(input: {
companyId: string;
sourceKind: AttentionSourceKind;
sourceId: string;
keep: boolean;
authActor: AuthorizationActor;
actor: DecisionMutationActor;
}) {
if (!(await canReadDecisionSource(db, input.authActor, input.companyId, input.sourceKind, input.sourceId))) {
throw notFound("Attention source not found");
}
const updated = await writeRetention(input.sourceKind, async tx => {
await materializeSourceState(tx, input);
return tx.update(decisionRetention).set({
keep: input.keep,
version: sql`${decisionRetention.version} + 1`,
updatedAt: new Date(),
}).where(and(
eq(decisionRetention.companyId, input.companyId),
eq(decisionRetention.sourceKind, input.sourceKind),
eq(decisionRetention.sourceId, input.sourceId),
)).returning().then((rows) => rows[0] ?? null);
});
if (!updated) throw notFound("Attention source not found");
await logActivity(db, {
companyId: input.companyId,
actorType: input.actor.actorType,
actorId: input.actor.actorId,
agentId: input.actor.agentId,
runId: input.actor.runId,
agentApiKeyId: input.actor.agentApiKeyId,
responsibleUserIdOverride: input.actor.responsibleUserId,
action: "decision_retention.keep_updated",
entityType: "attention_source",
entityId: input.sourceId,
details: { sourceKind: input.sourceKind, keep: input.keep },
});
return updated;
}
async function changeArchived(input: {
companyId: string;
sourceKind: AttentionSourceKind;
sourceId: string;
archived: boolean;
authActor: AuthorizationActor;
actor: DecisionMutationActor;
}) {
if (!(await canReadDecisionSource(db, input.authActor, input.companyId, input.sourceKind, input.sourceId))) {
throw notFound("Attention source not found");
}
const now = new Date();
const updated = await writeRetention(input.sourceKind, async tx => {
await materializeSourceState(tx, input);
return tx.update(decisionRetention).set(input.archived ? {
archivedAt: now,
archivedReason: "manual",
...archiveActorColumns(input.actor),
archiveVersion: sql`${decisionRetention.archiveVersion} + 1`,
version: sql`${decisionRetention.version} + 1`,
updatedAt: now,
} : {
archivedAt: null,
archivedReason: null,
archivedByType: null,
archivedByAgentId: null,
archivedByUserId: null,
archivedByRunId: null,
version: sql`${decisionRetention.version} + 1`,
updatedAt: now,
}).where(and(
eq(decisionRetention.companyId, input.companyId),
eq(decisionRetention.sourceKind, input.sourceKind),
eq(decisionRetention.sourceId, input.sourceId),
input.archived ? isNull(decisionRetention.archivedAt) : isNotNull(decisionRetention.archivedAt),
)).returning().then((rows) => rows[0] ?? null);
});
if (!updated) {
const current = await getState(input.companyId, input.sourceKind, input.sourceId);
if (!current) throw notFound("Attention source not found");
return current;
}
await logActivity(db, {
companyId: input.companyId,
actorType: input.actor.actorType,
actorId: input.actor.actorId,
agentId: input.actor.agentId,
runId: input.actor.runId,
agentApiKeyId: input.actor.agentApiKeyId,
responsibleUserIdOverride: input.actor.responsibleUserId,
action: input.archived ? "decision_retention.archived" : "decision_retention.revived",
entityType: "attention_source",
entityId: input.sourceId,
details: { sourceKind: input.sourceKind },
});
return updated;
}
async function archiveReviewedManifest(input: {
companyId: string;
manifest: AttentionArchiveManifestEntry[];
originActor: AuthorizationActor;
decidingActor: AuthorizationActor;
decidedByUserId: string;
}) {
const keys = input.manifest.map((entry) => sourceKey(entry.sourceKind, entry.sourceId));
if (new Set(keys).size !== keys.length || input.manifest.some((entry) => entry.companyId !== input.companyId)) {
throw unprocessable("Archive manifest must contain one exact company-scoped set");
}
return db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
for (const entry of input.manifest) {
const kind = entry.sourceKind as AttentionSourceKind;
if (!(await canReadDecisionSource(txDb, input.originActor, input.companyId, kind, entry.sourceId))
|| !(await canReadDecisionSource(txDb, input.decidingActor, input.companyId, kind, entry.sourceId))) {
throw conflict("archive_proposal_authority_changed", { code: "archive_proposal_authority_changed" });
}
await txDb.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`decision-retention:${input.companyId}:${entry.sourceKind}:${entry.sourceId}`}, 0))`);
const state = await txDb.select().from(decisionRetention).where(and(
eq(decisionRetention.companyId, input.companyId),
eq(decisionRetention.sourceKind, entry.sourceKind),
eq(decisionRetention.sourceId, entry.sourceId),
)).then((rows) => rows[0] ?? null);
if (!state || state.version !== entry.expectedVersion || state.sourceActivityAt.toISOString() !== entry.activityAt || state.archivedAt) {
throw conflict("archive_proposal_stale", { code: "archive_proposal_stale" });
}
}
const now = new Date();
for (const entry of input.manifest) {
await notifyRetentionWork(txDb, entry.sourceKind as AttentionSourceKind);
const archived = await txDb.update(decisionRetention).set({
archivedAt: now,
archivedReason: "accepted_proposal",
archivedByType: "user",
archivedByAgentId: null,
archivedByUserId: input.decidedByUserId,
archivedByRunId: null,
archiveVersion: sql`${decisionRetention.archiveVersion} + 1`,
version: sql`${decisionRetention.version} + 1`,
updatedAt: now,
}).where(and(
eq(decisionRetention.companyId, input.companyId),
eq(decisionRetention.sourceKind, entry.sourceKind),
eq(decisionRetention.sourceId, entry.sourceId),
eq(decisionRetention.version, entry.expectedVersion),
isNull(decisionRetention.archivedAt),
)).returning({ id: decisionRetention.id });
if (archived.length !== 1) {
throw conflict("archive_proposal_stale", { code: "archive_proposal_stale" });
}
await logActivity(txDb, {
companyId: input.companyId,
actorType: "system",
actorId: "decision-archive-proposal",
responsibleUserIdOverride: input.decidedByUserId,
action: "decision_retention.archived",
entityType: "attention_source",
entityId: entry.sourceId,
details: { sourceKind: entry.sourceKind, reason: "accepted_proposal" },
});
}
return { archived: input.manifest.length };
});
}
async function autoArchive(input: { companyId: string; items: 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);
let archived = 0;
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",
archivedByType: "system",
archivedByAgentId: null,
archivedByUserId: null,
archivedByRunId: null,
archiveVersion: sql`${decisionRetention.archiveVersion} + 1`,
version: sql`${decisionRetention.version} + 1`,
updatedAt: now,
}).where(and(
eq(decisionRetention.companyId, input.companyId),
eq(decisionRetention.sourceKind, item.sourceKind),
eq(decisionRetention.sourceId, item.subject.id),
eq(decisionRetention.version, item.retentionVersion),
eq(decisionRetention.keep, false),
isNull(decisionRetention.archivedAt),
lte(decisionRetention.sourceActivityAt, cutoff),
)).returning().then((rows) => rows[0] ?? null);
if (!updated) continue;
archived += 1;
const origin = await resolveOrigin(txDb, input.companyId, item.sourceKind, item.subject.id);
if (origin) {
await notifyDeliveryWork(txDb, DELIVERY_QUEUES.decisionArchiveDelivery);
await txDb.insert(decisionArchiveNotificationOutbox).values({
companyId: input.companyId,
sourceKind: item.sourceKind,
sourceId: item.subject.id,
archiveVersion: updated.archiveVersion,
originAgentId: origin.originAgentId,
originIssueId: origin.originIssueId,
}).onConflictDoNothing();
}
await logActivity(txDb, {
companyId: input.companyId,
actorType: "system",
actorId: "decision-retention-sweeper",
action: "decision_retention.auto_archived",
entityType: "attention_source",
entityId: item.subject.id,
details: { sourceKind: item.sourceKind, archiveVersion: updated.archiveVersion },
});
}
});
return archived;
}
const archiveDelivery = decisionArchiveDeliveryService(db, options);
return {
syncItems,
getState,
setKeep,
archive: (input: Omit<Parameters<typeof changeArchived>[0], "archived">) => changeArchived({ ...input, archived: true }),
revive: (input: Omit<Parameters<typeof changeArchived>[0], "archived">) => changeArchived({ ...input, archived: false }),
archiveReviewedManifest,
autoArchive,
deliverNotifications: archiveDelivery.deliver,
nextArchiveNotificationAt: archiveDelivery.next,
};
}