mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 23:20:53 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The control plane must preserve saved work before it admits another attempt. > - Retained-file recovery already has an authorized API and a bounded worker. > - The task UI hid that operation when workspace repair blocked execution. > - The run inspector could check source identity but could not request preservation. > - This pull request exposes that existing operation with its exact source binding. > - The worker still owns file verification, stop receipts, and execution release. ## Linked Issues or Issue Description Refs #18678 and #18803 for the existing retained-source and continuation contracts. **What happened?** A failed native run could leave an active, system-owned export recovery action. The task showed a generic Retry control. Its workspace repair gate correctly refused another provider turn. The run inspector offered only read-only source inspection. The authorized file-preservation API had no production UI caller. **Expected behavior** An authorized board operator can request preservation of the exact retained files. The UI keeps the repair hold until the server records verified preservation. A lost response must keep the same request identity. **Steps to reproduce** 1. Use an active retained-export action whose workspace recovery is blocked and retryable. 2. Open its source task while `workspaceRepairRequired` is true. 3. Open the exact failed run and inspect recovery. 4. Before this change, neither surface can request the existing file-preservation operation. **Paperclip version or commit** The focused regression fails on master `1f55073f96b2485f49f98ee8c261aa14ec2f79ac` before the production UI edits. I checked the roadmap and searched open public PRs for this UI repair. No duplicate was found. This is a repair to the existing recovery capability. ## What Changed - Add Recover saved files to the existing task recovery area and exact-run inspector. Reuse the current board task-mutation access check and the existing `recoverWorkspace` API. - Read the exact action, run, company, task, and source observation again before sending. Refuse changed, retired, foreign, unverified, or non-retryable sources. - Save one account-scoped request before POST. Keep its key through a lost response and reload. Reconcile with GET before an explicit resend. Use a new key only after an explicit click and a fresh, confirmed exhausted attempt; do not resend automatically. - Show queued, recovering, blocked, and verified receipt states separately. A request acknowledgement does not mark files saved or resolve the task hold. Hide generic Retry for native export holds. Keep the existing Retry path for legacy restoration. - Cover both mounted surfaces and negative access, source, account, persistence, duplicate-click, and lost-response cases. Update the existing design guidance and correct the affected run-detail recipe only. ## Verification - RED: the new component regression fails with `missing Recover saved files` before production changes. The original failure is retained. - GREEN: 265 tests pass across `WorkspaceExportRecovery.control.test.tsx`, `WorkspaceExportRecovery.test.tsx`, `RunWorkspaceRecoverySurface.test.tsx`, and the full `IssueDetail.test.tsx` suite. - Review RED/GREEN: the prior UI reused the failed key after a confirmed exhausted attempt. A focused regression now proves a different key for an explicit new attempt, while the lost-response controls still prove key reuse. Mounted task controls prove that verified native preservation stays available, unverified or stale native sources never offer generic Retry, and viewers cannot mutate. A separate RED against the exact prior UI proves the lost legacy control. Its GREEN clicks the existing legacy Retry API while the workspace hold remains active. - Full UI TypeScript check passes: `node scripts/run-tsc.mjs -p ui/tsconfig.json --noEmit`. - `node scripts/check-token-gates.mjs`, `node scripts/check-maps.mjs`, and `git diff --check` pass. The map check proves structure only. - One early legacy test probe used an unsigned fixture and a readonly array; those setup failures are retained. The corrected same-user fixture proves the source RED and GREEN. The first test command used an unnamed project filter and stopped before loading tests. The corrected package configuration produced the recorded RED. One initial storage fault probe did not intercept jsdom's storage method; the corrected prototype fault proves that unavailable storage sends no POST. - Dependencies use Node 24.22.2 and a frozen pnpm 9.15.4 install with scripts disabled. The maintained shared and SDK prerequisite builds pass on this base. No dependency or lockfile changes are included. - A later Paperclip Review follow-up found the plain-HTTP UUID gap. Its focused regression fails before POST with the prior direct call, then passes with the existing UUID helper. The same storage, lost-response, retry and source controls remain green. - Original full CI `41715060582` fails two Cloud-auth task-entry browser checks at their existing heading assertion. The retained original artifacts show a late task pane (one already contains the heading; one still shows Loading conversation). The cause remains unproven. No auth, fixture, timeout or assertion change is included. Fresh Greptile is 5/5 with zero open threads and Paperclip Review passes on the UUID fix head. Current full CI `41720068249` attempt 1 failed. Three workers were terminated by AWS Spot capacity loss. The CLI startup and dense-ZIP checks also timed out at their unchanged deadlines. The CLI/shared input trees match a previous passing CI job, and the dense-ZIP case passes one focused local check. The CLI timeout cause remains unknown. The coordinator requested one bounded recheck. Attempt 2 succeeds on the same source, with all current checks green. No source, fixture, timeout or assertion changed for that recheck. The worker issued no retry. This pass does not establish the original CLI timeout cause. - Full recursive typecheck, repository tests, and application build were not run locally under the bounded UI verification scope. Full CI is requested. - No live preservation request, provider turn, deployment, or sandbox cleanup was performed for this repair. Original saved-file recovery remains unverified until rollout and real acceptance. ## Risks The client access check is a display guard. The existing server authorization and physical source, ownership, stop, and snapshot checks remain authoritative. Unavailable browser storage blocks the request before POST. A failed acknowledgement retains the request for its owner. The UI never calls task Retry, recovery resolution, reconstruction, provider execution, or destructive cleanup. The existing worker retains its bounded recovery policy and may finish the saved result after real preservation. ## Model Used OpenAI Codex, GPT-6 family, with repository inspection, code editing, and test execution. The exact runtime model ID 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>
349 lines
16 KiB
TypeScript
349 lines
16 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import type { ComponentProps, ReactNode } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { flushSync } from "react-dom";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { HeartbeatRun, Issue, IssueRecoveryAction } from "@paperclipai/shared";
|
|
import { RunWorkspaceRecoverySurface } from "./RunWorkspaceRecoverySurface";
|
|
import { ToastProvider } from "../context/ToastContext";
|
|
|
|
const navigateMock = vi.hoisted(() => vi.fn());
|
|
const issueGetMock = vi.hoisted(() => vi.fn());
|
|
const issueCreateMock = vi.hoisted(() => vi.fn());
|
|
const resolveRecoveryMock = vi.hoisted(() => vi.fn());
|
|
const reconcileMock = vi.hoisted(() => vi.fn());
|
|
const boardAccessMock = vi.hoisted(() => vi.fn());
|
|
const recoveryActionsMock = vi.hoisted(() => vi.fn());
|
|
const recoverWorkspaceMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("@/lib/router", () => ({
|
|
useNavigate: () => navigateMock,
|
|
Link: ({ children, to, ...props }: { children: ReactNode; to: string } & ComponentProps<"a">) => (
|
|
<a href={to} {...props}>{children}</a>
|
|
),
|
|
}));
|
|
|
|
vi.mock("../api/issues", () => ({
|
|
issuesApi: {
|
|
get: issueGetMock,
|
|
create: issueCreateMock,
|
|
resolveRecoveryAction: resolveRecoveryMock,
|
|
recoveryActions: recoveryActionsMock,
|
|
recoverWorkspace: recoverWorkspaceMock,
|
|
},
|
|
}));
|
|
|
|
vi.mock("../api/execution-workspaces", () => ({
|
|
executionWorkspacesApi: {
|
|
reconcile: reconcileMock,
|
|
},
|
|
}));
|
|
|
|
vi.mock("../api/access", () => ({
|
|
accessApi: {
|
|
getCurrentBoardAccess: boardAccessMock,
|
|
},
|
|
}));
|
|
|
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
// flushSync-based sync `act` shim (matches the repo pattern in IssueRecoveryActionCard.test.tsx).
|
|
function act(callback: () => void): void {
|
|
flushSync(callback);
|
|
}
|
|
|
|
// Resolve pending mocked-promise micro/macrotasks, then flush the React state updates react-query
|
|
// queued, repeating so chained query → render → dependent-render settles fully.
|
|
async function flush() {
|
|
for (let i = 0; i < 6; i += 1) {
|
|
for (let j = 0; j < 4; j += 1) await Promise.resolve();
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
act(() => {});
|
|
}
|
|
}
|
|
|
|
let container: HTMLDivElement | null = null;
|
|
let root: Root | null = null;
|
|
|
|
afterEach(() => {
|
|
if (root) act(() => root?.unmount());
|
|
root = null;
|
|
container?.remove();
|
|
container = null;
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
function buildRun(overrides: Partial<HeartbeatRun> = {}): HeartbeatRun {
|
|
return {
|
|
id: "run-aaaa1111",
|
|
companyId: "company-1",
|
|
agentId: "agent-1",
|
|
status: "failed",
|
|
errorCode: "workspace_validation_failed",
|
|
contextSnapshot: { issueId: "issue-1" },
|
|
resultJson: null,
|
|
contextSnapshotJson: null,
|
|
...overrides,
|
|
} as unknown as HeartbeatRun;
|
|
}
|
|
|
|
function buildRecoveryAction(): IssueRecoveryAction {
|
|
return {
|
|
id: "action-1",
|
|
companyId: "company-1",
|
|
sourceIssueId: "issue-1",
|
|
recoveryIssueId: null,
|
|
kind: "workspace_validation",
|
|
status: "active",
|
|
ownerType: "board",
|
|
ownerAgentId: null,
|
|
ownerUserId: null,
|
|
previousOwnerAgentId: null,
|
|
returnOwnerAgentId: null,
|
|
cause: "workspace_validation_failed",
|
|
fingerprint: "fp",
|
|
evidence: {
|
|
workspaceValidation: {
|
|
reason: "git_worktree_branch_incoherence",
|
|
expectedBranch: "PAP-1405-recorded",
|
|
actualBranch: "live-branch",
|
|
cleanliness: "dirty",
|
|
statusEntryCount: 2,
|
|
dirtyPathSample: ["a.ts"],
|
|
sourceIdentifier: "PAP-1405",
|
|
persistedExecutionWorkspaceId: "ws-1",
|
|
provenance: {
|
|
expectedHeadSha: "aaaa1111bbbb",
|
|
actualHeadSha: "cccc2222dddd",
|
|
ancestryVerdict: "diverged",
|
|
plainLanguageReason: "Recorded branch is not an ancestor of the live branch.",
|
|
},
|
|
},
|
|
},
|
|
nextAction: "Repair the workspace.",
|
|
wakePolicy: null,
|
|
monitorPolicy: null,
|
|
attemptCount: 1,
|
|
maxAttempts: 3,
|
|
timeoutAt: null,
|
|
lastAttemptAt: null,
|
|
outcome: null,
|
|
resolutionNote: null,
|
|
resolvedAt: null,
|
|
createdAt: "2026-07-09T00:00:00.000Z",
|
|
updatedAt: "2026-07-09T00:00:00.000Z",
|
|
};
|
|
}
|
|
|
|
function buildIssue(action: IssueRecoveryAction | null): Issue {
|
|
return {
|
|
id: "issue-1",
|
|
identifier: "PAP-1405",
|
|
companyId: "company-1",
|
|
title: "Do the thing",
|
|
description: "body",
|
|
priority: "medium",
|
|
projectId: null,
|
|
parentId: null,
|
|
assigneeAgentId: null,
|
|
executionWorkspaceId: "ws-1",
|
|
activeRecoveryAction: action,
|
|
} as unknown as Issue;
|
|
}
|
|
|
|
async function renderSurface(run: HeartbeatRun, viewerId?: string) {
|
|
const client = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
if (viewerId) client.setQueryData(["auth", "session"], { user: { id: viewerId } });
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
act(() => {
|
|
root?.render(
|
|
<QueryClientProvider client={client}>
|
|
<ToastProvider>
|
|
<RunWorkspaceRecoverySurface run={run} />
|
|
</ToastProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
// Let the issue + board-access queries settle and commit.
|
|
await flush();
|
|
return container!;
|
|
}
|
|
|
|
function click(element: Element | null) {
|
|
if (!element) throw new Error("Expected element to exist");
|
|
act(() => {
|
|
element.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
}
|
|
|
|
describe("RunWorkspaceRecoverySurface", () => {
|
|
it("inspects a selected resolved source only on request and separates current identity from the recorded failure", async () => {
|
|
const action = { ...buildRecoveryAction(), status: "resolved", cause: "native_workspace_sync_out_retry_exhausted",
|
|
evidence: { systemRecovery: { failure: { code: "source_binding_invalid" } } } };
|
|
recoveryActionsMock.mockResolvedValueOnce({ active: null, actions: [action] }).mockResolvedValueOnce({ active: null,
|
|
actions: [action], sourceInspection: { actionId: action.id, runId: "run-aaaa1111", scope: "recorded_source_identity",
|
|
observedAt: "2026-10-26T17:00:00.000Z", status: "blocked", failureCode: "source_binding_invalid",
|
|
sourceVerification: { reason: "lease_policy_not_retained", observedLeasePolicy: "reuse_by_environment" } } });
|
|
const node = await renderSurface(buildRun({ errorCode: "native_workspace_sync_out_retry_exhausted" }));
|
|
expect(recoveryActionsMock).not.toHaveBeenCalled();
|
|
expect(issueGetMock).not.toHaveBeenCalled();
|
|
click(node.querySelector("button"));
|
|
await flush();
|
|
expect(recoveryActionsMock).toHaveBeenLastCalledWith("issue-1", { runId: "run-aaaa1111" });
|
|
expect(node.textContent).toContain("Recorded failure: source_binding_invalid");
|
|
const inspect = [...node.querySelectorAll("button")].find(button => button.textContent === "Inspect current source");
|
|
click(inspect ?? null);
|
|
await flush();
|
|
expect(recoveryActionsMock).toHaveBeenLastCalledWith("issue-1", { runId: "run-aaaa1111", actionId: "action-1" });
|
|
expect(node.textContent).toContain("Current recorded-source identity");
|
|
expect(node.textContent).toContain("lease_policy_not_retained");
|
|
expect(node.textContent).toContain("reuse_by_environment");
|
|
expect(node.textContent).toContain("does not confirm provider termination, local files, or successful preservation");
|
|
expect(issueCreateMock).not.toHaveBeenCalled();
|
|
expect(resolveRecoveryMock).not.toHaveBeenCalled();
|
|
expect(reconcileMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("shows verified identity without claiming preservation and reports an inspection error inline", async () => {
|
|
const action = buildRecoveryAction();
|
|
recoveryActionsMock.mockResolvedValueOnce({ active: null, actions: [action] })
|
|
.mockResolvedValueOnce({ active: null, actions: [action], sourceInspection: {
|
|
actionId: action.id, status: "verified", observedAt: "2026-10-26T17:00:00.000Z" } })
|
|
.mockRejectedValueOnce(new Error("Source inspection unavailable"));
|
|
const node = await renderSurface(buildRun({ errorCode: "native_workspace_sync_out_unrecoverable" }));
|
|
click(node.querySelector("button"));
|
|
await flush();
|
|
click([...node.querySelectorAll("button")].find(button => button.textContent === "Inspect current source") ?? null);
|
|
await flush();
|
|
expect(recoveryActionsMock).toHaveBeenLastCalledWith("issue-1", { runId: "run-aaaa1111", actionId: action.id });
|
|
expect(node.textContent).toContain("Recorded-source identity verified.");
|
|
expect(node.textContent).toContain("does not confirm provider termination");
|
|
click([...node.querySelectorAll("button")].find(button => button.textContent === "Inspect current source") ?? null);
|
|
await flush();
|
|
expect(node.querySelector("[role='alert']")?.textContent).toContain("Source inspection unavailable");
|
|
});
|
|
|
|
it("keeps inspection pending and explains a bounded source limit", async () => {
|
|
const action = buildRecoveryAction();
|
|
let complete!: (value: unknown) => void;
|
|
recoveryActionsMock.mockResolvedValueOnce({ active: null, actions: [action] })
|
|
.mockImplementationOnce(() => new Promise(resolve => { complete = resolve; }));
|
|
const node = await renderSurface(buildRun({ errorCode: "native_workspace_sync_out_retry_exhausted" }));
|
|
click(node.querySelector("button"));
|
|
await flush();
|
|
click([...node.querySelectorAll("button")].find(button => button.textContent === "Inspect current source") ?? null);
|
|
await flush();
|
|
expect(node.querySelector("button")?.disabled).toBe(true);
|
|
expect(recoveryActionsMock).toHaveBeenLastCalledWith("issue-1", { runId: "run-aaaa1111", actionId: action.id });
|
|
complete({ active: null, actions: [action], sourceInspection: { actionId: action.id,
|
|
observedAt: "2026-10-26T17:00:00.000Z", status: "not_inspected", reason: "source_limit" } });
|
|
await flush();
|
|
expect(node.textContent).toContain("exceeds the inspection limit");
|
|
expect(node.querySelector("button")?.disabled).toBe(false);
|
|
});
|
|
|
|
it("keeps saved reasons separate from changed observations and preserves other source selections", async () => {
|
|
const first = { ...buildRecoveryAction(), evidence: { systemRecovery: { failure: {
|
|
code: "source_binding_invalid", sourceVerification: { reason: "lease_policy_not_retained", observedLeasePolicy: "reuse_by_environment" },
|
|
} } } };
|
|
const second = { ...first, id: "action-2", evidence: { systemRecovery: { failure: { code: "source_binding_invalid",
|
|
sourceVerification: { reason: "secret-reason", observedLeasePolicy: "secret-policy" } } } } };
|
|
recoveryActionsMock.mockResolvedValueOnce({ active: null, actions: [first, second] })
|
|
.mockResolvedValueOnce({ active: null, actions: [first], sourceInspection: { actionId: first.id,
|
|
status: "blocked", observedAt: "2026-10-26T17:00:00.000Z", failureCode: "source_binding_invalid",
|
|
sourceVerification: { reason: "source_lease_not_bound" } } })
|
|
.mockResolvedValueOnce({ active: null, actions: [second], sourceInspection: { actionId: second.id,
|
|
status: "verified", observedAt: "2026-10-26T17:01:00.000Z" } });
|
|
const node = await renderSurface(buildRun({ errorCode: "native_workspace_sync_out_retry_exhausted" }));
|
|
click(node.querySelector("button"));
|
|
await flush();
|
|
expect(node.textContent).toContain("Recorded failure: source_binding_invalid · lease_policy_not_retained · lease policy reuse_by_environment");
|
|
expect(node.textContent).not.toContain("secret-reason");
|
|
expect(node.textContent).not.toContain("secret-policy");
|
|
const choices = () => [...node.querySelectorAll("button")].filter(button => button.textContent === "Inspect current source");
|
|
click(choices()[0]);
|
|
await flush();
|
|
expect(node.textContent).toContain("source_lease_not_bound");
|
|
expect(node.textContent).toContain("Action action-1");
|
|
expect(node.textContent).toContain("lease_policy_not_retained");
|
|
expect(choices()).toHaveLength(2);
|
|
click(choices()[1]);
|
|
await flush();
|
|
expect(recoveryActionsMock).toHaveBeenLastCalledWith("issue-1", { runId: "run-aaaa1111", actionId: "action-2" });
|
|
expect(node.textContent).toContain("Action action-2");
|
|
expect(node.textContent).toContain("Recorded-source identity verified.");
|
|
expect(choices()).toHaveLength(2);
|
|
});
|
|
|
|
it("offers the same governed file preservation from the exact failed run without resolving its hold", async () => {
|
|
const run = buildRun({ errorCode: "native_workspace_sync_out_retry_exhausted" });
|
|
const action = { ...buildRecoveryAction(), kind: "active_run_watchdog", ownerType: "system",
|
|
cause: "native_workspace_sync_out_retry_exhausted", evidence: { runId: run.id, workspaceRecovery: {
|
|
schema: "paperclip.workspace-recovery.v1", runId: run.id, status: "blocked",
|
|
stage: "restoring_files", retryable: true, requestIds: [],
|
|
}, systemRecovery: { state: "exhausted", preservationOperationIds: [] } } } as IssueRecoveryAction;
|
|
boardAccessMock.mockResolvedValue({ source: "session", companyIds: ["company-1"], userId: "run-operator",
|
|
memberships: [{ companyId: "company-1", membershipRole: "operator", status: "active" }] });
|
|
recoveryActionsMock.mockResolvedValue({ active: action, actions: [action], sourceInspection: {
|
|
actionId: action.id, runId: run.id, scope: "recorded_source_identity", status: "verified",
|
|
observedAt: "2026-11-03T12:00:00Z",
|
|
} });
|
|
recoverWorkspaceMock.mockResolvedValue({ actionId: action.id, status: "queued" });
|
|
const node = await renderSurface(run, "run-operator");
|
|
click([...node.querySelectorAll("button")].find(button => button.textContent === "Inspect recovery")!);
|
|
await flush();
|
|
const preserve = [...node.querySelectorAll("button")].find(button => button.textContent === "Recover saved files");
|
|
expect(preserve).toBeDefined();
|
|
click(preserve!); await flush();
|
|
expect(recoverWorkspaceMock).toHaveBeenCalledExactlyOnceWith("issue-1", {
|
|
actionId: action.id, runId: run.id, idempotencyKey: expect.any(String),
|
|
});
|
|
expect(resolveRecoveryMock).not.toHaveBeenCalled();
|
|
expect(issueCreateMock).not.toHaveBeenCalled();
|
|
expect(node.textContent).not.toContain("Saved files recovered");
|
|
});
|
|
|
|
it("renders nothing for a run that is not a workspace-validation failure", async () => {
|
|
issueGetMock.mockResolvedValue(buildIssue(buildRecoveryAction()));
|
|
boardAccessMock.mockResolvedValue(undefined);
|
|
const node = await renderSurface(buildRun({ errorCode: "process_lost" }));
|
|
expect(node.querySelector("[data-testid='run-workspace-recovery-surface']")).toBeNull();
|
|
expect(issueGetMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("renders nothing when the source issue has no workspace_validation recovery action", async () => {
|
|
issueGetMock.mockResolvedValue(buildIssue(null));
|
|
boardAccessMock.mockResolvedValue(undefined);
|
|
const node = await renderSurface(buildRun());
|
|
expect(node.querySelector("[data-testid='run-workspace-recovery-surface']")).toBeNull();
|
|
});
|
|
|
|
it("renders the compact recovery card and repair action for a dirty divergence", async () => {
|
|
issueGetMock.mockResolvedValue(buildIssue(buildRecoveryAction()));
|
|
boardAccessMock.mockResolvedValue({ source: "local_implicit", companyIds: ["company-1"] });
|
|
const node = await renderSurface(buildRun());
|
|
expect(node.querySelector("[data-testid='run-workspace-recovery-surface']")).not.toBeNull();
|
|
expect(node.querySelector("[data-testid='recovery-divergence-diagnosis']")).not.toBeNull();
|
|
expect(node.querySelector("[data-testid='recovery-action-repair-trigger']")).not.toBeNull();
|
|
// Compact: metadata rows dropped.
|
|
expect(node.textContent).not.toContain("Repair the workspace.");
|
|
});
|
|
|
|
it("wires the repair confirm to reconcile in quarantine_restore mode against the pinned workspace", async () => {
|
|
issueGetMock.mockResolvedValue(buildIssue(buildRecoveryAction()));
|
|
boardAccessMock.mockResolvedValue({ source: "local_implicit", companyIds: ["company-1"] });
|
|
reconcileMock.mockResolvedValue({ id: "ws-1" });
|
|
const node = await renderSurface(buildRun());
|
|
click(node.querySelector("[data-testid='recovery-action-repair-trigger']"));
|
|
click(document.body.querySelector("[data-testid='recovery-action-repair-confirm']"));
|
|
await flush();
|
|
expect(reconcileMock).toHaveBeenCalledWith("ws-1", { mode: "quarantine_restore" });
|
|
});
|
|
});
|