Files
PaperClipAI/server/src/services/agent-file-maintenance.ts
T
DottaandPaperclip d941ed3879 feat(server): verify existing-volume ephemeral enrollment (#18529)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Cloud web servers can now keep durable state in PostgreSQL and S3.
> - Existing servers still have files and a decision signing key on
their volume.
> - The volume must stay attached until every data owner verifies its
migration.
> - This pull request adds a dedicated maintenance process for that
transition.
> - Cloud can preserve the original key and remove the volume only after
a matching receipt.

## Linked Issues or Issue Description

Refs #18518 and #18526. Existing-volume enrollment is a required step in
the zero-downtime web deployment work. This is the Core maintenance
side; the Cloud transition is a separate private change.

## What Changed

- Package a standalone maintenance entry that starts no normal app
routes or execution workers.
- Coordinate the existing agent-file, run-evidence and skill migration
owners. Follow every page and verify shared bytes before final
eligibility.
- Require stopped writers. Refuse active execution, missing local bytes,
local workspaces and external instructions.
- Read the existing decision key without generating a replacement.
Preserve normal key creation for ordinary server startup.
- Require an attempt token for status and export. Export the original
key only after successful migration. Keep status and errors free of
secret material.
- Document the Cloud ownership boundary, deadline, retry and retention
requirements.

## Verification

- Full workspace typecheck and build passed before the review fixes. The
final server TypeScript compilation passed after correcting Drizzle's
optional predicate type.
- Production runtime bundling passed: 2,702 inputs, 122 chunks,
including the maintenance executable.
- The focused suite passed 21 cases using PostgreSQL and real
S3-compatible storage. It covers multi-page migration of 26 agents,
exact reads after deleting the predecessor home, corrupt-object
rejection, active/missing-data refusal, and the bundled plain-Node
process with authenticated export and clean SIGTERM exit.
- Regression cases refuse unsaved terminal working copies, require
explicit stopped/cleaned receipts for unavailable copies, clean 101
expired traces through the owning service, and enforce the deadline
after migration succeeds.
- Initial integration attempts exposed fixture cleanup ordering
problems; corrected runs pass. CI also caught a Drizzle predicate type
error, now fixed and compiled locally.
- All repository CI gates passed on a4db955403, including typecheck,
build, browser suites and serialized tests. Greptile reviewed that head
at 5/5; all four review threads are resolved. Hosted enrollment and
active-agent continuity remain separate acceptance work.

## Risks

- This is an explicit maintenance entry. The caller must confirm all
prior deployments stopped and keep normal tenant routing closed through
detachment.
- A timeout blocks export but an owner may finish its current bounded
operation before observing cancellation. Cloud must confirm process exit
before detaching. SIGTERM has a ten-second hard exit deadline.
- The authenticated export contains the original signing key. The Cloud
caller must put it only in the stack vault and must not log the body.
- Local-workspace and external-instruction migrations remain their
existing owners' responsibility. Enrollment fails closed until those
data are eligible.
- No database schema changes. Ordinary server startup is unchanged
unless this maintenance entry is explicitly launched.

## Model Used

OpenAI Codex, GPT-6. Tool use, code execution and repository review. The
runtime does not expose a more specific model ID or context-window size.

## Checklist

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-01 03:56:44 -06:00

131 lines
7.3 KiB
TypeScript

import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { and, asc, eq, gt, inArray } from "drizzle-orm";
import { agentFileRoots, agents, fileStorageDb, heartbeatRuns, type Db } from "@paperclipai/db";
import type { StorageProvider, StorageService } from "../storage/types.js";
import { adoptAgentFiles, inspectAgentDirectory } from "./agent-file-store.js";
import { agentInstructionsBundleMode, deriveBundleState, resolveManagedInstructionsRoot } from "./agent-instructions.js";
import { durableAgentFilesEnabled } from "./agent-file-durability.js";
export const AGENT_FILE_OBJECT_GRACE_MS = 30 * 24 * 60 * 60_000;
/** One-time operator migration on the host that owns the volume. Cloud must
* stop ingress and execution writers before apply; row locks also serialize
* ordinary file mutations. A dry run never adopts or rewrites local files. */
export async function migrateAgentFiles(db: Db, input: {
apply: boolean; writersStopped: boolean; companyId?: string; after?: string; limit?: number;
}) {
if (input.apply && (!input.writersStopped || !durableAgentFilesEnabled())) {
throw new Error("Agent file migration requires stopped writers and PAPERCLIP_DURABLE_AGENT_FILES=true");
}
const limit = input.limit ?? 25;
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new Error("Migration limit must be between 1 and 100");
const rows = await db.select({ id: agents.id }).from(agents).where(and(
input.companyId ? eq(agents.companyId, input.companyId) : undefined,
input.after ? gt(agents.id, input.after) : undefined,
)).orderBy(asc(agents.id)).limit(limit + 1);
const results: Array<{ agentId: string; state: string }> = [];
for (const candidate of rows.slice(0, limit)) {
const state = await fileStorageDb(db).transaction(async tx => {
const [agent] = await tx.select().from(agents).where(eq(agents.id, candidate.id)).for("update");
if (!agent) return "removed";
if (agentInstructionsBundleMode(agent) === "external") return "external_instructions";
const [run] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, agent.companyId), eq(heartbeatRuns.agentId, agent.id),
inArray(heartbeatRuns.status, ["running", "queued"]),
)).limit(1);
if (run) return "active_execution";
const [head] = await tx.select({ generation: agentFileRoots.generation }).from(agentFileRoots)
.where(and(eq(agentFileRoots.companyId, agent.companyId), eq(agentFileRoots.agentId, agent.id)));
if (input.apply) {
await adoptAgentFiles(tx, agent);
return "durable";
}
if (head) return "durable";
const root = resolveManagedInstructionsRoot(agent);
const stored = agent.adapterConfig.instructionsRootPath;
if (typeof stored === "string" && (path.resolve(stored) !== root
|| !await fs.stat(path.join(root, deriveBundleState(agent).entryFile)).then(s => s.isFile()).catch(() => false))) {
return "legacy_files_missing";
}
if (await fs.stat(root).then(s => s.isDirectory()).catch(() => false)) await inspectAgentDirectory(root, false);
return "needs_import";
});
results.push({ agentId: candidate.id, state });
}
return { results, next: rows.length > limit ? results.at(-1)!.agentId : null,
pageReady: results.every(row => row.state === "durable" || row.state === "removed") };
}
/** Bounded, explicit maintenance, with no idle polling. Keep every object for
* an unadopted agent: a rolled-back first save may have a local import receipt.
* The agent row lock prevents a concurrent publisher from reusing an object
* between the current-head check and deletion. Deleted agents have no writer. */
export async function collectAgentFileObjects(db: Db, provider: Pick<StorageProvider, "id" | "listObjects">, storage: StorageService, input: {
companyId: string; agentId: string; apply: boolean; continuationToken?: string; now?: number;
}) {
if (provider.id !== "s3" || storage.provider !== "s3" || !provider.listObjects) throw new Error("Shared object storage is required");
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
if (!uuid.test(input.companyId) || !uuid.test(input.agentId)) throw new Error("Company and agent UUIDs are required");
const prefix = `${input.companyId}/agent-files/${input.agentId}/`;
const signal = AbortSignal.timeout(30_000);
const page = await provider.listObjects({ prefix, continuationToken: input.continuationToken, signal });
let retained = 0, eligible = 0, deleted = 0;
for (const key of page.keys) {
signal.throwIfAborted();
if (!key.startsWith(prefix) || key.includes("..") || key.includes("\\")) throw new Error("Agent file object scope is invalid");
await fileStorageDb(db).transaction(async tx => {
const [agent] = await tx.select({ id: agents.id }).from(agents)
.where(and(eq(agents.companyId, input.companyId), eq(agents.id, input.agentId))).for("update");
const [head] = await tx.select({ entries: agentFileRoots.entries }).from(agentFileRoots)
.where(and(eq(agentFileRoots.companyId, input.companyId), eq(agentFileRoots.agentId, input.agentId)));
if ((agent && !head) || head?.entries.some(entry => entry.kind === "file" && entry.objectKey === key)) {
retained++; return;
}
const object = await storage.headObject(input.companyId, key, { signal });
if (!object.exists || !object.lastModified || object.lastModified.getTime() + AGENT_FILE_OBJECT_GRACE_MS > (input.now ?? Date.now())) {
retained++; return;
}
eligible++;
if (input.apply) {
await storage.deleteObject(input.companyId, key, { signal, allVersions: true });
if ((await storage.headObject(input.companyId, key, { signal })).exists) throw new Error("Agent file object remains after deletion");
deleted++;
}
});
}
return { retained, eligible, deleted, next: page.continuationToken ?? null };
}
/** Verify all authoritative bytes before the legacy volume can be detached.
* This runs only with writers stopped, using bounded pages and streams. */
export async function verifyAgentFileObjects(db: Db, storage: StorageService, signal: AbortSignal): Promise<number> {
if (storage.provider !== "s3") throw new Error("shared_object_storage_required");
let after: string | undefined;
let verified = 0;
for (;;) {
signal.throwIfAborted();
const heads = await db.select().from(agentFileRoots).where(after ? gt(agentFileRoots.agentId, after) : undefined)
.orderBy(asc(agentFileRoots.agentId)).limit(25);
for (const head of heads) for (const file of head.entries) {
if (file.kind !== "file") continue;
const hash = createHash("sha256");
let bytes = 0;
if (file.objectKey !== null) {
const object = await storage.getObject(head.companyId, file.objectKey, { signal });
for await (const chunk of object.stream) {
signal.throwIfAborted(); bytes += chunk.length;
if (bytes > file.size) throw new Error("agent_file_object_size_mismatch");
hash.update(chunk);
}
}
if (bytes !== file.size || hash.digest("hex") !== file.sha256) throw new Error("agent_file_object_digest_mismatch");
verified++;
}
if (heads.length < 25) break;
after = heads.at(-1)!.agentId;
}
return verified;
}