mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 08:28:48 +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>
115 lines
4.5 KiB
JavaScript
115 lines
4.5 KiB
JavaScript
|
|
import { readFile, rm } from "node:fs/promises";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { build } from "esbuild";
|
|
|
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
const serverDir = join(scriptDir, "..");
|
|
|
|
const REQUIRE_SHIM = "import { createRequire as __paperclipCreateRequire } from 'node:module'; const require = __paperclipCreateRequire(import.meta.url);";
|
|
|
|
export function preserveModuleLocation(source, file) {
|
|
return source
|
|
.replace(/\bimport\.meta\.url\b/g, JSON.stringify(pathToFileURL(file).href))
|
|
.replace(/\bimport\.meta\.dirname\b/g, JSON.stringify(dirname(file)))
|
|
.replace(/\bimport\.meta\.filename\b/g, JSON.stringify(file));
|
|
}
|
|
|
|
function runtimeEntry(entry) {
|
|
return {
|
|
name: "runtime-entry",
|
|
setup(builder) {
|
|
builder.onResolve({ filter: /^paperclip-runtime-entry$/ }, () => ({ path: "paperclip-runtime-entry", namespace: "runtime-entry" }));
|
|
builder.onLoad({ filter: /.*/, namespace: "runtime-entry" }, () => ({
|
|
contents: `process.argv[1] = ${JSON.stringify(entry)};\nawait import(${JSON.stringify(entry)});\n`,
|
|
loader: "js",
|
|
resolveDir: dirname(entry),
|
|
}));
|
|
},
|
|
};
|
|
}
|
|
|
|
const keepModuleLocations = {
|
|
name: "keep-module-locations",
|
|
setup(builder) {
|
|
builder.onLoad({ filter: /\.(?:[cm]?js|[cm]?ts)$/ }, async (args) => {
|
|
if (args.path.includes("/node_modules/")) return undefined;
|
|
const source = await readFile(args.path, "utf8");
|
|
if (!source.includes("import.meta")) return undefined;
|
|
return {
|
|
contents: preserveModuleLocation(source, args.path),
|
|
loader: /\.[cm]?ts$/.test(args.path) ? "ts" : "js",
|
|
resolveDir: dirname(args.path),
|
|
};
|
|
});
|
|
},
|
|
};
|
|
|
|
function externalizeThirdParty(isBundled, runtimeDir) {
|
|
return {
|
|
name: "externalize-third-party",
|
|
setup(builder) {
|
|
builder.onResolve({ filter: /^[^./]/ }, async (args) => {
|
|
if (args.pluginData?.externalizeThirdParty || isBundled(args.path)) return undefined;
|
|
const resolved = await builder.resolve(args.path, {
|
|
kind: args.kind,
|
|
resolveDir: args.resolveDir,
|
|
importer: args.importer,
|
|
pluginData: { externalizeThirdParty: true },
|
|
});
|
|
if (resolved.errors.length > 0 || resolved.external) return { path: args.path, external: true };
|
|
const fromServer = await builder.resolve(args.path, {
|
|
kind: args.kind,
|
|
resolveDir: runtimeDir,
|
|
pluginData: { externalizeThirdParty: true },
|
|
});
|
|
if (fromServer.errors.length === 0 && fromServer.path === resolved.path) return { path: args.path, external: true };
|
|
return { path: resolved.path, external: true };
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function bundleRuntime({ entry, outdir, maintenanceEntry, ephemeralMigrationEntry, isBundled = (specifier) => specifier.startsWith("@paperclipai/") }) {
|
|
await rm(outdir, { recursive: true, force: true });
|
|
const result = await build({
|
|
entryPoints: { index: "paperclip-runtime-entry", server: entry, ...(maintenanceEntry ? { "agent-files": maintenanceEntry } : {}),
|
|
...(ephemeralMigrationEntry ? { "ephemeral-migration": ephemeralMigrationEntry } : {}) },
|
|
outdir,
|
|
outExtension: { ".js": ".mjs" },
|
|
chunkNames: "chunk-[hash]",
|
|
bundle: true,
|
|
splitting: true,
|
|
format: "esm",
|
|
platform: "node",
|
|
target: "node24",
|
|
mainFields: ["main"],
|
|
conditions: ["node"],
|
|
banner: { js: REQUIRE_SHIM },
|
|
sourcemap: "linked",
|
|
metafile: true,
|
|
logLevel: "error",
|
|
plugins: [runtimeEntry(entry), keepModuleLocations, externalizeThirdParty(isBundled, dirname(entry))],
|
|
});
|
|
const outputs = Object.keys(result.metafile.outputs).filter((file) => file.endsWith(".mjs"));
|
|
return {
|
|
outfile: join(outdir, "index.mjs"),
|
|
serverModule: join(outdir, "server.mjs"),
|
|
inputs: Object.keys(result.metafile.inputs).length,
|
|
chunks: outputs.length,
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
const started = performance.now();
|
|
const summary = await bundleRuntime({ entry: join(serverDir, "dist", "index.js"), outdir: join(serverDir, "dist-runtime"),
|
|
maintenanceEntry: join(serverDir, "dist", "maintenance", "agent-files.js"),
|
|
ephemeralMigrationEntry: join(serverDir, "dist", "maintenance", "ephemeral-migration.js") });
|
|
console.log(`[bundle-runtime] ${summary.inputs} inputs -> ${summary.chunks} chunks in ${Math.round(performance.now() - started)} ms: ${summary.outfile}`);
|
|
}
|
|
|
|
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
|
|
await main();
|
|
}
|