From b3f81c0bf4cff01b5b528e0a01f77b323f428768 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 18 Jun 2026 16:46:24 +0700 Subject: [PATCH] Recreate deleted worktrees from their branch on restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Archiving a Paseo-owned worktree deletes its directory, so reopening its agent previously dead-ended even after the workspace record came back — there was no folder to return to. Restore now recreates the worktree at its original path from the kept branch (the branch is never deleted on archive), re-points the same workspace record, and clears archivedAt — preserving the workspaceId so the agent stays linked. The directory is recreated before the un-archive so the reconciler can't re-archive it for a missing directory. A deleted or already-checked-out branch surfaces a typed failure and leaves the workspace archived. Gated behind a worktreeRestore capability flag; older hosts get an explicit "update your host" state instead of a misleading failure. --- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + .../workspace/workspace-route-state-views.tsx | 27 +- .../workspace/workspace-route-state.test.ts | 13 + .../workspace/workspace-route-state.ts | 7 +- packages/app/src/stores/session-store.ts | 2 +- .../app/src/utils/navigate-to-agent/index.ts | 15 +- .../restore-archived-workspace.test.ts | 23 +- packages/protocol/src/messages.ts | 2 + .../src/server/paseo-worktree-service.test.ts | 105 +++++ packages/server/src/server/session.ts | 70 ++- .../src/server/session.workspaces.test.ts | 422 +++++++++++++++++- .../server/src/server/websocket-server.ts | 2 + 17 files changed, 670 insertions(+), 24 deletions(-) diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index fda62a4a3..f23c59b04 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -335,6 +335,7 @@ export const ar: TranslationResources = { cannotReachHost: "لا يمكن الوصول إلى{{hostName}}", hostStatus: "حالة Host:{{status}}", missing: "لم يتم العثور على Workspace", + needsHostUpgrade: "قم بتحديث مضيفك لاستعادة مساحة العمل هذه", manageHost: "إدارة المضيف", }, hoverCard: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 2e9cb0a09..fa8946b91 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -335,6 +335,7 @@ export const en = { cannotReachHost: "Cannot reach {{hostName}}", hostStatus: "Host status: {{status}}", missing: "Workspace not found", + needsHostUpgrade: "Update your host to restore this workspace", manageHost: "Manage host", }, hoverCard: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 603abd8d8..2a17dd216 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -339,6 +339,7 @@ export const es: TranslationResources = { cannotReachHost: "No se puede alcanzar{{hostName}}", hostStatus: "Estado deHost:{{status}}", missing: "Workspaceno encontrado", + needsHostUpgrade: "Actualiza tu host para restaurar este espacio de trabajo", manageHost: "Administrar host", }, hoverCard: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index c69b51d1e..fdbf7880d 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -339,6 +339,7 @@ export const fr: TranslationResources = { cannotReachHost: "Impossible d'atteindre{{hostName}}", hostStatus: "StatutHost:{{status}}", missing: "Workspaceintrouvable", + needsHostUpgrade: "Mettez à jour votre hôte pour restaurer cet espace de travail", manageHost: "Gérer l'hôte", }, hoverCard: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 40312d252..f8be82659 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -338,6 +338,7 @@ export const ru: TranslationResources = { cannotReachHost: "Невозможно связаться с{{hostName}}", hostStatus: "Статус Host:{{status}}", missing: "Workspace не найден", + needsHostUpgrade: "Обновите хост, чтобы восстановить эту рабочую область", manageHost: "Управление хостом", }, hoverCard: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index d79614a15..de1b40d75 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -335,6 +335,7 @@ export const zhCN: TranslationResources = { cannotReachHost: "无法连接 {{hostName}}", hostStatus: "Host 状态:{{status}}", missing: "Workspace 未找到", + needsHostUpgrade: "更新你的 Host 以恢复此 workspace", manageHost: "管理 Host", }, hoverCard: { diff --git a/packages/app/src/screens/workspace/workspace-route-state-views.tsx b/packages/app/src/screens/workspace/workspace-route-state-views.tsx index ba40dad12..a213e383b 100644 --- a/packages/app/src/screens/workspace/workspace-route-state-views.tsx +++ b/packages/app/src/screens/workspace/workspace-route-state-views.tsx @@ -23,6 +23,14 @@ export function renderWorkspaceRouteGate(input: { return ; case "restoring": return ; + case "needsHostUpgrade": + return ( + + ); case "unreachable": return ( ); @@ -142,13 +152,16 @@ function WorkspaceUnreachable({ ); } -function WorkspaceMissing({ +function WorkspaceEmptyState({ + titleKey, hostName, - restoreFailed, onDismiss, }: { + titleKey: + | "workspace.route.missing" + | "workspace.route.restoreFailed" + | "workspace.route.needsHostUpgrade"; hostName: string; - restoreFailed: boolean; onDismiss: () => void; }) { const { t } = useTranslation(); @@ -156,9 +169,7 @@ function WorkspaceMissing({ return ( - - {restoreFailed ? t("workspace.route.restoreFailed") : t("workspace.route.missing")} - + {t(titleKey)} {hostName} diff --git a/packages/app/src/screens/workspace/workspace-route-state.test.ts b/packages/app/src/screens/workspace/workspace-route-state.test.ts index 48d893959..34e858c4c 100644 --- a/packages/app/src/screens/workspace/workspace-route-state.test.ts +++ b/packages/app/src/screens/workspace/workspace-route-state.test.ts @@ -127,6 +127,19 @@ describe("resolveWorkspaceRouteState", () => { ).toEqual({ kind: "restoring", hostName: "Laptop" }); }); + it("returns needsHostUpgrade when the daemon lacks the restore capability", () => { + expect( + resolveWorkspaceRouteState({ + hostName: "Laptop", + connectionStatus: "online", + lastError: null, + workspace: null, + hasHydratedWorkspaces: true, + restoreStatus: "needs-host-upgrade", + }), + ).toEqual({ kind: "needsHostUpgrade", hostName: "Laptop" }); + }); + it("falls back to a restore-failed missing state once the restore times out", () => { expect( resolveWorkspaceRouteState({ diff --git a/packages/app/src/screens/workspace/workspace-route-state.ts b/packages/app/src/screens/workspace/workspace-route-state.ts index 0f689b2bd..1a9540c56 100644 --- a/packages/app/src/screens/workspace/workspace-route-state.ts +++ b/packages/app/src/screens/workspace/workspace-route-state.ts @@ -17,6 +17,7 @@ export type WorkspaceRouteState = } | { kind: "loading"; hostName: string } | { kind: "restoring"; hostName: string } + | { kind: "needsHostUpgrade"; hostName: string } | { kind: "missing"; hostName: string; restoreFailed: boolean }; export function resolveWorkspaceRouteState(input: { @@ -25,7 +26,7 @@ export function resolveWorkspaceRouteState(input: { lastError: string | null; workspace: WorkspaceDescriptor | null; hasHydratedWorkspaces: boolean; - restoreStatus: "restoring" | "failed" | null; + restoreStatus: "restoring" | "failed" | "needs-host-upgrade" | null; }): WorkspaceRouteState { if (input.workspace) { if (input.connectionStatus === "online") { @@ -45,6 +46,10 @@ export function resolveWorkspaceRouteState(input: { return { kind: "restoring", hostName: input.hostName }; } + if (input.restoreStatus === "needs-host-upgrade") { + return { kind: "needsHostUpgrade", hostName: input.hostName }; + } + if (input.hasHydratedWorkspaces) { return { kind: "missing", diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index a33b80792..5e5e00cdb 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -287,7 +287,7 @@ export interface AgentTimelineCursorState { endSeq: number; } -export type WorkspaceRestoreStatus = "restoring" | "failed"; +export type WorkspaceRestoreStatus = "restoring" | "failed" | "needs-host-upgrade"; // Per-session state export interface SessionState { diff --git a/packages/app/src/utils/navigate-to-agent/index.ts b/packages/app/src/utils/navigate-to-agent/index.ts index e2a0b9233..5a2de775c 100644 --- a/packages/app/src/utils/navigate-to-agent/index.ts +++ b/packages/app/src/utils/navigate-to-agent/index.ts @@ -7,8 +7,10 @@ import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; export type { NavigateToAgentInput } from "./resolve"; // Clears the transient restoring state if the daemon resolves refreshAgent without -// re-emitting a workspace_update (the directory-gone case), so the gate never spins forever. -const RESTORE_TIMEOUT_MS = 7000; +// re-emitting a workspace_update (the directory-gone case), so the gate never spins +// forever. Recreating a worktree can require a git fetch, so the budget is generous +// to avoid flashing a false "failed" on a capable daemon doing slow real work. +const RESTORE_TIMEOUT_MS = 30000; function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId: string): void { const snapshot = getHostRuntimeStore().getSnapshot(serverId); @@ -33,6 +35,15 @@ function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId return; } + // COMPAT(worktreeRestore): added in v0.1.98, drop the gate when floor >= v0.1.98 + // Single capability read for restore. An old daemon recreates nothing on + // refresh_agent, so a gone directory would spin then flash a misleading + // "couldn't restore". Surface an explicit "update your host" state instead. + if (session?.serverInfo?.features?.worktreeRestore !== true) { + store.setWorkspaceRestoreStatus(serverId, workspaceId, "needs-host-upgrade"); + return; + } + store.setWorkspaceRestoreStatus(serverId, workspaceId, "restoring"); // The reducer guards "failed" so a late timeout after the descriptor lands is a no-op. setTimeout( diff --git a/packages/app/src/utils/navigate-to-agent/restore-archived-workspace.test.ts b/packages/app/src/utils/navigate-to-agent/restore-archived-workspace.test.ts index 098327165..1f5a5e911 100644 --- a/packages/app/src/utils/navigate-to-agent/restore-archived-workspace.test.ts +++ b/packages/app/src/utils/navigate-to-agent/restore-archived-workspace.test.ts @@ -27,15 +27,21 @@ const SERVER_ID = "server-1"; const AGENT_ID = "agent-1"; const WORKSPACE_ID = "workspace-1"; -function status(): "restoring" | "failed" | null { +function status(): "restoring" | "failed" | "needs-host-upgrade" | null { return ( useSessionStore.getState().sessions[SERVER_ID]?.restoringWorkspaces.get(WORKSPACE_ID) ?? null ); } -function seedArchivedAgent(): void { +function seedArchivedAgent(options?: { worktreeRestore?: boolean }): void { const store = useSessionStore.getState(); store.initializeSession(SERVER_ID, null as unknown as DaemonClient); + store.updateSessionServerInfo(SERVER_ID, { + serverId: SERVER_ID, + hostname: "host", + version: "0.1.98", + features: { worktreeRestore: options?.worktreeRestore ?? true }, + } as unknown as Parameters[1]); store.setAgents(SERVER_ID, (prev) => { const next = new Map(prev); next.set(AGENT_ID, { @@ -133,10 +139,21 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => { await Promise.resolve(); expect(status()).toBe("restoring"); - await vi.advanceTimersByTimeAsync(7000); + await vi.advanceTimersByTimeAsync(30000); expect(status()).toBe("failed"); }); + it("marks the workspace needs-host-upgrade without refreshing when the daemon lacks worktreeRestore", () => { + useSessionStore.getState().clearSession(SERVER_ID); + seedArchivedAgent({ worktreeRestore: false }); + refreshAgent.mockImplementation(() => new Promise(() => {})); + + trigger(); + + expect(refreshAgent).not.toHaveBeenCalled(); + expect(status()).toBe("needs-host-upgrade"); + }); + it("is a no-op when the workspace descriptor is already present", () => { refreshAgent.mockImplementation(() => new Promise(() => {})); useSessionStore.getState().mergeWorkspaces(SERVER_ID, [ diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index b05f44746..ef21d804a 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2256,6 +2256,8 @@ export const ServerInfoStatusPayloadSchema = z checkoutRefresh: z.boolean().optional(), // COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97 workspaceMultiplicity: z.boolean().optional(), + // COMPAT(worktreeRestore): added in v0.1.98, drop the gate when floor >= v0.1.98 + worktreeRestore: z.boolean().optional(), }) .optional(), }) diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index e1ffbbd98..e7953d8a1 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -18,7 +18,9 @@ import { type CreatePaseoWorktreeDeps, } from "./paseo-worktree-service.js"; import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js"; +import { createWorktree } from "../utils/worktree.js"; import { isPlatform } from "../test-utils/platform.js"; +import { existsSync } from "node:fs"; const cleanupPaths: string[] = []; @@ -503,6 +505,109 @@ test("does not mutate registries or broadcast when core worktree creation fails" expect(deps.workspaces.size).toBe(0); }); +// Worktree restore (Unit 3): recreate a deleted Paseo-owned worktree from its +// kept branch via createWorktree's checkout-branch source. +test.skipIf(isPlatform("win32"))( + "recreates a deleted worktree on the same kept branch without creating a suffixed branch", + async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + const paseoHome = path.join(tempDir, ".paseo"); + + execFileSync("git", ["branch", "restore-me"], { cwd: repoDir, stdio: "pipe" }); + + const created = await createWorktree({ + cwd: repoDir, + worktreeSlug: "restore-me", + source: { kind: "checkout-branch", branchName: "restore-me" }, + runSetup: false, + paseoHome, + }); + expect(existsSync(created.worktreePath)).toBe(true); + + execFileSync("git", ["worktree", "remove", created.worktreePath, "--force"], { + cwd: repoDir, + stdio: "pipe", + }); + expect(existsSync(created.worktreePath)).toBe(false); + + const recreated = await createWorktree({ + cwd: repoDir, + worktreeSlug: "restore-me", + source: { kind: "checkout-branch", branchName: "restore-me" }, + runSetup: false, + paseoHome, + }); + + expect(recreated.worktreePath).toBe(created.worktreePath); + expect(existsSync(recreated.worktreePath)).toBe(true); + expect( + execFileSync("git", ["branch", "--show-current"], { + cwd: recreated.worktreePath, + stdio: "pipe", + }) + .toString() + .trim(), + ).toBe("restore-me"); + const branches = execFileSync("git", ["branch", "--list", "restore-me*"], { + cwd: repoDir, + stdio: "pipe", + }) + .toString() + .split("\n") + .map((line) => line.replace(/^[*+ ]+/, "").trim()) + .filter(Boolean); + expect(branches).toEqual(["restore-me"]); + }, +); + +test.skipIf(isPlatform("win32"))( + "rejects with UnknownBranchError when the kept branch no longer exists", + async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + + await expect( + createWorktree({ + cwd: repoDir, + worktreeSlug: "gone-branch", + source: { kind: "checkout-branch", branchName: "gone-branch" }, + runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), + }), + ).rejects.toMatchObject({ name: "UnknownBranchError" }); + }, +); + +test.skipIf(isPlatform("win32"))( + "rejects with BranchAlreadyCheckedOutError when the kept branch is checked out elsewhere", + async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + const paseoHome = path.join(tempDir, ".paseo"); + + execFileSync("git", ["branch", "busy-branch"], { cwd: repoDir, stdio: "pipe" }); + const first = await createWorktree({ + cwd: repoDir, + worktreeSlug: "busy-branch", + source: { kind: "checkout-branch", branchName: "busy-branch" }, + runSetup: false, + paseoHome, + }); + expect(existsSync(first.worktreePath)).toBe(true); + + await expect( + createWorktree({ + cwd: repoDir, + worktreeSlug: "busy-branch-again", + source: { kind: "checkout-branch", branchName: "busy-branch" }, + runSetup: false, + paseoHome, + }), + ).rejects.toMatchObject({ name: "BranchAlreadyCheckedOutError" }); + }, +); + interface TestDeps extends CreatePaseoWorktreeDeps { projectRegistry: Pick; projects: Map; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 827922dc7..3cfc49e9d 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -3,7 +3,7 @@ import { v4 as uuidv4 } from "uuid"; import { realpathSync } from "node:fs"; import type { FSWatcher } from "node:fs"; import { stat } from "node:fs/promises"; -import { resolve, sep } from "path"; +import { basename, normalize, resolve, sep } from "path"; import { homedir } from "node:os"; import { z } from "zod"; import type { ToolSet } from "ai"; @@ -271,7 +271,12 @@ import { handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage, } from "./worktree-session.js"; import { archiveByScope, type ActiveWorkspaceRef } from "./workspace-archive-service.js"; -import { toWorktreeWireError } from "./worktree-errors.js"; +import { + WorktreeRequestError, + toWorktreeRequestError, + toWorktreeWireError, +} from "./worktree-errors.js"; +import { type WorktreeConfig, createWorktree } from "../utils/worktree.js"; import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js"; const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__"; @@ -3555,7 +3560,7 @@ export class Session { requestId, requestType: msg.type, error: message, - code: "agent_refresh_failed", + code: error instanceof WorktreeRequestError ? error.code : "agent_refresh_failed", }, }); } @@ -7072,18 +7077,69 @@ export class Session { if (!record?.workspaceId) { return; } - const directoryExists = await this.filesystem.isDirectory(record.cwd).catch(() => false); - if (!directoryExists) { - return; - } const workspace = await this.workspaceRegistry.get(record.workspaceId); if (!workspace?.archivedAt) { return; } + + const directoryExists = await this.filesystem.isDirectory(record.cwd).catch(() => false); + if (!directoryExists) { + if (workspace.kind !== "worktree" || !workspace.branch) { + return; + } + // Recreate the worktree directory from its kept branch BEFORE clearing + // archivedAt — the reconciler re-archives workspaces whose directory is + // missing, so the record must point at a real directory first. + await this.recreateOwningWorktreeForRestore(workspace, workspace.branch); + } + await this.ensureWorkspaceRecordUnarchived(workspace); await this.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId]); } + private async recreateOwningWorktreeForRestore( + workspace: PersistedWorkspaceRecord, + branch: string, + ): Promise { + const project = await this.projectRegistry.get(workspace.projectId); + if (!project) { + throw new WorktreeRequestError({ + code: "unknown", + message: `Project ${workspace.projectId} not found for workspace ${workspace.workspaceId}`, + }); + } + const projectRootExists = await this.filesystem + .isDirectory(project.rootPath) + .catch(() => false); + if (!projectRootExists) { + throw new WorktreeRequestError({ + code: "unknown", + message: `Project root is missing for ${workspace.projectId}: ${project.rootPath}`, + }); + } + + let result: WorktreeConfig; + try { + result = await createWorktree({ + cwd: project.rootPath, + worktreeSlug: basename(workspace.cwd), + source: { kind: "checkout-branch", branchName: branch }, + runSetup: false, + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + }); + } catch (error) { + throw toWorktreeRequestError(error); + } + + if (normalize(result.worktreePath) !== normalize(workspace.cwd)) { + throw new WorktreeRequestError({ + code: "unknown", + message: `Recreated worktree diverged from ${workspace.cwd}: ${result.worktreePath}`, + }); + } + } + private async ensureWorkspaceRecordUnarchived( workspace: PersistedWorkspaceRecord, ): Promise { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index d93515d10..14be43d2f 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1,5 +1,13 @@ import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, expect, test, vi } from "vitest"; @@ -22,7 +30,8 @@ import type { AgentSessionConfig, AgentStreamEvent, } from "./agent/agent-sdk-types.js"; -import { createWorktree } from "../utils/worktree.js"; +import { createWorktree, UnknownBranchError } from "../utils/worktree.js"; +import { WorktreeRequestError, toWorktreeRequestError } from "./worktree-errors.js"; import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js"; import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js"; import type { GitHubService } from "../services/github-service.js"; @@ -106,6 +115,10 @@ interface SessionTestAccess { agentUpdatesSubscription: unknown; workspaceUpdatesSubscription: unknown; interruptAgentIfRunning(agentId: string): unknown; + recreateOwningWorktreeForRestore( + workspace: PersistedWorkspaceRecord, + branch: string, + ): Promise; reconcileActiveWorkspaceRecords(...args: unknown[]): Promise>; reconcileWorkspaceRecord(workspaceId: string): Promise<{ changed: boolean; @@ -4232,6 +4245,411 @@ test("refresh_agent_request leaves the owning workspace archived when its direct expect(projects.get(cwd)?.archivedAt).toBe("2026-03-10T00:00:00.000Z"); }); +test("refresh_agent_request recreates a deleted worktree directory and unarchives the same workspace", async () => { + const emitted: SessionOutboundMessage[] = []; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => { + if (isSessionOutboundMessage(message)) emitted.push(message); + }, + }); + const projects = new Map>(); + const workspaces = new Map>(); + + const cwd = path.resolve("/tmp/paseo-deleted-worktree-dir"); + session.filesystem.isDirectory = async () => false; + const workspaceId = "ws-deleted-worktree"; + const agentId = "agent-deleted-worktree"; + projects.set( + cwd, + createPersistedProjectRecord({ + projectId: cwd, + rootPath: cwd, + kind: "git", + displayName: "worktree-project", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-10T00:00:00.000Z", + archivedAt: "2026-03-10T00:00:00.000Z", + }), + ); + workspaces.set( + workspaceId, + createPersistedWorkspaceRecord({ + workspaceId, + projectId: cwd, + cwd, + kind: "worktree", + branch: "feature/keep", + displayName: "feature/keep", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-10T00:00:00.000Z", + archivedAt: "2026-03-10T00:00:00.000Z", + }), + ); + + const storedAgent: StoredAgentRecord = { + ...makeStoredAgent({ id: agentId, cwd, updatedAt: "2026-03-10T00:00:00.000Z" }), + workspaceId, + archivedAt: "2026-03-10T00:00:00.000Z", + }; + + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; + session.projectRegistry.upsert = async ( + record: ReturnType, + ) => { + projects.set(record.projectId, record); + }; + session.workspaceRegistry.get = async (lookupWorkspaceId: string) => + workspaces.get(lookupWorkspaceId) ?? null; + session.workspaceRegistry.upsert = async ( + record: ReturnType, + ) => { + workspaces.set(record.workspaceId, record); + }; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + + session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null); + session.agentStorage.upsert = async () => {}; + + const recreateCalls: string[] = []; + session.recreateOwningWorktreeForRestore = async ( + workspace: PersistedWorkspaceRecord, + ): Promise => { + recreateCalls.push(workspace.workspaceId); + }; + + const managed = makeManagedAgent({ + id: agentId, + cwd, + workspaceId, + lifecycle: "idle", + updatedAt: "2026-03-10T00:00:00.000Z", + }); + session.agentManager.getAgent = () => managed; + session.interruptAgentIfRunning = async () => undefined; + session.agentManager.reloadAgentSession = async () => managed; + session.agentManager.hydrateTimelineFromProvider = async () => undefined; + session.agentManager.getTimeline = () => []; + session.forwardAgentUpdate = async () => undefined; + + const unarchivedWorkspaceIds: string[][] = []; + const realEmit = session.emitWorkspaceUpdatesForWorkspaceIds.bind(session); + session.emitWorkspaceUpdatesForWorkspaceIds = async (ids: string[], ...rest: unknown[]) => { + unarchivedWorkspaceIds.push(ids); + return realEmit(ids, ...rest); + }; + + await session.handleMessage({ + type: "refresh_agent_request", + agentId, + requestId: "req-refresh-recreate-worktree", + }); + + expect(recreateCalls).toEqual([workspaceId]); + expect(workspaces.get(workspaceId)?.archivedAt).toBeNull(); + expect(workspaces.get(workspaceId)?.workspaceId).toBe(workspaceId); + expect(unarchivedWorkspaceIds).toContainEqual([workspaceId]); + expect(findByType(emitted, "rpc_error")).toBeUndefined(); +}); + +test("refresh_agent_request leaves the worktree archived and surfaces a typed error when recreation fails", async () => { + const emitted: SessionOutboundMessage[] = []; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => { + if (isSessionOutboundMessage(message)) emitted.push(message); + }, + }); + const projects = new Map>(); + const workspaces = new Map>(); + + const cwd = path.resolve("/tmp/paseo-deleted-worktree-fail"); + session.filesystem.isDirectory = async () => false; + const workspaceId = "ws-deleted-worktree-fail"; + const agentId = "agent-deleted-worktree-fail"; + projects.set( + cwd, + createPersistedProjectRecord({ + projectId: cwd, + rootPath: cwd, + kind: "git", + displayName: "worktree-project", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-10T00:00:00.000Z", + archivedAt: "2026-03-10T00:00:00.000Z", + }), + ); + workspaces.set( + workspaceId, + createPersistedWorkspaceRecord({ + workspaceId, + projectId: cwd, + cwd, + kind: "worktree", + branch: "feature/gone", + displayName: "feature/gone", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-10T00:00:00.000Z", + archivedAt: "2026-03-10T00:00:00.000Z", + }), + ); + + const storedAgent: StoredAgentRecord = { + ...makeStoredAgent({ id: agentId, cwd, updatedAt: "2026-03-10T00:00:00.000Z" }), + workspaceId, + archivedAt: "2026-03-10T00:00:00.000Z", + }; + + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; + session.projectRegistry.upsert = async ( + record: ReturnType, + ) => { + projects.set(record.projectId, record); + }; + session.workspaceRegistry.get = async (lookupWorkspaceId: string) => + workspaces.get(lookupWorkspaceId) ?? null; + session.workspaceRegistry.upsert = async ( + record: ReturnType, + ) => { + workspaces.set(record.workspaceId, record); + }; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + + session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null); + session.agentStorage.upsert = async () => {}; + + session.recreateOwningWorktreeForRestore = async (): Promise => { + throw toWorktreeRequestError(new UnknownBranchError({ branchName: "feature/gone", cwd })); + }; + + const managed = makeManagedAgent({ + id: agentId, + cwd, + workspaceId, + lifecycle: "idle", + updatedAt: "2026-03-10T00:00:00.000Z", + }); + session.agentManager.getAgent = () => managed; + session.interruptAgentIfRunning = async () => undefined; + session.agentManager.reloadAgentSession = async () => managed; + session.agentManager.hydrateTimelineFromProvider = async () => undefined; + session.agentManager.getTimeline = () => []; + session.forwardAgentUpdate = async () => undefined; + + await session.handleMessage({ + type: "refresh_agent_request", + agentId, + requestId: "req-refresh-recreate-fail", + }); + + expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z"); + const rpcError = findByType(emitted, "rpc_error"); + expect(rpcError).toBeDefined(); + expect((rpcError?.payload as { code?: string } | undefined)?.code).toBe("unknown_branch"); +}); + +function createRecreateWorktreeRepo(): { tempDir: string; repoDir: string } { + const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-recreate-worktree-"))); + const repoDir = path.join(tempDir, "repo"); + execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir, stdio: "pipe" }); + writeFileSync(path.join(repoDir, "README.md"), "main\n"); + execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "initial"], { cwd: repoDir, stdio: "pipe" }); + return { tempDir, repoDir }; +} + +test("refresh_agent_request recreates a real deleted worktree against a temp git repo and unarchives the same workspace", async () => { + const { tempDir, repoDir } = createRecreateWorktreeRepo(); + const branch = "feature/keep"; + execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" }); + + const worktreesRoot = path.join(tempDir, "worktrees"); + const paseoHome = path.join(tempDir, "paseo-home"); + const created = await createWorktree({ + cwd: repoDir, + worktreeSlug: "keep", + source: { kind: "checkout-branch", branchName: branch }, + runSetup: false, + paseoHome, + worktreesRoot, + }); + const worktreePath = realpathSync(created.worktreePath); + // Simulate archive: drop the worktree dir but keep the branch. + rmSync(worktreePath, { recursive: true, force: true }); + execFileSync("git", ["worktree", "prune"], { cwd: repoDir, stdio: "pipe" }); + expect(existsSync(worktreePath)).toBe(false); + + const emitted: SessionOutboundMessage[] = []; + const session = createSessionForWorkspaceTests({ + paseoHome, + worktreesRoot, + onMessage: (message) => { + if (isSessionOutboundMessage(message)) emitted.push(message); + }, + }); + // Real directory probe so the missing worktree reads as gone and the repo root as present. + session.filesystem.isDirectory = async (target: string) => + existsSync(target) && statSync(target).isDirectory(); + + const projects = new Map>(); + const workspaces = new Map>(); + const workspaceId = "ws-real-recreate"; + const agentId = "agent-real-recreate"; + const projectId = repoDir; + projects.set( + projectId, + createPersistedProjectRecord({ + projectId, + rootPath: repoDir, + kind: "git", + displayName: "worktree-project", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-10T00:00:00.000Z", + archivedAt: "2026-03-10T00:00:00.000Z", + }), + ); + workspaces.set( + workspaceId, + createPersistedWorkspaceRecord({ + workspaceId, + projectId, + cwd: worktreePath, + kind: "worktree", + branch, + displayName: branch, + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-10T00:00:00.000Z", + archivedAt: "2026-03-10T00:00:00.000Z", + }), + ); + + const storedAgent: StoredAgentRecord = { + ...makeStoredAgent({ id: agentId, cwd: worktreePath, updatedAt: "2026-03-10T00:00:00.000Z" }), + workspaceId, + archivedAt: "2026-03-10T00:00:00.000Z", + }; + + session.projectRegistry.get = async (id: string) => projects.get(id) ?? null; + session.projectRegistry.upsert = async ( + record: ReturnType, + ) => { + projects.set(record.projectId, record); + }; + session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null; + session.workspaceRegistry.upsert = async ( + record: ReturnType, + ) => { + workspaces.set(record.workspaceId, record); + }; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null); + session.agentStorage.upsert = async () => {}; + + const managed = makeManagedAgent({ + id: agentId, + cwd: worktreePath, + workspaceId, + lifecycle: "idle", + updatedAt: "2026-03-10T00:00:00.000Z", + }); + session.agentManager.getAgent = () => managed; + session.interruptAgentIfRunning = async () => undefined; + session.agentManager.reloadAgentSession = async () => managed; + session.agentManager.hydrateTimelineFromProvider = async () => undefined; + session.agentManager.getTimeline = () => []; + session.forwardAgentUpdate = async () => undefined; + + await session.handleMessage({ + type: "refresh_agent_request", + agentId, + requestId: "req-refresh-real-recreate", + }); + + expect(findByType(emitted, "rpc_error")).toBeUndefined(); + expect(existsSync(worktreePath)).toBe(true); + const headBranch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { + cwd: worktreePath, + stdio: "pipe", + }) + .toString() + .trim(); + expect(headBranch).toBe(branch); + expect(workspaces.get(workspaceId)?.workspaceId).toBe(workspaceId); + expect(workspaces.get(workspaceId)?.cwd).toBe(worktreePath); + expect(workspaces.get(workspaceId)?.archivedAt).toBeNull(); + + rmSync(tempDir, { recursive: true, force: true }); +}); + +test("recreateOwningWorktreeForRestore throws a typed WorktreeRequestError and leaves the workspace archived when the project root is missing", async () => { + const { tempDir, repoDir } = createRecreateWorktreeRepo(); + const branch = "feature/keep"; + execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" }); + + const worktreesRoot = path.join(tempDir, "worktrees"); + const paseoHome = path.join(tempDir, "paseo-home"); + const created = await createWorktree({ + cwd: repoDir, + worktreeSlug: "keep", + source: { kind: "checkout-branch", branchName: branch }, + runSetup: false, + paseoHome, + worktreesRoot, + }); + const worktreePath = realpathSync(created.worktreePath); + + const session = createSessionForWorkspaceTests({ paseoHome, worktreesRoot }); + const projects = new Map>(); + const workspaces = new Map>(); + const workspaceId = "ws-missing-root"; + const projectId = repoDir; + const missingRoot = path.join(tempDir, "does-not-exist"); + projects.set( + projectId, + createPersistedProjectRecord({ + projectId, + rootPath: missingRoot, + kind: "git", + displayName: "worktree-project", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-10T00:00:00.000Z", + archivedAt: "2026-03-10T00:00:00.000Z", + }), + ); + const workspaceRecord = createPersistedWorkspaceRecord({ + workspaceId, + projectId, + cwd: worktreePath, + kind: "worktree", + branch, + displayName: branch, + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-10T00:00:00.000Z", + archivedAt: "2026-03-10T00:00:00.000Z", + }); + workspaces.set(workspaceId, workspaceRecord); + + session.projectRegistry.get = async (id: string) => projects.get(id) ?? null; + session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null; + session.filesystem.isDirectory = async (target: string) => + existsSync(target) && statSync(target).isDirectory(); + + await expect( + session.recreateOwningWorktreeForRestore(workspaceRecord, branch), + ).rejects.toBeInstanceOf(WorktreeRequestError); + // Guard fires before createWorktree, so archivedAt is untouched. + expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z"); + + rmSync(tempDir, { recursive: true, force: true }); +}); + test.skip("open_project_request collapses a git subdirectory onto the repo root workspace", async () => { const emitted: SessionOutboundMessage[] = []; const session = createSessionForWorkspaceTests(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index be4bae891..3e5044157 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1157,6 +1157,8 @@ export class VoiceAssistantWebSocketServer { checkoutRefresh: true, // COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97 workspaceMultiplicity: true, + // COMPAT(worktreeRestore): added in v0.1.98, drop the gate when floor >= v0.1.98 + worktreeRestore: true, }, }; }