mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +01:00
## Thinking Path > - Paperclip preserves the files and recovery state produced by its agents. > - Canonical online migration can preserve selected local workspaces while unrelated work continues. > - Operators sometimes need a newer migration owner before the serving web process can take that release. > - The existing packaged maintenance entry requires stopped writers. > - This pull request adds a finite online entry from one coherent reviewed release tree. > - The entry delegates capture and publication to the existing owners and keeps tool and serving identities separate. ## Linked Issues or Issue Description Refs #19854, #19855 and #19872. The duplicate search found related owner fixes and no separate versioned online entry. **What happened?** The finite online workspace importer was available as a library in the serving image. The packaged all-phase migration entry required maintenance mode. **Expected behavior** Run the reviewed online owner from a separate complete release tree without replacing the web process, stopping unrelated work or changing its release policy. **Deployment mode** A managed instance with its original local volume, database, existing encryption key and shared S3 storage. ## What Changed - Add strict inspect, single, cohort and exact single-journal resume requests with explicit company, workspace, deadline and concurrency bounds. - Verify the private tool root and its own build stamp separately from the serving source commit and deployment. - Reuse the nonmutating full migration-manifest inspector and require the additive controller host-identity column before publication. - Hydrate existing configuration and key bindings, then use the original ESM database pool and canonical storage, source locks and migration owners. - Keep multi-member inspection with the optional canonical cohort preflight in a read-only transaction with denied storage. Releases without that owner refuse cohort operations; single import and resume remain available. - Register the optional runtime bundle entry and document the supported full-tree source mode, resource admission and reconciliation. - Add hash-bound old-writer/reader compatibility and encrypted restore tests. ## Verification Final local revision: `c9345b72f27aee1158047dccd0b1060f9a0be6df`, on master `fbe689938c3bf8c5849d2cd56b9da90029db9389`, macOS arm64 with Node 24.22.2. - Entry and runtime packaging: **34/34 PASS**. - Real PostgreSQL/filesystem compatibility: **6/6 PASS**, disposable database wrapper exit 0 and cleanup complete. The frozen old source lock owner contends with the new entry; unrelated work proceeds. The old authority and snapshot readers decrypt and restore newly published bytes. - Pending and uploaded journal cases use the unchanged old persisted format and preserve operation/snapshot IDs through resume. - Full layered server source and test typecheck: **PASS**. - Module boundaries and `git diff --check`: **PASS**. - Actual source-mode module import under Node 24 succeeds without starting the application. - A separate temporary full-old-producer fixture did not get past module setup: 90 seconds under Vitest and a 15-second direct Node control. Neither is counted as an executed old-producer test or a product failure. The smaller frozen old-writer/reader proof and old-format journal cases are the local evidence. - Hosted resume of the actual saved operation and Linux artifact qualification remain operational acceptance gates. This PR does not claim those ran. - Review regression: a real successful import returned a null operation ID (genuine RED). The repaired entry binds the exact returned snapshot to its succeeded migration journal after commit (GREEN). Committed or superseded snapshots are accepted; a missing readback requires reconciliation rather than a blind retry. - Final affected check: **40/40 PASS** across the entry, packaging and actual PostgreSQL compatibility suites; wrapper exit 0 and cleanup complete. Full layered typecheck and boundaries pass after the user-requested rebase and current Runner rebuild. - A second real postcommit database-read fault also has genuine RED/GREEN proof. The succeeded journal, saved authority and original bytes remain intact, and every readback error now preserves the reconcile-required warning. - Final-head [CI run 42404116001](https://github.com/paperclipai/paperclip/actions/runs/42404116001): **PASS**, with all 59 check contexts terminal (55 success and 4 intentional skips). Fresh Greptile review is **5/5** on `c9345b72f27aee1158047dccd0b1060f9a0be6df`, with no unresolved threads; check and thread pagination are complete. ## Risks - The tool must come from one authenticated complete release tree with its own dependencies. A copied bundle chunk can retain old absolute paths and is not a portable release. - Concurrency is not resource admission. The operator must check memory, CPU and scratch headroom and start with one import or two cohort members. - Schema must be prepared by the existing schema owner. This entry never applies DDL. - Cancellation awaits the canonical owner. An SSH interruption does not prove the process stopped; durable journals must be reconciled before retry. - The entry preserves remote obligations and source files. It does not prove enrollment readiness or zero-downtime operation. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools and code execution. The exact serving snapshot and context-window size are not exposed to this agent. ## 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>
223 lines
14 KiB
JavaScript
223 lines
14 KiB
JavaScript
import { execFile } from "node:child_process";
|
|
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { promisify } from "node:util";
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import { bundleRuntime, preserveModuleLocation } from "./bundle-runtime.mjs";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
function write(file, content) {
|
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
writeFileSync(file, content);
|
|
}
|
|
|
|
describe("bundleRuntime", () => {
|
|
let root;
|
|
|
|
beforeEach(() => {
|
|
root = realpathSync(mkdtempSync(path.join(tmpdir(), "paperclip-bundle-runtime-")));
|
|
const pkg = path.join(root, "packages", "fixture");
|
|
write(path.join(pkg, "package.json"), JSON.stringify({ name: "@paperclipai/fixture", type: "module", exports: { ".": "./src/index.ts" } }));
|
|
write(path.join(pkg, "src", "asset.txt"), "asset beside its source\n");
|
|
write(path.join(pkg, "src", "helper.ts"), "export const double = (value: number): number => value * 2;\n");
|
|
write(path.join(pkg, "src", "index.ts"), [
|
|
"import { readFileSync } from \"node:fs\";",
|
|
"import { double } from \"./helper.js\";",
|
|
"import { onlyForPackage } from \"package-private-dep\";",
|
|
"import { nodeBuild } from \"dual-build-dep\";",
|
|
"export const asset = readFileSync(new URL(\"./asset.txt\", import.meta.url), \"utf8\").trim();",
|
|
"export const location = import.meta.dirname;",
|
|
"export const values = { doubled: double(21), onlyForPackage, nodeBuild };",
|
|
"",
|
|
].join("\n"));
|
|
write(path.join(pkg, "node_modules", "package-private-dep", "package.json"), JSON.stringify({ name: "package-private-dep", type: "module", main: "index.js" }));
|
|
write(path.join(pkg, "node_modules", "package-private-dep", "index.js"), "export const onlyForPackage = \"resolved from the package\";\n");
|
|
write(path.join(pkg, "node_modules", "dual-build-dep", "package.json"), JSON.stringify({
|
|
name: "dual-build-dep", exports: { ".": { module: "./bundler/index.js", default: "./node.cjs" } },
|
|
}));
|
|
write(path.join(pkg, "node_modules", "dual-build-dep", "node.cjs"), "exports.nodeBuild = \"node build\";\n");
|
|
write(path.join(pkg, "node_modules", "dual-build-dep", "bundler", "index.js"), "export * from \"./extensionless\";\n");
|
|
|
|
const server = path.join(root, "server");
|
|
mkdirSync(path.join(server, "node_modules", "@paperclipai"), { recursive: true });
|
|
symlinkSync(pkg, path.join(server, "node_modules", "@paperclipai", "fixture"), "dir");
|
|
write(path.join(server, "package.json"), JSON.stringify({ name: "server", type: "module" }));
|
|
for (const [version, flavor] of [["1", "installed at build"], ["2", "replaced after build"]]) {
|
|
const store = path.join(root, "node_modules", ".pnpm", `server-dep@${version}`);
|
|
write(path.join(store, "package.json"), JSON.stringify({ name: "server-dep", type: "module", main: "index.js" }));
|
|
write(path.join(store, "index.js"), `export const flavor = ${JSON.stringify(flavor)};\n`);
|
|
}
|
|
symlinkSync(path.join(root, "node_modules", ".pnpm", "server-dep@1"), path.join(server, "node_modules", "server-dep"), "dir");
|
|
write(path.join(server, "dist", "late.js"), "import { values } from \"@paperclipai/fixture\";\nexport const late = () => values.doubled + 1;\n");
|
|
write(path.join(server, "dist", "index.js"), [
|
|
"import { asset, location, values } from \"@paperclipai/fixture\";",
|
|
"import { flavor } from \"server-dep\";",
|
|
"import { pathToFileURL } from \"node:url\";",
|
|
"const { late } = await import(\"./late.js\");",
|
|
"// The server only starts when it is the main module.",
|
|
"if (pathToFileURL(process.argv[1]).href === import.meta.url) {",
|
|
" console.log(JSON.stringify({ asset, location, values, flavor, late: late(), entryDir: import.meta.dirname }));",
|
|
"}",
|
|
"",
|
|
].join("\n"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
it("packages a maintenance entry without starting the application or needing a TypeScript loader", async () => {
|
|
const maintenanceEntry = path.join(root, "server", "dist", "maintenance.js");
|
|
write(maintenanceEntry, 'import { asset } from "@paperclipai/fixture"; console.log(JSON.stringify({ asset, phase: process.argv.at(-1) }));');
|
|
const outdir = path.join(root, "server", "dist-runtime");
|
|
await bundleRuntime({ entry: path.join(root, "server", "dist", "index.js"), outdir, maintenanceEntry });
|
|
const { stdout } = await execFileAsync(process.execPath, [path.join(outdir, "agent-files.mjs"), "--phase=eligibility"], { timeout: 30_000 });
|
|
expect(JSON.parse(stdout)).toEqual({ asset: "asset beside its source", phase: "--phase=eligibility" });
|
|
});
|
|
|
|
it("packages standalone enrollment without starting the app", async () => {
|
|
const ephemeralMigrationEntry = path.join(root, "server", "dist", "migration.js");
|
|
write(ephemeralMigrationEntry, 'import { values } from "@paperclipai/fixture"; console.log(JSON.stringify({ migrated: values.doubled }));');
|
|
const outdir = path.join(root, "server", "dist-runtime");
|
|
await bundleRuntime({ entry: path.join(root, "server", "dist", "index.js"), outdir, ephemeralMigrationEntry });
|
|
const { stdout } = await execFileAsync(process.execPath, [path.join(outdir, "ephemeral-migration.mjs")], { timeout: 30_000 });
|
|
expect(JSON.parse(stdout)).toEqual({ migrated: 42 });
|
|
});
|
|
|
|
it("packages online harness preparation as a standalone plain-Node entry", async () => {
|
|
const nativeHarnessPrestageEntry = path.join(root, "server", "dist", "prestage.js");
|
|
write(nativeHarnessPrestageEntry, 'import { values } from "@paperclipai/fixture"; console.log(JSON.stringify({ copied: values.doubled, enrollmentReady: false }));');
|
|
const outdir = path.join(root, "server", "dist-runtime");
|
|
await bundleRuntime({ entry: path.join(root, "server", "dist", "index.js"), outdir, nativeHarnessPrestageEntry });
|
|
const { stdout } = await execFileAsync(process.execPath, [path.join(outdir, "native-harness-prestage.mjs")], { timeout: 30_000 });
|
|
expect(JSON.parse(stdout)).toEqual({ copied: 42, enrollmentReady: false });
|
|
});
|
|
|
|
it("packages the finite online workspace entry without starting the app", async () => {
|
|
const onlineWorkspaceMigrationEntry = path.join(root, "server", "dist", "online.js");
|
|
write(onlineWorkspaceMigrationEntry, 'import { values } from "@paperclipai/fixture"; console.log(JSON.stringify({ migrated: values.doubled, enrollmentReady: false }));');
|
|
const outdir = path.join(root, "server", "dist-runtime");
|
|
await bundleRuntime({ entry: path.join(root, "server", "dist", "index.js"), outdir, onlineWorkspaceMigrationEntry });
|
|
const { stdout } = await execFileAsync(process.execPath, [path.join(outdir, "online-workspace-migration.mjs")], { timeout: 30_000 });
|
|
expect(JSON.parse(stdout)).toEqual({ migrated: 42, enrollmentReady: false });
|
|
});
|
|
|
|
it("bundles workspace TypeScript and runs with plain node", async () => {
|
|
const outdir = path.join(root, "server", "dist-runtime");
|
|
const summary = await bundleRuntime({ entry: path.join(root, "server", "dist", "index.js"), outdir });
|
|
expect(summary.outfile).toBe(path.join(outdir, "index.mjs"));
|
|
|
|
const { stdout } = await execFileAsync(process.execPath, [summary.outfile], { cwd: path.join(root, "server"), timeout: 30_000 });
|
|
const output = JSON.parse(stdout);
|
|
expect(output.values).toEqual({ doubled: 42, onlyForPackage: "resolved from the package", nodeBuild: "node build" });
|
|
expect(output.late).toBe(43);
|
|
expect(output.asset).toBe("asset beside its source");
|
|
expect(output.location).toBe(path.join(root, "packages", "fixture", "src"));
|
|
expect(output.entryDir).toBe(path.join(root, "server", "dist"));
|
|
});
|
|
|
|
it("keeps database caller locations public in a source-mapped runtime bundle", async () => {
|
|
const captureModule = fileURLToPath(new URL("../../packages/db/src/response-capture.ts", import.meta.url));
|
|
write(path.join(root, "server", "dist", "services", "response-caller.js"), [
|
|
`import { publicDatabaseCallers } from ${JSON.stringify(captureModule)};`,
|
|
'export function callers() { return publicDatabaseCallers(new Error("private-capture-message").stack); }',
|
|
].join("\n"));
|
|
write(path.join(root, "server", "dist", "index.js"), [
|
|
'import { callers } from "./services/response-caller.js";',
|
|
'console.log(JSON.stringify({ callers: callers() }));',
|
|
].join("\n"));
|
|
const summary = await bundleRuntime({ entry: path.join(root, "server", "dist", "index.js"), outdir: path.join(root, "server", "dist-runtime") });
|
|
const env = { ...process.env };
|
|
delete env.NODE_OPTIONS;
|
|
delete env.PAPERCLIP_DB_RESPONSE_CAPTURE_WINDOW;
|
|
const options = { cwd: path.join(root, "server"), env, timeout: 30_000 };
|
|
const plain = JSON.parse((await execFileAsync(process.execPath, [summary.outfile], options)).stdout);
|
|
const mapped = JSON.parse((await execFileAsync(process.execPath, ["--enable-source-maps", summary.outfile], options)).stdout);
|
|
expect(plain.callers).toEqual([]);
|
|
expect(mapped.callers).toEqual([expect.stringMatching(/^services\/response-caller\.js:\d+:\d+$/)]);
|
|
for (const result of [plain, mapped]) {
|
|
const output = JSON.stringify(result);
|
|
expect(output).not.toContain(root);
|
|
expect(output).not.toContain(captureModule);
|
|
expect(output).not.toContain("private-capture-message");
|
|
}
|
|
});
|
|
|
|
it("attributes asynchronous database operations in a plain runtime bundle", async () => {
|
|
const captureModule = fileURLToPath(new URL("../../packages/db/src/response-capture.ts", import.meta.url));
|
|
write(path.join(root, "server", "dist", "services", "database-owner.js"), [
|
|
`import { withDatabaseOperation } from ${JSON.stringify(captureModule)};`,
|
|
'export const run = (owner, db) => withDatabaseOperation(owner, async () => {',
|
|
' await Promise.resolve();',
|
|
' return await db.$client.unsafe("select $1", ["private-parameter"]);',
|
|
'});',
|
|
].join("\n"));
|
|
write(path.join(root, "server", "dist", "index.js"), [
|
|
`import { createDatabaseResponseCapture, installDatabaseResponseCapture } from ${JSON.stringify(captureModule)};`,
|
|
'import { run } from "./services/database-owner.js";',
|
|
'const events = [];',
|
|
'const now = Date.now();',
|
|
'const capture = createDatabaseResponseCapture({ start: now, end: now + 60000 }, event => events.push(event));',
|
|
'const db = { $client: { unsafe: () => ({ then(resolve, reject) { return Promise.resolve(Object.assign([["private-result"]], { columns: [{ name: "value" }] })).then(resolve, reject); } }) } };',
|
|
'installDatabaseResponseCapture(db, {}, capture);',
|
|
'await Promise.all([run("fixture.first", db), run("fixture.second", db)]);',
|
|
'capture.flush();',
|
|
'console.log(JSON.stringify(events));',
|
|
].join("\n"));
|
|
const summary = await bundleRuntime({ entry: path.join(root, "server", "dist", "index.js"), outdir: path.join(root, "server", "dist-runtime") });
|
|
const env = { ...process.env };
|
|
delete env.NODE_OPTIONS;
|
|
delete env.PAPERCLIP_DB_RESPONSE_CAPTURE_WINDOW;
|
|
const { stdout } = await execFileAsync(process.execPath, [summary.outfile], { cwd: path.join(root, "server"), env, timeout: 30_000 });
|
|
const events = JSON.parse(stdout);
|
|
const entries = events.filter(event => event.event === "paperclip.db_response_capture.entry.v2");
|
|
expect(entries.map(entry => entry.owner).sort()).toEqual(["fixture.first", "fixture.second"]);
|
|
for (const entry of entries) expect(entry).toMatchObject({ calls: 1, rows: 1, callers: [] });
|
|
expect(events.at(-1)).toMatchObject({ event: "paperclip.db_response_capture.summary.v2", completedQueries: 2 });
|
|
expect(stdout).not.toContain("private-parameter");
|
|
expect(stdout).not.toContain("private-result");
|
|
expect(stdout).not.toContain(root);
|
|
});
|
|
|
|
it("emits a server module that loads without starting the server", async () => {
|
|
const outdir = path.join(root, "server", "dist-runtime");
|
|
const summary = await bundleRuntime({ entry: path.join(root, "server", "dist", "index.js"), outdir });
|
|
expect(summary.serverModule).toBe(path.join(outdir, "server.mjs"));
|
|
const probe = path.join(root, "server", "probe.mjs");
|
|
write(probe, `await import(${JSON.stringify(pathToFileURL(summary.serverModule).href)});\nconsole.log("loaded");\n`);
|
|
const loaded = await execFileAsync(process.execPath, [probe], { cwd: path.join(root, "server"), timeout: 30_000 });
|
|
expect(loaded.stdout.trim()).toBe("loaded");
|
|
const started = await execFileAsync(process.execPath, [summary.outfile], { cwd: path.join(root, "server"), timeout: 30_000 });
|
|
expect(JSON.parse(started.stdout).late).toBe(43);
|
|
});
|
|
|
|
it("leaves the server's own dependencies to runtime resolution", async () => {
|
|
const outdir = path.join(root, "server", "dist-runtime");
|
|
const summary = await bundleRuntime({ entry: path.join(root, "server", "dist", "index.js"), outdir });
|
|
rmSync(path.join(root, "server", "node_modules", "server-dep"));
|
|
symlinkSync(path.join(root, "node_modules", ".pnpm", "server-dep@2"), path.join(root, "server", "node_modules", "server-dep"), "dir");
|
|
const { stdout } = await execFileAsync(process.execPath, [summary.outfile], { cwd: path.join(root, "server"), timeout: 30_000 });
|
|
expect(JSON.parse(stdout).flavor).toBe("replaced after build");
|
|
});
|
|
|
|
it("keeps third-party packages external", async () => {
|
|
const outdir = path.join(root, "server", "dist-runtime");
|
|
await bundleRuntime({ entry: path.join(root, "server", "dist", "index.js"), outdir });
|
|
const { stdout } = await execFileAsync("grep", ["-rl", "resolved from the package", outdir], { encoding: "utf8" }).catch(() => ({ stdout: "" }));
|
|
expect(stdout.trim()).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("preserveModuleLocation", () => {
|
|
it("replaces import.meta location properties with the original path", () => {
|
|
const file = "/srv/app/packages/x/src/mod.ts";
|
|
const out = preserveModuleLocation("a(import.meta.url); b(import.meta.dirname); c(import.meta.filename); d(import.meta.resolve);", file);
|
|
expect(out).toBe(`a("file:///srv/app/packages/x/src/mod.ts"); b("/srv/app/packages/x/src"); c("/srv/app/packages/x/src/mod.ts"); d(import.meta.resolve);`);
|
|
});
|
|
});
|