fix: keep archived worktrees optimistically hidden (#640)
This commit is contained in:
@@ -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 ?? [],
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -31,6 +31,7 @@ function workspaceDescriptor(input: {
|
||||
workspaceKind: "worktree",
|
||||
name: input.name ?? input.id,
|
||||
status: "done",
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ function workspace(input: {
|
||||
workspaceKind: "checkout",
|
||||
name: "main",
|
||||
status: "running",
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: input.scripts ?? [],
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>): 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -87,6 +87,7 @@ function workspace(input: {
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: input.id,
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
|
||||
@@ -57,6 +57,7 @@ function workspaceDescriptor(id: string): WorkspaceDescriptor {
|
||||
workspaceKind: "worktree",
|
||||
name: id,
|
||||
status: "done",
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
};
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -26,6 +26,7 @@ function createWorkspaceDescriptor(input: Partial<WorkspaceDescriptor> = {}): Wo
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
...input,
|
||||
archivingAt: input.archivingAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<WorkspaceDescriptor> & Pick<WorkspaceDescripto
|
||||
workspaceKind: input.workspaceKind ?? "worktree",
|
||||
name: input.name ?? input.id,
|
||||
status: input.status ?? "done",
|
||||
archivingAt: input.archivingAt ?? null,
|
||||
diffStat: input.diffStat ?? null,
|
||||
scripts: input.scripts ?? [],
|
||||
} satisfies WorkspaceDescriptor;
|
||||
@@ -207,6 +209,7 @@ describe("checkout-git-actions-store", () => {
|
||||
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<Record<string, never>>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<CheckoutKey, StatusMap>;
|
||||
|
||||
@@ -436,7 +445,7 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
} catch (error) {
|
||||
restoreWorktreeArchiveState({ serverId, worktreePath, snapshot });
|
||||
restoreWorktreeArchiveState({ serverId, snapshot });
|
||||
throw error;
|
||||
}
|
||||
invalidateWorktreeList();
|
||||
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -43,6 +43,7 @@ function workspace(input: {
|
||||
workspaceKind: "local_checkout",
|
||||
name: input.id,
|
||||
status: "done",
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
gitRuntime: {
|
||||
|
||||
@@ -23,6 +23,7 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
|
||||
scripts: [],
|
||||
hasRunningScripts: false,
|
||||
...overrides,
|
||||
archivingAt: overrides.archivingAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ function workspace(input: {
|
||||
workspaceKind: "checkout",
|
||||
name: input.name,
|
||||
statusBucket: "done",
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
hasRunningScripts: false,
|
||||
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -79,7 +79,9 @@ export interface AgentMcpServerOptions {
|
||||
github?: GitHubService;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
|
||||
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(
|
||||
|
||||
@@ -519,11 +519,26 @@ export async function createPaseoDaemon(
|
||||
projectRegistry,
|
||||
});
|
||||
};
|
||||
const emitWorkspaceUpdatesForMcpArchive = async (cwds: Iterable<string>) => {
|
||||
const cwdList = Array.from(cwds);
|
||||
const markWorkspaceArchivingForMcpArchive = (
|
||||
workspaceIds: Iterable<string>,
|
||||
archivingAt: string,
|
||||
) => {
|
||||
const workspaceIdList = Array.from(workspaceIds);
|
||||
for (const session of wsServer?.listActiveSessions() ?? []) {
|
||||
session.markWorkspaceArchivingForExternalMutation(workspaceIdList, archivingAt);
|
||||
}
|
||||
};
|
||||
const clearWorkspaceArchivingForMcpArchive = (workspaceIds: Iterable<string>) => {
|
||||
const workspaceIdList = Array.from(workspaceIds);
|
||||
for (const session of wsServer?.listActiveSessions() ?? []) {
|
||||
session.clearWorkspaceArchivingForExternalMutation(workspaceIdList);
|
||||
}
|
||||
};
|
||||
const emitWorkspaceUpdatesForMcpArchive = async (workspaceIds: Iterable<string>) => {
|
||||
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);
|
||||
|
||||
@@ -19,7 +19,9 @@ export interface ArchivePaseoWorktreeDependencies {
|
||||
agentStorage: Pick<AgentStorage, "list" | "remove">;
|
||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||
emit: EmitSessionMessage;
|
||||
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
|
||||
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
|
||||
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
|
||||
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
||||
killTerminalsUnderPath: (rootPath: string) => Promise<void>;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -939,6 +939,7 @@ export class Session {
|
||||
private readonly workspaceSetupSnapshots: Map<string, WorkspaceSetupSnapshot>;
|
||||
private readonly workspaceGitFetchSubscriptions = new Map<string, () => void>();
|
||||
private readonly workspaceGitSubscriptions = new Map<string, () => void>();
|
||||
private readonly archivingByWorkspaceId = new Map<string, string>();
|
||||
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<string>): Promise<void> {
|
||||
await this.emitWorkspaceUpdatesForCwds(cwds);
|
||||
markWorkspaceArchivingForExternalMutation(
|
||||
workspaceIds: Iterable<string>,
|
||||
archivingAt: string,
|
||||
): void {
|
||||
this.markWorkspaceArchiving(workspaceIds, archivingAt);
|
||||
}
|
||||
|
||||
clearWorkspaceArchivingForExternalMutation(workspaceIds: Iterable<string>): void {
|
||||
this.clearWorkspaceArchiving(workspaceIds);
|
||||
}
|
||||
|
||||
async emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIds: Iterable<string>): Promise<void> {
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds);
|
||||
}
|
||||
|
||||
async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
|
||||
@@ -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<string>, archivingAt: string): void {
|
||||
for (const workspaceId of workspaceIds) {
|
||||
this.archivingByWorkspaceId.set(workspaceId, archivingAt);
|
||||
}
|
||||
}
|
||||
|
||||
clearWorkspaceArchiving(workspaceIds: Iterable<string>): void {
|
||||
for (const workspaceId of workspaceIds) {
|
||||
this.archivingByWorkspaceId.delete(workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
private async buildWorkspaceDescriptorMap(options: {
|
||||
includeGitData: boolean;
|
||||
workspaceIds?: Iterable<string>;
|
||||
@@ -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<string>): Promise<void> {
|
||||
const workspaces = await this.workspaceRegistry.list();
|
||||
const uniqueWorkspaceIds = new Set<string>();
|
||||
for (const cwd of cwds) {
|
||||
uniqueWorkspaceIds.add(this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces));
|
||||
}
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds(uniqueWorkspaceIds);
|
||||
}
|
||||
|
||||
private async handleFetchAgents(
|
||||
request: Extract<SessionInboundMessage, { type: "fetch_agents_request" }>,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -58,7 +58,10 @@ interface SessionTestAccess {
|
||||
buildWorkspaceDescriptorMap(...args: unknown[]): Promise<Map<string, unknown>>;
|
||||
describeWorkspaceRecord(...args: unknown[]): Promise<unknown>;
|
||||
describeWorkspaceRecordWithGitData(...args: unknown[]): Promise<unknown>;
|
||||
markWorkspaceArchiving(workspaceIds: Iterable<string>, archivingAt: string): void;
|
||||
clearWorkspaceArchiving(workspaceIds: Iterable<string>): void;
|
||||
emitWorkspaceUpdateForCwd(...args: unknown[]): Promise<unknown>;
|
||||
emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise<unknown>;
|
||||
emit(message: unknown): void;
|
||||
onMessage(message: unknown): void;
|
||||
getAvailableEditorTargets(...args: unknown[]): Promise<unknown>;
|
||||
@@ -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", {
|
||||
|
||||
@@ -354,6 +354,14 @@ function createAgentStorageStub(): Pick<AgentStorage, "list" | "remove"> {
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkspaceArchivingDeps() {
|
||||
return {
|
||||
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => {}),
|
||||
markWorkspaceArchiving: vi.fn(),
|
||||
clearWorkspaceArchiving: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createGitRepo(options?: { paseoConfig?: Record<string, unknown> }) {
|
||||
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<string>();
|
||||
const archivingByWorkspaceId = new Map<string, string>();
|
||||
const emitWorkspaceUpdatesForWorkspaceIds = vi.fn(async (workspaceIds: Iterable<string>) => {
|
||||
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<string>, archivingAt: string) => {
|
||||
events.push(`mark:${Array.from(workspaceIds).join(",")}`);
|
||||
for (const workspaceId of workspaceIds) {
|
||||
archivingByWorkspaceId.set(workspaceId, archivingAt);
|
||||
}
|
||||
},
|
||||
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => {
|
||||
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<SessionOutboundMessage, { type: "workspace_update" }> =>
|
||||
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<string, string>();
|
||||
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<string>) => {
|
||||
for (const workspaceId of workspaceIds) {
|
||||
emittedUpdates.push({
|
||||
kind: "upsert",
|
||||
workspaceId,
|
||||
archivingAt: archivingByWorkspaceId.get(workspaceId) ?? null,
|
||||
});
|
||||
}
|
||||
}),
|
||||
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => {
|
||||
for (const workspaceId of workspaceIds) {
|
||||
archivingByWorkspaceId.set(workspaceId, archivingAt);
|
||||
}
|
||||
},
|
||||
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => {
|
||||
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(),
|
||||
|
||||
@@ -364,11 +364,11 @@ export async function handlePaseoWorktreeListRequest(
|
||||
export async function handlePaseoWorktreeArchiveRequest(
|
||||
dependencies: Omit<
|
||||
ArchivePaseoWorktreeDependencies,
|
||||
"emitWorkspaceUpdatesForCwds" | "workspaceGitService"
|
||||
"emitWorkspaceUpdatesForWorkspaceIds" | "workspaceGitService"
|
||||
> & {
|
||||
emit: EmitSessionMessage;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
|
||||
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
|
||||
},
|
||||
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user