Remove a worktree when its last workspace is archived (#1562)

* Remove a worktree when its last workspace is archived

Archiving is now workspace-centric: the UI always archives a workspace,
and a worktree's directory is removed only when its last referencing
workspace is archived (derived from ownership + a single reference
count). The "remove the worktree from disk?" prompt is gone; archiving
warns only when the workspace is dirty or has unpushed commits.

MCP archive_worktree and the CLI keep the explicit lower-level behavior:
archive every workspace backing the directory, then remove it.
Auto-archive-after-merge and create-agent auto-archive respect the
reference count like any other workspace archival.

The policy now lives in two modules - a server archive service and a
client archive hook - with the reference-count check in exactly one
place. The deleteWorktreeFromDisk request field is retained but ignored
for wire back-compat.

* Tidy archive-policy internals and close test gaps

Follow-up polish on the archive-policy consolidation; no behavior change.

Structure: the server archive service is renamed to reflect that it now
owns all archive policy (the file and its types no longer say "worktree"
where they mean "archive"); the client archive input derives the
workspace kind from the canonical type instead of re-listing it; the
not-found target is typed string|null instead of an empty-string
sentinel; a redundant workspace-update emit and the ambiguous
worktrees-root naming are cleaned up. Wire, SDK, and keybinding names are
deliberately left unchanged for back-compat.

Tests: re-home the archiving-state lifecycle and per-workspace snapshot
assertions; pin worktree-kind acceptance through archive_workspace, the
ignored deleteWorktreeFromDisk field, and three-workspace archive-all;
add a Playwright spec for the dirty/unpushed worktree archive flow (the
warning gates it, confirming removes the directory) and the bulk
remove-project confirm branch.

* Fix the hanging worktree-archive risk-warning e2e spec

The spec added in the previous commit hung deterministically, locally and
in CI. Two test-only bugs, no product change.

The archive click opens a synchronous window.confirm(); the spec
registered a passive dialog waiter but only dismissed the dialog after
awaiting the click, so the click never resolved (Playwright won't settle
a click while a dialog is open) - a circular wait. Answer the dialog
inline via page.once("dialog", ...) before the click resolves, matching
the proven worktree-archive spec.

The seed also committed on a never-pushed branch, so aheadOfOrigin was
null and the "1 unpushed commit" warning never appeared. Push the branch
to set its upstream before the local commit, so it reports exactly one
unpushed commit.

Verified: the spec now passes in ~6s (was a 45s timeout); both the
dismiss-gates and accept-removes paths run.
This commit is contained in:
Mohamed Boudra
2026-06-17 00:38:56 +08:00
committed by GitHub
parent 0fef66283a
commit e202ca5036
45 changed files with 2627 additions and 1610 deletions

View File

@@ -9,6 +9,7 @@ type NewWorkspaceDaemonClient = Pick<
InternalDaemonClient,
| "archivePaseoWorktree"
| "archiveWorkspace"
| "checkoutRefresh"
| "close"
| "connect"
| "createPaseoWorktree"

View File

@@ -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<void> {
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<v
// The workspace row kebab and its menu items carry no web ARIA role, so the sidebar
// suite addresses them by the stable test ids the app assigns per workspace — the same
// convention the rename flow uses. The kebab only reveals on hover.
export async function clickArchiveWorkspaceMenuItem(
page: Page,
workspaceId: string,
): Promise<void> {
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<void> {
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<void> {
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(

View File

@@ -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<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
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);
});
});

View File

@@ -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<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
worktreeDirectory: string,
): Promise<void> {
// The daemon only reports unpushed commits when the branch has a configured
// upstream (aheadOfOrigin is computed against `branch.<name>.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<Dialog> {
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<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
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);
});
});

View File

@@ -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}
/>
<WorktreeDeletePrompt
visible={archiveController.deletePromptOpen}
workspaceName={workspace.name}
onKeep={archiveController.confirmKeepOnDisk}
onDelete={archiveController.confirmDeleteFromDisk}
onCancel={archiveController.cancelDeletePrompt}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
title={t("sidebar.workspace.rename.title")}
@@ -1927,16 +1926,22 @@ function ProjectBlock({
}
setIsRemovingProject(true);
void archiveWorkspacesOptimistically({
client,
workspaces: project.workspaces,
}).then((failures) => {
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]);

View File

@@ -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}
/>
<WorktreeDeletePrompt
visible={archiveController.deletePromptOpen}
workspaceName={workspace.name}
onKeep={archiveController.confirmKeepOnDisk}
onDelete={archiveController.confirmDeleteFromDisk}
onCancel={archiveController.cancelDeletePrompt}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
title="Rename workspace"

View File

@@ -252,7 +252,6 @@ interface CheckoutGitActionsStoreState {
cwd: string;
worktreePath: string;
workspaceId?: string;
deleteWorktreeFromDisk?: boolean;
}) => Promise<void>;
}
@@ -492,7 +491,7 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
});
},
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<CheckoutGitActionsStoreState>()
const payload = await client.archivePaseoWorktree({
worktreePath,
...(workspaceId !== undefined ? { workspaceId } : {}),
deleteWorktreeFromDisk,
});
if (payload.error) {
throw new Error(payload.error.message);

View File

@@ -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 ? <GitActionsSplitButton gitActions={gitActions} /> : null}
{worktreeDeletePrompt}
</View>
) : null}

View File

@@ -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 = (
<WorktreeDeletePrompt
visible={state !== null}
workspaceName={state?.workspaceName ?? ""}
onKeep={onKeep}
onDelete={onDelete}
onCancel={onCancel}
/>
);
return { open, element };
}
type PrStatusValue = NonNullable<CheckoutPrStatusPayload["status"]> | 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, unknown>) => 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(

View File

@@ -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 (
<>
<GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />
{worktreeDeletePrompt}
</>
);
return <GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />;
}

View File

@@ -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 },
});
});
});

View File

@@ -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,

View File

@@ -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");

View File

@@ -814,12 +814,6 @@ export const ar: TranslationResources = {
'إخفاء "{{workspaceName}}" من الشريط الجانبي؟\n\n لن يتم تغيير الملفات الموجودة على القرص.',
hideConfirm: "يخفي",
cancel: "يلغي",
deleteWorktreePrompt: {
title: "أرشفة مساحة العمل",
message: "هل تريد أيضًا إزالة شجرة العمل من القرص؟",
keep: "الاحتفاظ على القرص",
delete: "حذف",
},
},
rename: {
title: "إعادة تسمية مساحة العمل",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -832,12 +832,6 @@ export const ru: TranslationResources = {
"Скрыть «{{workspaceName}}» на боковой панели?\n\n Файлы на диске не будут изменены.",
hideConfirm: "Скрывать",
cancel: "Отмена",
deleteWorktreePrompt: {
title: "Архивировать рабочее пространство",
message: "Также удалить рабочее дерево с диска?",
keep: "Оставить на диске",
delete: "Удалить",
},
},
rename: {
title: "Переименовать рабочую область",

View File

@@ -805,12 +805,6 @@ export const zhCN: TranslationResources = {
hideMessage: "从侧边栏隐藏「{{workspaceName}}」?\n\n磁盘上的文件不会被更改。",
hideConfirm: "隐藏",
cancel: "取消",
deleteWorktreePrompt: {
title: "归档 workspace",
message: "同时从磁盘删除 worktree",
keep: "保留在磁盘上",
delete: "删除",
},
},
rename: {
title: "重命名 workspace",

View File

@@ -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",
},
]);
});
});

View File

@@ -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<WorkspaceArchiveTarget[]> {
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;
}

View File

@@ -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,
};
}

View File

@@ -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 (
<AdaptiveModalSheet
header={header}
visible={visible}
onClose={onCancel}
scrollable={false}
desktopMaxWidth={420}
testID="worktree-delete-confirm"
>
<View style={styles.body}>
<Text style={styles.message}>
{t("sidebar.workspace.confirmations.deleteWorktreePrompt.message", {
workspaceName,
})}
</Text>
<View style={styles.actions}>
<Button variant="default" onPress={onKeep} testID="worktree-delete-confirm-keep">
{t("sidebar.workspace.confirmations.deleteWorktreePrompt.keep")}
</Button>
<Button variant="destructive" onPress={onDelete} testID="worktree-delete-confirm-delete">
{t("sidebar.workspace.confirmations.deleteWorktreePrompt.delete")}
</Button>
</View>
</View>
</AdaptiveModalSheet>
);
}
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],
},
}));

View File

@@ -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, "getPaseoWorktreeList" | "archivePaseoWorktree" | "close">
> = {},
): 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<DaemonClient["archivePaseoWorktree"]>[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<DaemonClient["archivePaseoWorktree"]>[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",
});
});
});

View File

@@ -39,6 +39,14 @@ export async function runArchiveCommand(
nameArg: string,
options: WorktreeArchiveOptions,
_command: Command,
): Promise<WorktreeArchiveCommandResult> {
return runArchiveCommandWithDeps(nameArg, options, { connectToDaemon });
}
export async function runArchiveCommandWithDeps(
nameArg: string,
options: WorktreeArchiveOptions,
deps: { connectToDaemon: typeof connectToDaemon },
): Promise<WorktreeArchiveCommandResult> {
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();

View File

@@ -3212,7 +3212,7 @@ export class DaemonClient {
repoRoot?: string;
branchName?: string;
workspaceId?: string;
deleteWorktreeFromDisk?: boolean;
scope?: "workspace" | "worktree";
},
requestId?: string,
): Promise<PaseoWorktreeArchivePayload> {
@@ -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,

View File

@@ -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<string, unknown> = {}) {
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");
});
});

View File

@@ -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(),
});

View File

@@ -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);
}

View File

@@ -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(

View File

@@ -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,

View File

@@ -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<WorkspaceGitRuntimeSnapshot | null>;
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<WorktreeConfig> {
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<string>;
}) {
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<StoredAgentRecord[]> => [],
} 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<string>(),
};
}
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<string>();
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<string>();
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);
});
});

View File

@@ -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",
},
);

View File

@@ -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,

View File

@@ -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<WorkspaceGitService, "getSnapshot">;
agentManager: Pick<AgentManager, "listAgents" | "archiveAgent" | "archiveSnapshot">;
agentStorage: Pick<AgentStorage, "list">;
// 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<string | null>;
// 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<ActiveWorkspaceRef[]>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
killTerminalsForWorkspace: (workspaceId: string) => Promise<void>;
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<string[]> {
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<string>();
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<string | null> {
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<Set<string>> {
const archivedAgents = new Set<string>();
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<void> {
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<void> {
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",
);
}
}),
);
}

View File

@@ -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 () => {

View File

@@ -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: {

View File

@@ -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<WorkspaceGitRuntimeSnapshot> => ({
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<string, unknown> }
| 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();

View File

@@ -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);

View File

@@ -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<WorktreeConfig> {
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<string | null>;
}
interface ArchiveTestDependencies extends ArchiveDependencies {
activeWorkspaces: ActiveWorkspaceRef[];
archivedAgentIds: string[];
archivedSnapshotIds: string[];
}
function createArchiveDeps(input: ArchiveDepsInput): ArchiveTestDependencies {
const archivedWorkspaceIds = new Set<string>();
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<WorkspaceGitService, "getSnapshot">,
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<StoredAgentRecord[]> => [],
} as Pick<AgentStorage, "list">,
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<string, string>();
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<string>, 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<string>) => {
for (const id of workspaceIds) {
archivingByWorkspaceId.delete(id);
}
events.push({ type: "clear", workspaceIds: Array.from(workspaceIds) });
});
deps.emitWorkspaceUpdatesForWorkspaceIds = vi.fn(async (workspaceIds: Iterable<string>) => {
const ids = Array.from(workspaceIds);
const activeIds = new Set<string>();
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<LifecycleEvent, { type: "emit" }>;
expect(firstEmit.workspaceIds).toEqual([workspaceId]);
expect(firstEmit.updates).toEqual([
{ kind: "upsert", workspaceId, archivingAt: expect.any(String) },
]);
const secondEmit = events[4] as Extract<LifecycleEvent, { type: "emit" }>;
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<AgentStorage, "list">;
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");
});
});

View File

@@ -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<WorkspaceGitService, "getSnapshot">;
agentManager: Pick<AgentManager, "listAgents" | "archiveAgent" | "archiveSnapshot">;
agentStorage: Pick<AgentStorage, "list">;
// 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<string | null>;
// 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<ActiveWorkspaceRef[]>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
killTerminalsForWorkspace: (workspaceId: string) => Promise<void>;
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<ArchiveDependencies, "findWorkspaceIdForCwd" | "listActiveWorkspaces">,
targetPath: string,
): Promise<string | null> {
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<ArchiveResult> {
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<string>; archivedWorkspaceIds: string[] }> {
const archivedAgents = new Set<string>();
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<ArchiveByScopeRequest, "scope">,
targetDir: string,
archivedWorkspaceIds: string[],
): Promise<boolean> {
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<Set<string>> {
const archivedAgents = new Set<string>();
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<string>,
): 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<void> {
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.

View File

@@ -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<AgentStorage, "list"> {
};
}
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<string, number> = {};
const teardownEndTimes: Record<string, number> = {};
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<string>();
const archivingByWorkspaceId = new Map<string, string>();
const emitWorkspaceUpdatesForWorkspaceIds = vi.fn(async (workspaceIds: Iterable<string>) => {
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<string>, archivingAt: string) => {
events.push(`mark:${Array.from(workspaceIds).join(",")}`);
for (const markedWorkspaceId of workspaceIds) {
archivingByWorkspaceId.set(markedWorkspaceId, archivingAt);
}
},
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => {
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<SessionOutboundMessage, { type: "workspace_update" }> =>
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);
});
});

View File

@@ -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({

View File

@@ -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<Result extends CreatePaseoWorkt
}
}
export interface ArchivePaseoWorktreeCommandDependencies extends Omit<
ArchivePaseoWorktreeDependencies,
export interface ArchiveCommandDependencies extends Omit<
ArchiveDependencies,
"workspaceGitService"
> {
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
}
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<ArchivePaseoWorktreeCommandResult> {
export async function archiveCommand(
dependencies: ArchiveCommandDependencies,
input: ArchiveCommandInput,
): Promise<ArchiveCommandResult> {
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<ResolvedArchiveTarget> {
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<string> {
const worktreesRoot = await getPaseoWorktreesRoot(
repoRoot,
dependencies.paseoHome,
dependencies.worktreesRoot,
dependencies.paseoWorktreesBaseRoot,
);
return join(worktreesRoot, worktreeSlug);
}

View File

@@ -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,