From 6c1b48d2962ec6a48d5a553e312dfcc1f94e2e7a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 16:16:11 +0700 Subject: [PATCH] Archive a workspace from its sidebar row, with undo (#8904) * feat(app): archive a workspace from its sidebar row and undo it Hovering a workspace row on desktop shows an archive button next to the row menu. Archiving shows a toast with Undo and a Cmd/Ctrl+Z shortcut so a mis-click costs one keystroke instead of a trip through History. The daemon serializes archive and undo per workspace across every connection. An undo that lands while an archive is still tearing down aborts it at the next checkpoint and restores only the agents that archive took down; deleting the worktree directory is the point of no return, after which undo recreates the worktree. * fix(server,app): close archive and undo races found in review - An archive request now claims the workspace's archive queue before looking up the workspace, so an undo sent right behind it cancels it instead of running first and losing to the archive. - A repeat archive of an already archived workspace no longer wipes the agents the earlier archive saved for undo. - Undo no longer brings back an agent that was archived before the workspace archive and only loaded to read its history. - Each local archive or undo request owns its own intent token, so a late response to an earlier request cannot release a newer request's hold on the row. An archive canceled by another client's undo puts the row back. - The undo failure toast shows translated copy; the cause goes to the log. * fix(app): keep a workspace's layout when another client restores it during the undo window The undo window purged tabs and layout whenever it closed after a finished archive. If another client restored the workspace before this client's toast closed, the restored workspace lost its saved arrangement. The archive now commits only if the workspace is still gone when the window closes. * fix(app): show the archived toast's Undo as a button * fix(app): keep a workspace's agent tabs when it is opened while its undo runs * fix(app): keep a row whose undo is running when a directory snapshot omits it --- docs/agent-lifecycle.md | 21 ++ docs/keyboard-shortcuts.md | 7 + .../browser/workspace-archive-undo.spec.ts | 186 +++++++++++ .../support/helpers/workspace-archive-undo.ts | 97 ++++++ packages/app/src/app/_layout.tsx | 2 + .../src/components/sidebar-workspace-list.tsx | 3 + .../sidebar/sidebar-status-list.tsx | 6 + .../sidebar/sidebar-workspace-menu.tsx | 48 ++- .../sidebar/sidebar-workspace-row-content.tsx | 45 ++- .../sidebar/sidebar-workspace-row.tsx | 3 + packages/app/src/components/toast-host.tsx | 81 +++-- .../components/ui/trailing-action-scrim.tsx | 28 +- .../session-workspace-upserts.test.ts | 41 +++ .../src/contexts/session-workspace-upserts.ts | 163 +++++++--- packages/app/src/i18n/resources/ar.ts | 3 + packages/app/src/i18n/resources/en.ts | 3 + packages/app/src/i18n/resources/es.ts | 3 + packages/app/src/i18n/resources/fr.ts | 3 + packages/app/src/i18n/resources/ja.ts | 3 + packages/app/src/i18n/resources/ko.ts | 3 + packages/app/src/i18n/resources/pt-BR.ts | 3 + packages/app/src/i18n/resources/ru.ts | 3 + packages/app/src/i18n/resources/zh-CN.ts | 3 + packages/app/src/keyboard/actions.ts | 1 + .../keyboard/keyboard-action-dispatcher.ts | 2 + .../src/keyboard/keyboard-shortcuts.test.ts | 19 ++ .../app/src/keyboard/keyboard-shortcuts.ts | 26 ++ packages/app/src/keyboard/route-shortcut.ts | 1 + .../directory-sync/workspace-replica.test.ts | 52 +++ .../directory-sync/workspace-replica.ts | 16 + .../screens/workspace/workspace-screen.tsx | 9 +- .../app/src/workspace/archive-undo/index.ts | 8 + .../archive-undo/internal/capability.ts | 8 + .../internal/undo-shortcut-host.tsx | 22 ++ .../archive-undo/internal/undo-window.test.ts | 301 ++++++++++++++++++ .../archive-undo/internal/undo-window.tsx | 129 ++++++++ .../use-workspace-archive-undo-running.ts | 21 ++ .../internal/workspace-archived-toast.tsx | 41 +++ .../src/workspace/use-workspace-archive.ts | 22 +- .../src/workspace/workspace-archive.test.ts | 142 +++++++++ .../app/src/workspace/workspace-archive.ts | 121 +++++-- packages/client/src/daemon-client.ts | 17 + packages/protocol/src/messages.ts | 26 ++ .../server/src/server/agent/agent-manager.ts | 8 +- .../authorization/operation-permissions.ts | 2 + packages/server/src/server/session.ts | 169 ++++++++-- .../server/src/server/websocket-server.ts | 7 + .../workspace-archive-operations.test.ts | 282 ++++++++++++++++ .../server/workspace-archive-operations.ts | 133 ++++++++ .../server/workspace-archive-service.test.ts | 202 ++++++++++++ .../src/server/workspace-archive-service.ts | 127 ++++++-- .../server/workspace-archive-undo.e2e.test.ts | 275 ++++++++++++++++ 52 files changed, 2767 insertions(+), 180 deletions(-) create mode 100644 packages/app/e2e/browser/workspace-archive-undo.spec.ts create mode 100644 packages/app/e2e/support/helpers/workspace-archive-undo.ts create mode 100644 packages/app/src/workspace/archive-undo/index.ts create mode 100644 packages/app/src/workspace/archive-undo/internal/capability.ts create mode 100644 packages/app/src/workspace/archive-undo/internal/undo-shortcut-host.tsx create mode 100644 packages/app/src/workspace/archive-undo/internal/undo-window.test.ts create mode 100644 packages/app/src/workspace/archive-undo/internal/undo-window.tsx create mode 100644 packages/app/src/workspace/archive-undo/internal/use-workspace-archive-undo-running.ts create mode 100644 packages/app/src/workspace/archive-undo/internal/workspace-archived-toast.tsx create mode 100644 packages/server/src/server/workspace-archive-operations.test.ts create mode 100644 packages/server/src/server/workspace-archive-operations.ts create mode 100644 packages/server/src/server/workspace-archive-undo.e2e.test.ts diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 45c54ca18b..c0aad974f6 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -259,6 +259,27 @@ archived workspace. History navigation must not infer workspace lifecycle from ` or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state; only the route's explicit Unarchive or Restore action changes the archived workspace. +Workspace archive is undoable from the archived toast, or with Cmd/Ctrl+Z, while the toast is up. +The daemon runs one archive lane per workspace, shared by every connection +(`packages/server/src/server/workspace-archive-operations.ts`): + +| A request arrives while | Result | +| ------------------------ | --------------------------------------------------------- | +| an archive runs: archive | joins the running archive | +| an archive runs: undo | aborts the archive, waits for it to settle, then restores | +| an undo runs: archive | waits for the undo, then archives | +| an undo runs: undo | joins the running undo | + +A request takes its place in the lane before its first `await`. Arrival order decides, so an undo +sent right behind its archive still cancels it. + +An aborted archive stops at its next checkpoint and reports `canceled`; the undo unarchives only +the agents that archive itself archived. Deleting the worktree directory is the point of no +return: an undo after it recreates the worktree through workspace recovery. The client shows or +hides the row by the latest local intent, so a late remove or upsert from a superseded request +does not flip it back. Tabs and layout are purged only once the undo window closes with the +archive committed. + History navigation opens the selected agent without changing either archive state. Workspace **Restore** recovers only the workspace; the selected archived agent stays open with its callout. The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and diff --git a/docs/keyboard-shortcuts.md b/docs/keyboard-shortcuts.md index eac3ee8103..2c1045eb54 100644 --- a/docs/keyboard-shortcuts.md +++ b/docs/keyboard-shortcuts.md @@ -84,3 +84,10 @@ its DOM-derived scope. Escape interrupts an agent only in the message input or an unclaimed surface. A focused terminal excludes that binding. The source leaves terminal Escape alone; terminal Escape/Ctrl forwarding is a separate feature. + +## Keys shared with text editing + +A binding on a key the platform already uses for editing, such as Cmd/Ctrl+Z for +workspace archive undo, declares `editable: false` and registers its handler with +`isActive` true only while it has work. An inactive handler leaves the key +unhandled, so the pipeline skips `preventDefault` and the platform's own undo runs. diff --git a/packages/app/e2e/browser/workspace-archive-undo.spec.ts b/packages/app/e2e/browser/workspace-archive-undo.spec.ts new file mode 100644 index 0000000000..90e47aab3c --- /dev/null +++ b/packages/app/e2e/browser/workspace-archive-undo.spec.ts @@ -0,0 +1,186 @@ +import type { Page } from "@playwright/test"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { + archiveWorkspaceFromDaemon, + connectNewWorkspaceDaemonClient, + createWorktreeViaDaemon, + openProjectViaDaemon, +} from "../support/helpers/new-workspace"; +import { getServerId } from "../support/helpers/server-id"; +import { expectWorkspaceAbsentFromSidebar } from "../support/helpers/sidebar"; +import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab"; +import { connectSeedClient } from "../support/helpers/seed-client"; +import { createTempGitRepo } from "../support/helpers/workspace"; +import { getWorkspaceTabTestIds } from "../support/helpers/workspace-tabs"; +import { + archiveFromRowButton, + createGatedTeardown, + expectDirectoryPresence, + expectHostWorkspaceSettledActive, + outlastUndoWindow, + pressUndoShortcut, + rowArchiveButton, + rowKebab, + undoButton, + workspaceRow, + workspaceTitleWidth, +} from "../support/helpers/workspace-archive-undo"; +import { + waitForSidebarHydration, + waitForWorkspaceInSidebar, +} from "../support/helpers/workspace-ui"; + +test.describe("Workspace archive undo", () => { + let client: Awaited>; + let tempRepo: { path: string; cleanup: () => Promise }; + const worktreeDirectories = new Set(); + + test.describe.configure({ timeout: 120_000 }); + + test.beforeEach(async () => { + client = await connectNewWorkspaceDaemonClient(); + tempRepo = await createTempGitRepo("archive-undo-"); + }); + + test.afterEach(async () => { + for (const directory of worktreeDirectories) { + await archiveWorkspaceFromDaemon(client, directory).catch(() => undefined); + } + worktreeDirectories.clear(); + await client?.close().catch(() => undefined); + await tempRepo?.cleanup().catch(() => undefined); + }); + + async function seedWorktree(page: Page, slug: string) { + await openProjectViaDaemon(client, tempRepo.path); + const worktree = await createWorktreeViaDaemon(client, { + cwd: tempRepo.path, + slug: `${slug}-${Date.now()}`, + }); + worktreeDirectories.add(worktree.workspaceDirectory); + await gotoAppShell(page); + await waitForSidebarHydration(page); + await waitForWorkspaceInSidebar(page, { + serverId: getServerId(), + workspaceId: worktree.workspaceId, + }); + return worktree; + } + + test("the hover archive button overlays the title instead of narrowing it", async ({ page }) => { + const worktree = await seedWorktree(page, "archive-button-overlay"); + + await page.mouse.move(0, 0); + await expect(rowArchiveButton(page, worktree.workspaceId)).toHaveCount(0); + const restingWidth = await workspaceTitleWidth(page, worktree.workspaceId); + + await workspaceRow(page, worktree.workspaceId).hover(); + await expect(rowArchiveButton(page, worktree.workspaceId)).toBeVisible(); + await expect(page.getByTestId("sidebar-workspace-trailing-scrim")).toBeVisible(); + expect(await workspaceTitleWidth(page, worktree.workspaceId)).toBe(restingWidth); + + const [buttonBox, kebabBox] = await Promise.all([ + rowArchiveButton(page, worktree.workspaceId).boundingBox(), + rowKebab(page, worktree.workspaceId).boundingBox(), + ]); + expect(buttonBox && kebabBox && buttonBox.x + buttonBox.width <= kebabBox.x).toBe(true); + }); + + test("Cmd/Ctrl+Z right after archiving keeps the worktree", async ({ page }) => { + const worktree = await seedWorktree(page, "archive-undo-shortcut"); + + await archiveFromRowButton(page, worktree.workspaceId); + await pressUndoShortcut(page); + + await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible(); + await expect(undoButton(page)).toHaveCount(0); + await expectHostWorkspaceSettledActive(client, worktree.workspaceId); + // Once the host settled, any remove the canceled archive sent has already arrived. + await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible(); + await expectDirectoryPresence(worktree.workspaceDirectory, true); + }); + + test("Undo after the worktree was removed recreates it", async ({ page }) => { + const worktree = await seedWorktree(page, "archive-undo-recreate"); + + await archiveFromRowButton(page, worktree.workspaceId); + // Hovering the toast holds it, and with it the undo, open while the archive finishes. + await undoButton(page).hover(); + await expectDirectoryPresence(worktree.workspaceDirectory, false); + await undoButton(page).click(); + + await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible({ timeout: 30_000 }); + await expectDirectoryPresence(worktree.workspaceDirectory, true); + }); + + test("hovering Undo keeps the toast open past its deadline, and its close ends the undo", async ({ + page, + }) => { + await page.clock.install({ time: Date.now() }); + const worktree = await seedWorktree(page, "archive-undo-hold"); + + await archiveFromRowButton(page, worktree.workspaceId); + await page.getByTestId("workspace-archived-toast").hover({ position: { x: 8, y: 8 } }); + await undoButton(page).hover(); + await outlastUndoWindow(page); + await expect(undoButton(page)).toBeVisible(); + + await page.mouse.move(0, 0); + await outlastUndoWindow(page); + await expect(undoButton(page)).toHaveCount(0); + + // The toast took the undo with it. + await pressUndoShortcut(page); + await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId); + }); + + test("opening the workspace while its undo is still running keeps its tabs", async ({ page }) => { + const teardown = createGatedTeardown(); + const repo = await createTempGitRepo("archive-undo-tabs-", { + paseoConfig: teardown.paseoConfig, + }); + const seedClient = await connectSeedClient(); + try { + await openProjectViaDaemon(client, repo.path); + const worktree = await createWorktreeViaDaemon(client, { + cwd: repo.path, + slug: `archive-undo-tabs-${Date.now()}`, + }); + worktreeDirectories.add(worktree.workspaceDirectory); + const target = { cwd: worktree.workspaceDirectory, workspaceId: worktree.workspaceId }; + const agents = [ + await createMockIdleAgent(seedClient, { ...target, title: "first" }), + await createMockIdleAgent(seedClient, { ...target, title: "second" }), + ] as const; + await openWorkspaceWithAgents(page, [agents[0], agents[1]]); + const tabsBefore = await getWorkspaceTabTestIds(page); + + await archiveFromRowButton(page, worktree.workspaceId); + // The archive has taken its agents down and now parks in teardown. + await teardown.expectStarted(); + await undoButton(page).click(); + await workspaceRow(page, worktree.workspaceId).click(); + + for (const agent of agents) { + await expect(page.getByTestId(`workspace-tab-agent_${agent.id}`).first()).toBeVisible(); + } + teardown.release(); + await expectHostWorkspaceSettledActive(client, worktree.workspaceId); + await expect.poll(() => getWorkspaceTabTestIds(page)).toEqual(tabsBefore); + } finally { + teardown.release(); + await seedClient.close().catch(() => undefined); + await repo.cleanup().catch(() => undefined); + } + }); + + test("Cmd/Ctrl+Z without an archive to undo leaves the sidebar alone", async ({ page }) => { + const worktree = await seedWorktree(page, "archive-undo-none"); + + await pressUndoShortcut(page); + + await expect(undoButton(page)).toHaveCount(0); + await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible(); + }); +}); diff --git a/packages/app/e2e/support/helpers/workspace-archive-undo.ts b/packages/app/e2e/support/helpers/workspace-archive-undo.ts new file mode 100644 index 0000000000..0eadc58644 --- /dev/null +++ b/packages/app/e2e/support/helpers/workspace-archive-undo.ts @@ -0,0 +1,97 @@ +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { Page } from "@playwright/test"; +import { expect } from "../fixtures"; +import type { connectNewWorkspaceDaemonClient } from "./new-workspace"; +import { getServerId } from "./server-id"; +import { expectWorkspaceAbsentFromSidebar } from "./sidebar"; + +type DaemonClient = Awaited>; + +const UNDO_SHORTCUT = process.platform === "darwin" ? "Meta+Z" : "Control+Z"; +/** The archived toast's lifetime, from `WORKSPACE_ARCHIVE_UNDO_WINDOW_MS`. */ +const UNDO_WINDOW_MS = 6000; + +export function workspaceRow(page: Page, workspaceId: string) { + return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`); +} + +export function rowArchiveButton(page: Page, workspaceId: string) { + return page.getByTestId(`sidebar-workspace-archive-button-${getServerId()}:${workspaceId}`); +} + +export function rowKebab(page: Page, workspaceId: string) { + return page.getByTestId(`sidebar-workspace-kebab-${getServerId()}:${workspaceId}`); +} + +export function undoButton(page: Page) { + return page.getByRole("button", { name: /^Undo/ }); +} + +export async function workspaceTitleWidth(page: Page, workspaceId: string): Promise { + const box = await page + .getByTestId(`sidebar-workspace-title-${getServerId()}:${workspaceId}`) + .boundingBox(); + if (!box) throw new Error(`Title of ${workspaceId} is not laid out`); + return box.width; +} + +/** Archives a workspace with its row's hover button and waits for the undo toast. */ +export async function archiveFromRowButton(page: Page, workspaceId: string): Promise { + page.once("dialog", (dialog) => void dialog.accept()); + await workspaceRow(page, workspaceId).hover(); + await rowArchiveButton(page, workspaceId).click(); + await expectWorkspaceAbsentFromSidebar(page, workspaceId); + await expect(undoButton(page)).toBeVisible(); +} + +export async function pressUndoShortcut(page: Page): Promise { + await page.keyboard.press(UNDO_SHORTCUT); +} + +/** Moves the page clock past the moment an unheld archived toast would have closed. */ +export async function outlastUndoWindow(page: Page): Promise { + await page.clock.fastForward(UNDO_WINDOW_MS + 1000); +} + +/** Waits until the host lists the workspace as active with no archive running on it. */ +export async function expectHostWorkspaceSettledActive( + client: DaemonClient, + workspaceId: string, +): Promise { + await expect + .poll( + async () => { + const { entries } = await client.fetchWorkspaces(); + const entry = entries.find((candidate) => candidate.id === workspaceId); + return entry ? { active: true, archiving: Boolean(entry.archivingAt) } : null; + }, + { timeout: 30_000 }, + ) + .toEqual({ active: true, archiving: false }); +} + +export async function expectDirectoryPresence(directory: string, present: boolean): Promise { + await expect.poll(() => existsSync(directory), { timeout: 30_000 }).toBe(present); +} + +export interface GatedTeardown { + /** The `paseo.json` config whose worktree teardown parks until released. */ + paseoConfig: Record; + expectStarted(): Promise; + release(): void; +} + +/** A worktree teardown that parks an archive mid-flight, so an undo lands while it runs. */ +export function createGatedTeardown(): GatedTeardown { + const gateDir = mkdtempSync(path.join(tmpdir(), "archive-undo-gate-")); + const startedPath = path.join(gateDir, "started"); + const releasePath = path.join(gateDir, "release"); + const teardown = `node -e "const fs=require('fs');fs.writeFileSync('${startedPath}','started');const timer=setInterval(()=>{if(fs.existsSync('${releasePath}'))clearInterval(timer)},20)"`; + return { + paseoConfig: { worktree: { teardown: [teardown] } }, + expectStarted: () => expectDirectoryPresence(startedPath, true), + release: () => writeFileSync(releasePath, "release\n"), + }; +} diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 8548b4bb76..b4ca122f77 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -55,6 +55,7 @@ import { DesktopWindowControls } from "@/components/desktop/window-controls"; import { SidebarModelProvider } from "@/components/sidebar/sidebar-model"; import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler"; import { WorkspaceRenameHost } from "@/components/workspace-rename-host"; +import { WorkspaceArchiveUndoShortcutHost } from "@/workspace/archive-undo"; import { WorkspaceExplorerOpenGesture } from "@/mobile-panels/explorer-open-gesture"; import { ProviderSettingsHost } from "@/components/provider-settings-host"; import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog"; @@ -557,6 +558,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon + diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 5000e1f067..d6a810e74e 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -649,6 +649,7 @@ function WorkspaceRowRightGroup({ const { trailingPresentation, showKebab: showKebabInSlot, + showArchiveButton, showScrim, renderSlot, reserveSlotWidth, @@ -675,6 +676,8 @@ function WorkspaceRowRightGroup({ {onArchive ? ( {kebab.showKebab && onArchive ? ( ; const unpinLeadingIcon = ; +/** Pointer-only, so it is always the kebab's pointer size. */ +export const ARCHIVE_BUTTON_SIZE = 18; + const triggerIconProps = densityIconProps("sm", foregroundMutedColorMapping); const triggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping); @@ -79,6 +83,33 @@ function renderTriggerIcon({ hovered }: { hovered?: boolean }) { return ; } +function renderArchiveIcon({ hovered }: PressableStateCallbackType & { hovered?: boolean }) { + return ; +} + +/** One-click archive beside the kebab, for pointer rows that reveal it on hover. */ +export function SidebarWorkspaceArchiveButton({ + workspaceKey, + onArchive, +}: { + workspaceKey: string; + onArchive: () => void; +}) { + const { t } = useTranslation(); + return ( + + {renderArchiveIcon} + + ); +} + export interface SidebarWorkspaceMenuProps { workspaceKey: string; serverId?: string; @@ -437,6 +468,12 @@ function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovere return [styles.trigger, hovered && styles.triggerHovered]; } +function archiveButtonStyle({ + hovered = false, +}: PressableStateCallbackType & { hovered?: boolean }) { + return [styles.archiveButton, hovered && styles.triggerHovered]; +} + const styles = StyleSheet.create((theme) => ({ trigger: { // Larger on touch so the kebab is an easy tap target. @@ -455,4 +492,13 @@ const styles = StyleSheet.create((theme) => ({ triggerHovered: { backgroundColor: theme.colors.surface2, }, + // The kebab's box without its offsets: the archive glyph fills its view box, so it needs + // none of the nudging the three dots do. The kebab's marginLeft is the gap between them. + archiveButton: { + width: ARCHIVE_BUTTON_SIZE, + height: ARCHIVE_BUTTON_SIZE, + alignItems: "center", + justifyContent: "center", + borderRadius: 4, + }, })); diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx index 7fc904f3b3..aef846c4a3 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx @@ -29,7 +29,11 @@ import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; import { StatusRing } from "@/components/status-ring"; import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title"; import { DensityIcon } from "@/components/ui/density-icon"; -import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; +import { SCRIM_WIDTH, TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; +import { + ARCHIVE_BUTTON_SIZE, + SidebarWorkspaceArchiveButton, +} from "@/components/sidebar/sidebar-workspace-menu"; import { useWorkspaceLabelDefinitions } from "@/workspace-labels"; const needsInputColorMapping = (theme: Theme) => ({ @@ -156,7 +160,11 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte )} - + {workspaceLabel} {children} @@ -330,6 +338,8 @@ export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({ position: "absolute", top: 0, right: 0, + flexDirection: "row", + alignItems: "flex-start", }, })); @@ -370,6 +380,7 @@ export function resolveTrailingActionVisibility({ }): { trailingPresentation: SidebarWorkspaceTrailingPresentation; showKebab: boolean; + showArchiveButton: boolean; showScrim: boolean; renderSlot: boolean; reserveSlotWidth: boolean; @@ -384,6 +395,9 @@ export function resolveTrailingActionVisibility({ return { trailingPresentation, showKebab, + // One-click archive is a pointer affordance: on touch it would sit a few pixels from the + // kebab and turn a miss into an archive. + showArchiveButton: showKebab && isHovered && !isTouchPlatform, // The scrim paints the row's own hover background, so it can only be drawn on a hovered // row — over an unhovered one the gradient fades to the wrong color. That is also why // touch, which shows the kebab without ever hovering, never gets one. @@ -432,23 +446,46 @@ export function SidebarWorkspaceTrailingActionBase({ ); } +// The archive button plus the kebab's marginLeft, which is the gap between them. +const ARCHIVE_BUTTON_SCRIM_WIDTH = ARCHIVE_BUTTON_SIZE + 2; + export function SidebarWorkspaceTrailingActionOverlay({ visible, scrimBackdrop, + workspaceKey, + onArchiveButtonPress, children, }: { visible: boolean; /** Fade the row into the kebab when something (the diff stat) is still rendered behind it. */ scrimBackdrop?: SidebarSurfaceBackdrop; + workspaceKey: string; + /** + * Shows the archive button left of the kebab. It overlays the title like the kebab does, so + * the scrim widens to cover it rather than the slot reserving its width. + */ + onArchiveButtonPress?: () => void; children: ReactNode; }) { if (!visible || !children) return null; return ( <> {scrimBackdrop ? ( - + ) : null} - {children} + + {onArchiveButtonPress ? ( + + ) : null} + {children} + ); } diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx index 2488e7956e..d458b390ed 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx @@ -404,6 +404,7 @@ function WorkspaceRowTrailingActions({ const { trailingPresentation, showKebab: showKebabInSlot, + showArchiveButton, showScrim, renderSlot, reserveSlotWidth, @@ -430,6 +431,8 @@ function WorkspaceRowTrailingActions({ {onArchive ? ( void; } export interface ToastState { @@ -45,6 +47,7 @@ export interface ToastState { variant: ToastVariant; durationMs: number | null; testID?: string; + onDismiss?: () => void; } /** Dismisses the toast it was returned for; a no-op once another toast replaced it. */ @@ -78,40 +81,56 @@ export function useToastHost(): { dismiss: () => void; } { const { t } = useTranslation(); - const [toast, setToast] = useState(null); + const [toast, setToastState] = useState(null); + const toastRef = useRef(null); const idRef = useRef(0); - const show = useCallback((content: ReactNode, options?: ToastShowOptions): DismissToast => { - const nativeMessage = typeof content === "string" ? content.trim() : null; - if (!content || nativeMessage === "") { - return noopDismiss; - } - - const variant = options?.variant ?? "default"; - const durationMs = options?.durationMs === undefined ? DEFAULT_DURATION_MS : options.durationMs; - const nativeAndroid = options?.nativeAndroid ?? false; - - if (Platform.OS === "android" && nativeAndroid && nativeMessage) { - const duration = - durationMs !== null && durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG; - ToastAndroid.showWithGravity(nativeMessage, duration, ToastAndroid.TOP); - return noopDismiss; - } - - idRef.current += 1; - const id = idRef.current; - setToast({ - id, - content, - nativeMessage, - icon: options?.icon, - variant, - durationMs, - testID: options?.testID, - }); - return () => setToast((current) => (current?.id === id ? null : current)); + const setToast = useCallback((next: ToastState | null) => { + const previous = toastRef.current; + if (previous === next) return; + toastRef.current = next; + setToastState(next); + previous?.onDismiss?.(); }, []); + const show = useCallback( + (content: ReactNode, options?: ToastShowOptions): DismissToast => { + const nativeMessage = typeof content === "string" ? content.trim() : null; + if (!content || nativeMessage === "") { + return noopDismiss; + } + + const variant = options?.variant ?? "default"; + const durationMs = + options?.durationMs === undefined ? DEFAULT_DURATION_MS : options.durationMs; + const nativeAndroid = options?.nativeAndroid ?? false; + + if (Platform.OS === "android" && nativeAndroid && nativeMessage) { + const duration = + durationMs !== null && durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG; + ToastAndroid.showWithGravity(nativeMessage, duration, ToastAndroid.TOP); + return noopDismiss; + } + + idRef.current += 1; + const id = idRef.current; + setToast({ + id, + content, + nativeMessage, + icon: options?.icon, + variant, + durationMs, + testID: options?.testID, + onDismiss: options?.onDismiss, + }); + return () => { + if (toastRef.current?.id === id) setToast(null); + }; + }, + [setToast], + ); + const api = useMemo( () => ({ show, @@ -127,7 +146,7 @@ export function useToastHost(): { const dismiss = useCallback(() => { setToast(null); - }, []); + }, [setToast]); return { api, toast, dismiss }; } diff --git a/packages/app/src/components/ui/trailing-action-scrim.tsx b/packages/app/src/components/ui/trailing-action-scrim.tsx index e106bf3ec5..0b43136bd1 100644 --- a/packages/app/src/components/ui/trailing-action-scrim.tsx +++ b/packages/app/src/components/ui/trailing-action-scrim.tsx @@ -6,9 +6,18 @@ import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; import type { Theme } from "@/styles/theme"; export const SCRIM_WIDTH = 48; -const SCRIM_SOLID_OFFSET = "55%"; +// The fade is a fixed length so a wider scrim only adds solid surface under its actions. +const SCRIM_FADE_WIDTH = SCRIM_WIDTH * 0.55; -function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; color: string }) { +function TrailingActionScrimSvg({ + gradientId, + color, + solidOffset, +}: { + gradientId: string; + color: string; + solidOffset: string; +}) { return ( @@ -16,7 +25,7 @@ function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; col {/* Vary opacity rather than interpolating toward `transparent`, which crosses black in some engines and leaves a grey fringe. */} - + @@ -39,17 +48,22 @@ const backdropColorMappings: Record { color: /** Fades trailing content into the surface beneath an absolutely overlaid action. */ export function TrailingActionScrim({ backdrop, + width = SCRIM_WIDTH, testID, }: { backdrop: SurfaceBackdrop; + /** Covers more than one trailing action; the fade keeps its length. */ + width?: number; testID?: string; }) { // React-generated ids contain characters that are invalid inside SVG fragment references. const gradientId = `trailing-action-scrim-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`; + const solidOffset = `${Math.round((SCRIM_FADE_WIDTH / width) * 100)}%`; return ( - + @@ -57,11 +71,11 @@ export function TrailingActionScrim({ } const styles = StyleSheet.create({ - scrim: { + scrim: (width: number) => ({ position: "absolute", top: 0, bottom: 0, right: 0, - width: SCRIM_WIDTH, - }, + width, + }), }); diff --git a/packages/app/src/contexts/session-workspace-upserts.test.ts b/packages/app/src/contexts/session-workspace-upserts.test.ts index c0c3bea91f..7e942f234b 100644 --- a/packages/app/src/contexts/session-workspace-upserts.test.ts +++ b/packages/app/src/contexts/session-workspace-upserts.test.ts @@ -4,7 +4,10 @@ import { clearWorkspaceArchivePending, isWorkspaceArchivePending, markWorkspaceArchivePending, + markWorkspaceArchiveUndoPending, shouldSuppressWorkspaceForLocalArchive, + settleWorkspaceArchiveIntent, + shouldSuppressWorkspaceRemovalForLocalUndo, } from "@/contexts/session-workspace-upserts"; const baseWorkspace: WorkspaceDescriptor = { @@ -116,3 +119,41 @@ describe("workspace archive pending suppression", () => { clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" }); }); }); + +describe("workspace archive undo intent", () => { + const target = { serverId: "server-1", workspaceId: "/repo/worktree" }; + + it("lets host upserts through and holds host removes while an undo is pending", () => { + markWorkspaceArchivePending(target); + const undo = markWorkspaceArchiveUndoPending(target); + + expect({ + upsertSuppressed: shouldSuppressWorkspaceForLocalArchive({ + serverId: target.serverId, + workspace: workspace({ archivingAt: null }), + }), + removeSuppressed: shouldSuppressWorkspaceRemovalForLocalUndo(target), + }).toEqual({ upsertSuppressed: false, removeSuppressed: true }); + + settleWorkspaceArchiveIntent(undo); + expect(shouldSuppressWorkspaceRemovalForLocalUndo(target)).toBe(false); + }); + + it("keeps a newer intent when the request behind an older one settles late", () => { + const firstArchive = markWorkspaceArchivePending(target); + const firstUndo = markWorkspaceArchiveUndoPending(target); + settleWorkspaceArchiveIntent(firstArchive); + clearWorkspaceArchivePending(target); + expect(shouldSuppressWorkspaceRemovalForLocalUndo(target)).toBe(true); + + markWorkspaceArchivePending(target); + settleWorkspaceArchiveIntent(firstUndo); + expect(isWorkspaceArchivePending(target)).toBe(true); + + const secondUndo = markWorkspaceArchiveUndoPending(target); + settleWorkspaceArchiveIntent(firstUndo); + expect(shouldSuppressWorkspaceRemovalForLocalUndo(target)).toBe(true); + + settleWorkspaceArchiveIntent(secondUndo); + }); +}); diff --git a/packages/app/src/contexts/session-workspace-upserts.ts b/packages/app/src/contexts/session-workspace-upserts.ts index 1b4279631f..2957a90b0d 100644 --- a/packages/app/src/contexts/session-workspace-upserts.ts +++ b/packages/app/src/contexts/session-workspace-upserts.ts @@ -1,77 +1,142 @@ import type { WorkspaceDescriptor } from "@/stores/session-store"; import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; -interface PendingWorkspaceArchive { - workspaceId: string; +/** + * What this client last asked the host to do with a workspace, while the host catches up. + * + * - `archive`: the row is hidden; host upserts for it are dropped until its remove arrives. + * - `undo`: the row is back; host removes for it are dropped until the undo response arrives. + * + * Each request gets its own intent. A request settles or checks only the intent it set, so a + * late response for an earlier request never touches the intent of a newer one. + */ +export interface WorkspaceArchiveIntent { + readonly kind: "archive" | "undo"; } -const pendingWorkspaceArchivesByServer = new Map>(); +const intentsByServer = new Map>(); +const intentListeners = new Set<() => void>(); +const targetByIntent = new WeakMap< + WorkspaceArchiveIntent, + { serverId: string; workspaceId: string } +>(); -function pendingArchiveKey(input: { serverId: string; workspaceId: string }): string { - return `${input.serverId.trim()}::${input.workspaceId.trim()}`; +interface WorkspaceIntentTarget { + serverId: string; + workspaceId?: string | null; +} + +function resolveTarget( + input: WorkspaceIntentTarget, +): { serverId: string; workspaceId: string } | null { + const serverId = input.serverId.trim(); + const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); + if (!serverId || !workspaceId) return null; + return { serverId, workspaceId }; +} + +function readIntent(input: WorkspaceIntentTarget): WorkspaceArchiveIntent | null { + const target = resolveTarget(input); + if (!target) return null; + return intentsByServer.get(target.serverId)?.get(target.workspaceId) ?? null; +} + +function setIntent( + input: WorkspaceIntentTarget, + kind: WorkspaceArchiveIntent["kind"], +): WorkspaceArchiveIntent { + const intent: WorkspaceArchiveIntent = { kind }; + const target = resolveTarget(input); + if (!target) return intent; + targetByIntent.set(intent, target); + const intents = intentsByServer.get(target.serverId) ?? new Map(); + intents.set(target.workspaceId, intent); + intentsByServer.set(target.serverId, intents); + notifyIntentListeners(); + return intent; +} + +function deleteIntent(target: { serverId: string; workspaceId: string }): void { + const intents = intentsByServer.get(target.serverId); + if (!intents) return; + intents.delete(target.workspaceId); + if (intents.size === 0) intentsByServer.delete(target.serverId); + notifyIntentListeners(); +} + +function notifyIntentListeners(): void { + for (const listener of intentListeners) listener(); +} + +/** Calls `listener` whenever any workspace's archive intent changes. */ +export function subscribeWorkspaceArchiveIntents(listener: () => void): () => void { + intentListeners.add(listener); + return () => { + intentListeners.delete(listener); + }; +} + +function clearIntentOfKind( + input: WorkspaceIntentTarget, + kind: WorkspaceArchiveIntent["kind"], +): void { + const target = resolveTarget(input); + if (!target || readIntent(target)?.kind !== kind) return; + deleteIntent(target); +} + +/** True while `intent` is still the latest thing this client asked for its workspace. */ +export function isWorkspaceArchiveIntentCurrent(intent: WorkspaceArchiveIntent): boolean { + const target = targetByIntent.get(intent); + return target !== undefined && readIntent(target) === intent; +} + +/** The request behind `intent` settled. A newer request's intent stays in place. */ +export function settleWorkspaceArchiveIntent(intent: WorkspaceArchiveIntent): void { + const target = targetByIntent.get(intent); + if (target && readIntent(target) === intent) deleteIntent(target); } export function markWorkspaceArchivePending(input: { serverId: string; workspaceId: string; -}): void { - const serverId = input.serverId.trim(); - const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); - if (!serverId || !workspaceId) { - return; - } - - const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Map(); - archives.set(pendingArchiveKey({ serverId, workspaceId }), { - workspaceId, - }); - pendingWorkspaceArchivesByServer.set(serverId, archives); +}): WorkspaceArchiveIntent { + return setIntent(input, "archive"); } +/** The host removed the workspace, which settles whichever archive this client has pending. */ export function clearWorkspaceArchivePending(input: { serverId: string; workspaceId: string; }): void { - const serverId = input.serverId.trim(); - const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); - if (!serverId || !workspaceId) { - return; - } - - const archives = pendingWorkspaceArchivesByServer.get(serverId); - if (!archives) { - return; - } - archives.delete(pendingArchiveKey({ serverId, workspaceId })); - if (archives.size === 0) { - pendingWorkspaceArchivesByServer.delete(serverId); - } + clearIntentOfKind(input, "archive"); } -export function isWorkspaceArchivePending(input: { +export function isWorkspaceArchivePending(input: WorkspaceIntentTarget): boolean { + return readIntent(input)?.kind === "archive"; +} + +export function markWorkspaceArchiveUndoPending(input: { serverId: string; - workspaceId?: string | null; -}): boolean { - const serverId = input.serverId.trim(); - if (!serverId) { - return false; - } + workspaceId: string; +}): WorkspaceArchiveIntent { + return setIntent(input, "undo"); +} - const archives = pendingWorkspaceArchivesByServer.get(serverId); - if (!archives) { - return false; - } - - const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); - return Boolean(workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId }))); +export function isWorkspaceArchiveUndoPending(input: WorkspaceIntentTarget): boolean { + return readIntent(input)?.kind === "undo"; } export function shouldSuppressWorkspaceForLocalArchive(input: { serverId: string; workspace: WorkspaceDescriptor; }): boolean { - return isWorkspaceArchivePending({ - serverId: input.serverId, - workspaceId: input.workspace.id, - }); + return isWorkspaceArchivePending({ serverId: input.serverId, workspaceId: input.workspace.id }); +} + +export function shouldSuppressWorkspaceRemovalForLocalUndo(input: { + serverId: string; + workspaceId: string; +}): boolean { + return isWorkspaceArchiveUndoPending(input); } diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index f86d81b6e9..fda62bd508 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1341,6 +1341,9 @@ export const ar: TranslationResources = { hostDisconnected: "Host غير متصل", hideFailed: "فشل في إخفاء مساحة العمل", archiveFailed: "فشل في أرشفة مساحة العمل", + archived: "تمت أرشفة مساحة العمل", + undoArchive: "تراجع", + undoArchiveFailed: "تعذّر التراجع عن الأرشفة", }, }, }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 103a981abc..79baa3a48c 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1349,6 +1349,9 @@ export const en = { hostDisconnected: "Host is not connected", hideFailed: "Failed to hide workspace", archiveFailed: "Failed to archive workspace", + archived: "Workspace archived", + undoArchive: "Undo", + undoArchiveFailed: "Couldn't undo the archive", }, }, }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 48737ee20e..2841ec345f 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1377,6 +1377,9 @@ export const es: TranslationResources = { hostDisconnected: "Hostno está conectado", hideFailed: "No se pudo ocultar el espacio de trabajo", archiveFailed: "No se pudo archivar el espacio de trabajo", + archived: "Espacio de trabajo archivado", + undoArchive: "Deshacer", + undoArchiveFailed: "No se pudo deshacer el archivado", }, }, }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 0d2e4aabce..9324fa84c8 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1371,6 +1371,9 @@ export const fr: TranslationResources = { hostDisconnected: "L’hôte n’est pas connecté", hideFailed: "Impossible de masquer l’espace de travail", archiveFailed: "Impossible d’archiver l’espace de travail", + archived: "Espace de travail archivé", + undoArchive: "Annuler", + undoArchiveFailed: "Impossible d’annuler l’archivage", }, }, }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index ca93aa3106..58c2ec59df 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1355,6 +1355,9 @@ export const ja: TranslationResources = { hostDisconnected: "ホストが接続されていません", hideFailed: "ワークスペースの非表示に失敗しました", archiveFailed: "ワークスペースのアーカイブに失敗しました", + archived: "ワークスペースをアーカイブしました", + undoArchive: "元に戻す", + undoArchiveFailed: "アーカイブを元に戻せませんでした", }, }, }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 51ea225983..12e2909ddc 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1348,6 +1348,9 @@ export const ko: TranslationResources = { hostDisconnected: "호스트가 연결되어 있지 않습니다", hideFailed: "워크스페이스를 숨기지 못했습니다", archiveFailed: "워크스페이스를 보관하지 못했습니다.", + archived: "워크스페이스를 보관했습니다", + undoArchive: "실행 취소", + undoArchiveFailed: "보관을 취소하지 못했습니다", }, }, }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 5c2695f3bf..88f400907d 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1367,6 +1367,9 @@ export const ptBR: TranslationResources = { hostDisconnected: "Host não está conectado", hideFailed: "Falha ao ocultar workspace", archiveFailed: "Falha ao arquivar workspace", + archived: "Workspace arquivado", + undoArchive: "Desfazer", + undoArchiveFailed: "Não foi possível desfazer o arquivamento", }, }, }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index ea8fb1eb25..abab6a8395 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1359,6 +1359,9 @@ export const ru: TranslationResources = { hostDisconnected: "Хост не подключён", hideFailed: "Не удалось скрыть рабочее пространство", archiveFailed: "Не удалось архивировать рабочее пространство", + archived: "Рабочее пространство архивировано", + undoArchive: "Отменить", + undoArchiveFailed: "Не удалось отменить архивацию", }, }, }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 54454d9bff..3a1c9bf29b 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1330,6 +1330,9 @@ export const zhCN: TranslationResources = { hostDisconnected: "Host 未连接", hideFailed: "隐藏 workspace 失败", archiveFailed: "归档工作区失败", + archived: "工作区已归档", + undoArchive: "撤销", + undoArchiveFailed: "无法撤销归档", }, }, }, diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts index 1bf78d923f..ef656a40a5 100644 --- a/packages/app/src/keyboard/actions.ts +++ b/packages/app/src/keyboard/actions.ts @@ -52,6 +52,7 @@ export type KeyboardActionId = | "workspace.project.pick" | "worktree.new" | "workspace.archive" + | "workspace.archive.undo" | "workspace.pin" | "view.toggle.focus" | "theme.cycle" diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts index a3f2b4c515..ba27e4b354 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts @@ -51,6 +51,7 @@ export type KeyboardActionId = | "workspace.project.pick" | "worktree.new" | "workspace.archive" + | "workspace.archive.undo" | "workspace.pin" // Command-center only: no keybind, so these are absent from route-shortcut.ts. | "workspace.rename" @@ -109,6 +110,7 @@ export type KeyboardActionDefinition = | { id: "workspace.project.pick"; scope: KeyboardActionScope } | { id: "worktree.new"; scope: KeyboardActionScope } | { id: "workspace.archive"; scope: KeyboardActionScope } + | { id: "workspace.archive.undo"; scope: KeyboardActionScope } | { id: "workspace.pin"; scope: KeyboardActionScope } | { id: "workspace.rename"; scope: KeyboardActionScope } | { id: "workspace.setup.show"; scope: KeyboardActionScope }; diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index 29179901ee..9eddf4b5b5 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -408,6 +408,25 @@ describe("keyboard-shortcuts", () => { }, ); + describe("undo archive workspace", () => { + it.each([ + { isMac: true, event: { key: "z", code: "KeyZ", metaKey: true } }, + { isMac: false, event: { key: "z", code: "KeyZ", ctrlKey: true } }, + ])("routes Cmd/Ctrl+Z outside text and terminals (mac: $isMac)", ({ isMac, event }) => { + expectShortcutResolution({ event, context: { isMac }, action: "workspace.archive.undo" }); + }); + + it.each(["editable", "message-input", "terminal"] as const)( + "leaves Cmd+Z to the focused %s", + (focusScope) => { + expectNoShortcutResolution({ + event: { key: "z", code: "KeyZ", metaKey: true }, + context: { isMac: true, focusScope }, + }); + }, + ); + }); + it("leaves Escape to an editable field", () => { expectNoShortcutResolution({ event: { key: "Escape", code: "Escape" }, diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index b55e78fc25..d6db467da3 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -169,6 +169,7 @@ export const SHORTCUT_HELP_ROW_ORDER: Record = { "workspace.new": { id: "workspace.new", scope: "sidebar" }, "workspace.project.pick": { id: "workspace.project.pick", scope: "workspace" }, "workspace.archive": { id: "workspace.archive", scope: "sidebar" }, + "workspace.archive.undo": { id: "workspace.archive.undo", scope: "global" }, "workspace.pin": { id: "workspace.pin", scope: "sidebar" }, "worktree.new": { id: "worktree.new", scope: "sidebar" }, "workspace.terminal.new": { id: "workspace.terminal.new", scope: "workspace" }, diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts index 792c005100..e3aa345d74 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts @@ -9,6 +9,8 @@ import { import { clearWorkspaceArchivePending, markWorkspaceArchivePending, + markWorkspaceArchiveUndoPending, + settleWorkspaceArchiveIntent, } from "@/contexts/session-workspace-upserts"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { WorkspaceDirectoryReplica } from "./workspace-replica"; @@ -378,3 +380,53 @@ it("restores a missing workspace after an archive failure without replacing a li store.clearSession(serverId); } }); + +it("keeps a workspace whose archive is being undone when the host reports its removal", () => { + const serverId = "workspace-archive-undo"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new WorkspaceDirectoryReplica(serverId); + const restored = normalizeWorkspaceDescriptor(workspace("restored")); + + const undo = markWorkspaceArchiveUndoPending({ serverId, workspaceId: restored.id }); + try { + replica.acceptWorkspaces([restored]); + + expect(replica.applyDelta({ kind: "remove", id: restored.id })).toEqual([]); + expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(restored.id)).toBe(true); + + settleWorkspaceArchiveIntent(undo); + replica.applyDelta({ kind: "remove", id: restored.id }); + expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(restored.id)).toBe(false); + } finally { + settleWorkspaceArchiveIntent(undo); + store.clearSession(serverId); + } +}); + +it("keeps a workspace whose archive is being undone when a refresh snapshot omits it", () => { + const serverId = "workspace-archive-undo-refresh"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new WorkspaceDirectoryReplica(serverId); + const restored = normalizeWorkspaceDescriptor(workspace("restored")); + const other = normalizeWorkspaceDescriptor(workspace("other")); + const workspaceIds = () => [ + ...(useSessionStore.getState().sessions[serverId]?.workspaces.keys() ?? []), + ]; + + const undo = markWorkspaceArchiveUndoPending({ serverId, workspaceId: restored.id }); + try { + replica.acceptWorkspaces([restored]); + + replica.commitSnapshot({ workspaces: new Map([[other.id, other]]), projects: new Map() }, []); + expect(workspaceIds().sort()).toEqual(["other", "restored"]); + + settleWorkspaceArchiveIntent(undo); + replica.commitSnapshot({ workspaces: new Map([[other.id, other]]), projects: new Map() }, []); + expect(workspaceIds()).toEqual(["other"]); + } finally { + settleWorkspaceArchiveIntent(undo); + store.clearSession(serverId); + } +}); diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.ts b/packages/app/src/runtime/directory-sync/workspace-replica.ts index 8f21ebd214..b5a7895467 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.ts @@ -14,6 +14,7 @@ import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { clearWorkspaceArchivePending, shouldSuppressWorkspaceForLocalArchive, + shouldSuppressWorkspaceRemovalForLocalUndo, } from "@/contexts/session-workspace-upserts"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; @@ -107,6 +108,15 @@ export class WorkspaceDirectoryReplica { workspaces.set(workspaceId, workspace); } } + // A snapshot taken while an undo runs can predate the restore; the host publishes the row. + for (const [workspaceId, workspace] of this.workspaces) { + if ( + !workspaces.has(workspaceId) && + shouldSuppressWorkspaceRemovalForLocalUndo({ serverId: this.serverId, workspaceId }) + ) { + workspaces.set(workspaceId, workspace); + } + } for (const workspace of workspaces.values()) this.applySetupStatus(workspace); this.workspaces = workspaces; this.projects = new Map(snapshot.projects); @@ -173,6 +183,12 @@ export class WorkspaceDirectoryReplica { kind: "remove"; }, ): DirectoryReplicaMutation[] { + // The archive this remove reports is being undone; the host publishes the restored row. + if ( + shouldSuppressWorkspaceRemovalForLocalUndo({ serverId: this.serverId, workspaceId: delta.id }) + ) { + return []; + } this.deleteWorkspace(delta.id); const mutations: DirectoryReplicaMutation[] = [ { kind: "workspace", type: "delete", id: delta.id }, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index d30893078c..f4b131d041 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -137,6 +137,7 @@ import { useWorkspace } from "@/stores/session-store-hooks"; import type { CheckoutStatusPayload } from "@/git/use-status-query"; import { confirmDialog } from "@/utils/confirm-dialog"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; +import { useWorkspaceArchiveUndoRunning } from "@/workspace/archive-undo"; import { useStableEvent } from "@/hooks/use-stable-event"; import { removeResidentBrowserWebview } from "@/desktop/browser/resident-webviews"; import { createWorkspaceBrowser, useBrowserStore } from "@/desktop/browser/store"; @@ -1249,6 +1250,11 @@ function WorkspaceScreenContent({ const hasHydratedAgents = useSessionStore( (state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false, ); + // An undo puts the row back before the host restores its agents. Their tabs stay until it settles. + const isArchiveUndoRunning = useWorkspaceArchiveUndoRunning({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + }); const workspaceRouteState = useResolvedWorkspaceRouteState({ serverId: normalizedServerId, workspace: workspaceDescriptor, @@ -1549,7 +1555,7 @@ function WorkspaceScreenContent({ persistenceKey, buildWorkspaceTabSnapshot({ agentVisibility: workspaceAgentVisibility, - agentsHydrated: hasHydratedAgents, + agentsHydrated: hasHydratedAgents && !isArchiveUndoRunning, terminalsHydrated: terminalsQuery.isSuccess, knownTerminalIds, standaloneTerminalIds, @@ -1560,6 +1566,7 @@ function WorkspaceScreenContent({ ); }, [ hasHydratedAgents, + isArchiveUndoRunning, hasHydratedWorkspaceLayoutStore, pendingTerminalCreateInput, createTerminalMutation.isPending, diff --git a/packages/app/src/workspace/archive-undo/index.ts b/packages/app/src/workspace/archive-undo/index.ts new file mode 100644 index 0000000000..c6107367aa --- /dev/null +++ b/packages/app/src/workspace/archive-undo/index.ts @@ -0,0 +1,8 @@ +export { + archiveWorkspaceWithUndo, + undoLatestWorkspaceArchive, + type UndoableWorkspaceArchiveClient, +} from "./internal/undo-window"; +export { WorkspaceArchiveUndoShortcutHost } from "./internal/undo-shortcut-host"; +export { supportsWorkspaceArchiveUndo } from "./internal/capability"; +export { useWorkspaceArchiveUndoRunning } from "./internal/use-workspace-archive-undo-running"; diff --git a/packages/app/src/workspace/archive-undo/internal/capability.ts b/packages/app/src/workspace/archive-undo/internal/capability.ts new file mode 100644 index 0000000000..5f5a4a7c20 --- /dev/null +++ b/packages/app/src/workspace/archive-undo/internal/capability.ts @@ -0,0 +1,8 @@ +import { useSessionStore } from "@/stores/session-store"; + +export function supportsWorkspaceArchiveUndo(serverId: string): boolean { + return ( + useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.workspaceArchiveUndo === + true + ); +} diff --git a/packages/app/src/workspace/archive-undo/internal/undo-shortcut-host.tsx b/packages/app/src/workspace/archive-undo/internal/undo-shortcut-host.tsx new file mode 100644 index 0000000000..3d2ea9c4e4 --- /dev/null +++ b/packages/app/src/workspace/archive-undo/internal/undo-shortcut-host.tsx @@ -0,0 +1,22 @@ +import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; +import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher"; +import { hasUndoableWorkspaceArchive, undoLatestWorkspaceArchive } from "./undo-window"; + +const UNDO_ARCHIVE_ACTIONS: readonly KeyboardActionId[] = ["workspace.archive.undo"]; + +/** + * Cmd/Ctrl+Z for the archive toast. Mounted once at the app root because the row that started + * the archive unmounts as soon as its workspace hides. It claims the key only while an archive + * can be undone, so the key falls through to text undo the rest of the time. + */ +export function WorkspaceArchiveUndoShortcutHost(): null { + useKeyboardActionHandler({ + handlerId: "workspace-archive-undo-global", + actions: UNDO_ARCHIVE_ACTIONS, + enabled: true, + priority: 0, + isActive: hasUndoableWorkspaceArchive, + handle: undoLatestWorkspaceArchive, + }); + return null; +} diff --git a/packages/app/src/workspace/archive-undo/internal/undo-window.test.ts b/packages/app/src/workspace/archive-undo/internal/undo-window.test.ts new file mode 100644 index 0000000000..efa9a539ac --- /dev/null +++ b/packages/app/src/workspace/archive-undo/internal/undo-window.test.ts @@ -0,0 +1,301 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { ToastApi, ToastShowOptions } from "@/components/toast-host"; +import { clearWorkspaceArchivePending } from "@/contexts/session-workspace-upserts"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; +import { seedSessionHosts } from "@/test/seed-session"; +import { + archiveWorkspaceWithUndo, + undoLatestWorkspaceArchive, + type UndoableWorkspaceArchiveClient, +} from "@/workspace/archive-undo"; + +const SERVER_ID = "workspace-archive-undo-window"; + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function workspace(id: string): WorkspaceDescriptor { + return { + createdAt: null, + lastUserMessageAt: null, + id, + projectId: "project", + projectDisplayName: "Project", + projectRootPath: "/repo/project", + workspaceDirectory: `/repo/project/${id}`, + projectKind: "git", + workspaceKind: "worktree", + name: id, + status: "done", + archivingAt: null, + statusEnteredAt: null, + diffStat: null, + scripts: [], + }; +} + +/** A toast host with one slot, like the app's: a new toast replaces the shown one. */ +function createToastHost() { + let shown: { content: ReactNode; options?: ToastShowOptions; dismiss: () => void } | null = null; + const errors: string[] = []; + const api: ToastApi = { + show: (content, options) => { + const previous = shown; + const entry = { + content, + options, + dismiss: () => { + if (shown !== entry) return; + shown = null; + options?.onDismiss?.(); + }, + }; + shown = entry; + previous?.options?.onDismiss?.(); + return entry.dismiss; + }, + copied: () => {}, + error: (message) => { + errors.push(message); + api.show(message); + }, + }; + return { + api, + errors, + get shownTestID() { + return shown?.options?.testID ?? null; + }, + timeOut: () => shown?.dismiss(), + }; +} + +type ArchivePayload = Awaited>; + +function createClient() { + const archives = new Map>(); + const undos = new Map>(); + const client: UndoableWorkspaceArchiveClient = { + archiveWorkspace: (workspaceId) => { + const response = deferred(); + archives.set(workspaceId, response); + return response.promise; + }, + undoWorkspaceArchive: (workspaceId) => { + const response = deferred(); + undos.set(workspaceId, response); + return response.promise; + }, + }; + return { + client, + finishArchive: (workspaceId: string, payload: Partial = {}) => + archives.get(workspaceId)?.resolve({ error: null, ...payload }), + undoRequested: (workspaceId: string) => undos.has(workspaceId), + finishUndo: (workspaceId: string) => undos.get(workspaceId)?.resolve(), + failUndo: (workspaceId: string, error: Error) => undos.get(workspaceId)?.reject(error), + }; +} + +function storedWorkspace(id: string): WorkspaceDescriptor | undefined { + return useSessionStore.getState().sessions[SERVER_ID]?.workspaces.get(id); +} + +function startArchive(input: { + host: ReturnType; + toast: ReturnType; + workspaceId: string; + committed: string[]; +}) { + return archiveWorkspaceWithUndo({ + client: input.host.client, + workspace: { serverId: SERVER_ID, workspaceId: input.workspaceId }, + toast: input.toast.api, + onArchiveCommitted: () => input.committed.push(input.workspaceId), + }); +} + +beforeEach(() => { + seedSessionHosts([SERVER_ID]); + useSessionStore.getState().initializeSession(SERVER_ID, {} as DaemonClient); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [workspace("a"), workspace("b")]); +}); + +afterEach(() => { + undoLatestWorkspaceArchive(); + clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "a" }); + clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "b" }); + seedSessionHosts([]); + useSessionStore.setState((state) => ({ ...state, sessions: {} })); +}); + +describe("archiveWorkspaceWithUndo", () => { + it("commits the archive only once its toast is gone", async () => { + const host = createClient(); + const toast = createToastHost(); + const committed: string[] = []; + + const archive = startArchive({ host, toast, workspaceId: "a", committed }); + expect(toast.shownTestID).toBe("workspace-archived-toast"); + host.finishArchive("a"); + await archive; + expect(committed).toEqual([]); + + toast.timeOut(); + expect({ committed, undone: undoLatestWorkspaceArchive() }).toEqual({ + committed: ["a"], + undone: false, + }); + }); + + it("keeps the layout when another client restores the workspace before the toast closes", async () => { + const host = createClient(); + const toast = createToastHost(); + const committed: string[] = []; + + const archive = startArchive({ host, toast, workspaceId: "a", committed }); + host.finishArchive("a"); + await archive; + clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "a" }); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [workspace("a")]); + toast.timeOut(); + + expect({ committed, row: storedWorkspace("a") }).toEqual({ + committed: [], + row: workspace("a"), + }); + }); + + it("commits when the archive finishes after its toast already timed out", async () => { + const host = createClient(); + const toast = createToastHost(); + const committed: string[] = []; + + const archive = startArchive({ host, toast, workspaceId: "a", committed }); + toast.timeOut(); + expect(committed).toEqual([]); + host.finishArchive("a"); + await archive; + + expect(committed).toEqual(["a"]); + }); + + it("undo during a running archive brings the row back and never commits", async () => { + const host = createClient(); + const toast = createToastHost(); + const committed: string[] = []; + + const archive = startArchive({ host, toast, workspaceId: "a", committed }); + expect(storedWorkspace("a")).toBeUndefined(); + + expect(undoLatestWorkspaceArchive()).toBe(true); + expect({ + row: storedWorkspace("a"), + undoRequested: host.undoRequested("a"), + toast: toast.shownTestID, + }).toEqual({ row: workspace("a"), undoRequested: true, toast: null }); + + host.finishArchive("a", { canceled: true, error: "Workspace archive was undone" }); + await archive; + host.finishUndo("a"); + toast.timeOut(); + + expect({ committed, errors: toast.errors, row: storedWorkspace("a") }).toEqual({ + committed: [], + errors: [], + row: workspace("a"), + }); + }); + + it("does not claim Cmd+Z when no archive can be undone", async () => { + const host = createClient(); + const toast = createToastHost(); + + expect(undoLatestWorkspaceArchive()).toBe(false); + + const archive = startArchive({ host, toast, workspaceId: "a", committed: [] }); + host.finishArchive("a"); + await archive; + toast.timeOut(); + + expect(undoLatestWorkspaceArchive()).toBe(false); + expect(host.undoRequested("a")).toBe(false); + }); + + it("a newer archive takes over undo and commits the older one", async () => { + const host = createClient(); + const toast = createToastHost(); + const committed: string[] = []; + + const first = startArchive({ host, toast, workspaceId: "a", committed }); + host.finishArchive("a"); + await first; + const second = startArchive({ host, toast, workspaceId: "b", committed }); + expect(committed).toEqual(["a"]); + + expect(undoLatestWorkspaceArchive()).toBe(true); + expect({ undoA: host.undoRequested("a"), undoB: host.undoRequested("b") }).toEqual({ + undoA: false, + undoB: true, + }); + host.finishArchive("b", { canceled: true }); + await second; + }); + + it("reports a failed undo and hides the row again", async () => { + const host = createClient(); + const toast = createToastHost(); + + const archive = startArchive({ host, toast, workspaceId: "a", committed: [] }); + host.finishArchive("a"); + await archive; + undoLatestWorkspaceArchive(); + host.failUndo("a", new Error("Branch is gone")); + await expect.poll(() => toast.errors).toEqual(["Couldn't undo the archive"]); + + expect(storedWorkspace("a")).toBeUndefined(); + }); + + it("stays quiet about an archive that failed after the user undid it", async () => { + const host = createClient(); + const toast = createToastHost(); + + const archive = startArchive({ host, toast, workspaceId: "a", committed: [] }); + undoLatestWorkspaceArchive(); + host.finishArchive("a", { error: "registry unavailable" }); + + await expect(archive).resolves.toBeUndefined(); + expect(toast.errors).toEqual([]); + }); + + it("rejects a failed archive, closes its toast and puts the row back", async () => { + const host = createClient(); + const toast = createToastHost(); + + const archive = startArchive({ host, toast, workspaceId: "a", committed: [] }); + host.finishArchive("a", { error: "registry unavailable" }); + + await expect(archive).rejects.toThrow("registry unavailable"); + expect({ + toast: toast.shownTestID, + undone: undoLatestWorkspaceArchive(), + row: storedWorkspace("a"), + }).toEqual({ toast: null, undone: false, row: workspace("a") }); + }); +}); diff --git a/packages/app/src/workspace/archive-undo/internal/undo-window.tsx b/packages/app/src/workspace/archive-undo/internal/undo-window.tsx new file mode 100644 index 0000000000..5dc968124e --- /dev/null +++ b/packages/app/src/workspace/archive-undo/internal/undo-window.tsx @@ -0,0 +1,129 @@ +import type { DismissToast, ToastApi } from "@/components/toast-host"; +import { i18n } from "@/i18n/i18next"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { + beginOptimisticWorkspaceArchive, + isWorkspaceListed, + undoWorkspaceArchiveOptimistically, + type WorkspaceArchiveTarget, +} from "@/workspace/workspace-archive"; +import { WorkspaceArchivedToast } from "./workspace-archived-toast"; + +/** How long the archived toast, and with it the chance to undo, stays up. */ +export const WORKSPACE_ARCHIVE_UNDO_WINDOW_MS = 6000; + +export interface UndoableWorkspaceArchiveClient { + archiveWorkspace: (workspaceId: string) => Promise<{ error: string | null; canceled?: boolean }>; + undoWorkspaceArchive: (workspaceId: string) => Promise; +} + +interface UndoWindowInput { + client: UndoableWorkspaceArchiveClient; + workspace: WorkspaceArchiveTarget; + toast: ToastApi; + hidden: WorkspaceDescriptor | null; + onArchiveCommitted: () => void; +} + +let latest: UndoWindow | null = null; + +/** + * One archive's chance to be undone. It opens when the row hides and closes when its toast + * leaves the screen (timeout, replaced by another toast, or undo). + * + * - `open`: undo is offered. + * - `closed`: undo is no longer offered; the archive stands. + * - `undone`: the user took the archive back. + */ +class UndoWindow { + phase: "open" | "closed" | "undone" = "open"; + private archived = false; + private readonly dismissToast: DismissToast; + + constructor(private readonly input: UndoWindowInput) { + this.dismissToast = input.toast.show(, { + durationMs: WORKSPACE_ARCHIVE_UNDO_WINDOW_MS, + testID: "workspace-archived-toast", + onDismiss: this.close, + }); + } + + /** The host finished the archive. It commits now if undo is no longer offered. */ + archiveFinished(): void { + this.archived = true; + if (this.phase === "closed") this.commit(); + } + + /** The archive failed on the host, so there is nothing left to undo. */ + archiveFailed(): void { + this.close(); + this.dismissToast(); + } + + readonly close = (): void => { + if (this.phase !== "open") return; + this.phase = "closed"; + this.release(); + if (this.archived) this.commit(); + }; + + /** Commits unless the workspace is back by now, for example restored from another client. */ + private commit(): void { + if (isWorkspaceListed(this.input.workspace)) return; + this.input.onArchiveCommitted(); + } + + readonly undo = (): boolean => { + if (this.phase !== "open") return false; + this.phase = "undone"; + this.release(); + this.dismissToast(); + void undoWorkspaceArchiveOptimistically(this.input).catch((error: unknown) => { + console.warn("[workspace-archive-undo] undo failed", error); + this.input.toast.error(i18n.t("sidebar.workspace.toasts.undoArchiveFailed")); + }); + return true; + }; + + private release(): void { + if (latest === this) latest = null; + } +} + +/** + * Archives a workspace and offers to undo it until the toast goes away. + * + * `onArchiveCommitted` runs once the archive both finished on the host and can no longer be + * undone, and only if the workspace is still gone then. Local state the undo would want back + * (tabs, layout) survives the window, and survives a restore from another client. + * Rejects when the archive fails and was not undone; the row is back by then. + */ +export async function archiveWorkspaceWithUndo(input: { + client: UndoableWorkspaceArchiveClient; + workspace: WorkspaceArchiveTarget; + toast: ToastApi; + onArchiveCommitted: () => void; +}): Promise { + const archive = beginOptimisticWorkspaceArchive(input); + const window = new UndoWindow({ ...input, hidden: archive.hidden }); + latest = window; + + let outcome: Awaited; + try { + outcome = await archive.done; + } catch (error) { + if (window.phase === "undone") return; + window.archiveFailed(); + throw error; + } + if (outcome === "archived") window.archiveFinished(); +} + +/** Undoes the most recent archive while its toast is up. Returns false when there is none. */ +export function undoLatestWorkspaceArchive(): boolean { + return latest ? latest.undo() : false; +} + +export function hasUndoableWorkspaceArchive(): boolean { + return latest !== null; +} diff --git a/packages/app/src/workspace/archive-undo/internal/use-workspace-archive-undo-running.ts b/packages/app/src/workspace/archive-undo/internal/use-workspace-archive-undo-running.ts new file mode 100644 index 0000000000..31e9dd54fd --- /dev/null +++ b/packages/app/src/workspace/archive-undo/internal/use-workspace-archive-undo-running.ts @@ -0,0 +1,21 @@ +import { useCallback, useSyncExternalStore } from "react"; +import { + isWorkspaceArchiveUndoPending, + subscribeWorkspaceArchiveIntents, +} from "@/contexts/session-workspace-upserts"; + +/** + * True while this client's undo of the workspace's archive is running on the host. Until it + * settles, the workspace's agents may still read as archived even though they are coming back. + */ +export function useWorkspaceArchiveUndoRunning(input: { + serverId: string; + workspaceId: string | null; +}): boolean { + const { serverId, workspaceId } = input; + const read = useCallback( + () => isWorkspaceArchiveUndoPending({ serverId, workspaceId }), + [serverId, workspaceId], + ); + return useSyncExternalStore(subscribeWorkspaceArchiveIntents, read, read); +} diff --git a/packages/app/src/workspace/archive-undo/internal/workspace-archived-toast.tsx b/packages/app/src/workspace/archive-undo/internal/workspace-archived-toast.tsx new file mode 100644 index 0000000000..584b71dec9 --- /dev/null +++ b/packages/app/src/workspace/archive-undo/internal/workspace-archived-toast.tsx @@ -0,0 +1,41 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { Button } from "@/components/ui/button"; +import { Shortcut } from "@/components/ui/shortcut"; +import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; + +export function WorkspaceArchivedToast({ onUndo }: { onUndo: () => void }) { + const { t } = useTranslation(); + const undoKeys = useShortcutKeys("undo-archive-workspace"); + const undoShortcut = useMemo(() => (undoKeys ? : null), [undoKeys]); + + return ( + + {t("sidebar.workspace.toasts.archived")} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + row: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + message: { + flexShrink: 1, + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.normal, + }, +})); diff --git a/packages/app/src/workspace/use-workspace-archive.ts b/packages/app/src/workspace/use-workspace-archive.ts index 7dc86a61ae..9608cef0cd 100644 --- a/packages/app/src/workspace/use-workspace-archive.ts +++ b/packages/app/src/workspace/use-workspace-archive.ts @@ -10,6 +10,7 @@ import { import type { WorkspaceDescriptor } from "@/stores/session-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; +import { archiveWorkspaceWithUndo, supportsWorkspaceArchiveUndo } from "@/workspace/archive-undo"; import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive"; function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void { @@ -58,17 +59,22 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch toast.error(t("sidebar.workspace.toasts.hostDisconnected")); return; } + const workspace = { serverId, workspaceId }; onSetHiding?.(true); try { onArchiveStarted(); - await archiveWorkspaceOptimistically({ - client, - workspace: { - serverId, - workspaceId, - }, - }); - purgeArchivedWorkspaceState({ serverId, workspaceId }); + if (supportsWorkspaceArchiveUndo(serverId)) { + await archiveWorkspaceWithUndo({ + client, + workspace, + toast, + onArchiveCommitted: () => purgeArchivedWorkspaceState(workspace), + }); + } else { + // COMPAT(workspaceArchiveUndo): hosts before v0.15.3 cannot undo; remove after 2027-05-06. + await archiveWorkspaceOptimistically({ client, workspace }); + purgeArchivedWorkspaceState(workspace); + } } catch (error) { toast.error( error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"), diff --git a/packages/app/src/workspace/workspace-archive.test.ts b/packages/app/src/workspace/workspace-archive.test.ts index 3facdee34e..e519bcffb3 100644 --- a/packages/app/src/workspace/workspace-archive.test.ts +++ b/packages/app/src/workspace/workspace-archive.test.ts @@ -5,11 +5,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { clearWorkspaceArchivePending, isWorkspaceArchivePending, + isWorkspaceArchiveUndoPending, } from "@/contexts/session-workspace-upserts"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { archiveWorkspaceOptimistically, archiveWorkspacesOptimistically, + beginOptimisticWorkspaceArchive, + undoWorkspaceArchiveOptimistically, type WorkspaceArchiveTarget, } from "@/workspace/workspace-archive"; @@ -21,12 +24,14 @@ type ArchiveWorkspacePayload = Awaited { expect(storedWorkspaceOn(SECOND_SERVER_ID, second.id)).toBeUndefined(); }); }); + +describe("undoWorkspaceArchiveOptimistically", () => { + it("brings the row back while the host undoes the archive it is still running", async () => { + const archived = workspace(); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [archived]); + const archiveResponse = deferred(); + const undoResponse = deferred(); + const archive = beginOptimisticWorkspaceArchive({ + client: createClient(async () => archiveResponse.promise), + workspace: target(), + }); + + const undo = undoWorkspaceArchiveOptimistically({ + client: { undoWorkspaceArchive: async () => undoResponse.promise }, + workspace: target(), + hidden: archive.hidden, + }); + expect(storedWorkspace(archived.id)).toEqual(archived); + + // The host answers the canceled archive before the undo. + archiveResponse.resolve(archivePayload({ workspaceId: archived.id, canceled: true })); + await expect(archive.done).resolves.toBe("canceled"); + undoResponse.resolve(); + await undo; + + expect({ + row: storedWorkspace(archived.id), + archivePending: isWorkspaceArchivePending(target()), + undoPending: isWorkspaceArchiveUndoPending(target()), + }).toEqual({ row: archived, archivePending: false, undoPending: false }); + }); + + it("hides the row again when the host cannot undo the archive", async () => { + const archived = workspace(); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [archived]); + const archive = beginOptimisticWorkspaceArchive({ + client: createClient(async () => archivePayload({ workspaceId: archived.id })), + workspace: target(), + }); + await archive.done; + + await expect( + undoWorkspaceArchiveOptimistically({ + client: { + undoWorkspaceArchive: async () => { + throw new Error("Branch is gone"); + }, + }, + workspace: target(), + hidden: archive.hidden, + }), + ).rejects.toThrow("Branch is gone"); + + expect(storedWorkspace(archived.id)).toBeUndefined(); + expect(isWorkspaceArchiveUndoPending(target())).toBe(false); + }); + + it("leaves a re-archived row hidden when the earlier undo settles", async () => { + const archived = workspace(); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [archived]); + const undoResponse = deferred(); + const first = beginOptimisticWorkspaceArchive({ + client: createClient(async () => archivePayload({ workspaceId: archived.id })), + workspace: target(), + }); + await first.done; + const undo = undoWorkspaceArchiveOptimistically({ + client: { undoWorkspaceArchive: async () => undoResponse.promise }, + workspace: target(), + hidden: first.hidden, + }); + + const rearchive = beginOptimisticWorkspaceArchive({ + client: createClient(async () => new Promise(() => {})), + workspace: target(), + }); + undoResponse.resolve(); + await undo; + + expect(rearchive.hidden).toEqual(archived); + expect(storedWorkspace(archived.id)).toBeUndefined(); + expect(isWorkspaceArchivePending(target())).toBe(true); + }); + it("puts the row back when another client's undo cancels this archive", async () => { + const archived = workspace(); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [archived]); + const archive = beginOptimisticWorkspaceArchive({ + client: createClient(async () => + archivePayload({ workspaceId: archived.id, canceled: true, error: "undone" }), + ), + workspace: target(), + }); + + await expect(archive.done).resolves.toBe("canceled"); + + expect({ + row: storedWorkspace(archived.id), + archivePending: isWorkspaceArchivePending(target()), + }).toEqual({ row: archived, archivePending: false }); + }); + + it("an earlier undo settling keeps holding the row for a later undo", async () => { + const archived = workspace(); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [archived]); + const firstUndoResponse = deferred(); + const secondUndoResponse = deferred(); + const first = beginOptimisticWorkspaceArchive({ + client: createClient(async () => archivePayload({ workspaceId: archived.id })), + workspace: target(), + }); + await first.done; + const firstUndo = undoWorkspaceArchiveOptimistically({ + client: { undoWorkspaceArchive: async () => firstUndoResponse.promise }, + workspace: target(), + hidden: first.hidden, + }); + const second = beginOptimisticWorkspaceArchive({ + client: createClient(async () => new Promise(() => {})), + workspace: target(), + }); + const secondUndo = undoWorkspaceArchiveOptimistically({ + client: { undoWorkspaceArchive: async () => secondUndoResponse.promise }, + workspace: target(), + hidden: second.hidden, + }); + + firstUndoResponse.resolve(); + await firstUndo; + + expect({ + row: storedWorkspace(archived.id), + undoPending: isWorkspaceArchiveUndoPending(target()), + }).toEqual({ row: archived, undoPending: true }); + secondUndoResponse.resolve(); + await secondUndo; + }); +}); diff --git a/packages/app/src/workspace/workspace-archive.ts b/packages/app/src/workspace/workspace-archive.ts index a4e5a00e80..fd757934ef 100644 --- a/packages/app/src/workspace/workspace-archive.ts +++ b/packages/app/src/workspace/workspace-archive.ts @@ -1,7 +1,10 @@ import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { - clearWorkspaceArchivePending, + isWorkspaceArchiveIntentCurrent, markWorkspaceArchivePending, + markWorkspaceArchiveUndoPending, + settleWorkspaceArchiveIntent, + type WorkspaceArchiveIntent, } from "@/contexts/session-workspace-upserts"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; @@ -13,11 +16,25 @@ export interface WorkspaceArchiveTarget { } interface WorkspaceArchiveClient { - archiveWorkspace: (workspaceId: string) => Promise<{ error: string | null }>; + archiveWorkspace: (workspaceId: string) => Promise<{ error: string | null; canceled?: boolean }>; +} + +interface WorkspaceArchiveUndoClient { + undoWorkspaceArchive: (workspaceId: string) => Promise; +} + +/** `canceled`: an undo stopped the archive on the host before it finished. */ +export type WorkspaceArchiveOutcome = "archived" | "canceled"; + +export interface OptimisticWorkspaceArchive { + /** The row as it was before the archive hid it, for undo to put back. */ + hidden: WorkspaceDescriptor | null; + done: Promise; } interface OptimisticWorkspaceArchiveSnapshot { workspace: WorkspaceDescriptor | null; + intent: WorkspaceArchiveIntent; } export interface WorkspaceArchiveFailure { @@ -38,32 +55,42 @@ function isWorkspaceArchiveFailure(error: unknown): error is WorkspaceArchiveFai ); } -function hideWorkspaceOptimistically( - workspace: WorkspaceArchiveTarget, -): OptimisticWorkspaceArchiveSnapshot { +function findListedWorkspace(workspace: WorkspaceArchiveTarget): WorkspaceDescriptor | null { const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces; const workspaceKey = resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId: workspace.workspaceId, }); - const snapshot = workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null; - markWorkspaceArchivePending({ + return workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null; +} + +/** True when the workspace is in this client's list, for example after another client restored it. */ +export function isWorkspaceListed(workspace: WorkspaceArchiveTarget): boolean { + return findListedWorkspace(workspace) !== null; +} + +function hideWorkspaceOptimistically( + workspace: WorkspaceArchiveTarget, +): OptimisticWorkspaceArchiveSnapshot { + const snapshot = findListedWorkspace(workspace); + const intent = markWorkspaceArchivePending({ serverId: workspace.serverId, workspaceId: workspace.workspaceId, }); getHostRuntimeStore().removeWorkspaceSnapshot(workspace.serverId, workspace.workspaceId); - return { workspace: snapshot }; + return { workspace: snapshot, intent }; } +/** + * Puts back a row whose archive did not happen. Skipped when a newer request on this client + * (an undo, or another archive) owns the row by now. + */ function restoreOptimisticallyHiddenWorkspace(input: { serverId: string; - workspaceId: string; snapshot: OptimisticWorkspaceArchiveSnapshot; }): void { - clearWorkspaceArchivePending({ - serverId: input.serverId, - workspaceId: input.workspaceId, - }); + if (!isWorkspaceArchiveIntentCurrent(input.snapshot.intent)) return; + settleWorkspaceArchiveIntent(input.snapshot.intent); if (input.snapshot.workspace) { getHostRuntimeStore().acceptWorkspaceSnapshots(input.serverId, [input.snapshot.workspace]); } @@ -72,31 +99,75 @@ function restoreOptimisticallyHiddenWorkspace(input: { async function archiveWorkspaceOrThrow(input: { client: WorkspaceArchiveClient; workspaceId: string; -}): Promise { +}): Promise { const payload = await input.client.archiveWorkspace(input.workspaceId); + if (payload.canceled) { + return "canceled"; + } if (payload.error) { throw new Error(payload.error); } + return "archived"; +} + +/** + * Hides the row now and archives it on the host. A failed archive puts the row back, and so + * does a canceled one: an undo from another client stopped it, so the workspace stays. + */ +export function beginOptimisticWorkspaceArchive(input: { + client: WorkspaceArchiveClient; + workspace: WorkspaceArchiveTarget; +}): OptimisticWorkspaceArchive { + const snapshot = hideWorkspaceOptimistically(input.workspace); + const done = (async () => { + try { + const outcome = await archiveWorkspaceOrThrow({ + client: input.client, + workspaceId: input.workspace.workspaceId, + }); + if (outcome === "canceled") { + restoreOptimisticallyHiddenWorkspace({ serverId: input.workspace.serverId, snapshot }); + } + return outcome; + } catch (error) { + restoreOptimisticallyHiddenWorkspace({ serverId: input.workspace.serverId, snapshot }); + throw error; + } + })(); + return { hidden: snapshot.workspace, done }; } export async function archiveWorkspaceOptimistically(input: { client: WorkspaceArchiveClient; workspace: WorkspaceArchiveTarget; -}): Promise { - const snapshot = hideWorkspaceOptimistically(input.workspace); +}): Promise { + return beginOptimisticWorkspaceArchive(input).done; +} +/** + * Puts the archived row back now and asks the host to undo the archive, whether it is still + * running or already finished. A failed undo hides the row again, unless the user archived + * it again in the meantime. + */ +export async function undoWorkspaceArchiveOptimistically(input: { + client: WorkspaceArchiveUndoClient; + workspace: WorkspaceArchiveTarget; + hidden: WorkspaceDescriptor | null; +}): Promise { + const { serverId, workspaceId } = input.workspace; + const intent = markWorkspaceArchiveUndoPending({ serverId, workspaceId }); + if (input.hidden) { + getHostRuntimeStore().acceptWorkspaceSnapshots(serverId, [input.hidden]); + } try { - await archiveWorkspaceOrThrow({ - client: input.client, - workspaceId: input.workspace.workspaceId, - }); + await input.client.undoWorkspaceArchive(workspaceId); } catch (error) { - restoreOptimisticallyHiddenWorkspace({ - serverId: input.workspace.serverId, - workspaceId: input.workspace.workspaceId, - snapshot, - }); + if (isWorkspaceArchiveIntentCurrent(intent)) { + getHostRuntimeStore().removeWorkspaceSnapshot(serverId, workspaceId); + } throw error; + } finally { + settleWorkspaceArchiveIntent(intent); } } diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index b29a34111c..7b235040bf 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -3628,6 +3628,23 @@ export class DaemonClient { } } + /** Rejects when the host could not put the workspace back; an old host has no such RPC. */ + async undoWorkspaceArchive(workspaceId: string, requestId?: string): Promise { + const payload = + await this.sendNamespacedCorrelatedSessionRequest<"workspace.archive.undo.response">({ + requestId, + message: { + type: "workspace.archive.undo.request", + workspaceId, + }, + // Undoing a finished worktree archive recreates the worktree, like recovery does. + timeout: 150_000, + }); + if (!payload.accepted) { + throw new Error(payload.error ?? "The host could not undo the workspace archive"); + } + } + async resumeAgent( handle: AgentPersistenceHandle, overrides?: Partial, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 5436e1737d..c5fc881240 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1105,6 +1105,14 @@ export const WorkspaceRecoveryRestoreRequestSchema = z.object({ requestId: z.string(), }); +// Undoes the latest archive of a workspace: cancels it if it is still running, otherwise +// restores the workspace, then unarchives the agents that archive took down. +export const WorkspaceArchiveUndoRequestSchema = z.object({ + type: z.literal("workspace.archive.undo.request"), + workspaceId: z.string(), + requestId: z.string(), +}); + export const SetVoiceModeMessageSchema = z.object({ type: z.literal("set_voice_mode"), enabled: z.boolean(), @@ -2249,6 +2257,16 @@ export const WorkspaceRecoveryRestoreResponseSchema = z.object({ }), }); +export const WorkspaceArchiveUndoResponseSchema = z.object({ + type: z.literal("workspace.archive.undo.response"), + payload: z.object({ + requestId: z.string(), + workspaceId: z.string(), + accepted: z.boolean(), + error: z.string().nullable(), + }), +}); + export const SetVoiceModeResponseMessageSchema = z.object({ type: z.literal("set_voice_mode_response"), payload: z.object({ @@ -3355,6 +3373,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ WorkspaceLabelDeleteInspectRequestSchema, WorkspaceRecoveryInspectRequestSchema, WorkspaceRecoveryRestoreRequestSchema, + WorkspaceArchiveUndoRequestSchema, SetVoiceModeMessageSchema, SendAgentMessageRequestSchema, WaitForFinishRequestSchema, @@ -3820,6 +3839,8 @@ export const ServerInfoStatusPayloadSchema = z worktreeRestore: z.boolean().optional(), // COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105. workspaceRecovery: z.boolean().optional(), + // COMPAT(workspaceArchiveUndo): added in v0.15.3, remove gate after 2027-05-06. + workspaceArchiveUndo: z.boolean().optional(), // COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0. workspaceFileEditing: z.boolean().optional(), // COMPAT(fileByteWrites): added in v0.13.0, remove gate after 2027-04-20. @@ -4796,6 +4817,8 @@ export const ArchiveWorkspaceResponseMessageSchema = z.object({ workspaceId: z.string(), archivedAt: z.string().nullable(), error: z.string().nullable(), + // An undo stopped this archive before it finished; `error` carries the reason. + canceled: z.boolean().optional(), }), }); @@ -7231,6 +7254,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ WorkspaceBaseBranchSetResponseSchema, WorkspaceRecoveryInspectResponseSchema, WorkspaceRecoveryRestoreResponseSchema, + WorkspaceArchiveUndoResponseSchema, WaitForFinishResponseMessageSchema, AgentPermissionRequestMessageSchema, AgentPermissionResolvedMessageSchema, @@ -7444,6 +7468,7 @@ export type WorkspaceRecoveryInspectResponse = z.infer< export type WorkspaceRecoveryRestoreResponse = z.infer< typeof WorkspaceRecoveryRestoreResponseSchema >; +export type WorkspaceArchiveUndoResponse = z.infer; export type WorkspaceCreateRequest = z.infer; export type WorkspaceCreateResponse = z.infer; export type ProjectRenameResponsePayload = z.infer; @@ -7593,6 +7618,7 @@ export type WorkspacePinSetRequest = z.infer; export type WorkspaceRecoveryInspectRequest = z.infer; export type WorkspaceRecoveryRestoreRequest = z.infer; +export type WorkspaceArchiveUndoRequest = z.infer; export type SetAgentModeRequestMessage = z.infer; export type SetAgentModelRequestMessage = z.infer; export type SetAgentThinkingRequestMessage = z.infer; diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 9f87600ed9..81b5495377 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -2543,16 +2543,20 @@ export class AgentManager { if (this.agents.has(agentId)) await this.closeAgentRuntime(agentId); await this.syncNativeArchiveState(record.provider, record.persistence, "restore"); - await registry.upsert({ + const nextRecord: StoredAgentRecord = { ...record, ...(updates?.workspaceId ? { workspaceId: updates.workspaceId } : {}), ...(updates?.labels ? { labels: applyLabelPatch(record.labels, updates.labels) } : {}), archivedAt: null, updatedAt: new Date().toISOString(), - }); + }; + await registry.upsert(nextRecord); if (this.getAgent(agentId)) { this.notifyAgentState(agentId); + } else if (!nextRecord.internal) { + // Same publication archiveSnapshot makes, so a stored-only agent reappears for every client. + this.dispatchStoredAgentState(nextRecord); } return true; } diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 8cc9d6d1a0..c45d9a5c32 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -219,6 +219,7 @@ const INBOUND_PERMISSION = { "workspace.base_branch.set.request": "workspace.write", "workspace.recovery.inspect.request": ["workspace.read", "hub.execute"], "workspace.recovery.restore.request": ["workspace.manage", "hub.execute"], + "workspace.archive.undo.request": ["workspace.manage", "hub.execute"], "workspace.script.list.request": "workspace.read", "workspace.script.start.request": "workspace.write", "workspace.script.stop.request": "workspace.write", @@ -462,6 +463,7 @@ const OUTBOUND_PERMISSION = { "workspace.base_branch.set.response": "workspace.write", "workspace.recovery.inspect.response": ["workspace.read", "hub.execute"], "workspace.recovery.restore.response": ["workspace.manage", "hub.execute"], + "workspace.archive.undo.response": ["workspace.manage", "hub.execute"], "workspace.script.list.response": "workspace.read", "workspace.script.start.response": "workspace.write", "workspace.script.stop.response": "workspace.write", diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 339a7c17fb..c652f70908 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -278,6 +278,10 @@ import { handleWorkspaceSetupRunRequest as handleWorkspaceSetupRunRequestMessage, } from "./worktree-session.js"; import { archiveByScope, type ActiveWorkspaceRef } from "./workspace-archive-service.js"; +import { + WorkspaceArchiveOperations, + type WorkspaceArchiveUndoInput, +} from "./workspace-archive-operations.js"; import { WorkspaceSetupRuntime } from "./workspace-setup-runtime.js"; import { SessionAuthorization, type DaemonPermission } from "./authorization/index.js"; @@ -286,6 +290,12 @@ function resolveWorkspaceSetupRuntime( ): WorkspaceSetupRuntime { return runtime ?? new WorkspaceSetupRuntime(); } + +function resolveWorkspaceArchiveOperations( + operations: WorkspaceArchiveOperations | undefined, +): WorkspaceArchiveOperations { + return operations ?? new WorkspaceArchiveOperations(); +} import { WorktreeRequestError, toWorktreeWireError } from "./worktree-errors.js"; import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; import { @@ -566,6 +576,8 @@ export interface SessionOptions { scriptRuntimeStore?: WorkspaceScriptRuntimeStore; workspaceSetupObservation?: WorkspaceSetupObservation; workspaceSetupRuntime?: WorkspaceSetupRuntime; + /** Shared by every session so an undo from any connection reaches the archive it cancels. */ + workspaceArchiveOperations?: WorkspaceArchiveOperations; onBranchChanged?: ( workspaceId: string, oldBranch: string | null, @@ -837,6 +849,7 @@ export class Session { private readonly workspaceSetupObservation: WorkspaceSetupObservation; private readonly unsubscribeSetupObservation: () => void; private readonly workspaceSetupRuntime: WorkspaceSetupRuntime; + private readonly workspaceArchiveOperations: WorkspaceArchiveOperations; private readonly workspaceGitObserver: WorkspaceGitObserverService; private readonly workspaceDirectory: WorkspaceDirectory; private readonly voiceSessions: VoiceSessions; @@ -902,6 +915,7 @@ export class Session { scriptRuntimeStore, workspaceSetupObservation, workspaceSetupRuntime, + workspaceArchiveOperations, onBranchChanged, getDaemonTcpPort, getDaemonTcpHost, @@ -1249,6 +1263,7 @@ export class Session { }, ); this.workspaceSetupRuntime = resolveWorkspaceSetupRuntime(workspaceSetupRuntime); + this.workspaceArchiveOperations = resolveWorkspaceArchiveOperations(workspaceArchiveOperations); this.getDaemonTcpPort = getDaemonTcpPort ?? null; this.getDaemonTcpHost = getDaemonTcpHost ?? null; this.serviceProxyPublicBaseUrl = serviceProxyPublicBaseUrl ?? null; @@ -3306,6 +3321,8 @@ export class Session { return this.handleWorkspaceRecoveryInspectRequest(msg); case "workspace.recovery.restore.request": return this.handleWorkspaceRecoveryRestoreRequest(msg); + case "workspace.archive.undo.request": + return this.handleWorkspaceArchiveUndoRequest(msg); case "workspace.clear_attention.request": return this.handleWorkspaceClearAttentionRequest(msg); case "workspace.mark_unread.request": @@ -6180,6 +6197,10 @@ export class Session { private async restoreWorkspaceAndEmit(workspaceId: string): Promise { await this.workspaceRecovery.restore(workspaceId); + await this.publishRecoveredWorkspace(workspaceId); + } + + private async publishRecoveredWorkspace(workspaceId: string): Promise { const workspace = await this.workspaceRegistry.get(workspaceId); if (!workspace) { throw new Error(`Recovered workspace record not found: ${workspaceId}`); @@ -7786,41 +7807,62 @@ export class Session { request: Extract, ): Promise { try { - const existing = await this.workspaceRegistry.get(request.workspaceId); - if (!existing) { - throw new Error(`Workspace not found: ${request.workspaceId}`); - } - - 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), - getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId), - 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), - assertWorkspaceAutomationAllowed: (workspaceId) => - assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId), - killTerminalsForWorkspace: (workspaceId) => - this.terminalController.killTerminalsForWorkspace(workspaceId), - stopWorkspaceSetup: (workspaceId) => this.workspaceSetupRuntime.stop(workspaceId), - sessionLogger: this.sessionLogger, - }, - { - scope: { kind: "workspace", workspaceId: existing.workspaceId }, - requestId: request.requestId, + // Claim the archive queue before any await, so an undo sent right behind this request + // queues after it and cancels it instead of running first. + const outcome = await this.workspaceArchiveOperations.archive( + request.workspaceId, + async (signal) => { + const existing = await this.workspaceRegistry.get(request.workspaceId); + if (!existing) { + throw new Error(`Workspace not found: ${request.workspaceId}`); + } + return archiveByScope( + { + paseoHome: this.paseoHome, + paseoWorktreesBaseRoot: this.worktreesRoot, + github: this.github, + workspaceGitService: this.workspaceGitService, + agentManager: this.agentManager, + agentStorage: this.agentStorage, + findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd), + getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId), + 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), + assertWorkspaceAutomationAllowed: (workspaceId) => + assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId), + killTerminalsForWorkspace: (workspaceId) => + this.terminalController.killTerminalsForWorkspace(workspaceId), + stopWorkspaceSetup: (workspaceId) => this.workspaceSetupRuntime.stop(workspaceId), + sessionLogger: this.sessionLogger, + }, + { + scope: { kind: "workspace", workspaceId: existing.workspaceId }, + requestId: request.requestId, + signal, + }, + ); }, ); + if (outcome.kind === "canceled") { + this.emit({ + type: "archive_workspace_response", + payload: { + requestId: request.requestId, + workspaceId: request.workspaceId, + archivedAt: null, + error: "Workspace archive was undone", + canceled: true, + }, + }); + return; + } + const archivedWorkspace = await this.workspaceRegistry.get(request.workspaceId); const archivedAt = archivedWorkspace?.archivedAt ?? new Date().toISOString(); this.emit({ @@ -7850,6 +7892,69 @@ export class Session { } } + private async handleWorkspaceArchiveUndoRequest( + request: Extract, + ): Promise { + try { + await this.workspaceArchiveOperations.undo(request.workspaceId, (input) => + this.restoreArchivedWorkspaceAndAgents(request.workspaceId, input), + ); + this.emit({ + type: "workspace.archive.undo.response", + payload: { + requestId: request.requestId, + workspaceId: request.workspaceId, + accepted: true, + error: null, + }, + }); + } catch (error) { + this.sessionLogger.warn( + { err: error, workspaceId: request.workspaceId, requestId: request.requestId }, + "session: workspace.archive.undo.request rejected", + ); + this.emit({ + type: "workspace.archive.undo.response", + payload: { + requestId: request.requestId, + workspaceId: request.workspaceId, + accepted: false, + error: getErrorMessageOr(error, "Failed to undo the workspace archive"), + }, + }); + } + } + + // Runs on the archive lane after any in-flight archive has stopped, so the record reflects how + // far it got: still active when it was canceled early, archived (directory kept or removed) + // otherwise. + private async restoreArchivedWorkspaceAndAgents( + workspaceId: string, + input: WorkspaceArchiveUndoInput, + ): Promise { + const workspace = await this.workspaceRegistry.get(workspaceId); + if (!workspace) { + throw new Error(`Workspace not found: ${workspaceId}`); + } + if (workspace.archivedAt) { + await this.workspaceRecovery.restore(workspaceId); + } + const results = await Promise.allSettled( + input.agentIds.map((agentId) => + unarchiveAgentState(this.agentStorage, this.agentManager, agentId), + ), + ); + for (const [index, result] of results.entries()) { + if (result.status === "rejected") { + this.sessionLogger.warn( + { err: result.reason, workspaceId, agentId: input.agentIds[index] }, + "Failed to unarchive agent while undoing workspace archive", + ); + } + } + await this.publishRecoveredWorkspace(workspaceId); + } + private async handleWorkspaceClearAttentionRequest( request: Extract, ): Promise { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 7c301556c3..74188b3d7a 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -65,6 +65,7 @@ import { import type { HubRelationshipManagement } from "./hub/relationship-controller.js"; import { WorkspaceSetupObservation } from "./workspace-setup-observation.js"; import { WorkspaceSetupRuntime } from "./workspace-setup-runtime.js"; +import { WorkspaceArchiveOperations } from "./workspace-archive-operations.js"; import type { HubExecutionAgents } from "./hub/daemon-executions.js"; import type { AgentProvider } from "./agent/agent-sdk-types.js"; import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; @@ -609,6 +610,9 @@ export class VoiceAssistantWebSocketServer { private readonly voiceCallerContexts = new Map(); private readonly workspaceSetupObservation = new WorkspaceSetupObservation(); private readonly workspaceSetupRuntime: WorkspaceSetupRuntime; + // One lane per workspace across every connection, so an undo from any client can cancel an + // archive another connection started. + private readonly workspaceArchiveOperations = new WorkspaceArchiveOperations(); private readonly providerSnapshotManager: ProviderSnapshotManager; private onLifecycleIntent!: ((intent: SessionLifecycleIntent) => void) | null; private onBranchChanged!: @@ -1697,6 +1701,7 @@ export class VoiceAssistantWebSocketServer { scriptRuntimeStore: this.scriptRuntimeStore ?? undefined, workspaceSetupObservation: this.workspaceSetupObservation, workspaceSetupRuntime: this.workspaceSetupRuntime, + workspaceArchiveOperations: this.workspaceArchiveOperations, onBranchChanged: this.onBranchChanged ?? undefined, getDaemonTcpPort: this.getDaemonTcpPort ?? undefined, getDaemonTcpHost: this.getDaemonTcpHost ?? undefined, @@ -2120,6 +2125,8 @@ export class VoiceAssistantWebSocketServer { worktreeRestore: true, // COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105. workspaceRecovery: true, + // COMPAT(workspaceArchiveUndo): added in v0.15.3, remove gate after 2027-05-06. + workspaceArchiveUndo: true, // COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0. workspaceFileEditing: true, // COMPAT(fileByteWrites): added in v0.13.0, remove gate after 2027-04-20. diff --git a/packages/server/src/server/workspace-archive-operations.test.ts b/packages/server/src/server/workspace-archive-operations.test.ts new file mode 100644 index 0000000000..c262f58fa3 --- /dev/null +++ b/packages/server/src/server/workspace-archive-operations.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, test } from "vitest"; + +import { WorkspaceArchiveOperations } from "./workspace-archive-operations.js"; +import { type ArchiveResult, WorkspaceArchiveCanceledError } from "./workspace-archive-service.js"; + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function archived(newlyArchivedAgentIds: string[]): ArchiveResult { + return { + archivedAgentIds: newlyArchivedAgentIds, + newlyArchivedAgentIds, + archivedWorkspaceIds: ["ws"], + removedDirectory: false, + }; +} + +/** + * An archive that parks until released, then honours its abort signal the way archiveByScope's + * checkpoints do: an aborted run reports the agents it already took down. + */ +function parkedArchive(agentIds: string[]) { + const release = deferred(); + const started = deferred(); + let signal: AbortSignal | null = null; + return { + release: () => release.resolve(), + started: started.promise, + get aborted() { + return signal?.aborted ?? false; + }, + run: async (runSignal: AbortSignal) => { + signal = runSignal; + started.resolve(); + await release.promise; + if (runSignal.aborted) throw new WorkspaceArchiveCanceledError(agentIds, false); + return archived(agentIds); + }, + }; +} + +describe("WorkspaceArchiveOperations", () => { + test("an undo during a running archive aborts it and restores the agents it took down", async () => { + const operations = new WorkspaceArchiveOperations(); + const archive = parkedArchive(["agent-a", "agent-b"]); + const restored: string[][] = []; + + const archiveDone = operations.archive("ws", archive.run); + await archive.started; + const undoDone = operations.undo("ws", async ({ agentIds }) => { + restored.push(agentIds); + }); + + expect(archive.aborted).toBe(true); + expect(restored).toEqual([]); + archive.release(); + + await expect(archiveDone).resolves.toEqual({ kind: "canceled" }); + await undoDone; + expect(restored).toEqual([["agent-a", "agent-b"]]); + }); + + test("an undo after the archive finished restores the agents that archive took down", async () => { + const operations = new WorkspaceArchiveOperations(); + await operations.archive("ws", async () => archived(["agent-a"])); + + const restored: string[][] = []; + await operations.undo("ws", async ({ agentIds }) => { + restored.push(agentIds); + }); + + expect(restored).toEqual([["agent-a"]]); + }); + + test("restore runs only after the aborted archive has stopped", async () => { + const operations = new WorkspaceArchiveOperations(); + const archive = parkedArchive([]); + const order: string[] = []; + + const archiveDone = operations + .archive("ws", async (signal) => { + try { + return await archive.run(signal); + } finally { + order.push("archive stopped"); + } + }) + .catch(() => undefined); + await archive.started; + const undoDone = operations.undo("ws", async () => { + order.push("restore"); + }); + archive.release(); + await Promise.all([archiveDone, undoDone]); + + expect(order).toEqual(["archive stopped", "restore"]); + }); + + test("a second archive while one runs joins it instead of archiving twice", async () => { + const operations = new WorkspaceArchiveOperations(); + const archive = parkedArchive(["agent-a"]); + let runs = 0; + const run = (signal: AbortSignal) => { + runs += 1; + return archive.run(signal); + }; + + const first = operations.archive("ws", run); + const second = operations.archive("ws", run); + archive.release(); + + await expect(Promise.all([first, second])).resolves.toEqual([ + { kind: "archived", result: archived(["agent-a"]) }, + { kind: "archived", result: archived(["agent-a"]) }, + ]); + expect(runs).toBe(1); + }); + + test("an archive requested during an undo runs after the undo, and wins", async () => { + const operations = new WorkspaceArchiveOperations(); + await operations.archive("ws", async () => archived(["agent-a"])); + + const restoreGate = deferred(); + const order: string[] = []; + const undoDone = operations.undo("ws", async () => { + await restoreGate.promise; + order.push("restored"); + }); + const rearchive = operations.archive("ws", async () => { + order.push("archived again"); + return archived(["agent-a"]); + }); + + restoreGate.resolve(); + await undoDone; + await expect(rearchive).resolves.toMatchObject({ kind: "archived" }); + expect(order).toEqual(["restored", "archived again"]); + }); + + test("archive, undo, archive in quick succession leaves the workspace archived", async () => { + const operations = new WorkspaceArchiveOperations(); + const first = parkedArchive(["agent-a"]); + const restored: string[][] = []; + + const firstDone = operations.archive("ws", first.run); + await first.started; + const undoDone = operations.undo("ws", async ({ agentIds }) => { + restored.push(agentIds); + }); + // The re-archive must not join the archive the undo just aborted. + const secondDone = operations.archive("ws", async () => archived(["agent-a"])); + first.release(); + + await expect(firstDone).resolves.toEqual({ kind: "canceled" }); + await undoDone; + await expect(secondDone).resolves.toMatchObject({ kind: "archived" }); + expect(restored).toEqual([["agent-a"]]); + }); + + test("a second undo while one runs joins it", async () => { + const operations = new WorkspaceArchiveOperations(); + await operations.archive("ws", async () => archived(["agent-a"])); + const restoreGate = deferred(); + let restores = 0; + const restore = async () => { + restores += 1; + await restoreGate.promise; + }; + + const first = operations.undo("ws", restore); + const second = operations.undo("ws", restore); + restoreGate.resolve(); + await Promise.all([first, second]); + + expect(restores).toBe(1); + }); + + test("a failed restore keeps the agents for a retry", async () => { + const operations = new WorkspaceArchiveOperations(); + await operations.archive("ws", async () => archived(["agent-a"])); + + await expect( + operations.undo("ws", async () => { + throw new Error("worktree recreation failed"); + }), + ).rejects.toThrow("worktree recreation failed"); + + const restored: string[][] = []; + await operations.undo("ws", async ({ agentIds }) => { + restored.push(agentIds); + }); + expect(restored).toEqual([["agent-a"]]); + }); + + test("an archive of an already archived workspace keeps the earlier archive's agents for undo", async () => { + const operations = new WorkspaceArchiveOperations(); + await operations.archive("ws", async () => archived(["agent-a"])); + await operations.archive("ws", async () => ({ + archivedAgentIds: ["agent-a"], + newlyArchivedAgentIds: [], + archivedWorkspaceIds: [], + removedDirectory: false, + })); + const restored: string[][] = []; + + await operations.undo("ws", async ({ agentIds }) => { + restored.push(agentIds); + }); + + expect(restored).toEqual([["agent-a"]]); + }); + + test("an undo restores the archive's agents once; a repeat undo restores none", async () => { + const operations = new WorkspaceArchiveOperations(); + await operations.archive("ws", async () => archived(["agent-a"])); + const restored: string[][] = []; + const restore = async ({ agentIds }: { agentIds: string[] }) => { + restored.push(agentIds); + }; + + await operations.undo("ws", restore); + await operations.undo("ws", restore); + + expect(restored).toEqual([["agent-a"], []]); + }); + + test("an undo after the retention window restores the workspace without agents", async () => { + let now = 0; + const operations = new WorkspaceArchiveOperations({ undoRetentionMs: 1000, now: () => now }); + await operations.archive("ws", async () => archived(["agent-a"])); + now = 1000; + + const restored: string[][] = []; + await operations.undo("ws", async ({ agentIds }) => { + restored.push(agentIds); + }); + expect(restored).toEqual([[]]); + }); + + test("workspaces have independent lanes", async () => { + const operations = new WorkspaceArchiveOperations(); + const archive = parkedArchive(["agent-a"]); + + const parked = operations.archive("ws-a", archive.run); + await archive.started; + await expect(operations.archive("ws-b", async () => archived(["agent-b"]))).resolves.toEqual({ + kind: "archived", + result: archived(["agent-b"]), + }); + await operations.undo("ws-b", async () => {}); + + expect(archive.aborted).toBe(false); + archive.release(); + await expect(parked).resolves.toMatchObject({ kind: "archived" }); + }); + + test("a failed archive frees the lane for the next request", async () => { + const operations = new WorkspaceArchiveOperations(); + await expect( + operations.archive("ws", async () => { + throw new Error("registry unavailable"); + }), + ).rejects.toThrow("registry unavailable"); + + await expect(operations.archive("ws", async () => archived([]))).resolves.toMatchObject({ + kind: "archived", + }); + }); +}); diff --git a/packages/server/src/server/workspace-archive-operations.ts b/packages/server/src/server/workspace-archive-operations.ts new file mode 100644 index 0000000000..c8bb15a9d2 --- /dev/null +++ b/packages/server/src/server/workspace-archive-operations.ts @@ -0,0 +1,133 @@ +import { type ArchiveResult, WorkspaceArchiveCanceledError } from "./workspace-archive-service.js"; + +export type WorkspaceArchiveOutcome = + | { kind: "archived"; result: ArchiveResult } + | { kind: "canceled" }; + +export interface WorkspaceArchiveUndoInput { + /** Agents the undone archive took down. Empty when the daemon no longer remembers it. */ + agentIds: string[]; +} + +type LaneEntry = + | { kind: "archiving"; controller: AbortController; done: Promise } + | { kind: "undoing"; done: Promise }; + +interface UndoableArchive { + agentIds: string[]; + expiresAt: number; +} + +const DEFAULT_UNDO_RETENTION_MS = 10 * 60_000; + +/** + * One lane per workspace for archive and its undo, shared by every session on the daemon. + * + * - An archive while one runs joins it. + * - An undo while an archive runs aborts it, waits for it to stop, then restores. + * - An archive while an undo runs waits for the undo, then archives. + * - An undo while an undo runs joins it. + * + * Each call claims the lane synchronously, so whichever request arrives last decides the + * workspace's final state no matter how the earlier work interleaves. + * + * The agents an archive took down are kept in memory for the undo window. An archive that took + * nothing down, such as a repeat archive from a stale client, leaves them alone. After a daemon + * restart, undo still restores the workspace but its agents stay archived. + */ +export class WorkspaceArchiveOperations { + private readonly lanes = new Map(); + private readonly undoable = new Map(); + private readonly undoRetentionMs: number; + private readonly now: () => number; + + constructor(options: { undoRetentionMs?: number; now?: () => number } = {}) { + this.undoRetentionMs = options.undoRetentionMs ?? DEFAULT_UNDO_RETENTION_MS; + this.now = options.now ?? Date.now; + } + + archive( + workspaceId: string, + run: (signal: AbortSignal) => Promise, + ): Promise { + const current = this.lanes.get(workspaceId); + if (current?.kind === "archiving") { + return current.done; + } + const controller = new AbortController(); + const done = (async (): Promise => { + await settled(current?.done); + try { + const result = await run(controller.signal); + if (result.archivedWorkspaceIds.length > 0 || result.newlyArchivedAgentIds.length > 0) { + this.retainUndoable(workspaceId, result.newlyArchivedAgentIds); + } + return { kind: "archived", result }; + } catch (error) { + if (!(error instanceof WorkspaceArchiveCanceledError)) throw error; + if (error.recordArchived || error.newlyArchivedAgentIds.length > 0) { + this.retainUndoable(workspaceId, error.newlyArchivedAgentIds); + } + return { kind: "canceled" }; + } + })(); + this.claim(workspaceId, { kind: "archiving", controller, done }); + return done; + } + + undo( + workspaceId: string, + restore: (input: WorkspaceArchiveUndoInput) => Promise, + ): Promise { + const current = this.lanes.get(workspaceId); + if (current?.kind === "undoing") { + return current.done; + } + current?.controller.abort(); + const done = (async () => { + await settled(current?.done); + const agentIds = this.takeUndoable(workspaceId); + try { + await restore({ agentIds }); + } catch (error) { + // Keep the agents for a retry; the archive they came from is still the latest one. + this.retainUndoable(workspaceId, agentIds); + throw error; + } + })(); + this.claim(workspaceId, { kind: "undoing", done }); + return done; + } + + private claim(workspaceId: string, entry: LaneEntry): void { + this.lanes.set(workspaceId, entry); + const release = () => { + if (this.lanes.get(workspaceId) === entry) this.lanes.delete(workspaceId); + }; + void (entry.done as Promise).then(release, release); + } + + private retainUndoable(workspaceId: string, agentIds: string[]): void { + const now = this.now(); + for (const [id, entry] of this.undoable) { + if (entry.expiresAt <= now) this.undoable.delete(id); + } + this.undoable.set(workspaceId, { agentIds, expiresAt: now + this.undoRetentionMs }); + } + + private takeUndoable(workspaceId: string): string[] { + const entry = this.undoable.get(workspaceId); + this.undoable.delete(workspaceId); + if (!entry || entry.expiresAt <= this.now()) return []; + return entry.agentIds; + } +} + +async function settled(promise: Promise | undefined): Promise { + if (!promise) return; + try { + await promise; + } catch { + // The earlier request reports its own failure; this one only waits for the lane. + } +} diff --git a/packages/server/src/server/workspace-archive-service.test.ts b/packages/server/src/server/workspace-archive-service.test.ts index 43ba26b8ad..ecf4210ece 100644 --- a/packages/server/src/server/workspace-archive-service.test.ts +++ b/packages/server/src/server/workspace-archive-service.test.ts @@ -19,6 +19,7 @@ import { type ArchiveDependencies, type ArchiveResult, resolveWorkspaceIdAtPath, + WorkspaceArchiveCanceledError, } from "./workspace-archive-service.js"; import { WorkspaceAutomationBlockedError } from "./workspace-automation-gate.js"; @@ -1019,6 +1020,207 @@ describe("archiveByScope", () => { }); }); +function gate(): { + wait: Promise; + open: () => void; + reached: Promise; + reach: () => void; +} { + let open!: () => void; + let reach!: () => void; + const wait = new Promise((resolve) => { + open = resolve; + }); + const reached = new Promise((resolve) => { + reach = resolve; + }); + return { wait, open, reached, reach }; +} + +async function canceledArchive(promise: Promise): Promise { + const error = await promise.then( + () => null, + (reason: unknown) => reason, + ); + if (!(error instanceof WorkspaceArchiveCanceledError)) { + throw new Error(`Expected the archive to be canceled, got ${String(error)}`); + } + return error; +} + +describe("archiveByScope cancellation", () => { + function storedAgents( + workspaceId: string, + agents: Array<{ id: string; archivedAt: string | null }>, + ) { + return { + listByWorkspace: async () => + agents.map((agent) => ({ ...agent, workspaceId })) as StoredAgentRecord[], + } as Pick; + } + + test("an abort while agents archive keeps the record and reports only the agents it took down", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "cancel-agents"); + const workspaceId = "ws-cancel-agents"; + const deps = createArchiveDeps({ + paseoHome, + activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }], + }); + deps.agentStorage = storedAgents(workspaceId, [ + { id: "agent-active", archivedAt: null }, + { id: "agent-already-archived", archivedAt: "2026-01-01T00:00:00.000Z" }, + ]); + const agentArchive = gate(); + deps.agentManager.archiveSnapshot = vi.fn(async (agentId: string) => { + agentArchive.reach(); + await agentArchive.wait; + deps.archivedSnapshotIds.push(agentId); + return {} as StoredAgentRecord; + }); + const controller = new AbortController(); + + const archive = archiveByScope(deps, { + scope: { kind: "workspace", workspaceId }, + requestId: "req-cancel-agents", + signal: controller.signal, + }); + await agentArchive.reached; + controller.abort(); + agentArchive.open(); + const error = await canceledArchive(archive); + + expect({ + newlyArchivedAgentIds: error.newlyArchivedAgentIds, + recordArchived: error.recordArchived, + activeWorkspaceIds: (await deps.listActiveWorkspaces()).map((w) => w.workspaceId), + directoryExists: existsSync(worktree.worktreePath), + }).toEqual({ + newlyArchivedAgentIds: ["agent-active"], + recordArchived: false, + activeWorkspaceIds: [workspaceId], + directoryExists: true, + }); + // The archiving marker never outlives a canceled archive. + expect(deps.clearWorkspaceArchiving).toHaveBeenCalledWith([workspaceId]); + }); + + test("an abort after the record archived keeps the worktree directory", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "cancel-record"); + const workspaceId = "ws-cancel-record"; + const deps = createArchiveDeps({ + paseoHome, + activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }], + }); + const recordArchive = gate(); + const archiveRecord = deps.archiveWorkspaceRecord; + deps.archiveWorkspaceRecord = async (id: string) => { + recordArchive.reach(); + await recordArchive.wait; + await archiveRecord(id); + }; + const controller = new AbortController(); + + const archive = archiveByScope(deps, { + scope: { kind: "workspace", workspaceId }, + requestId: "req-cancel-record", + signal: controller.signal, + }); + await recordArchive.reached; + controller.abort(); + recordArchive.open(); + const error = await canceledArchive(archive); + + expect({ + recordArchived: error.recordArchived, + activeWorkspaceIds: (await deps.listActiveWorkspaces()).map((w) => w.workspaceId), + directoryExists: existsSync(worktree.worktreePath), + }).toEqual({ recordArchived: true, activeWorkspaceIds: [], directoryExists: true }); + }); + + test("an archive aborted before it starts touches nothing", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "cancel-before"); + const workspaceId = "ws-cancel-before"; + const deps = createArchiveDeps({ + paseoHome, + activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }], + }); + deps.agentStorage = storedAgents(workspaceId, [{ id: "agent-active", archivedAt: null }]); + const controller = new AbortController(); + controller.abort(); + + const error = await canceledArchive( + archiveByScope(deps, { + scope: { kind: "workspace", workspaceId }, + requestId: "req-cancel-before", + signal: controller.signal, + }), + ); + + expect(error.newlyArchivedAgentIds).toEqual([]); + expect(deps.archivedSnapshotIds).toEqual([]); + expect(deps.markWorkspaceArchiving).not.toHaveBeenCalled(); + expect(existsSync(worktree.worktreePath)).toBe(true); + }); + + test("a completed archive reports the agents it newly archived", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "newly-archived"); + const workspaceId = "ws-newly-archived"; + const deps = createArchiveDeps({ + paseoHome, + activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }], + }); + deps.agentStorage = storedAgents(workspaceId, [ + { id: "agent-active", archivedAt: null }, + { id: "agent-already-archived", archivedAt: "2026-01-01T00:00:00.000Z" }, + ]); + + const result = await archiveByScope(deps, { + scope: { kind: "workspace", workspaceId }, + requestId: "req-newly-archived", + signal: new AbortController().signal, + }); + + expect(result.archivedAgentIds.sort()).toEqual(["agent-active", "agent-already-archived"]); + expect(result.newlyArchivedAgentIds).toEqual(["agent-active"]); + }); + + test("an archived agent loaded to read its history is not reported as newly archived", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "loaded-archived"); + const workspaceId = "ws-loaded-archived"; + const deps = createArchiveDeps({ + paseoHome, + activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }], + }); + deps.agentStorage = storedAgents(workspaceId, [ + { id: "agent-active", archivedAt: null }, + { id: "agent-history", archivedAt: "2026-01-01T00:00:00.000Z" }, + ]); + deps.agentManager.listAgents = () => + [ + { id: "agent-active", workspaceId }, + { id: "agent-history", workspaceId }, + ] as ManagedAgent[]; + + const result = await archiveByScope(deps, { + scope: { kind: "workspace", workspaceId }, + requestId: "req-loaded-archived", + signal: new AbortController().signal, + }); + + expect(result.newlyArchivedAgentIds).toEqual(["agent-active"]); + }); +}); + describe("resolveWorkspaceIdAtPath", () => { test("prefers the worktree-kind record on an exact cwd tie", async () => { const targetPath = "/worktrees/repo/feature"; diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts index 78f8c0b97d..54d772add1 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -67,6 +67,9 @@ export type ArchiveScope = export interface ArchiveResult { archivedAgentIds: string[]; + // Agents this request moved from active to archived. `archivedAgentIds` also lists agents that + // were already archived; undo must restore only the ones this archive took down. + newlyArchivedAgentIds: string[]; archivedWorkspaceIds: string[]; removedDirectory: boolean; // Set when the record archived but its worktree directory was left on @@ -78,6 +81,24 @@ export interface ArchiveResult { export interface ArchiveByScopeRequest { scope: ArchiveScope; requestId: string; + // Aborting cancels the archive at its next checkpoint. Removing the worktree directory is the + // point of no return: once it starts the archive completes. + signal?: AbortSignal; +} + +/** + * The archive stopped at a checkpoint because its signal aborted. Whatever ran before the + * checkpoint stays done; `newlyArchivedAgentIds` and `recordArchived` say how far it got so the + * canceller can put it back. + */ +export class WorkspaceArchiveCanceledError extends Error { + constructor( + public readonly newlyArchivedAgentIds: string[], + public readonly recordArchived: boolean, + ) { + super("Workspace archive was canceled"); + this.name = "WorkspaceArchiveCanceledError"; + } } export async function requireActiveWorkspaceForArchive( @@ -144,10 +165,22 @@ async function archiveByScopeWithPriority( dependencies: ArchiveDependencies, request: ArchiveByScopeRequest, ): Promise { + const progress: ArchiveProgress = { newlyArchivedAgentIds: new Set(), recordArchived: false }; + const checkpoint = () => { + if (request.signal?.aborted) { + throw new WorkspaceArchiveCanceledError( + Array.from(progress.newlyArchivedAgentIds), + progress.recordArchived, + ); + } + }; + + checkpoint(); const target = await resolveArchiveTarget(dependencies, request.scope); const targetWorkspaceIds = target.workspaceIds; await stopWorkspaceSetups(dependencies, target.setupWorkspaceIds, request.requestId); + checkpoint(); if (targetWorkspaceIds.length > 0) { dependencies.markWorkspaceArchiving(targetWorkspaceIds, new Date().toISOString()); @@ -160,12 +193,14 @@ async function archiveByScopeWithPriority( if (targetWorkspaceIds.length > 0) { await dependencies.emitWorkspaceUpdatesForWorkspaceIds(targetWorkspaceIds); } + checkpoint(); const { archivedAgents, archivedWorkspaceIds } = await archiveTargetRecords( dependencies, targetWorkspaceIds, - request.requestId, + { requestId: request.requestId, checkpoint, progress }, ); + checkpoint(); if (target.backing?.mainRepoRoot) { try { @@ -184,7 +219,7 @@ async function archiveByScopeWithPriority( if (target.backing !== null) { const removal = await maybeRemoveDirectory( dependencies, - request, + { requestId: request.requestId, checkpoint }, target, archivedWorkspaceIds, ); @@ -194,6 +229,7 @@ async function archiveByScopeWithPriority( return { archivedAgentIds: Array.from(archivedAgents), + newlyArchivedAgentIds: Array.from(progress.newlyArchivedAgentIds), archivedWorkspaceIds, removedDirectory, ...(directoryLeftBehind ? { directoryLeftBehind } : {}), @@ -206,6 +242,11 @@ async function archiveByScopeWithPriority( } } +interface ArchiveProgress { + newlyArchivedAgentIds: Set; + recordArchived: boolean; +} + async function resolveArchiveTarget( dependencies: ArchiveDependencies, scope: ArchiveScope, @@ -352,16 +393,23 @@ async function resolveBackingDirectory( async function archiveTargetRecords( dependencies: ArchiveDependencies, targetWorkspaceIds: string[], - requestId: string, + options: { requestId: string; checkpoint: () => void; progress: ArchiveProgress }, ): Promise<{ archivedAgents: Set; archivedWorkspaceIds: string[] }> { const archivedAgents = new Set(); const archivedWorkspaceIds: string[] = []; const results = await Promise.allSettled( targetWorkspaceIds.map(async (workspaceId) => { - const agents = await archiveWorkspaceContents(dependencies, workspaceId); + const contents = await archiveWorkspaceContentsWithProgress(dependencies, workspaceId); + for (const agentId of contents.newlyArchivedAgentIds) { + options.progress.newlyArchivedAgentIds.add(agentId); + } + // Agents and terminals are down, the record is not: an abort here leaves the workspace + // listed, and the canceller restores the agents it reports. + options.checkpoint(); await dependencies.archiveWorkspaceRecord(workspaceId); - return { workspaceId, agents }; + options.progress.recordArchived = true; + return { workspaceId, agents: contents.agentIds }; }), ); @@ -371,9 +419,14 @@ async function archiveTargetRecords( for (const agentId of result.value.agents) { archivedAgents.add(agentId); } + } else if (result.reason instanceof WorkspaceArchiveCanceledError) { + throw new WorkspaceArchiveCanceledError( + Array.from(options.progress.newlyArchivedAgentIds), + options.progress.recordArchived, + ); } else { dependencies.sessionLogger?.warn( - { err: result.reason, requestId }, + { err: result.reason, requestId: options.requestId }, "archiveByScope workspace teardown failed; continuing", ); } @@ -384,7 +437,7 @@ async function archiveTargetRecords( async function maybeRemoveDirectory( dependencies: ArchiveDependencies, - request: Pick, + request: { requestId: string; checkpoint: () => void }, target: ArchiveTarget, archivedWorkspaceIds: string[], ): Promise<{ @@ -423,6 +476,7 @@ async function maybeRemoveDirectory( teardownTargets, ); for (const teardownTarget of uniqueTeardownTargets(allowedTeardownTargets)) { + request.checkpoint(); await runWorktreeTeardownCommands({ worktreePath: backing.path, teardownCwd: teardownTarget.cwd, @@ -452,6 +506,9 @@ async function maybeRemoveDirectory( return { removedDirectory: false }; } + // Last chance to keep the directory. Teardown commands already ran, so a cancel here restores + // a workspace whose teardown hooks fired; that beats deleting what the user asked to keep. + request.checkpoint(); try { await deletePaseoWorktree({ cwd: backing.mainRepoRoot, @@ -518,6 +575,13 @@ export async function archiveWorkspaceContents( dependencies: ArchiveWorkspaceContentsDependencies, workspaceId: string, ): Promise> { + return (await archiveWorkspaceContentsWithProgress(dependencies, workspaceId)).agentIds; +} + +async function archiveWorkspaceContentsWithProgress( + dependencies: ArchiveWorkspaceContentsDependencies, + workspaceId: string, +): Promise<{ agentIds: Set; newlyArchivedAgentIds: string[] }> { const archivedAgents = new Set(); const liveAgents = dependencies.agentManager @@ -542,29 +606,52 @@ export async function archiveWorkspaceContents( } const archivedAt = new Date().toISOString(); - const agentIdsToArchive = new Set([ - ...liveAgents.map((agent) => agent.id), - ...matchingStoredRecords.filter((record) => !record.archivedAt).map((record) => record.id), - ]); - const archiveResults = await Promise.allSettled([ - ...[...agentIdsToArchive].map((agentId) => - dependencies.agentManager.getAgent(agentId) - ? dependencies.agentManager.archiveAgent(agentId) - : dependencies.agentManager.archiveSnapshot(agentId, archivedAt), + const agentIdsToArchive = [ + ...new Set([ + ...liveAgents.map((agent) => agent.id), + ...matchingStoredRecords.filter((record) => !record.archivedAt).map((record) => record.id), + ]), + ]; + // Undo brings back only what this archive took down. Internal agents are retired rather than + // archived, and an agent loaded only to read its history was archived before this request. + const restorableAgentIds = new Set(agentIdsToArchive); + for (const agent of liveAgents) { + if (agent.internal) restorableAgentIds.delete(agent.id); + } + for (const record of matchingStoredRecords) { + if (record.archivedAt) restorableAgentIds.delete(record.id); + } + const [agentResults, [terminalResult]] = await Promise.all([ + Promise.allSettled( + agentIdsToArchive.map((agentId) => + dependencies.agentManager.getAgent(agentId) + ? dependencies.agentManager.archiveAgent(agentId) + : dependencies.agentManager.archiveSnapshot(agentId, archivedAt), + ), ), - dependencies.killTerminalsForWorkspace(workspaceId), + Promise.allSettled([dependencies.killTerminalsForWorkspace(workspaceId)]), ]); - for (const result of archiveResults) { + const newlyArchivedAgentIds: string[] = []; + for (const [index, result] of agentResults.entries()) { + const agentId = agentIdsToArchive[index]!; if (result.status === "rejected") { dependencies.sessionLogger?.warn( - { err: result.reason, workspaceId }, + { err: result.reason, workspaceId, agentId }, "Workspace archive teardown step failed; continuing", ); + } else if (restorableAgentIds.has(agentId)) { + newlyArchivedAgentIds.push(agentId); } } + if (terminalResult?.status === "rejected") { + dependencies.sessionLogger?.warn( + { err: terminalResult.reason, workspaceId }, + "Workspace archive teardown step failed; continuing", + ); + } - return archivedAgents; + return { agentIds: archivedAgents, newlyArchivedAgentIds }; } // True when, after archiving diff --git a/packages/server/src/server/workspace-archive-undo.e2e.test.ts b/packages/server/src/server/workspace-archive-undo.e2e.test.ts new file mode 100644 index 0000000000..674e99415b --- /dev/null +++ b/packages/server/src/server/workspace-archive-undo.e2e.test.ts @@ -0,0 +1,275 @@ +import { execFileSync } from "node:child_process"; +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"; + +import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js"; +import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/index.js"; + +// Undo of a workspace archive, end to end through a real daemon. The archive is parked inside a +// worktree teardown command that waits on a file, so the undo lands at a known point. + +let ctx: DaemonTestContext; +const tempRoots: string[] = []; + +beforeEach(async () => { + ctx = await createDaemonTestContext(); +}); + +afterEach(async () => { + await ctx.cleanup(); + for (const tempRoot of tempRoots.splice(0)) { + rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +interface GatedRepo { + repoDir: string; + teardownStartedPath: string; + releaseTeardown: () => void; +} + +function git(repoDir: string, args: string[]): void { + execFileSync("git", args, { cwd: repoDir, stdio: "pipe" }); +} + +/** A repo whose worktree teardown parks until released. */ +function createRepoWithGatedTeardown(): GatedRepo { + const tempRoot = mkdtempSync(path.join(tmpdir(), "workspace-archive-undo-")); + tempRoots.push(tempRoot); + const repoDir = path.join(tempRoot, "repo"); + const teardownStartedPath = path.join(tempRoot, "teardown-started"); + const releasePath = path.join(tempRoot, "release-teardown"); + execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" }); + git(repoDir, ["config", "user.email", "test@getpaseo.local"]); + git(repoDir, ["config", "user.name", "Paseo Test"]); + const teardown = `node -e "const fs=require('fs');fs.writeFileSync('${teardownStartedPath}','started');const timer=setInterval(()=>{if(fs.existsSync('${releasePath}'))clearInterval(timer)},20)"`; + writeFileSync( + path.join(repoDir, "paseo.json"), + JSON.stringify({ worktree: { teardown: [teardown] } }), + ); + git(repoDir, ["add", "paseo.json"]); + git(repoDir, ["-c", "commit.gpgsign=false", "commit", "-m", "gated teardown"]); + return { + repoDir, + teardownStartedPath, + releaseTeardown: () => writeFileSync(releasePath, "release\n"), + }; +} + +async function createWorktreeWorkspaceWithAgent(repoDir: string, slug: string) { + const result = await ctx.client.createWorkspace({ + source: { kind: "worktree", cwd: repoDir, worktreeSlug: slug, baseBranch: "main" }, + }); + const workspace = result.workspace; + if (!workspace?.workspaceDirectory) { + throw new Error(result.error ?? "Failed to create worktree workspace"); + } + const agent = await ctx.client.createAgent({ + ...getFullAccessConfig("codex"), + cwd: workspace.workspaceDirectory, + workspaceId: workspace.id, + title: "Undo agent", + }); + return { workspaceId: workspace.id, directory: workspace.workspaceDirectory, agentId: agent.id }; +} + +async function isWorkspaceActive(workspaceId: string): Promise { + const workspaces = await ctx.client.fetchWorkspaces(); + return workspaces.entries.some((entry) => entry.id === workspaceId); +} + +async function isAgentArchived(agentId: string): Promise { + const agents = await ctx.client.fetchAgents({ filter: { includeArchived: true } }); + const entry = agents.entries.find((candidate) => candidate.agent.id === agentId); + if (!entry) throw new Error(`Agent ${agentId} is missing`); + return entry.agent.archivedAt !== null && entry.agent.archivedAt !== undefined; +} + +test.skipIf(process.platform === "win32")( + "an undo while teardown runs cancels the archive before the worktree is deleted", + async () => { + const repo = createRepoWithGatedTeardown(); + const { workspaceId, directory, agentId } = await createWorktreeWorkspaceWithAgent( + repo.repoDir, + "undo-during-teardown", + ); + + try { + const archive = ctx.client.archiveWorkspace(workspaceId); + await expect.poll(() => existsSync(repo.teardownStartedPath), { timeout: 15000 }).toBe(true); + const undo = ctx.client.undoWorkspaceArchive(workspaceId); + repo.releaseTeardown(); + + const [archiveResponse] = await Promise.all([archive, undo]); + + expect({ + canceled: archiveResponse.canceled, + archivedAt: archiveResponse.archivedAt, + workspaceActive: await isWorkspaceActive(workspaceId), + directoryExists: existsSync(directory), + agentArchived: await isAgentArchived(agentId), + }).toEqual({ + canceled: true, + archivedAt: null, + workspaceActive: true, + directoryExists: true, + agentArchived: false, + }); + } finally { + repo.releaseTeardown(); + } + }, + 60000, +); + +test.skipIf(process.platform === "win32")( + "an undo after the archive finished recreates the worktree and its agents", + async () => { + const repo = createRepoWithGatedTeardown(); + repo.releaseTeardown(); + const { workspaceId, directory, agentId } = await createWorktreeWorkspaceWithAgent( + repo.repoDir, + "undo-after-archive", + ); + + const archived = await ctx.client.archiveWorkspace(workspaceId); + expect(archived.error).toBeNull(); + expect(existsSync(directory)).toBe(false); + expect(await isAgentArchived(agentId)).toBe(true); + + await ctx.client.undoWorkspaceArchive(workspaceId); + + expect({ + workspaceActive: await isWorkspaceActive(workspaceId), + directoryExists: existsSync(directory), + agentArchived: await isAgentArchived(agentId), + }).toEqual({ workspaceActive: true, directoryExists: true, agentArchived: false }); + }, + 60000, +); + +test.skipIf(process.platform === "win32")( + "archive, undo, archive leaves the workspace archived", + async () => { + const repo = createRepoWithGatedTeardown(); + const { workspaceId, directory, agentId } = await createWorktreeWorkspaceWithAgent( + repo.repoDir, + "archive-undo-archive", + ); + + try { + const first = ctx.client.archiveWorkspace(workspaceId); + await expect.poll(() => existsSync(repo.teardownStartedPath), { timeout: 15000 }).toBe(true); + const undo = ctx.client.undoWorkspaceArchive(workspaceId); + const second = ctx.client.archiveWorkspace(workspaceId); + repo.releaseTeardown(); + + const [firstResponse, , secondResponse] = await Promise.all([first, undo, second]); + + expect({ + firstCanceled: firstResponse.canceled, + secondError: secondResponse.error, + secondCanceled: secondResponse.canceled ?? false, + workspaceActive: await isWorkspaceActive(workspaceId), + directoryExists: existsSync(directory), + agentArchived: await isAgentArchived(agentId), + }).toEqual({ + firstCanceled: true, + secondError: null, + secondCanceled: false, + workspaceActive: false, + directoryExists: false, + agentArchived: true, + }); + } finally { + repo.releaseTeardown(); + } + }, + 60000, +); + +test.skipIf(process.platform === "win32")( + "an undo sent right behind its archive leaves the workspace active", + async () => { + const repo = createRepoWithGatedTeardown(); + repo.releaseTeardown(); + const { workspaceId, directory, agentId } = await createWorktreeWorkspaceWithAgent( + repo.repoDir, + "archive-then-undo-same-tick", + ); + + const [archiveResponse] = await Promise.all([ + ctx.client.archiveWorkspace(workspaceId), + ctx.client.undoWorkspaceArchive(workspaceId), + ]); + + expect({ + archiveCanceled: archiveResponse.canceled ?? false, + workspaceActive: await isWorkspaceActive(workspaceId), + directoryExists: existsSync(directory), + agentArchived: await isAgentArchived(agentId), + }).toEqual({ + archiveCanceled: true, + workspaceActive: true, + directoryExists: true, + agentArchived: false, + }); + }, + 60000, +); + +test.skipIf(process.platform === "win32")( + "the undo response arrives after the client has seen its agents restored", + async () => { + const repo = createRepoWithGatedTeardown(); + repo.releaseTeardown(); + const { workspaceId, agentId } = await createWorktreeWorkspaceWithAgent( + repo.repoDir, + "undo-update-order", + ); + await ctx.client.fetchAgents({ subscribe: {} }); + // An archived agent leaves a default subscription; a restored one comes back as active. + let seenState: "active" | "archived" | "removed" = "active"; + const stop = ctx.client.on("agent_update", (message) => { + if (message.payload.kind === "remove" && message.payload.agentId === agentId) { + seenState = "removed"; + } + if (message.payload.kind === "upsert" && message.payload.agent.id === agentId) { + seenState = message.payload.agent.archivedAt ? "archived" : "active"; + } + }); + + try { + await ctx.client.archiveWorkspace(workspaceId); + await expect.poll(() => seenState, { timeout: 15000 }).not.toBe("active"); + + await ctx.client.undoWorkspaceArchive(workspaceId); + + // An app holds its agent tabs open until this response; an agent it still sees as + // archived at that point gets its tab closed. + expect(seenState).toBe("active"); + } finally { + stop(); + } + }, + 60000, +); + +test("an undo on a workspace that was never archived leaves it as it was", async () => { + const repo = createRepoWithGatedTeardown(); + repo.releaseTeardown(); + const { workspaceId, directory, agentId } = await createWorktreeWorkspaceWithAgent( + repo.repoDir, + "undo-without-archive", + ); + + await ctx.client.undoWorkspaceArchive(workspaceId); + + expect({ + workspaceActive: await isWorkspaceActive(workspaceId), + directoryExists: existsSync(directory), + agentArchived: await isAgentArchived(agentId), + }).toEqual({ workspaceActive: true, directoryExists: true, agentArchived: false }); +}, 60000);