fix: keep archived worktrees optimistically hidden (#640)

This commit is contained in:
Mohamed Boudra
2026-04-30 21:36:41 +08:00
committed by GitHub
parent 92afb826d9
commit 93c6c3e5f8
32 changed files with 712 additions and 112 deletions
@@ -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 {
+13 -3
View File
@@ -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(
+21 -4
View File
@@ -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);
}
+39 -19
View File
@@ -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> {
+1
View File
@@ -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",