fix: keep archived worktrees optimistically hidden (#640)
This commit is contained in:
@@ -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