import { randomUUID } from "node:crypto"; import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { restoreArchivedWorkspace, expectCommittedFile, expectCommittedFileAfterReload, } from "../support/helpers/workspace-recovery"; import { archiveWorkspaceFromSidebar } from "../support/helpers/sidebar"; import { rename } from "node:fs/promises"; import type { Page } from "@playwright/test"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { expect, test } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { expectWorkspaceBranch, openChangesPanel, switchBranchFromChangesPanel, } from "../support/helpers/branch-switcher"; import { createIdleAgent, expectSessionRowNotArchived, fetchAgentArchivedAt, openSessions, } from "../support/helpers/archive-tab"; import { archiveWorkspaceFromDaemon, archiveLocalWorkspaceFromDaemon, connectNewWorkspaceDaemonClient, createWorktreeViaDaemon, openProjectViaDaemon, } from "../support/helpers/new-workspace"; import { connectSeedClient } from "../support/helpers/seed-client"; import { getServerId } from "../support/helpers/server-id"; import { createTempGitRepo } from "../support/helpers/workspace"; import { waitForSidebarHydration, waitForWorkspaceInSidebar, } from "../support/helpers/workspace-ui"; test.describe("Worktree restore", () => { let client: Awaited>; let worktreeClient: Awaited>; let tempRepo: { path: string; cleanup: () => Promise }; const createdWorktreeDirectories = new Set(); const createdProjectIds = new Set(); test.describe.configure({ timeout: 120_000 }); test.beforeEach(async () => { client = await connectSeedClient(); worktreeClient = await connectNewWorkspaceDaemonClient(); tempRepo = await createTempGitRepo("wt-restore-"); }); async function createArchivedMissingWorktree( prefix: string, options: { agentCount?: number; keepAgentsArchived?: boolean } = {}, ) { const project = await openProjectViaDaemon(worktreeClient, tempRepo.path); createdProjectIds.add(project.projectKey); const worktree = await createWorktreeViaDaemon(worktreeClient, { cwd: tempRepo.path, slug: `${prefix}-${randomUUID().slice(0, 8)}`, }); createdProjectIds.add(worktree.projectKey); createdWorktreeDirectories.add(worktree.workspaceDirectory); const agents = await Promise.all( Array.from({ length: options.agentCount ?? 1 }, () => createIdleAgent(client, { cwd: worktree.workspaceDirectory, workspaceId: worktree.workspaceId, title: `${prefix}-${randomUUID().slice(0, 8)}`, }), ), ); const agent = agents[0]; if (!agent) { throw new Error("Expected at least one archived-worktree agent"); } await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory); await expect .poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 }) .toBe(false); // Match the remote cloud-race record: workspace archived and absent, while // the surviving closed agent record is not agent-archived. Refresh now owns // only agent lifecycle, so its expected cwd failure cannot recover the workspace. if (options.keepAgentsArchived) { for (const archivedAgent of agents) { await expect .poll(() => fetchAgentArchivedAt(client, archivedAgent.id), { timeout: 30_000 }) .not.toBeNull(); } } else { await client.refreshAgent(agent.id).catch(() => undefined); await expect .poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }) .toBeNull(); } expect(existsSync(worktree.workspaceDirectory)).toBe(false); return { agent, agents, worktree }; } async function openArchivedWorkspaceFromHistory(page: Page, prefix: string) { const seeded = await createArchivedMissingWorktree(prefix); await gotoAppShell(page); await waitForSidebarHydration(page); await openSessions(page); await expectSessionRowNotArchived(page, seeded.agent.title); await page.getByTestId(`agent-row-${getServerId()}-${seeded.agent.id}`).click(); await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({ timeout: 30_000, }); await expect(page.getByTestId("workspace-recovery-action")).toHaveText("Restore"); return seeded; } async function openArchivedAgentBeforeWorkspaceHydration(page: Page, prefix: string) { const seeded = await createArchivedMissingWorktree(prefix); const workspaceRoute = buildHostWorkspaceRoute(getServerId(), seeded.worktree.workspaceId); const openAgent = encodeURIComponent(`agent:${seeded.agent.id}`); await page.goto(`${workspaceRoute}?open=${openAgent}`); await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({ timeout: 30_000, }); await expect(page.getByTestId("workspace-recovery-action")).toHaveText("Restore"); return seeded; } test.afterEach(async () => { for (const directory of createdWorktreeDirectories) { await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined); } createdWorktreeDirectories.clear(); for (const projectId of createdProjectIds) { await worktreeClient.removeProject(projectId).catch(() => undefined); } createdProjectIds.clear(); await client?.close().catch(() => undefined); await worktreeClient?.close().catch(() => undefined); await tempRepo?.cleanup().catch(() => undefined); }); async function createWorkspaceWithCommittedChange() { const worktree = await createWorktreeViaDaemon(worktreeClient, { cwd: tempRepo.path, slug: `restore-diff-${randomUUID().slice(0, 8)}`, }); createdProjectIds.add(worktree.projectKey); createdWorktreeDirectories.add(worktree.workspaceDirectory); commitRestorableChange(worktree.workspaceDirectory); const agent = await createIdleAgent(client, { cwd: worktree.workspaceDirectory, workspaceId: worktree.workspaceId, title: "Restore committed changes", }); const route = `${buildHostWorkspaceRoute(getServerId(), worktree.workspaceId)}?open=${encodeURIComponent(`agent:${agent.id}`)}`; return { worktree, route }; } test("committed changes survive archive, restore and page reload", async ({ page }) => { const { worktree, route } = await createWorkspaceWithCommittedChange(); await openCommittedWorkspace(page, route); await archiveCommittedWorkspace(page, worktree); await restoreCommittedWorkspace(page, route); await expectCommittedFileAfterReload(page, "restored-change.txt"); await expectRestoredCommitDiff(page); }); test("opening an active History agent navigates without restoring or unarchiving", async ({ page, }) => { const serverId = getServerId(); const project = await openProjectViaDaemon(worktreeClient, tempRepo.path); createdProjectIds.add(project.projectKey); const worktree = await createWorktreeViaDaemon(worktreeClient, { cwd: tempRepo.path, slug: `restore-inplace-${randomUUID().slice(0, 8)}`, }); createdProjectIds.add(worktree.projectKey); createdWorktreeDirectories.add(worktree.workspaceDirectory); const agent = await createIdleAgent(client, { cwd: worktree.workspaceDirectory, workspaceId: worktree.workspaceId, title: `restore-inplace-${randomUUID().slice(0, 8)}`, }); expect(existsSync(worktree.workspaceDirectory)).toBe(true); expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull(); await gotoAppShell(page); await waitForSidebarHydration(page); await openSessions(page); await expectSessionRowNotArchived(page, agent.title); await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click(); await expect( page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).first(), ).toBeVisible({ timeout: 30_000 }); await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0); expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull(); expect(existsSync(worktree.workspaceDirectory)).toBe(true); await openSessions(page); await expectSessionRowNotArchived(page, agent.title); await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click(); await expect( page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).first(), ).toBeVisible({ timeout: 30_000 }); await expect( page.getByTestId(`workspace-deck-entry-${serverId}:${worktree.workspaceId}`), ).toHaveCount(1); expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull(); }); test("opening a recoverable archived workspace shows an explicit Restore action without mutating it", async ({ page, }) => { const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-ready"); expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull(); expect(existsSync(worktree.workspaceDirectory)).toBe(false); await expect( worktreeClient.inspectWorkspaceRecovery(worktree.workspaceId), ).resolves.toMatchObject({ kind: "recoverable", action: "restore" }); }); test("explicit Restore shows loading and opens the recreated workspace", async ({ page }) => { const { agent, worktree } = await openArchivedAgentBeforeWorkspaceHydration( page, "restore-success", ); await worktreeClient.fetchWorkspaces({ subscribe: {}, }); let updateTimeout: ReturnType | null = null; let unsubscribeSecondaryWorkspaceUpdate = () => {}; const secondaryWorkspaceUpdate = new Promise((resolve, reject) => { updateTimeout = setTimeout( () => reject(new Error("Secondary client did not receive the restored workspace")), 30_000, ); unsubscribeSecondaryWorkspaceUpdate = worktreeClient.on("workspace_update", (message) => { if ( message.payload.kind === "upsert" && message.payload.workspace.id === worktree.workspaceId ) { resolve(); } }); }); try { await page.getByTestId("workspace-recovery-action").click(); await expect(page.getByText("Restoring workspace", { exact: true })).toBeVisible(); await expect .poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 }) .toBe(true); await secondaryWorkspaceUpdate; await waitForWorkspaceInSidebar(page, { serverId: getServerId(), workspaceId: worktree.workspaceId, }); await expect( page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).first(), ).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId("workspace-recovery-action")).toHaveCount(0); expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull(); } finally { unsubscribeSecondaryWorkspaceUpdate(); if (updateTimeout) { clearTimeout(updateTimeout); } } const switchedBranch = `restored-live-${randomUUID().slice(0, 8)}`; execFileSync("git", ["branch", switchedBranch], { cwd: tempRepo.path, stdio: "pipe", }); await openChangesPanel(page); await expectWorkspaceBranch(page, worktree.workspaceName); await switchBranchFromChangesPanel(page, { from: worktree.workspaceName, to: switchedBranch, }); await expectWorkspaceBranch(page, switchedBranch); await expect( page.getByTestId("workspace-header-title").filter({ visible: true }).first(), ).toHaveText(switchedBranch, { timeout: 30_000 }); }); test("restores the workspace before explicitly unarchiving each selected agent", async ({ page, }) => { const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", { agentCount: 2, keepAgentsArchived: true, }); const [firstAgent, secondAgent] = agents; if (!firstAgent || !secondAgent) { throw new Error("Expected two archived agents"); } await gotoAppShell(page); await waitForSidebarHydration(page); await openSessions(page); await page.getByTestId(`agent-row-${getServerId()}-${firstAgent.id}`).click(); await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({ timeout: 30_000, }); await page.getByTestId("workspace-recovery-action").click(); await expect .poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 }) .toBe(true); await expect( page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(), ).toBeVisible({ timeout: 30_000 }); await expect( page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(), ).toHaveAttribute("aria-selected", "true"); expect(await fetchAgentArchivedAt(client, firstAgent.id)).not.toBeNull(); expect(await fetchAgentArchivedAt(client, secondAgent.id)).not.toBeNull(); await page.getByRole("button", { name: "Unarchive", exact: true }).click(); await expect .poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 }) .toBeNull(); expect(await fetchAgentArchivedAt(client, secondAgent.id)).not.toBeNull(); await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, { timeout: 30_000, }); await openSessions(page); await page.getByTestId(`agent-row-${getServerId()}-${secondAgent.id}`).click(); await expect( page.getByTestId(`workspace-tab-agent_${secondAgent.id}`).filter({ visible: true }).first(), ).toBeVisible({ timeout: 30_000 }); await expect(page.getByRole("button", { name: "Unarchive" })).toBeVisible({ timeout: 30_000, }); await page.getByRole("button", { name: "Unarchive" }).click(); await expect .poll(() => fetchAgentArchivedAt(client, secondAgent.id), { timeout: 30_000 }) .toBeNull(); }); test("restores a branchless workspace so its archived agent can be used again", async ({ page, }) => { const project = await openProjectViaDaemon(worktreeClient, tempRepo.path); createdProjectIds.add(project.projectKey); const backingWorktree = await createWorktreeViaDaemon(worktreeClient, { cwd: tempRepo.path, slug: `branchless-${randomUUID().slice(0, 8)}`, }); createdProjectIds.add(backingWorktree.projectKey); createdWorktreeDirectories.add(backingWorktree.workspaceDirectory); execFileSync("git", ["switch", "--detach", "main"], { cwd: backingWorktree.workspaceDirectory, stdio: "pipe", }); const created = await worktreeClient.createWorkspace({ source: { kind: "directory", path: backingWorktree.workspaceDirectory }, title: "Sample workspace", }); if (!created.workspace) { throw new Error(created.error ?? "Could not create the branchless workspace"); } const workspaceId = created.workspace.id; const agent = await createIdleAgent(client, { cwd: backingWorktree.workspaceDirectory, workspaceId, title: "Archived agent", }); await archiveWorkspaceFromDaemon(worktreeClient, backingWorktree.workspaceDirectory, { scope: "worktree", }); await expect .poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 }) .toBe(false); await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).not.toBeNull(); await gotoAppShell(page); await waitForSidebarHydration(page); await openSessions(page); await page.getByTestId(`agent-row-${getServerId()}-${agent.id}`).click(); await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible(); await expect( page.getByText( "Restore Sample workspace to return to its agents. A new branch will start from the saved base or the repository default.", { exact: true }, ), ).toBeVisible(); await captureBranchlessRestoreStep(page, "01-restore-workspace.png"); await page.getByRole("button", { name: "Restore", exact: true }).click(); await expect .poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 }) .toBe(true); await expect(page.getByRole("button", { name: "Unarchive", exact: true })).toBeVisible({ timeout: 30_000, }); await captureBranchlessRestoreStep(page, "02-unarchive-agent.png"); await page.getByRole("button", { name: "Unarchive", exact: true }).click(); await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).toBeNull(); await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable(); await captureBranchlessRestoreStep(page, "03-agent-ready.png"); const restoredHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: backingWorktree.workspaceDirectory, encoding: "utf8", }).trim(); const defaultHead = execFileSync("git", ["rev-parse", "main"], { cwd: tempRepo.path, encoding: "utf8", }).trim(); expect(restoredHead).toBe(defaultHead); expect( execFileSync("git", ["branch", "--show-current"], { cwd: backingWorktree.workspaceDirectory, encoding: "utf8", }).trim(), ).toMatch(/^restored\//); }); test("restore failure stays visible and permits a successful retry", async ({ page }) => { const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry"); const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`; await rename(tempRepo.path, displacedProjectPath); try { await page.getByTestId("workspace-recovery-action").click(); await expect(page.getByTestId("workspace-recovery-error")).toHaveText( "The source repository needed to restore this worktree no longer exists.", ); await expect(page.getByTestId("workspace-recovery-action")).toHaveText("Retry"); expect(existsSync(worktree.workspaceDirectory)).toBe(false); } finally { await rename(displacedProjectPath, tempRepo.path); } await page.getByTestId("workspace-recovery-action").click(); await expect .poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 }) .toBe(true); await waitForWorkspaceInSidebar(page, { serverId: getServerId(), workspaceId: worktree.workspaceId, }); await expect( page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).first(), ).toBeVisible({ timeout: 30_000 }); }); test("an unrecoverable missing workspace shows no misleading recovery action", async ({ page, }) => { const project = await openProjectViaDaemon(worktreeClient, tempRepo.path); createdProjectIds.add(project.projectKey); const agent = await createIdleAgent(client, { cwd: project.workspaceDirectory, workspaceId: project.workspaceId, title: `unrecoverable-${randomUUID().slice(0, 8)}`, }); await archiveLocalWorkspaceFromDaemon(worktreeClient, project.workspaceId); await client.refreshAgent(agent.id).catch(() => undefined); await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull(); const displacedProjectPath = `${tempRepo.path}-missing`; await rename(tempRepo.path, displacedProjectPath); try { await gotoAppShell(page); await waitForSidebarHydration(page); await openSessions(page); await expectSessionRowNotArchived(page, agent.title); await page.getByTestId(`agent-row-${getServerId()}-${agent.id}`).click(); await expect(page.getByText("Workspace unavailable", { exact: true })).toBeVisible({ timeout: 30_000, }); await expect( page.getByText( "The archived workspace directory no longer exists and cannot be recreated.", { exact: true }, ), ).toBeVisible(); await expect(page.getByTestId("workspace-recovery-action")).toHaveCount(0); } finally { await rename(displacedProjectPath, tempRepo.path); } }); }); async function captureBranchlessRestoreStep(page: Page, filename: string): Promise { const screenshotDirectory = path.join(tmpdir(), "paseo-branchless-restore-evidence"); mkdirSync(screenshotDirectory, { recursive: true }); await page.screenshot({ path: path.join(screenshotDirectory, filename), fullPage: true }); } function commitRestorableChange(cwd: string): void { writeFileSync(path.join(cwd, "restored-change.txt"), "preserved after restore\n"); execFileSync("git", ["add", "restored-change.txt"], { cwd, stdio: "pipe" }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "Preserve restored diff"], { cwd, stdio: "pipe", }); } async function openCommittedWorkspace(page: Page, route: string): Promise { await page.goto(route); await expectCommittedFile(page, "restored-change.txt"); } async function archiveCommittedWorkspace( page: Page, worktree: { workspaceId: string; workspaceDirectory: string }, ): Promise { await archiveWorkspaceFromSidebar(page, worktree.workspaceId); await expect.poll(() => existsSync(worktree.workspaceDirectory)).toBe(false); } async function restoreCommittedWorkspace(page: Page, route: string): Promise { await page.goto(route); await restoreArchivedWorkspace(page); } async function expectRestoredCommitDiff(page: Page): Promise { await page.getByRole("button", { name: /Commits/i }).click(); await page.getByText("Preserve restored diff", { exact: true }).click(); // Diff file headers have no semantic role; their accessible names identify the file and counts. await expect( page.getByTestId("commit-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"), ).toHaveAccessibleName("restored-change.txt, +1, -0"); await page.screenshot({ path: test.info().outputPath("restored-commit.png") }); }