diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index 8371a0694..31d0f9f37 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -9,6 +9,7 @@ type NewWorkspaceDaemonClient = Pick< InternalDaemonClient, | "archivePaseoWorktree" | "archiveWorkspace" + | "checkoutRefresh" | "close" | "connect" | "createPaseoWorktree" diff --git a/packages/app/e2e/helpers/sidebar.ts b/packages/app/e2e/helpers/sidebar.ts index de9e9cdc2..6d6afc900 100644 --- a/packages/app/e2e/helpers/sidebar.ts +++ b/packages/app/e2e/helpers/sidebar.ts @@ -7,6 +7,19 @@ export async function selectWorkspaceInSidebar(page: Page, workspaceId: string): await row.click(); } +async function openWorkspaceSidebarKebab(page: Page, workspaceId: string) { + const serverId = getServerId(); + const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.hover(); + + const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${workspaceId}`); + await expect(kebab).toBeVisible({ timeout: 10_000 }); + await kebab.click(); + + return serverId; +} + export async function expectWorkspaceListed(page: Page, name: string): Promise { await expect( page.locator('[data-testid^="sidebar-workspace-row-"]').filter({ hasText: name }).first(), @@ -16,54 +29,22 @@ export async function expectWorkspaceListed(page: Page, name: string): Promise { + const serverId = await openWorkspaceSidebarKebab(page, workspaceId); + const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`); + await expect(archiveItem).toBeVisible({ timeout: 10_000 }); + await archiveItem.click(); +} + export async function archiveWorktreeFromSidebar(page: Page, workspaceId: string): Promise { - const serverId = getServerId(); - const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`); - await expect(row).toBeVisible({ timeout: 30_000 }); - await row.hover(); - - const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${workspaceId}`); - await expect(kebab).toBeVisible({ timeout: 10_000 }); - await kebab.click(); - // A clean worktree archives with no prompt; if the host reports unsynced work the app // raises a browser confirm. Accept it so the user-confirmed archive stays deterministic // either way. page.once("dialog", (dialog) => void dialog.accept()); - - const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`); - await expect(archiveItem).toBeVisible({ timeout: 10_000 }); - await archiveItem.click(); - - // Archiving the last reference to a worktree opens the keep/delete prompt. - // This helper deletes the worktree from disk; callers that want to keep it use - // openWorktreeDeletePrompt directly. - const deleteButton = page.getByTestId("worktree-delete-confirm-delete"); - await expect(deleteButton).toBeVisible({ timeout: 10_000 }); - await deleteButton.click(); -} - -// Opens the archive flow for a last-reference worktree and stops at the inline -// keep/delete prompt, which the caller resolves by clicking keep or delete. -export async function openWorktreeDeletePrompt(page: Page, workspaceId: string): Promise { - const serverId = getServerId(); - const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`); - await expect(row).toBeVisible({ timeout: 30_000 }); - await row.hover(); - - const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${workspaceId}`); - await expect(kebab).toBeVisible({ timeout: 10_000 }); - await kebab.click(); - - // A dirty/unsynced worktree raises a browser confirm before the prompt; accept - // it so the prompt opens deterministically either way. - page.once("dialog", (dialog) => void dialog.accept()); - - const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`); - await expect(archiveItem).toBeVisible({ timeout: 10_000 }); - await archiveItem.click(); - - await expect(page.getByTestId("worktree-delete-confirm-keep")).toBeVisible({ timeout: 10_000 }); + await clickArchiveWorkspaceMenuItem(page, workspaceId); } export async function expectWorkspaceAbsentFromSidebar( diff --git a/packages/app/e2e/worktree-archive-keep-prompt.spec.ts b/packages/app/e2e/worktree-archive-keep-prompt.spec.ts deleted file mode 100644 index 4ad13f4a5..000000000 --- a/packages/app/e2e/worktree-archive-keep-prompt.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { existsSync } from "node:fs"; -import { expect, test } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; -import { - archiveWorkspaceFromDaemon, - connectNewWorkspaceDaemonClient, - createWorktreeViaDaemon, - openProjectViaDaemon, -} from "./helpers/new-workspace"; -import { getServerId } from "./helpers/server-id"; -import { expectWorkspaceAbsentFromSidebar, openWorktreeDeletePrompt } from "./helpers/sidebar"; -import { createTempGitRepo } from "./helpers/workspace"; -import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui"; - -// Model B: archiving the LAST reference to a Paseo-owned worktree opens the -// keep/delete prompt. Choosing "Keep on disk" archives the workspace record (the -// row disappears) but leaves the worktree directory on disk, because a directory -// can back multiple workspaces and archive removes the task, not the directory. -test.describe("Worktree archive keep prompt", () => { - let client: Awaited>; - let tempRepo: { path: string; cleanup: () => Promise }; - const createdWorktreeDirectories = new Set(); - - test.describe.configure({ retries: 1, timeout: 120_000 }); - - test.beforeEach(async () => { - client = await connectNewWorkspaceDaemonClient(); - tempRepo = await createTempGitRepo("wt-archive-keep-"); - }); - - test.afterEach(async () => { - for (const directory of createdWorktreeDirectories) { - await archiveWorkspaceFromDaemon(client, directory).catch(() => undefined); - } - createdWorktreeDirectories.clear(); - await client?.close().catch(() => undefined); - await tempRepo?.cleanup().catch(() => undefined); - }); - - test("keeping a last-reference worktree on disk removes the row but preserves the directory", async ({ - page, - }) => { - const serverId = getServerId(); - await openProjectViaDaemon(client, tempRepo.path); - const worktree = await createWorktreeViaDaemon(client, { - cwd: tempRepo.path, - slug: `archive-keep-${Date.now()}`, - }); - createdWorktreeDirectories.add(worktree.workspaceDirectory); - expect(existsSync(worktree.workspaceDirectory)).toBe(true); - - await gotoAppShell(page); - await waitForSidebarHydration(page); - await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId }); - - await openWorktreeDeletePrompt(page, worktree.workspaceId); - await page.getByTestId("worktree-delete-confirm-keep").click(); - - await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId); - - // The row is gone, but keeping on disk leaves the worktree directory in place - // and the git worktree still registered with the repo. - expect(existsSync(worktree.workspaceDirectory)).toBe(true); - const listed = await client.getPaseoWorktreeList({ cwd: tempRepo.path }); - expect( - listed.worktrees.some((entry) => entry.worktreePath === worktree.workspaceDirectory), - ).toBe(true); - }); -}); diff --git a/packages/app/e2e/worktree-archive-risk-warning.spec.ts b/packages/app/e2e/worktree-archive-risk-warning.spec.ts new file mode 100644 index 000000000..fce68f548 --- /dev/null +++ b/packages/app/e2e/worktree-archive-risk-warning.spec.ts @@ -0,0 +1,140 @@ +import { execSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { Dialog, Page } from "@playwright/test"; +import { expect, test } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { + archiveWorkspaceFromDaemon, + connectNewWorkspaceDaemonClient, + createWorktreeViaDaemon, + openProjectViaDaemon, +} from "./helpers/new-workspace"; +import { getServerId } from "./helpers/server-id"; +import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar"; +import { createTempGitRepo } from "./helpers/workspace"; +import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui"; + +async function seedRiskyWorktree( + client: Awaited>, + worktreeDirectory: string, +): Promise { + // The daemon only reports unpushed commits when the branch has a configured + // upstream (aheadOfOrigin is computed against `branch..merge`). Push the + // worktree branch at its current head first so it tracks origin with 0 ahead, + // then add the local commit below that becomes the single unpushed commit. + const branch = execSync("git rev-parse --abbrev-ref HEAD", { + cwd: worktreeDirectory, + stdio: "pipe", + }) + .toString() + .trim(); + execSync(`git push -u origin ${JSON.stringify(branch)}`, { + cwd: worktreeDirectory, + stdio: "ignore", + }); + + const committedFile = path.join(worktreeDirectory, "UNPUSHED.md"); + await writeFile(committedFile, "# unpushed\n"); + execSync(`git add ${JSON.stringify(path.basename(committedFile))}`, { + cwd: worktreeDirectory, + stdio: "ignore", + }); + execSync('git commit -m "Add unpushed change"', { + cwd: worktreeDirectory, + stdio: "ignore", + }); + + const dirtyFile = path.join(worktreeDirectory, "DIRTY.md"); + await writeFile(dirtyFile, "# dirty\n"); + + const refreshed = await client.checkoutRefresh(worktreeDirectory); + if (!refreshed.success) { + throw new Error(`Failed to refresh checkout for ${worktreeDirectory}`); + } +} + +// The archive confirmation is a synchronous web `window.confirm()`. The click that +// opens it does not resolve until the dialog is answered, so the handler must +// accept/dismiss inline — awaiting the dialog only *after* the click deadlocks, as +// the click waits for an answer that is gated behind that same click. +async function clickArchiveAndAnswerWarning( + page: Page, + workspaceId: string, + answer: "accept" | "dismiss", +): Promise { + let warning: Dialog | undefined; + page.once("dialog", (dialog) => { + warning = dialog; + void (answer === "accept" ? dialog.accept() : dialog.dismiss()); + }); + await clickArchiveWorkspaceMenuItem(page, workspaceId); + if (!warning) { + throw new Error("Expected an archive confirmation dialog, but none was shown."); + } + return warning; +} + +test.describe("Worktree archive risk warning", () => { + let client: Awaited>; + let tempRepo: { path: string; cleanup: () => Promise }; + const createdWorktreeDirectories = new Set(); + + test.describe.configure({ retries: 1, timeout: 120_000 }); + + test.beforeEach(async () => { + client = await connectNewWorkspaceDaemonClient(); + tempRepo = await createTempGitRepo("wt-archive-risk-", { withRemote: true }); + }); + + test.afterEach(async () => { + for (const directory of createdWorktreeDirectories) { + await archiveWorkspaceFromDaemon(client, directory).catch(() => undefined); + } + createdWorktreeDirectories.clear(); + await client?.close().catch(() => undefined); + await tempRepo?.cleanup().catch(() => undefined); + }); + + test("a risky worktree archive is gated by confirmation and removes the directory after acceptance", async ({ + page, + }) => { + const serverId = getServerId(); + await openProjectViaDaemon(client, tempRepo.path); + const worktree = await createWorktreeViaDaemon(client, { + cwd: tempRepo.path, + slug: `archive-risk-${Date.now()}`, + }); + createdWorktreeDirectories.add(worktree.workspaceDirectory); + expect(existsSync(worktree.workspaceDirectory)).toBe(true); + + await seedRiskyWorktree(client, worktree.workspaceDirectory); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId }); + + const firstWarning = await clickArchiveAndAnswerWarning(page, worktree.workspaceId, "dismiss"); + expect(firstWarning.type()).toBe("confirm"); + expect(firstWarning.message()).toContain(`Archive "${worktree.workspaceName}"?`); + expect(firstWarning.message()).toContain("Uncommitted changes"); + expect(firstWarning.message()).toContain("1 unpushed commit"); + + await expect( + page.getByTestId(`sidebar-workspace-row-${serverId}:${worktree.workspaceId}`), + ).toBeVisible({ timeout: 10_000 }); + expect(existsSync(worktree.workspaceDirectory)).toBe(true); + + const secondWarning = await clickArchiveAndAnswerWarning(page, worktree.workspaceId, "accept"); + expect(secondWarning.message()).toContain("Uncommitted changes"); + expect(secondWarning.message()).toContain("1 unpushed commit"); + + await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId); + await expect + .poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 }) + .toBe(false); + + createdWorktreeDirectories.delete(worktree.workspaceDirectory); + }); +}); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index be70f1f7b..cd7c7203d 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -81,6 +81,7 @@ import { import { SyncedLoader } from "@/components/synced-loader"; import { useToast } from "@/contexts/toast-context"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; +import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning"; import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder"; import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration"; import { confirmDialog } from "@/utils/confirm-dialog"; @@ -114,8 +115,8 @@ import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-ar import { openExternalUrl } from "@/utils/open-external-url"; import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory"; import { archiveWorkspacesOptimistically } from "@/workspace/workspace-archive"; +import { selectProjectWorkspacesToArchive } from "@/workspace/project-workspace-archive"; import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; -import { WorktreeDeletePrompt } from "@/workspace/worktree-delete-prompt"; import { isWeb as platformIsWeb, isNative as platformIsNative, @@ -1510,7 +1511,12 @@ function WorkspaceRowWithMenu({ }, [selected, workspace]); const archiveController = useWorkspaceArchive({ - workspace, + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + workspaceDirectory: workspace.workspaceDirectory, + workspaceKind: workspace.workspaceKind, + name: workspace.name, + ...toWorktreeArchiveRisk(workspace), onArchiveStarted: redirectAfterArchive, onSetHiding: setIsHidingWorkspace, }); @@ -1519,7 +1525,7 @@ function WorkspaceRowWithMenu({ if (isArchiving) { return; } - archiveController.beginArchive(); + archiveController.archive(); }, [archiveController, isArchiving]); const handleCopyPath = useCallback(() => { @@ -1624,13 +1630,6 @@ function WorkspaceRowWithMenu({ onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined} archiveShortcutKeys={selected ? archiveShortcutKeys : null} /> - { - if (failures.length > 0) { - toast.error(t("sidebar.project.toasts.removeFailed")); - } - setIsRemovingProject(false); - return; - }); + void selectProjectWorkspacesToArchive(project.workspaces) + .then((workspaces) => archiveWorkspacesOptimistically({ client, workspaces })) + .then((failures) => { + if (failures.length > 0) { + toast.error(t("sidebar.project.toasts.removeFailed")); + } + setIsRemovingProject(false); + return; + }) + .catch((error) => { + toast.error( + error instanceof Error ? error.message : t("sidebar.project.toasts.removeFailed"), + ); + setIsRemovingProject(false); + return; + }); })(); }, [isRemovingProject, serverId, displayName, t, toast, project.workspaces]); diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index 13d1de456..28a32c655 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -41,8 +41,8 @@ import { AdaptiveRenameModal } from "@/components/rename-modal"; import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; -import { WorktreeDeletePrompt } from "@/workspace/worktree-delete-prompt"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; +import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning"; import * as Clipboard from "expo-clipboard"; import { Shortcut } from "@/components/ui/shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut"; @@ -376,14 +376,19 @@ function StatusWorkspaceRowWithMenu({ }, [selected, workspace]); const archiveController = useWorkspaceArchive({ - workspace, + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + workspaceDirectory: workspace.workspaceDirectory, + workspaceKind: workspace.workspaceKind, + name: workspace.name, + ...toWorktreeArchiveRisk(workspace), onArchiveStarted: redirectAfterArchive, onSetHiding: setIsHidingWorkspace, }); const handleArchive = useCallback(() => { if (isArchiving) return; - archiveController.beginArchive(); + archiveController.archive(); }, [archiveController, isArchiving]); const handleCopyPath = useCallback(() => { @@ -472,13 +477,6 @@ function StatusWorkspaceRowWithMenu({ onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined} archiveShortcutKeys={selected ? archiveShortcutKeys : null} /> - Promise; } @@ -492,7 +491,7 @@ export const useCheckoutGitActionsStore = create() }); }, - archiveWorktree: async ({ serverId, cwd, worktreePath, workspaceId, deleteWorktreeFromDisk }) => { + archiveWorktree: async ({ serverId, cwd, worktreePath, workspaceId }) => { await runCheckoutAction({ serverId, cwd, @@ -517,7 +516,6 @@ export const useCheckoutGitActionsStore = create() const payload = await client.archivePaseoWorktree({ worktreePath, ...(workspaceId !== undefined ? { workspaceId } : {}), - deleteWorktreeFromDisk, }); if (payload.error) { throw new Error(payload.error.message); diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index a6ffaa08d..306bb473e 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -2103,7 +2103,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane }), [], ); - const { gitActions, branchLabel, worktreeDeletePrompt } = useGitActions({ + const { gitActions, branchLabel } = useGitActions({ serverId, cwd, icons: gitActionsIcons, @@ -2164,7 +2164,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane testID="changes-branch-switcher" /> {isMobile ? : null} - {worktreeDeletePrompt} ) : null} diff --git a/packages/app/src/git/use-actions.tsx b/packages/app/src/git/use-actions.tsx index af66c94b4..bdf74b323 100644 --- a/packages/app/src/git/use-actions.tsx +++ b/packages/app/src/git/use-actions.tsx @@ -13,14 +13,14 @@ import { import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages"; import { openExternalUrl } from "@/utils/open-external-url"; import { useToast } from "@/contexts/toast-context"; -import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; -import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; -import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; +import { useSessionStore } from "@/stores/session-store"; import { - confirmRiskyWorktreeArchive, - type WorktreeArchiveWarningLabels, -} from "@/git/worktree-archive-warning"; -import { WorktreeDeletePrompt } from "@/workspace/worktree-delete-prompt"; + useActiveWorkspaceSelection, + type ActiveWorkspaceSelection, +} from "@/stores/navigation-active-workspace-store"; +import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; +import { type WorktreeArchiveWarningLabels } from "@/git/worktree-archive-warning"; +import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; export type { GitActionId, GitAction, GitActions } from "@/git/policy"; @@ -53,65 +53,6 @@ function formatBaseRefLabel(baseRef: string | undefined, fallbackLabel: string): return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed; } -// The header archive only appears for Paseo-owned worktrees. When this is the -// last active workspace referencing the worktree directory, offer to remove it -// from disk; otherwise a sibling workspace still needs the directory. -function isLastWorktreeReference(workspaces: WorkspaceDescriptor[], worktreePath: string): boolean { - let references = 0; - for (const candidate of workspaces) { - // Git-fact: counting how many workspaces still reference the worktree - // directory on disk, not attributing ownership. Same-cwd siblings keep the - // directory alive, so the disk-deletion offer must compare directories. - if (candidate.workspaceDirectory === worktreePath) { - references += 1; - } - } - return references <= 1; -} - -// Owns the inline keep/delete prompt for the last-reference worktree case so the -// archive flow stays a single decision point and `useGitActions` keeps a flat -// shape. -function useWorktreeDeletePrompt( - runArchive: (worktreePath: string, deleteWorktreeFromDisk: boolean) => void, -): { - open: (input: { worktreePath: string; workspaceName: string }) => void; - element: ReactElement; -} { - const [state, setState] = useState<{ worktreePath: string; workspaceName: string } | null>(null); - - const resolve = useCallback( - (deleteWorktreeFromDisk: boolean) => { - const prompt = state; - setState(null); - if (prompt) { - runArchive(prompt.worktreePath, deleteWorktreeFromDisk); - } - }, - [runArchive, state], - ); - - const onKeep = useCallback(() => resolve(false), [resolve]); - const onDelete = useCallback(() => resolve(true), [resolve]); - const onCancel = useCallback(() => setState(null), []); - const open = useCallback( - (input: { worktreePath: string; workspaceName: string }) => setState(input), - [], - ); - - const element = ( - - ); - - return { open, element }; -} - type PrStatusValue = NonNullable | null; interface DeriveGitActionsStateArgs { @@ -220,9 +161,59 @@ interface UseGitActionsResult { gitActions: GitActions; branchLabel: string; isGit: boolean; - // Inline keep/delete confirmation for archiving the last reference to a - // Paseo-owned worktree. Consumers must render this so the prompt is visible. - worktreeDeletePrompt: ReactElement | null; +} + +interface UseWorkspaceScreenArchiveControllerInput { + serverId: string; + activeWorkspaceSelection: ActiveWorkspaceSelection | null; + workspaceDirectory: string | null | undefined; + branchLabel: string; + gitStatus: CheckoutStatusPayload | null; + t: (key: string, options?: Record) => string; +} + +function useWorkspaceScreenArchiveController({ + serverId, + activeWorkspaceSelection, + workspaceDirectory, + branchLabel, + gitStatus, + t, +}: UseWorkspaceScreenArchiveControllerInput) { + const sessionWorkspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces); + const archiveWorkspaceRecord = useMemo(() => { + if (!workspaceDirectory) { + return null; + } + for (const candidate of sessionWorkspaces?.values() ?? []) { + if (candidate.workspaceDirectory === workspaceDirectory) { + return candidate; + } + } + return null; + }, [sessionWorkspaces, workspaceDirectory]); + + return useWorkspaceArchive({ + serverId, + workspaceId: activeWorkspaceSelection?.workspaceId ?? archiveWorkspaceRecord?.id ?? "", + workspaceDirectory, + workspaceKind: gitStatus?.isPaseoOwnedWorktree ? "worktree" : "local_checkout", + name: archiveWorkspaceRecord?.name ?? branchLabel, + isDirty: gitStatus?.isDirty, + aheadOfOrigin: gitStatus?.aheadOfOrigin, + diffStat: archiveWorkspaceRecord?.diffStat ?? null, + warningLabels: getWorktreeArchiveWarningLabels(t), + onArchiveStarted: () => { + if (!activeWorkspaceSelection) { + return; + } + redirectIfArchivingActiveWorkspace({ + serverId, + workspaceId: activeWorkspaceSelection.workspaceId, + activeWorkspaceSelection, + }); + }, + }); } export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult { @@ -364,7 +355,6 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use const runDisablePrAutoMerge = useCheckoutGitActionsStore((s) => s.disablePrAutoMerge); const runMergeBranch = useCheckoutGitActionsStore((s) => s.mergeBranch); const runMergeFromBase = useCheckoutGitActionsStore((s) => s.mergeFromBase); - const runArchiveWorktree = useCheckoutGitActionsStore((s) => s.archiveWorktree); const githubAutoMergeActionsEnabled = useSessionStore( (s) => s.sessions[serverId]?.serverInfo?.features?.checkoutGithubSetAutoMerge === true, ); @@ -533,79 +523,18 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use }); }, [baseRef, cwd, runMergeFromBase, serverId, t, toast, toastActionError, toastActionSuccess]); - const runArchiveWorktreeRecord = useCallback( - (worktreePath: string, deleteWorktreeFromDisk: boolean) => { - // These git actions only ever target the workspace the screen is showing, - // so the redirect is sourced from the active workspace selection (the - // screen's own route context), not from any cwd→workspace inference here. - if (activeWorkspaceSelection) { - redirectIfArchivingActiveWorkspace({ - serverId, - workspaceId: activeWorkspaceSelection.workspaceId, - activeWorkspaceSelection, - }); - } - // The server archive is keyed by worktreePath; the daemon owns the - // directory→workspace resolution for archive-by-path. - void runArchiveWorktree({ serverId, cwd, worktreePath, deleteWorktreeFromDisk }).catch( - (err) => { - toastActionError(err, t("workspace.git.actions.toasts.failedArchive")); - }, - ); - }, - [activeWorkspaceSelection, cwd, runArchiveWorktree, serverId, t, toastActionError], - ); - - const worktreeDeletePrompt = useWorktreeDeletePrompt(runArchiveWorktreeRecord); - - const archiveWorktreeAfterConfirmation = useCallback(async () => { - const worktreePath = status?.cwd; - if (!worktreePath) { - toast.error(t("workspace.git.actions.toasts.worktreePathUnavailable")); - return; - } - - const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces; - const workspaceList = Array.from(workspaces?.values() ?? []); - // Git-fact lookup: find the workspace backing this worktree directory to show - // its name and diff stat in the confirmation. This reads display data by - // directory, the same as the disk-deletion reference counting, and never - // derives an id used to key the archive (the archive runs by path). - const workspace = - workspaceList.find((candidate) => candidate.workspaceDirectory === worktreePath) ?? null; - const confirmed = await confirmRiskyWorktreeArchive( - { - worktreeName: workspace?.name ?? branchLabel, - isDirty: gitStatus?.isDirty, - aheadOfOrigin: gitStatus?.aheadOfOrigin, - diffStat: workspace?.diffStat ?? null, - }, - getWorktreeArchiveWarningLabels(t), - ); - if (!confirmed) { - return; - } - - if (isLastWorktreeReference(workspaceList, worktreePath)) { - worktreeDeletePrompt.open({ worktreePath, workspaceName: workspace?.name ?? branchLabel }); - return; - } - runArchiveWorktreeRecord(worktreePath, false); - }, [ - branchLabel, - gitStatus?.aheadOfOrigin, - gitStatus?.isDirty, - runArchiveWorktreeRecord, + const archiveController = useWorkspaceScreenArchiveController({ serverId, - status?.cwd, + activeWorkspaceSelection, + workspaceDirectory: status?.cwd, + branchLabel, + gitStatus, t, - toast, - worktreeDeletePrompt, - ]); + }); const handleArchiveWorktree = useCallback(() => { - void archiveWorktreeAfterConfirmation(); - }, [archiveWorktreeAfterConfirmation]); + archiveController.archive(); + }, [archiveController]); const derived = deriveGitActionsState({ isGit, @@ -811,7 +740,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use baseRef, ]); - return { gitActions, branchLabel, isGit, worktreeDeletePrompt: worktreeDeletePrompt.element }; + return { gitActions, branchLabel, isGit }; } function translateGitActions( diff --git a/packages/app/src/git/workspace-actions.tsx b/packages/app/src/git/workspace-actions.tsx index 7c142552f..e1921f9d5 100644 --- a/packages/app/src/git/workspace-actions.tsx +++ b/packages/app/src/git/workspace-actions.tsx @@ -48,7 +48,7 @@ const ICONS = { }; export function WorkspaceGitActions({ serverId, cwd, hideLabels }: WorkspaceGitActionsProps) { - const { gitActions, isGit, worktreeDeletePrompt } = useGitActions({ + const { gitActions, isGit } = useGitActions({ serverId, cwd, icons: ICONS, @@ -58,10 +58,5 @@ export function WorkspaceGitActions({ serverId, cwd, hideLabels }: WorkspaceGitA return null; } - return ( - <> - - {worktreeDeletePrompt} - - ); + return ; } diff --git a/packages/app/src/git/worktree-archive-warning.test.ts b/packages/app/src/git/worktree-archive-warning.test.ts index 2d9e6c2f5..362afade0 100644 --- a/packages/app/src/git/worktree-archive-warning.test.ts +++ b/packages/app/src/git/worktree-archive-warning.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { buildWorktreeArchiveConfirmationMessage, buildWorktreeArchiveRiskReasons, + toWorktreeArchiveRisk, } from "@/git/worktree-archive-warning"; describe("worktree archive warning", () => { @@ -57,4 +58,18 @@ describe("worktree archive warning", () => { }), ).toBe("Uncommitted changes (1 added line, 3 deleted lines)\n1 unpushed commit"); }); + + it("maps archive workspace fields into the shared worktree risk shape", () => { + expect( + toWorktreeArchiveRisk({ + archiveHasUncommittedChanges: true, + archiveUnpushedCommitCount: 3, + diffStat: { additions: 2, deletions: 1 }, + }), + ).toEqual({ + isDirty: true, + aheadOfOrigin: 3, + diffStat: { additions: 2, deletions: 1 }, + }); + }); }); diff --git a/packages/app/src/git/worktree-archive-warning.ts b/packages/app/src/git/worktree-archive-warning.ts index 691defefb..51e882eaa 100644 --- a/packages/app/src/git/worktree-archive-warning.ts +++ b/packages/app/src/git/worktree-archive-warning.ts @@ -7,6 +7,12 @@ export interface WorktreeArchiveRisk { diffStat?: { additions: number; deletions: number } | null; } +export interface WorktreeArchiveRiskInput { + archiveHasUncommittedChanges?: boolean | null; + archiveUnpushedCommitCount?: number | null; + diffStat?: WorktreeArchiveRisk["diffStat"]; +} + export interface WorktreeArchiveConfirmationInput extends WorktreeArchiveRisk { worktreeName: string; } @@ -43,6 +49,14 @@ export const DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS: WorktreeArchiveWarningLabe : i18n.t("workspace.git.actions.archiveWarning.unpushedCommits", { count }), }; +export function toWorktreeArchiveRisk(input: WorktreeArchiveRiskInput): WorktreeArchiveRisk { + return { + isDirty: input.archiveHasUncommittedChanges, + aheadOfOrigin: input.archiveUnpushedCommitCount, + diffStat: input.diffStat, + }; +} + function formatDiffStat( diffStat: WorktreeArchiveRisk["diffStat"], labels: WorktreeArchiveWarningLabels, diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 8ee055362..4d7c987c6 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -254,21 +254,6 @@ describe("translation resources", () => { expect(en.openProject.tiles.addProject.title).toBe("Add a project"); }); - it("resolves the worktree delete prompt keys used by the sidebar prompt", () => { - const strings = flattenStrings(en); - - expect(strings["sidebar.workspace.confirmations.deleteWorktreePrompt.title"]).toBe( - "Archive workspace", - ); - expect(strings["sidebar.workspace.confirmations.deleteWorktreePrompt.message"]).toBe( - "Also remove the worktree from disk?", - ); - expect(strings["sidebar.workspace.confirmations.deleteWorktreePrompt.keep"]).toBe( - "Keep on disk", - ); - expect(strings["sidebar.workspace.confirmations.deleteWorktreePrompt.delete"]).toBe("Delete"); - }); - it("includes provider selector and pairing keys for the Batch 4D migration", () => { expect(en.modelSelector.title).toBe("Select provider"); expect(en.modelSelector.favorites).toBe("Favorites"); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 7999db547..5225d3f7d 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -814,12 +814,6 @@ export const ar: TranslationResources = { 'إخفاء "{{workspaceName}}" من الشريط الجانبي؟\n\n لن يتم تغيير الملفات الموجودة على القرص.', hideConfirm: "يخفي", cancel: "يلغي", - deleteWorktreePrompt: { - title: "أرشفة مساحة العمل", - message: "هل تريد أيضًا إزالة شجرة العمل من القرص؟", - keep: "الاحتفاظ على القرص", - delete: "حذف", - }, }, rename: { title: "إعادة تسمية مساحة العمل", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 85e30d5b1..4694e66dd 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -820,12 +820,6 @@ export const en = { 'Hide "{{workspaceName}}" from the sidebar?\n\nFiles on disk will not be changed.', hideConfirm: "Hide", cancel: "Cancel", - deleteWorktreePrompt: { - title: "Archive workspace", - message: "Also remove the worktree from disk?", - keep: "Keep on disk", - delete: "Delete", - }, }, rename: { title: "Rename workspace", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index ce0672e5b..d27a5c13a 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -840,12 +840,6 @@ export const es: TranslationResources = { '¿Ocultar "{{workspaceName}}" de la barra lateral?\n\nLos archivos en el disco no se cambiarán.', hideConfirm: "Esconder", cancel: "Cancelar", - deleteWorktreePrompt: { - title: "Archivar espacio de trabajo", - message: "¿También eliminar el worktree del disco?", - keep: "Conservar en disco", - delete: "Eliminar", - }, }, rename: { title: "Cambiar nombre del espacio de trabajo", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 6966020db..bbd52f085 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -839,12 +839,6 @@ export const fr: TranslationResources = { "Masquer «{{workspaceName}}» dans la barre latérale?\n\nLes fichiers sur le disque ne seront pas modifiés.", hideConfirm: "Cacher", cancel: "Annuler", - deleteWorktreePrompt: { - title: "Archiver l'espace de travail", - message: "Supprimer aussi le worktree du disque?", - keep: "Conserver sur le disque", - delete: "Supprimer", - }, }, rename: { title: "Renommer l'espace de travail", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index e1c9b5d34..367f29755 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -832,12 +832,6 @@ export const ru: TranslationResources = { "Скрыть «{{workspaceName}}» на боковой панели?\n\n Файлы на диске не будут изменены.", hideConfirm: "Скрывать", cancel: "Отмена", - deleteWorktreePrompt: { - title: "Архивировать рабочее пространство", - message: "Также удалить рабочее дерево с диска?", - keep: "Оставить на диске", - delete: "Удалить", - }, }, rename: { title: "Переименовать рабочую область", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index cecf2d1a0..5162ace51 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -805,12 +805,6 @@ export const zhCN: TranslationResources = { hideMessage: "从侧边栏隐藏「{{workspaceName}}」?\n\n磁盘上的文件不会被更改。", hideConfirm: "隐藏", cancel: "取消", - deleteWorktreePrompt: { - title: "归档 workspace", - message: "同时从磁盘删除 worktree?", - keep: "保留在磁盘上", - delete: "删除", - }, }, rename: { title: "重命名 workspace", diff --git a/packages/app/src/workspace/project-workspace-archive.test.ts b/packages/app/src/workspace/project-workspace-archive.test.ts new file mode 100644 index 000000000..53cc41a2f --- /dev/null +++ b/packages/app/src/workspace/project-workspace-archive.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; +import { selectProjectWorkspacesToArchive } from "@/workspace/project-workspace-archive"; + +describe("selectProjectWorkspacesToArchive", () => { + it("skips archiving a dirty and unpushed worktree when the risky archive confirmation is canceled", async () => { + const confirmWorktreeArchive = vi.fn(async () => false); + + const targets = await selectProjectWorkspacesToArchive( + [ + { + serverId: "server-1", + workspaceId: "workspace-worktree", + workspaceKind: "worktree", + name: "feature/risky", + archiveHasUncommittedChanges: true, + archiveUnpushedCommitCount: 2, + diffStat: { additions: 5, deletions: 1 }, + }, + { + serverId: "server-1", + workspaceId: "workspace-checkout", + workspaceKind: "local_checkout", + name: "main", + archiveHasUncommittedChanges: null, + archiveUnpushedCommitCount: null, + diffStat: null, + }, + ], + confirmWorktreeArchive, + ); + + expect(confirmWorktreeArchive).toHaveBeenCalledOnce(); + expect(confirmWorktreeArchive).toHaveBeenCalledWith({ + worktreeName: "feature/risky", + isDirty: true, + aheadOfOrigin: 2, + diffStat: { additions: 5, deletions: 1 }, + }); + expect(targets).toEqual([ + { + serverId: "server-1", + workspaceId: "workspace-checkout", + }, + ]); + }); + + it("includes a dirty and unpushed worktree when the risky archive confirmation is accepted", async () => { + const confirmWorktreeArchive = vi.fn(async () => true); + + const targets = await selectProjectWorkspacesToArchive( + [ + { + serverId: "server-1", + workspaceId: "workspace-worktree", + workspaceKind: "worktree", + name: "feature/risky", + archiveHasUncommittedChanges: true, + archiveUnpushedCommitCount: 2, + diffStat: { additions: 5, deletions: 1 }, + }, + { + serverId: "server-1", + workspaceId: "workspace-checkout", + workspaceKind: "local_checkout", + name: "main", + archiveHasUncommittedChanges: null, + archiveUnpushedCommitCount: null, + diffStat: null, + }, + ], + confirmWorktreeArchive, + ); + + expect(confirmWorktreeArchive).toHaveBeenCalledOnce(); + expect(confirmWorktreeArchive).toHaveBeenCalledWith({ + worktreeName: "feature/risky", + isDirty: true, + aheadOfOrigin: 2, + diffStat: { additions: 5, deletions: 1 }, + }); + expect(targets).toEqual([ + { + serverId: "server-1", + workspaceId: "workspace-worktree", + }, + { + serverId: "server-1", + workspaceId: "workspace-checkout", + }, + ]); + }); +}); diff --git a/packages/app/src/workspace/project-workspace-archive.ts b/packages/app/src/workspace/project-workspace-archive.ts new file mode 100644 index 000000000..243ca69b7 --- /dev/null +++ b/packages/app/src/workspace/project-workspace-archive.ts @@ -0,0 +1,42 @@ +import { confirmRiskyWorktreeArchive, toWorktreeArchiveRisk } from "@/git/worktree-archive-warning"; +import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; +import type { WorkspaceArchiveTarget } from "@/workspace/workspace-archive"; + +export interface ProjectWorkspaceArchiveEntry extends Pick< + SidebarWorkspaceEntry, + | "serverId" + | "workspaceId" + | "workspaceKind" + | "name" + | "archiveHasUncommittedChanges" + | "archiveUnpushedCommitCount" + | "diffStat" +> {} + +type ConfirmWorktreeArchive = typeof confirmRiskyWorktreeArchive; + +export async function selectProjectWorkspacesToArchive( + workspaces: ProjectWorkspaceArchiveEntry[], + confirmWorktreeArchive: ConfirmWorktreeArchive = confirmRiskyWorktreeArchive, +): Promise { + const confirmed: WorkspaceArchiveTarget[] = []; + + for (const workspace of workspaces) { + if (workspace.workspaceKind === "worktree") { + const shouldArchive = await confirmWorktreeArchive({ + worktreeName: workspace.name, + ...toWorktreeArchiveRisk(workspace), + }); + if (!shouldArchive) { + continue; + } + } + + confirmed.push({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + }); + } + + return confirmed; +} diff --git a/packages/app/src/workspace/use-workspace-archive.ts b/packages/app/src/workspace/use-workspace-archive.ts index 8d483ea4e..e9a7262ec 100644 --- a/packages/app/src/workspace/use-workspace-archive.ts +++ b/packages/app/src/workspace/use-workspace-archive.ts @@ -1,105 +1,83 @@ -import { useCallback, useState } from "react"; +import { useCallback } from "react"; import { useTranslation } from "react-i18next"; -import { useSessionStore } from "@/stores/session-store"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useToast } from "@/contexts/toast-context"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; -import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning"; +import { + confirmRiskyWorktreeArchive, + DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS, + type WorktreeArchiveWarningLabels, +} from "@/git/worktree-archive-warning"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive"; import { requireWorkspaceDirectory } from "@/utils/workspace-directory"; -import { normalizeWorkspacePath } from "@/utils/workspace-identity"; -import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; -// A workspace is the last reference to its backing worktree when no other active -// workspace on the same host points at the same directory. A directory can back -// multiple workspaces (Model B), so a sibling reference must keep the worktree on -// disk even when this workspace is archived. Local checkouts are never worktrees, -// so they never reach the disk-deletion prompt. -export function useIsLastWorktreeReference(workspace: SidebarWorkspaceEntry): boolean { - return useSessionStore((state) => { - if (workspace.workspaceKind !== "worktree") { - return false; - } - const directory = normalizeWorkspacePath(workspace.workspaceDirectory); - if (!directory) { - return false; - } - const workspaces = state.sessions[workspace.serverId]?.workspaces; - if (!workspaces) { - return true; - } - for (const candidate of workspaces.values()) { - if (candidate.id === workspace.workspaceId) { - continue; - } - // Git-fact: comparing directories to detect a sibling worktree reference on - // disk, not attributing ownership. The disk-deletion decision is about the - // backing directory, which same-cwd siblings genuinely share. - if (normalizeWorkspacePath(candidate.workspaceDirectory) === directory) { - return false; - } - } - return true; - }); +export interface ArchiveWorkspaceInput { + serverId: string; + workspaceId: string; + workspaceDirectory: string | null | undefined; + workspaceKind: WorkspaceDescriptor["workspaceKind"]; + name: string; + isDirty?: boolean | null; + aheadOfOrigin?: number | null; + diffStat?: { additions: number; deletions: number } | null; + warningLabels?: WorktreeArchiveWarningLabels; + onArchiveStarted: () => void; + onSetHiding?: (hiding: boolean) => void; } export interface WorkspaceArchiveController { - // Begins the archive flow. For a last-reference worktree this opens the inline - // keep/delete prompt; otherwise it archives the workspace record directly. - beginArchive: () => void; - // Inline prompt state for the last-reference worktree case. - deletePromptOpen: boolean; - confirmKeepOnDisk: () => void; - confirmDeleteFromDisk: () => void; - cancelDeletePrompt: () => void; + archive: () => void; } -export function useWorkspaceArchive(input: { - workspace: SidebarWorkspaceEntry; - onArchiveStarted: () => void; - onSetHiding?: (hiding: boolean) => void; -}): WorkspaceArchiveController { - const { workspace, onArchiveStarted, onSetHiding } = input; +export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArchiveController { + const { + serverId, + workspaceId, + workspaceDirectory, + workspaceKind, + name, + isDirty, + aheadOfOrigin, + diffStat, + warningLabels = DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS, + onArchiveStarted, + onSetHiding, + } = input; const { t } = useTranslation(); const toast = useToast(); const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); - const isLastWorktreeReference = useIsLastWorktreeReference(workspace); - const [deletePromptOpen, setDeletePromptOpen] = useState(false); - const archiveWorktreeRecord = useCallback( - (deleteWorktreeFromDisk: boolean) => { - let archiveDirectory: string; - try { - archiveDirectory = requireWorkspaceDirectory({ - workspaceId: workspace.workspaceId, - workspaceDirectory: workspace.workspaceDirectory, - }); - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : t("sidebar.workspace.toasts.workspacePathUnavailable"), - ); - return; - } - onArchiveStarted(); - void archiveWorktree({ - serverId: workspace.serverId, - cwd: archiveDirectory, - worktreePath: archiveDirectory, - workspaceId: workspace.workspaceId, - deleteWorktreeFromDisk, - }).catch((error) => { - toast.error( - error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"), - ); + const archiveWorktreeRecord = useCallback(() => { + let archiveDirectory: string; + try { + archiveDirectory = requireWorkspaceDirectory({ + workspaceId, + workspaceDirectory, }); - }, - [archiveWorktree, onArchiveStarted, t, toast, workspace], - ); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : t("sidebar.workspace.toasts.workspacePathUnavailable"), + ); + return; + } + onArchiveStarted(); + void archiveWorktree({ + serverId, + cwd: archiveDirectory, + worktreePath: archiveDirectory, + workspaceId, + }).catch((error) => { + toast.error( + error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"), + ); + }); + }, [archiveWorktree, onArchiveStarted, serverId, t, toast, workspaceDirectory, workspaceId]); const archiveNonWorktreeRecord = useCallback(async () => { - const client = getHostRuntimeStore().getClient(workspace.serverId); + const client = getHostRuntimeStore().getClient(serverId); if (!client) { toast.error(t("sidebar.workspace.toasts.hostDisconnected")); return; @@ -108,7 +86,10 @@ export function useWorkspaceArchive(input: { try { await archiveWorkspaceOptimistically({ client, - workspace, + workspace: { + serverId, + workspaceId, + }, afterHide: onArchiveStarted, }); } catch (error) { @@ -118,50 +99,40 @@ export function useWorkspaceArchive(input: { } finally { onSetHiding?.(false); } - }, [onArchiveStarted, onSetHiding, t, toast, workspace]); + }, [onArchiveStarted, onSetHiding, serverId, t, toast, workspaceId]); - const beginArchive = useCallback(() => { + const archive = useCallback(() => { void (async () => { - if (workspace.workspaceKind === "worktree") { - const confirmed = await confirmRiskyWorktreeArchive({ - worktreeName: workspace.name, - isDirty: workspace.archiveHasUncommittedChanges, - aheadOfOrigin: workspace.archiveUnpushedCommitCount, - diffStat: workspace.diffStat, - }); + if (workspaceKind === "worktree") { + const confirmed = await confirmRiskyWorktreeArchive( + { + worktreeName: name, + isDirty, + aheadOfOrigin, + diffStat, + }, + warningLabels, + ); if (!confirmed) { return; } - if (isLastWorktreeReference) { - setDeletePromptOpen(true); - return; - } - archiveWorktreeRecord(false); + archiveWorktreeRecord(); return; } await archiveNonWorktreeRecord(); })(); - }, [archiveNonWorktreeRecord, archiveWorktreeRecord, isLastWorktreeReference, workspace]); - - const confirmKeepOnDisk = useCallback(() => { - setDeletePromptOpen(false); - archiveWorktreeRecord(false); - }, [archiveWorktreeRecord]); - - const confirmDeleteFromDisk = useCallback(() => { - setDeletePromptOpen(false); - archiveWorktreeRecord(true); - }, [archiveWorktreeRecord]); - - const cancelDeletePrompt = useCallback(() => { - setDeletePromptOpen(false); - }, []); + }, [ + aheadOfOrigin, + archiveNonWorktreeRecord, + archiveWorktreeRecord, + diffStat, + isDirty, + name, + warningLabels, + workspaceKind, + ]); return { - beginArchive, - deletePromptOpen, - confirmKeepOnDisk, - confirmDeleteFromDisk, - cancelDeletePrompt, + archive, }; } diff --git a/packages/app/src/workspace/worktree-delete-prompt.tsx b/packages/app/src/workspace/worktree-delete-prompt.tsx deleted file mode 100644 index 0bd591951..000000000 --- a/packages/app/src/workspace/worktree-delete-prompt.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useMemo } from "react"; -import { Text, View } from "react-native"; -import { useTranslation } from "react-i18next"; -import { StyleSheet } from "react-native-unistyles"; -import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; -import { Button } from "@/components/ui/button"; - -// Inline keep/delete confirmation shown when archiving the last reference to a -// Paseo-owned worktree. "Keep on disk" is the default, non-destructive choice; -// "Delete" removes the worktree directory from disk. -export function WorktreeDeletePrompt({ - visible, - workspaceName, - onKeep, - onDelete, - onCancel, -}: { - visible: boolean; - workspaceName: string; - onKeep: () => void; - onDelete: () => void; - onCancel: () => void; -}) { - const { t } = useTranslation(); - const header = useMemo( - () => ({ title: t("sidebar.workspace.confirmations.deleteWorktreePrompt.title") }), - [t], - ); - - return ( - - - - {t("sidebar.workspace.confirmations.deleteWorktreePrompt.message", { - workspaceName, - })} - - - - - - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - body: { - gap: theme.spacing[4], - }, - message: { - fontSize: theme.fontSize.sm, - color: theme.colors.foreground, - }, - actions: { - flexDirection: "row", - justifyContent: "flex-end", - gap: theme.spacing[2], - }, -})); diff --git a/packages/cli/src/commands/worktree/archive.test.ts b/packages/cli/src/commands/worktree/archive.test.ts new file mode 100644 index 000000000..5314be144 --- /dev/null +++ b/packages/cli/src/commands/worktree/archive.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { runArchiveCommandWithDeps } from "./archive.js"; + +function createFakeDaemonClient( + overrides: Partial< + Pick + > = {}, +): DaemonClient { + return { + getPaseoWorktreeList: async () => ({ + worktrees: [], + error: null, + requestId: "req-list", + }), + archivePaseoWorktree: async () => ({ + success: true, + removedAgents: [], + error: null, + requestId: "req-archive", + }), + close: async () => {}, + ...overrides, + } as unknown as DaemonClient; +} + +// NOTE: This file tests CLI routing/resolution only. The actual directory-removal +// outcome is covered by composition: workspace-archive-service.test.ts and +// worktree-session.test.ts prove real filesystem removal end-to-end. + +describe("runArchiveCommand", () => { + it("sends scope worktree when archiving by worktree path", async () => { + const worktreePath = "/tmp/paseo-home/worktrees/repo/feature"; + const archiveCalls: Array<{ + input: Parameters[0]; + }> = []; + const fakeClient = createFakeDaemonClient({ + getPaseoWorktreeList: async () => ({ + worktrees: [ + { + worktreePath, + branchName: "feature", + head: "abc123", + createdAt: "2026-04-12T00:00:00.000Z", + }, + ], + error: null, + requestId: "req-list", + }), + archivePaseoWorktree: async (input) => { + archiveCalls.push({ input }); + return { + success: true, + removedAgents: ["agent-1"], + error: null, + requestId: "req-archive", + }; + }, + }); + + const result = await runArchiveCommandWithDeps( + "feature", + {}, + { + connectToDaemon: async () => fakeClient, + }, + ); + + expect(archiveCalls).toHaveLength(1); + expect(archiveCalls[0]?.input.scope).toBe("worktree"); + expect(archiveCalls[0]?.input.worktreePath).toBe(worktreePath); + expect(result).toEqual({ + type: "single", + data: { + name: "feature", + status: "archived", + removedAgents: ["agent-1"], + }, + schema: expect.any(Object), + }); + }); + + it("archives by matching branch name when no directory name matches", async () => { + const worktreePath = "/tmp/paseo-home/worktrees/repo/feature-branch"; + const archiveCalls: Array<{ + input: Parameters[0]; + }> = []; + const fakeClient = createFakeDaemonClient({ + getPaseoWorktreeList: async () => ({ + worktrees: [ + { + worktreePath, + branchName: "feature-x", + head: "abc123", + createdAt: "2026-04-12T00:00:00.000Z", + }, + ], + error: null, + requestId: "req-list", + }), + archivePaseoWorktree: async (input) => { + archiveCalls.push({ input }); + return { + success: true, + removedAgents: [], + error: null, + requestId: "req-archive", + }; + }, + }); + + await runArchiveCommandWithDeps( + "feature-x", + {}, + { + connectToDaemon: async () => fakeClient, + }, + ); + + expect(archiveCalls).toHaveLength(1); + expect(archiveCalls[0]?.input.scope).toBe("worktree"); + expect(archiveCalls[0]?.input.worktreePath).toBe(worktreePath); + }); + + it("throws a CommandError when the worktree is not found", async () => { + const fakeClient = createFakeDaemonClient({ + getPaseoWorktreeList: async () => ({ + worktrees: [], + error: null, + requestId: "req-list", + }), + }); + + await expect( + runArchiveCommandWithDeps( + "missing", + {}, + { + connectToDaemon: async () => fakeClient, + }, + ), + ).rejects.toMatchObject({ + code: "WORKTREE_NOT_FOUND", + }); + }); +}); diff --git a/packages/cli/src/commands/worktree/archive.ts b/packages/cli/src/commands/worktree/archive.ts index 894c157d0..711711dce 100644 --- a/packages/cli/src/commands/worktree/archive.ts +++ b/packages/cli/src/commands/worktree/archive.ts @@ -39,6 +39,14 @@ export async function runArchiveCommand( nameArg: string, options: WorktreeArchiveOptions, _command: Command, +): Promise { + return runArchiveCommandWithDeps(nameArg, options, { connectToDaemon }); +} + +export async function runArchiveCommandWithDeps( + nameArg: string, + options: WorktreeArchiveOptions, + deps: { connectToDaemon: typeof connectToDaemon }, ): Promise { const host = getDaemonHost({ host: options.host }); @@ -54,7 +62,7 @@ export async function runArchiveCommand( let client: DaemonClient; try { - client = await connectToDaemon({ host: options.host }); + client = await deps.connectToDaemon({ host: options.host }); } catch (err) { const message = err instanceof Error ? err.message : String(err); const error: CommandError = { @@ -92,9 +100,11 @@ export async function runArchiveCommand( throw error; } - // Archive the worktree + // Archive the worktree. scope:"worktree" archives every active workspace on + // the directory and then removes the directory (Paseo-owned gated). const response = await client.archivePaseoWorktree({ worktreePath: worktree.worktreePath, + scope: "worktree", }); await client.close(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 26ce138a7..dfd17220e 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -3212,7 +3212,7 @@ export class DaemonClient { repoRoot?: string; branchName?: string; workspaceId?: string; - deleteWorktreeFromDisk?: boolean; + scope?: "workspace" | "worktree"; }, requestId?: string, ): Promise { @@ -3224,9 +3224,7 @@ export class DaemonClient { repoRoot: input.repoRoot, branchName: input.branchName, ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}), - ...(input.deleteWorktreeFromDisk !== undefined - ? { deleteWorktreeFromDisk: input.deleteWorktreeFromDisk } - : {}), + ...(input.scope !== undefined ? { scope: input.scope } : {}), }, responseType: "paseo_worktree_archive_response", timeout: 60000, diff --git a/packages/protocol/src/messages.test.ts b/packages/protocol/src/messages.test.ts index 815a85de1..cb9c564e3 100644 --- a/packages/protocol/src/messages.test.ts +++ b/packages/protocol/src/messages.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "vitest"; -import { FileExplorerRequestSchema, SessionOutboundMessageSchema } from "./messages.js"; +import { + FileExplorerRequestSchema, + PaseoWorktreeArchiveRequestSchema, + SessionOutboundMessageSchema, +} from "./messages.js"; function workspaceDescriptor(overrides: Record = {}) { return { @@ -161,3 +165,35 @@ describe("file explorer request compatibility", () => { }); }); }); + +describe("paseo worktree archive request compatibility", () => { + test("omitted scope defaults to workspace", () => { + const parsed = PaseoWorktreeArchiveRequestSchema.parse({ + type: "paseo_worktree_archive_request", + worktreePath: "/repo/app", + requestId: "req-old-scope", + }); + expect(parsed.scope).toBe("workspace"); + }); + + test("scope worktree parses", () => { + const parsed = PaseoWorktreeArchiveRequestSchema.parse({ + type: "paseo_worktree_archive_request", + worktreePath: "/repo/app", + scope: "worktree", + requestId: "req-worktree-scope", + }); + expect(parsed.scope).toBe("worktree"); + }); + + test("unknown extra field is still accepted", () => { + const parsed = PaseoWorktreeArchiveRequestSchema.parse({ + type: "paseo_worktree_archive_request", + worktreePath: "/repo/app", + requestId: "req-extra", + extraField: "ignored", + }); + expect(parsed).not.toHaveProperty("extraField"); + expect(parsed.scope).toBe("workspace"); + }); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 402dc5c97..384a3cec8 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1614,10 +1614,15 @@ export const PaseoWorktreeArchiveRequestSchema = z.object({ // present the daemon archives this exact workspace; when absent it falls back to // resolving by worktreePath, preferring the worktree-kind record on a cwd tie. workspaceId: z.string().optional(), - // COMPAT(worktreeDiskDeletion): added in v0.1.97, drop the optional gate when floor >= v0.1.97. - // When true, and the workspace is the last active reference to a Paseo-owned - // worktree, the daemon also removes the worktree directory from disk. Default - // false: archiving only removes the workspace record and leaves the directory. + // COMPAT(worktreeArchiveScope): added in v0.1.97, drop the gate when floor >= v0.1.97. + // Scope of the archive operation. "workspace" archives a single workspace record + // (today's default UI behavior). "worktree" archives every active workspace whose + // cwd resolves to the target directory, then removes the directory if it is + // Paseo-owned. Omitted/unknown values default to "workspace" for old-client safety. + scope: z.enum(["workspace", "worktree"]).optional().default("workspace"), + // COMPAT(worktreeDiskDeletion): added in v0.1.97, ignored as of v0.1.97 + // (disk removal derived from scope + last-reference + ownership); field + // retained for wire parse-compat, drop when floor >= v0.1.97. deleteWorktreeFromDisk: z.boolean().optional().default(false), requestId: z.string(), }); diff --git a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts index 2a0fe1d84..eeaced590 100644 --- a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts +++ b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts @@ -4,9 +4,10 @@ import type pino from "pino"; import type { GitHubService } from "../../services/github-service.js"; import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js"; import { + archiveByScope, type ActiveWorkspaceRef, - archivePaseoWorktree, -} from "../paseo-worktree-archive-service.js"; + resolveWorkspaceIdAtPath, +} from "../workspace-archive-service.js"; import type { CreatePaseoWorktreeWorkflowFn, CreatePaseoWorktreeWorkflowResult, @@ -203,32 +204,47 @@ export class CreateAgentLifecycleDispatch { throw new Error("Auto-created worktree is not a Paseo-owned worktree"); } - await archivePaseoWorktree( + const workspaceId = await resolveWorkspaceIdAtPath( { - paseoHome: this.dependencies.paseoHome, - worktreesRoot: this.dependencies.worktreesRoot, - github: this.dependencies.github, - workspaceGitService: this.dependencies.workspaceGitService, - agentManager: this.dependencies.agentManager, - agentStorage: this.dependencies.agentStorage, findWorkspaceIdForCwd: this.dependencies.findWorkspaceIdForCwd, listActiveWorkspaces: this.dependencies.listActiveWorkspaces, - archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord, - emitWorkspaceUpdatesForWorkspaceIds: this.dependencies.emitWorkspaceUpdatesForWorkspaceIds, - markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving, - clearWorkspaceArchiving: this.dependencies.clearWorkspaceArchiving, - killTerminalsForWorkspace: this.dependencies.killTerminalsForWorkspace, - sessionLogger: this.dependencies.logger, - }, - { - targetPath: options.worktreePath, - repoRoot: options.repoRoot ?? ownership.repoRoot ?? null, - worktreesRoot: ownership.worktreeRoot, - worktreesBaseRoot: this.dependencies.worktreesRoot, - requestId: randomUUID(), }, + options.worktreePath, ); + if (!workspaceId) { + this.dependencies.logger.warn( + { worktreePath: options.worktreePath }, + "Could not resolve workspace for auto-archive; skipping", + ); + } else { + await archiveByScope( + { + paseoHome: this.dependencies.paseoHome, + paseoWorktreesBaseRoot: this.dependencies.worktreesRoot, + github: this.dependencies.github, + workspaceGitService: this.dependencies.workspaceGitService, + agentManager: this.dependencies.agentManager, + agentStorage: this.dependencies.agentStorage, + findWorkspaceIdForCwd: this.dependencies.findWorkspaceIdForCwd, + listActiveWorkspaces: this.dependencies.listActiveWorkspaces, + archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord, + emitWorkspaceUpdatesForWorkspaceIds: + this.dependencies.emitWorkspaceUpdatesForWorkspaceIds, + markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving, + clearWorkspaceArchiving: this.dependencies.clearWorkspaceArchiving, + killTerminalsForWorkspace: this.dependencies.killTerminalsForWorkspace, + sessionLogger: this.dependencies.logger, + }, + { + scope: { kind: "workspace", workspaceId }, + repoRoot: options.repoRoot ?? ownership.repoRoot ?? null, + paseoWorktreesBaseRoot: this.dependencies.worktreesRoot, + requestId: randomUUID(), + }, + ); + } + if (options.agentId) { this.dependencies.emitAgentRemove(options.agentId); } diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index ee831db99..b368b30b2 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -503,6 +503,23 @@ function createStoredSchedule(input: CreateScheduleInput): StoredSchedule { }; } +function createArchiveWorkspaceRecordMutator( + activeWorkspaces: Array<{ + workspaceId: string; + cwd: string; + kind: "worktree" | "local_checkout" | "directory"; + }>, + archivedWorkspaceIds: string[], +) { + return async (workspaceId: string) => { + archivedWorkspaceIds.push(workspaceId); + const index = activeWorkspaces.findIndex((workspace) => workspace.workspaceId === workspaceId); + if (index !== -1) { + activeWorkspaces.splice(index, 1); + } + }; +} + function createPaseoWorktreeForMcpTest(options: { paseoHome: string; broadcasts: string[]; @@ -1439,6 +1456,7 @@ describe("create_agent MCP tool", () => { const emitWorkspaceUpdatesForWorkspaceIds = vi.fn(async () => undefined); const markWorkspaceArchiving = vi.fn(); const clearWorkspaceArchiving = vi.fn(); + const listActiveWorkspaces = vi.fn(async () => []); const server = await createAgentMcpServer({ agentManager, agentStorage, @@ -1450,7 +1468,7 @@ describe("create_agent MCP tool", () => { "getSnapshot" | "listWorktrees" | "resolveRepoRoot" >, findWorkspaceIdForCwd: vi.fn(async () => "ws-archive-tool-worktree"), - listActiveWorkspaces: vi.fn(async () => []), + listActiveWorkspaces, archiveWorkspaceRecord, emitWorkspaceUpdatesForWorkspaceIds, markWorkspaceArchiving, @@ -1464,6 +1482,13 @@ describe("create_agent MCP tool", () => { cwd: repoDir, target: { mode: "branch-off", newBranch: "archive-tool-worktree", base: "main" }, }); + const createdWorktreePath = z.string().parse(created.structuredContent.worktreePath); + listActiveWorkspaces.mockImplementation(async () => [ + { workspaceId: "ws-archive-tool-worktree", cwd: createdWorktreePath, kind: "worktree" }, + ]); + archiveWorkspaceRecord.mockImplementation(async () => { + listActiveWorkspaces.mockResolvedValueOnce([]); + }); workspaceGitService.getSnapshot.mockClear(); await archiveTool.handler({ @@ -1494,6 +1519,93 @@ describe("create_agent MCP tool", () => { } }); + it("archives every workspace on a directory and removes the directory", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const tempDir = realpathSync.native( + await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-multi-")), + ); + const repoDir = join(tempDir, "repo"); + const paseoHome = join(tempDir, ".paseo"); + + try { + execFileSync("git", ["init", repoDir], { stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["config", "commit.gpgsign", "false"], { + cwd: repoDir, + stdio: "pipe", + }); + await writeFile(join(repoDir, "README.md"), "hello\n"); + execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); + + const workspaceGitService = { + getSnapshot: vi.fn(async () => null), + listWorktrees: vi.fn(async () => []), + resolveRepoRoot: vi.fn(async () => repoDir), + }; + const archivedWorkspaceIds: string[] = []; + let activeWorkspaces: Array<{ + workspaceId: string; + cwd: string; + kind: "worktree" | "local_checkout" | "directory"; + }> = []; + const listActiveWorkspaces = vi.fn(async () => activeWorkspaces); + const archiveWorkspaceRecord = createArchiveWorkspaceRecordMutator( + activeWorkspaces, + archivedWorkspaceIds, + ); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + paseoHome, + createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts: [] }), + workspaceGitService: workspaceGitService as unknown as Pick< + WorkspaceGitService, + "getSnapshot" | "listWorktrees" | "resolveRepoRoot" + >, + findWorkspaceIdForCwd: vi.fn(async () => "ws-mcp-A"), + listActiveWorkspaces, + archiveWorkspaceRecord, + emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => undefined), + markWorkspaceArchiving: vi.fn(), + clearWorkspaceArchiving: vi.fn(), + github: createGitHubServiceStub(), + logger, + }); + const createTool = registeredTool(server, "create_worktree"); + const archiveTool = registeredTool(server, "archive_worktree"); + const created = await createTool.handler({ + cwd: repoDir, + target: { mode: "branch-off", newBranch: "archive-multi-worktree", base: "main" }, + }); + const worktreePath = z.string().parse(created.structuredContent.worktreePath); + + // Populate the active workspaces with the real created path so archiveByScope + // matches it against the worktree directory. + activeWorkspaces = [ + { workspaceId: "ws-mcp-A", cwd: worktreePath, kind: "worktree" as const }, + { workspaceId: "ws-mcp-B", cwd: worktreePath, kind: "worktree" as const }, + ]; + + await archiveTool.handler({ + cwd: repoDir, + worktreePath, + }); + + expect(archivedWorkspaceIds).toContain("ws-mcp-A"); + expect(archivedWorkspaceIds).toContain("ws-mcp-B"); + await expect(access(worktreePath)).rejects.toThrow(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + it("archives a worktree by slug", async () => { const { agentManager, agentStorage } = createTestDeps(); const tempDir = realpathSync.native( diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 976082698..bf3e5ad59 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -31,8 +31,8 @@ import { ensureAgentLoaded } from "./agent-loading.js"; import { isStoredAgentProviderAvailable } from "../persistence-hooks.js"; import { killTerminalsForWorkspace, - type ArchivePaseoWorktreeDependencies, -} from "../paseo-worktree-archive-service.js"; + type ArchiveDependencies, +} from "../workspace-archive-service.js"; import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; import { createAgentCommand } from "./create-agent/create.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js"; @@ -74,8 +74,8 @@ import type { GitHubService } from "../../services/github-service.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; import { WorktreeRequestError } from "../worktree-errors.js"; import { - archivePaseoWorktreeCommand, - type ArchivePaseoWorktreeCommandDependencies, + archiveCommand, + type ArchiveCommandDependencies, createPaseoWorktreeCommand, type CreatePaseoWorktreeCommandInput, listPaseoWorktreesCommand, @@ -93,12 +93,12 @@ export interface AgentMcpServerOptions { WorkspaceGitService, "getSnapshot" | "listWorktrees" | "resolveRepoRoot" >; - findWorkspaceIdForCwd?: ArchivePaseoWorktreeDependencies["findWorkspaceIdForCwd"]; - listActiveWorkspaces?: ArchivePaseoWorktreeDependencies["listActiveWorkspaces"]; - archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"]; - emitWorkspaceUpdatesForWorkspaceIds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForWorkspaceIds"]; - markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"]; - clearWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["clearWorkspaceArchiving"]; + findWorkspaceIdForCwd?: ArchiveDependencies["findWorkspaceIdForCwd"]; + listActiveWorkspaces?: ArchiveDependencies["listActiveWorkspaces"]; + archiveWorkspaceRecord?: ArchiveDependencies["archiveWorkspaceRecord"]; + emitWorkspaceUpdatesForWorkspaceIds?: ArchiveDependencies["emitWorkspaceUpdatesForWorkspaceIds"]; + markWorkspaceArchiving?: ArchiveDependencies["markWorkspaceArchiving"]; + clearWorkspaceArchiving?: ArchiveDependencies["clearWorkspaceArchiving"]; createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn; // Mints a fresh workspace for a cwd and returns its id, used when an agent is // created with no parent and no worktree. @@ -2236,7 +2236,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom } const repoRoot = await options.workspaceGitService.resolveRepoRoot(resolvedCwd); - const result = await archivePaseoWorktreeCommand( + const result = await archiveCommand( archiveWorktreeDependencies(options, { agentManager, agentStorage, @@ -2248,9 +2248,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom repoRoot, worktreePath, worktreeSlug, - // This tool's contract is to delete the worktree; on-disk removal still - // only happens when no sibling workspace references the directory. - deleteWorktreeFromDisk: true, + // This tool archives every workspace on the directory, then removes the + // directory. Disk removal is derived from scope + last-reference. + scope: "worktree", }, ); if (!result.ok) { @@ -2430,7 +2430,7 @@ interface ArchiveWorktreeCommandContext { function archiveWorktreeDependencies( options: AgentMcpServerOptions, context: ArchiveWorktreeCommandContext, -): ArchivePaseoWorktreeCommandDependencies { +): ArchiveCommandDependencies { if (!options.github) { throw new Error("GitHub service is required to archive worktrees"); } @@ -2457,7 +2457,7 @@ function archiveWorktreeDependencies( } return { paseoHome: options.paseoHome, - worktreesRoot: options.worktreesRoot, + paseoWorktreesBaseRoot: options.worktreesRoot, github: options.github, workspaceGitService: options.workspaceGitService, agentManager: context.agentManager, diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts index 60dcc0f76..dffbea068 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts @@ -1,4 +1,9 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import type { Logger } from "pino"; +import pino from "pino"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { @@ -6,7 +11,11 @@ import { type ArchiveIfSafeDependencies, type AutoArchiveArchiveOptions, } from "./archive-if-safe.js"; +import type { ArchiveResult, ActiveWorkspaceRef } from "../workspace-archive-service.js"; import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js"; +import { createWorktree, type WorktreeConfig } from "../../utils/worktree.js"; +import type { GitHubService } from "../../../services/github-service.js"; +import type { StoredAgentRecord } from "../agent/agent-storage.js"; const CWD = "/tmp/paseo/worktrees/repo/branch"; const PASEO_HOME = "/tmp/paseo"; @@ -72,7 +81,8 @@ function createHarness(overrides?: { autoArchiveAfterMerge?: boolean; getSnapshot?: () => Promise; isPaseoOwnedWorktreeCwd?: ArchiveIfSafeDependencies["isPaseoOwnedWorktreeCwd"]; - archivePaseoWorktree?: ArchiveIfSafeDependencies["archivePaseoWorktree"]; + archiveByScope?: ArchiveIfSafeDependencies["archiveByScope"]; + resolveWorkspaceIdAtPath?: ArchiveIfSafeDependencies["resolveWorkspaceIdAtPath"]; }) { const getConfig = vi.fn(() => ({ autoArchiveAfterMerge: overrides?.autoArchiveAfterMerge ?? true, @@ -100,9 +110,18 @@ function createHarness(overrides?: { clearWorkspaceArchiving: vi.fn(), emitWorkspaceUpdatesForWorkspaceIds: vi.fn(), }; - const archivePaseoWorktree = vi.fn( - overrides?.archivePaseoWorktree ?? (async () => undefined), - ) as unknown as ArchiveIfSafeDependencies["archivePaseoWorktree"]; + const archiveByScope = vi.fn( + overrides?.archiveByScope ?? + (async () => + ({ + archivedAgentIds: [], + archivedWorkspaceIds: [], + removedDirectory: false, + }) satisfies ArchiveResult), + ) as unknown as ArchiveIfSafeDependencies["archiveByScope"]; + const resolveWorkspaceIdAtPath = vi.fn( + overrides?.resolveWorkspaceIdAtPath ?? (async () => "ws-auto-archive"), + ) as unknown as ArchiveIfSafeDependencies["resolveWorkspaceIdAtPath"]; const isPaseoOwnedWorktreeCwd = vi.fn( overrides?.isPaseoOwnedWorktreeCwd ?? (async () => ({ @@ -113,7 +132,8 @@ function createHarness(overrides?: { })), ) as unknown as ArchiveIfSafeDependencies["isPaseoOwnedWorktreeCwd"]; const deps: ArchiveIfSafeDependencies = { - archivePaseoWorktree, + archiveByScope, + resolveWorkspaceIdAtPath, isPaseoOwnedWorktreeCwd, killTerminalsForWorkspace: vi.fn(), }; @@ -150,6 +170,164 @@ async function runArchiveIfSafe( }); } +const cleanupPaths: string[] = []; + +function createGitRepo(): { tempDir: string; repoDir: string } { + const tempDir = mkdtempSync(path.join(tmpdir(), "archive-if-safe-")); + cleanupPaths.push(tempDir); + const repoDir = path.join(tempDir, "repo"); + mkdirSync(repoDir, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: 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", ["-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "initial"], { + cwd: repoDir, + stdio: "pipe", + }); + return { tempDir, repoDir }; +} + +async function createPaseoOwnedWorktree( + repoDir: string, + paseoHome: string, + worktreeSlug: string, +): Promise { + return createWorktree({ + cwd: repoDir, + worktreeSlug, + source: { + kind: "branch-off", + baseBranch: "main", + branchName: worktreeSlug, + }, + runSetup: false, + paseoHome, + }); +} + +function createGitHubServiceStub(): GitHubService { + return { + listPullRequests: async () => [], + listIssues: async () => [], + searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }), + getPullRequest: async ({ number }) => ({ + number, + title: `PR ${number}`, + url: `https://github.com/acme/repo/pull/${number}`, + state: "OPEN", + body: null, + baseRefName: "main", + headRefName: `pr-${number}`, + labels: [], + }), + getPullRequestHeadRef: async ({ number }) => `pr-${number}`, + getCurrentPullRequestStatus: async () => null, + createPullRequest: async () => ({ + number: 1, + url: "https://github.com/acme/repo/pull/1", + }), + mergePullRequest: async () => ({ success: true }), + isAuthenticated: async () => true, + invalidate: () => {}, + }; +} + +function createRealOutcomeHarness(input: { + paseoHome: string; + repoDir: string; + worktreePath: string; + activeWorkspaces: ActiveWorkspaceRef[]; + archivedWorkspaceIds: Set; +}) { + const active = [...input.activeWorkspaces]; + const logger = pino({ level: "silent" }); + vi.spyOn(logger, "info").mockImplementation(() => undefined); + vi.spyOn(logger, "warn").mockImplementation(() => undefined); + vi.spyOn(logger, "error").mockImplementation(() => undefined); + + const options: AutoArchiveArchiveOptions = { + paseoHome: input.paseoHome, + daemonConfigStore: { + get: () => ({ autoArchiveAfterMerge: true }), + } as unknown as AutoArchiveArchiveOptions["daemonConfigStore"], + workspaceGitService: { + getSnapshot: async () => + ({ + cwd: input.worktreePath, + git: { + isGit: true, + repoRoot: input.repoDir, + mainRepoRoot: input.repoDir, + currentBranch: "feature", + remoteUrl: "https://github.com/acme/repo.git", + isPaseoOwnedWorktree: true, + isDirty: false, + baseRef: "main", + aheadBehind: { ahead: 0, behind: 0 }, + aheadOfOrigin: 0, + behindOfOrigin: 0, + hasRemote: true, + diffStat: { additions: 0, deletions: 0 }, + }, + github: { + featuresEnabled: true, + pullRequest: createPullRequest({ isMerged: true }), + error: null, + }, + }) satisfies WorkspaceGitRuntimeSnapshot, + } as unknown as AutoArchiveArchiveOptions["workspaceGitService"], + github: createGitHubServiceStub(), + agentManager: { + listAgents: () => [], + archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })), + archiveSnapshot: vi.fn(async () => { + throw new Error("not expected without stored agents"); + }), + } as unknown as AutoArchiveArchiveOptions["agentManager"], + agentStorage: { + list: async (): Promise => [], + } as unknown as AutoArchiveArchiveOptions["agentStorage"], + terminalManager: { + listDirectories: () => [], + getTerminals: vi.fn().mockResolvedValue([]), + } as unknown as AutoArchiveArchiveOptions["terminalManager"], + findWorkspaceIdForCwd: async (cwd: string) => { + const match = active.find((workspace) => workspace.cwd === cwd); + return match?.workspaceId ?? null; + }, + listActiveWorkspaces: async () => + active.filter((workspace) => !input.archivedWorkspaceIds.has(workspace.workspaceId)), + archiveWorkspaceRecord: async (workspaceId: string) => { + input.archivedWorkspaceIds.add(workspaceId); + const index = active.findIndex((workspace) => workspace.workspaceId === workspaceId); + if (index !== -1) { + active.splice(index, 1); + } + }, + markWorkspaceArchiving: () => {}, + clearWorkspaceArchiving: () => {}, + emitWorkspaceUpdatesForWorkspaceIds: vi.fn(), + }; + + return { + options, + log: logger, + inFlight: new Set(), + }; +} + +afterEach(() => { + for (const target of cleanupPaths.splice(0)) { + rmSync(target, { recursive: true, force: true }); + } +}); + describe("archiveIfSafe", () => { beforeEach(() => { vi.clearAllMocks(); @@ -162,7 +340,7 @@ describe("archiveIfSafe", () => { expect(harness.getConfig).not.toHaveBeenCalled(); expect(harness.getSnapshot).not.toHaveBeenCalled(); - expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled(); + expect(harness.deps.archiveByScope).not.toHaveBeenCalled(); }); test("does nothing when auto-archive-after-merge is disabled", async () => { @@ -172,7 +350,7 @@ describe("archiveIfSafe", () => { expect(harness.getConfig).toHaveBeenCalledTimes(1); expect(harness.getSnapshot).not.toHaveBeenCalled(); - expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled(); + expect(harness.deps.archiveByScope).not.toHaveBeenCalled(); }); test("does nothing when the cwd already has an archive in flight", async () => { @@ -182,7 +360,7 @@ describe("archiveIfSafe", () => { await runArchiveIfSafe(harness); expect(harness.getSnapshot).not.toHaveBeenCalled(); - expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled(); + expect(harness.deps.archiveByScope).not.toHaveBeenCalled(); expect(harness.inFlight.has(CWD)).toBe(true); }); @@ -199,7 +377,7 @@ describe("archiveIfSafe", () => { { err: expect.any(Error), cwd: CWD }, "Failed to read snapshot for auto-archive; skipping", ); - expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled(); + expect(harness.deps.archiveByScope).not.toHaveBeenCalled(); expect(harness.inFlight.has(CWD)).toBe(false); }); @@ -209,7 +387,7 @@ describe("archiveIfSafe", () => { await runArchiveIfSafe(harness); expect(harness.deps.isPaseoOwnedWorktreeCwd).not.toHaveBeenCalled(); - expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled(); + expect(harness.deps.archiveByScope).not.toHaveBeenCalled(); }); test("does nothing when the worktree is dirty", async () => { @@ -220,7 +398,7 @@ describe("archiveIfSafe", () => { await runArchiveIfSafe(harness); expect(harness.deps.isPaseoOwnedWorktreeCwd).not.toHaveBeenCalled(); - expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled(); + expect(harness.deps.archiveByScope).not.toHaveBeenCalled(); }); test("does nothing when the worktree is ahead of origin", async () => { @@ -231,7 +409,7 @@ describe("archiveIfSafe", () => { await runArchiveIfSafe(harness); expect(harness.deps.isPaseoOwnedWorktreeCwd).not.toHaveBeenCalled(); - expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled(); + expect(harness.deps.archiveByScope).not.toHaveBeenCalled(); }); test("archives when the PR is merged and the upstream branch was deleted", async () => { @@ -242,7 +420,7 @@ describe("archiveIfSafe", () => { await runArchiveIfSafe(harness); - expect(harness.deps.archivePaseoWorktree).toHaveBeenCalledTimes(1); + expect(harness.deps.archiveByScope).toHaveBeenCalledTimes(1); }); test("does nothing when the cwd is not a Paseo-owned worktree", async () => { @@ -255,12 +433,12 @@ describe("archiveIfSafe", () => { expect(harness.deps.isPaseoOwnedWorktreeCwd).toHaveBeenCalledWith(CWD, { paseoHome: PASEO_HOME, }); - expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled(); + expect(harness.deps.archiveByScope).not.toHaveBeenCalled(); }); test("logs and does not throw when archiving fails", async () => { const harness = createHarness({ - archivePaseoWorktree: async () => { + archiveByScope: async () => { throw new Error("archive failed"); }, }); @@ -279,20 +457,24 @@ describe("archiveIfSafe", () => { await runArchiveIfSafe(harness); - expect(harness.deps.archivePaseoWorktree).toHaveBeenCalledTimes(1); - expect(harness.deps.archivePaseoWorktree).toHaveBeenCalledWith( + expect(harness.deps.resolveWorkspaceIdAtPath).toHaveBeenCalledTimes(1); + expect(harness.deps.resolveWorkspaceIdAtPath).toHaveBeenCalledWith( + { + findWorkspaceIdForCwd: harness.options.findWorkspaceIdForCwd, + listActiveWorkspaces: harness.options.listActiveWorkspaces, + }, + CWD, + ); + expect(harness.deps.archiveByScope).toHaveBeenCalledTimes(1); + expect(harness.deps.archiveByScope).toHaveBeenCalledWith( expect.objectContaining({ paseoHome: PASEO_HOME, workspaceGitService: harness.options.workspaceGitService, }), { - targetPath: CWD, + scope: { kind: "workspace", workspaceId: "ws-auto-archive" }, repoRoot: "/tmp/repo", - worktreesRoot: WORKTREES_ROOT, - // A merged worktree is the last reference to its directory; remove it from - // disk so merged worktrees do not accumulate. Sibling protection still - // happens inside the service (last-reference + ownership gated). - deleteWorktreeFromDisk: true, + paseoWorktreesBaseRoot: undefined, requestId: "auto-archive-on-merge", }, ); @@ -302,4 +484,84 @@ describe("archiveIfSafe", () => { ); expect(harness.inFlight.has(CWD)).toBe(false); }); + + test("resolves the merged cwd to a single workspace and does not iterate siblings", async () => { + const harness = createHarness({ + resolveWorkspaceIdAtPath: async () => "ws-merged-worktree", + }); + harness.options.listActiveWorkspaces = vi.fn(async () => [ + { workspaceId: "ws-merged-worktree", cwd: CWD, kind: "worktree" as const }, + { workspaceId: "ws-sibling", cwd: CWD, kind: "local_checkout" as const }, + ]); + + await runArchiveIfSafe(harness); + + expect(harness.deps.resolveWorkspaceIdAtPath).toHaveBeenCalledTimes(1); + expect(harness.deps.archiveByScope).toHaveBeenCalledTimes(1); + expect(harness.deps.archiveByScope).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + scope: { kind: "workspace", workspaceId: "ws-merged-worktree" }, + }), + ); + }); + + test("real outcome: keeps sibling workspace and directory on last reference", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "merged-with-sibling"); + const workspaceA = "ws-merged-with-sibling-a"; + const workspaceB = "ws-merged-with-sibling-b"; + const archivedWorkspaceIds = new Set(); + + const harness = createRealOutcomeHarness({ + paseoHome, + repoDir, + worktreePath: worktree.worktreePath, + activeWorkspaces: [ + { workspaceId: workspaceA, cwd: worktree.worktreePath, kind: "worktree" }, + { workspaceId: workspaceB, cwd: worktree.worktreePath, kind: "local_checkout" }, + ], + archivedWorkspaceIds, + }); + + await archiveIfSafe({ + cwd: worktree.worktreePath, + pullRequest: createPullRequest({ isMerged: true }), + inFlight: harness.inFlight, + options: harness.options, + log: harness.log, + }); + + expect(archivedWorkspaceIds.has(workspaceA)).toBe(true); + expect(archivedWorkspaceIds.has(workspaceB)).toBe(false); + expect(existsSync(worktree.worktreePath)).toBe(true); + }); + + test("real outcome: removes directory when no sibling workspace remains", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "merged-last-ref"); + const workspaceA = "ws-merged-last-ref"; + const archivedWorkspaceIds = new Set(); + + const harness = createRealOutcomeHarness({ + paseoHome, + repoDir, + worktreePath: worktree.worktreePath, + activeWorkspaces: [{ workspaceId: workspaceA, cwd: worktree.worktreePath, kind: "worktree" }], + archivedWorkspaceIds, + }); + + await archiveIfSafe({ + cwd: worktree.worktreePath, + pullRequest: createPullRequest({ isMerged: true }), + inFlight: harness.inFlight, + options: harness.options, + log: harness.log, + }); + + expect(archivedWorkspaceIds.has(workspaceA)).toBe(true); + expect(existsSync(worktree.worktreePath)).toBe(false); + }); }); diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts index 5a762b81d..a7aaadf6c 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts @@ -4,10 +4,11 @@ import type { AgentManager } from "../agent/agent-manager.js"; import type { AgentStorage } from "../agent/agent-storage.js"; import type { DaemonConfigStore } from "../daemon-config-store.js"; import { + archiveByScope, type ActiveWorkspaceRef, - archivePaseoWorktree, killTerminalsForWorkspace, -} from "../paseo-worktree-archive-service.js"; + resolveWorkspaceIdAtPath, +} from "../workspace-archive-service.js"; import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitServiceImpl, @@ -18,7 +19,7 @@ import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js"; export interface AutoArchiveArchiveOptions { paseoHome: string; - worktreesRoot?: string; + paseoWorktreesBaseRoot?: string; daemonConfigStore: DaemonConfigStore; workspaceGitService: WorkspaceGitServiceImpl; github: GitHubService; @@ -34,13 +35,15 @@ export interface AutoArchiveArchiveOptions { } export interface ArchiveIfSafeDependencies { - archivePaseoWorktree: typeof archivePaseoWorktree; + archiveByScope: typeof archiveByScope; + resolveWorkspaceIdAtPath: typeof resolveWorkspaceIdAtPath; isPaseoOwnedWorktreeCwd: typeof isPaseoOwnedWorktreeCwd; killTerminalsForWorkspace: typeof killTerminalsForWorkspace; } const defaultDependencies: ArchiveIfSafeDependencies = { - archivePaseoWorktree, + archiveByScope, + resolveWorkspaceIdAtPath, isPaseoOwnedWorktreeCwd, killTerminalsForWorkspace, }; @@ -90,17 +93,29 @@ export async function archiveIfSafe(input: { const ownership = await deps.isPaseoOwnedWorktreeCwd(cwd, { paseoHome: options.paseoHome, - worktreesRoot: options.worktreesRoot, + worktreesRoot: options.paseoWorktreesBaseRoot, }); if (!ownership.allowed) { return; } try { - await deps.archivePaseoWorktree( + const workspaceId = await deps.resolveWorkspaceIdAtPath( + { + findWorkspaceIdForCwd: options.findWorkspaceIdForCwd, + listActiveWorkspaces: options.listActiveWorkspaces, + }, + cwd, + ); + if (!workspaceId) { + log.warn({ cwd }, "Auto-archive could not resolve a workspace for cwd; skipping"); + return; + } + + await deps.archiveByScope( { paseoHome: options.paseoHome, - worktreesRoot: options.worktreesRoot, + paseoWorktreesBaseRoot: options.paseoWorktreesBaseRoot, github: options.github, workspaceGitService: options.workspaceGitService, agentManager: options.agentManager, @@ -111,25 +126,20 @@ export async function archiveIfSafe(input: { emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds, markWorkspaceArchiving: options.markWorkspaceArchiving, clearWorkspaceArchiving: options.clearWorkspaceArchiving, - killTerminalsForWorkspace: (workspaceId) => + killTerminalsForWorkspace: (workspaceIdToKill) => deps.killTerminalsForWorkspace( { terminalManager: options.terminalManager, sessionLogger: log, }, - workspaceId, + workspaceIdToKill, ), sessionLogger: log, }, { - targetPath: cwd, + scope: { kind: "workspace", workspaceId }, repoRoot: ownership.repoRoot ?? null, - worktreesRoot: ownership.worktreeRoot, - worktreesBaseRoot: options.worktreesRoot, - // Last-reference + Paseo-ownership gated inside the service, so sibling - // workspaces sharing the directory stay protected. Removing the merged - // worktree off disk prevents merged worktrees from accumulating. - deleteWorktreeFromDisk: true, + paseoWorktreesBaseRoot: options.paseoWorktreesBaseRoot, requestId: "auto-archive-on-merge", }, ); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 4ff3f97db..674f346a7 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -114,9 +114,11 @@ import { ScheduleService } from "./schedule/service.js"; import { DaemonConfigStore } from "./daemon-config-store.js"; import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; import { resolveWorkspaceIdForPath } from "./resolve-workspace-id-for-path.js"; -import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js"; +import { + archivePersistedWorkspaceRecord, + type ActiveWorkspaceRef, +} from "./workspace-archive-service.js"; import { setupAutoArchiveOnMerge } from "./auto-archive-on-merge/index.js"; -import type { ActiveWorkspaceRef } from "./paseo-worktree-archive-service.js"; import { wrapSessionMessage, type SessionOutboundMessage } from "./messages.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import { createConfiguredTerminalManager } from "../terminal/terminal-manager-factory.js"; @@ -799,7 +801,7 @@ export async function createPaseoDaemon( setupAutoArchiveOnMerge({ paseoHome: config.paseoHome, - worktreesRoot: config.worktreesRoot, + paseoWorktreesBaseRoot: config.worktreesRoot, daemonConfigStore, workspaceGitService, github, diff --git a/packages/server/src/server/paseo-worktree-archive-service.ts b/packages/server/src/server/paseo-worktree-archive-service.ts deleted file mode 100644 index ed5f43cc8..000000000 --- a/packages/server/src/server/paseo-worktree-archive-service.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { resolve } from "node:path"; - -import type { Logger } from "pino"; - -import type { AgentManager } from "./agent/agent-manager.js"; -import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js"; -import type { WorkspaceGitService } from "./workspace-git-service.js"; -import type { GitHubService } from "../services/github-service.js"; -import { - deletePaseoWorktree, - isPaseoOwnedWorktreeCwd, - resolvePaseoWorktreeRootForCwd, - WorktreeTeardownError, -} from "../utils/worktree.js"; -import type { TerminalManager } from "../terminal/terminal-manager.js"; - -export interface ActiveWorkspaceRef { - workspaceId: string; - cwd: string; - kind?: "local_checkout" | "worktree" | "directory"; -} - -export interface ArchivePaseoWorktreeDependencies { - paseoHome?: string; - worktreesRoot?: string; - github: GitHubService; - workspaceGitService: Pick; - agentManager: Pick; - agentStorage: Pick; - // Resolves the worktree at a path to its workspaceId for archive-by-path. The - // path uniquely identifies a worktree workspace; this is a directory lookup for - // the archive target, not status/ownership. - findWorkspaceIdForCwd: (cwd: string) => Promise; - // Active (non-archived) workspaces, used to decide whether the workspace being - // archived is the last reference to its backing worktree directory, and to - // break a same-cwd tie in favor of the worktree-kind record when archiving by - // path (no explicit workspaceId). - listActiveWorkspaces: () => Promise; - archiveWorkspaceRecord: (workspaceId: string) => Promise; - emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable) => Promise; - markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => void; - clearWorkspaceArchiving: (workspaceIds: Iterable) => void; - killTerminalsForWorkspace: (workspaceId: string) => Promise; - sessionLogger?: Logger; -} - -export interface KillTerminalsForWorkspaceDependencies { - detachTerminalStream?: (terminalId: string, options: { emitExit: boolean }) => void; - sessionLogger: Logger; - terminalManager: TerminalManager | null; -} - -// Archiving is scoped to a single workspace RECORD (by workspaceId), not to a -// directory. A directory can back multiple workspaces (Model B), so cwd-scoped -// teardown would destroy a sibling workspace's agents and terminals. We tear -// down only the agents and terminals owned by the target workspaceId. -// -// On-disk worktree removal is opt-in (deleteWorktreeFromDisk) and only happens -// when this workspace is the LAST active reference to a Paseo-owned worktree -// directory. If a sibling workspace still references the directory, it is kept. -// Local checkouts are never deleted. -export async function archivePaseoWorktree( - dependencies: ArchivePaseoWorktreeDependencies, - options: { - targetPath: string; - repoRoot: string | null; - worktreesRoot?: string; - worktreesBaseRoot?: string; - workspaceId?: string; - deleteWorktreeFromDisk?: boolean; - requestId: string; - }, -): Promise { - let targetPath = options.targetPath; - const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, { - paseoHome: dependencies.paseoHome, - worktreesRoot: options.worktreesBaseRoot ?? dependencies.worktreesRoot, - }); - if (resolvedWorktree) { - targetPath = resolvedWorktree.worktreePath; - } - - // A directory can back multiple workspaces (Model B), so resolving the target - // by cwd alone picks an arbitrary record. Prefer the explicit workspaceId the - // caller supplied; otherwise resolve by path, breaking a same-cwd tie toward - // the worktree-kind record. - const targetWorkspaceId = - options.workspaceId ?? (await resolveTargetWorkspaceId(dependencies, targetPath)); - if (!targetWorkspaceId) { - dependencies.sessionLogger?.warn( - { targetPath }, - "Skipping workspace archive for unregistered directory", - ); - return []; - } - - const affectedWorkspaceIdList = [targetWorkspaceId]; - dependencies.markWorkspaceArchiving(affectedWorkspaceIdList, new Date().toISOString()); - - let archivedAgents = new Set(); - - try { - await dependencies.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIdList); - - archivedAgents = await archiveWorkspaceContents(dependencies, targetWorkspaceId); - - if (options.repoRoot) { - try { - await dependencies.workspaceGitService.getSnapshot(options.repoRoot, { - force: true, - reason: "archive-worktree", - }); - } catch (error) { - dependencies.sessionLogger?.warn( - { err: error, cwd: options.repoRoot }, - "Failed to force-refresh workspace git snapshot after archiving worktree", - ); - } - } - - dependencies.github.invalidate({ cwd: targetPath }); - - try { - await dependencies.archiveWorkspaceRecord(targetWorkspaceId); - } catch (error) { - dependencies.sessionLogger?.warn( - { err: error, workspaceId: targetWorkspaceId }, - "Failed to archive workspace record", - ); - } - - if (options.deleteWorktreeFromDisk) { - await deleteWorktreeFromDiskIfLastReference(dependencies, { - targetPath, - targetWorkspaceId, - repoRoot: options.repoRoot, - worktreesRoot: options.worktreesRoot, - worktreesBaseRoot: options.worktreesBaseRoot, - }); - } - } finally { - dependencies.clearWorkspaceArchiving(affectedWorkspaceIdList); - await dependencies.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIdList); - } - - return Array.from(archivedAgents); -} - -// Resolves the workspace record to archive when no explicit workspaceId was -// supplied. When several active workspaces share the exact target cwd, prefer -// the worktree-kind record so archiving-by-path tears down the worktree rather -// than an arbitrary sibling. Falls back to the path-based resolver otherwise. -async function resolveTargetWorkspaceId( - dependencies: Pick< - ArchivePaseoWorktreeDependencies, - "findWorkspaceIdForCwd" | "listActiveWorkspaces" - >, - targetPath: string, -): Promise { - const targetDir = resolve(targetPath); - const exactMatches = (await dependencies.listActiveWorkspaces()).filter( - (workspace) => resolve(workspace.cwd) === targetDir, - ); - const worktreeMatch = exactMatches.find((workspace) => workspace.kind === "worktree"); - if (worktreeMatch) { - return worktreeMatch.workspaceId; - } - return dependencies.findWorkspaceIdForCwd(targetPath); -} - -export type ArchiveWorkspaceContentsDependencies = Pick< - ArchivePaseoWorktreeDependencies, - "agentManager" | "agentStorage" | "killTerminalsForWorkspace" | "sessionLogger" ->; - -// Tears down everything OWNED by a single workspace record: its live agents, -// its persisted-but-not-running agent snapshots, and its terminals. Scoped by -// workspaceId so a sibling workspace sharing the same directory is untouched. -// Returns the set of archived agent ids. -export async function archiveWorkspaceContents( - dependencies: ArchiveWorkspaceContentsDependencies, - workspaceId: string, -): Promise> { - const archivedAgents = new Set(); - - const liveAgents = dependencies.agentManager - .listAgents() - .filter((agent) => agent.workspaceId === workspaceId); - for (const agent of liveAgents) { - archivedAgents.add(agent.id); - } - - let storedRecords: StoredAgentRecord[] = []; - try { - storedRecords = await dependencies.agentStorage.list(); - } catch (error) { - dependencies.sessionLogger?.warn( - { err: error, workspaceId }, - "Failed to list stored agents during workspace archive; continuing", - ); - } - const liveAgentIds = new Set(liveAgents.map((agent) => agent.id)); - const matchingStoredRecords = storedRecords.filter( - (record) => record.workspaceId === workspaceId, - ); - for (const record of matchingStoredRecords) { - archivedAgents.add(record.id); - } - - const archivedAt = new Date().toISOString(); - const archiveResults = await Promise.allSettled([ - ...liveAgents.map((agent) => dependencies.agentManager.archiveAgent(agent.id)), - ...matchingStoredRecords - .filter((record) => !liveAgentIds.has(record.id) && !record.archivedAt) - .map((record) => dependencies.agentManager.archiveSnapshot(record.id, archivedAt)), - dependencies.killTerminalsForWorkspace(workspaceId), - ]); - - for (const result of archiveResults) { - if (result.status === "rejected") { - dependencies.sessionLogger?.warn( - { err: result.reason, workspaceId }, - "Workspace archive teardown step failed; continuing", - ); - } - } - - return archivedAgents; -} - -// Removes the worktree directory from disk, but only when the just-archived -// workspace was the last active reference to a Paseo-owned worktree. A directory -// can back multiple workspaces (Model B), so a sibling still referencing it must -// keep the directory. Local checkouts are never Paseo-owned and so never deleted. -async function deleteWorktreeFromDiskIfLastReference( - dependencies: Pick< - ArchivePaseoWorktreeDependencies, - "paseoHome" | "worktreesRoot" | "listActiveWorkspaces" | "github" | "sessionLogger" - >, - options: { - targetPath: string; - targetWorkspaceId: string; - repoRoot: string | null; - worktreesRoot?: string; - worktreesBaseRoot?: string; - }, -): Promise { - const ownership = await isPaseoOwnedWorktreeCwd(options.targetPath, { - paseoHome: dependencies.paseoHome, - worktreesRoot: options.worktreesBaseRoot ?? dependencies.worktreesRoot, - }); - if (!ownership.allowed) { - return; - } - - const targetDir = resolve(options.targetPath); - const activeWorkspaces = await dependencies.listActiveWorkspaces(); - const siblingStillReferences = activeWorkspaces.some( - (workspace) => - workspace.workspaceId !== options.targetWorkspaceId && resolve(workspace.cwd) === targetDir, - ); - if (siblingStillReferences) { - return; - } - - try { - await deletePaseoWorktree({ - cwd: options.repoRoot, - worktreePath: options.targetPath, - worktreesRoot: options.worktreesRoot, - paseoHome: dependencies.paseoHome, - worktreesBaseRoot: options.worktreesBaseRoot ?? dependencies.worktreesRoot, - }); - dependencies.github.invalidate({ cwd: options.targetPath }); - } catch (error) { - if (error instanceof WorktreeTeardownError) { - dependencies.sessionLogger?.warn( - { err: error, targetPath: options.targetPath }, - "Worktree disk removal failed during archive; workspace already archived", - ); - return; - } - throw error; - } -} - -export async function killTerminalsForWorkspace( - dependencies: KillTerminalsForWorkspaceDependencies, - workspaceId: string, -): Promise { - const terminalManager = dependencies.terminalManager; - if (!terminalManager) { - return; - } - - const terminalIds: string[] = []; - const terminalLists = await Promise.all( - terminalManager.listDirectories().map(async (terminalCwd) => { - try { - return await terminalManager.getTerminals(terminalCwd, { workspaceId }); - } catch (error) { - dependencies.sessionLogger.warn( - { err: error, cwd: terminalCwd }, - "Failed to enumerate workspace terminals during archive", - ); - return []; - } - }), - ); - for (const terminals of terminalLists) { - for (const terminal of terminals) { - if (terminal.workspaceId === workspaceId) { - terminalIds.push(terminal.id); - } - } - } - - if (terminalIds.length === 0) { - return; - } - - await Promise.allSettled( - terminalIds.map(async (terminalId) => { - try { - dependencies.detachTerminalStream?.(terminalId, { emitExit: true }); - await terminalManager.killTerminalAndWait(terminalId, { - gracefulTimeoutMs: 2000, - forceTimeoutMs: 1500, - }); - } catch (error) { - dependencies.sessionLogger.warn( - { err: error, terminalId }, - "Terminal kill escalation failed during archive; proceeding anyway", - ); - } - }), - ); -} diff --git a/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts index 391936228..c76827e95 100644 --- a/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts +++ b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, expect, test } from "vitest"; @@ -136,10 +136,10 @@ test("create_agent_request creates a worktree and auto-archives both after the f await ctx.client.waitForFinish(created.id, 10000); - // Archiving removes the task and its agents, but never deletes the worktree - // from disk — a directory can back multiple workspaces. + // Auto-archive is asynchronous after the agent turns complete; poll until the + // last-reference worktree directory is gone. await expectAgentAbsentFromActiveList(created.id); - await expectWorktreePresentInList(repoDir, created.cwd); + await expect.poll(() => existsSync(created.cwd), { timeout: 10000, interval: 100 }).toBe(false); }, 30000); test("create_agent_request with autoArchive archives only the agent when no worktree was created", async () => { @@ -206,14 +206,36 @@ test("create_agent_request with worktree but no autoArchive leaves agent and wor await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath }); }); -test("archiving a created worktree archives its agents but leaves the worktree on disk", async () => { +test("archiving a created worktree removes the directory on last reference", async () => { const created = await createAgentInBranchOffWorktree(); await ctx.client.waitForFinish(created.agentId, 10000); await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath }); + await expectAgentAbsentFromActiveList(created.agentId); + await expectWorktreeListEmpty(created.repoDir); + expect(existsSync(created.worktreePath)).toBe(false); +}); + +test("auto-archiving a created worktree keeps the directory when a sibling workspace references it", async () => { + const created = await createAgentInBranchOffWorktree({ autoArchive: true }); + + // Create a sibling workspace that shares the same backing directory. + const sibling = await ctx.client.createWorkspace({ + source: { kind: "directory", path: created.worktreePath }, + title: "sibling", + }); + if (!sibling.workspace) { + throw new Error(sibling.error ?? "Failed to create sibling workspace"); + } + + await ctx.client.waitForFinish(created.agentId, 10000); + await expectAgentAbsentFromActiveList(created.agentId); await expectWorktreePresentInList(created.repoDir, created.worktreePath); + expect(existsSync(created.worktreePath)).toBe(true); + + await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath }); }); test("create_agent_request rejects legacy git options before creating a worktree", async () => { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 7c18b8de0..651c57efe 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -267,10 +267,7 @@ import { handlePaseoWorktreeListRequest as handleWorktreeListRequest, handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage, } from "./worktree-session.js"; -import { - type ActiveWorkspaceRef, - archiveWorkspaceContents, -} from "./paseo-worktree-archive-service.js"; +import { archiveByScope, type ActiveWorkspaceRef } from "./workspace-archive-service.js"; import { toWorktreeWireError } from "./worktree-errors.js"; import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js"; @@ -6018,7 +6015,7 @@ export class Session { return handleWorktreeArchiveRequest( { paseoHome: this.paseoHome, - worktreesRoot: this.worktreesRoot, + paseoWorktreesBaseRoot: this.worktreesRoot, github: this.github, workspaceGitService: this.workspaceGitService, agentManager: this.agentManager, @@ -8037,31 +8034,42 @@ export class Session { if (!existing) { throw new Error(`Workspace not found: ${request.workspaceId}`); } - if (existing.kind === "worktree") { - throw new Error("Use worktree archive for Paseo worktrees"); - } - const archivedAt = new Date().toISOString(); - // Archive removes the task (the workspace record) and everything the - // workspace owns — its agents and terminals — scoped by workspaceId so a - // sibling workspace sharing the same directory is untouched. The directory - // itself is never deleted here; local checkouts are not Paseo-owned. - this.markWorkspaceArchiving([existing.workspaceId], archivedAt); - try { - await archiveWorkspaceContents( - { - agentManager: this.agentManager, - agentStorage: this.agentStorage, - killTerminalsForWorkspace: (workspaceId) => - this.terminalController.killTerminalsForWorkspace(workspaceId), - sessionLogger: this.sessionLogger, - }, - existing.workspaceId, - ); - await this.archiveWorkspaceRecord(existing.workspaceId, archivedAt); - } finally { - this.clearWorkspaceArchiving([existing.workspaceId]); - } - await this.emitWorkspaceUpdateForWorkspaceId(existing.workspaceId); + + const gitSnapshot = await this.workspaceGitService + .getSnapshot(existing.cwd) + .catch(() => null); + const repoRoot = gitSnapshot?.git?.repoRoot ?? null; + + await archiveByScope( + { + paseoHome: this.paseoHome, + paseoWorktreesBaseRoot: this.worktreesRoot, + github: this.github, + workspaceGitService: this.workspaceGitService, + agentManager: this.agentManager, + agentStorage: this.agentStorage, + findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd), + listActiveWorkspaces: () => this.listActiveWorkspaceRefs(), + archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), + emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) => + this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds), + markWorkspaceArchiving: (workspaceIds, archivingAt) => + this.markWorkspaceArchiving(workspaceIds, archivingAt), + clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds), + killTerminalsForWorkspace: (workspaceId) => + this.terminalController.killTerminalsForWorkspace(workspaceId), + sessionLogger: this.sessionLogger, + }, + { + scope: { kind: "workspace", workspaceId: existing.workspaceId }, + repoRoot, + paseoWorktreesBaseRoot: this.worktreesRoot, + requestId: request.requestId, + }, + ); + + const archivedWorkspace = await this.workspaceRegistry.get(request.workspaceId); + const archivedAt = archivedWorkspace?.archivedAt ?? new Date().toISOString(); this.emit({ type: "archive_workspace_response", payload: { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 72c77a389..73916b6ee 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, expect, test, vi } from "vitest"; @@ -22,6 +22,7 @@ import type { AgentSessionConfig, AgentStreamEvent, } from "./agent/agent-sdk-types.js"; +import { createWorktree } from "../utils/worktree.js"; import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js"; import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js"; import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js"; @@ -4032,6 +4033,111 @@ test("archive_workspace_request hides non-destructive workspace records", async expect(response?.payload.error).toBeNull(); }); +test("archive_workspace_request archives a worktree-kind workspace and removes the directory on last reference", async () => { + const tempDir = mkdtempSync(path.join(tmpdir(), "session-worktree-kind-archive-")); + const repoDir = path.join(tempDir, "repo"); + mkdirSync(repoDir, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: 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", ["-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "initial"], { + cwd: repoDir, + stdio: "pipe", + }); + + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createWorktree({ + cwd: repoDir, + worktreeSlug: "worktree-kind-archive", + source: { + kind: "branch-off", + baseBranch: "main", + branchName: "worktree-kind-archive", + }, + runSetup: false, + paseoHome, + }); + + const workspaceId = "ws-worktree-kind-archive"; + const projectId = "proj-worktree-kind-archive"; + const workspace = createPersistedWorkspaceRecord({ + workspaceId, + projectId, + cwd: worktree.worktreePath, + kind: "worktree", + displayName: "worktree-kind-archive", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const project = createPersistedProjectRecord({ + projectId, + rootPath: repoDir, + kind: "git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + + const emitted: SessionOutboundMessage[] = []; + const session = createSessionForWorkspaceTests({ + workspaceGitService: createNoopWorkspaceGitService({ + getSnapshot: async (): Promise => ({ + cwd: worktree.worktreePath, + git: { + isGit: true, + repoRoot: repoDir, + mainRepoRoot: repoDir, + currentBranch: "worktree-kind-archive", + remoteUrl: null, + isPaseoOwnedWorktree: true, + isDirty: false, + baseRef: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + hasRemote: false, + diffStat: null, + }, + github: { + featuresEnabled: false, + pullRequest: null, + error: null, + }, + }), + }), + }); + session.paseoHome = paseoHome; + session.emit = (message) => { + if (isSessionOutboundMessage(message)) emitted.push(message); + }; + session.workspaceRegistry.get = async () => workspace; + session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.archive = async (_id: string, archivedAt: string) => { + workspace.archivedAt = archivedAt; + }; + session.projectRegistry.list = async () => [project]; + + try { + await session.handleMessage({ + type: "archive_workspace_request", + workspaceId, + requestId: "req-worktree-kind-archive", + }); + + expect(workspace.archivedAt).toBeTruthy(); + expect(existsSync(worktree.worktreePath)).toBe(false); + const response = emitted.find((message) => message.type === "archive_workspace_response") as + | { payload: Record } + | undefined; + expect(response?.payload.error).toBeNull(); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + test.skip("opening a new worktree reconciles older local workspaces into the remote project", async () => { const emitted: SessionOutboundMessage[] = []; const session = createSessionForWorkspaceTests(); diff --git a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts index 4d20d5144..50cd9d20e 100644 --- a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts +++ b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts @@ -10,8 +10,9 @@ import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/in // Model B archive is scoped to a single workspace RECORD (by workspaceId), not // to a directory on disk. A directory can back multiple workspaces, so archiving // one must never tear down a sibling's agents/terminals, and must never delete a -// directory another workspace still references. On-disk worktree removal is an -// explicit, last-reference-only opt-in (deleteWorktreeFromDisk). +// directory another workspace still references. On-disk worktree removal is +// derived from scope + last-reference + Paseo ownership; there is no caller- +// supplied disk flag. let ctx: DaemonTestContext; const tempRoots: string[] = []; @@ -160,7 +161,7 @@ test("archiving one of two workspaces sharing a cwd spares the sibling and the d await ctx.client.killTerminal(terminalBId); }, 60000); -test("archiving the last reference to a worktree honors deleteWorktreeFromDisk", async () => { +test("archiving the last reference to a worktree removes it from disk regardless of the disk flag", async () => { const repoDir = createGitRepo(); const keepResult = await ctx.client.createWorkspace({ @@ -173,7 +174,7 @@ test("archiving the last reference to a worktree honors deleteWorktreeFromDisk", const keepDir = keepWorkspace.workspaceDirectory; expect(existsSync(keepDir)).toBe(true); - // Last reference, deleteWorktreeFromDisk omitted (defaults false) → dir stays. + // Last reference, deleteWorktreeFromDisk omitted (defaults ignored) → dir removed. const keepArchive = await ctx.client.archivePaseoWorktree({ worktreePath: keepDir }); expect(keepArchive.success).toBe(true); await expect @@ -182,7 +183,7 @@ test("archiving the last reference to a worktree honors deleteWorktreeFromDisk", interval: 100, }) .toBe(false); - expect(existsSync(keepDir)).toBe(true); + await expect.poll(() => existsSync(keepDir), { timeout: 10000, interval: 100 }).toBe(false); const deleteResult = await ctx.client.createWorkspace({ source: { @@ -199,11 +200,9 @@ test("archiving the last reference to a worktree honors deleteWorktreeFromDisk", const deleteDir = deleteWorkspace.workspaceDirectory; expect(existsSync(deleteDir)).toBe(true); - // Last reference, deleteWorktreeFromDisk true → dir is removed from disk. - const deleteArchive = await ctx.client.archivePaseoWorktree({ - worktreePath: deleteDir, - deleteWorktreeFromDisk: true, - }); + // Last reference on a fresh worktree still removes the directory without any + // caller-supplied disk-deletion flag. + const deleteArchive = await ctx.client.archivePaseoWorktree({ worktreePath: deleteDir }); expect(deleteArchive.success).toBe(true); await expect .poll(async () => (await activeWorkspaceIds()).has(deleteWorkspace.id), { @@ -260,7 +259,7 @@ test("worktree archive targets the explicit workspaceId when a directory backs m }); }, 60000); -test("deleteWorktreeFromDisk keeps the worktree when a sibling workspace still references it", async () => { +test("keeps the worktree on disk when a sibling workspace still references it", async () => { const repoDir = createGitRepo(); const worktreeResult = await ctx.client.createWorkspace({ @@ -282,11 +281,10 @@ test("deleteWorktreeFromDisk keeps the worktree when a sibling workspace still r const siblingWorkspaceId = await createLocalWorkspace(worktreeDir, "sibling"); expect(siblingWorkspaceId).not.toBe(worktreeWorkspace.id); - // Archive the worktree-backed workspace with deleteWorktreeFromDisk true. - // It is NOT the last reference, so the directory must survive. + // Archive the worktree-backed workspace. It is NOT the last reference, so the + // directory must survive regardless of the legacy disk flag. const archive = await ctx.client.archivePaseoWorktree({ worktreePath: worktreeDir, - deleteWorktreeFromDisk: true, }); expect(archive.success).toBe(true); diff --git a/packages/server/src/server/workspace-archive-service.test.ts b/packages/server/src/server/workspace-archive-service.test.ts new file mode 100644 index 000000000..0571de998 --- /dev/null +++ b/packages/server/src/server/workspace-archive-service.test.ts @@ -0,0 +1,577 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino, { type Logger } from "pino"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import type { GitHubService } from "../services/github-service.js"; +import { createWorktree, type WorktreeConfig } from "../utils/worktree.js"; +import type { ManagedAgent } from "./agent/agent-manager.js"; +import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js"; +import type { WorkspaceGitService } from "./workspace-git-service.js"; +import { + archiveByScope, + type ActiveWorkspaceRef, + type ArchiveDependencies, + type ArchiveResult, + resolveWorkspaceIdAtPath, +} from "./workspace-archive-service.js"; + +const cleanupPaths: string[] = []; + +afterEach(() => { + for (const target of cleanupPaths.splice(0)) { + rmSync(target, { recursive: true, force: true }); + } +}); + +function createLogger(): Logger { + const logger = pino({ level: "silent" }); + vi.spyOn(logger, "info").mockImplementation(() => undefined); + vi.spyOn(logger, "warn").mockImplementation(() => undefined); + vi.spyOn(logger, "error").mockImplementation(() => undefined); + return logger; +} + +function createGitHubServiceStub(): GitHubService { + return { + listPullRequests: async () => [], + listIssues: async () => [], + searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }), + getPullRequest: async ({ number }) => ({ + number, + title: `PR ${number}`, + url: `https://github.com/acme/repo/pull/${number}`, + state: "OPEN", + body: null, + baseRefName: "main", + headRefName: `pr-${number}`, + labels: [], + }), + getPullRequestHeadRef: async ({ number }) => `pr-${number}`, + getCurrentPullRequestStatus: async () => null, + createPullRequest: async () => ({ + number: 1, + url: "https://github.com/acme/repo/pull/1", + }), + mergePullRequest: async () => ({ success: true }), + isAuthenticated: async () => true, + invalidate: () => {}, + }; +} + +function createGitRepo(): { tempDir: string; repoDir: string } { + const tempDir = mkdtempSync(path.join(tmpdir(), "workspace-archive-service-")); + cleanupPaths.push(tempDir); + const repoDir = path.join(tempDir, "repo"); + mkdirSync(repoDir, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: 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", ["-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "initial"], { + cwd: repoDir, + stdio: "pipe", + }); + return { tempDir, repoDir }; +} + +async function createPaseoOwnedWorktree( + repoDir: string, + paseoHome: string, + worktreeSlug: string, +): Promise { + return createWorktree({ + cwd: repoDir, + worktreeSlug, + source: { + kind: "branch-off", + baseBranch: "main", + branchName: worktreeSlug, + }, + runSetup: false, + paseoHome, + }); +} + +interface ArchiveDepsInput { + paseoHome: string; + activeWorkspaces: ActiveWorkspaceRef[]; + paseoWorktreesBaseRoot?: string; + findWorkspaceIdForCwd?: (cwd: string) => Promise; +} + +interface ArchiveTestDependencies extends ArchiveDependencies { + activeWorkspaces: ActiveWorkspaceRef[]; + archivedAgentIds: string[]; + archivedSnapshotIds: string[]; +} + +function createArchiveDeps(input: ArchiveDepsInput): ArchiveTestDependencies { + const archivedWorkspaceIds = new Set(); + const active = [...input.activeWorkspaces]; + const archivedAgentIds: string[] = []; + const archivedSnapshotIds: string[] = []; + + return { + paseoHome: input.paseoHome, + paseoWorktreesBaseRoot: input.paseoWorktreesBaseRoot, + github: createGitHubServiceStub(), + workspaceGitService: { + getSnapshot: vi.fn(async () => null), + } as unknown as Pick, + agentManager: { + listAgents: () => [], + archiveAgent: vi.fn(async (agentId: string) => { + archivedAgentIds.push(agentId); + return { archivedAt: new Date().toISOString() }; + }), + archiveSnapshot: vi.fn(async (agentId: string, _archivedAt: string) => { + archivedSnapshotIds.push(agentId); + return {}; + }), + }, + agentStorage: { + list: async (): Promise => [], + } as Pick, + findWorkspaceIdForCwd: input.findWorkspaceIdForCwd ?? vi.fn(async () => null), + listActiveWorkspaces: async () => + active.filter((workspace) => !archivedWorkspaceIds.has(workspace.workspaceId)), + archiveWorkspaceRecord: async (workspaceId: string) => { + archivedWorkspaceIds.add(workspaceId); + const index = active.findIndex((workspace) => workspace.workspaceId === workspaceId); + if (index !== -1) { + active.splice(index, 1); + } + }, + emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => {}), + markWorkspaceArchiving: vi.fn(), + clearWorkspaceArchiving: vi.fn(), + killTerminalsForWorkspace: vi.fn(async () => {}), + sessionLogger: createLogger(), + activeWorkspaces: active, + archivedAgentIds, + archivedSnapshotIds, + }; +} + +function assertArchiveResult( + result: ArchiveResult, + expected: { + archivedWorkspaceIds: string[]; + removedDirectory: boolean; + }, +): void { + expect(result.archivedWorkspaceIds).toEqual(expected.archivedWorkspaceIds); + expect(result.removedDirectory).toBe(expected.removedDirectory); +} + +describe("archiveByScope", () => { + test("workspace scope archives the record and removes the directory on last reference", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "last-ref-workspace"); + const workspaceId = "ws-last-ref"; + + const result = await archiveByScope( + createArchiveDeps({ + paseoHome, + activeWorkspaces: [ + { + workspaceId, + cwd: worktree.worktreePath, + kind: "worktree", + }, + ], + }), + { + scope: { kind: "workspace", workspaceId }, + repoRoot: repoDir, + requestId: "req-last-ref-workspace", + }, + ); + + assertArchiveResult(result, { + archivedWorkspaceIds: [workspaceId], + removedDirectory: true, + }); + expect(existsSync(worktree.worktreePath)).toBe(false); + }); + + test("workspace scope keeps the directory when a sibling workspace still references it", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "sibling-workspace"); + const workspaceA = "ws-sibling-a"; + const workspaceB = "ws-sibling-b"; + + const result = await archiveByScope( + createArchiveDeps({ + paseoHome, + activeWorkspaces: [ + { workspaceId: workspaceA, cwd: worktree.worktreePath, kind: "worktree" }, + { workspaceId: workspaceB, cwd: worktree.worktreePath, kind: "local_checkout" }, + ], + }), + { + scope: { kind: "workspace", workspaceId: workspaceA }, + repoRoot: repoDir, + requestId: "req-sibling-workspace", + }, + ); + + assertArchiveResult(result, { + archivedWorkspaceIds: [workspaceA], + removedDirectory: false, + }); + expect(existsSync(worktree.worktreePath)).toBe(true); + }); + + test("worktree scope archives every workspace on the directory and removes it", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "worktree-scope"); + const workspaceA = "ws-worktree-a"; + const workspaceB = "ws-worktree-b"; + + const result = await archiveByScope( + createArchiveDeps({ + paseoHome, + activeWorkspaces: [ + { workspaceId: workspaceA, cwd: worktree.worktreePath, kind: "worktree" }, + { workspaceId: workspaceB, cwd: worktree.worktreePath, kind: "local_checkout" }, + ], + }), + { + scope: { kind: "worktree", targetPath: worktree.worktreePath }, + repoRoot: repoDir, + requestId: "req-worktree-scope", + }, + ); + + expect(result.archivedWorkspaceIds).toEqual(expect.arrayContaining([workspaceA, workspaceB])); + expect(result.archivedWorkspaceIds).toHaveLength(2); + expect(result.removedDirectory).toBe(true); + expect(existsSync(worktree.worktreePath)).toBe(false); + }); + + test("workspace scope never removes a non-Paseo-owned directory", async () => { + const { tempDir } = createGitRepo(); + const localCheckoutDir = mkdtempSync(path.join(tempDir, "local-checkout-")); + const workspaceId = "ws-local-checkout"; + + const result = await archiveByScope( + createArchiveDeps({ + paseoHome: path.join(tempDir, ".paseo"), + activeWorkspaces: [{ workspaceId, cwd: localCheckoutDir, kind: "local_checkout" }], + }), + { + scope: { kind: "workspace", workspaceId }, + repoRoot: null, + requestId: "req-local-checkout", + }, + ); + + assertArchiveResult(result, { + archivedWorkspaceIds: [workspaceId], + removedDirectory: false, + }); + expect(existsSync(localCheckoutDir)).toBe(true); + }); + + test("worktree scope keeps the directory when one record teardown fails", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "partial-failure"); + const workspaceA = "ws-partial-a"; + const workspaceB = "ws-partial-b"; + + const deps = createArchiveDeps({ + paseoHome, + activeWorkspaces: [ + { workspaceId: workspaceA, cwd: worktree.worktreePath, kind: "worktree" }, + { workspaceId: workspaceB, cwd: worktree.worktreePath, kind: "worktree" }, + ], + }); + const originalArchiveWorkspaceRecord = deps.archiveWorkspaceRecord; + deps.archiveWorkspaceRecord = async (workspaceId: string) => { + if (workspaceId === workspaceA) { + throw new Error("intentional teardown failure"); + } + return originalArchiveWorkspaceRecord(workspaceId); + }; + + const result = await archiveByScope(deps, { + scope: { kind: "worktree", targetPath: worktree.worktreePath }, + repoRoot: repoDir, + requestId: "req-partial-failure", + }); + + expect(result.archivedWorkspaceIds).toEqual([workspaceB]); + expect(result.archivedWorkspaceIds).not.toContain(workspaceA); + expect(result.removedDirectory).toBe(false); + expect(existsSync(worktree.worktreePath)).toBe(true); + }); + + test("workspace scope with unknown workspace id is a clean no-op", async () => { + const { tempDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + + const deps = createArchiveDeps({ + paseoHome, + activeWorkspaces: [], + }); + const originalArchiveWorkspaceRecord = deps.archiveWorkspaceRecord; + deps.archiveWorkspaceRecord = vi.fn(async (workspaceId: string) => { + return originalArchiveWorkspaceRecord(workspaceId); + }); + + const result = await archiveByScope(deps, { + scope: { kind: "workspace", workspaceId: "ws-does-not-exist" }, + repoRoot: null, + requestId: "req-unknown-workspace", + }); + + assertArchiveResult(result, { + archivedWorkspaceIds: [], + removedDirectory: false, + }); + expect(deps.markWorkspaceArchiving).not.toHaveBeenCalled(); + expect(deps.archiveWorkspaceRecord).not.toHaveBeenCalled(); + expect(deps.emitWorkspaceUpdatesForWorkspaceIds).not.toHaveBeenCalled(); + }); + + test("worktree scope removes an owned directory with zero matching records", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "zero-records"); + + const result = await archiveByScope( + createArchiveDeps({ + paseoHome, + activeWorkspaces: [], + }), + { + scope: { kind: "worktree", targetPath: worktree.worktreePath }, + repoRoot: repoDir, + requestId: "req-zero-records", + }, + ); + + assertArchiveResult(result, { + archivedWorkspaceIds: [], + removedDirectory: true, + }); + expect(existsSync(worktree.worktreePath)).toBe(false); + }); + + test("marks archiving, emits an upsert carrying the archiving state, then clears it and emits a remove", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "lifecycle"); + const workspaceId = "ws-lifecycle"; + + const deps = createArchiveDeps({ + paseoHome, + activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }], + }); + + const archivingByWorkspaceId = new Map(); + type LifecycleEvent = + | { type: "mark"; workspaceIds: string[]; archivingAt: string } + | { + type: "emit"; + workspaceIds: string[]; + updates: Array<{ + kind: "upsert" | "remove"; + workspaceId: string; + archivingAt: string | null; + }>; + } + | { type: "archive"; workspaceId: string } + | { type: "clear"; workspaceIds: string[] }; + const events: LifecycleEvent[] = []; + + const originalArchiveWorkspaceRecord = deps.archiveWorkspaceRecord; + deps.archiveWorkspaceRecord = async (id: string) => { + await originalArchiveWorkspaceRecord(id); + events.push({ type: "archive", workspaceId: id }); + }; + deps.markWorkspaceArchiving = vi.fn((workspaceIds: Iterable, archivingAt: string) => { + for (const id of workspaceIds) { + archivingByWorkspaceId.set(id, archivingAt); + } + events.push({ type: "mark", workspaceIds: Array.from(workspaceIds), archivingAt }); + }); + deps.clearWorkspaceArchiving = vi.fn((workspaceIds: Iterable) => { + for (const id of workspaceIds) { + archivingByWorkspaceId.delete(id); + } + events.push({ type: "clear", workspaceIds: Array.from(workspaceIds) }); + }); + deps.emitWorkspaceUpdatesForWorkspaceIds = vi.fn(async (workspaceIds: Iterable) => { + const ids = Array.from(workspaceIds); + const activeIds = new Set(); + for (const workspace of deps.activeWorkspaces) { + activeIds.add(workspace.workspaceId); + } + const updates: Array<{ + kind: "upsert" | "remove"; + workspaceId: string; + archivingAt: string | null; + }> = []; + for (const id of ids) { + const archivingAt = archivingByWorkspaceId.get(id) ?? null; + if (archivingAt && activeIds.has(id)) { + updates.push({ kind: "upsert", workspaceId: id, archivingAt }); + } else { + updates.push({ kind: "remove", workspaceId: id, archivingAt: null }); + } + } + events.push({ type: "emit", workspaceIds: ids, updates }); + }); + + await archiveByScope(deps, { + scope: { kind: "workspace", workspaceId }, + repoRoot: repoDir, + requestId: "req-lifecycle", + }); + + expect(events.map((event) => event.type)).toEqual(["mark", "emit", "archive", "clear", "emit"]); + + const firstEmit = events[1] as Extract; + expect(firstEmit.workspaceIds).toEqual([workspaceId]); + expect(firstEmit.updates).toEqual([ + { kind: "upsert", workspaceId, archivingAt: expect.any(String) }, + ]); + + const secondEmit = events[4] as Extract; + expect(secondEmit.workspaceIds).toEqual([workspaceId]); + expect(secondEmit.updates).toEqual([{ kind: "remove", workspaceId, archivingAt: null }]); + }); + + test("archives stored snapshots only for the target workspace", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "snapshot-scope"); + const targetWorkspaceId = "ws-snapshot-target"; + const otherWorkspaceId = "ws-snapshot-other"; + const liveAgentId = "agent-live"; + const targetStoredAgentId = "agent-stored-target"; + const otherStoredAgentId = "agent-stored-other"; + + const deps = createArchiveDeps({ + paseoHome, + activeWorkspaces: [ + { workspaceId: targetWorkspaceId, cwd: worktree.worktreePath, kind: "worktree" }, + ], + }); + deps.agentManager = { + listAgents: () => [{ id: liveAgentId, workspaceId: targetWorkspaceId }] as ManagedAgent[], + archiveAgent: vi.fn(async (agentId: string) => { + deps.archivedAgentIds.push(agentId); + return { archivedAt: new Date().toISOString() }; + }), + archiveSnapshot: vi.fn(async (agentId: string, _archivedAt: string) => { + deps.archivedSnapshotIds.push(agentId); + return {}; + }), + }; + deps.agentStorage = { + list: async () => + [ + { id: targetStoredAgentId, workspaceId: targetWorkspaceId, archivedAt: null }, + { id: otherStoredAgentId, workspaceId: otherWorkspaceId, archivedAt: null }, + ] as StoredAgentRecord[], + } as Pick; + + const result = await archiveByScope(deps, { + scope: { kind: "workspace", workspaceId: targetWorkspaceId }, + repoRoot: repoDir, + requestId: "req-snapshot-scope", + }); + + assertArchiveResult(result, { + archivedWorkspaceIds: [targetWorkspaceId], + removedDirectory: true, + }); + expect(result.archivedAgentIds).toContain(liveAgentId); + expect(result.archivedAgentIds).toContain(targetStoredAgentId); + expect(result.archivedAgentIds).not.toContain(otherStoredAgentId); + expect(deps.archivedSnapshotIds).toEqual([targetStoredAgentId]); + expect(existsSync(worktree.worktreePath)).toBe(false); + }); + + test("worktree scope archives three workspaces on the directory and removes it", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "worktree-scope-n3"); + const workspaceA = "ws-worktree-n3-a"; + const workspaceB = "ws-worktree-n3-b"; + const workspaceC = "ws-worktree-n3-c"; + + const result = await archiveByScope( + createArchiveDeps({ + paseoHome, + activeWorkspaces: [ + { workspaceId: workspaceA, cwd: worktree.worktreePath, kind: "worktree" }, + { workspaceId: workspaceB, cwd: worktree.worktreePath, kind: "worktree" }, + { workspaceId: workspaceC, cwd: worktree.worktreePath, kind: "local_checkout" }, + ], + }), + { + scope: { kind: "worktree", targetPath: worktree.worktreePath }, + repoRoot: repoDir, + requestId: "req-worktree-scope-n3", + }, + ); + + expect(result.archivedWorkspaceIds).toEqual( + expect.arrayContaining([workspaceA, workspaceB, workspaceC]), + ); + expect(result.archivedWorkspaceIds).toHaveLength(3); + expect(result.removedDirectory).toBe(true); + expect(existsSync(worktree.worktreePath)).toBe(false); + }); +}); + +describe("resolveWorkspaceIdAtPath", () => { + test("prefers the worktree-kind record on an exact cwd tie", async () => { + const targetPath = "/worktrees/repo/feature"; + + const result = await resolveWorkspaceIdAtPath( + { + listActiveWorkspaces: async () => [ + { workspaceId: "ws-local", cwd: targetPath, kind: "local_checkout" }, + { workspaceId: "ws-worktree", cwd: targetPath, kind: "worktree" }, + ], + findWorkspaceIdForCwd: vi.fn(async () => "ws-local"), + }, + targetPath, + ); + + expect(result).toBe("ws-worktree"); + }); + + test("falls back to the path resolver when there is no exact match", async () => { + const targetPath = "/worktrees/repo/feature"; + + const result = await resolveWorkspaceIdAtPath( + { + listActiveWorkspaces: async () => [ + { workspaceId: "ws-nested", cwd: "/worktrees/repo", kind: "worktree" }, + ], + findWorkspaceIdForCwd: vi.fn(async () => "ws-nested"), + }, + targetPath, + ); + + expect(result).toBe("ws-nested"); + }); +}); diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts index faa974bae..d06d54410 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -1,5 +1,396 @@ +import { resolve } from "node:path"; + +import type { Logger } from "pino"; + +import type { AgentManager } from "./agent/agent-manager.js"; +import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js"; +import type { WorkspaceGitService } from "./workspace-git-service.js"; +import type { GitHubService } from "../services/github-service.js"; +import { + deletePaseoWorktree, + isPaseoOwnedWorktreeCwd, + resolvePaseoWorktreeRootForCwd, + WorktreeTeardownError, +} from "../utils/worktree.js"; +import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "./workspace-registry.js"; +export interface ActiveWorkspaceRef { + workspaceId: string; + cwd: string; + kind?: "local_checkout" | "worktree" | "directory"; +} + +export interface ArchiveDependencies { + paseoHome?: string; + // Base directory that may hold worktrees across repositories. Used as a fallback + // when the request does not supply a per-repo root. + paseoWorktreesBaseRoot?: string; + github: GitHubService; + workspaceGitService: Pick; + agentManager: Pick; + agentStorage: Pick; + // Resolves the worktree at a path to its workspaceId for archive-by-path. The + // path uniquely identifies a worktree workspace; this is a directory lookup for + // the archive target, not status/ownership. + findWorkspaceIdForCwd: (cwd: string) => Promise; + // Active (non-archived) workspaces, used to decide whether the workspace being + // archived is the last reference to its backing worktree directory, and to + // break a same-cwd tie in favor of the worktree-kind record when archiving by + // path (no explicit workspaceId). + listActiveWorkspaces: () => Promise; + archiveWorkspaceRecord: (workspaceId: string) => Promise; + emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable) => Promise; + markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => void; + clearWorkspaceArchiving: (workspaceIds: Iterable) => void; + killTerminalsForWorkspace: (workspaceId: string) => Promise; + sessionLogger?: Logger; +} + +export interface KillTerminalsForWorkspaceDependencies { + detachTerminalStream?: (terminalId: string, options: { emitExit: boolean }) => void; + sessionLogger: Logger; + terminalManager: TerminalManager | null; +} + +export type ArchiveScope = + | { kind: "workspace"; workspaceId: string } + | { kind: "worktree"; targetPath: string }; + +export interface ArchiveResult { + archivedAgentIds: string[]; + archivedWorkspaceIds: string[]; + removedDirectory: boolean; +} + +export interface ArchiveByScopeRequest { + scope: ArchiveScope; + repoRoot: string | null; + // Per-repository worktree root, used to remove the actual directory. + repoWorktreesRoot?: string; + // Base directory that may hold worktrees across repositories; falls back to the + // dependency's base root for ownership checks and path resolution. + paseoWorktreesBaseRoot?: string; + requestId: string; +} + +export async function resolveWorkspaceIdAtPath( + dependencies: Pick, + targetPath: string, +): Promise { + const targetDir = resolve(targetPath); + const activeWorkspaces = await dependencies.listActiveWorkspaces(); + const exactMatches = activeWorkspaces.filter((workspace) => resolve(workspace.cwd) === targetDir); + const worktreeMatch = exactMatches.find((workspace) => workspace.kind === "worktree"); + if (worktreeMatch) { + return worktreeMatch.workspaceId; + } + return dependencies.findWorkspaceIdForCwd(targetPath); +} + +// THE single archive entry. Resolves the in-scope record set, tears each down +// (agents + terminals + record), then removes the backing directory iff it is +// Paseo-owned AND no active workspace still references it. +export async function archiveByScope( + dependencies: ArchiveDependencies, + request: ArchiveByScopeRequest, +): Promise { + const { targetDir, targetWorkspaceIds } = await resolveArchiveTargets( + dependencies, + request.scope, + request.paseoWorktreesBaseRoot, + ); + + if (targetWorkspaceIds.length > 0) { + dependencies.markWorkspaceArchiving(targetWorkspaceIds, new Date().toISOString()); + } + + let removedDirectory = false; + + try { + if (targetWorkspaceIds.length > 0) { + await dependencies.emitWorkspaceUpdatesForWorkspaceIds(targetWorkspaceIds); + } + + const { archivedAgents, archivedWorkspaceIds } = await archiveTargetRecords( + dependencies, + targetWorkspaceIds, + request.requestId, + ); + + if (request.repoRoot) { + try { + await dependencies.workspaceGitService.getSnapshot(request.repoRoot, { + force: true, + reason: "archive-worktree", + }); + } catch (error) { + dependencies.sessionLogger?.warn( + { err: error, cwd: request.repoRoot, requestId: request.requestId }, + "Failed to force-refresh workspace git snapshot after archiving", + ); + } + } + + if (targetDir !== null) { + removedDirectory = await maybeRemoveDirectory( + dependencies, + request, + targetDir, + archivedWorkspaceIds, + ); + } + + return { + archivedAgentIds: Array.from(archivedAgents), + archivedWorkspaceIds, + removedDirectory, + }; + } finally { + if (targetWorkspaceIds.length > 0) { + dependencies.clearWorkspaceArchiving(targetWorkspaceIds); + await dependencies.emitWorkspaceUpdatesForWorkspaceIds(targetWorkspaceIds); + } + } +} + +async function resolveArchiveTargets( + dependencies: ArchiveDependencies, + scope: ArchiveScope, + paseoWorktreesBaseRoot?: string, +): Promise<{ targetDir: string | null; targetWorkspaceIds: string[] }> { + const activeWorkspaces = await dependencies.listActiveWorkspaces(); + + if (scope.kind === "workspace") { + const workspaceId = scope.workspaceId; + const record = activeWorkspaces.find((workspace) => workspace.workspaceId === workspaceId); + if (!record) { + dependencies.sessionLogger?.warn( + { workspaceId }, + "Workspace not found for archive-by-scope; skipping", + ); + return { targetDir: null, targetWorkspaceIds: [] }; + } + return { targetDir: resolve(record.cwd), targetWorkspaceIds: [workspaceId] }; + } + + let targetPath = scope.targetPath; + const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, { + paseoHome: dependencies.paseoHome, + worktreesRoot: paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot, + }); + if (resolvedWorktree) { + targetPath = resolvedWorktree.worktreePath; + } + const targetDir = resolve(targetPath); + const targetWorkspaceIds = activeWorkspaces + .filter((workspace) => resolve(workspace.cwd) === targetDir) + .map((workspace) => workspace.workspaceId); + return { targetDir, targetWorkspaceIds }; +} + +async function archiveTargetRecords( + dependencies: ArchiveDependencies, + targetWorkspaceIds: string[], + requestId: string, +): Promise<{ archivedAgents: Set; archivedWorkspaceIds: string[] }> { + const archivedAgents = new Set(); + const archivedWorkspaceIds: string[] = []; + + const results = await Promise.allSettled( + targetWorkspaceIds.map(async (workspaceId) => { + const agents = await archiveWorkspaceContents(dependencies, workspaceId); + await dependencies.archiveWorkspaceRecord(workspaceId); + return { workspaceId, agents }; + }), + ); + + for (const result of results) { + if (result.status === "fulfilled") { + archivedWorkspaceIds.push(result.value.workspaceId); + for (const agentId of result.value.agents) { + archivedAgents.add(agentId); + } + } else { + dependencies.sessionLogger?.warn( + { err: result.reason, requestId }, + "archiveByScope workspace teardown failed; continuing", + ); + } + } + + return { archivedAgents, archivedWorkspaceIds }; +} + +async function maybeRemoveDirectory( + dependencies: ArchiveDependencies, + request: Omit, + targetDir: string, + archivedWorkspaceIds: string[], +): Promise { + const ownership = await isPaseoOwnedWorktreeCwd(targetDir, { + paseoHome: dependencies.paseoHome, + worktreesRoot: request.paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot, + }); + if (!ownership.allowed) { + return false; + } + + const remainingActive = await dependencies.listActiveWorkspaces(); + if (!isDirectoryUnreferenced(remainingActive, targetDir, new Set(archivedWorkspaceIds))) { + return false; + } + + try { + await deletePaseoWorktree({ + cwd: request.repoRoot, + worktreePath: targetDir, + worktreesRoot: request.repoWorktreesRoot ?? ownership.worktreeRoot, + paseoHome: dependencies.paseoHome, + worktreesBaseRoot: request.paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot, + }); + dependencies.github.invalidate({ cwd: targetDir }); + return true; + } catch (error) { + if (error instanceof WorktreeTeardownError) { + dependencies.sessionLogger?.warn( + { err: error, targetPath: targetDir, requestId: request.requestId }, + "Worktree disk removal failed during archive; workspace already archived", + ); + return false; + } + throw error; + } +} + +export type ArchiveWorkspaceContentsDependencies = Pick< + ArchiveDependencies, + "agentManager" | "agentStorage" | "killTerminalsForWorkspace" | "sessionLogger" +>; + +// Tears down everything OWNED by a single workspace record: its live agents, +// its persisted-but-not-running agent snapshots, and its terminals. Scoped by +// workspaceId so a sibling workspace sharing the same directory is untouched. +// Returns the set of archived agent ids. +export async function archiveWorkspaceContents( + dependencies: ArchiveWorkspaceContentsDependencies, + workspaceId: string, +): Promise> { + const archivedAgents = new Set(); + + const liveAgents = dependencies.agentManager + .listAgents() + .filter((agent) => agent.workspaceId === workspaceId); + for (const agent of liveAgents) { + archivedAgents.add(agent.id); + } + + let storedRecords: StoredAgentRecord[] = []; + try { + storedRecords = await dependencies.agentStorage.list(); + } catch (error) { + dependencies.sessionLogger?.warn( + { err: error, workspaceId }, + "Failed to list stored agents during workspace archive; continuing", + ); + } + const liveAgentIds = new Set(liveAgents.map((agent) => agent.id)); + const matchingStoredRecords = storedRecords.filter( + (record) => record.workspaceId === workspaceId, + ); + for (const record of matchingStoredRecords) { + archivedAgents.add(record.id); + } + + const archivedAt = new Date().toISOString(); + const archiveResults = await Promise.allSettled([ + ...liveAgents.map((agent) => dependencies.agentManager.archiveAgent(agent.id)), + ...matchingStoredRecords + .filter((record) => !liveAgentIds.has(record.id) && !record.archivedAt) + .map((record) => dependencies.agentManager.archiveSnapshot(record.id, archivedAt)), + dependencies.killTerminalsForWorkspace(workspaceId), + ]); + + for (const result of archiveResults) { + if (result.status === "rejected") { + dependencies.sessionLogger?.warn( + { err: result.reason, workspaceId }, + "Workspace archive teardown step failed; continuing", + ); + } + } + + return archivedAgents; +} + +// EXACTLY one last-reference predicate in the module. True when, after archiving +// the in-scope records, no active workspace still points at targetDir. Derived +// from records each call — no stored counter. +function isDirectoryUnreferenced( + activeWorkspaces: ActiveWorkspaceRef[], + targetDir: string, + archivedWorkspaceIds: ReadonlySet, +): boolean { + const target = resolve(targetDir); + return !activeWorkspaces.some( + (workspace) => + !archivedWorkspaceIds.has(workspace.workspaceId) && resolve(workspace.cwd) === target, + ); +} + +export async function killTerminalsForWorkspace( + dependencies: KillTerminalsForWorkspaceDependencies, + workspaceId: string, +): Promise { + const terminalManager = dependencies.terminalManager; + if (!terminalManager) { + return; + } + + const terminalIds: string[] = []; + const terminalLists = await Promise.all( + terminalManager.listDirectories().map(async (terminalCwd) => { + try { + return await terminalManager.getTerminals(terminalCwd, { workspaceId }); + } catch (error) { + dependencies.sessionLogger.warn( + { err: error, cwd: terminalCwd }, + "Failed to enumerate workspace terminals during archive", + ); + return []; + } + }), + ); + for (const terminals of terminalLists) { + for (const terminal of terminals) { + if (terminal.workspaceId === workspaceId) { + terminalIds.push(terminal.id); + } + } + } + + if (terminalIds.length === 0) { + return; + } + + await Promise.allSettled( + terminalIds.map(async (terminalId) => { + try { + dependencies.detachTerminalStream?.(terminalId, { emitExit: true }); + await terminalManager.killTerminalAndWait(terminalId, { + gracefulTimeoutMs: 2000, + forceTimeoutMs: 1500, + }); + } catch (error) { + dependencies.sessionLogger.warn( + { err: error, terminalId }, + "Terminal kill escalation failed during archive; proceeding anyway", + ); + } + }), + ); +} + // Archiving the last workspace of a project leaves the project as a first-class // empty project — it persists until explicitly removed, so we never archive the // parent project here. diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index 3dd0e4d43..c661c017e 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -14,10 +14,6 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import pino, { type Logger } from "pino"; import type { SessionOutboundMessage, WorkspaceDescriptorPayload } from "./messages.js"; -import { - archivePaseoWorktree, - killTerminalsForWorkspace as killWorkspaceTerminals, -} from "./paseo-worktree-archive-service.js"; import { buildAgentSessionConfig, createPaseoWorktreeWorkflow, @@ -35,7 +31,6 @@ import { } from "../utils/worktree.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalSession } from "../terminal/terminal.js"; -import type { ManagedAgent } from "./agent/agent-manager.js"; import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js"; import type { GitHubService } from "../services/github-service.js"; @@ -302,47 +297,6 @@ function createPaseoWorktreeForTest(options: { }; } -function createManagedAgentForArchive(input: { - id: string; - cwd: string; - workspaceId?: string; -}): ManagedAgent { - const now = new Date(); - return { - id: input.id, - provider: "codex", - cwd: input.cwd, - ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), - capabilities: { - supportsStreaming: false, - supportsSessionPersistence: false, - supportsDynamicModes: false, - supportsMcpServers: false, - supportsReasoningStream: false, - supportsToolInvocations: false, - }, - config: { provider: "codex", cwd: input.cwd }, - createdAt: now, - updatedAt: now, - availableModes: [], - currentModeId: null, - pendingPermissions: new Map(), - bufferedPermissionResolutions: new Map(), - inFlightPermissionResponses: new Set(), - pendingReplacement: false, - persistence: null, - historyPrimed: false, - lastUserMessageAt: null, - attention: { requiresAttention: false }, - foregroundTurnWaiters: new Set(), - unsubscribeSession: null, - labels: {}, - lifecycle: "closed", - session: null, - activeForegroundTurnId: null, - }; -} - describe("handlePaseoWorktreeListRequest", () => { test("lists worktrees through the workspace git service", async () => { const emitted: SessionOutboundMessage[] = []; @@ -495,13 +449,20 @@ function createAgentStorageStub(): Pick { }; } -function createWorkspaceArchivingDeps() { - return { - findWorkspaceIdForCwd: vi.fn(async () => "ws-archive-test"), - listActiveWorkspaces: vi.fn(async () => []), - emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => {}), - markWorkspaceArchiving: vi.fn(), - clearWorkspaceArchiving: vi.fn(), +function createArchiveWorkspaceRecordMutator( + activeWorkspaces: Array<{ + workspaceId: string; + cwd: string; + kind: "worktree" | "local_checkout" | "directory"; + }>, + archivedWorkspaceRecords: string[], +) { + return async (id: string) => { + archivedWorkspaceRecords.push(id); + const index = activeWorkspaces.findIndex((workspace) => workspace.workspaceId === id); + if (index !== -1) { + activeWorkspaces.splice(index, 1); + } }; } @@ -1709,7 +1670,7 @@ describe("handleCreatePaseoWorktreeRequest", () => { }); }); -describe("archivePaseoWorktree", () => { +describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { const cleanupPaths: string[] = []; afterEach(() => { @@ -1718,303 +1679,29 @@ describe("archivePaseoWorktree", () => { } }); - function createWorkspaceRecord(input: { - workspaceId: string; - cwd: string; - repoDir: string; - displayName: string; - }): PersistedWorkspaceRecord { - return { - workspaceId: input.workspaceId, - projectId: input.repoDir, - cwd: input.cwd, - kind: "worktree", - displayName: input.displayName, - createdAt: "2026-04-30T00:00:00.000Z", - updatedAt: "2026-04-30T00:00:00.000Z", - archivedAt: null, - }; - } - - function createTerminalSessionStub(input: { - id: string; - cwd: string; - workspaceId?: string; - }): TerminalSession { - return { - id: input.id, - cwd: input.cwd, - ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), - } as unknown as TerminalSession; - } - - // Minimal terminal manager that only supports the queries archive performs: - // enumerate directories and fetch terminals filtered by owning workspaceId. - function createArchiveTerminalManagerStub( - sessions: TerminalSession[], - killed: string[], - ): TerminalManager { - return { - listDirectories: () => Array.from(new Set(sessions.map((session) => session.cwd))), - getTerminals: async (cwd: string, options?: { workspaceId?: string }) => - sessions.filter( - (session) => - session.cwd === cwd && - (options?.workspaceId === undefined || - session.workspaceId === undefined || - session.workspaceId === options.workspaceId), - ), - killTerminalAndWait: async (terminalId: string) => { - killed.push(terminalId); - }, - } as unknown as TerminalManager; - } - - test("runs agent close and terminal teardown concurrently, scoped to the workspace", async () => { + test("archives every active workspace on the directory and removes it", async () => { const { tempDir, repoDir } = createGitRepo(); cleanupPaths.push(tempDir); const paseoHome = path.join(tempDir, ".paseo"); const created = await createLegacyWorktreeForTest({ - branchName: "archive-parallel", + branchName: "archive-worktree-scope", cwd: repoDir, baseBranch: "main", - worktreeSlug: "archive-parallel", - runSetup: false, - paseoHome, - }); - const workspaceId = "ws-archive-parallel"; - const teardownStartTimes: Record = {}; - const teardownEndTimes: Record = {}; - const archiveAgentSpy = vi.fn(async (agentId: string) => { - teardownStartTimes[agentId] = Date.now(); - await new Promise((resolve) => setTimeout(resolve, 100)); - teardownEndTimes[agentId] = Date.now(); - return { archivedAt: new Date().toISOString() }; - }); - const archiveSnapshotSpy = vi.fn(async () => { - throw new Error("not expected for live agents"); - }); - const killTerminalsForWorkspace = vi.fn(async () => { - teardownStartTimes.__terminals = Date.now(); - await new Promise((resolve) => setTimeout(resolve, 100)); - teardownEndTimes.__terminals = Date.now(); - }); - - const archivedAgents = await archivePaseoWorktree( - { - paseoHome, - github: createGitHubServiceStub(), - agentManager: { - listAgents: () => [ - createManagedAgentForArchive({ id: "agent-1", cwd: created.worktreePath, workspaceId }), - createManagedAgentForArchive({ id: "agent-2", cwd: created.worktreePath, workspaceId }), - ], - archiveAgent: archiveAgentSpy, - archiveSnapshot: archiveSnapshotSpy, - }, - agentStorage: createAgentStorageStub(), - archiveWorkspaceRecord: vi.fn(async () => {}), - ...createWorkspaceArchivingDeps(), - findWorkspaceIdForCwd: vi.fn(async (cwd: string) => - cwd === created.worktreePath ? workspaceId : null, - ), - killTerminalsForWorkspace, - sessionLogger: createLogger(), - }, - { - targetPath: created.worktreePath, - repoRoot: repoDir, - requestId: "req-archive-parallel", - }, - ); - - expect(archivedAgents).toEqual(expect.arrayContaining(["agent-1", "agent-2"])); - expect(archiveAgentSpy).toHaveBeenCalledTimes(2); - expect(archiveSnapshotSpy).not.toHaveBeenCalled(); - expect(killTerminalsForWorkspace).toHaveBeenCalledWith(workspaceId); - - // Archiving must never delete the worktree from disk. - expect(existsSync(created.worktreePath)).toBe(true); - - // All teardown work must overlap — sequential would take ~300ms, parallel ~100ms. - const starts = Object.values(teardownStartTimes); - const ends = Object.values(teardownEndTimes); - const maxEnd = Math.max(...ends); - const minStart = Math.min(...starts); - expect(maxEnd - minStart).toBeLessThan(220); - }); - - test("tears down only the target workspace; a sibling sharing the cwd survives", async () => { - const { tempDir, repoDir } = createGitRepo(); - cleanupPaths.push(tempDir); - - const paseoHome = path.join(tempDir, ".paseo"); - const created = await createLegacyWorktreeForTest({ - branchName: "archive-siblings", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "archive-siblings", + worktreeSlug: "archive-worktree-scope", runSetup: false, paseoHome, }); const sharedCwd = created.worktreePath; - const workspaceA = "ws-A"; - const workspaceB = "ws-B"; - - const liveAgents = [ - createManagedAgentForArchive({ id: "agent-A", cwd: sharedCwd, workspaceId: workspaceA }), - createManagedAgentForArchive({ id: "agent-B", cwd: sharedCwd, workspaceId: workspaceB }), + const workspaceA = "ws-worktree-scope-A"; + const workspaceB = "ws-worktree-scope-B"; + const activeWorkspaces = [ + { workspaceId: workspaceA, cwd: sharedCwd, kind: "worktree" as const }, + { workspaceId: workspaceB, cwd: sharedCwd, kind: "worktree" as const }, ]; - const storedRecords: StoredAgentRecord[] = [ - { - id: "snapshot-A", - provider: "codex", - cwd: sharedCwd, - workspaceId: workspaceA, - createdAt: "2026-04-30T00:00:00.000Z", - updatedAt: "2026-04-30T00:00:00.000Z", - labels: {}, - lastStatus: "closed", - config: null, - } as unknown as StoredAgentRecord, - { - id: "snapshot-B", - provider: "codex", - cwd: sharedCwd, - workspaceId: workspaceB, - createdAt: "2026-04-30T00:00:00.000Z", - updatedAt: "2026-04-30T00:00:00.000Z", - labels: {}, - lastStatus: "closed", - config: null, - } as unknown as StoredAgentRecord, - ]; - - const killedTerminals: string[] = []; - const terminalManager = createArchiveTerminalManagerStub( - [ - createTerminalSessionStub({ id: "term-A", cwd: sharedCwd, workspaceId: workspaceA }), - createTerminalSessionStub({ id: "term-B", cwd: sharedCwd, workspaceId: workspaceB }), - ], - killedTerminals, - ); - - const archivedAgentIds: string[] = []; - const archivedSnapshotIds: string[] = []; const archivedWorkspaceRecords: string[] = []; - - const archivedAgents = await archivePaseoWorktree( - { - paseoHome, - github: createGitHubServiceStub(), - agentManager: { - listAgents: () => liveAgents, - archiveAgent: async (agentId: string) => { - archivedAgentIds.push(agentId); - return { archivedAt: new Date().toISOString() }; - }, - archiveSnapshot: async (agentId: string) => { - archivedSnapshotIds.push(agentId); - return storedRecords.find((record) => record.id === agentId)!; - }, - }, - agentStorage: { list: async () => storedRecords }, - archiveWorkspaceRecord: async (id: string) => { - archivedWorkspaceRecords.push(id); - }, - ...createWorkspaceArchivingDeps(), - findWorkspaceIdForCwd: vi.fn(async (cwd: string) => - cwd === sharedCwd ? workspaceA : null, - ), - killTerminalsForWorkspace: (workspaceId) => - killWorkspaceTerminals({ terminalManager, sessionLogger: createLogger() }, workspaceId), - sessionLogger: createLogger(), - }, - { - targetPath: sharedCwd, - repoRoot: repoDir, - requestId: "req-archive-siblings", - }, - ); - - // Only workspace A's agents, snapshots, terminals, and record are torn down. - expect(archivedAgents).toEqual(["agent-A", "snapshot-A"]); - expect(archivedAgentIds).toEqual(["agent-A"]); - expect(archivedSnapshotIds).toEqual(["snapshot-A"]); - expect(archivedWorkspaceRecords).toEqual([workspaceA]); - expect(killedTerminals).toEqual(["term-A"]); - - // Sibling workspace B is untouched, and the shared directory survives. - expect(archivedAgentIds).not.toContain("agent-B"); - expect(killedTerminals).not.toContain("term-B"); - expect(existsSync(sharedCwd)).toBe(true); - }); - - test("emits archiving upserts during worktree archive request until final remove", async () => { - const { tempDir, repoDir } = createGitRepo(); - cleanupPaths.push(tempDir); - - const paseoHome = path.join(tempDir, ".paseo"); - const created = await createLegacyWorktreeForTest({ - branchName: "archive-marked-during-close", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "archive-marked-during-close", - runSetup: false, - paseoHome, - }); - const workspaceId = "ws-archive-marked-during-close"; - const affectedIds = [workspaceId]; - const liveAgent = createManagedAgentForArchive({ - id: "agent-1", - cwd: created.worktreePath, - workspaceId, - }); - const workspaceRecord = createWorkspaceRecord({ - workspaceId, - cwd: created.worktreePath, - repoDir, - displayName: "archive-marked-during-close", - }); + const listActiveWorkspaces = vi.fn(async () => activeWorkspaces); const emitted: SessionOutboundMessage[] = []; - const events: string[] = []; - const archivedWorkspaceIds = new Set(); - const archivingByWorkspaceId = new Map(); - const emitWorkspaceUpdatesForWorkspaceIds = vi.fn(async (workspaceIds: Iterable) => { - for (const emittedWorkspaceId of workspaceIds) { - if (archivedWorkspaceIds.has(emittedWorkspaceId)) { - emitted.push({ - type: "workspace_update", - payload: { - kind: "remove", - id: emittedWorkspaceId, - }, - }); - continue; - } - emitted.push({ - type: "workspace_update", - payload: { - kind: "upsert", - workspace: { - ...createWorkspaceDescriptor({ workspace: workspaceRecord, repoDir }), - archivingAt: archivingByWorkspaceId.get(emittedWorkspaceId) ?? null, - }, - }, - }); - } - events.push(`emit:${Array.from(workspaceIds).join(",")}`); - }); - const archiveAgent = vi.fn(async () => { - events.push("close:start"); - await emitWorkspaceUpdatesForWorkspaceIds(affectedIds); - events.push("close:end"); - return { archivedAt: new Date().toISOString() }; - }); - const archiveSnapshot = vi.fn(async () => { - throw new Error("not expected for live agents"); - }); await handlePaseoWorktreeArchiveRequest( { @@ -2025,75 +1712,38 @@ describe("archivePaseoWorktree", () => { listWorktrees: vi.fn(async () => []), }, agentManager: { - listAgents: () => [liveAgent], - archiveAgent, - archiveSnapshot, + listAgents: () => [], + archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })), + archiveSnapshot: vi.fn(async () => { + throw new Error("not expected for empty agent list"); + }), }, agentStorage: createAgentStorageStub(), - findWorkspaceIdForCwd: vi.fn(async (cwd: string) => - cwd === created.worktreePath ? workspaceId : null, + findWorkspaceIdForCwd: vi.fn(async () => workspaceA), + listActiveWorkspaces, + archiveWorkspaceRecord: createArchiveWorkspaceRecordMutator( + activeWorkspaces, + archivedWorkspaceRecords, ), - listActiveWorkspaces: vi.fn(async () => []), - archiveWorkspaceRecord: vi.fn(async (archivedWorkspaceId: string) => { - archivedWorkspaceIds.add(archivedWorkspaceId); - }), emit: (message) => emitted.push(message), - emitWorkspaceUpdatesForWorkspaceIds, - markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => { - events.push(`mark:${Array.from(workspaceIds).join(",")}`); - for (const markedWorkspaceId of workspaceIds) { - archivingByWorkspaceId.set(markedWorkspaceId, archivingAt); - } - }, - clearWorkspaceArchiving: (workspaceIds: Iterable) => { - events.push(`clear:${Array.from(workspaceIds).join(",")}`); - for (const clearedWorkspaceId of workspaceIds) { - archivingByWorkspaceId.delete(clearedWorkspaceId); - } - }, + emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => {}), + markWorkspaceArchiving: vi.fn(), + clearWorkspaceArchiving: vi.fn(), killTerminalsForWorkspace: vi.fn(async () => {}), sessionLogger: createLogger(), }, { type: "paseo_worktree_archive_request", - requestId: "req-archive-marked-during-close", - worktreePath: created.worktreePath, + requestId: "req-worktree-scope", + worktreePath: sharedCwd, repoRoot: repoDir, + scope: "worktree", }, ); - const workspaceUpdates = emitted.filter( - (message): message is Extract => - message.type === "workspace_update", - ); - expect(events.slice(0, 3)).toEqual([ - `mark:${workspaceId}`, - `emit:${workspaceId}`, - "close:start", - ]); - expect(workspaceUpdates[0]?.payload).toEqual({ - kind: "upsert", - workspace: expect.objectContaining({ - id: workspaceId, - archivingAt: expect.any(String), - }), - }); - const archivingAt = - workspaceUpdates[0]?.payload.kind === "upsert" - ? workspaceUpdates[0].payload.workspace.archivingAt - : null; - expect(workspaceUpdates[1]?.payload).toEqual({ - kind: "upsert", - workspace: expect.objectContaining({ - id: workspaceId, - archivingAt, - }), - }); - expect(workspaceUpdates.at(-1)?.payload).toEqual({ - kind: "remove", - id: workspaceId, - }); - expect(events.slice(-2)).toEqual([`clear:${workspaceId}`, `emit:${workspaceId}`]); + expect(archivedWorkspaceRecords).toContain(workspaceA); + expect(archivedWorkspaceRecords).toContain(workspaceB); + expect(existsSync(sharedCwd)).toBe(false); expect( emitted.find((message) => message.type === "paseo_worktree_archive_response"), ).toMatchObject({ @@ -2104,27 +1754,34 @@ describe("archivePaseoWorktree", () => { }); }); - test("archives the workspace record but leaves the worktree on disk", async () => { + test("default scope archives a single workspace record and removes the directory on last reference", async () => { const { tempDir, repoDir } = createGitRepo(); cleanupPaths.push(tempDir); const paseoHome = path.join(tempDir, ".paseo"); const created = await createLegacyWorktreeForTest({ - branchName: "archive-keeps-disk", + branchName: "archive-default-scope", cwd: repoDir, baseBranch: "main", - worktreeSlug: "archive-keeps-disk", + worktreeSlug: "archive-default-scope", runSetup: false, paseoHome, }); - const workspaceId = "ws-archive-keeps-disk"; - const archiveWorkspaceRecord = vi.fn(async () => {}); + const workspaceId = "ws-default-scope"; + const activeWorkspaces = [ + { workspaceId, cwd: created.worktreePath, kind: "worktree" as const }, + ]; + const archivedWorkspaceRecords: string[] = []; + const emitted: SessionOutboundMessage[] = []; - await archivePaseoWorktree( + await handlePaseoWorktreeArchiveRequest( { paseoHome, github: createGitHubServiceStub(), - workspaceGitService: { getSnapshot: vi.fn(async () => null) }, + workspaceGitService: { + getSnapshot: vi.fn(async () => null), + listWorktrees: vi.fn(async () => []), + }, agentManager: { listAgents: () => [], archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })), @@ -2133,148 +1790,74 @@ describe("archivePaseoWorktree", () => { }), }, agentStorage: createAgentStorageStub(), - archiveWorkspaceRecord, - ...createWorkspaceArchivingDeps(), findWorkspaceIdForCwd: vi.fn(async (cwd: string) => cwd === created.worktreePath ? workspaceId : null, ), + listActiveWorkspaces: vi.fn(async () => activeWorkspaces), + archiveWorkspaceRecord: vi.fn(async (id: string) => { + archivedWorkspaceRecords.push(id); + if (activeWorkspaces[0]?.workspaceId === id) { + activeWorkspaces.splice(0, 1); + } + }), + emit: (message) => emitted.push(message), + emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => {}), + markWorkspaceArchiving: vi.fn(), + clearWorkspaceArchiving: vi.fn(), killTerminalsForWorkspace: vi.fn(async () => {}), sessionLogger: createLogger(), }, { - targetPath: created.worktreePath, + type: "paseo_worktree_archive_request", + requestId: "req-default-scope", + worktreePath: created.worktreePath, repoRoot: repoDir, - requestId: "req-archive-keeps-disk", - }, - ); - - expect(existsSync(created.worktreePath)).toBe(true); - expect(archiveWorkspaceRecord).toHaveBeenCalledWith(workspaceId); - }); - - test("forces a workspace git snapshot refresh after archive", async () => { - const { tempDir, repoDir } = createGitRepo(); - cleanupPaths.push(tempDir); - - const paseoHome = path.join(tempDir, ".paseo"); - const created = await createLegacyWorktreeForTest({ - branchName: "archive-refresh", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "archive-refresh", - runSetup: false, - paseoHome, - }); - const workspaceGitService = { - getSnapshot: vi.fn(async () => null), - }; - - await archivePaseoWorktree( - { - paseoHome, - github: createGitHubServiceStub(), - workspaceGitService: workspaceGitService as unknown as WorkspaceGitService, - agentManager: { - listAgents: () => [], - archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })), - archiveSnapshot: vi.fn(async () => { - throw new Error("not expected for empty agent list"); - }), - }, - agentStorage: createAgentStorageStub(), - archiveWorkspaceRecord: vi.fn(async () => {}), - ...createWorkspaceArchivingDeps(), - findWorkspaceIdForCwd: vi.fn(async (cwd: string) => - cwd === created.worktreePath ? "ws-archive-refresh" : null, - ), - killTerminalsForWorkspace: vi.fn(async () => {}), - sessionLogger: createLogger(), - }, - { - targetPath: created.worktreePath, - repoRoot: repoDir, - requestId: "req-archive-refresh", - }, - ); - - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, { - force: true, - reason: "archive-worktree", - }); - }); - - test("deletes the worktree from disk when asked and it is the last reference", async () => { - const { tempDir, repoDir } = createGitRepo(); - cleanupPaths.push(tempDir); - - const paseoHome = path.join(tempDir, ".paseo"); - const created = await createLegacyWorktreeForTest({ - branchName: "archive-delete-last-ref", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "archive-delete-last-ref", - runSetup: false, - paseoHome, - }); - const workspaceId = "ws-delete-last-ref"; - - await archivePaseoWorktree( - { - paseoHome, - github: createGitHubServiceStub(), - workspaceGitService: { getSnapshot: vi.fn(async () => null) }, - agentManager: { - listAgents: () => [], - archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })), - archiveSnapshot: vi.fn(async () => { - throw new Error("not expected for empty agent list"); - }), - }, - agentStorage: createAgentStorageStub(), - archiveWorkspaceRecord: vi.fn(async () => {}), - ...createWorkspaceArchivingDeps(), - findWorkspaceIdForCwd: vi.fn(async (cwd: string) => - cwd === created.worktreePath ? workspaceId : null, - ), - // No other active workspace references the worktree dir. - listActiveWorkspaces: vi.fn(async () => []), - killTerminalsForWorkspace: vi.fn(async () => {}), - sessionLogger: createLogger(), - }, - { - targetPath: created.worktreePath, - repoRoot: repoDir, - worktreesBaseRoot: path.join(paseoHome, "worktrees"), - deleteWorktreeFromDisk: true, - requestId: "req-delete-last-ref", }, ); + expect(archivedWorkspaceRecords).toEqual([workspaceId]); expect(existsSync(created.worktreePath)).toBe(false); + expect( + emitted.find((message) => message.type === "paseo_worktree_archive_response"), + ).toMatchObject({ + payload: { + success: true, + error: null, + }, + }); }); - test("keeps the worktree on disk when a sibling workspace still references it", async () => { + test("default scope keeps the directory when a sibling workspace still references it", async () => { const { tempDir, repoDir } = createGitRepo(); cleanupPaths.push(tempDir); const paseoHome = path.join(tempDir, ".paseo"); const created = await createLegacyWorktreeForTest({ - branchName: "archive-delete-sibling", + branchName: "archive-default-scope-sibling", cwd: repoDir, baseBranch: "main", - worktreeSlug: "archive-delete-sibling", + worktreeSlug: "archive-default-scope-sibling", runSetup: false, paseoHome, }); const sharedCwd = created.worktreePath; - const workspaceA = "ws-delete-A"; - const workspaceB = "ws-delete-B"; + const workspaceA = "ws-default-scope-sibling-A"; + const workspaceB = "ws-default-scope-sibling-B"; + const activeWorkspaces = [ + { workspaceId: workspaceA, cwd: sharedCwd, kind: "worktree" as const }, + { workspaceId: workspaceB, cwd: sharedCwd, kind: "local_checkout" as const }, + ]; + const archivedWorkspaceRecords: string[] = []; + const emitted: SessionOutboundMessage[] = []; - await archivePaseoWorktree( + await handlePaseoWorktreeArchiveRequest( { paseoHome, github: createGitHubServiceStub(), - workspaceGitService: { getSnapshot: vi.fn(async () => null) }, + workspaceGitService: { + getSnapshot: vi.fn(async () => null), + listWorktrees: vi.fn(async () => []), + }, agentManager: { listAgents: () => [], archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })), @@ -2283,76 +1866,131 @@ describe("archivePaseoWorktree", () => { }), }, agentStorage: createAgentStorageStub(), - archiveWorkspaceRecord: vi.fn(async () => {}), - ...createWorkspaceArchivingDeps(), findWorkspaceIdForCwd: vi.fn(async (cwd: string) => cwd === sharedCwd ? workspaceA : null, ), - // Sibling workspace B still references the same worktree directory. - listActiveWorkspaces: vi.fn(async () => [{ workspaceId: workspaceB, cwd: sharedCwd }]), + listActiveWorkspaces: vi.fn(async () => activeWorkspaces), + archiveWorkspaceRecord: vi.fn(async (id: string) => { + archivedWorkspaceRecords.push(id); + if (activeWorkspaces[0]?.workspaceId === id) { + activeWorkspaces.splice(0, 1); + } + }), + emit: (message) => emitted.push(message), + emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => {}), + markWorkspaceArchiving: vi.fn(), + clearWorkspaceArchiving: vi.fn(), killTerminalsForWorkspace: vi.fn(async () => {}), sessionLogger: createLogger(), }, { - targetPath: sharedCwd, + type: "paseo_worktree_archive_request", + requestId: "req-default-scope-sibling", + worktreePath: sharedCwd, repoRoot: repoDir, - worktreesBaseRoot: path.join(paseoHome, "worktrees"), - deleteWorktreeFromDisk: true, - requestId: "req-delete-sibling", }, ); + expect(archivedWorkspaceRecords).toEqual([workspaceA]); expect(existsSync(sharedCwd)).toBe(true); + expect( + emitted.find((message) => message.type === "paseo_worktree_archive_response"), + ).toMatchObject({ + payload: { + success: true, + error: null, + }, + }); }); - test("keeps the worktree on disk when deleteWorktreeFromDisk is not requested", async () => { + test("ignores deleteWorktreeFromDisk:true and derives directory removal from remaining references", async () => { const { tempDir, repoDir } = createGitRepo(); cleanupPaths.push(tempDir); const paseoHome = path.join(tempDir, ".paseo"); const created = await createLegacyWorktreeForTest({ - branchName: "archive-no-delete-flag", + branchName: "archive-delete-flag", cwd: repoDir, baseBranch: "main", - worktreeSlug: "archive-no-delete-flag", + worktreeSlug: "archive-delete-flag", runSetup: false, paseoHome, }); - const workspaceId = "ws-no-delete-flag"; - const listActiveWorkspaces = vi.fn(async () => []); + const sharedCwd = created.worktreePath; + const workspaceA = "ws-delete-flag-a"; + const workspaceB = "ws-delete-flag-b"; + const activeWorkspaces = [ + { workspaceId: workspaceA, cwd: sharedCwd, kind: "worktree" as const }, + { workspaceId: workspaceB, cwd: sharedCwd, kind: "worktree" as const }, + ]; + const archivedWorkspaceRecords: string[] = []; + const emitted: SessionOutboundMessage[] = []; + const listActiveWorkspaces = vi.fn(async () => activeWorkspaces); - await archivePaseoWorktree( - { - paseoHome, - github: createGitHubServiceStub(), - workspaceGitService: { getSnapshot: vi.fn(async () => null) }, - agentManager: { - listAgents: () => [], - archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })), - archiveSnapshot: vi.fn(async () => { - throw new Error("not expected for empty agent list"); - }), - }, - agentStorage: createAgentStorageStub(), - archiveWorkspaceRecord: vi.fn(async () => {}), - ...createWorkspaceArchivingDeps(), - findWorkspaceIdForCwd: vi.fn(async (cwd: string) => - cwd === created.worktreePath ? workspaceId : null, - ), - listActiveWorkspaces, - killTerminalsForWorkspace: vi.fn(async () => {}), - sessionLogger: createLogger(), + const deps = { + paseoHome, + github: createGitHubServiceStub(), + workspaceGitService: { + getSnapshot: vi.fn(async () => null), + listWorktrees: vi.fn(async () => []), }, - { - targetPath: created.worktreePath, - repoRoot: repoDir, - worktreesBaseRoot: path.join(paseoHome, "worktrees"), - requestId: "req-no-delete-flag", + agentManager: { + listAgents: () => [], + archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })), + archiveSnapshot: vi.fn(async () => { + throw new Error("not expected for empty agent list"); + }), }, - ); + agentStorage: createAgentStorageStub(), + findWorkspaceIdForCwd: vi.fn(async (cwd: string) => (cwd === sharedCwd ? workspaceA : null)), + listActiveWorkspaces, + archiveWorkspaceRecord: createArchiveWorkspaceRecordMutator( + activeWorkspaces, + archivedWorkspaceRecords, + ), + emit: (message: SessionOutboundMessage) => emitted.push(message), + emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => {}), + markWorkspaceArchiving: vi.fn(), + clearWorkspaceArchiving: vi.fn(), + killTerminalsForWorkspace: vi.fn(async () => {}), + sessionLogger: createLogger(), + }; - // Default (false): the directory survives. The explicit workspaceId is - // resolved by path, so no last-reference deletion runs. - expect(existsSync(created.worktreePath)).toBe(true); + // First archive: a sibling workspace still references the directory, so the + // retained deleteWorktreeFromDisk:true flag must NOT force removal. + await handlePaseoWorktreeArchiveRequest(deps, { + type: "paseo_worktree_archive_request", + requestId: "req-delete-flag-first", + worktreePath: sharedCwd, + repoRoot: repoDir, + workspaceId: workspaceA, + scope: "workspace", + deleteWorktreeFromDisk: true, + }); + + expect(archivedWorkspaceRecords).toEqual([workspaceA]); + expect(activeWorkspaces).toHaveLength(1); + expect(activeWorkspaces[0]?.workspaceId).toBe(workspaceB); + expect(existsSync(sharedCwd)).toBe(true); + + archivedWorkspaceRecords.length = 0; + + // Second archive: last reference, so removal is derived even though the flag + // is still ignored. + await handlePaseoWorktreeArchiveRequest(deps, { + type: "paseo_worktree_archive_request", + requestId: "req-delete-flag-second", + worktreePath: sharedCwd, + repoRoot: repoDir, + workspaceId: workspaceB, + scope: "workspace", + deleteWorktreeFromDisk: true, + }); + + expect(archivedWorkspaceRecords).toEqual([workspaceB]); + expect(existsSync(sharedCwd)).toBe(false); + expect( + emitted.filter((message) => message.type === "paseo_worktree_archive_response"), + ).toHaveLength(2); }); }); diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index d7b13ff38..758da42f0 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -40,10 +40,10 @@ import type { CreatePaseoWorktreeInput, CreatePaseoWorktreeResult, } from "./paseo-worktree-service.js"; -import type { ArchivePaseoWorktreeDependencies } from "./paseo-worktree-archive-service.js"; +import type { ArchiveDependencies } from "./workspace-archive-service.js"; import { toWorktreeWireError } from "./worktree-errors.js"; import { - archivePaseoWorktreeCommand, + archiveCommand, createPaseoWorktreeCommand, listPaseoWorktreesCommand, } from "./worktree/commands.js"; @@ -436,7 +436,7 @@ export async function handlePaseoWorktreeListRequest( export async function handlePaseoWorktreeArchiveRequest( dependencies: Omit< - ArchivePaseoWorktreeDependencies, + ArchiveDependencies, "emitWorkspaceUpdatesForWorkspaceIds" | "workspaceGitService" > & { emit: EmitSessionMessage; @@ -448,13 +448,13 @@ export async function handlePaseoWorktreeArchiveRequest( const { requestId } = msg; try { - const result = await archivePaseoWorktreeCommand(dependencies, { + const result = await archiveCommand(dependencies, { requestId, worktreePath: msg.worktreePath, repoRoot: msg.repoRoot, branchName: msg.branchName, workspaceId: msg.workspaceId, - deleteWorktreeFromDisk: msg.deleteWorktreeFromDisk, + scope: msg.scope, }); if (!result.ok) { dependencies.emit({ diff --git a/packages/server/src/server/worktree/commands.ts b/packages/server/src/server/worktree/commands.ts index 9cda1dbe2..3fa7f601a 100644 --- a/packages/server/src/server/worktree/commands.ts +++ b/packages/server/src/server/worktree/commands.ts @@ -2,9 +2,11 @@ import { join } from "node:path"; import { getPaseoWorktreesRoot, isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js"; import { - archivePaseoWorktree, - type ArchivePaseoWorktreeDependencies, -} from "../paseo-worktree-archive-service.js"; + archiveByScope, + resolveWorkspaceIdAtPath, + type ArchiveDependencies, + type ArchiveScope, +} from "../workspace-archive-service.js"; import type { CreatePaseoWorktreeInput, CreatePaseoWorktreeResult, @@ -87,24 +89,24 @@ export async function createPaseoWorktreeCommand { workspaceGitService: Pick; } -export interface ArchivePaseoWorktreeCommandInput { +export interface ArchiveCommandInput { requestId: string; repoRoot?: string | null; worktreePath?: string; worktreeSlug?: string; branchName?: string; workspaceId?: string; - deleteWorktreeFromDisk?: boolean; + scope?: ArchiveScope["kind"]; } -export type ArchivePaseoWorktreeCommandResult = +export type ArchiveCommandResult = | { ok: true; removedAgents: string[]; @@ -116,39 +118,66 @@ export type ArchivePaseoWorktreeCommandResult = removedAgents: []; }; -export async function archivePaseoWorktreeCommand( - dependencies: ArchivePaseoWorktreeCommandDependencies, - input: ArchivePaseoWorktreeCommandInput, -): Promise { +export async function archiveCommand( + dependencies: ArchiveCommandDependencies, + input: ArchiveCommandInput, +): Promise { const resolvedTarget = await resolveArchiveTarget(dependencies, input); - const ownership = await isPaseoOwnedWorktreeCwd(resolvedTarget.targetPath, { - paseoHome: dependencies.paseoHome, - worktreesRoot: dependencies.worktreesRoot, - }); + const scope = input.scope ?? "workspace"; + + if (scope === "worktree") { + const ownership = await isPaseoOwnedWorktreeCwd(resolvedTarget.targetPath, { + paseoHome: dependencies.paseoHome, + worktreesRoot: dependencies.paseoWorktreesBaseRoot, + }); + + if (!ownership.allowed) { + return { + ok: false, + code: "NOT_ALLOWED", + message: "Worktree is not a Paseo-owned worktree", + removedAgents: [], + }; + } + + const result = await archiveByScope(dependencies, { + scope: { kind: "worktree", targetPath: resolvedTarget.targetPath }, + repoRoot: ownership.repoRoot ?? resolvedTarget.repoRoot ?? null, + repoWorktreesRoot: ownership.worktreeRoot, + paseoWorktreesBaseRoot: dependencies.paseoWorktreesBaseRoot, + requestId: input.requestId, + }); - if (!ownership.allowed) { return { - ok: false, - code: "NOT_ALLOWED", - message: "Worktree is not a Paseo-owned worktree", + ok: true, + removedAgents: result.archivedAgentIds, + }; + } + + const workspaceId = + input.workspaceId ?? (await resolveWorkspaceIdAtPath(dependencies, resolvedTarget.targetPath)); + + if (!workspaceId) { + dependencies.sessionLogger?.warn( + { targetPath: resolvedTarget.targetPath }, + "Could not resolve workspace for archive; skipping", + ); + return { + ok: true, removedAgents: [], }; } - const repoRoot = ownership.repoRoot ?? resolvedTarget.repoRoot ?? null; - const removedAgents = await archivePaseoWorktree(dependencies, { - targetPath: resolvedTarget.targetPath, - repoRoot, - worktreesRoot: ownership.worktreeRoot, - worktreesBaseRoot: dependencies.worktreesRoot, - workspaceId: input.workspaceId, - deleteWorktreeFromDisk: input.deleteWorktreeFromDisk, + const result = await archiveByScope(dependencies, { + scope: { kind: "workspace", workspaceId }, + repoRoot: resolvedTarget.repoRoot, + paseoWorktreesBaseRoot: dependencies.paseoWorktreesBaseRoot, requestId: input.requestId, }); return { ok: true, - removedAgents, + removedAgents: result.archivedAgentIds, }; } @@ -158,8 +187,8 @@ interface ResolvedArchiveTarget { } async function resolveArchiveTarget( - dependencies: ArchivePaseoWorktreeCommandDependencies, - input: ArchivePaseoWorktreeCommandInput, + dependencies: ArchiveCommandDependencies, + input: ArchiveCommandInput, ): Promise { const repoRoot = input.repoRoot ?? null; if (input.worktreePath) { @@ -189,14 +218,14 @@ async function resolveArchiveTarget( } async function resolveWorktreeSlugPath( - dependencies: ArchivePaseoWorktreeCommandDependencies, + dependencies: ArchiveCommandDependencies, repoRoot: string, worktreeSlug: string, ): Promise { const worktreesRoot = await getPaseoWorktreesRoot( repoRoot, dependencies.paseoHome, - dependencies.worktreesRoot, + dependencies.paseoWorktreesBaseRoot, ); return join(worktreesRoot, worktreeSlug); } diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index 409d32692..74556965f 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -13,7 +13,7 @@ import type { UnsubscribeTerminalRequest, UnsubscribeTerminalsRequest, } from "../server/messages.js"; -import { killTerminalsForWorkspace as killWorkspaceTerminals } from "../server/paseo-worktree-archive-service.js"; +import { killTerminalsForWorkspace as killWorkspaceTerminals } from "../server/workspace-archive-service.js"; import { TerminalStreamOpcode, decodeTerminalResizePayload,