diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index 014965a58..9d7eed278 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -83,6 +83,7 @@ function workspace(input: { workspaceKind: input.name === "main" ? "local_checkout" : "worktree", name: input.name, status: input.status ?? "done", + archivingAt: null, diffStat: null, scripts: input.scripts ?? [], }; diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 19227105e..c6b24168e 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -1462,7 +1462,7 @@ function WorkspaceRowWithMenu({ : "idle", ); const isWorktree = workspace.workspaceKind === "worktree"; - const isArchiving = isWorktree ? archiveStatus === "pending" : isArchivingWorkspace; + const isArchiving = isWorktree ? workspace.archivingAt !== null : isArchivingWorkspace; const redirectAfterArchive = useCallback(() => { redirectIfArchivingActiveWorkspace({ serverId: workspace.serverId, diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx index b8e3692f4..8809abf3e 100644 --- a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx +++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx @@ -31,6 +31,7 @@ function workspaceDescriptor(input: { workspaceKind: "worktree", name: input.name ?? input.id, status: "done", + archivingAt: null, diffStat: null, scripts: [], }; diff --git a/packages/app/src/contexts/session-context.service-status.test.ts b/packages/app/src/contexts/session-context.service-status.test.ts index ed23076a8..aad407202 100644 --- a/packages/app/src/contexts/session-context.service-status.test.ts +++ b/packages/app/src/contexts/session-context.service-status.test.ts @@ -18,6 +18,7 @@ function workspace(input: { workspaceKind: "checkout", name: "main", status: "running", + archivingAt: null, diffStat: null, scripts: input.scripts ?? [], }; diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 0a4acc788..ecaf39ccb 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -41,6 +41,7 @@ import { normalizeWorkspaceDescriptor, } from "@/stores/session-store"; import { useDraftStore } from "@/stores/draft-store"; +import { isLocalWorktreeArchivePending } from "@/stores/checkout-git-actions-store"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { sendOsNotification } from "@/utils/os-notifications"; import { getIsAppActivelyVisible } from "@/utils/app-visibility"; @@ -58,6 +59,7 @@ import type { AttachmentMetadata } from "@/attachments/types"; import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments"; import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking"; import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts"; +import { shouldSuppressWorkspaceUpsertForLocalArchive } from "@/contexts/session-workspace-upserts"; import { isNative } from "@/constants/platform"; import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; @@ -1232,6 +1234,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider return; } const workspace = normalizeWorkspaceDescriptor(message.payload.workspace); + if ( + shouldSuppressWorkspaceUpsertForLocalArchive({ + serverId, + workspace, + isArchivePending: isLocalWorktreeArchivePending, + }) + ) { + return; + } mergeWorkspaces(serverId, [workspace]); }); diff --git a/packages/app/src/contexts/session-workspace-upserts.test.ts b/packages/app/src/contexts/session-workspace-upserts.test.ts new file mode 100644 index 000000000..39c67e110 --- /dev/null +++ b/packages/app/src/contexts/session-workspace-upserts.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { shouldSuppressWorkspaceUpsertForLocalArchive } from "@/contexts/session-workspace-upserts"; + +const baseWorkspace: WorkspaceDescriptor = { + id: "/repo/worktree", + projectId: "/repo", + projectDisplayName: "Repo", + projectRootPath: "/repo", + workspaceDirectory: "/repo/worktree", + projectKind: "git", + workspaceKind: "worktree", + name: "feature", + status: "done", + archivingAt: "2026-04-30T00:00:00.000Z", + diffStat: null, + scripts: [], +}; + +function workspace(input?: Partial): WorkspaceDescriptor { + return { ...baseWorkspace, ...input }; +} + +describe("shouldSuppressWorkspaceUpsertForLocalArchive", () => { + it("suppresses archiving upserts for a locally pending archive", () => { + const isArchivePending = vi.fn(() => true); + + expect( + shouldSuppressWorkspaceUpsertForLocalArchive({ + serverId: "server-1", + workspace: workspace({ workspaceDirectory: "/repo/worktree" }), + isArchivePending, + }), + ).toBe(true); + expect(isArchivePending).toHaveBeenCalledWith({ + serverId: "server-1", + cwd: "/repo/worktree", + }); + }); + + it("allows archiving upserts when this client did not start the archive", () => { + expect( + shouldSuppressWorkspaceUpsertForLocalArchive({ + serverId: "server-1", + workspace: workspace(), + isArchivePending: () => false, + }), + ).toBe(false); + }); + + it("allows normal upserts while a local archive is pending", () => { + expect( + shouldSuppressWorkspaceUpsertForLocalArchive({ + serverId: "server-1", + workspace: workspace({ archivingAt: null }), + isArchivePending: () => true, + }), + ).toBe(false); + }); +}); diff --git a/packages/app/src/contexts/session-workspace-upserts.ts b/packages/app/src/contexts/session-workspace-upserts.ts new file mode 100644 index 000000000..55c123c30 --- /dev/null +++ b/packages/app/src/contexts/session-workspace-upserts.ts @@ -0,0 +1,15 @@ +import type { WorkspaceDescriptor } from "@/stores/session-store"; + +export function shouldSuppressWorkspaceUpsertForLocalArchive(input: { + serverId: string; + workspace: WorkspaceDescriptor; + isArchivePending: (params: { serverId: string; cwd: string }) => boolean; +}): boolean { + return ( + input.workspace.archivingAt !== null && + input.isArchivePending({ + serverId: input.serverId, + cwd: input.workspace.workspaceDirectory, + }) + ); +} diff --git a/packages/app/src/hooks/use-open-project.test.ts b/packages/app/src/hooks/use-open-project.test.ts index bc365efc5..252a14196 100644 --- a/packages/app/src/hooks/use-open-project.test.ts +++ b/packages/app/src/hooks/use-open-project.test.ts @@ -78,6 +78,7 @@ describe("openProjectDirectly", () => { projectKind: "git" as const, workspaceKind: "checkout" as const, name: "project", + archivingAt: null, status: "done" as const, activityAt: null, diffStat: null, diff --git a/packages/app/src/hooks/use-projects.test.ts b/packages/app/src/hooks/use-projects.test.ts index 5f66af2f5..6b07a4a1b 100644 --- a/packages/app/src/hooks/use-projects.test.ts +++ b/packages/app/src/hooks/use-projects.test.ts @@ -87,6 +87,7 @@ function workspace(input: { projectKind: "git", workspaceKind: "local_checkout", name: input.id, + archivingAt: null, status: "done", activityAt: null, diffStat: null, diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts index ab3f684af..f0c36de48 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts @@ -57,6 +57,7 @@ function workspaceDescriptor(id: string): WorkspaceDescriptor { workspaceKind: "worktree", name: id, status: "done", + archivingAt: null, diffStat: null, scripts: [], }; diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 5af1f481a..ef7ef5636 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -28,6 +28,7 @@ export interface SidebarWorkspaceEntry { workspaceKind: WorkspaceDescriptor["workspaceKind"]; name: string; statusBucket: SidebarStateBucket; + archivingAt: string | null; diffStat: { additions: number; deletions: number } | null; scripts: WorkspaceDescriptor["scripts"]; hasRunningScripts: boolean; @@ -65,6 +66,7 @@ function createStructuralWorkspaceEntry(input: { workspaceKind: "checkout", name: input.workspaceId, statusBucket: "done", + archivingAt: null, diffStat: null, scripts: [], hasRunningScripts: false, @@ -86,6 +88,7 @@ export function createSidebarWorkspaceEntry(input: { workspaceKind: input.workspace.workspaceKind, name: input.workspace.name, statusBucket: input.workspace.status, + archivingAt: input.workspace.archivingAt, diffStat: input.workspace.diffStat, scripts: input.workspace.scripts, hasRunningScripts: input.workspace.scripts.some((script) => script.lifecycle === "running"), diff --git a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts index 90e870c96..b90a9eb17 100644 --- a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts +++ b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts @@ -26,6 +26,7 @@ function createWorkspaceDescriptor(input: Partial = {}): Wo diffStat: null, scripts: [], ...input, + archivingAt: input.archivingAt ?? null, }; } diff --git a/packages/app/src/stores/checkout-git-actions-store.test.ts b/packages/app/src/stores/checkout-git-actions-store.test.ts index bf5f9e6db..05aa0282c 100644 --- a/packages/app/src/stores/checkout-git-actions-store.test.ts +++ b/packages/app/src/stores/checkout-git-actions-store.test.ts @@ -7,6 +7,7 @@ import type { WorkspaceDescriptor } from "@/stores/session-store"; import { __resetCheckoutGitActionsStoreForTests, invalidateCheckoutGitQueriesForClient, + isLocalWorktreeArchivePending, useCheckoutGitActionsStore, } from "@/stores/checkout-git-actions-store"; @@ -39,6 +40,7 @@ function workspace(input: Partial & Pick { expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual([ { worktreePath: "/tmp/other" }, ]); + expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(true); deferred.resolve({}); await archive; @@ -233,4 +236,25 @@ describe("checkout-git-actions-store", () => { listSnapshot, ); }); + + it("reports local archive pending only while the archive action is in flight", async () => { + const deferred = createDeferred>(); + const client = { + archivePaseoWorktree: vi.fn(() => deferred.promise), + }; + const featureWorkspace = workspace({ id: cwd, name: "feature" }); + useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient); + useSessionStore.getState().setWorkspaces(serverId, new Map([[cwd, featureWorkspace]])); + + const archive = useCheckoutGitActionsStore + .getState() + .archiveWorktree({ serverId, cwd, worktreePath: cwd }); + + expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(true); + + deferred.resolve({}); + await archive; + + expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(false); + }); }); diff --git a/packages/app/src/stores/checkout-git-actions-store.ts b/packages/app/src/stores/checkout-git-actions-store.ts index 11a6047c0..0f631bc48 100644 --- a/packages/app/src/stores/checkout-git-actions-store.ts +++ b/packages/app/src/stores/checkout-git-actions-store.ts @@ -191,7 +191,6 @@ function removeWorktreeFromSessionStore(input: { serverId: string; worktreePath: function restoreWorktreeArchiveState(input: { serverId: string; - worktreePath: string; snapshot: WorktreeArchiveSnapshot; }): void { if (input.snapshot.workspace) { @@ -223,6 +222,16 @@ function inFlightKey(key: CheckoutKey, actionId: CheckoutGitAsyncActionId): stri return `${key}::${actionId}`; } +export function isLocalWorktreeArchivePending(input: { serverId: string; cwd: string }): boolean { + return ( + useCheckoutGitActionsStore.getState().getStatus({ + serverId: input.serverId, + cwd: input.cwd, + actionId: "archive-worktree", + }) === "pending" + ); +} + interface CheckoutGitActionsStoreState { statusByCheckout: Record; @@ -436,7 +445,7 @@ export const useCheckoutGitActionsStore = create() throw new Error(payload.error.message); } } catch (error) { - restoreWorktreeArchiveState({ serverId, worktreePath, snapshot }); + restoreWorktreeArchiveState({ serverId, snapshot }); throw error; } invalidateWorktreeList(); diff --git a/packages/app/src/stores/session-store-hooks.test.ts b/packages/app/src/stores/session-store-hooks.test.ts index f8566472e..b5258d88f 100644 --- a/packages/app/src/stores/session-store-hooks.test.ts +++ b/packages/app/src/stores/session-store-hooks.test.ts @@ -34,6 +34,7 @@ function createWorkspace( workspaceKind: input.workspaceKind ?? "local_checkout", name: input.name ?? "main", status: input.status ?? "done", + archivingAt: input.archivingAt ?? null, diffStat: input.diffStat ?? null, scripts: input.scripts ?? [], }; diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 8ea73dbcf..f82782e67 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -23,6 +23,7 @@ function createWorkspace( workspaceKind: input.workspaceKind ?? "local_checkout", name: input.name ?? "main", status: input.status ?? "done", + archivingAt: input.archivingAt ?? null, diffStat: input.diffStat ?? null, scripts: input.scripts ?? [], }; @@ -73,6 +74,7 @@ describe("normalizeWorkspaceDescriptor", () => { projectKind: "git", workspaceKind: "checkout", name: "main", + archivingAt: null, status: "running", activityAt: "not-a-date", diffStat: null, @@ -105,6 +107,7 @@ describe("normalizeWorkspaceDescriptor", () => { projectKind: "git", workspaceKind: "checkout", name: "main", + archivingAt: null, status: "done", activityAt: null, diffStat: null, @@ -116,6 +119,27 @@ describe("normalizeWorkspaceDescriptor", () => { expect(workspace.scripts).toEqual([]); }); + it("defaults missing archivingAt to null", () => { + const payload = { + id: "1", + projectId: "1", + projectDisplayName: "Project 1", + projectRootPath: "/repo", + workspaceDirectory: "/repo", + projectKind: "git", + workspaceKind: "checkout", + name: "main", + status: "done", + activityAt: null, + diffStat: null, + scripts: [], + } as unknown as WorkspaceDescriptorPayload; + + const workspace = normalizeWorkspaceDescriptor(payload); + + expect(workspace.archivingAt).toBeNull(); + }); + it("preserves project placement from workspace descriptor payloads", () => { const workspace = normalizeWorkspaceDescriptor({ id: "1", @@ -126,6 +150,7 @@ describe("normalizeWorkspaceDescriptor", () => { projectKind: "git", workspaceKind: "local_checkout", name: "main", + archivingAt: null, status: "done", activityAt: null, diffStat: null, diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 4cc08d49e..4d88cb1a3 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -117,6 +117,7 @@ export interface WorkspaceDescriptor { workspaceKind: WorkspaceDescriptorPayload["workspaceKind"]; name: string; status: WorkspaceDescriptorPayload["status"]; + archivingAt: string | null; diffStat: { additions: number; deletions: number } | null; scripts: WorkspaceDescriptorPayload["scripts"]; gitRuntime?: WorkspaceDescriptorPayload["gitRuntime"]; @@ -137,6 +138,7 @@ export function normalizeWorkspaceDescriptor( workspaceKind: payload.workspaceKind, name: payload.name, status: payload.status, + archivingAt: payload.archivingAt ?? null, diffStat: payload.diffStat ?? null, scripts: (payload.scripts ?? []).map((s) => Object.assign({}, s)), gitRuntime: payload.gitRuntime, diff --git a/packages/app/src/utils/projects.test.ts b/packages/app/src/utils/projects.test.ts index 7500fe04c..fff1cde08 100644 --- a/packages/app/src/utils/projects.test.ts +++ b/packages/app/src/utils/projects.test.ts @@ -43,6 +43,7 @@ function workspace(input: { workspaceKind: "local_checkout", name: input.id, status: "done", + archivingAt: null, diffStat: null, scripts: [], gitRuntime: { diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index 7554877bb..9972b4085 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -23,6 +23,7 @@ function workspace(overrides: Partial = {}): SidebarWorks scripts: [], hasRunningScripts: false, ...overrides, + archivingAt: overrides.archivingAt ?? null, }; } diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index cdad6e862..2cd200d64 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -23,6 +23,7 @@ function workspace(input: { workspaceKind: "checkout", name: input.name, statusBucket: "done", + archivingAt: null, diffStat: null, scripts: [], hasRunningScripts: false, diff --git a/packages/app/src/utils/workspace-archive-navigation.test.ts b/packages/app/src/utils/workspace-archive-navigation.test.ts index 067cf9f74..87c932487 100644 --- a/packages/app/src/utils/workspace-archive-navigation.test.ts +++ b/packages/app/src/utils/workspace-archive-navigation.test.ts @@ -32,6 +32,7 @@ function workspace( workspaceKind: input.workspaceKind ?? "worktree", name: input.name ?? input.id, status: input.status ?? "done", + archivingAt: input.archivingAt ?? null, diffStat: input.diffStat ?? null, scripts: input.scripts ?? [], }; diff --git a/packages/app/src/utils/workspace-execution.test.ts b/packages/app/src/utils/workspace-execution.test.ts index 335b16399..9d96e8f1e 100644 --- a/packages/app/src/utils/workspace-execution.test.ts +++ b/packages/app/src/utils/workspace-execution.test.ts @@ -21,6 +21,7 @@ function createWorkspace( workspaceKind: input.workspaceKind ?? "checkout", name: input.name ?? "main", status: input.status ?? "running", + archivingAt: input.archivingAt ?? null, diffStat: input.diffStat ?? null, scripts: input.scripts ?? [], }; diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 0c0db97f1..6c59761f9 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -743,7 +743,9 @@ describe("create_agent MCP tool", () => { getSnapshot: vi.fn(async () => null), }; const archiveWorkspaceRecord = vi.fn(async () => undefined); - const emitWorkspaceUpdatesForCwds = vi.fn(async () => undefined); + const emitWorkspaceUpdatesForWorkspaceIds = vi.fn(async () => undefined); + const markWorkspaceArchiving = vi.fn(); + const clearWorkspaceArchiving = vi.fn(); const emitSessionMessage = vi.fn(); const server = await createAgentMcpServer({ agentManager, @@ -755,7 +757,9 @@ describe("create_agent MCP tool", () => { "getSnapshot" | "listWorktrees" >, archiveWorkspaceRecord, - emitWorkspaceUpdatesForCwds, + emitWorkspaceUpdatesForWorkspaceIds, + markWorkspaceArchiving, + clearWorkspaceArchiving, emitSessionMessage, github: createGitHubServiceStub(), logger, @@ -779,7 +783,14 @@ describe("create_agent MCP tool", () => { reason: "archive-worktree", }); expect(archiveWorkspaceRecord).toHaveBeenCalledWith(created.structuredContent.worktreePath); - expect(Array.from(emitWorkspaceUpdatesForCwds.mock.calls[0]?.[0] ?? [])).toEqual([ + expect(markWorkspaceArchiving).toHaveBeenCalledWith( + [created.structuredContent.worktreePath], + expect.any(String), + ); + expect(clearWorkspaceArchiving).toHaveBeenCalledWith([ + created.structuredContent.worktreePath, + ]); + expect(Array.from(emitWorkspaceUpdatesForWorkspaceIds.mock.calls[0]?.[0] ?? [])).toEqual([ created.structuredContent.worktreePath, ]); } finally { diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 3935c9720..fd6020700 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -79,7 +79,9 @@ export interface AgentMcpServerOptions { github?: GitHubService; workspaceGitService?: Pick; archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"]; - emitWorkspaceUpdatesForCwds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForCwds"]; + emitWorkspaceUpdatesForWorkspaceIds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForWorkspaceIds"]; + markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"]; + clearWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["clearWorkspaceArchiving"]; emitSessionMessage?: ArchivePaseoWorktreeDependencies["emit"]; createPaseoWorktree?: CreatePaseoWorktreeFn; paseoHome?: string; @@ -1850,9 +1852,15 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom if (!options.archiveWorkspaceRecord) { throw new Error("Workspace registry archiver is required to archive worktrees"); } - if (!options.emitWorkspaceUpdatesForCwds) { + if (!options.emitWorkspaceUpdatesForWorkspaceIds) { throw new Error("Workspace update emitter is required to archive worktrees"); } + if (!options.markWorkspaceArchiving) { + throw new Error("Workspace archiving marker is required to archive worktrees"); + } + if (!options.clearWorkspaceArchiving) { + throw new Error("Workspace archiving clearer is required to archive worktrees"); + } if (!options.emitSessionMessage) { throw new Error("Session message emitter is required to archive worktrees"); } @@ -1870,7 +1878,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom agentStorage, archiveWorkspaceRecord: options.archiveWorkspaceRecord, emit: options.emitSessionMessage, - emitWorkspaceUpdatesForCwds: options.emitWorkspaceUpdatesForCwds, + emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds, + markWorkspaceArchiving: options.markWorkspaceArchiving, + clearWorkspaceArchiving: options.clearWorkspaceArchiving, isPathWithinRoot: isSameOrDescendantPath, killTerminalsUnderPath: (rootPath) => killTerminalsUnderPath( diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index c8c43df55..03b91bf59 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -519,11 +519,26 @@ export async function createPaseoDaemon( projectRegistry, }); }; - const emitWorkspaceUpdatesForMcpArchive = async (cwds: Iterable) => { - const cwdList = Array.from(cwds); + const markWorkspaceArchivingForMcpArchive = ( + workspaceIds: Iterable, + archivingAt: string, + ) => { + const workspaceIdList = Array.from(workspaceIds); + for (const session of wsServer?.listActiveSessions() ?? []) { + session.markWorkspaceArchivingForExternalMutation(workspaceIdList, archivingAt); + } + }; + const clearWorkspaceArchivingForMcpArchive = (workspaceIds: Iterable) => { + const workspaceIdList = Array.from(workspaceIds); + for (const session of wsServer?.listActiveSessions() ?? []) { + session.clearWorkspaceArchivingForExternalMutation(workspaceIdList); + } + }; + const emitWorkspaceUpdatesForMcpArchive = async (workspaceIds: Iterable) => { + const workspaceIdList = Array.from(workspaceIds); await Promise.all( (wsServer?.listActiveSessions() ?? []).map((session) => - session.emitWorkspaceUpdatesForExternalCwds(cwdList), + session.emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIdList), ), ); }; @@ -542,7 +557,9 @@ export async function createPaseoDaemon( github, workspaceGitService, archiveWorkspaceRecord: archiveWorkspaceRecordForMcp, - emitWorkspaceUpdatesForCwds: emitWorkspaceUpdatesForMcpArchive, + emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesForMcpArchive, + markWorkspaceArchiving: markWorkspaceArchivingForMcpArchive, + clearWorkspaceArchiving: clearWorkspaceArchivingForMcpArchive, emitSessionMessage: emitMcpArchiveSessionMessage, createPaseoWorktree: async (input, serviceOptions) => { const coreDeps = createWorktreeCoreDeps(github); diff --git a/packages/server/src/server/paseo-worktree-archive-service.ts b/packages/server/src/server/paseo-worktree-archive-service.ts index 9001c365f..28b2eaf02 100644 --- a/packages/server/src/server/paseo-worktree-archive-service.ts +++ b/packages/server/src/server/paseo-worktree-archive-service.ts @@ -19,7 +19,9 @@ export interface ArchivePaseoWorktreeDependencies { agentStorage: Pick; archiveWorkspaceRecord: (workspaceId: string) => Promise; emit: EmitSessionMessage; - emitWorkspaceUpdatesForCwds: (cwds: Iterable) => Promise; + emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable) => Promise; + markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => void; + clearWorkspaceArchiving: (workspaceIds: Iterable) => void; isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; killTerminalsUnderPath: (rootPath: string) => Promise; sessionLogger?: Logger; @@ -86,87 +88,95 @@ export async function archivePaseoWorktree( ...matchingStoredRecords.map((record) => record.id), ]); - const teardownResults = await Promise.allSettled([ - ...liveAgents.map((agent) => dependencies.agentManager.closeAgent(agent.id)), - dependencies.killTerminalsUnderPath(targetPath), - ]); + const affectedWorkspaceIdList = Array.from(affectedWorkspaceIds); + dependencies.markWorkspaceArchiving(affectedWorkspaceIdList, new Date().toISOString()); - for (const result of teardownResults) { - if (result.status === "rejected") { - dependencies.sessionLogger?.warn( - { err: result.reason, targetPath }, - "Worktree teardown step failed during archive; continuing", - ); - } - } + try { + await dependencies.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIdList); - const agentIdsToRemove = Array.from(agentIdsToRemoveFromStorage); - const storageRemovalResults = await Promise.allSettled( - agentIdsToRemove.map((agentId) => dependencies.agentStorage.remove(agentId)), - ); - storageRemovalResults.forEach((result, index) => { - if (result.status === "fulfilled") { - return; - } - dependencies.sessionLogger?.warn( - { - err: result.reason, - agentId: agentIdsToRemove[index], - targetPath, - }, - "Failed to remove archived worktree agent from storage; continuing", - ); - }); + const teardownResults = await Promise.allSettled([ + ...liveAgents.map((agent) => dependencies.agentManager.closeAgent(agent.id)), + dependencies.killTerminalsUnderPath(targetPath), + ]); - await deletePaseoWorktree({ - cwd: options.repoRoot, - worktreePath: targetPath, - worktreesRoot: options.worktreesRoot, - paseoHome: dependencies.paseoHome, - }); - - if (options.repoRoot) { - try { - await dependencies.workspaceGitService.getSnapshot(options.repoRoot, { - force: true, - reason: "archive-worktree", - }); - } catch (error) { - dependencies.sessionLogger?.warn( - { err: error, cwd: options.repoRoot }, - "Failed to force-refresh workspace git snapshot after archiving worktree", - ); - } - } - - for (const cwd of affectedWorkspaceCwds) { - dependencies.github.invalidate({ cwd }); - } - - await Promise.all( - Array.from(affectedWorkspaceIds).map(async (workspaceId) => { - try { - await dependencies.archiveWorkspaceRecord(workspaceId); - } catch (error) { + for (const result of teardownResults) { + if (result.status === "rejected") { dependencies.sessionLogger?.warn( - { err: error, workspaceId }, - "Failed to archive workspace record; worktree FS already removed", + { err: result.reason, targetPath }, + "Worktree teardown step failed during archive; continuing", ); } - }), - ); + } - for (const agentId of removedAgents) { - dependencies.emit({ - type: "agent_deleted", - payload: { - agentId, - requestId: options.requestId, - }, + const agentIdsToRemove = Array.from(agentIdsToRemoveFromStorage); + const storageRemovalResults = await Promise.allSettled( + agentIdsToRemove.map((agentId) => dependencies.agentStorage.remove(agentId)), + ); + storageRemovalResults.forEach((result, index) => { + if (result.status === "fulfilled") { + return; + } + dependencies.sessionLogger?.warn( + { + err: result.reason, + agentId: agentIdsToRemove[index], + targetPath, + }, + "Failed to remove archived worktree agent from storage; continuing", + ); }); - } - await dependencies.emitWorkspaceUpdatesForCwds(affectedWorkspaceCwds); + await deletePaseoWorktree({ + cwd: options.repoRoot, + worktreePath: targetPath, + worktreesRoot: options.worktreesRoot, + paseoHome: dependencies.paseoHome, + }); + + if (options.repoRoot) { + try { + await dependencies.workspaceGitService.getSnapshot(options.repoRoot, { + force: true, + reason: "archive-worktree", + }); + } catch (error) { + dependencies.sessionLogger?.warn( + { err: error, cwd: options.repoRoot }, + "Failed to force-refresh workspace git snapshot after archiving worktree", + ); + } + } + + for (const cwd of affectedWorkspaceCwds) { + dependencies.github.invalidate({ cwd }); + } + + await Promise.all( + affectedWorkspaceIdList.map(async (workspaceId) => { + try { + await dependencies.archiveWorkspaceRecord(workspaceId); + } catch (error) { + dependencies.sessionLogger?.warn( + { err: error, workspaceId }, + "Failed to archive workspace record; worktree FS already removed", + ); + } + }), + ); + + for (const agentId of removedAgents) { + dependencies.emit({ + type: "agent_deleted", + payload: { + agentId, + requestId: options.requestId, + }, + }); + } + } finally { + dependencies.clearWorkspaceArchiving(affectedWorkspaceIdList); + await dependencies.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIdList); + } return Array.from(removedAgents); } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2d0b93088..62fd7a249 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -939,6 +939,7 @@ export class Session { private readonly workspaceSetupSnapshots: Map; private readonly workspaceGitFetchSubscriptions = new Map void>(); private readonly workspaceGitSubscriptions = new Map void>(); + private readonly archivingByWorkspaceId = new Map(); private registerVoiceSpeakHandler?: (agentId: string, handler: VoiceSpeakHandler) => void; private unregisterVoiceSpeakHandler?: (agentId: string) => void; private registerVoiceCallerContext?: (agentId: string, context: VoiceCallerContext) => void; @@ -1061,8 +1062,19 @@ export class Session { await this.archiveWorkspaceRecord(workspaceId); } - async emitWorkspaceUpdatesForExternalCwds(cwds: Iterable): Promise { - await this.emitWorkspaceUpdatesForCwds(cwds); + markWorkspaceArchivingForExternalMutation( + workspaceIds: Iterable, + archivingAt: string, + ): void { + this.markWorkspaceArchiving(workspaceIds, archivingAt); + } + + clearWorkspaceArchivingForExternalMutation(workspaceIds: Iterable): void { + this.clearWorkspaceArchiving(workspaceIds); + } + + async emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIds: Iterable): Promise { + await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds); } async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise { @@ -5468,14 +5480,13 @@ export class Session { workspaceGitService: this.workspaceGitService, agentManager: this.agentManager, agentStorage: this.agentStorage, - archiveWorkspaceRecord: async (workspaceDirectory) => { - const workspace = await this.findWorkspaceByDirectory(workspaceDirectory); - if (workspace) { - await this.archiveWorkspaceRecord(workspace.workspaceId); - } - }, + archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), emit: (message) => this.emit(message), - emitWorkspaceUpdatesForCwds: (cwds) => this.emitWorkspaceUpdatesForCwds(cwds), + emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) => + this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds), + markWorkspaceArchiving: (workspaceIds, archivingAt) => + this.markWorkspaceArchiving(workspaceIds, archivingAt), + clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds), isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath), killTerminalsUnderPath: (rootPath) => this.killTerminalsUnderPath(rootPath), @@ -6171,6 +6182,7 @@ export class Session { projectKind: (resolvedProjectRecord?.kind ?? "directory") === "git" ? "git" : "non_git", workspaceKind: workspace.kind, name: workspace.displayName, + archivingAt: null, status: "done", activityAt: null, diffStat, @@ -6258,6 +6270,7 @@ export class Session { projectKind: "git", workspaceKind: result.workspace.kind, name: result.worktree.branchName || result.workspace.displayName, + archivingAt: null, status: "done", activityAt: null, diffStat: { additions: 0, deletions: 0 }, @@ -6286,6 +6299,18 @@ export class Session { return this.describeWorkspaceRecord(input.workspace, input.projectRecord); } + markWorkspaceArchiving(workspaceIds: Iterable, archivingAt: string): void { + for (const workspaceId of workspaceIds) { + this.archivingByWorkspaceId.set(workspaceId, archivingAt); + } + } + + clearWorkspaceArchiving(workspaceIds: Iterable): void { + for (const workspaceId of workspaceIds) { + this.archivingByWorkspaceId.delete(workspaceId); + } + } + private async buildWorkspaceDescriptorMap(options: { includeGitData: boolean; workspaceIds?: Iterable; @@ -6329,7 +6354,11 @@ export class Session { ), ); for (let i = 0; i < includedWorkspaces.length; i += 1) { - descriptorsByWorkspaceId.set(includedWorkspaces[i]!.workspaceId, workspaceDescriptors[i]!); + const workspaceId = includedWorkspaces[i]!.workspaceId; + descriptorsByWorkspaceId.set(workspaceId, { + ...workspaceDescriptors[i]!, + archivingAt: this.archivingByWorkspaceId.get(workspaceId) ?? null, + }); } for (const agent of agents) { @@ -7019,15 +7048,6 @@ export class Session { await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], options); } - private async emitWorkspaceUpdatesForCwds(cwds: Iterable): Promise { - const workspaces = await this.workspaceRegistry.list(); - const uniqueWorkspaceIds = new Set(); - for (const cwd of cwds) { - uniqueWorkspaceIds.add(this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces)); - } - await this.emitWorkspaceUpdatesForWorkspaceIds(uniqueWorkspaceIds); - } - private async handleFetchAgents( request: Extract, ): Promise { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index f1936903d..42dcbf3a0 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -58,7 +58,10 @@ interface SessionTestAccess { buildWorkspaceDescriptorMap(...args: unknown[]): Promise>; describeWorkspaceRecord(...args: unknown[]): Promise; describeWorkspaceRecordWithGitData(...args: unknown[]): Promise; + markWorkspaceArchiving(workspaceIds: Iterable, archivingAt: string): void; + clearWorkspaceArchiving(workspaceIds: Iterable): void; emitWorkspaceUpdateForCwd(...args: unknown[]): Promise; + emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise; emit(message: unknown): void; onMessage(message: unknown): void; getAvailableEditorTargets(...args: unknown[]): Promise; @@ -3343,6 +3346,7 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc projectKind: project.kind, workspaceKind: workspace.kind, name: "main", + archivingAt: null, status: "done", activityAt: null, diffStat: null, @@ -3368,6 +3372,105 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc expect(descriptors).toEqual([baselineDescriptor]); }); +test("buildWorkspaceDescriptorMap stamps workspace archiving state", async () => { + const session = createSessionForWorkspaceTests(); + const archivingAt = "2026-04-30T20:45:00.000Z"; + const project = createPersistedProjectRecord({ + projectId: "proj-archiving-map", + rootPath: "/tmp/repo", + kind: "git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-archiving-map", + projectId: project.projectId, + cwd: "/tmp/repo/worktree", + kind: "worktree", + displayName: "feature", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + + session.listAgentPayloads = async () => []; + session.projectRegistry.list = async () => [project]; + session.workspaceRegistry.list = async () => [workspace]; + + const readArchivingAt = async () => + ( + await session.buildWorkspaceDescriptorMap({ + includeGitData: false, + }) + ).get(workspace.workspaceId)?.archivingAt; + + await expect(readArchivingAt()).resolves.toBeNull(); + + session.markWorkspaceArchiving([workspace.workspaceId], archivingAt); + await expect(readArchivingAt()).resolves.toBe(archivingAt); + + session.clearWorkspaceArchiving([workspace.workspaceId]); + await expect(readArchivingAt()).resolves.toBeNull(); +}); + +test("emitWorkspaceUpdatesForWorkspaceIds includes archiving state and dedupes unchanged emits", async () => { + const emitted: Array<{ type: string; payload: unknown }> = []; + const session = createSessionForWorkspaceTests(); + const archivingAt = "2026-04-30T20:45:00.000Z"; + const project = createPersistedProjectRecord({ + projectId: "proj-archiving-emit", + rootPath: "/tmp/repo", + kind: "git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-archiving-emit", + projectId: project.projectId, + cwd: "/tmp/repo/worktree", + kind: "worktree", + displayName: "feature", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + + session.emit = (message) => emitted.push(message as { type: string; payload: unknown }); + session.workspaceUpdatesSubscription = { + subscriptionId: "sub-archiving", + filter: undefined, + isBootstrapping: false, + pendingUpdatesByWorkspaceId: new Map(), + lastEmittedByWorkspaceId: new Map(), + }; + session.reconcileActiveWorkspaceRecords = async () => new Set(); + session.listAgentPayloads = async () => []; + session.projectRegistry.list = async () => [project]; + session.workspaceRegistry.list = async () => [workspace]; + + session.markWorkspaceArchiving([workspace.workspaceId], archivingAt); + + await session.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId], { + skipReconcile: true, + }); + await session.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId], { + skipReconcile: true, + }); + + expect(emitted).toEqual([ + { + type: "workspace_update", + payload: { + kind: "upsert", + workspace: expect.objectContaining({ + id: workspace.workspaceId, + archivingAt, + }), + }, + }, + ]); +}); + test("fetch_workspaces_response reads runtime fields from passive workspace git service snapshots", async () => { const emitted: Array<{ type: string; payload: unknown }> = []; const runtimeSnapshot = createWorkspaceRuntimeSnapshot("/tmp/repo", { diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index 68d9bfffa..dc7b1140a 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -354,6 +354,14 @@ function createAgentStorageStub(): Pick { }; } +function createWorkspaceArchivingDeps() { + return { + emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => {}), + markWorkspaceArchiving: vi.fn(), + clearWorkspaceArchiving: vi.fn(), + }; +} + function createGitRepo(options?: { paseoConfig?: Record }) { const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "worktree-session-test-"))); const repoDir = path.join(tempDir, "repo"); @@ -1548,7 +1556,7 @@ describe("archivePaseoWorktree", () => { agentStorage: createAgentStorageStub(), archiveWorkspaceRecord: vi.fn(async () => {}), emit: (msg) => emitted.push(msg), - emitWorkspaceUpdatesForCwds: vi.fn(async () => {}), + ...createWorkspaceArchivingDeps(), isPathWithinRoot: createIsPathWithinRoot(), killTerminalsUnderPath, sessionLogger: createLogger(), @@ -1573,6 +1581,235 @@ describe("archivePaseoWorktree", () => { expect(maxEnd - minStart).toBeLessThan(220); }); + test("emits archiving upserts during worktree archive request until final remove", async () => { + const { tempDir, repoDir } = createGitRepo(); + cleanupPaths.push(tempDir); + + const paseoHome = path.join(tempDir, ".paseo"); + const created = await createLegacyWorktreeForTest({ + branchName: "archive-marked-during-close", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "archive-marked-during-close", + runSetup: false, + paseoHome, + }); + const affectedIds = [created.worktreePath]; + const liveAgent = createManagedAgentForArchive({ + id: "agent-1", + cwd: created.worktreePath, + }); + const workspaceRecord: PersistedWorkspaceRecord = { + workspaceId: created.worktreePath, + projectId: repoDir, + cwd: created.worktreePath, + kind: "worktree", + displayName: "archive-marked-during-close", + createdAt: "2026-04-30T00:00:00.000Z", + updatedAt: "2026-04-30T00:00:00.000Z", + archivedAt: null, + }; + const emitted: SessionOutboundMessage[] = []; + const events: string[] = []; + const archivedWorkspaceIds = new Set(); + const archivingByWorkspaceId = new Map(); + const emitWorkspaceUpdatesForWorkspaceIds = vi.fn(async (workspaceIds: Iterable) => { + for (const workspaceId of workspaceIds) { + if (archivedWorkspaceIds.has(workspaceId)) { + emitted.push({ + type: "workspace_update", + payload: { + kind: "remove", + id: workspaceId, + }, + }); + continue; + } + emitted.push({ + type: "workspace_update", + payload: { + kind: "upsert", + workspace: { + ...createWorkspaceDescriptor({ workspace: workspaceRecord, repoDir }), + archivingAt: archivingByWorkspaceId.get(workspaceId) ?? null, + }, + }, + }); + } + events.push(`emit:${Array.from(workspaceIds).join(",")}`); + }); + const closeAgent = vi.fn(async () => { + events.push("close:start"); + await emitWorkspaceUpdatesForWorkspaceIds(affectedIds); + events.push("close:end"); + }); + + await handlePaseoWorktreeArchiveRequest( + { + paseoHome, + github: createGitHubServiceStub(), + workspaceGitService: { + getSnapshot: vi.fn(async () => null), + listWorktrees: vi.fn(async () => []), + }, + agentManager: { + listAgents: () => [liveAgent], + closeAgent, + }, + agentStorage: createAgentStorageStub(), + archiveWorkspaceRecord: vi.fn(async (workspaceId: string) => { + archivedWorkspaceIds.add(workspaceId); + }), + emit: (message) => emitted.push(message), + emitWorkspaceUpdatesForWorkspaceIds, + markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => { + events.push(`mark:${Array.from(workspaceIds).join(",")}`); + for (const workspaceId of workspaceIds) { + archivingByWorkspaceId.set(workspaceId, archivingAt); + } + }, + clearWorkspaceArchiving: (workspaceIds: Iterable) => { + events.push(`clear:${Array.from(workspaceIds).join(",")}`); + for (const workspaceId of workspaceIds) { + archivingByWorkspaceId.delete(workspaceId); + } + }, + isPathWithinRoot: createIsPathWithinRoot(), + killTerminalsUnderPath: vi.fn(async () => {}), + sessionLogger: createLogger(), + }, + { + type: "paseo_worktree_archive_request", + requestId: "req-archive-marked-during-close", + worktreePath: created.worktreePath, + repoRoot: repoDir, + }, + ); + + const workspaceUpdates = emitted.filter( + (message): message is Extract => + message.type === "workspace_update", + ); + expect(events.slice(0, 3)).toEqual([ + `mark:${created.worktreePath}`, + `emit:${created.worktreePath}`, + "close:start", + ]); + expect(workspaceUpdates[0]?.payload).toEqual({ + kind: "upsert", + workspace: expect.objectContaining({ + id: created.worktreePath, + archivingAt: expect.any(String), + }), + }); + const archivingAt = + workspaceUpdates[0]?.payload.kind === "upsert" + ? workspaceUpdates[0].payload.workspace.archivingAt + : null; + expect(workspaceUpdates[1]?.payload).toEqual({ + kind: "upsert", + workspace: expect.objectContaining({ + id: created.worktreePath, + archivingAt, + }), + }); + expect(workspaceUpdates.at(-1)?.payload).toEqual({ + kind: "remove", + id: created.worktreePath, + }); + expect(events.slice(-2)).toEqual([ + `clear:${created.worktreePath}`, + `emit:${created.worktreePath}`, + ]); + expect( + emitted.find((message) => message.type === "paseo_worktree_archive_response"), + ).toMatchObject({ + payload: { + success: true, + error: null, + }, + }); + }); + + test("clears archiving state and leaves workspace records active when worktree delete fails", async () => { + const { tempDir, repoDir } = createGitRepo(); + cleanupPaths.push(tempDir); + + const paseoHome = path.join(tempDir, ".paseo"); + const created = await createLegacyWorktreeForTest({ + branchName: "archive-delete-fails", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "archive-delete-fails", + runSetup: false, + paseoHome, + }); + const archivingByWorkspaceId = new Map(); + const emittedUpdates: Array<{ + kind: "upsert"; + workspaceId: string; + archivingAt: string | null; + }> = []; + const archiveWorkspaceRecord = vi.fn(async () => {}); + + await expect( + archivePaseoWorktree( + { + paseoHome, + github: createGitHubServiceStub(), + workspaceGitService: { getSnapshot: vi.fn(async () => null) }, + agentManager: { + listAgents: () => [], + closeAgent: vi.fn(async () => {}), + }, + agentStorage: createAgentStorageStub(), + archiveWorkspaceRecord, + emit: vi.fn(), + emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async (workspaceIds: Iterable) => { + for (const workspaceId of workspaceIds) { + emittedUpdates.push({ + kind: "upsert", + workspaceId, + archivingAt: archivingByWorkspaceId.get(workspaceId) ?? null, + }); + } + }), + markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => { + for (const workspaceId of workspaceIds) { + archivingByWorkspaceId.set(workspaceId, archivingAt); + } + }, + clearWorkspaceArchiving: (workspaceIds: Iterable) => { + for (const workspaceId of workspaceIds) { + archivingByWorkspaceId.delete(workspaceId); + } + }, + isPathWithinRoot: createIsPathWithinRoot(), + killTerminalsUnderPath: vi.fn(async () => {}), + sessionLogger: createLogger(), + }, + { + targetPath: created.worktreePath, + repoRoot: null, + requestId: "req-archive-delete-fails", + }, + ), + ).rejects.toThrow("cwd or worktreesRoot is required to delete a Paseo worktree"); + + expect(existsSync(created.worktreePath)).toBe(true); + expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); + expect(emittedUpdates[0]).toEqual({ + kind: "upsert", + workspaceId: created.worktreePath, + archivingAt: expect.any(String), + }); + expect(emittedUpdates.at(-1)).toEqual({ + kind: "upsert", + workspaceId: created.worktreePath, + archivingAt: null, + }); + }); + test("proceeds to FS delete even when terminal teardown rejects", async () => { const { tempDir, repoDir } = createGitRepo(); cleanupPaths.push(tempDir); @@ -1602,7 +1839,7 @@ describe("archivePaseoWorktree", () => { agentStorage: createAgentStorageStub(), archiveWorkspaceRecord: vi.fn(async () => {}), emit: vi.fn(), - emitWorkspaceUpdatesForCwds: vi.fn(async () => {}), + ...createWorkspaceArchivingDeps(), isPathWithinRoot: createIsPathWithinRoot(), killTerminalsUnderPath, sessionLogger: createLogger(), @@ -1647,7 +1884,7 @@ describe("archivePaseoWorktree", () => { agentStorage: createAgentStorageStub(), archiveWorkspaceRecord: vi.fn(async () => {}), emit: vi.fn(), - emitWorkspaceUpdatesForCwds: vi.fn(async () => {}), + ...createWorkspaceArchivingDeps(), isPathWithinRoot: createIsPathWithinRoot(), killTerminalsUnderPath: vi.fn(async () => {}), sessionLogger: createLogger(), @@ -1698,7 +1935,7 @@ describe("archivePaseoWorktree", () => { agentStorage: createAgentStorageStub(), archiveWorkspaceRecord: vi.fn(async () => {}), emit: (msg) => emitted.push(msg), - emitWorkspaceUpdatesForCwds: vi.fn(async () => {}), + ...createWorkspaceArchivingDeps(), isPathWithinRoot: createIsPathWithinRoot(), killTerminalsUnderPath: vi.fn(async () => {}), sessionLogger: createLogger(), diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 8de2472b9..d1578def5 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -364,11 +364,11 @@ export async function handlePaseoWorktreeListRequest( export async function handlePaseoWorktreeArchiveRequest( dependencies: Omit< ArchivePaseoWorktreeDependencies, - "emitWorkspaceUpdatesForCwds" | "workspaceGitService" + "emitWorkspaceUpdatesForWorkspaceIds" | "workspaceGitService" > & { emit: EmitSessionMessage; workspaceGitService: Pick; - emitWorkspaceUpdatesForCwds: (cwds: Iterable) => Promise; + emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable) => Promise; }, msg: Extract, ): Promise { diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 91a2f21d4..00e05b9bf 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -2175,6 +2175,7 @@ export const WorkspaceDescriptorPayloadSchema = z // COMPAT(workspaces): keep legacy directory workspace kind parseable. workspaceKind: z.enum(["directory", "local_checkout", "checkout", "worktree"]), name: z.string(), + archivingAt: z.string().nullable().optional().default(null), status: WorkspaceStateBucketSchema, activityAt: z.string().nullable(), diffStat: z diff --git a/packages/server/src/shared/messages.workspaces.test.ts b/packages/server/src/shared/messages.workspaces.test.ts index 08af11d48..c20791f86 100644 --- a/packages/server/src/shared/messages.workspaces.test.ts +++ b/packages/server/src/shared/messages.workspaces.test.ts @@ -1,6 +1,10 @@ import { z } from "zod"; import { describe, expect, test } from "vitest"; -import { SessionInboundMessageSchema, SessionOutboundMessageSchema } from "./messages.js"; +import { + SessionInboundMessageSchema, + SessionOutboundMessageSchema, + WorkspaceDescriptorPayloadSchema, +} from "./messages.js"; describe("workspace message schemas", () => { test("parses fetch_workspaces_request", () => { @@ -281,6 +285,31 @@ describe("workspace message schemas", () => { expect(parsed.payload.workspace.workspaceDirectory).toBe("/repo"); }); + test("defaults omitted workspace archiving state and preserves present timestamps", () => { + const baseWorkspace = { + id: "ws-archiving", + projectId: "proj-archiving", + projectDisplayName: "repo", + projectRootPath: "/repo", + workspaceDirectory: "/repo", + projectKind: "git", + workspaceKind: "worktree", + name: "feature", + status: "done", + activityAt: null, + scripts: [], + } as const; + const archivingAt = "2026-04-30T20:45:00.000Z"; + + expect(WorkspaceDescriptorPayloadSchema.parse(baseWorkspace).archivingAt).toBeNull(); + expect( + WorkspaceDescriptorPayloadSchema.parse({ + ...baseWorkspace, + archivingAt, + }).archivingAt, + ).toBe(archivingAt); + }); + test("parses legacy workspace descriptor enum values", () => { const parsed = SessionOutboundMessageSchema.parse({ type: "workspace_update",