mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 02:32:07 +01:00
## 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>
109 lines
5.4 KiB
TypeScript
109 lines
5.4 KiB
TypeScript
import { inArray } from "drizzle-orm";
|
|
import { heartbeatRuns, type Db } from "@paperclipai/db";
|
|
import { getStorageService } from "../storage/index.js";
|
|
import { migrateAgentFiles, verifyAgentFileObjects } from "../services/agent-file-maintenance.js";
|
|
import { readEphemeralServerDataEligibility } from "../services/ephemeral-server-contract.js";
|
|
import { migrateRunEvidence } from "../services/run-evidence-migration.js";
|
|
import { skillInstallationMaintenance } from "../services/skill-installation-maintenance.js";
|
|
import { skillPackageMaintenance } from "../services/skill-package-maintenance.js";
|
|
import { hasUnsettledLocalInstructionCopies } from "../services/agent-instruction-working-copies.js";
|
|
import { providerTraceStore } from "../services/provider-trace-store.js";
|
|
|
|
/** Runs only in the maintenance process after Cloud has confirmed all legacy
|
|
* deployments stopped. Each owner retains its own migration/publication rules;
|
|
* a restart repeats idempotent pages, never reimports over durable authority. */
|
|
export async function migrateEphemeralServerData(db: Db, input: {
|
|
signal: AbortSignal; phase: (name: string) => void;
|
|
}) {
|
|
const { signal, phase } = input;
|
|
const counts: Record<string, number> = {};
|
|
const [active] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns)
|
|
.where(inArray(heartbeatRuns.status, ["running", "queued"])).limit(1);
|
|
if (active) throw new Error("active_execution_requires_drain");
|
|
if (await hasUnsettledLocalInstructionCopies(db)) throw new Error("local_instruction_copies_require_settlement");
|
|
const before = await readEphemeralServerDataEligibility(db);
|
|
const unsupported = before.blockers.filter(blocker => ![
|
|
"agent_files_require_migration", "run_logs_require_migration", "provider_traces_require_migration", "skills_require_migration",
|
|
].includes(blocker));
|
|
if (unsupported.length) throw new Error(unsupported[0]);
|
|
|
|
phase("agent_files");
|
|
let after: string | undefined;
|
|
do {
|
|
signal.throwIfAborted();
|
|
const page = await migrateAgentFiles(db, { apply: true, writersStopped: true, after, limit: 25 });
|
|
if (!page.pageReady) throw new Error("agent_file_migration_blocked");
|
|
counts.agentFiles = (counts.agentFiles ?? 0) + page.results.length;
|
|
after = page.next ?? undefined;
|
|
} while (after);
|
|
|
|
phase("run_evidence");
|
|
// Separate cursors avoid restarting an exhausted kind when the other kind
|
|
// still has pages. A blocked item fails enrollment instead of being skipped.
|
|
for (const kind of ["log", "trace"] as const) {
|
|
after = undefined;
|
|
do {
|
|
signal.throwIfAborted();
|
|
const page = await migrateRunEvidence(db, { apply: true, kinds: [kind], limit: 50, signal,
|
|
...(kind === "log" ? { afterRunId: after } : { afterTraceId: after }) });
|
|
if (page.items.some(item => item.state !== "migrated")) throw new Error(`${kind}_migration_blocked`);
|
|
counts[kind] = (counts[kind] ?? 0) + page.items.length;
|
|
after = kind === "log" ? page.nextRunId : page.nextTraceId;
|
|
} while (after);
|
|
}
|
|
|
|
const packages = skillPackageMaintenance(db);
|
|
const skills = skillInstallationMaintenance(db);
|
|
for (const [name, migrate] of [["skill_packages", packages.migrateSnapshots], ["skill_versions", packages.migrateVersions]] as const) {
|
|
phase(name);
|
|
after = undefined;
|
|
for (;;) {
|
|
signal.throwIfAborted();
|
|
const page: Awaited<ReturnType<typeof migrate>> = await migrate({ apply: true, after, limit: 50, signal });
|
|
counts[name] = (counts[name] ?? 0) + page.inspected;
|
|
if (page.inspected < 50 || !page.after) break;
|
|
after = page.after;
|
|
}
|
|
}
|
|
phase("skill_installations");
|
|
after = undefined;
|
|
for (;;) {
|
|
signal.throwIfAborted();
|
|
const page = await skills.installations({ apply: true, writersStopped: true, after, limit: 50, signal });
|
|
if (page.results.some(row => !["converted", "unchanged"].includes(row.status))) throw new Error("skill_installation_migration_blocked");
|
|
counts.skillInstallations = (counts.skillInstallations ?? 0) + page.inspected;
|
|
if (page.inspected < 50 || !page.after) break;
|
|
after = page.after;
|
|
}
|
|
|
|
phase("verify_agent_objects");
|
|
const storage = getStorageService();
|
|
if (storage.provider !== "s3") throw new Error("shared_object_storage_required");
|
|
counts.verifiedAgentObjects = await verifyAgentFileObjects(db, storage, signal);
|
|
phase("verify_skill_versions");
|
|
after = undefined;
|
|
for (;;) {
|
|
signal.throwIfAborted();
|
|
const page = await skills.verifyVersions({ after, limit: 50, signal });
|
|
if (page.results.some(row => !["remote_storage", "database_release_snapshot", "database_snapshot", "expired_git_history"].includes(row.status))) {
|
|
throw new Error("skill_version_verification_failed");
|
|
}
|
|
counts.verifiedSkillVersions = (counts.verifiedSkillVersions ?? 0) + page.inspected;
|
|
if (page.inspected < 50 || !page.after) break;
|
|
after = page.after;
|
|
}
|
|
phase("eligibility");
|
|
// A trace can expire after the app stops or while other owners migrate.
|
|
// Its owner tombstones and cleans all expired pages before final eligibility.
|
|
const traces = providerTraceStore(db), expiryAt = new Date();
|
|
for (;;) {
|
|
signal.throwIfAborted();
|
|
const removed = await traces.cleanupExpired(expiryAt, signal);
|
|
counts.expiredTraces = (counts.expiredTraces ?? 0) + removed.length;
|
|
if (removed.length < 100) break;
|
|
}
|
|
const eligibility = await readEphemeralServerDataEligibility(db);
|
|
if (!eligibility.eligible) throw new Error("final_data_eligibility_failed");
|
|
return { counts, eligibility };
|
|
}
|