// @vitest-environment jsdom import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { IssueRecoveryAction } from "@paperclipai/shared"; import { ApiError } from "../api/client"; import { queryKeys } from "../lib/queryKeys"; import { writePendingSend } from "../lib/pending-send"; import { WorkspaceExportRecovery } from "./WorkspaceExportRecovery"; const api = vi.hoisted(() => ({ inspect: vi.fn(), recover: vi.fn(), resume: vi.fn(), confirmController: vi.fn(), confirmOwnerGone: vi.fn() })); vi.mock("../api/issues", () => ({ issuesApi: { recoveryActions: api.inspect, recoverWorkspace: api.recover, resumeWorkspaceFinalization: api.resume, confirmControllerStopped: api.confirmController, confirmOwnerGone: api.confirmOwnerGone } })); (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const action = { id: "action-1", companyId: "company-1", sourceIssueId: "issue-1", cause: "native_workspace_sync_out_retry_exhausted", kind: "active_run_watchdog", status: "active", ownerType: "system", wakePolicy: null, evidence: { runId: "run-1", workspaceRecovery: { schema: "paperclip.workspace-recovery.v1", runId: "run-1", status: "blocked", stage: "restoring_files", attempt: 6, requestIds: [], retryable: true, }, systemRecovery: { state: "exhausted", preservationOperationIds: [] } }, } as unknown as IssueRecoveryAction; function inspection(value = action) { return { active: value, actions: [value], sourceInspection: { schema: "paperclip.workspace-source-inspection.v1", actionId: value.id, runId: "run-1", status: "verified", scope: "recorded_source_identity", observedAt: "2026-11-03T12:00:00Z", } }; } describe("governed retained-file recovery", () => { let root: Root, container: HTMLDivElement, client: QueryClient; beforeEach(() => { vi.resetAllMocks(); localStorage.clear(); api.inspect.mockResolvedValue(inspection()); api.recover.mockResolvedValue({ actionId: action.id, status: "queued" }); client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity }, mutations: { retry: false } } }); client.setQueryData(queryKeys.auth.session, { user: { id: "board-1" } }); client.setQueryData(queryKeys.access.currentBoardAccess, { userId: "board-1", companyIds: ["company-1"], source: "session", isInstanceAdmin: false, memberships: [{ companyId: "company-1", membershipRole: "operator", status: "active" }], }); container = document.createElement("div"); document.body.append(container); root = createRoot(container); }); afterEach(async () => { await act(async () => root.unmount()); container.remove(); client.clear(); localStorage.clear(); vi.restoreAllMocks(); }); async function mount(value: IssueRecoveryAction | null = action) { const props = { action: value, issueId: "issue-1", companyId: "company-1" }; await act(async () => root.render( )); await settle(); } async function settle() { await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); } function button(label: string) { return [...container.querySelectorAll("button")].find(b => b.textContent === label); } async function click(label: string) { const target = button(label); expect(target, `missing ${label}`).toBeDefined(); await act(async () => target!.click()); await settle(); } it("offers the existing preservation operation for a stopped, exhausted system-owned export", async () => { await mount(); await click("Recover saved files"); expect(api.inspect).toHaveBeenCalledWith("issue-1", { runId: "run-1", actionId: "action-1" }); expect(api.recover).toHaveBeenCalledTimes(1); expect(api.recover).toHaveBeenCalledWith("issue-1", { actionId: "action-1", runId: "run-1", idempotencyKey: expect.any(String), }); expect(container.textContent).toContain("queued"); expect(container.textContent).not.toContain("Saved files recovered"); }); it.each(["access", "source"])("retains loaded recovery controls through a transient %s refresh failure", async kind => { await mount(); const key = kind === "access" ? queryKeys.access.currentBoardAccess : queryKeys.issues.recoveryActions("issue-1", "action-1", "run-1"); await act(async () => { await client.fetchQuery({ queryKey: key, staleTime: 0, queryFn: async () => { throw new ApiError("Restarting", 503, { error: "tenant_app_unavailable" }); }, retry: false }).catch(() => {}); }); await settle(); expect(button("Recover saved files")).toBeDefined(); expect(container.querySelector('[role="alert"]')).toBeNull(); expect(container.textContent).not.toContain("tenant_app_unavailable"); expect(api.recover).not.toHaveBeenCalled(); }); it.each(["access", "source"])("hides cached recovery controls after definitive %s denial", async kind => { await mount(); const key = kind === "access" ? queryKeys.access.currentBoardAccess : queryKeys.issues.recoveryActions("issue-1", "action-1", "run-1"); await act(async () => { await client.fetchQuery({ queryKey: key, staleTime: 0, queryFn: async () => { throw new ApiError("Forbidden", 403, null); }, retry: false }).catch(() => {}); }); await settle(); expect(button("Recover saved files")).toBeUndefined(); expect(container.querySelector('[role="alert"]')).not.toBeNull(); expect(api.recover).not.toHaveBeenCalled(); }); it("requests file preservation on plain HTTP without crypto.randomUUID", async () => { const originalCrypto = globalThis.crypto; vi.stubGlobal("crypto", { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) }); try { await mount(); await click("Recover saved files"); expect(api.recover).toHaveBeenCalledExactlyOnceWith("issue-1", { actionId: "action-1", runId: "run-1", idempotencyKey: expect.stringMatching( /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/), }); expect(container.textContent).not.toContain("Saved files recovered"); } finally { vi.unstubAllGlobals(); } }); it.each(["viewer", "suspended", "foreign-company", "other-account"])("does not offer mutation with %s access", async kind => { client.setQueryData(queryKeys.access.currentBoardAccess, { userId: kind === "other-account" ? "board-2" : "board-1", source: "session", isInstanceAdmin: false, companyIds: kind === "foreign-company" ? ["company-2"] : ["company-1"], memberships: [{ companyId: kind === "foreign-company" ? "company-2" : "company-1", membershipRole: kind === "viewer" ? "viewer" : "operator", status: kind === "suspended" ? "suspended" : "active" }], }); await mount(); expect(button("Recover saved files")).toBeUndefined(); expect(api.inspect).not.toHaveBeenCalled(); expect(api.recover).not.toHaveBeenCalled(); }); it.each(["resolved", "cancelled", "foreign-company", "foreign-task", "unsafe-archive"])("does not offer a %s source", async kind => { await mount({ ...action, ...(kind === "foreign-company" ? { companyId: "company-2" } : kind === "foreign-task" ? { sourceIssueId: "issue-2" } : kind === "unsafe-archive" ? { cause: "native_workspace_sync_out_unsafe_archive" } : { status: kind }) } as IssueRecoveryAction); expect(button("Recover saved files")).toBeUndefined(); expect(api.recover).not.toHaveBeenCalled(); }); it("rejects a source/action/run binding that changed after the control was rendered", async () => { api.inspect.mockResolvedValueOnce(inspection()).mockResolvedValueOnce({ ...inspection(), sourceInspection: { ...inspection().sourceInspection, runId: "foreign-run" }, }); await mount(); await click("Recover saved files"); expect(api.recover).not.toHaveBeenCalled(); expect(container.textContent).toContain("File recovery changed"); }); it("does not offer an unverified source", async () => { api.inspect.mockResolvedValue({ ...inspection(), sourceInspection: { ...inspection().sourceInspection, status: "blocked", failureCode: "source_binding_invalid", } }); await mount(); expect(button("Recover saved files")).toBeUndefined(); expect(container.textContent).toContain("can’t verify the saved files’ recorded source (source_binding_invalid)"); }); // Recovery saved by an older release: a board-owned hold whose // sandbox predates ownership receipts. The server still records a retryable // blocked recovery, but the read-only source probe cannot verify ownership. const legacyHold = { ...action, id: "legacy-hold", ownerType: "board", evidence: { runId: "run-1", workspaceRecovery: { schema: "paperclip.workspace-recovery.v1", runId: "run-1", stage: "verifying_stop", status: "blocked", attempt: 1, attemptLimit: 6, requestIds: [], retryable: true, message: "Automatic recovery of saved work stopped after 1 attempts (last failure: allocation_ownership_ambiguous)." }, systemRecovery: { schema: "paperclip.system-recovery.v1", phase: "verify_execution", state: "exhausted", revision: 5, sourceRunId: "run-1", attempts: 1, retriesUsed: 0, preservationOperationIds: [], freshAttemptRunId: null, failure: { code: "allocation_ownership_ambiguous", retryClass: "service_required" } }, originalFailureCode: "native_workspace_sync_out_failed", workspaceFinalizeAttempt: 3, previousWorkspaceRecovery: null }, } as unknown as IssueRecoveryAction; const blockedInspection = (value: IssueRecoveryAction, failureCode = "allocation_ownership_ambiguous") => ({ active: value, actions: [value], sourceInspection: { schema: "paperclip.workspace-source-inspection.v1", actionId: value.id, runId: "run-1", status: "blocked", failureCode, scope: "recorded_source_identity", observedAt: "2026-11-07T14:44:46Z" } }); it("hands the next action to the task Retry for an old-release hold whose source cannot be verified", async () => { api.inspect.mockResolvedValue(blockedInspection(legacyHold)); const owns = vi.fn(); await act(async () => root.render( )); await settle(); expect(owns).toHaveBeenLastCalledWith(false); expect(button("Recover saved files")).toBeUndefined(); // No dead-end: no "changed, refresh" alert and no status check that can only repeat it. expect(button("Check file recovery status")).toBeUndefined(); expect(container.textContent).not.toContain("has changed"); expect(container.textContent).toContain("can’t confirm that this run alone used the sandbox"); expect(api.recover).not.toHaveBeenCalled(); }); it("does not request recovery when the source stops verifying between render and click", async () => { api.inspect.mockResolvedValueOnce(inspection()).mockResolvedValueOnce({ ...inspection(), sourceInspection: { ...inspection().sourceInspection, status: "blocked", failureCode: "allocation_ownership_ambiguous" } }); await mount(); await click("Recover saved files"); expect(api.recover).not.toHaveBeenCalled(); expect(container.textContent).toContain("can’t confirm that this run alone used the sandbox"); expect(container.textContent).not.toContain("File recovery changed"); expect(button("Recover saved files")).toBeUndefined(); }); it("keeps naming the unverified source after an accepted request failed", async () => { const key = "accepted-request"; writePendingSend("workspace-file-recovery:company-1:issue-1:legacy-hold:run-1:board-1", { idempotencyKey: key, payload: { actionId: "legacy-hold", runId: "run-1" }, createdAt: Date.now() }); const failed = { ...legacyHold, evidence: { ...legacyHold.evidence, workspaceRecovery: { ...(legacyHold.evidence.workspaceRecovery as object), requestIds: [key] } } } as IssueRecoveryAction; api.inspect.mockResolvedValue(blockedInspection(failed)); await mount(failed); expect(container.textContent).toContain("File recovery has not finished"); expect(container.textContent).toContain("can’t confirm that this run alone used the sandbox"); // While recovery is running, the panel shows progress instead of the reason. const running = { ...failed, evidence: { ...failed.evidence, workspaceRecovery: { ...(failed.evidence.workspaceRecovery as object), status: "recovering" } } } as IssueRecoveryAction; api.inspect.mockResolvedValue(blockedInspection(running)); await act(async () => client.invalidateQueries({ queryKey: queryKeys.issues.recoveryActions("issue-1", "legacy-hold", "run-1") })); await settle(); expect(container.textContent).toContain("Recovering saved files"); expect(container.textContent).not.toContain("can’t confirm that this run alone used the sandbox"); expect(api.recover).not.toHaveBeenCalled(); }); it("keeps ownership of the next action while the source is verified", async () => { const owns = vi.fn(); await act(async () => root.render( )); await settle(); expect(owns).toHaveBeenLastCalledWith(true); expect(button("Recover saved files")).toBeDefined(); }); it("reports recovered files even when the source no longer matches after recovery", async () => { // Preservation completed, so the workspace moved past the // recorded source and inspection reports a reference mismatch. const recovered = { ...legacyHold, ownerType: "system", evidence: { ...legacyHold.evidence, workspaceRecovery: { ...(legacyHold.evidence.workspaceRecovery as object), status: "recovered" }, systemRecovery: { ...(legacyHold.evidence.systemRecovery as object), state: "complete", preservationOperationIds: ["preservation-1"] } } } as IssueRecoveryAction; api.inspect.mockResolvedValue(blockedInspection(recovered, "source_binding_invalid")); await mount(recovered); expect(container.textContent).toContain("Saved files recovered"); expect(container.textContent).not.toContain("need recovery"); expect(container.textContent).not.toContain("has changed"); expect(button("Recover saved files")).toBeUndefined(); }); it("reloads the task instead of looping when the recovery record was replaced", async () => { api.inspect.mockResolvedValue({ active: null, actions: [], sourceInspection: undefined }); const invalidate = vi.spyOn(client, "invalidateQueries"); await mount(legacyHold); await click("Check file recovery status"); expect(container.textContent).not.toContain("Refresh the task"); expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.issues.detail("issue-1") }); }); it("persists the exact request before POST and prevents a double click", async () => { let finish!: (value: unknown) => void; api.recover.mockImplementation((_issue, body) => { expect(localStorage.getItem("workspace-file-recovery:company-1:issue-1:action-1:run-1:board-1")).toContain(body.idempotencyKey); return new Promise(resolve => { finish = resolve; }); }); await mount(); const target = button("Recover saved files")!; await act(async () => { target.click(); target.click(); }); await settle(); expect(api.recover).toHaveBeenCalledTimes(1); await act(async () => finish({ actionId: action.id, status: "queued" })); await settle(); expect(button("Recover saved files")).toBeUndefined(); expect(container.textContent).not.toContain("Saved files recovered"); }); it("reconciles a lost accepted response through GET without another POST", async () => { let current = action; api.inspect.mockImplementation(async () => inspection(current)); api.recover.mockImplementation(async (_issue, body) => { current = { ...action, evidence: { ...action.evidence, workspaceRecovery: { ...(action.evidence.workspaceRecovery as object), status: "queued", requestIds: [body.idempotencyKey], } } }; throw Object.assign(new Error("Network reply lost"), { status: 503 }); }); await mount(); await click("Recover saved files"); expect(container.textContent).toContain("could not be confirmed"); await click("Check file recovery status"); expect(api.recover).toHaveBeenCalledTimes(1); expect(container.textContent).toContain("Recovering saved files"); expect(container.textContent).not.toContain("could not be confirmed"); expect(button("Recover saved files")).toBeUndefined(); }); it("reuses the stored key after remount and a GET that has no acceptance receipt", async () => { api.recover.mockRejectedValueOnce(Object.assign(new Error("Network unavailable"), { status: 503 })); await mount(); await click("Recover saved files"); const first = api.recover.mock.calls[0][1].idempotencyKey; await act(async () => root.render(null)); await mount(); expect(button("Recover saved files")).toBeUndefined(); await click("Check file recovery status"); await click("Recover saved files"); expect(api.recover).toHaveBeenCalledTimes(2); expect(api.recover.mock.calls[1][1].idempotencyKey).toBe(first); }); it("does not POST when durable storage is unavailable", async () => { vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("storage denied"); }); await mount(); await click("Recover saved files"); expect(api.recover).not.toHaveBeenCalled(); expect(container.textContent).toContain("No request was sent"); }); it("does not send an old account's request after a source read completes", async () => { let finish!: (value: unknown) => void; api.inspect.mockResolvedValueOnce(inspection()).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); await mount(); await click("Recover saved files"); await act(async () => client.setQueryData(queryKeys.auth.session, { user: { id: "board-2" } })); await act(async () => finish(inspection())); await settle(); expect(api.recover).not.toHaveBeenCalled(); }); it("shows a confirmed blocked attempt as failed preservation, not queued or recovered", async () => { let current = action; api.inspect.mockImplementation(async () => inspection(current)); api.recover.mockImplementation(async (_issue, body) => { current = { ...action, evidence: { ...action.evidence, workspaceRecovery: { ...(action.evidence.workspaceRecovery as object), requestIds: [body.idempotencyKey], } } }; return { actionId: action.id, status: "queued" }; }); await mount(); await click("Recover saved files"); expect(container.textContent).toContain("File recovery has not finished"); expect(container.textContent).not.toContain("File recovery is queued"); expect(container.textContent).not.toContain("Saved files recovered"); expect(api.recover).toHaveBeenCalledTimes(1); }); it("offers a new explicit request only after the prior accepted attempt is confirmed exhausted", async () => { let current = action; const accepted: string[] = []; api.inspect.mockImplementation(async () => inspection(current)); api.recover.mockImplementation(async (_issue, body) => { accepted.push(body.idempotencyKey); current = { ...action, evidence: { ...action.evidence, workspaceRecovery: { ...(action.evidence.workspaceRecovery as object), requestIds: [...accepted], } } }; return { actionId: action.id, status: "queued" }; }); await mount(); await click("Recover saved files"); expect(container.textContent).toContain("File recovery has not finished"); expect(api.recover).toHaveBeenCalledTimes(1); await click("Try file recovery again"); expect(api.recover).toHaveBeenCalledTimes(2); expect(accepted[1]).not.toBe(accepted[0]); expect(container.textContent).not.toContain("Saved files recovered"); }); it("does not replace the confirmed failed key if another recovery is active at the fresh read", async () => { let current = action; api.inspect.mockImplementation(async () => inspection(current)); api.recover.mockImplementation(async (_issue, body) => { current = { ...action, evidence: { ...action.evidence, workspaceRecovery: { ...(action.evidence.workspaceRecovery as object), requestIds: [body.idempotencyKey], } } }; return { actionId: action.id, status: "queued" }; }); await mount(); await click("Recover saved files"); const previous = api.recover.mock.calls[0][1].idempotencyKey; current = { ...current, evidence: { ...current.evidence, workspaceRecovery: { ...(current.evidence.workspaceRecovery as object), status: "recovering", } } }; await click("Try file recovery again"); expect(api.recover).toHaveBeenCalledTimes(1); expect(localStorage.getItem("workspace-file-recovery:company-1:issue-1:action-1:run-1:board-1")).toContain(previous); }); it("only reports recovered files after the durable preservation receipt is present", async () => { const recovered = { ...action, evidence: { ...action.evidence, workspaceRecovery: { ...(action.evidence.workspaceRecovery as object), status: "recovered", }, systemRecovery: { state: "complete", preservationOperationIds: [] } } }; api.inspect.mockResolvedValue(inspection(recovered)); await mount(); expect(container.textContent).not.toContain("Saved files recovered"); api.inspect.mockResolvedValue(inspection({ ...recovered, evidence: { ...recovered.evidence, systemRecovery: { state: "complete", preservationOperationIds: ["host-preservation-operation"] }, } })); await act(async () => client.invalidateQueries({ queryKey: queryKeys.issues.recoveryActions("issue-1", "action-1", "run-1") })); await settle(); expect(container.textContent).toContain("Saved files recovered"); expect(api.recover).not.toHaveBeenCalled(); }); // Recovery is automatic. No recovery state may tell a person to fix or inspect // a sandbox, or name an owner or administrator as the one who must act. const manualCopy = /administrator|\bowner\b|operator assistance|service assistance|service attention|inspect the (run|failed run)|environment/i; const withRecovery = (value: IssueRecoveryAction, patch: Record, system?: Record) => ({ ...value, evidence: { ...value.evidence, workspaceRecovery: { ...(value.evidence.workspaceRecovery as object), ...patch }, ...(system ? { systemRecovery: { ...(value.evidence.systemRecovery as object), ...system } } : {}) } }) as IssueRecoveryAction; const operatorAccess = { userId: "board-1", companyIds: ["company-1"], source: "session", isInstanceAdmin: false, memberships: [{ companyId: "company-1", membershipRole: "operator", status: "active" }] }; const viewerAccess = { ...operatorAccess, memberships: [{ companyId: "company-1", membershipRole: "viewer", status: "active" }] }; it.each([ ["verified and retryable", action, inspection(action), operatorAccess], ["unverified ownership", legacyHold, blockedInspection(legacyHold), operatorAccess], ["unverified binding", legacyHold, blockedInspection(legacyHold, "source_binding_invalid"), operatorAccess], ["not inspected", legacyHold, { ...blockedInspection(legacyHold), sourceInspection: { ...blockedInspection(legacyHold).sourceInspection, status: "not_inspected", reason: "source_limit" } }, operatorAccess], ["queued", withRecovery(legacyHold, { status: "queued" }), blockedInspection(withRecovery(legacyHold, { status: "queued" })), operatorAccess], ["not retryable", withRecovery(legacyHold, { retryable: false }), blockedInspection(withRecovery(legacyHold, { retryable: false })), operatorAccess], ["recovered", withRecovery(legacyHold, { status: "recovered" }, { state: "complete", preservationOperationIds: ["op-1"] }), blockedInspection(withRecovery(legacyHold, { status: "recovered" }, { state: "complete", preservationOperationIds: ["op-1"] })), operatorAccess], ["viewer", legacyHold, blockedInspection(legacyHold), viewerAccess], ["copyback owner hold for an operator", { ...action, evidence: { ...action.evidence, workspaceOwnerRecovery: { state: "operator_required", owner: { token: "token" } } } } as IssueRecoveryAction, inspection(action), operatorAccess], ["board-owned status", { ...action, cause: "native_workspace_finalization_owner_unverified", ownerType: "board", evidence: { runId: "run-1" } } as unknown as IssueRecoveryAction, inspection(action), operatorAccess], ] as const)("renders no manual sandbox or administrator copy: %s", async (_name, value, response, access) => { client.setQueryData(queryKeys.access.currentBoardAccess, access); api.inspect.mockResolvedValue(response); await mount(value); expect(container.textContent).not.toMatch(manualCopy); }); describe("previous server stop confirmation", () => { const ownerAction = { ...action, cause: "native_workspace_finalization_owner_unverified", ownerType: "board", status: "escalated", evidence: { runId: "run-1", owner: { token: "owner-token", hostname: "secret-host", pid: 2 }, workspaceOwnerRecovery: { state: "operator_required" } } } as unknown as IssueRecoveryAction; it("offers one board action that resumes the saved result with the board's stop evidence", async () => { api.resume.mockResolvedValue({ runId: "run-1", resultId: "result-1", status: "queued" }); client.setQueryData(queryKeys.access.currentBoardAccess, { userId: "board-1", source: "session", isInstanceAdmin: false, companyIds: ["company-1"], memberships: [{ companyId: "company-1", membershipRole: "owner", status: "active" }] }); await mount(ownerAction); expect(container.textContent).toContain("can’t verify that it stopped"); expect(container.textContent).not.toContain("secret-host"); await click("Confirm the previous server stopped"); const submit = button("Confirm and resume")!; expect(submit.disabled).toBe(true); const textarea = container.querySelector("textarea")!; await act(async () => { const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!; setter.call(textarea, "The old server process exited and was not restarted."); textarea.dispatchEvent(new Event("input", { bubbles: true })); }); await click("Confirm and resume"); expect(api.resume).toHaveBeenCalledExactlyOnceWith("issue-1", { actionId: "action-1", runId: "run-1", ownerToken: "owner-token", controllerAndCopybackStopped: true, stopEvidence: "The old server process exited and was not restarted." }); expect(container.textContent).toContain("Restoring saved work…"); }); it("offers the same action for retained saved-work recovery held by a stopped copyback owner", async () => { api.resume.mockResolvedValue({ runId: "run-1", resultId: null, status: "queued" }); client.setQueryData(queryKeys.access.currentBoardAccess, { userId: "board-1", source: "session", isInstanceAdmin: false, companyIds: ["company-1"], memberships: [{ companyId: "company-1", membershipRole: "admin", status: "active" }] }); const held = { ...action, evidence: { ...action.evidence, workspaceOwnerRecovery: { state: "operator_required", owner: { token: "retained-token", hostname: "secret-host", pid: 9 } } } } as IssueRecoveryAction; await mount(held); expect(button("Recover saved files")).toBeUndefined(); expect(container.textContent).not.toContain("secret-host"); await click("Confirm the previous server stopped"); const textarea = container.querySelector("textarea")!; await act(async () => { const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!; setter.call(textarea, "The old server process exited and was not restarted."); textarea.dispatchEvent(new Event("input", { bubbles: true })); }); await click("Confirm and resume"); expect(api.resume).toHaveBeenCalledExactlyOnceWith("issue-1", { actionId: "action-1", runId: "run-1", ownerToken: "retained-token", controllerAndCopybackStopped: true, stopEvidence: "The old server process exited and was not restarted." }); // Once confirmed or resuming, the ordinary retained recovery controls return. await mount({ ...held, evidence: { ...held.evidence, workspaceOwnerRecovery: { state: "resuming" } } } as IssueRecoveryAction); expect(button("Confirm the previous server stopped")).toBeUndefined(); expect(container.querySelector('[data-testid="retained-file-recovery"]')).not.toBeNull(); }); it("confirms a pre-upgrade controller an ephemeral server cannot verify", async () => { api.confirmController.mockResolvedValue({ runId: "run-1", status: "queued" }); client.setQueryData(queryKeys.access.currentBoardAccess, { userId: "board-1", source: "session", isInstanceAdmin: false, companyIds: ["company-1"], memberships: [{ companyId: "company-1", membershipRole: "owner", status: "active" }] }); const controllerAction = { ...action, cause: "native_controller_host_unverified", ownerType: "board", evidence: { runId: "run-1", controller: { bootId: "boot", pid: 7 } } } as unknown as IssueRecoveryAction; await mount(controllerAction); expect(container.textContent).toContain("before Paperclip stored which host it ran on"); await click("Confirm the previous server stopped"); const textarea = container.querySelector("textarea")!; await act(async () => { const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!; setter.call(textarea, "The old server container was replaced and is not running."); textarea.dispatchEvent(new Event("input", { bubbles: true })); }); await click("Confirm and resume"); expect(api.confirmController).toHaveBeenCalledExactlyOnceWith("issue-1", { actionId: "action-1", runId: "run-1", stopEvidence: "The old server container was replaced and is not running." }); expect(api.resume).not.toHaveBeenCalled(); expect(container.textContent).toContain("Controller stop recorded. Checking recovery…"); }); it("confirms the unrecorded server holding a stuck resource, for board owners only", async () => { api.confirmOwnerGone.mockResolvedValue({ recordId: "lease-1", status: "queued" }); const ownerGoneAction = { ...action, cause: "ownership_owner_unprovable", ownerType: "board", evidence: { kind: "environment_run_lease_active", recordId: "lease-1", runId: "run-1" } } as unknown as IssueRecoveryAction; // An operator sees the wait but cannot attest the stop. await mount(ownerGoneAction); expect(container.textContent).toContain("before Paperclip stored which server held it"); expect(button("Confirm the previous server stopped")).toBeUndefined(); client.setQueryData(queryKeys.access.currentBoardAccess, { userId: "board-1", source: "session", isInstanceAdmin: false, companyIds: ["company-1"], memberships: [{ companyId: "company-1", membershipRole: "owner", status: "active" }] }); await mount(ownerGoneAction); await click("Confirm the previous server stopped"); const textarea = container.querySelector("textarea")!; await act(async () => { const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!; setter.call(textarea, "The old server container was replaced and is not running."); textarea.dispatchEvent(new Event("input", { bubbles: true })); }); await click("Confirm and resume"); expect(api.confirmOwnerGone).toHaveBeenCalledExactlyOnceWith("issue-1", { actionId: "action-1", recordId: "lease-1", stopEvidence: "The old server container was replaced and is not running." }); expect(container.textContent).toContain("Releasing the sandbox lease…"); }); it("resets an open confirmation when the request is refreshed to a newer record version", async () => { api.confirmOwnerGone.mockResolvedValue({ recordId: "run-1", status: "queued" }); client.setQueryData(queryKeys.access.currentBoardAccess, { userId: "board-1", source: "session", isInstanceAdmin: false, companyIds: ["company-1"], memberships: [{ companyId: "company-1", membershipRole: "owner", status: "active" }] }); const at = (version: string) => ({ ...action, cause: "ownership_owner_unprovable", ownerType: "board", evidence: { kind: "legacy_controller_lease", recordId: "run-1", runId: "run-1", recordUpdatedAt: version } }) as unknown as IssueRecoveryAction; await mount(at("2026-11-08T10:00:00.000Z")); await click("Confirm the previous server stopped"); expect(container.querySelector("textarea")).not.toBeNull(); // The record changed and the request now names its new version: the open form starts over. await mount(at("2026-11-08T10:05:00.000Z")); expect(container.querySelector("textarea")).toBeNull(); await click("Confirm the previous server stopped"); const textarea = container.querySelector("textarea")!; await act(async () => { const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!; setter.call(textarea, "The old server container was replaced and is not running."); textarea.dispatchEvent(new Event("input", { bubbles: true })); }); await click("Confirm and resume"); expect(api.confirmOwnerGone).toHaveBeenCalledExactlyOnceWith("issue-1", { actionId: "action-1", recordId: "run-1", stopEvidence: "The old server container was replaced and is not running.", recordVersion: "2026-11-08T10:05:00.000Z" }); }); it("names the stuck record for a kind confirmed by record version, and a generic resource otherwise", async () => { client.setQueryData(queryKeys.access.currentBoardAccess, { userId: "board-1", source: "session", isInstanceAdmin: false, companyIds: ["company-1"], memberships: [{ companyId: "company-1", membershipRole: "owner", status: "active" }] }); const ownerGone = (kind: string) => ({ ...action, cause: "ownership_owner_unprovable", ownerType: "board", evidence: { kind, recordId: "run-1", runId: "run-1", recordUpdatedAt: "2026-11-08T10:00:00.000Z" } }) as unknown as IssueRecoveryAction; await mount(ownerGone("legacy_controller_lease")); expect(container.textContent).toContain("This task’s earlier run was recorded before Paperclip stored which server held it"); expect(button("Confirm the previous server stopped")).toBeDefined(); await mount(ownerGone("a_future_kind")); expect(container.textContent).toContain("A resource from this task’s earlier run was recorded"); }); it("shows status only, without the action, once automatic recovery is resuming or for non-administrators", async () => { // The default fixture is an operator: it may resolve other recovery but not attest a controller stop. await mount({ ...ownerAction, evidence: { ...ownerAction.evidence, workspaceOwnerRecovery: { state: "resuming" } } } as IssueRecoveryAction); expect(button("Confirm the previous server stopped")).toBeUndefined(); client.setQueryData(queryKeys.access.currentBoardAccess, { userId: "board-1", source: "session", isInstanceAdmin: false, companyIds: ["company-1"], memberships: [{ companyId: "company-1", membershipRole: "operator", status: "active" }] }); await mount(ownerAction); expect(button("Confirm the previous server stopped")).toBeUndefined(); expect(container.textContent).toContain("waiting to confirm that the previous server stopped"); expect(api.resume).not.toHaveBeenCalled(); }); }); });