import { randomUUID } from "node:crypto"; import { test, expect } from "@playwright/test"; import type { EnvironmentCapabilities } from "../../packages/shared/src/environment-support.js"; import type { SandboxWorkFolderManifest, WorkFolderSyncStatus } from "../../packages/shared/src/work-folders.js"; import { QUALIFIED_ACPX_RUNNER_MODELS } from "../../server/src/services/native-runtime/provider-profile.js"; import { pollUntil } from "./api.js"; import { DeployedStackApi, deployedAgentEngine, findDeployedWorkFile, loadDeployedStack } from "./deployed-stack.js"; import { repoAcceptancePrompt } from "./work-folder-acceptance-prompts.js"; const stack = loadDeployedStack(); const api = new DeployedStackApi(stack); const folder = (scope: string, ownerId: string) => `/api/companies/${stack.companyId}/work-folders/${scope}/${encodeURIComponent(ownerId)}`; test.beforeAll(async () => { const health = await api.json<{ commit: string }>("/api/health"); expect(health.commit, "Only exercise the declared deployed candidate").toBe(stack.commit); const identity = await api.json<{ userId: string; companyIds: string[] }>("/api/cli-auth/me"); expect(identity.userId, "The board token must belong to the manifest's responsible user").toBe(stack.userId); expect(identity.companyIds, "The board token must have access to the acceptance company").toContain(stack.companyId); }); test("deployed candidate and complete supported adapter inventory", async ({}, info) => { const health = await api.json<{ commit: string }>("/api/health"); expect(health.commit).toBe(stack.commit); const capabilities = await api.json(`/api/companies/${stack.companyId}/environments/capabilities`); expect(capabilities.sandboxProviders.daytona?.supportsRunExecution).toBe(true); const adapters = await api.json>("/api/adapters"); const required: string[] = []; const excluded = new Set(stack.excludedAdapters?.map((entry) => entry.adapterType)); for (const adapter of adapters.filter((entry) => !entry.disabled && !excluded.has(entry.type))) { if (capabilities.adapters.find((entry) => entry.adapterType === adapter.type)?.drivers.sandbox !== "supported") continue; if (adapter.type === "paperclip_runner") { required.push("paperclip_runner:codex", "paperclip_runner:opencode", ...Object.keys(QUALIFIED_ACPX_RUNNER_MODELS).map((name) => `paperclip_runner:acpx:${name}`)); } else { required.push(`${adapter.type}:cli`); if (adapter.capabilities.supportsAcp) required.push(`${adapter.type}:acp`); } } const configured = new Set(); for (const profile of stack.profiles) { const agent = await api.json<{ adapterType: string; adapterConfig: Record }>(`/api/agents/${profile.agentId}`); expect(agent.adapterType).toBe(profile.adapterType); expect(agent.adapterConfig.model).toBe(profile.model); const engine = deployedAgentEngine(agent); expect(engine, `${profile.id} must exercise its declared live engine`).toBe(profile.engine); configured.add(`${agent.adapterType}:${engine}`); } expect(required.filter((key) => !configured.has(key)), "Every exposed sandbox adapter/engine requires a qualified fixture").toEqual([]); await info.attach("deployed-candidate-and-inventory", { contentType: "application/json", body: Buffer.from(JSON.stringify({ stack, required }, null, 2)) }); }); for (const [scope, owner] of [["task", stack.taskId], ["agent", stack.agentId], ["project", stack.projectId], ["user", stack.userId]]) { test(`${scope} nested empty executable files, retry, trash and restoration`, async () => { const base = folder(scope!, owner!); const filename = `acceptance/${randomUUID()}/empty.sh`; const key = randomUUID(); const write = () => api.request(`${base}/content?path=${encodeURIComponent(filename)}`, { method: "PUT", headers: { "Content-Type": "application/octet-stream", "X-File-Executable": "true", "Idempotency-Key": key }, body: "", }); expect((await write()).ok).toBe(true); expect((await write()).ok).toBe(true); const file = (await findDeployedWorkFile(api, base, filename))!; expect(file).toMatchObject({ byteSize: 0, executable: true, deletedAt: null }); const download = await api.request(`${base}/content?path=${encodeURIComponent(filename)}`); expect(download.status).toBe(200); expect((await download.arrayBuffer()).byteLength).toBe(0); await api.json(`${base}/operations`, "POST", { action: "delete", path: filename }); expect((await api.request(`${base}/content?path=${encodeURIComponent(filename)}`)).status).toBe(404); expect((await findDeployedWorkFile(api, base, filename, true))?.id).toBe(file.id); await api.json(`${base}/operations`, "POST", { action: "restore", fileId: file.id }); expect((await api.request(`${base}/content?path=${encodeURIComponent(filename)}`)).status).toBe(200); }); } for (const profile of stack.profiles) { test(`${profile.id} preserves task-specific repo and file state across cold and warm runs`, async ({}, info) => { test.setTimeout(1_800_000); const nonce = randomUUID(); const issue = await api.json<{ id: string; identifier: string }>(`/api/companies/${stack.companyId}/issues`, "POST", { title: `Work folder acceptance ${profile.id} ${nonce}`, projectId: stack.projectId, assigneeAgentId: profile.agentId, status: "todo", description: repoAcceptancePrompt(nonce, false), }); await info.attach("task", { contentType: "application/json", body: Buffer.from(JSON.stringify({ profile: profile.id, ...issue })) }); const base = folder("task", issue.id); const cold = await pollUntil({ label: `${profile.id} completed run and durable task file`, deadlineAt: Date.now() + 840_000, intervalMs: 5_000, load: async () => ({ issue: await api.json<{ status: string }>(`/api/issues/${issue.id}`), saves: await api.json(`${base}/sync`) }), accept: (state) => state.issue.status === "done" && state.saves.some((save) => !save.active && save.state === "saved" && save.lastSavedAt !== null), reject: (state) => state.saves.some((save) => save.state === "failed") ? "Work-folder save failed" : state.issue.status === "blocked" || state.issue.status === "cancelled" ? `Task ${issue.identifier} ended ${state.issue.status}` : undefined, }); const content = await api.request(`${base}/content?path=acceptance.txt`); expect(content.status).toBe(200); expect(await content.text()).toBe(nonce); const coldRunIds = new Set(cold.saves.map((save) => save.runId)); const coldSave = cold.saves.find((save) => !save.active && save.state === "saved")!; const coldRun = await api.json<{ status: string; contextSnapshot: { paperclipWorkFolders: SandboxWorkFolderManifest } }>(`/api/heartbeat-runs/${coldSave.runId}`); expect(coldRun.status, "A saved checkpoint must not hide a failed adapter run").toBe("succeeded"); const coldManifest = coldRun.contextSnapshot.paperclipWorkFolders; expect(coldManifest.sandboxKey).toBeTruthy(); const owners = {task: issue.id, agent: profile.agentId, user: stack.userId, project: stack.projectId}; expect(coldManifest.responsibleUserId).toBe(stack.userId); for (const [scope, owner] of Object.entries(owners)) { const scoped = folder(scope, owner); const read = await api.request(`${scoped}/content?path=${encodeURIComponent(`roundtrip-${nonce}/message.txt`)}`); expect(read.status, `${profile.id} ${scope} durable bytes`).toBe(200); expect(await read.text()).toBe(nonce); expect(await findDeployedWorkFile(api, scoped, `roundtrip-${nonce}/empty.sh`)).toMatchObject({byteSize:0, executable:true}); } await api.json(`/api/issues/${issue.id}`, "PATCH", { status: "todo", description: repoAcceptancePrompt(nonce, true) }); const warm = await pollUntil({ label: `${profile.id} warm run preserves saved work`, deadlineAt: Date.now() + 840_000, intervalMs: 5_000, load: async () => ({ issue: await api.json<{ status: string }>(`/api/issues/${issue.id}`), saves: (await api.json(`${base}/sync`)).filter((save) => !coldRunIds.has(save.runId)) }), accept: (state) => state.issue.status === "done" && state.saves.some((save) => !save.active && save.state === "saved" && save.lastSavedAt !== null), reject: (state) => state.saves.some((save) => save.state === "failed") ? "Warm save failed" : ["blocked", "cancelled"].includes(state.issue.status) ? `Warm task ended ${state.issue.status}` : undefined, }); const warmContent = await api.request(`${base}/content?path=warm.txt`); expect(warmContent.status).toBe(200); expect(await warmContent.text()).toBe(nonce); const warmSave = warm.saves.find((save) => !save.active && save.state === "saved")!; const warmRun = await api.json<{ status: string; contextSnapshot: { paperclipWorkFolders: SandboxWorkFolderManifest } }>(`/api/heartbeat-runs/${warmSave.runId}`); expect(warmRun.status, "The warm adapter run must also succeed").toBe("succeeded"); const warmManifest = warmRun.contextSnapshot.paperclipWorkFolders; expect(warmManifest.sandboxKey, "Warm acceptance requires the same physical sandbox").toBe(coldManifest.sandboxKey); await info.attach("cold-and-warm-checkpoints", { contentType: "application/json", body: Buffer.from(JSON.stringify({ cold: cold.saves, warm: warm.saves, coldManifest, warmManifest })) }); }); } test("saves during two real 180-second intervals and flushes the final edit", async ({}, info) => { const profile = stack.profiles.find((entry) => entry.id === "legacy-codex")!; const nonce = randomUUID(); const issue = await api.json<{ id: string; identifier: string }>(`/api/companies/${stack.companyId}/issues`, "POST", { title: `Real checkpoint intervals ${nonce}`, projectId: stack.projectId, assigneeAgentId: profile.agentId, status: "todo", description: [ "Run a real timed persistence acceptance test. Execute the following shell sequence and wait for it to finish, keeping this task in progress throughout both sleeps. Use a tool timeout of at least 420 seconds, or poll its session until it exits. Do not shorten either sleep or mark the task complete early.", `printf '${nonce}:one' > "$HOME/task/interval.txt"; sleep 190; printf '${nonce}:two' > "$HOME/task/interval.txt"; sleep 190; printf '${nonce}:final' > "$HOME/task/interval.txt"`, "After the command exits successfully, complete the task. Do not print credentials.", ].join("\n"), }); await info.attach("task", { contentType: "application/json", body: Buffer.from(JSON.stringify(issue)) }); const base = folder("task", issue.id); const observations: Array<{ observedAt: string; phase: string; saves: WorkFolderSyncStatus[] }> = []; for (const phase of ["one", "two", "final"]) { const snapshot = await pollUntil({ label: `durable interval ${phase}`, deadlineAt: Date.now() + (phase === "one" ? 840_000 : 300_000), intervalMs: 3_000, load: async () => { const response = await api.request(`${base}/content?path=interval.txt`); return { content: response.ok ? await response.text() : null, saves: await api.json(`${base}/sync`), issue: await api.json<{ status: string }>(`/api/issues/${issue.id}`) }; }, accept: (state) => state.content === `${nonce}:${phase}` && state.saves.some((save) => save.state === "saved" && save.lastSavedAt !== null && save.active === (phase !== "final")), reject: (state) => state.saves.some((save) => save.state === "failed") ? "Timed checkpoint failed" : ["blocked", "cancelled"].includes(state.issue.status) ? `Timed task ended ${state.issue.status}` : undefined, }); observations.push({ observedAt: new Date().toISOString(), phase, saves: snapshot.saves }); } const first = observations[0]!.saves.find((save) => save.active)!; const second = observations[1]!.saves.find((save) => save.runId === first.runId)!; // Upload duration varies; completion times do not measure the timer cadence. // The host records checkpoint intent before transferring any files. const activity = await api.json>( `/api/companies/${stack.companyId}/activity?entityType=heartbeat_run&entityId=${first.runId}&limit=100`, ); const starts = activity.filter((entry) => entry.action === "work_folder.checkpoint" && entry.runId === first.runId) .map((entry) => Date.parse(entry.createdAt)).sort((a, b) => a - b); await info.attach("real-checkpoint-intervals", { contentType: "application/json", body: Buffer.from(JSON.stringify({ observations, starts }, null, 2)) }); expect(starts.length).toBeGreaterThanOrEqual(3); expect(starts[1]! - starts[0]!).toBeGreaterThanOrEqual(179_000); expect(starts[1]! - starts[0]!).toBeLessThan(210_000); expect(starts[0]!).toBeLessThanOrEqual(Date.parse(first.lastSavedAt!)); expect(starts[1]!).toBeLessThanOrEqual(Date.parse(second.lastSavedAt!)); });