Files
PaperClipAI/tests/runner-e2e/rolling-deployment-process.ts
T
DottaandPaperclip 006d5691a6 feat(server): support volume-free web server handoff (#18518)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Its web server also owns controller admission and user commands.
> - A rolling deployment starts a replacement before the old server
exits.
> - Shared database state alone does not preserve local agent files,
browser chunks, or in-process commands.
> - This pull request adds an opt-in ephemeral server contract and a
local overlap test.
> - The benefit is a web server that can relinquish traffic and local
files without losing acknowledged work.

## Linked Issues or Issue Description

Refs #18403, #18327, #18335, #18341.

**Current behavior**

The control-plane web server still has local agent-file authority and
local command targets. A new container can miss saved bytes or send a
command to the wrong process. An existing browser tab also needs chunks
from its original build.

**Proposed behavior**

Eligible servers share only PostgreSQL, object storage and stable
secrets. A replacement waits for readiness. The old server drains
admitted requests, keeps owner commands available through handoff, and
releases controller leases. Explicit maintenance adopts existing files
before the volume is removed.

**Reason and benefit**

This is the application prerequisite for rolling app-only deployments.
Schema migrations and permanent multi-replica operation are outside this
change.

## What Changed

- Store agent-file manifests in PostgreSQL and immutable bytes in S3.
Treat local roots as disposable projections. Preserve existing
permission checks and edit conflicts.
- Add an explicit migration, eligibility and object-retention command to
the production runtime bundle.
- Add `/api/ready`, admitted-request drain, browser restart signaling
and retained immutable UI chunks.
- Carry transaction-aware work hints across controllers during handoff
and active execution. Keep listeners closed when idle.
- Deliver durable Stop and runtime responses to the execution owner.
Recheck request lifecycle and access before response dispatch.
- Reject local-only configuration in ephemeral mode. Keep the feature
disabled for existing installations by default.
- Add an isolated PostgreSQL/S3/proxy/browser deployment harness with
independent server homes and retained failure evidence.

## Verification

- All CI checks pass on `ce17b95019d3cf1cabd357d19b6d37f96782cc5c`:
workspace typecheck, build, Runner checks, server/general tests and
browser shards. Greptile reviewed the runtime changes at 5/5 with all
findings resolved; the final commit updates maps and the plan.
- Local targeted checks passed: coordinator 27; Stop/recovery 33;
PostgreSQL command delivery 6; route/workspace regressions 8; bootstrap
Stop 1; restart recovery 75. PostgreSQL command tests simulate the
provider edge.
- Local browser proof passed 91 reads and 19 saves; reconnect took
1,013ms. The tab opened Audit from S3 after deletion of A's whole
home/build. This uses compiled workspace code and local-trusted auth,
not released containers or live agents.
- Local workspace typecheck/build passed; current server and runtime
bundle passed. The earlier broad local suite was stopped after three
hours and is not a passing receipt. Its retained failures and isolated
reruns are documented in the plan.
- Stacked #18526 adds stricter graceful-exit proof, an unready
replacement control, live Runner qualification and a Stop handoff
correction. Hosted enablement remains gated on those results and Cloud
#1096. No hosted stack has been enabled.

## Risks

- Apply migration 0422 before enabling the feature. Existing data must
pass each owner's migration and eligibility checks. Do not discard a
volume based on a single migration page.
- Durable manifests remain authoritative after the opt-in is disabled.
Rolling back to a binary that ignores them is unsupported.
- Cold directory restoration and complete shutdown timing still need
qualification before enabling a hosted stack.
- Retained UI assets must cover the supported browser and rollback
window. Cleanup is explicit; no new idle maintenance polling is
introduced.
- This change does not claim production zero downtime or authorize a
fleet-wide enablement.

## Model Used

OpenAI Codex, GPT-6 family. The exact serving model ID and
context-window limit are not exposed in this session. Used reasoning,
code editing, shell execution and browser automation.

## 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 01:58:54 -06:00

95 lines
4.9 KiB
TypeScript

import { spawn, type ChildProcess } from "node:child_process";
import { createWriteStream } from "node:fs";
import { cp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { paperclipConfigSchema } from "../../packages/shared/src/config-schema.js";
import { reserveRunnerE2EServerPort } from "./ports.js";
import { prepareRunnerE2EServerConfig } from "./server-config.js";
import { createRunnerE2EServerStopper } from "./server-stop.js";
import { runnerE2ETypeScriptProcessArgs } from "./web-server-command.js";
export async function startRollingDeploymentProcess(input: {
root: string;
name: string;
repositoryRoot: string;
databaseUrl: string;
ingressOrigin: string;
sharedEnvironment: NodeJS.ProcessEnv;
browser?: { omitAssets?: string[] };
}) {
const home = path.join(input.root, input.name);
const temporary = path.join(home, "tmp");
await mkdir(temporary, { recursive: true, mode: 0o700 });
const port = await reserveRunnerE2EServerPort();
const configPath = path.join(home, "instances/default/config.json");
const reservation = await prepareRunnerE2EServerConfig({ temporaryRoot: home, configPath, serverPort: port });
await reservation?.close();
const config = paperclipConfigSchema.parse(JSON.parse(await readFile(configPath, "utf8")));
config.database = { ...config.database, mode: "postgres", connectionString: input.databaseUrl };
config.server.serveUi = Boolean(input.browser);
await writeFile(configPath, JSON.stringify(config), { mode: 0o600 });
const log = createWriteStream(path.join(input.root, `${input.name}.log`), { mode: 0o600 });
const spawnErrors = new WeakMap<ChildProcess, Error>();
const stopper = createRunnerE2EServerStopper({
gracefulTimeoutMs: 90_000, forcedTimeoutMs: 5_000,
hasSpawnError: child => spawnErrors.has(child), markExpectedStop() {},
log: message => log.write(message),
});
let entry = path.join(input.repositoryRoot, "server/src/index.ts");
if (input.browser) {
// Independent build directories let the browser test remove A completely
// and omit an old lazy chunk from B without mutating the checkout's build.
const appRoot = path.join(home, "app/server");
await mkdir(appRoot, { recursive: true });
await cp(path.join(input.repositoryRoot, "server/dist"), path.join(appRoot, "dist"), { recursive: true });
await cp(path.join(input.repositoryRoot, "ui/dist"), path.join(appRoot, "ui-dist"), { recursive: true });
await symlink(path.join(input.repositoryRoot, "server/node_modules"), path.join(appRoot, "node_modules"));
await writeFile(path.join(appRoot, "package.json"), '{"type":"module"}');
for (const omittedAsset of input.browser.omitAssets ?? []) {
if (!/^[A-Za-z0-9_.-]+\.js$/.test(omittedAsset)) throw new Error("Unsafe omitted asset fixture");
for (const suffix of ["", ".br", ".gz"]) await rm(path.join(appRoot, "ui-dist/assets", omittedAsset + suffix), { force: true });
}
entry = path.join(appRoot, "dist/index.js");
}
const child = spawn(process.execPath, runnerE2ETypeScriptProcessArgs(input.repositoryRoot, entry), {
cwd: input.repositoryRoot,
env: {
PATH: process.env.PATH, HOME: home, TMPDIR: temporary,
NODE_ENV: "test", PAPERCLIP_HOME: home, PAPERCLIP_CONFIG: configPath,
PAPERCLIP_INSTANCE_ID: "default", DATABASE_URL: input.databaseUrl,
PAPERCLIP_API_URL: input.ingressOrigin, PAPERCLIP_TELEMETRY_ENABLED: "false",
PAPERCLIP_DISABLE_TELEMETRY: "true", PAPERCLIP_LOG_LEVEL: "warn",
HEARTBEAT_SCHEDULER_ENABLED: "false", ...input.sharedEnvironment,
},
detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"],
});
child.on("error", error => spawnErrors.set(child, error));
child.stdout!.pipe(log, { end: false }); child.stderr!.pipe(log, { end: false });
await stopper.watch(child);
const origin = `http://127.0.0.1:${port}`;
let stopped = false;
return {
child, home, origin,
async waitForReady() {
const started = performance.now();
while (performance.now() - started < 180_000) {
if (spawnErrors.has(child) || child.exitCode !== null || child.signalCode !== null) {
throw new Error(`Server ${input.name} exited before readiness; inspect ${input.name}.log`);
}
const health = await fetch(`${origin}/api/ready`, { signal: AbortSignal.timeout(2_000) })
.then(async response => response.ok ? await response.json() as { status?: string } : null).catch(() => null);
if (health?.status === "ready") return { elapsedMs: Math.round(performance.now() - started), pid: child.pid };
await delay(100);
}
throw new Error(`Server ${input.name} did not become ready; inspect ${input.name}.log`);
},
async stop() {
if (stopped) return;
await stopper.stopAll();
await new Promise<void>(resolve => log.end(resolve));
stopped = true;
},
};
}