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