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. > - The web server owns request handling and remote execution coordination. > - Deployment must start a ready replacement while the old server still serves. > - An outage test does not prove that two live servers can overlap safely. > - This pull request adds a separate Product E2E cell for that boundary. > - The existing run, file, recovery, and retained-session checks still apply. ## Linked Issues or Issue Description Refs #18518. The runtime prerequisite is merged; this PR qualifies its ephemeral server and deployment contracts. ## What Changed - Add the explicit `rolling-web-deployment` Codex Daytona workflow. - Start independent A/B/C homes and compiled builds behind a test ingress. - Wait for readiness before routing, then drain and delete the old home. - Observe active authority before handoff and use the existing read-only recovery oracle. - Require ordinary reads and saves to succeed throughout replacement. Read back every accepted save exactly once. - Preserve the five-minute outage suites and their existing validators. - Add a model-free supervisor smoke and an unready replacement control. - Require graceful exit before deleting an old home; forced cleanup cannot satisfy the oracle. - Preserve a durable Stop through lease release/expiry until the recovered execution owner acknowledges it. Permit only fenced reattachment of the original execution for Stop cleanup; submit no new turn. - Exclude events saved by A during shutdown from evidence of B making progress. ## Verification - E2E TypeScript check passed. - Affected harness checks: 143 passed. Catalog, fixture and report checks: 120 passed. - Actual supervisor smoke passed two replacements: 436 reads, 45 saves, zero request failures, and confirmed owned resource cleanup. - B became ready in 18.7 seconds; C became ready in 109.2 seconds. A prior C timeout remains failed evidence. - Fresh browser proof: 11 checks passed, 246 reads, 50 acknowledged saves, zero failures, 1,058ms reconnect, both old homes deleted and cleanup confirmed. - Stop lease-gap regression: three PostgreSQL delivery tests passed with a simulated provider edge. Current server TypeScript build/vendoring passed. - Full harness: 5,814 passed, 17 failed, four skipped. Two shutdown races passed the focused file; two report timeouts passed an isolated 42-test rerun. Onboarding/workflow-security/Pi/date-expired fixtures are unchanged on inspected master; the host embedded PostgreSQL fixture remains a setup limitation. This is not a green full local suite. - CI at `172509b8be` passed all gates. Greptile is 5/5 with every review thread resolved. The earlier access-service race failure passed its isolated regression and the subsequent full CI. - Final review corrections: six focused executor cases, three PostgreSQL command-delivery cases, 19 harness cases and server/E2E typechecks passed. - Three live attempts failed before agent creation: two harness setup gaps were fixed, then provider authentication failed. The saved OpenAI key returns 401; a refresh is pending. The Daytona probe reported cleanup uncertain, and no sandbox with that attempt label was found through the authenticated staging manager. No live model execution is claimed. - Live Runner overlap, released-container qualification and hosted deployment proof remain pending. Merging this opt-in qualification lane does not claim those results. ## Risks - The source lane shares workspace dependencies and does not establish container packaging correctness. - The live workflow needs an explicitly owned S3 bucket, PostgreSQL, pinned remote image and provider credentials. - Qualification cleanup stops all owned processes after failure. A failed replacement must not be reported as a pass. ## 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>
311 lines
19 KiB
TypeScript
311 lines
19 KiB
TypeScript
/** Credential-free full-server overlap characterization. No model or Runner
|
|
* continuity is claimed. Requires an explicitly selected disposable S3 service.
|
|
* node --import ./cli/node_modules/tsx/dist/loader.mjs tests/runner-e2e/rolling-deployment-smoke.ts */
|
|
import assert from "node:assert/strict";
|
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
import { execFileSync } from "node:child_process";
|
|
import { readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
import { createRequire } from "node:module";
|
|
import path from "node:path";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { startEmbeddedPostgresTestDatabase } from "../../packages/db/src/index.js";
|
|
import { createExternalControllerDatabase } from "./external-controller-database.js";
|
|
import { reserveRunnerE2EServerPort } from "./ports.js";
|
|
import { createRunnerE2ETemporaryRoot } from "./server-config.js";
|
|
import { openRollingDeploymentBrowser } from "./rolling-deployment-browser.js";
|
|
import { startRollingDeploymentProcess } from "./rolling-deployment-process.js";
|
|
import { startRollingDeploymentProxy } from "./rolling-deployment-proxy.js";
|
|
import { startRollingStorageProxy } from "./rolling-deployment-storage-proxy.js";
|
|
|
|
// Exercise the SDK version owned by the server under test.
|
|
const { S3Client, CreateBucketCommand, ListObjectsV2Command, DeleteObjectsCommand, DeleteBucketCommand } =
|
|
createRequire(new URL("../../server/package.json", import.meta.url))("@aws-sdk/client-s3");
|
|
const repositoryRoot = path.resolve(import.meta.dirname, "../..");
|
|
const patchHash = createHash("sha256").update(execFileSync("git", ["diff", "--binary", "HEAD"], { cwd: repositoryRoot, maxBuffer: 32 * 1024 * 1024 }));
|
|
for (const filename of execFileSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], { cwd: repositoryRoot, encoding: "utf8" }).split("\0").filter(Boolean).sort()) {
|
|
patchHash.update(filename).update("\0").update(await readFile(path.join(repositoryRoot, filename)));
|
|
}
|
|
const root = await createRunnerE2ETemporaryRoot();
|
|
const reportPath = path.join(root, "rolling-deployment-evidence.json");
|
|
const checks: { name: string; passed: boolean; error?: string }[] = [];
|
|
const report: Record<string, unknown> = {
|
|
schema: "paperclip.e2e.rolling-deployment-smoke/v1", startedAt: new Date().toISOString(),
|
|
sourceRevision: execFileSync("git", ["rev-parse", "HEAD"], { cwd: repositoryRoot, encoding: "utf8" }).trim(),
|
|
sourceDirty: execFileSync("git", ["status", "--porcelain"], { cwd: repositoryRoot, encoding: "utf8" }).trim().length > 0,
|
|
sourcePatchSha256: patchHash.digest("hex"), nodeVersion: process.version,
|
|
lockfileSha256: createHash("sha256").update(await readFile(path.join(repositoryRoot, "pnpm-lock.yaml"))).digest("hex"),
|
|
artifactKind: "source", authentication: "local_trusted", readinessProbe: "/api/ready",
|
|
activeAgentContinuityQualified: false, browserQualified: false, checks,
|
|
};
|
|
console.info(JSON.stringify({ started: true, evidence: reportPath }));
|
|
const saveReport = () => writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
|
|
const processes: Awaited<ReturnType<typeof startRollingDeploymentProcess>>[] = [];
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
|
let proxy: Awaited<ReturnType<typeof startRollingDeploymentProxy>> | undefined;
|
|
let browser: Awaited<ReturnType<typeof openRollingDeploymentBrowser>> | undefined;
|
|
let storageProxy: Awaited<ReturnType<typeof startRollingStorageProxy>> | undefined;
|
|
let s3: InstanceType<typeof S3Client> | undefined;
|
|
const bucket = `rolling-deploy-${randomUUID()}`;
|
|
let bucketCreated = false;
|
|
let probing = false;
|
|
let probes: Promise<void> | undefined;
|
|
const requestErrors: string[] = [];
|
|
const acceptedWrites: string[] = [];
|
|
let reads = 0;
|
|
async function api(origin: string, route: string, method = "GET", body?: object) {
|
|
const response = await fetch(`${origin}/api${route}`, {
|
|
method, headers: { "content-type": "application/json", origin },
|
|
body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(10_000),
|
|
});
|
|
if (!response.ok) throw new Error(`${method} ${route} returned ${response.status}`);
|
|
return response.json() as Promise<Record<string, any>>;
|
|
}
|
|
async function check(name: string, run: () => Promise<void>) {
|
|
try { await run(); checks.push({ name, passed: true }); }
|
|
catch (error) { checks.push({ name, passed: false, error: error instanceof Error ? error.message : String(error) }); }
|
|
await saveReport();
|
|
}
|
|
try {
|
|
const storageFile = process.env.PAPERCLIP_ROLLING_TEST_STORAGE_CONFIG;
|
|
if (!storageFile) throw new Error("Set PAPERCLIP_ROLLING_TEST_STORAGE_CONFIG to private disposable S3 connection JSON");
|
|
const storage = JSON.parse(await readFile(storageFile, "utf8")) as {
|
|
endpoint: string; accessKeyId: string; secretAccessKey: string;
|
|
};
|
|
const endpoint = new URL(storage.endpoint);
|
|
if (endpoint.protocol !== "http:" || !["127.0.0.1", "localhost", "[::1]"].includes(endpoint.hostname)
|
|
|| endpoint.username || endpoint.password || !storage.accessKeyId || !storage.secretAccessKey) {
|
|
throw new Error("Rolling smoke requires an explicit loopback S3 service with disposable credentials");
|
|
}
|
|
s3 = new S3Client({ region: "us-east-1", endpoint: storage.endpoint, forcePathStyle: true,
|
|
credentials: { accessKeyId: storage.accessKeyId, secretAccessKey: storage.secretAccessKey } });
|
|
await s3.send(new CreateBucketCommand({ Bucket: bucket })); bucketCreated = true;
|
|
storageProxy = await startRollingStorageProxy(storage.endpoint);
|
|
const setupStarted = performance.now();
|
|
if (process.env.PAPERCLIP_ROLLING_TEST_DATABASE_CONFIG) {
|
|
const { url } = JSON.parse(await readFile(process.env.PAPERCLIP_ROLLING_TEST_DATABASE_CONFIG, "utf8")) as { url: string };
|
|
const configPath = path.join(root, "database-owner/config.json");
|
|
const owned = await createExternalControllerDatabase({ adminUrl: url, temporaryRoot: root,
|
|
configPath, serverPort: await reserveRunnerE2EServerPort() });
|
|
// Register cleanup before reading the generated configuration.
|
|
database = { connectionString: "", cleanup: owned.cleanup };
|
|
const saved = JSON.parse(await readFile(configPath, "utf8")) as { database: { connectionString: string } };
|
|
database.connectionString = saved.database.connectionString;
|
|
} else {
|
|
database = await startEmbeddedPostgresTestDatabase("rolling-deployment-");
|
|
}
|
|
proxy = await startRollingDeploymentProxy();
|
|
const sharedEnvironment = {
|
|
PAPERCLIP_DURABLE_AGENT_FILES: "true",
|
|
PAPERCLIP_SHARED_UI_ASSETS: "true",
|
|
PAPERCLIP_DATABASE_LISTEN_URL: database.connectionString,
|
|
PAPERCLIP_SECRETS_MASTER_KEY: randomBytes(32).toString("base64"),
|
|
PAPERCLIP_AGENT_JWT_SECRET: randomUUID(), PAPERCLIP_DECISION_SIGNING_SECRET: randomUUID(),
|
|
PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: randomUUID(), BETTER_AUTH_SECRET: randomUUID(),
|
|
PAPERCLIP_STORAGE_PROVIDER: "s3", PAPERCLIP_STORAGE_S3_BUCKET: bucket,
|
|
PAPERCLIP_STORAGE_S3_REGION: "us-east-1", PAPERCLIP_STORAGE_S3_ENDPOINT: storageProxy.endpoint,
|
|
PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE: "true", AWS_ACCESS_KEY_ID: storage.accessKeyId,
|
|
AWS_SECRET_ACCESS_KEY: storage.secretAccessKey, AWS_EC2_METADATA_DISABLED: "true",
|
|
RUN_LOG_S3_BUCKET: bucket, RUN_LOG_S3_ENDPOINT: storage.endpoint,
|
|
RUN_LOG_S3_REGION: "us-east-1", RUN_LOG_S3_FORCE_PATH_STYLE: "true",
|
|
};
|
|
const browserEnabled = process.env.PAPERCLIP_ROLLING_TEST_BROWSER === "true";
|
|
report.artifactKind = browserEnabled ? "compiled-server-with-workspace-dependencies" : "source";
|
|
const omittedAssets = browserEnabled ? (await readdir(path.join(repositoryRoot, "ui/dist/assets"))).filter(name => /^CompanyActivity(?:\.production)?-[^.]+\.js$/.test(name)) : [];
|
|
if (browserEnabled && !omittedAssets.length) throw new Error("Build the UI before browser qualification");
|
|
const start = async (name: string, durableAgentFiles = true) => {
|
|
const server = await startRollingDeploymentProcess({ root, name, repositoryRoot,
|
|
databaseUrl: database!.connectionString, ingressOrigin: proxy!.origin,
|
|
...(browserEnabled ? { browser: name === "A" ? {} : { omitAssets: omittedAssets } } : {}),
|
|
sharedEnvironment: { ...sharedEnvironment, PAPERCLIP_EPHEMERAL_SERVER: String(durableAgentFiles), PAPERCLIP_DURABLE_AGENT_FILES: String(durableAgentFiles) } });
|
|
processes.push(server);
|
|
report[`${name}Ready`] = await server.waitForReady();
|
|
return server;
|
|
};
|
|
const a = await start("A"); proxy.routeTo(a.origin);
|
|
report.setupElapsedMs = Math.round(performance.now() - setupStarted);
|
|
const company = await api(proxy.origin, "/companies", "POST", { name: "Rolling deployment qualification" });
|
|
const task = await api(proxy.origin, `/companies/${company.id}/issues`, "POST", { title: "Persist through deployment" });
|
|
const agent = await api(proxy.origin, `/companies/${company.id}/agents`, "POST", {
|
|
name: "Storage qualification", role: "engineer", adapterType: "process",
|
|
adapterConfig: { command: "/usr/bin/true" },
|
|
});
|
|
const fileRoute = `/agents/${agent.id}/instructions-bundle/file`;
|
|
await api(proxy.origin, fileRoute, "PUT", { path: "AGENTS.md", content: "Synthetic deployment fixture.", baseRevisionId: null });
|
|
let content = `Acknowledged file ${randomUUID()}`;
|
|
await api(proxy.origin, fileRoute, "PUT", { path: "reference.txt", content, baseHash: null });
|
|
probing = true;
|
|
probes = (async () => {
|
|
let sequence = 0;
|
|
while (probing) {
|
|
try {
|
|
const saved = await api(proxy!.origin, `/issues/${task.id}`); assert.equal(saved.id, task.id); reads++;
|
|
if (sequence++ % 5 === 0) {
|
|
const key = randomUUID();
|
|
const comment = await api(proxy!.origin, `/issues/${task.id}/comments`, "POST", { body: key, clientRequestId: key });
|
|
acceptedWrites.push(String(comment.id));
|
|
}
|
|
} catch (error) { requestErrors.push(error instanceof Error ? error.message : String(error)); }
|
|
await delay(100);
|
|
}
|
|
})();
|
|
if (browserEnabled) browser = await openRollingDeploymentBrowser({ origin: proxy.origin, root, taskTitle: task.title,
|
|
taskPath: `/issues/${task.id}`, omittedAssets });
|
|
await check("Unready replacement leaves the healthy server serving", async () => {
|
|
const unready = await startRollingDeploymentProcess({ root, name: "unready", repositoryRoot,
|
|
databaseUrl: database!.connectionString, ingressOrigin: proxy!.origin,
|
|
sharedEnvironment: { ...sharedEnvironment, PAPERCLIP_EPHEMERAL_SERVER: "true", PAPERCLIP_DECISION_SIGNING_SECRET: "" } });
|
|
processes.push(unready);
|
|
await assert.rejects(unready.waitForReady(), /exited before readiness/);
|
|
assert.equal((await api(proxy!.origin, `/issues/${task.id}`)).id, task.id);
|
|
assert.equal((await api(a.origin, `/issues/${task.id}`)).id, task.id);
|
|
});
|
|
const b = await start("B");
|
|
await check("B reads acknowledged task while A is alive", async () => {
|
|
assert.equal((await api(b.origin, `/issues/${task.id}`)).title, task.title);
|
|
});
|
|
await check("B reads acknowledged agent file while A filesystem exists", async () => {
|
|
assert.equal((await api(b.origin, `${fileRoute}?path=reference.txt`)).content, content);
|
|
});
|
|
await check("Concurrent servers observe file saves and enforce stale-write conflicts", async () => {
|
|
const before = await api(a.origin, `${fileRoute}?path=reference.txt`);
|
|
content = `Saved on B ${randomUUID()}`;
|
|
await api(b.origin, fileRoute, "PUT", { path: "reference.txt", content, baseHash: before.contentHash });
|
|
const onA = await api(a.origin, `${fileRoute}?path=reference.txt`);
|
|
assert.equal(onA.content, content);
|
|
const conflict = await fetch(`${a.origin}/api${fileRoute}`, { method: "PUT",
|
|
headers: { "content-type": "application/json", origin: a.origin },
|
|
body: JSON.stringify({ path: "reference.txt", content: "Stale write must fail", baseHash: before.contentHash }),
|
|
signal: AbortSignal.timeout(10_000) });
|
|
assert.equal(conflict.status, 409); await conflict.arrayBuffer();
|
|
content = `Saved on A after B cached its files ${randomUUID()}`;
|
|
await api(a.origin, fileRoute, "PUT", { path: "reference.txt", content, baseHash: onA.contentHash });
|
|
assert.equal((await api(b.origin, `${fileRoute}?path=reference.txt`)).content, content);
|
|
});
|
|
const switchedAt = performance.now();
|
|
await check("A finishes an admitted save after traffic switches and SIGTERM starts draining", async () => {
|
|
storageProxy!.holdUploads();
|
|
let acknowledged = false;
|
|
const saving = api(proxy!.origin, fileRoute, "PUT", { path: "drained-save.txt", content: "Saved during HTTP drain", baseHash: null })
|
|
.then(result => { acknowledged = true; return result; });
|
|
void saving.catch(() => undefined);
|
|
let stopping: Promise<void> | undefined;
|
|
try {
|
|
const deadline = Date.now() + 8_000;
|
|
while (storageProxy!.heldUploads < 1 && Date.now() < deadline) await delay(25);
|
|
assert.equal(storageProxy!.heldUploads, 1);
|
|
proxy!.routeTo(b.origin);
|
|
stopping = a.stop({ requireGraceful: true });
|
|
await delay(100);
|
|
assert.equal(acknowledged, false);
|
|
storageProxy!.resumeUploads();
|
|
assert.equal((await saving).content, "Saved during HTTP drain");
|
|
} finally {
|
|
storageProxy!.resumeUploads();
|
|
await saving.catch(() => undefined);
|
|
await stopping;
|
|
}
|
|
});
|
|
proxy.routeTo(b.origin);
|
|
await delay(500);
|
|
await a.stop({ requireGraceful: true });
|
|
await rm(a.home, { recursive: true, force: true });
|
|
await delay(1_000);
|
|
report.handoffElapsedMs = Math.round(performance.now() - switchedAt);
|
|
if (browser) await check("Existing browser reconnects and opens an old lazy chunk after A removal", async () => {
|
|
report.browser = browser!.metrics;
|
|
await browser!.verifyReplacement(); report.browserQualified = true;
|
|
});
|
|
probing = false; await probes;
|
|
report.reads = reads; report.acknowledgedWrites = acceptedWrites.length;
|
|
await check("HTTP requests succeed across overlap and shutdown", async () => {
|
|
assert.ok(reads > 0); assert.ok(acceptedWrites.length > 0); assert.deepEqual(requestErrors, []);
|
|
});
|
|
await check("Every acknowledged write survives A removal exactly once", async () => {
|
|
const comments = await api(b.origin, `/issues/${task.id}/comments`) as unknown as { id: string }[];
|
|
for (const id of acceptedWrites) assert.equal(comments.filter(comment => comment.id === id).length, 1);
|
|
});
|
|
await check("Agent file survives A filesystem removal", async () => {
|
|
assert.equal((await api(b.origin, `${fileRoute}?path=reference.txt`)).content, content);
|
|
assert.equal((await api(b.origin, `${fileRoute}?path=drained-save.txt`)).content, "Saved during HTTP drain");
|
|
});
|
|
await check("Object-store outage preserves files and leaves ordinary requests responsive", async () => {
|
|
const fixtureAgents = await Promise.all([0, 1, 2].map(async index => {
|
|
const fixture = await api(b.origin, `/companies/${company.id}/agents`, "POST", {
|
|
name: `Storage failure ${index}`, role: "engineer", adapterType: "process", adapterConfig: { command: "/usr/bin/true" },
|
|
});
|
|
// Exercise both previously committed files and an agent's first save.
|
|
if (index < 2) await api(b.origin, `/agents/${fixture.id}/instructions-bundle/file`, "PUT", {
|
|
path: "AGENTS.md", content: "Durable original", baseRevisionId: null,
|
|
});
|
|
return { id: String(fixture.id), seeded: index < 2 };
|
|
}));
|
|
storageProxy!.holdUploads();
|
|
const writes = Promise.all(fixtureAgents.map(fixture => fetch(`${b.origin}/api/agents/${fixture.id}/instructions-bundle/file`, {
|
|
method: "PUT", headers: { "content-type": "application/json", origin: b.origin },
|
|
body: JSON.stringify({ path: "failed.txt", content: "Must never become authoritative", baseHash: null }),
|
|
signal: AbortSignal.timeout(40_000),
|
|
}).then(async response => { await response.arrayBuffer(); return response.status; })));
|
|
// Keep a rejection handler attached even if an earlier assertion fails.
|
|
void writes.catch(() => undefined);
|
|
try {
|
|
const deadline = Date.now() + 10_000;
|
|
while (storageProxy!.heldUploads < 2 && Date.now() < deadline) await delay(25);
|
|
assert.equal(storageProxy!.heldUploads, 2, "Both file-transfer connections must be occupied before probing ordinary HTTP");
|
|
const began = performance.now();
|
|
assert.equal((await api(b.origin, `/issues/${task.id}`)).id, task.id);
|
|
report.storageOutageReadMs = Math.round(performance.now() - began);
|
|
assert.ok(Number(report.storageOutageReadMs) < 2_000);
|
|
storageProxy!.rejectUploads();
|
|
assert.ok((await writes).every(status => status >= 500));
|
|
} finally {
|
|
storageProxy!.rejectUploads();
|
|
await writes.catch(() => undefined);
|
|
storageProxy!.resumeUploads();
|
|
}
|
|
for (const fixture of fixtureAgents) {
|
|
const route = `/agents/${fixture.id}/instructions-bundle/file`;
|
|
const response = await fetch(`${b.origin}/api${route}?path=failed.txt`, { signal: AbortSignal.timeout(10_000) });
|
|
const body = await response.text();
|
|
assert.equal(response.status, 404, `Failed-write read returned ${response.status}: ${body.slice(0, 300)}`);
|
|
if (fixture.seeded) assert.equal((await api(b.origin, `${route}?path=AGENTS.md`)).content, "Durable original");
|
|
}
|
|
});
|
|
await b.stop({ requireGraceful: true }); await rm(b.home, { recursive: true, force: true });
|
|
const c = await start("C", false); proxy.routeTo(c.origin);
|
|
await check("Fresh C restores files after both homes are deleted, even with adoption opt-in disabled", async () => {
|
|
assert.equal((await api(c.origin, `${fileRoute}?path=reference.txt`)).content, content);
|
|
assert.equal((await api(c.origin, `/issues/${task.id}`)).id, task.id);
|
|
});
|
|
} catch (error) {
|
|
report.error = error instanceof Error ? error.message : String(error);
|
|
} finally {
|
|
probing = false;
|
|
await probes;
|
|
const cleanupErrors: string[] = [];
|
|
const clean = async (run: () => Promise<unknown>) => {
|
|
try { await run(); } catch (error) { cleanupErrors.push(error instanceof Error ? error.message : String(error)); }
|
|
};
|
|
if (browser) await clean(() => browser!.close());
|
|
for (const server of processes) await clean(() => server.stop());
|
|
if (proxy) await clean(() => proxy!.close());
|
|
if (storageProxy) await clean(() => storageProxy!.close());
|
|
if (database) await clean(() => database!.cleanup());
|
|
if (s3 && bucketCreated) await clean(async () => {
|
|
while (true) {
|
|
const objects = await s3!.send(new ListObjectsV2Command({ Bucket: bucket }));
|
|
if (!objects.Contents?.length) break;
|
|
const deleted = await s3!.send(new DeleteObjectsCommand({ Bucket: bucket,
|
|
Delete: { Objects: objects.Contents.map((object: { Key: string }) => ({ Key: object.Key })) } }));
|
|
if (deleted.Errors?.length) throw new Error("Owned S3 bucket cleanup could not delete all objects");
|
|
}
|
|
await s3!.send(new DeleteBucketCommand({ Bucket: bucket }));
|
|
});
|
|
s3?.destroy();
|
|
report.cleanupConfirmed = cleanupErrors.length === 0;
|
|
if (cleanupErrors.length) report.cleanupErrors = cleanupErrors;
|
|
report.passed = !report.error && checks.length > 0 && checks.every(item => item.passed) && report.cleanupConfirmed;
|
|
await saveReport();
|
|
console.info(JSON.stringify({ passed: report.passed, checks, evidence: reportPath, cleanupConfirmed: report.cleanupConfirmed }));
|
|
}
|
|
if (!report.passed) process.exitCode = 1;
|