Remove a worktree when its last workspace is archived (#1562)

* Remove a worktree when its last workspace is archived

Archiving is now workspace-centric: the UI always archives a workspace,
and a worktree's directory is removed only when its last referencing
workspace is archived (derived from ownership + a single reference
count). The "remove the worktree from disk?" prompt is gone; archiving
warns only when the workspace is dirty or has unpushed commits.

MCP archive_worktree and the CLI keep the explicit lower-level behavior:
archive every workspace backing the directory, then remove it.
Auto-archive-after-merge and create-agent auto-archive respect the
reference count like any other workspace archival.

The policy now lives in two modules - a server archive service and a
client archive hook - with the reference-count check in exactly one
place. The deleteWorktreeFromDisk request field is retained but ignored
for wire back-compat.

* Tidy archive-policy internals and close test gaps

Follow-up polish on the archive-policy consolidation; no behavior change.

Structure: the server archive service is renamed to reflect that it now
owns all archive policy (the file and its types no longer say "worktree"
where they mean "archive"); the client archive input derives the
workspace kind from the canonical type instead of re-listing it; the
not-found target is typed string|null instead of an empty-string
sentinel; a redundant workspace-update emit and the ambiguous
worktrees-root naming are cleaned up. Wire, SDK, and keybinding names are
deliberately left unchanged for back-compat.

Tests: re-home the archiving-state lifecycle and per-workspace snapshot
assertions; pin worktree-kind acceptance through archive_workspace, the
ignored deleteWorktreeFromDisk field, and three-workspace archive-all;
add a Playwright spec for the dirty/unpushed worktree archive flow (the
warning gates it, confirming removes the directory) and the bulk
remove-project confirm branch.

* Fix the hanging worktree-archive risk-warning e2e spec

The spec added in the previous commit hung deterministically, locally and
in CI. Two test-only bugs, no product change.

The archive click opens a synchronous window.confirm(); the spec
registered a passive dialog waiter but only dismissed the dialog after
awaiting the click, so the click never resolved (Playwright won't settle
a click while a dialog is open) - a circular wait. Answer the dialog
inline via page.once("dialog", ...) before the click resolves, matching
the proven worktree-archive spec.

The seed also committed on a never-pushed branch, so aheadOfOrigin was
null and the "1 unpushed commit" warning never appeared. Push the branch
to set its upstream before the local commit, so it reports exactly one
unpushed commit.

Verified: the spec now passes in ~6s (was a 45s timeout); both the
dismiss-gates and accept-removes paths run.
This commit is contained in:
Mohamed Boudra
2026-06-17 00:38:56 +08:00
committed by GitHub
parent 0fef66283a
commit e202ca5036
45 changed files with 2627 additions and 1610 deletions
@@ -4,9 +4,10 @@ import type pino from "pino";
import type { GitHubService } from "../../services/github-service.js";
import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
import {
archiveByScope,
type ActiveWorkspaceRef,
archivePaseoWorktree,
} from "../paseo-worktree-archive-service.js";
resolveWorkspaceIdAtPath,
} from "../workspace-archive-service.js";
import type {
CreatePaseoWorktreeWorkflowFn,
CreatePaseoWorktreeWorkflowResult,
@@ -203,32 +204,47 @@ export class CreateAgentLifecycleDispatch {
throw new Error("Auto-created worktree is not a Paseo-owned worktree");
}
await archivePaseoWorktree(
const workspaceId = await resolveWorkspaceIdAtPath(
{
paseoHome: this.dependencies.paseoHome,
worktreesRoot: this.dependencies.worktreesRoot,
github: this.dependencies.github,
workspaceGitService: this.dependencies.workspaceGitService,
agentManager: this.dependencies.agentManager,
agentStorage: this.dependencies.agentStorage,
findWorkspaceIdForCwd: this.dependencies.findWorkspaceIdForCwd,
listActiveWorkspaces: this.dependencies.listActiveWorkspaces,
archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds: this.dependencies.emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving,
clearWorkspaceArchiving: this.dependencies.clearWorkspaceArchiving,
killTerminalsForWorkspace: this.dependencies.killTerminalsForWorkspace,
sessionLogger: this.dependencies.logger,
},
{
targetPath: options.worktreePath,
repoRoot: options.repoRoot ?? ownership.repoRoot ?? null,
worktreesRoot: ownership.worktreeRoot,
worktreesBaseRoot: this.dependencies.worktreesRoot,
requestId: randomUUID(),
},
options.worktreePath,
);
if (!workspaceId) {
this.dependencies.logger.warn(
{ worktreePath: options.worktreePath },
"Could not resolve workspace for auto-archive; skipping",
);
} else {
await archiveByScope(
{
paseoHome: this.dependencies.paseoHome,
paseoWorktreesBaseRoot: this.dependencies.worktreesRoot,
github: this.dependencies.github,
workspaceGitService: this.dependencies.workspaceGitService,
agentManager: this.dependencies.agentManager,
agentStorage: this.dependencies.agentStorage,
findWorkspaceIdForCwd: this.dependencies.findWorkspaceIdForCwd,
listActiveWorkspaces: this.dependencies.listActiveWorkspaces,
archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds:
this.dependencies.emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving,
clearWorkspaceArchiving: this.dependencies.clearWorkspaceArchiving,
killTerminalsForWorkspace: this.dependencies.killTerminalsForWorkspace,
sessionLogger: this.dependencies.logger,
},
{
scope: { kind: "workspace", workspaceId },
repoRoot: options.repoRoot ?? ownership.repoRoot ?? null,
paseoWorktreesBaseRoot: this.dependencies.worktreesRoot,
requestId: randomUUID(),
},
);
}
if (options.agentId) {
this.dependencies.emitAgentRemove(options.agentId);
}
@@ -503,6 +503,23 @@ function createStoredSchedule(input: CreateScheduleInput): StoredSchedule {
};
}
function createArchiveWorkspaceRecordMutator(
activeWorkspaces: Array<{
workspaceId: string;
cwd: string;
kind: "worktree" | "local_checkout" | "directory";
}>,
archivedWorkspaceIds: string[],
) {
return async (workspaceId: string) => {
archivedWorkspaceIds.push(workspaceId);
const index = activeWorkspaces.findIndex((workspace) => workspace.workspaceId === workspaceId);
if (index !== -1) {
activeWorkspaces.splice(index, 1);
}
};
}
function createPaseoWorktreeForMcpTest(options: {
paseoHome: string;
broadcasts: string[];
@@ -1439,6 +1456,7 @@ describe("create_agent MCP tool", () => {
const emitWorkspaceUpdatesForWorkspaceIds = vi.fn(async () => undefined);
const markWorkspaceArchiving = vi.fn();
const clearWorkspaceArchiving = vi.fn();
const listActiveWorkspaces = vi.fn(async () => []);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
@@ -1450,7 +1468,7 @@ describe("create_agent MCP tool", () => {
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
>,
findWorkspaceIdForCwd: vi.fn(async () => "ws-archive-tool-worktree"),
listActiveWorkspaces: vi.fn(async () => []),
listActiveWorkspaces,
archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving,
@@ -1464,6 +1482,13 @@ describe("create_agent MCP tool", () => {
cwd: repoDir,
target: { mode: "branch-off", newBranch: "archive-tool-worktree", base: "main" },
});
const createdWorktreePath = z.string().parse(created.structuredContent.worktreePath);
listActiveWorkspaces.mockImplementation(async () => [
{ workspaceId: "ws-archive-tool-worktree", cwd: createdWorktreePath, kind: "worktree" },
]);
archiveWorkspaceRecord.mockImplementation(async () => {
listActiveWorkspaces.mockResolvedValueOnce([]);
});
workspaceGitService.getSnapshot.mockClear();
await archiveTool.handler({
@@ -1494,6 +1519,93 @@ describe("create_agent MCP tool", () => {
}
});
it("archives every workspace on a directory and removes the directory", async () => {
const { agentManager, agentStorage } = createTestDeps();
const tempDir = realpathSync.native(
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-multi-")),
);
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
const workspaceGitService = {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => []),
resolveRepoRoot: vi.fn(async () => repoDir),
};
const archivedWorkspaceIds: string[] = [];
let activeWorkspaces: Array<{
workspaceId: string;
cwd: string;
kind: "worktree" | "local_checkout" | "directory";
}> = [];
const listActiveWorkspaces = vi.fn(async () => activeWorkspaces);
const archiveWorkspaceRecord = createArchiveWorkspaceRecordMutator(
activeWorkspaces,
archivedWorkspaceIds,
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts: [] }),
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
>,
findWorkspaceIdForCwd: vi.fn(async () => "ws-mcp-A"),
listActiveWorkspaces,
archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => undefined),
markWorkspaceArchiving: vi.fn(),
clearWorkspaceArchiving: vi.fn(),
github: createGitHubServiceStub(),
logger,
});
const createTool = registeredTool(server, "create_worktree");
const archiveTool = registeredTool(server, "archive_worktree");
const created = await createTool.handler({
cwd: repoDir,
target: { mode: "branch-off", newBranch: "archive-multi-worktree", base: "main" },
});
const worktreePath = z.string().parse(created.structuredContent.worktreePath);
// Populate the active workspaces with the real created path so archiveByScope
// matches it against the worktree directory.
activeWorkspaces = [
{ workspaceId: "ws-mcp-A", cwd: worktreePath, kind: "worktree" as const },
{ workspaceId: "ws-mcp-B", cwd: worktreePath, kind: "worktree" as const },
];
await archiveTool.handler({
cwd: repoDir,
worktreePath,
});
expect(archivedWorkspaceIds).toContain("ws-mcp-A");
expect(archivedWorkspaceIds).toContain("ws-mcp-B");
await expect(access(worktreePath)).rejects.toThrow();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("archives a worktree by slug", async () => {
const { agentManager, agentStorage } = createTestDeps();
const tempDir = realpathSync.native(
+16 -16
View File
@@ -31,8 +31,8 @@ import { ensureAgentLoaded } from "./agent-loading.js";
import { isStoredAgentProviderAvailable } from "../persistence-hooks.js";
import {
killTerminalsForWorkspace,
type ArchivePaseoWorktreeDependencies,
} from "../paseo-worktree-archive-service.js";
type ArchiveDependencies,
} from "../workspace-archive-service.js";
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
import { createAgentCommand } from "./create-agent/create.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
@@ -74,8 +74,8 @@ import type { GitHubService } from "../../services/github-service.js";
import type { WorkspaceGitService } from "../workspace-git-service.js";
import { WorktreeRequestError } from "../worktree-errors.js";
import {
archivePaseoWorktreeCommand,
type ArchivePaseoWorktreeCommandDependencies,
archiveCommand,
type ArchiveCommandDependencies,
createPaseoWorktreeCommand,
type CreatePaseoWorktreeCommandInput,
listPaseoWorktreesCommand,
@@ -93,12 +93,12 @@ export interface AgentMcpServerOptions {
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
>;
findWorkspaceIdForCwd?: ArchivePaseoWorktreeDependencies["findWorkspaceIdForCwd"];
listActiveWorkspaces?: ArchivePaseoWorktreeDependencies["listActiveWorkspaces"];
archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"];
emitWorkspaceUpdatesForWorkspaceIds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForWorkspaceIds"];
markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"];
clearWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["clearWorkspaceArchiving"];
findWorkspaceIdForCwd?: ArchiveDependencies["findWorkspaceIdForCwd"];
listActiveWorkspaces?: ArchiveDependencies["listActiveWorkspaces"];
archiveWorkspaceRecord?: ArchiveDependencies["archiveWorkspaceRecord"];
emitWorkspaceUpdatesForWorkspaceIds?: ArchiveDependencies["emitWorkspaceUpdatesForWorkspaceIds"];
markWorkspaceArchiving?: ArchiveDependencies["markWorkspaceArchiving"];
clearWorkspaceArchiving?: ArchiveDependencies["clearWorkspaceArchiving"];
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
// Mints a fresh workspace for a cwd and returns its id, used when an agent is
// created with no parent and no worktree.
@@ -2236,7 +2236,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
}
const repoRoot = await options.workspaceGitService.resolveRepoRoot(resolvedCwd);
const result = await archivePaseoWorktreeCommand(
const result = await archiveCommand(
archiveWorktreeDependencies(options, {
agentManager,
agentStorage,
@@ -2248,9 +2248,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
repoRoot,
worktreePath,
worktreeSlug,
// This tool's contract is to delete the worktree; on-disk removal still
// only happens when no sibling workspace references the directory.
deleteWorktreeFromDisk: true,
// This tool archives every workspace on the directory, then removes the
// directory. Disk removal is derived from scope + last-reference.
scope: "worktree",
},
);
if (!result.ok) {
@@ -2430,7 +2430,7 @@ interface ArchiveWorktreeCommandContext {
function archiveWorktreeDependencies(
options: AgentMcpServerOptions,
context: ArchiveWorktreeCommandContext,
): ArchivePaseoWorktreeCommandDependencies {
): ArchiveCommandDependencies {
if (!options.github) {
throw new Error("GitHub service is required to archive worktrees");
}
@@ -2457,7 +2457,7 @@ function archiveWorktreeDependencies(
}
return {
paseoHome: options.paseoHome,
worktreesRoot: options.worktreesRoot,
paseoWorktreesBaseRoot: options.worktreesRoot,
github: options.github,
workspaceGitService: options.workspaceGitService,
agentManager: context.agentManager,
@@ -1,4 +1,9 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Logger } from "pino";
import pino from "pino";
import { beforeEach, describe, expect, test, vi } from "vitest";
import {
@@ -6,7 +11,11 @@ import {
type ArchiveIfSafeDependencies,
type AutoArchiveArchiveOptions,
} from "./archive-if-safe.js";
import type { ArchiveResult, ActiveWorkspaceRef } from "../workspace-archive-service.js";
import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js";
import { createWorktree, type WorktreeConfig } from "../../utils/worktree.js";
import type { GitHubService } from "../../../services/github-service.js";
import type { StoredAgentRecord } from "../agent/agent-storage.js";
const CWD = "/tmp/paseo/worktrees/repo/branch";
const PASEO_HOME = "/tmp/paseo";
@@ -72,7 +81,8 @@ function createHarness(overrides?: {
autoArchiveAfterMerge?: boolean;
getSnapshot?: () => Promise<WorkspaceGitRuntimeSnapshot | null>;
isPaseoOwnedWorktreeCwd?: ArchiveIfSafeDependencies["isPaseoOwnedWorktreeCwd"];
archivePaseoWorktree?: ArchiveIfSafeDependencies["archivePaseoWorktree"];
archiveByScope?: ArchiveIfSafeDependencies["archiveByScope"];
resolveWorkspaceIdAtPath?: ArchiveIfSafeDependencies["resolveWorkspaceIdAtPath"];
}) {
const getConfig = vi.fn(() => ({
autoArchiveAfterMerge: overrides?.autoArchiveAfterMerge ?? true,
@@ -100,9 +110,18 @@ function createHarness(overrides?: {
clearWorkspaceArchiving: vi.fn(),
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(),
};
const archivePaseoWorktree = vi.fn(
overrides?.archivePaseoWorktree ?? (async () => undefined),
) as unknown as ArchiveIfSafeDependencies["archivePaseoWorktree"];
const archiveByScope = vi.fn(
overrides?.archiveByScope ??
(async () =>
({
archivedAgentIds: [],
archivedWorkspaceIds: [],
removedDirectory: false,
}) satisfies ArchiveResult),
) as unknown as ArchiveIfSafeDependencies["archiveByScope"];
const resolveWorkspaceIdAtPath = vi.fn(
overrides?.resolveWorkspaceIdAtPath ?? (async () => "ws-auto-archive"),
) as unknown as ArchiveIfSafeDependencies["resolveWorkspaceIdAtPath"];
const isPaseoOwnedWorktreeCwd = vi.fn(
overrides?.isPaseoOwnedWorktreeCwd ??
(async () => ({
@@ -113,7 +132,8 @@ function createHarness(overrides?: {
})),
) as unknown as ArchiveIfSafeDependencies["isPaseoOwnedWorktreeCwd"];
const deps: ArchiveIfSafeDependencies = {
archivePaseoWorktree,
archiveByScope,
resolveWorkspaceIdAtPath,
isPaseoOwnedWorktreeCwd,
killTerminalsForWorkspace: vi.fn(),
};
@@ -150,6 +170,164 @@ async function runArchiveIfSafe(
});
}
const cleanupPaths: string[] = [];
function createGitRepo(): { tempDir: string; repoDir: string } {
const tempDir = mkdtempSync(path.join(tmpdir(), "archive-if-safe-"));
cleanupPaths.push(tempDir);
const repoDir = path.join(tempDir, "repo");
mkdirSync(repoDir, { recursive: true });
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@getpaseo.local"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Paseo Test"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "initial"], {
cwd: repoDir,
stdio: "pipe",
});
return { tempDir, repoDir };
}
async function createPaseoOwnedWorktree(
repoDir: string,
paseoHome: string,
worktreeSlug: string,
): Promise<WorktreeConfig> {
return createWorktree({
cwd: repoDir,
worktreeSlug,
source: {
kind: "branch-off",
baseBranch: "main",
branchName: worktreeSlug,
},
runSetup: false,
paseoHome,
});
}
function createGitHubServiceStub(): GitHubService {
return {
listPullRequests: async () => [],
listIssues: async () => [],
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
getPullRequest: async ({ number }) => ({
number,
title: `PR ${number}`,
url: `https://github.com/acme/repo/pull/${number}`,
state: "OPEN",
body: null,
baseRefName: "main",
headRefName: `pr-${number}`,
labels: [],
}),
getPullRequestHeadRef: async ({ number }) => `pr-${number}`,
getCurrentPullRequestStatus: async () => null,
createPullRequest: async () => ({
number: 1,
url: "https://github.com/acme/repo/pull/1",
}),
mergePullRequest: async () => ({ success: true }),
isAuthenticated: async () => true,
invalidate: () => {},
};
}
function createRealOutcomeHarness(input: {
paseoHome: string;
repoDir: string;
worktreePath: string;
activeWorkspaces: ActiveWorkspaceRef[];
archivedWorkspaceIds: Set<string>;
}) {
const active = [...input.activeWorkspaces];
const logger = pino({ level: "silent" });
vi.spyOn(logger, "info").mockImplementation(() => undefined);
vi.spyOn(logger, "warn").mockImplementation(() => undefined);
vi.spyOn(logger, "error").mockImplementation(() => undefined);
const options: AutoArchiveArchiveOptions = {
paseoHome: input.paseoHome,
daemonConfigStore: {
get: () => ({ autoArchiveAfterMerge: true }),
} as unknown as AutoArchiveArchiveOptions["daemonConfigStore"],
workspaceGitService: {
getSnapshot: async () =>
({
cwd: input.worktreePath,
git: {
isGit: true,
repoRoot: input.repoDir,
mainRepoRoot: input.repoDir,
currentBranch: "feature",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: true,
isDirty: false,
baseRef: "main",
aheadBehind: { ahead: 0, behind: 0 },
aheadOfOrigin: 0,
behindOfOrigin: 0,
hasRemote: true,
diffStat: { additions: 0, deletions: 0 },
},
github: {
featuresEnabled: true,
pullRequest: createPullRequest({ isMerged: true }),
error: null,
},
}) satisfies WorkspaceGitRuntimeSnapshot,
} as unknown as AutoArchiveArchiveOptions["workspaceGitService"],
github: createGitHubServiceStub(),
agentManager: {
listAgents: () => [],
archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })),
archiveSnapshot: vi.fn(async () => {
throw new Error("not expected without stored agents");
}),
} as unknown as AutoArchiveArchiveOptions["agentManager"],
agentStorage: {
list: async (): Promise<StoredAgentRecord[]> => [],
} as unknown as AutoArchiveArchiveOptions["agentStorage"],
terminalManager: {
listDirectories: () => [],
getTerminals: vi.fn().mockResolvedValue([]),
} as unknown as AutoArchiveArchiveOptions["terminalManager"],
findWorkspaceIdForCwd: async (cwd: string) => {
const match = active.find((workspace) => workspace.cwd === cwd);
return match?.workspaceId ?? null;
},
listActiveWorkspaces: async () =>
active.filter((workspace) => !input.archivedWorkspaceIds.has(workspace.workspaceId)),
archiveWorkspaceRecord: async (workspaceId: string) => {
input.archivedWorkspaceIds.add(workspaceId);
const index = active.findIndex((workspace) => workspace.workspaceId === workspaceId);
if (index !== -1) {
active.splice(index, 1);
}
},
markWorkspaceArchiving: () => {},
clearWorkspaceArchiving: () => {},
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(),
};
return {
options,
log: logger,
inFlight: new Set<string>(),
};
}
afterEach(() => {
for (const target of cleanupPaths.splice(0)) {
rmSync(target, { recursive: true, force: true });
}
});
describe("archiveIfSafe", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -162,7 +340,7 @@ describe("archiveIfSafe", () => {
expect(harness.getConfig).not.toHaveBeenCalled();
expect(harness.getSnapshot).not.toHaveBeenCalled();
expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled();
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
});
test("does nothing when auto-archive-after-merge is disabled", async () => {
@@ -172,7 +350,7 @@ describe("archiveIfSafe", () => {
expect(harness.getConfig).toHaveBeenCalledTimes(1);
expect(harness.getSnapshot).not.toHaveBeenCalled();
expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled();
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
});
test("does nothing when the cwd already has an archive in flight", async () => {
@@ -182,7 +360,7 @@ describe("archiveIfSafe", () => {
await runArchiveIfSafe(harness);
expect(harness.getSnapshot).not.toHaveBeenCalled();
expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled();
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
expect(harness.inFlight.has(CWD)).toBe(true);
});
@@ -199,7 +377,7 @@ describe("archiveIfSafe", () => {
{ err: expect.any(Error), cwd: CWD },
"Failed to read snapshot for auto-archive; skipping",
);
expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled();
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
expect(harness.inFlight.has(CWD)).toBe(false);
});
@@ -209,7 +387,7 @@ describe("archiveIfSafe", () => {
await runArchiveIfSafe(harness);
expect(harness.deps.isPaseoOwnedWorktreeCwd).not.toHaveBeenCalled();
expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled();
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
});
test("does nothing when the worktree is dirty", async () => {
@@ -220,7 +398,7 @@ describe("archiveIfSafe", () => {
await runArchiveIfSafe(harness);
expect(harness.deps.isPaseoOwnedWorktreeCwd).not.toHaveBeenCalled();
expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled();
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
});
test("does nothing when the worktree is ahead of origin", async () => {
@@ -231,7 +409,7 @@ describe("archiveIfSafe", () => {
await runArchiveIfSafe(harness);
expect(harness.deps.isPaseoOwnedWorktreeCwd).not.toHaveBeenCalled();
expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled();
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
});
test("archives when the PR is merged and the upstream branch was deleted", async () => {
@@ -242,7 +420,7 @@ describe("archiveIfSafe", () => {
await runArchiveIfSafe(harness);
expect(harness.deps.archivePaseoWorktree).toHaveBeenCalledTimes(1);
expect(harness.deps.archiveByScope).toHaveBeenCalledTimes(1);
});
test("does nothing when the cwd is not a Paseo-owned worktree", async () => {
@@ -255,12 +433,12 @@ describe("archiveIfSafe", () => {
expect(harness.deps.isPaseoOwnedWorktreeCwd).toHaveBeenCalledWith(CWD, {
paseoHome: PASEO_HOME,
});
expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled();
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
});
test("logs and does not throw when archiving fails", async () => {
const harness = createHarness({
archivePaseoWorktree: async () => {
archiveByScope: async () => {
throw new Error("archive failed");
},
});
@@ -279,20 +457,24 @@ describe("archiveIfSafe", () => {
await runArchiveIfSafe(harness);
expect(harness.deps.archivePaseoWorktree).toHaveBeenCalledTimes(1);
expect(harness.deps.archivePaseoWorktree).toHaveBeenCalledWith(
expect(harness.deps.resolveWorkspaceIdAtPath).toHaveBeenCalledTimes(1);
expect(harness.deps.resolveWorkspaceIdAtPath).toHaveBeenCalledWith(
{
findWorkspaceIdForCwd: harness.options.findWorkspaceIdForCwd,
listActiveWorkspaces: harness.options.listActiveWorkspaces,
},
CWD,
);
expect(harness.deps.archiveByScope).toHaveBeenCalledTimes(1);
expect(harness.deps.archiveByScope).toHaveBeenCalledWith(
expect.objectContaining({
paseoHome: PASEO_HOME,
workspaceGitService: harness.options.workspaceGitService,
}),
{
targetPath: CWD,
scope: { kind: "workspace", workspaceId: "ws-auto-archive" },
repoRoot: "/tmp/repo",
worktreesRoot: WORKTREES_ROOT,
// A merged worktree is the last reference to its directory; remove it from
// disk so merged worktrees do not accumulate. Sibling protection still
// happens inside the service (last-reference + ownership gated).
deleteWorktreeFromDisk: true,
paseoWorktreesBaseRoot: undefined,
requestId: "auto-archive-on-merge",
},
);
@@ -302,4 +484,84 @@ describe("archiveIfSafe", () => {
);
expect(harness.inFlight.has(CWD)).toBe(false);
});
test("resolves the merged cwd to a single workspace and does not iterate siblings", async () => {
const harness = createHarness({
resolveWorkspaceIdAtPath: async () => "ws-merged-worktree",
});
harness.options.listActiveWorkspaces = vi.fn(async () => [
{ workspaceId: "ws-merged-worktree", cwd: CWD, kind: "worktree" as const },
{ workspaceId: "ws-sibling", cwd: CWD, kind: "local_checkout" as const },
]);
await runArchiveIfSafe(harness);
expect(harness.deps.resolveWorkspaceIdAtPath).toHaveBeenCalledTimes(1);
expect(harness.deps.archiveByScope).toHaveBeenCalledTimes(1);
expect(harness.deps.archiveByScope).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
scope: { kind: "workspace", workspaceId: "ws-merged-worktree" },
}),
);
});
test("real outcome: keeps sibling workspace and directory on last reference", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "merged-with-sibling");
const workspaceA = "ws-merged-with-sibling-a";
const workspaceB = "ws-merged-with-sibling-b";
const archivedWorkspaceIds = new Set<string>();
const harness = createRealOutcomeHarness({
paseoHome,
repoDir,
worktreePath: worktree.worktreePath,
activeWorkspaces: [
{ workspaceId: workspaceA, cwd: worktree.worktreePath, kind: "worktree" },
{ workspaceId: workspaceB, cwd: worktree.worktreePath, kind: "local_checkout" },
],
archivedWorkspaceIds,
});
await archiveIfSafe({
cwd: worktree.worktreePath,
pullRequest: createPullRequest({ isMerged: true }),
inFlight: harness.inFlight,
options: harness.options,
log: harness.log,
});
expect(archivedWorkspaceIds.has(workspaceA)).toBe(true);
expect(archivedWorkspaceIds.has(workspaceB)).toBe(false);
expect(existsSync(worktree.worktreePath)).toBe(true);
});
test("real outcome: removes directory when no sibling workspace remains", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "merged-last-ref");
const workspaceA = "ws-merged-last-ref";
const archivedWorkspaceIds = new Set<string>();
const harness = createRealOutcomeHarness({
paseoHome,
repoDir,
worktreePath: worktree.worktreePath,
activeWorkspaces: [{ workspaceId: workspaceA, cwd: worktree.worktreePath, kind: "worktree" }],
archivedWorkspaceIds,
});
await archiveIfSafe({
cwd: worktree.worktreePath,
pullRequest: createPullRequest({ isMerged: true }),
inFlight: harness.inFlight,
options: harness.options,
log: harness.log,
});
expect(archivedWorkspaceIds.has(workspaceA)).toBe(true);
expect(existsSync(worktree.worktreePath)).toBe(false);
});
});
@@ -4,10 +4,11 @@ import type { AgentManager } from "../agent/agent-manager.js";
import type { AgentStorage } from "../agent/agent-storage.js";
import type { DaemonConfigStore } from "../daemon-config-store.js";
import {
archiveByScope,
type ActiveWorkspaceRef,
archivePaseoWorktree,
killTerminalsForWorkspace,
} from "../paseo-worktree-archive-service.js";
resolveWorkspaceIdAtPath,
} from "../workspace-archive-service.js";
import type {
WorkspaceGitRuntimeSnapshot,
WorkspaceGitServiceImpl,
@@ -18,7 +19,7 @@ import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
export interface AutoArchiveArchiveOptions {
paseoHome: string;
worktreesRoot?: string;
paseoWorktreesBaseRoot?: string;
daemonConfigStore: DaemonConfigStore;
workspaceGitService: WorkspaceGitServiceImpl;
github: GitHubService;
@@ -34,13 +35,15 @@ export interface AutoArchiveArchiveOptions {
}
export interface ArchiveIfSafeDependencies {
archivePaseoWorktree: typeof archivePaseoWorktree;
archiveByScope: typeof archiveByScope;
resolveWorkspaceIdAtPath: typeof resolveWorkspaceIdAtPath;
isPaseoOwnedWorktreeCwd: typeof isPaseoOwnedWorktreeCwd;
killTerminalsForWorkspace: typeof killTerminalsForWorkspace;
}
const defaultDependencies: ArchiveIfSafeDependencies = {
archivePaseoWorktree,
archiveByScope,
resolveWorkspaceIdAtPath,
isPaseoOwnedWorktreeCwd,
killTerminalsForWorkspace,
};
@@ -90,17 +93,29 @@ export async function archiveIfSafe(input: {
const ownership = await deps.isPaseoOwnedWorktreeCwd(cwd, {
paseoHome: options.paseoHome,
worktreesRoot: options.worktreesRoot,
worktreesRoot: options.paseoWorktreesBaseRoot,
});
if (!ownership.allowed) {
return;
}
try {
await deps.archivePaseoWorktree(
const workspaceId = await deps.resolveWorkspaceIdAtPath(
{
findWorkspaceIdForCwd: options.findWorkspaceIdForCwd,
listActiveWorkspaces: options.listActiveWorkspaces,
},
cwd,
);
if (!workspaceId) {
log.warn({ cwd }, "Auto-archive could not resolve a workspace for cwd; skipping");
return;
}
await deps.archiveByScope(
{
paseoHome: options.paseoHome,
worktreesRoot: options.worktreesRoot,
paseoWorktreesBaseRoot: options.paseoWorktreesBaseRoot,
github: options.github,
workspaceGitService: options.workspaceGitService,
agentManager: options.agentManager,
@@ -111,25 +126,20 @@ export async function archiveIfSafe(input: {
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: options.markWorkspaceArchiving,
clearWorkspaceArchiving: options.clearWorkspaceArchiving,
killTerminalsForWorkspace: (workspaceId) =>
killTerminalsForWorkspace: (workspaceIdToKill) =>
deps.killTerminalsForWorkspace(
{
terminalManager: options.terminalManager,
sessionLogger: log,
},
workspaceId,
workspaceIdToKill,
),
sessionLogger: log,
},
{
targetPath: cwd,
scope: { kind: "workspace", workspaceId },
repoRoot: ownership.repoRoot ?? null,
worktreesRoot: ownership.worktreeRoot,
worktreesBaseRoot: options.worktreesRoot,
// Last-reference + Paseo-ownership gated inside the service, so sibling
// workspaces sharing the directory stay protected. Removing the merged
// worktree off disk prevents merged worktrees from accumulating.
deleteWorktreeFromDisk: true,
paseoWorktreesBaseRoot: options.paseoWorktreesBaseRoot,
requestId: "auto-archive-on-merge",
},
);
+5 -3
View File
@@ -114,9 +114,11 @@ import { ScheduleService } from "./schedule/service.js";
import { DaemonConfigStore } from "./daemon-config-store.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
import { resolveWorkspaceIdForPath } from "./resolve-workspace-id-for-path.js";
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
import {
archivePersistedWorkspaceRecord,
type ActiveWorkspaceRef,
} from "./workspace-archive-service.js";
import { setupAutoArchiveOnMerge } from "./auto-archive-on-merge/index.js";
import type { ActiveWorkspaceRef } from "./paseo-worktree-archive-service.js";
import { wrapSessionMessage, type SessionOutboundMessage } from "./messages.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import { createConfiguredTerminalManager } from "../terminal/terminal-manager-factory.js";
@@ -799,7 +801,7 @@ export async function createPaseoDaemon(
setupAutoArchiveOnMerge({
paseoHome: config.paseoHome,
worktreesRoot: config.worktreesRoot,
paseoWorktreesBaseRoot: config.worktreesRoot,
daemonConfigStore,
workspaceGitService,
github,
@@ -1,338 +0,0 @@
import { resolve } from "node:path";
import type { Logger } from "pino";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import type { GitHubService } from "../services/github-service.js";
import {
deletePaseoWorktree,
isPaseoOwnedWorktreeCwd,
resolvePaseoWorktreeRootForCwd,
WorktreeTeardownError,
} from "../utils/worktree.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
export interface ActiveWorkspaceRef {
workspaceId: string;
cwd: string;
kind?: "local_checkout" | "worktree" | "directory";
}
export interface ArchivePaseoWorktreeDependencies {
paseoHome?: string;
worktreesRoot?: string;
github: GitHubService;
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">;
agentManager: Pick<AgentManager, "listAgents" | "archiveAgent" | "archiveSnapshot">;
agentStorage: Pick<AgentStorage, "list">;
// Resolves the worktree at a path to its workspaceId for archive-by-path. The
// path uniquely identifies a worktree workspace; this is a directory lookup for
// the archive target, not status/ownership.
findWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
// Active (non-archived) workspaces, used to decide whether the workspace being
// archived is the last reference to its backing worktree directory, and to
// break a same-cwd tie in favor of the worktree-kind record when archiving by
// path (no explicit workspaceId).
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
killTerminalsForWorkspace: (workspaceId: string) => Promise<void>;
sessionLogger?: Logger;
}
export interface KillTerminalsForWorkspaceDependencies {
detachTerminalStream?: (terminalId: string, options: { emitExit: boolean }) => void;
sessionLogger: Logger;
terminalManager: TerminalManager | null;
}
// Archiving is scoped to a single workspace RECORD (by workspaceId), not to a
// directory. A directory can back multiple workspaces (Model B), so cwd-scoped
// teardown would destroy a sibling workspace's agents and terminals. We tear
// down only the agents and terminals owned by the target workspaceId.
//
// On-disk worktree removal is opt-in (deleteWorktreeFromDisk) and only happens
// when this workspace is the LAST active reference to a Paseo-owned worktree
// directory. If a sibling workspace still references the directory, it is kept.
// Local checkouts are never deleted.
export async function archivePaseoWorktree(
dependencies: ArchivePaseoWorktreeDependencies,
options: {
targetPath: string;
repoRoot: string | null;
worktreesRoot?: string;
worktreesBaseRoot?: string;
workspaceId?: string;
deleteWorktreeFromDisk?: boolean;
requestId: string;
},
): Promise<string[]> {
let targetPath = options.targetPath;
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, {
paseoHome: dependencies.paseoHome,
worktreesRoot: options.worktreesBaseRoot ?? dependencies.worktreesRoot,
});
if (resolvedWorktree) {
targetPath = resolvedWorktree.worktreePath;
}
// A directory can back multiple workspaces (Model B), so resolving the target
// by cwd alone picks an arbitrary record. Prefer the explicit workspaceId the
// caller supplied; otherwise resolve by path, breaking a same-cwd tie toward
// the worktree-kind record.
const targetWorkspaceId =
options.workspaceId ?? (await resolveTargetWorkspaceId(dependencies, targetPath));
if (!targetWorkspaceId) {
dependencies.sessionLogger?.warn(
{ targetPath },
"Skipping workspace archive for unregistered directory",
);
return [];
}
const affectedWorkspaceIdList = [targetWorkspaceId];
dependencies.markWorkspaceArchiving(affectedWorkspaceIdList, new Date().toISOString());
let archivedAgents = new Set<string>();
try {
await dependencies.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIdList);
archivedAgents = await archiveWorkspaceContents(dependencies, targetWorkspaceId);
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",
);
}
}
dependencies.github.invalidate({ cwd: targetPath });
try {
await dependencies.archiveWorkspaceRecord(targetWorkspaceId);
} catch (error) {
dependencies.sessionLogger?.warn(
{ err: error, workspaceId: targetWorkspaceId },
"Failed to archive workspace record",
);
}
if (options.deleteWorktreeFromDisk) {
await deleteWorktreeFromDiskIfLastReference(dependencies, {
targetPath,
targetWorkspaceId,
repoRoot: options.repoRoot,
worktreesRoot: options.worktreesRoot,
worktreesBaseRoot: options.worktreesBaseRoot,
});
}
} finally {
dependencies.clearWorkspaceArchiving(affectedWorkspaceIdList);
await dependencies.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIdList);
}
return Array.from(archivedAgents);
}
// Resolves the workspace record to archive when no explicit workspaceId was
// supplied. When several active workspaces share the exact target cwd, prefer
// the worktree-kind record so archiving-by-path tears down the worktree rather
// than an arbitrary sibling. Falls back to the path-based resolver otherwise.
async function resolveTargetWorkspaceId(
dependencies: Pick<
ArchivePaseoWorktreeDependencies,
"findWorkspaceIdForCwd" | "listActiveWorkspaces"
>,
targetPath: string,
): Promise<string | null> {
const targetDir = resolve(targetPath);
const exactMatches = (await dependencies.listActiveWorkspaces()).filter(
(workspace) => resolve(workspace.cwd) === targetDir,
);
const worktreeMatch = exactMatches.find((workspace) => workspace.kind === "worktree");
if (worktreeMatch) {
return worktreeMatch.workspaceId;
}
return dependencies.findWorkspaceIdForCwd(targetPath);
}
export type ArchiveWorkspaceContentsDependencies = Pick<
ArchivePaseoWorktreeDependencies,
"agentManager" | "agentStorage" | "killTerminalsForWorkspace" | "sessionLogger"
>;
// Tears down everything OWNED by a single workspace record: its live agents,
// its persisted-but-not-running agent snapshots, and its terminals. Scoped by
// workspaceId so a sibling workspace sharing the same directory is untouched.
// Returns the set of archived agent ids.
export async function archiveWorkspaceContents(
dependencies: ArchiveWorkspaceContentsDependencies,
workspaceId: string,
): Promise<Set<string>> {
const archivedAgents = new Set<string>();
const liveAgents = dependencies.agentManager
.listAgents()
.filter((agent) => agent.workspaceId === workspaceId);
for (const agent of liveAgents) {
archivedAgents.add(agent.id);
}
let storedRecords: StoredAgentRecord[] = [];
try {
storedRecords = await dependencies.agentStorage.list();
} catch (error) {
dependencies.sessionLogger?.warn(
{ err: error, workspaceId },
"Failed to list stored agents during workspace archive; continuing",
);
}
const liveAgentIds = new Set(liveAgents.map((agent) => agent.id));
const matchingStoredRecords = storedRecords.filter(
(record) => record.workspaceId === workspaceId,
);
for (const record of matchingStoredRecords) {
archivedAgents.add(record.id);
}
const archivedAt = new Date().toISOString();
const archiveResults = await Promise.allSettled([
...liveAgents.map((agent) => dependencies.agentManager.archiveAgent(agent.id)),
...matchingStoredRecords
.filter((record) => !liveAgentIds.has(record.id) && !record.archivedAt)
.map((record) => dependencies.agentManager.archiveSnapshot(record.id, archivedAt)),
dependencies.killTerminalsForWorkspace(workspaceId),
]);
for (const result of archiveResults) {
if (result.status === "rejected") {
dependencies.sessionLogger?.warn(
{ err: result.reason, workspaceId },
"Workspace archive teardown step failed; continuing",
);
}
}
return archivedAgents;
}
// Removes the worktree directory from disk, but only when the just-archived
// workspace was the last active reference to a Paseo-owned worktree. A directory
// can back multiple workspaces (Model B), so a sibling still referencing it must
// keep the directory. Local checkouts are never Paseo-owned and so never deleted.
async function deleteWorktreeFromDiskIfLastReference(
dependencies: Pick<
ArchivePaseoWorktreeDependencies,
"paseoHome" | "worktreesRoot" | "listActiveWorkspaces" | "github" | "sessionLogger"
>,
options: {
targetPath: string;
targetWorkspaceId: string;
repoRoot: string | null;
worktreesRoot?: string;
worktreesBaseRoot?: string;
},
): Promise<void> {
const ownership = await isPaseoOwnedWorktreeCwd(options.targetPath, {
paseoHome: dependencies.paseoHome,
worktreesRoot: options.worktreesBaseRoot ?? dependencies.worktreesRoot,
});
if (!ownership.allowed) {
return;
}
const targetDir = resolve(options.targetPath);
const activeWorkspaces = await dependencies.listActiveWorkspaces();
const siblingStillReferences = activeWorkspaces.some(
(workspace) =>
workspace.workspaceId !== options.targetWorkspaceId && resolve(workspace.cwd) === targetDir,
);
if (siblingStillReferences) {
return;
}
try {
await deletePaseoWorktree({
cwd: options.repoRoot,
worktreePath: options.targetPath,
worktreesRoot: options.worktreesRoot,
paseoHome: dependencies.paseoHome,
worktreesBaseRoot: options.worktreesBaseRoot ?? dependencies.worktreesRoot,
});
dependencies.github.invalidate({ cwd: options.targetPath });
} catch (error) {
if (error instanceof WorktreeTeardownError) {
dependencies.sessionLogger?.warn(
{ err: error, targetPath: options.targetPath },
"Worktree disk removal failed during archive; workspace already archived",
);
return;
}
throw error;
}
}
export async function killTerminalsForWorkspace(
dependencies: KillTerminalsForWorkspaceDependencies,
workspaceId: string,
): Promise<void> {
const terminalManager = dependencies.terminalManager;
if (!terminalManager) {
return;
}
const terminalIds: string[] = [];
const terminalLists = await Promise.all(
terminalManager.listDirectories().map(async (terminalCwd) => {
try {
return await terminalManager.getTerminals(terminalCwd, { workspaceId });
} catch (error) {
dependencies.sessionLogger.warn(
{ err: error, cwd: terminalCwd },
"Failed to enumerate workspace terminals during archive",
);
return [];
}
}),
);
for (const terminals of terminalLists) {
for (const terminal of terminals) {
if (terminal.workspaceId === workspaceId) {
terminalIds.push(terminal.id);
}
}
}
if (terminalIds.length === 0) {
return;
}
await Promise.allSettled(
terminalIds.map(async (terminalId) => {
try {
dependencies.detachTerminalStream?.(terminalId, { emitExit: true });
await terminalManager.killTerminalAndWait(terminalId, {
gracefulTimeoutMs: 2000,
forceTimeoutMs: 1500,
});
} catch (error) {
dependencies.sessionLogger.warn(
{ err: error, terminalId },
"Terminal kill escalation failed during archive; proceeding anyway",
);
}
}),
);
}
@@ -1,5 +1,5 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, test } from "vitest";
@@ -136,10 +136,10 @@ test("create_agent_request creates a worktree and auto-archives both after the f
await ctx.client.waitForFinish(created.id, 10000);
// Archiving removes the task and its agents, but never deletes the worktree
// from disk — a directory can back multiple workspaces.
// Auto-archive is asynchronous after the agent turns complete; poll until the
// last-reference worktree directory is gone.
await expectAgentAbsentFromActiveList(created.id);
await expectWorktreePresentInList(repoDir, created.cwd);
await expect.poll(() => existsSync(created.cwd), { timeout: 10000, interval: 100 }).toBe(false);
}, 30000);
test("create_agent_request with autoArchive archives only the agent when no worktree was created", async () => {
@@ -206,14 +206,36 @@ test("create_agent_request with worktree but no autoArchive leaves agent and wor
await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath });
});
test("archiving a created worktree archives its agents but leaves the worktree on disk", async () => {
test("archiving a created worktree removes the directory on last reference", async () => {
const created = await createAgentInBranchOffWorktree();
await ctx.client.waitForFinish(created.agentId, 10000);
await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath });
await expectAgentAbsentFromActiveList(created.agentId);
await expectWorktreeListEmpty(created.repoDir);
expect(existsSync(created.worktreePath)).toBe(false);
});
test("auto-archiving a created worktree keeps the directory when a sibling workspace references it", async () => {
const created = await createAgentInBranchOffWorktree({ autoArchive: true });
// Create a sibling workspace that shares the same backing directory.
const sibling = await ctx.client.createWorkspace({
source: { kind: "directory", path: created.worktreePath },
title: "sibling",
});
if (!sibling.workspace) {
throw new Error(sibling.error ?? "Failed to create sibling workspace");
}
await ctx.client.waitForFinish(created.agentId, 10000);
await expectAgentAbsentFromActiveList(created.agentId);
await expectWorktreePresentInList(created.repoDir, created.worktreePath);
expect(existsSync(created.worktreePath)).toBe(true);
await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath });
});
test("create_agent_request rejects legacy git options before creating a worktree", async () => {
+38 -30
View File
@@ -267,10 +267,7 @@ import {
handlePaseoWorktreeListRequest as handleWorktreeListRequest,
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
} from "./worktree-session.js";
import {
type ActiveWorkspaceRef,
archiveWorkspaceContents,
} from "./paseo-worktree-archive-service.js";
import { archiveByScope, type ActiveWorkspaceRef } from "./workspace-archive-service.js";
import { toWorktreeWireError } from "./worktree-errors.js";
import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js";
@@ -6018,7 +6015,7 @@ export class Session {
return handleWorktreeArchiveRequest(
{
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
paseoWorktreesBaseRoot: this.worktreesRoot,
github: this.github,
workspaceGitService: this.workspaceGitService,
agentManager: this.agentManager,
@@ -8037,31 +8034,42 @@ export class Session {
if (!existing) {
throw new Error(`Workspace not found: ${request.workspaceId}`);
}
if (existing.kind === "worktree") {
throw new Error("Use worktree archive for Paseo worktrees");
}
const archivedAt = new Date().toISOString();
// Archive removes the task (the workspace record) and everything the
// workspace owns — its agents and terminals — scoped by workspaceId so a
// sibling workspace sharing the same directory is untouched. The directory
// itself is never deleted here; local checkouts are not Paseo-owned.
this.markWorkspaceArchiving([existing.workspaceId], archivedAt);
try {
await archiveWorkspaceContents(
{
agentManager: this.agentManager,
agentStorage: this.agentStorage,
killTerminalsForWorkspace: (workspaceId) =>
this.terminalController.killTerminalsForWorkspace(workspaceId),
sessionLogger: this.sessionLogger,
},
existing.workspaceId,
);
await this.archiveWorkspaceRecord(existing.workspaceId, archivedAt);
} finally {
this.clearWorkspaceArchiving([existing.workspaceId]);
}
await this.emitWorkspaceUpdateForWorkspaceId(existing.workspaceId);
const gitSnapshot = await this.workspaceGitService
.getSnapshot(existing.cwd)
.catch(() => null);
const repoRoot = gitSnapshot?.git?.repoRoot ?? null;
await archiveByScope(
{
paseoHome: this.paseoHome,
paseoWorktreesBaseRoot: this.worktreesRoot,
github: this.github,
workspaceGitService: this.workspaceGitService,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds),
markWorkspaceArchiving: (workspaceIds, archivingAt) =>
this.markWorkspaceArchiving(workspaceIds, archivingAt),
clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds),
killTerminalsForWorkspace: (workspaceId) =>
this.terminalController.killTerminalsForWorkspace(workspaceId),
sessionLogger: this.sessionLogger,
},
{
scope: { kind: "workspace", workspaceId: existing.workspaceId },
repoRoot,
paseoWorktreesBaseRoot: this.worktreesRoot,
requestId: request.requestId,
},
);
const archivedWorkspace = await this.workspaceRegistry.get(request.workspaceId);
const archivedAt = archivedWorkspace?.archivedAt ?? new Date().toISOString();
this.emit({
type: "archive_workspace_response",
payload: {
@@ -1,5 +1,5 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, expect, test, vi } from "vitest";
@@ -22,6 +22,7 @@ import type {
AgentSessionConfig,
AgentStreamEvent,
} from "./agent/agent-sdk-types.js";
import { createWorktree } from "../utils/worktree.js";
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js";
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
@@ -4032,6 +4033,111 @@ test("archive_workspace_request hides non-destructive workspace records", async
expect(response?.payload.error).toBeNull();
});
test("archive_workspace_request archives a worktree-kind workspace and removes the directory on last reference", async () => {
const tempDir = mkdtempSync(path.join(tmpdir(), "session-worktree-kind-archive-"));
const repoDir = path.join(tempDir, "repo");
mkdirSync(repoDir, { recursive: true });
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@getpaseo.local"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "initial"], {
cwd: repoDir,
stdio: "pipe",
});
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createWorktree({
cwd: repoDir,
worktreeSlug: "worktree-kind-archive",
source: {
kind: "branch-off",
baseBranch: "main",
branchName: "worktree-kind-archive",
},
runSetup: false,
paseoHome,
});
const workspaceId = "ws-worktree-kind-archive";
const projectId = "proj-worktree-kind-archive";
const workspace = createPersistedWorkspaceRecord({
workspaceId,
projectId,
cwd: worktree.worktreePath,
kind: "worktree",
displayName: "worktree-kind-archive",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const project = createPersistedProjectRecord({
projectId,
rootPath: repoDir,
kind: "git",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({
workspaceGitService: createNoopWorkspaceGitService({
getSnapshot: async (): Promise<WorkspaceGitRuntimeSnapshot> => ({
cwd: worktree.worktreePath,
git: {
isGit: true,
repoRoot: repoDir,
mainRepoRoot: repoDir,
currentBranch: "worktree-kind-archive",
remoteUrl: null,
isPaseoOwnedWorktree: true,
isDirty: false,
baseRef: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
hasRemote: false,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
},
}),
}),
});
session.paseoHome = paseoHome;
session.emit = (message) => {
if (isSessionOutboundMessage(message)) emitted.push(message);
};
session.workspaceRegistry.get = async () => workspace;
session.workspaceRegistry.list = async () => [workspace];
session.workspaceRegistry.archive = async (_id: string, archivedAt: string) => {
workspace.archivedAt = archivedAt;
};
session.projectRegistry.list = async () => [project];
try {
await session.handleMessage({
type: "archive_workspace_request",
workspaceId,
requestId: "req-worktree-kind-archive",
});
expect(workspace.archivedAt).toBeTruthy();
expect(existsSync(worktree.worktreePath)).toBe(false);
const response = emitted.find((message) => message.type === "archive_workspace_response") as
| { payload: Record<string, unknown> }
| undefined;
expect(response?.payload.error).toBeNull();
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test.skip("opening a new worktree reconciles older local workspaces into the remote project", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests();
@@ -10,8 +10,9 @@ import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/in
// Model B archive is scoped to a single workspace RECORD (by workspaceId), not
// to a directory on disk. A directory can back multiple workspaces, so archiving
// one must never tear down a sibling's agents/terminals, and must never delete a
// directory another workspace still references. On-disk worktree removal is an
// explicit, last-reference-only opt-in (deleteWorktreeFromDisk).
// directory another workspace still references. On-disk worktree removal is
// derived from scope + last-reference + Paseo ownership; there is no caller-
// supplied disk flag.
let ctx: DaemonTestContext;
const tempRoots: string[] = [];
@@ -160,7 +161,7 @@ test("archiving one of two workspaces sharing a cwd spares the sibling and the d
await ctx.client.killTerminal(terminalBId);
}, 60000);
test("archiving the last reference to a worktree honors deleteWorktreeFromDisk", async () => {
test("archiving the last reference to a worktree removes it from disk regardless of the disk flag", async () => {
const repoDir = createGitRepo();
const keepResult = await ctx.client.createWorkspace({
@@ -173,7 +174,7 @@ test("archiving the last reference to a worktree honors deleteWorktreeFromDisk",
const keepDir = keepWorkspace.workspaceDirectory;
expect(existsSync(keepDir)).toBe(true);
// Last reference, deleteWorktreeFromDisk omitted (defaults false) → dir stays.
// Last reference, deleteWorktreeFromDisk omitted (defaults ignored) → dir removed.
const keepArchive = await ctx.client.archivePaseoWorktree({ worktreePath: keepDir });
expect(keepArchive.success).toBe(true);
await expect
@@ -182,7 +183,7 @@ test("archiving the last reference to a worktree honors deleteWorktreeFromDisk",
interval: 100,
})
.toBe(false);
expect(existsSync(keepDir)).toBe(true);
await expect.poll(() => existsSync(keepDir), { timeout: 10000, interval: 100 }).toBe(false);
const deleteResult = await ctx.client.createWorkspace({
source: {
@@ -199,11 +200,9 @@ test("archiving the last reference to a worktree honors deleteWorktreeFromDisk",
const deleteDir = deleteWorkspace.workspaceDirectory;
expect(existsSync(deleteDir)).toBe(true);
// Last reference, deleteWorktreeFromDisk true → dir is removed from disk.
const deleteArchive = await ctx.client.archivePaseoWorktree({
worktreePath: deleteDir,
deleteWorktreeFromDisk: true,
});
// Last reference on a fresh worktree still removes the directory without any
// caller-supplied disk-deletion flag.
const deleteArchive = await ctx.client.archivePaseoWorktree({ worktreePath: deleteDir });
expect(deleteArchive.success).toBe(true);
await expect
.poll(async () => (await activeWorkspaceIds()).has(deleteWorkspace.id), {
@@ -260,7 +259,7 @@ test("worktree archive targets the explicit workspaceId when a directory backs m
});
}, 60000);
test("deleteWorktreeFromDisk keeps the worktree when a sibling workspace still references it", async () => {
test("keeps the worktree on disk when a sibling workspace still references it", async () => {
const repoDir = createGitRepo();
const worktreeResult = await ctx.client.createWorkspace({
@@ -282,11 +281,10 @@ test("deleteWorktreeFromDisk keeps the worktree when a sibling workspace still r
const siblingWorkspaceId = await createLocalWorkspace(worktreeDir, "sibling");
expect(siblingWorkspaceId).not.toBe(worktreeWorkspace.id);
// Archive the worktree-backed workspace with deleteWorktreeFromDisk true.
// It is NOT the last reference, so the directory must survive.
// Archive the worktree-backed workspace. It is NOT the last reference, so the
// directory must survive regardless of the legacy disk flag.
const archive = await ctx.client.archivePaseoWorktree({
worktreePath: worktreeDir,
deleteWorktreeFromDisk: true,
});
expect(archive.success).toBe(true);
@@ -0,0 +1,577 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino, { type Logger } from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { GitHubService } from "../services/github-service.js";
import { createWorktree, type WorktreeConfig } from "../utils/worktree.js";
import type { ManagedAgent } from "./agent/agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import {
archiveByScope,
type ActiveWorkspaceRef,
type ArchiveDependencies,
type ArchiveResult,
resolveWorkspaceIdAtPath,
} from "./workspace-archive-service.js";
const cleanupPaths: string[] = [];
afterEach(() => {
for (const target of cleanupPaths.splice(0)) {
rmSync(target, { recursive: true, force: true });
}
});
function createLogger(): Logger {
const logger = pino({ level: "silent" });
vi.spyOn(logger, "info").mockImplementation(() => undefined);
vi.spyOn(logger, "warn").mockImplementation(() => undefined);
vi.spyOn(logger, "error").mockImplementation(() => undefined);
return logger;
}
function createGitHubServiceStub(): GitHubService {
return {
listPullRequests: async () => [],
listIssues: async () => [],
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
getPullRequest: async ({ number }) => ({
number,
title: `PR ${number}`,
url: `https://github.com/acme/repo/pull/${number}`,
state: "OPEN",
body: null,
baseRefName: "main",
headRefName: `pr-${number}`,
labels: [],
}),
getPullRequestHeadRef: async ({ number }) => `pr-${number}`,
getCurrentPullRequestStatus: async () => null,
createPullRequest: async () => ({
number: 1,
url: "https://github.com/acme/repo/pull/1",
}),
mergePullRequest: async () => ({ success: true }),
isAuthenticated: async () => true,
invalidate: () => {},
};
}
function createGitRepo(): { tempDir: string; repoDir: string } {
const tempDir = mkdtempSync(path.join(tmpdir(), "workspace-archive-service-"));
cleanupPaths.push(tempDir);
const repoDir = path.join(tempDir, "repo");
mkdirSync(repoDir, { recursive: true });
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@getpaseo.local"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Paseo Test"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "initial"], {
cwd: repoDir,
stdio: "pipe",
});
return { tempDir, repoDir };
}
async function createPaseoOwnedWorktree(
repoDir: string,
paseoHome: string,
worktreeSlug: string,
): Promise<WorktreeConfig> {
return createWorktree({
cwd: repoDir,
worktreeSlug,
source: {
kind: "branch-off",
baseBranch: "main",
branchName: worktreeSlug,
},
runSetup: false,
paseoHome,
});
}
interface ArchiveDepsInput {
paseoHome: string;
activeWorkspaces: ActiveWorkspaceRef[];
paseoWorktreesBaseRoot?: string;
findWorkspaceIdForCwd?: (cwd: string) => Promise<string | null>;
}
interface ArchiveTestDependencies extends ArchiveDependencies {
activeWorkspaces: ActiveWorkspaceRef[];
archivedAgentIds: string[];
archivedSnapshotIds: string[];
}
function createArchiveDeps(input: ArchiveDepsInput): ArchiveTestDependencies {
const archivedWorkspaceIds = new Set<string>();
const active = [...input.activeWorkspaces];
const archivedAgentIds: string[] = [];
const archivedSnapshotIds: string[] = [];
return {
paseoHome: input.paseoHome,
paseoWorktreesBaseRoot: input.paseoWorktreesBaseRoot,
github: createGitHubServiceStub(),
workspaceGitService: {
getSnapshot: vi.fn(async () => null),
} as unknown as Pick<WorkspaceGitService, "getSnapshot">,
agentManager: {
listAgents: () => [],
archiveAgent: vi.fn(async (agentId: string) => {
archivedAgentIds.push(agentId);
return { archivedAt: new Date().toISOString() };
}),
archiveSnapshot: vi.fn(async (agentId: string, _archivedAt: string) => {
archivedSnapshotIds.push(agentId);
return {};
}),
},
agentStorage: {
list: async (): Promise<StoredAgentRecord[]> => [],
} as Pick<AgentStorage, "list">,
findWorkspaceIdForCwd: input.findWorkspaceIdForCwd ?? vi.fn(async () => null),
listActiveWorkspaces: async () =>
active.filter((workspace) => !archivedWorkspaceIds.has(workspace.workspaceId)),
archiveWorkspaceRecord: async (workspaceId: string) => {
archivedWorkspaceIds.add(workspaceId);
const index = active.findIndex((workspace) => workspace.workspaceId === workspaceId);
if (index !== -1) {
active.splice(index, 1);
}
},
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => {}),
markWorkspaceArchiving: vi.fn(),
clearWorkspaceArchiving: vi.fn(),
killTerminalsForWorkspace: vi.fn(async () => {}),
sessionLogger: createLogger(),
activeWorkspaces: active,
archivedAgentIds,
archivedSnapshotIds,
};
}
function assertArchiveResult(
result: ArchiveResult,
expected: {
archivedWorkspaceIds: string[];
removedDirectory: boolean;
},
): void {
expect(result.archivedWorkspaceIds).toEqual(expected.archivedWorkspaceIds);
expect(result.removedDirectory).toBe(expected.removedDirectory);
}
describe("archiveByScope", () => {
test("workspace scope archives the record and removes the directory on last reference", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "last-ref-workspace");
const workspaceId = "ws-last-ref";
const result = await archiveByScope(
createArchiveDeps({
paseoHome,
activeWorkspaces: [
{
workspaceId,
cwd: worktree.worktreePath,
kind: "worktree",
},
],
}),
{
scope: { kind: "workspace", workspaceId },
repoRoot: repoDir,
requestId: "req-last-ref-workspace",
},
);
assertArchiveResult(result, {
archivedWorkspaceIds: [workspaceId],
removedDirectory: true,
});
expect(existsSync(worktree.worktreePath)).toBe(false);
});
test("workspace scope keeps the directory when a sibling workspace still references it", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "sibling-workspace");
const workspaceA = "ws-sibling-a";
const workspaceB = "ws-sibling-b";
const result = await archiveByScope(
createArchiveDeps({
paseoHome,
activeWorkspaces: [
{ workspaceId: workspaceA, cwd: worktree.worktreePath, kind: "worktree" },
{ workspaceId: workspaceB, cwd: worktree.worktreePath, kind: "local_checkout" },
],
}),
{
scope: { kind: "workspace", workspaceId: workspaceA },
repoRoot: repoDir,
requestId: "req-sibling-workspace",
},
);
assertArchiveResult(result, {
archivedWorkspaceIds: [workspaceA],
removedDirectory: false,
});
expect(existsSync(worktree.worktreePath)).toBe(true);
});
test("worktree scope archives every workspace on the directory and removes it", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "worktree-scope");
const workspaceA = "ws-worktree-a";
const workspaceB = "ws-worktree-b";
const result = await archiveByScope(
createArchiveDeps({
paseoHome,
activeWorkspaces: [
{ workspaceId: workspaceA, cwd: worktree.worktreePath, kind: "worktree" },
{ workspaceId: workspaceB, cwd: worktree.worktreePath, kind: "local_checkout" },
],
}),
{
scope: { kind: "worktree", targetPath: worktree.worktreePath },
repoRoot: repoDir,
requestId: "req-worktree-scope",
},
);
expect(result.archivedWorkspaceIds).toEqual(expect.arrayContaining([workspaceA, workspaceB]));
expect(result.archivedWorkspaceIds).toHaveLength(2);
expect(result.removedDirectory).toBe(true);
expect(existsSync(worktree.worktreePath)).toBe(false);
});
test("workspace scope never removes a non-Paseo-owned directory", async () => {
const { tempDir } = createGitRepo();
const localCheckoutDir = mkdtempSync(path.join(tempDir, "local-checkout-"));
const workspaceId = "ws-local-checkout";
const result = await archiveByScope(
createArchiveDeps({
paseoHome: path.join(tempDir, ".paseo"),
activeWorkspaces: [{ workspaceId, cwd: localCheckoutDir, kind: "local_checkout" }],
}),
{
scope: { kind: "workspace", workspaceId },
repoRoot: null,
requestId: "req-local-checkout",
},
);
assertArchiveResult(result, {
archivedWorkspaceIds: [workspaceId],
removedDirectory: false,
});
expect(existsSync(localCheckoutDir)).toBe(true);
});
test("worktree scope keeps the directory when one record teardown fails", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "partial-failure");
const workspaceA = "ws-partial-a";
const workspaceB = "ws-partial-b";
const deps = createArchiveDeps({
paseoHome,
activeWorkspaces: [
{ workspaceId: workspaceA, cwd: worktree.worktreePath, kind: "worktree" },
{ workspaceId: workspaceB, cwd: worktree.worktreePath, kind: "worktree" },
],
});
const originalArchiveWorkspaceRecord = deps.archiveWorkspaceRecord;
deps.archiveWorkspaceRecord = async (workspaceId: string) => {
if (workspaceId === workspaceA) {
throw new Error("intentional teardown failure");
}
return originalArchiveWorkspaceRecord(workspaceId);
};
const result = await archiveByScope(deps, {
scope: { kind: "worktree", targetPath: worktree.worktreePath },
repoRoot: repoDir,
requestId: "req-partial-failure",
});
expect(result.archivedWorkspaceIds).toEqual([workspaceB]);
expect(result.archivedWorkspaceIds).not.toContain(workspaceA);
expect(result.removedDirectory).toBe(false);
expect(existsSync(worktree.worktreePath)).toBe(true);
});
test("workspace scope with unknown workspace id is a clean no-op", async () => {
const { tempDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const deps = createArchiveDeps({
paseoHome,
activeWorkspaces: [],
});
const originalArchiveWorkspaceRecord = deps.archiveWorkspaceRecord;
deps.archiveWorkspaceRecord = vi.fn(async (workspaceId: string) => {
return originalArchiveWorkspaceRecord(workspaceId);
});
const result = await archiveByScope(deps, {
scope: { kind: "workspace", workspaceId: "ws-does-not-exist" },
repoRoot: null,
requestId: "req-unknown-workspace",
});
assertArchiveResult(result, {
archivedWorkspaceIds: [],
removedDirectory: false,
});
expect(deps.markWorkspaceArchiving).not.toHaveBeenCalled();
expect(deps.archiveWorkspaceRecord).not.toHaveBeenCalled();
expect(deps.emitWorkspaceUpdatesForWorkspaceIds).not.toHaveBeenCalled();
});
test("worktree scope removes an owned directory with zero matching records", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "zero-records");
const result = await archiveByScope(
createArchiveDeps({
paseoHome,
activeWorkspaces: [],
}),
{
scope: { kind: "worktree", targetPath: worktree.worktreePath },
repoRoot: repoDir,
requestId: "req-zero-records",
},
);
assertArchiveResult(result, {
archivedWorkspaceIds: [],
removedDirectory: true,
});
expect(existsSync(worktree.worktreePath)).toBe(false);
});
test("marks archiving, emits an upsert carrying the archiving state, then clears it and emits a remove", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "lifecycle");
const workspaceId = "ws-lifecycle";
const deps = createArchiveDeps({
paseoHome,
activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }],
});
const archivingByWorkspaceId = new Map<string, string>();
type LifecycleEvent =
| { type: "mark"; workspaceIds: string[]; archivingAt: string }
| {
type: "emit";
workspaceIds: string[];
updates: Array<{
kind: "upsert" | "remove";
workspaceId: string;
archivingAt: string | null;
}>;
}
| { type: "archive"; workspaceId: string }
| { type: "clear"; workspaceIds: string[] };
const events: LifecycleEvent[] = [];
const originalArchiveWorkspaceRecord = deps.archiveWorkspaceRecord;
deps.archiveWorkspaceRecord = async (id: string) => {
await originalArchiveWorkspaceRecord(id);
events.push({ type: "archive", workspaceId: id });
};
deps.markWorkspaceArchiving = vi.fn((workspaceIds: Iterable<string>, archivingAt: string) => {
for (const id of workspaceIds) {
archivingByWorkspaceId.set(id, archivingAt);
}
events.push({ type: "mark", workspaceIds: Array.from(workspaceIds), archivingAt });
});
deps.clearWorkspaceArchiving = vi.fn((workspaceIds: Iterable<string>) => {
for (const id of workspaceIds) {
archivingByWorkspaceId.delete(id);
}
events.push({ type: "clear", workspaceIds: Array.from(workspaceIds) });
});
deps.emitWorkspaceUpdatesForWorkspaceIds = vi.fn(async (workspaceIds: Iterable<string>) => {
const ids = Array.from(workspaceIds);
const activeIds = new Set<string>();
for (const workspace of deps.activeWorkspaces) {
activeIds.add(workspace.workspaceId);
}
const updates: Array<{
kind: "upsert" | "remove";
workspaceId: string;
archivingAt: string | null;
}> = [];
for (const id of ids) {
const archivingAt = archivingByWorkspaceId.get(id) ?? null;
if (archivingAt && activeIds.has(id)) {
updates.push({ kind: "upsert", workspaceId: id, archivingAt });
} else {
updates.push({ kind: "remove", workspaceId: id, archivingAt: null });
}
}
events.push({ type: "emit", workspaceIds: ids, updates });
});
await archiveByScope(deps, {
scope: { kind: "workspace", workspaceId },
repoRoot: repoDir,
requestId: "req-lifecycle",
});
expect(events.map((event) => event.type)).toEqual(["mark", "emit", "archive", "clear", "emit"]);
const firstEmit = events[1] as Extract<LifecycleEvent, { type: "emit" }>;
expect(firstEmit.workspaceIds).toEqual([workspaceId]);
expect(firstEmit.updates).toEqual([
{ kind: "upsert", workspaceId, archivingAt: expect.any(String) },
]);
const secondEmit = events[4] as Extract<LifecycleEvent, { type: "emit" }>;
expect(secondEmit.workspaceIds).toEqual([workspaceId]);
expect(secondEmit.updates).toEqual([{ kind: "remove", workspaceId, archivingAt: null }]);
});
test("archives stored snapshots only for the target workspace", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "snapshot-scope");
const targetWorkspaceId = "ws-snapshot-target";
const otherWorkspaceId = "ws-snapshot-other";
const liveAgentId = "agent-live";
const targetStoredAgentId = "agent-stored-target";
const otherStoredAgentId = "agent-stored-other";
const deps = createArchiveDeps({
paseoHome,
activeWorkspaces: [
{ workspaceId: targetWorkspaceId, cwd: worktree.worktreePath, kind: "worktree" },
],
});
deps.agentManager = {
listAgents: () => [{ id: liveAgentId, workspaceId: targetWorkspaceId }] as ManagedAgent[],
archiveAgent: vi.fn(async (agentId: string) => {
deps.archivedAgentIds.push(agentId);
return { archivedAt: new Date().toISOString() };
}),
archiveSnapshot: vi.fn(async (agentId: string, _archivedAt: string) => {
deps.archivedSnapshotIds.push(agentId);
return {};
}),
};
deps.agentStorage = {
list: async () =>
[
{ id: targetStoredAgentId, workspaceId: targetWorkspaceId, archivedAt: null },
{ id: otherStoredAgentId, workspaceId: otherWorkspaceId, archivedAt: null },
] as StoredAgentRecord[],
} as Pick<AgentStorage, "list">;
const result = await archiveByScope(deps, {
scope: { kind: "workspace", workspaceId: targetWorkspaceId },
repoRoot: repoDir,
requestId: "req-snapshot-scope",
});
assertArchiveResult(result, {
archivedWorkspaceIds: [targetWorkspaceId],
removedDirectory: true,
});
expect(result.archivedAgentIds).toContain(liveAgentId);
expect(result.archivedAgentIds).toContain(targetStoredAgentId);
expect(result.archivedAgentIds).not.toContain(otherStoredAgentId);
expect(deps.archivedSnapshotIds).toEqual([targetStoredAgentId]);
expect(existsSync(worktree.worktreePath)).toBe(false);
});
test("worktree scope archives three workspaces on the directory and removes it", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "worktree-scope-n3");
const workspaceA = "ws-worktree-n3-a";
const workspaceB = "ws-worktree-n3-b";
const workspaceC = "ws-worktree-n3-c";
const result = await archiveByScope(
createArchiveDeps({
paseoHome,
activeWorkspaces: [
{ workspaceId: workspaceA, cwd: worktree.worktreePath, kind: "worktree" },
{ workspaceId: workspaceB, cwd: worktree.worktreePath, kind: "worktree" },
{ workspaceId: workspaceC, cwd: worktree.worktreePath, kind: "local_checkout" },
],
}),
{
scope: { kind: "worktree", targetPath: worktree.worktreePath },
repoRoot: repoDir,
requestId: "req-worktree-scope-n3",
},
);
expect(result.archivedWorkspaceIds).toEqual(
expect.arrayContaining([workspaceA, workspaceB, workspaceC]),
);
expect(result.archivedWorkspaceIds).toHaveLength(3);
expect(result.removedDirectory).toBe(true);
expect(existsSync(worktree.worktreePath)).toBe(false);
});
});
describe("resolveWorkspaceIdAtPath", () => {
test("prefers the worktree-kind record on an exact cwd tie", async () => {
const targetPath = "/worktrees/repo/feature";
const result = await resolveWorkspaceIdAtPath(
{
listActiveWorkspaces: async () => [
{ workspaceId: "ws-local", cwd: targetPath, kind: "local_checkout" },
{ workspaceId: "ws-worktree", cwd: targetPath, kind: "worktree" },
],
findWorkspaceIdForCwd: vi.fn(async () => "ws-local"),
},
targetPath,
);
expect(result).toBe("ws-worktree");
});
test("falls back to the path resolver when there is no exact match", async () => {
const targetPath = "/worktrees/repo/feature";
const result = await resolveWorkspaceIdAtPath(
{
listActiveWorkspaces: async () => [
{ workspaceId: "ws-nested", cwd: "/worktrees/repo", kind: "worktree" },
],
findWorkspaceIdForCwd: vi.fn(async () => "ws-nested"),
},
targetPath,
);
expect(result).toBe("ws-nested");
});
});
@@ -1,5 +1,396 @@
import { resolve } from "node:path";
import type { Logger } from "pino";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import type { GitHubService } from "../services/github-service.js";
import {
deletePaseoWorktree,
isPaseoOwnedWorktreeCwd,
resolvePaseoWorktreeRootForCwd,
WorktreeTeardownError,
} from "../utils/worktree.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "./workspace-registry.js";
export interface ActiveWorkspaceRef {
workspaceId: string;
cwd: string;
kind?: "local_checkout" | "worktree" | "directory";
}
export interface ArchiveDependencies {
paseoHome?: string;
// Base directory that may hold worktrees across repositories. Used as a fallback
// when the request does not supply a per-repo root.
paseoWorktreesBaseRoot?: string;
github: GitHubService;
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">;
agentManager: Pick<AgentManager, "listAgents" | "archiveAgent" | "archiveSnapshot">;
agentStorage: Pick<AgentStorage, "list">;
// Resolves the worktree at a path to its workspaceId for archive-by-path. The
// path uniquely identifies a worktree workspace; this is a directory lookup for
// the archive target, not status/ownership.
findWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
// Active (non-archived) workspaces, used to decide whether the workspace being
// archived is the last reference to its backing worktree directory, and to
// break a same-cwd tie in favor of the worktree-kind record when archiving by
// path (no explicit workspaceId).
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
killTerminalsForWorkspace: (workspaceId: string) => Promise<void>;
sessionLogger?: Logger;
}
export interface KillTerminalsForWorkspaceDependencies {
detachTerminalStream?: (terminalId: string, options: { emitExit: boolean }) => void;
sessionLogger: Logger;
terminalManager: TerminalManager | null;
}
export type ArchiveScope =
| { kind: "workspace"; workspaceId: string }
| { kind: "worktree"; targetPath: string };
export interface ArchiveResult {
archivedAgentIds: string[];
archivedWorkspaceIds: string[];
removedDirectory: boolean;
}
export interface ArchiveByScopeRequest {
scope: ArchiveScope;
repoRoot: string | null;
// Per-repository worktree root, used to remove the actual directory.
repoWorktreesRoot?: string;
// Base directory that may hold worktrees across repositories; falls back to the
// dependency's base root for ownership checks and path resolution.
paseoWorktreesBaseRoot?: string;
requestId: string;
}
export async function resolveWorkspaceIdAtPath(
dependencies: Pick<ArchiveDependencies, "findWorkspaceIdForCwd" | "listActiveWorkspaces">,
targetPath: string,
): Promise<string | null> {
const targetDir = resolve(targetPath);
const activeWorkspaces = await dependencies.listActiveWorkspaces();
const exactMatches = activeWorkspaces.filter((workspace) => resolve(workspace.cwd) === targetDir);
const worktreeMatch = exactMatches.find((workspace) => workspace.kind === "worktree");
if (worktreeMatch) {
return worktreeMatch.workspaceId;
}
return dependencies.findWorkspaceIdForCwd(targetPath);
}
// THE single archive entry. Resolves the in-scope record set, tears each down
// (agents + terminals + record), then removes the backing directory iff it is
// Paseo-owned AND no active workspace still references it.
export async function archiveByScope(
dependencies: ArchiveDependencies,
request: ArchiveByScopeRequest,
): Promise<ArchiveResult> {
const { targetDir, targetWorkspaceIds } = await resolveArchiveTargets(
dependencies,
request.scope,
request.paseoWorktreesBaseRoot,
);
if (targetWorkspaceIds.length > 0) {
dependencies.markWorkspaceArchiving(targetWorkspaceIds, new Date().toISOString());
}
let removedDirectory = false;
try {
if (targetWorkspaceIds.length > 0) {
await dependencies.emitWorkspaceUpdatesForWorkspaceIds(targetWorkspaceIds);
}
const { archivedAgents, archivedWorkspaceIds } = await archiveTargetRecords(
dependencies,
targetWorkspaceIds,
request.requestId,
);
if (request.repoRoot) {
try {
await dependencies.workspaceGitService.getSnapshot(request.repoRoot, {
force: true,
reason: "archive-worktree",
});
} catch (error) {
dependencies.sessionLogger?.warn(
{ err: error, cwd: request.repoRoot, requestId: request.requestId },
"Failed to force-refresh workspace git snapshot after archiving",
);
}
}
if (targetDir !== null) {
removedDirectory = await maybeRemoveDirectory(
dependencies,
request,
targetDir,
archivedWorkspaceIds,
);
}
return {
archivedAgentIds: Array.from(archivedAgents),
archivedWorkspaceIds,
removedDirectory,
};
} finally {
if (targetWorkspaceIds.length > 0) {
dependencies.clearWorkspaceArchiving(targetWorkspaceIds);
await dependencies.emitWorkspaceUpdatesForWorkspaceIds(targetWorkspaceIds);
}
}
}
async function resolveArchiveTargets(
dependencies: ArchiveDependencies,
scope: ArchiveScope,
paseoWorktreesBaseRoot?: string,
): Promise<{ targetDir: string | null; targetWorkspaceIds: string[] }> {
const activeWorkspaces = await dependencies.listActiveWorkspaces();
if (scope.kind === "workspace") {
const workspaceId = scope.workspaceId;
const record = activeWorkspaces.find((workspace) => workspace.workspaceId === workspaceId);
if (!record) {
dependencies.sessionLogger?.warn(
{ workspaceId },
"Workspace not found for archive-by-scope; skipping",
);
return { targetDir: null, targetWorkspaceIds: [] };
}
return { targetDir: resolve(record.cwd), targetWorkspaceIds: [workspaceId] };
}
let targetPath = scope.targetPath;
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, {
paseoHome: dependencies.paseoHome,
worktreesRoot: paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot,
});
if (resolvedWorktree) {
targetPath = resolvedWorktree.worktreePath;
}
const targetDir = resolve(targetPath);
const targetWorkspaceIds = activeWorkspaces
.filter((workspace) => resolve(workspace.cwd) === targetDir)
.map((workspace) => workspace.workspaceId);
return { targetDir, targetWorkspaceIds };
}
async function archiveTargetRecords(
dependencies: ArchiveDependencies,
targetWorkspaceIds: string[],
requestId: string,
): Promise<{ archivedAgents: Set<string>; archivedWorkspaceIds: string[] }> {
const archivedAgents = new Set<string>();
const archivedWorkspaceIds: string[] = [];
const results = await Promise.allSettled(
targetWorkspaceIds.map(async (workspaceId) => {
const agents = await archiveWorkspaceContents(dependencies, workspaceId);
await dependencies.archiveWorkspaceRecord(workspaceId);
return { workspaceId, agents };
}),
);
for (const result of results) {
if (result.status === "fulfilled") {
archivedWorkspaceIds.push(result.value.workspaceId);
for (const agentId of result.value.agents) {
archivedAgents.add(agentId);
}
} else {
dependencies.sessionLogger?.warn(
{ err: result.reason, requestId },
"archiveByScope workspace teardown failed; continuing",
);
}
}
return { archivedAgents, archivedWorkspaceIds };
}
async function maybeRemoveDirectory(
dependencies: ArchiveDependencies,
request: Omit<ArchiveByScopeRequest, "scope">,
targetDir: string,
archivedWorkspaceIds: string[],
): Promise<boolean> {
const ownership = await isPaseoOwnedWorktreeCwd(targetDir, {
paseoHome: dependencies.paseoHome,
worktreesRoot: request.paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot,
});
if (!ownership.allowed) {
return false;
}
const remainingActive = await dependencies.listActiveWorkspaces();
if (!isDirectoryUnreferenced(remainingActive, targetDir, new Set(archivedWorkspaceIds))) {
return false;
}
try {
await deletePaseoWorktree({
cwd: request.repoRoot,
worktreePath: targetDir,
worktreesRoot: request.repoWorktreesRoot ?? ownership.worktreeRoot,
paseoHome: dependencies.paseoHome,
worktreesBaseRoot: request.paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot,
});
dependencies.github.invalidate({ cwd: targetDir });
return true;
} catch (error) {
if (error instanceof WorktreeTeardownError) {
dependencies.sessionLogger?.warn(
{ err: error, targetPath: targetDir, requestId: request.requestId },
"Worktree disk removal failed during archive; workspace already archived",
);
return false;
}
throw error;
}
}
export type ArchiveWorkspaceContentsDependencies = Pick<
ArchiveDependencies,
"agentManager" | "agentStorage" | "killTerminalsForWorkspace" | "sessionLogger"
>;
// Tears down everything OWNED by a single workspace record: its live agents,
// its persisted-but-not-running agent snapshots, and its terminals. Scoped by
// workspaceId so a sibling workspace sharing the same directory is untouched.
// Returns the set of archived agent ids.
export async function archiveWorkspaceContents(
dependencies: ArchiveWorkspaceContentsDependencies,
workspaceId: string,
): Promise<Set<string>> {
const archivedAgents = new Set<string>();
const liveAgents = dependencies.agentManager
.listAgents()
.filter((agent) => agent.workspaceId === workspaceId);
for (const agent of liveAgents) {
archivedAgents.add(agent.id);
}
let storedRecords: StoredAgentRecord[] = [];
try {
storedRecords = await dependencies.agentStorage.list();
} catch (error) {
dependencies.sessionLogger?.warn(
{ err: error, workspaceId },
"Failed to list stored agents during workspace archive; continuing",
);
}
const liveAgentIds = new Set(liveAgents.map((agent) => agent.id));
const matchingStoredRecords = storedRecords.filter(
(record) => record.workspaceId === workspaceId,
);
for (const record of matchingStoredRecords) {
archivedAgents.add(record.id);
}
const archivedAt = new Date().toISOString();
const archiveResults = await Promise.allSettled([
...liveAgents.map((agent) => dependencies.agentManager.archiveAgent(agent.id)),
...matchingStoredRecords
.filter((record) => !liveAgentIds.has(record.id) && !record.archivedAt)
.map((record) => dependencies.agentManager.archiveSnapshot(record.id, archivedAt)),
dependencies.killTerminalsForWorkspace(workspaceId),
]);
for (const result of archiveResults) {
if (result.status === "rejected") {
dependencies.sessionLogger?.warn(
{ err: result.reason, workspaceId },
"Workspace archive teardown step failed; continuing",
);
}
}
return archivedAgents;
}
// EXACTLY one last-reference predicate in the module. True when, after archiving
// the in-scope records, no active workspace still points at targetDir. Derived
// from records each call — no stored counter.
function isDirectoryUnreferenced(
activeWorkspaces: ActiveWorkspaceRef[],
targetDir: string,
archivedWorkspaceIds: ReadonlySet<string>,
): boolean {
const target = resolve(targetDir);
return !activeWorkspaces.some(
(workspace) =>
!archivedWorkspaceIds.has(workspace.workspaceId) && resolve(workspace.cwd) === target,
);
}
export async function killTerminalsForWorkspace(
dependencies: KillTerminalsForWorkspaceDependencies,
workspaceId: string,
): Promise<void> {
const terminalManager = dependencies.terminalManager;
if (!terminalManager) {
return;
}
const terminalIds: string[] = [];
const terminalLists = await Promise.all(
terminalManager.listDirectories().map(async (terminalCwd) => {
try {
return await terminalManager.getTerminals(terminalCwd, { workspaceId });
} catch (error) {
dependencies.sessionLogger.warn(
{ err: error, cwd: terminalCwd },
"Failed to enumerate workspace terminals during archive",
);
return [];
}
}),
);
for (const terminals of terminalLists) {
for (const terminal of terminals) {
if (terminal.workspaceId === workspaceId) {
terminalIds.push(terminal.id);
}
}
}
if (terminalIds.length === 0) {
return;
}
await Promise.allSettled(
terminalIds.map(async (terminalId) => {
try {
dependencies.detachTerminalStream?.(terminalId, { emitExit: true });
await terminalManager.killTerminalAndWait(terminalId, {
gracefulTimeoutMs: 2000,
forceTimeoutMs: 1500,
});
} catch (error) {
dependencies.sessionLogger.warn(
{ err: error, terminalId },
"Terminal kill escalation failed during archive; proceeding anyway",
);
}
}),
);
}
// Archiving the last workspace of a project leaves the project as a first-class
// empty project — it persists until explicitly removed, so we never archive the
// parent project here.
@@ -14,10 +14,6 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import pino, { type Logger } from "pino";
import type { SessionOutboundMessage, WorkspaceDescriptorPayload } from "./messages.js";
import {
archivePaseoWorktree,
killTerminalsForWorkspace as killWorkspaceTerminals,
} from "./paseo-worktree-archive-service.js";
import {
buildAgentSessionConfig,
createPaseoWorktreeWorkflow,
@@ -35,7 +31,6 @@ import {
} from "../utils/worktree.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { TerminalSession } from "../terminal/terminal.js";
import type { ManagedAgent } from "./agent/agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
import type { GitHubService } from "../services/github-service.js";
@@ -302,47 +297,6 @@ function createPaseoWorktreeForTest(options: {
};
}
function createManagedAgentForArchive(input: {
id: string;
cwd: string;
workspaceId?: string;
}): ManagedAgent {
const now = new Date();
return {
id: input.id,
provider: "codex",
cwd: input.cwd,
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
},
config: { provider: "codex", cwd: input.cwd },
createdAt: now,
updatedAt: now,
availableModes: [],
currentModeId: null,
pendingPermissions: new Map(),
bufferedPermissionResolutions: new Map(),
inFlightPermissionResponses: new Set(),
pendingReplacement: false,
persistence: null,
historyPrimed: false,
lastUserMessageAt: null,
attention: { requiresAttention: false },
foregroundTurnWaiters: new Set(),
unsubscribeSession: null,
labels: {},
lifecycle: "closed",
session: null,
activeForegroundTurnId: null,
};
}
describe("handlePaseoWorktreeListRequest", () => {
test("lists worktrees through the workspace git service", async () => {
const emitted: SessionOutboundMessage[] = [];
@@ -495,13 +449,20 @@ function createAgentStorageStub(): Pick<AgentStorage, "list"> {
};
}
function createWorkspaceArchivingDeps() {
return {
findWorkspaceIdForCwd: vi.fn(async () => "ws-archive-test"),
listActiveWorkspaces: vi.fn(async () => []),
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => {}),
markWorkspaceArchiving: vi.fn(),
clearWorkspaceArchiving: vi.fn(),
function createArchiveWorkspaceRecordMutator(
activeWorkspaces: Array<{
workspaceId: string;
cwd: string;
kind: "worktree" | "local_checkout" | "directory";
}>,
archivedWorkspaceRecords: string[],
) {
return async (id: string) => {
archivedWorkspaceRecords.push(id);
const index = activeWorkspaces.findIndex((workspace) => workspace.workspaceId === id);
if (index !== -1) {
activeWorkspaces.splice(index, 1);
}
};
}
@@ -1709,7 +1670,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
});
});
describe("archivePaseoWorktree", () => {
describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
const cleanupPaths: string[] = [];
afterEach(() => {
@@ -1718,303 +1679,29 @@ describe("archivePaseoWorktree", () => {
}
});
function createWorkspaceRecord(input: {
workspaceId: string;
cwd: string;
repoDir: string;
displayName: string;
}): PersistedWorkspaceRecord {
return {
workspaceId: input.workspaceId,
projectId: input.repoDir,
cwd: input.cwd,
kind: "worktree",
displayName: input.displayName,
createdAt: "2026-04-30T00:00:00.000Z",
updatedAt: "2026-04-30T00:00:00.000Z",
archivedAt: null,
};
}
function createTerminalSessionStub(input: {
id: string;
cwd: string;
workspaceId?: string;
}): TerminalSession {
return {
id: input.id,
cwd: input.cwd,
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
} as unknown as TerminalSession;
}
// Minimal terminal manager that only supports the queries archive performs:
// enumerate directories and fetch terminals filtered by owning workspaceId.
function createArchiveTerminalManagerStub(
sessions: TerminalSession[],
killed: string[],
): TerminalManager {
return {
listDirectories: () => Array.from(new Set(sessions.map((session) => session.cwd))),
getTerminals: async (cwd: string, options?: { workspaceId?: string }) =>
sessions.filter(
(session) =>
session.cwd === cwd &&
(options?.workspaceId === undefined ||
session.workspaceId === undefined ||
session.workspaceId === options.workspaceId),
),
killTerminalAndWait: async (terminalId: string) => {
killed.push(terminalId);
},
} as unknown as TerminalManager;
}
test("runs agent close and terminal teardown concurrently, scoped to the workspace", async () => {
test("archives every active workspace on the directory and removes it", async () => {
const { tempDir, repoDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const created = await createLegacyWorktreeForTest({
branchName: "archive-parallel",
branchName: "archive-worktree-scope",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "archive-parallel",
runSetup: false,
paseoHome,
});
const workspaceId = "ws-archive-parallel";
const teardownStartTimes: Record<string, number> = {};
const teardownEndTimes: Record<string, number> = {};
const archiveAgentSpy = vi.fn(async (agentId: string) => {
teardownStartTimes[agentId] = Date.now();
await new Promise((resolve) => setTimeout(resolve, 100));
teardownEndTimes[agentId] = Date.now();
return { archivedAt: new Date().toISOString() };
});
const archiveSnapshotSpy = vi.fn(async () => {
throw new Error("not expected for live agents");
});
const killTerminalsForWorkspace = vi.fn(async () => {
teardownStartTimes.__terminals = Date.now();
await new Promise((resolve) => setTimeout(resolve, 100));
teardownEndTimes.__terminals = Date.now();
});
const archivedAgents = await archivePaseoWorktree(
{
paseoHome,
github: createGitHubServiceStub(),
agentManager: {
listAgents: () => [
createManagedAgentForArchive({ id: "agent-1", cwd: created.worktreePath, workspaceId }),
createManagedAgentForArchive({ id: "agent-2", cwd: created.worktreePath, workspaceId }),
],
archiveAgent: archiveAgentSpy,
archiveSnapshot: archiveSnapshotSpy,
},
agentStorage: createAgentStorageStub(),
archiveWorkspaceRecord: vi.fn(async () => {}),
...createWorkspaceArchivingDeps(),
findWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
cwd === created.worktreePath ? workspaceId : null,
),
killTerminalsForWorkspace,
sessionLogger: createLogger(),
},
{
targetPath: created.worktreePath,
repoRoot: repoDir,
requestId: "req-archive-parallel",
},
);
expect(archivedAgents).toEqual(expect.arrayContaining(["agent-1", "agent-2"]));
expect(archiveAgentSpy).toHaveBeenCalledTimes(2);
expect(archiveSnapshotSpy).not.toHaveBeenCalled();
expect(killTerminalsForWorkspace).toHaveBeenCalledWith(workspaceId);
// Archiving must never delete the worktree from disk.
expect(existsSync(created.worktreePath)).toBe(true);
// All teardown work must overlap — sequential would take ~300ms, parallel ~100ms.
const starts = Object.values(teardownStartTimes);
const ends = Object.values(teardownEndTimes);
const maxEnd = Math.max(...ends);
const minStart = Math.min(...starts);
expect(maxEnd - minStart).toBeLessThan(220);
});
test("tears down only the target workspace; a sibling sharing the cwd survives", async () => {
const { tempDir, repoDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const created = await createLegacyWorktreeForTest({
branchName: "archive-siblings",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "archive-siblings",
worktreeSlug: "archive-worktree-scope",
runSetup: false,
paseoHome,
});
const sharedCwd = created.worktreePath;
const workspaceA = "ws-A";
const workspaceB = "ws-B";
const liveAgents = [
createManagedAgentForArchive({ id: "agent-A", cwd: sharedCwd, workspaceId: workspaceA }),
createManagedAgentForArchive({ id: "agent-B", cwd: sharedCwd, workspaceId: workspaceB }),
const workspaceA = "ws-worktree-scope-A";
const workspaceB = "ws-worktree-scope-B";
const activeWorkspaces = [
{ workspaceId: workspaceA, cwd: sharedCwd, kind: "worktree" as const },
{ workspaceId: workspaceB, cwd: sharedCwd, kind: "worktree" as const },
];
const storedRecords: StoredAgentRecord[] = [
{
id: "snapshot-A",
provider: "codex",
cwd: sharedCwd,
workspaceId: workspaceA,
createdAt: "2026-04-30T00:00:00.000Z",
updatedAt: "2026-04-30T00:00:00.000Z",
labels: {},
lastStatus: "closed",
config: null,
} as unknown as StoredAgentRecord,
{
id: "snapshot-B",
provider: "codex",
cwd: sharedCwd,
workspaceId: workspaceB,
createdAt: "2026-04-30T00:00:00.000Z",
updatedAt: "2026-04-30T00:00:00.000Z",
labels: {},
lastStatus: "closed",
config: null,
} as unknown as StoredAgentRecord,
];
const killedTerminals: string[] = [];
const terminalManager = createArchiveTerminalManagerStub(
[
createTerminalSessionStub({ id: "term-A", cwd: sharedCwd, workspaceId: workspaceA }),
createTerminalSessionStub({ id: "term-B", cwd: sharedCwd, workspaceId: workspaceB }),
],
killedTerminals,
);
const archivedAgentIds: string[] = [];
const archivedSnapshotIds: string[] = [];
const archivedWorkspaceRecords: string[] = [];
const archivedAgents = await archivePaseoWorktree(
{
paseoHome,
github: createGitHubServiceStub(),
agentManager: {
listAgents: () => liveAgents,
archiveAgent: async (agentId: string) => {
archivedAgentIds.push(agentId);
return { archivedAt: new Date().toISOString() };
},
archiveSnapshot: async (agentId: string) => {
archivedSnapshotIds.push(agentId);
return storedRecords.find((record) => record.id === agentId)!;
},
},
agentStorage: { list: async () => storedRecords },
archiveWorkspaceRecord: async (id: string) => {
archivedWorkspaceRecords.push(id);
},
...createWorkspaceArchivingDeps(),
findWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
cwd === sharedCwd ? workspaceA : null,
),
killTerminalsForWorkspace: (workspaceId) =>
killWorkspaceTerminals({ terminalManager, sessionLogger: createLogger() }, workspaceId),
sessionLogger: createLogger(),
},
{
targetPath: sharedCwd,
repoRoot: repoDir,
requestId: "req-archive-siblings",
},
);
// Only workspace A's agents, snapshots, terminals, and record are torn down.
expect(archivedAgents).toEqual(["agent-A", "snapshot-A"]);
expect(archivedAgentIds).toEqual(["agent-A"]);
expect(archivedSnapshotIds).toEqual(["snapshot-A"]);
expect(archivedWorkspaceRecords).toEqual([workspaceA]);
expect(killedTerminals).toEqual(["term-A"]);
// Sibling workspace B is untouched, and the shared directory survives.
expect(archivedAgentIds).not.toContain("agent-B");
expect(killedTerminals).not.toContain("term-B");
expect(existsSync(sharedCwd)).toBe(true);
});
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 workspaceId = "ws-archive-marked-during-close";
const affectedIds = [workspaceId];
const liveAgent = createManagedAgentForArchive({
id: "agent-1",
cwd: created.worktreePath,
workspaceId,
});
const workspaceRecord = createWorkspaceRecord({
workspaceId,
cwd: created.worktreePath,
repoDir,
displayName: "archive-marked-during-close",
});
const listActiveWorkspaces = vi.fn(async () => activeWorkspaces);
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 emittedWorkspaceId of workspaceIds) {
if (archivedWorkspaceIds.has(emittedWorkspaceId)) {
emitted.push({
type: "workspace_update",
payload: {
kind: "remove",
id: emittedWorkspaceId,
},
});
continue;
}
emitted.push({
type: "workspace_update",
payload: {
kind: "upsert",
workspace: {
...createWorkspaceDescriptor({ workspace: workspaceRecord, repoDir }),
archivingAt: archivingByWorkspaceId.get(emittedWorkspaceId) ?? null,
},
},
});
}
events.push(`emit:${Array.from(workspaceIds).join(",")}`);
});
const archiveAgent = vi.fn(async () => {
events.push("close:start");
await emitWorkspaceUpdatesForWorkspaceIds(affectedIds);
events.push("close:end");
return { archivedAt: new Date().toISOString() };
});
const archiveSnapshot = vi.fn(async () => {
throw new Error("not expected for live agents");
});
await handlePaseoWorktreeArchiveRequest(
{
@@ -2025,75 +1712,38 @@ describe("archivePaseoWorktree", () => {
listWorktrees: vi.fn(async () => []),
},
agentManager: {
listAgents: () => [liveAgent],
archiveAgent,
archiveSnapshot,
listAgents: () => [],
archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })),
archiveSnapshot: vi.fn(async () => {
throw new Error("not expected for empty agent list");
}),
},
agentStorage: createAgentStorageStub(),
findWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
cwd === created.worktreePath ? workspaceId : null,
findWorkspaceIdForCwd: vi.fn(async () => workspaceA),
listActiveWorkspaces,
archiveWorkspaceRecord: createArchiveWorkspaceRecordMutator(
activeWorkspaces,
archivedWorkspaceRecords,
),
listActiveWorkspaces: vi.fn(async () => []),
archiveWorkspaceRecord: vi.fn(async (archivedWorkspaceId: string) => {
archivedWorkspaceIds.add(archivedWorkspaceId);
}),
emit: (message) => emitted.push(message),
emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => {
events.push(`mark:${Array.from(workspaceIds).join(",")}`);
for (const markedWorkspaceId of workspaceIds) {
archivingByWorkspaceId.set(markedWorkspaceId, archivingAt);
}
},
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => {
events.push(`clear:${Array.from(workspaceIds).join(",")}`);
for (const clearedWorkspaceId of workspaceIds) {
archivingByWorkspaceId.delete(clearedWorkspaceId);
}
},
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => {}),
markWorkspaceArchiving: vi.fn(),
clearWorkspaceArchiving: vi.fn(),
killTerminalsForWorkspace: vi.fn(async () => {}),
sessionLogger: createLogger(),
},
{
type: "paseo_worktree_archive_request",
requestId: "req-archive-marked-during-close",
worktreePath: created.worktreePath,
requestId: "req-worktree-scope",
worktreePath: sharedCwd,
repoRoot: repoDir,
scope: "worktree",
},
);
const workspaceUpdates = emitted.filter(
(message): message is Extract<SessionOutboundMessage, { type: "workspace_update" }> =>
message.type === "workspace_update",
);
expect(events.slice(0, 3)).toEqual([
`mark:${workspaceId}`,
`emit:${workspaceId}`,
"close:start",
]);
expect(workspaceUpdates[0]?.payload).toEqual({
kind: "upsert",
workspace: expect.objectContaining({
id: workspaceId,
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: workspaceId,
archivingAt,
}),
});
expect(workspaceUpdates.at(-1)?.payload).toEqual({
kind: "remove",
id: workspaceId,
});
expect(events.slice(-2)).toEqual([`clear:${workspaceId}`, `emit:${workspaceId}`]);
expect(archivedWorkspaceRecords).toContain(workspaceA);
expect(archivedWorkspaceRecords).toContain(workspaceB);
expect(existsSync(sharedCwd)).toBe(false);
expect(
emitted.find((message) => message.type === "paseo_worktree_archive_response"),
).toMatchObject({
@@ -2104,27 +1754,34 @@ describe("archivePaseoWorktree", () => {
});
});
test("archives the workspace record but leaves the worktree on disk", async () => {
test("default scope archives a single workspace record and removes the directory on last reference", async () => {
const { tempDir, repoDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const created = await createLegacyWorktreeForTest({
branchName: "archive-keeps-disk",
branchName: "archive-default-scope",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "archive-keeps-disk",
worktreeSlug: "archive-default-scope",
runSetup: false,
paseoHome,
});
const workspaceId = "ws-archive-keeps-disk";
const archiveWorkspaceRecord = vi.fn(async () => {});
const workspaceId = "ws-default-scope";
const activeWorkspaces = [
{ workspaceId, cwd: created.worktreePath, kind: "worktree" as const },
];
const archivedWorkspaceRecords: string[] = [];
const emitted: SessionOutboundMessage[] = [];
await archivePaseoWorktree(
await handlePaseoWorktreeArchiveRequest(
{
paseoHome,
github: createGitHubServiceStub(),
workspaceGitService: { getSnapshot: vi.fn(async () => null) },
workspaceGitService: {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => []),
},
agentManager: {
listAgents: () => [],
archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })),
@@ -2133,148 +1790,74 @@ describe("archivePaseoWorktree", () => {
}),
},
agentStorage: createAgentStorageStub(),
archiveWorkspaceRecord,
...createWorkspaceArchivingDeps(),
findWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
cwd === created.worktreePath ? workspaceId : null,
),
listActiveWorkspaces: vi.fn(async () => activeWorkspaces),
archiveWorkspaceRecord: vi.fn(async (id: string) => {
archivedWorkspaceRecords.push(id);
if (activeWorkspaces[0]?.workspaceId === id) {
activeWorkspaces.splice(0, 1);
}
}),
emit: (message) => emitted.push(message),
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => {}),
markWorkspaceArchiving: vi.fn(),
clearWorkspaceArchiving: vi.fn(),
killTerminalsForWorkspace: vi.fn(async () => {}),
sessionLogger: createLogger(),
},
{
targetPath: created.worktreePath,
type: "paseo_worktree_archive_request",
requestId: "req-default-scope",
worktreePath: created.worktreePath,
repoRoot: repoDir,
requestId: "req-archive-keeps-disk",
},
);
expect(existsSync(created.worktreePath)).toBe(true);
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(workspaceId);
});
test("forces a workspace git snapshot refresh after archive", async () => {
const { tempDir, repoDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const created = await createLegacyWorktreeForTest({
branchName: "archive-refresh",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "archive-refresh",
runSetup: false,
paseoHome,
});
const workspaceGitService = {
getSnapshot: vi.fn(async () => null),
};
await archivePaseoWorktree(
{
paseoHome,
github: createGitHubServiceStub(),
workspaceGitService: workspaceGitService as unknown as WorkspaceGitService,
agentManager: {
listAgents: () => [],
archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })),
archiveSnapshot: vi.fn(async () => {
throw new Error("not expected for empty agent list");
}),
},
agentStorage: createAgentStorageStub(),
archiveWorkspaceRecord: vi.fn(async () => {}),
...createWorkspaceArchivingDeps(),
findWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
cwd === created.worktreePath ? "ws-archive-refresh" : null,
),
killTerminalsForWorkspace: vi.fn(async () => {}),
sessionLogger: createLogger(),
},
{
targetPath: created.worktreePath,
repoRoot: repoDir,
requestId: "req-archive-refresh",
},
);
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, {
force: true,
reason: "archive-worktree",
});
});
test("deletes the worktree from disk when asked and it is the last reference", async () => {
const { tempDir, repoDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const created = await createLegacyWorktreeForTest({
branchName: "archive-delete-last-ref",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "archive-delete-last-ref",
runSetup: false,
paseoHome,
});
const workspaceId = "ws-delete-last-ref";
await archivePaseoWorktree(
{
paseoHome,
github: createGitHubServiceStub(),
workspaceGitService: { getSnapshot: vi.fn(async () => null) },
agentManager: {
listAgents: () => [],
archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })),
archiveSnapshot: vi.fn(async () => {
throw new Error("not expected for empty agent list");
}),
},
agentStorage: createAgentStorageStub(),
archiveWorkspaceRecord: vi.fn(async () => {}),
...createWorkspaceArchivingDeps(),
findWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
cwd === created.worktreePath ? workspaceId : null,
),
// No other active workspace references the worktree dir.
listActiveWorkspaces: vi.fn(async () => []),
killTerminalsForWorkspace: vi.fn(async () => {}),
sessionLogger: createLogger(),
},
{
targetPath: created.worktreePath,
repoRoot: repoDir,
worktreesBaseRoot: path.join(paseoHome, "worktrees"),
deleteWorktreeFromDisk: true,
requestId: "req-delete-last-ref",
},
);
expect(archivedWorkspaceRecords).toEqual([workspaceId]);
expect(existsSync(created.worktreePath)).toBe(false);
expect(
emitted.find((message) => message.type === "paseo_worktree_archive_response"),
).toMatchObject({
payload: {
success: true,
error: null,
},
});
});
test("keeps the worktree on disk when a sibling workspace still references it", async () => {
test("default scope keeps the directory when a sibling workspace still references it", async () => {
const { tempDir, repoDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const created = await createLegacyWorktreeForTest({
branchName: "archive-delete-sibling",
branchName: "archive-default-scope-sibling",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "archive-delete-sibling",
worktreeSlug: "archive-default-scope-sibling",
runSetup: false,
paseoHome,
});
const sharedCwd = created.worktreePath;
const workspaceA = "ws-delete-A";
const workspaceB = "ws-delete-B";
const workspaceA = "ws-default-scope-sibling-A";
const workspaceB = "ws-default-scope-sibling-B";
const activeWorkspaces = [
{ workspaceId: workspaceA, cwd: sharedCwd, kind: "worktree" as const },
{ workspaceId: workspaceB, cwd: sharedCwd, kind: "local_checkout" as const },
];
const archivedWorkspaceRecords: string[] = [];
const emitted: SessionOutboundMessage[] = [];
await archivePaseoWorktree(
await handlePaseoWorktreeArchiveRequest(
{
paseoHome,
github: createGitHubServiceStub(),
workspaceGitService: { getSnapshot: vi.fn(async () => null) },
workspaceGitService: {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => []),
},
agentManager: {
listAgents: () => [],
archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })),
@@ -2283,76 +1866,131 @@ describe("archivePaseoWorktree", () => {
}),
},
agentStorage: createAgentStorageStub(),
archiveWorkspaceRecord: vi.fn(async () => {}),
...createWorkspaceArchivingDeps(),
findWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
cwd === sharedCwd ? workspaceA : null,
),
// Sibling workspace B still references the same worktree directory.
listActiveWorkspaces: vi.fn(async () => [{ workspaceId: workspaceB, cwd: sharedCwd }]),
listActiveWorkspaces: vi.fn(async () => activeWorkspaces),
archiveWorkspaceRecord: vi.fn(async (id: string) => {
archivedWorkspaceRecords.push(id);
if (activeWorkspaces[0]?.workspaceId === id) {
activeWorkspaces.splice(0, 1);
}
}),
emit: (message) => emitted.push(message),
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => {}),
markWorkspaceArchiving: vi.fn(),
clearWorkspaceArchiving: vi.fn(),
killTerminalsForWorkspace: vi.fn(async () => {}),
sessionLogger: createLogger(),
},
{
targetPath: sharedCwd,
type: "paseo_worktree_archive_request",
requestId: "req-default-scope-sibling",
worktreePath: sharedCwd,
repoRoot: repoDir,
worktreesBaseRoot: path.join(paseoHome, "worktrees"),
deleteWorktreeFromDisk: true,
requestId: "req-delete-sibling",
},
);
expect(archivedWorkspaceRecords).toEqual([workspaceA]);
expect(existsSync(sharedCwd)).toBe(true);
expect(
emitted.find((message) => message.type === "paseo_worktree_archive_response"),
).toMatchObject({
payload: {
success: true,
error: null,
},
});
});
test("keeps the worktree on disk when deleteWorktreeFromDisk is not requested", async () => {
test("ignores deleteWorktreeFromDisk:true and derives directory removal from remaining references", async () => {
const { tempDir, repoDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const created = await createLegacyWorktreeForTest({
branchName: "archive-no-delete-flag",
branchName: "archive-delete-flag",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "archive-no-delete-flag",
worktreeSlug: "archive-delete-flag",
runSetup: false,
paseoHome,
});
const workspaceId = "ws-no-delete-flag";
const listActiveWorkspaces = vi.fn(async () => []);
const sharedCwd = created.worktreePath;
const workspaceA = "ws-delete-flag-a";
const workspaceB = "ws-delete-flag-b";
const activeWorkspaces = [
{ workspaceId: workspaceA, cwd: sharedCwd, kind: "worktree" as const },
{ workspaceId: workspaceB, cwd: sharedCwd, kind: "worktree" as const },
];
const archivedWorkspaceRecords: string[] = [];
const emitted: SessionOutboundMessage[] = [];
const listActiveWorkspaces = vi.fn(async () => activeWorkspaces);
await archivePaseoWorktree(
{
paseoHome,
github: createGitHubServiceStub(),
workspaceGitService: { getSnapshot: vi.fn(async () => null) },
agentManager: {
listAgents: () => [],
archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })),
archiveSnapshot: vi.fn(async () => {
throw new Error("not expected for empty agent list");
}),
},
agentStorage: createAgentStorageStub(),
archiveWorkspaceRecord: vi.fn(async () => {}),
...createWorkspaceArchivingDeps(),
findWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
cwd === created.worktreePath ? workspaceId : null,
),
listActiveWorkspaces,
killTerminalsForWorkspace: vi.fn(async () => {}),
sessionLogger: createLogger(),
const deps = {
paseoHome,
github: createGitHubServiceStub(),
workspaceGitService: {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => []),
},
{
targetPath: created.worktreePath,
repoRoot: repoDir,
worktreesBaseRoot: path.join(paseoHome, "worktrees"),
requestId: "req-no-delete-flag",
agentManager: {
listAgents: () => [],
archiveAgent: vi.fn(async () => ({ archivedAt: new Date().toISOString() })),
archiveSnapshot: vi.fn(async () => {
throw new Error("not expected for empty agent list");
}),
},
);
agentStorage: createAgentStorageStub(),
findWorkspaceIdForCwd: vi.fn(async (cwd: string) => (cwd === sharedCwd ? workspaceA : null)),
listActiveWorkspaces,
archiveWorkspaceRecord: createArchiveWorkspaceRecordMutator(
activeWorkspaces,
archivedWorkspaceRecords,
),
emit: (message: SessionOutboundMessage) => emitted.push(message),
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => {}),
markWorkspaceArchiving: vi.fn(),
clearWorkspaceArchiving: vi.fn(),
killTerminalsForWorkspace: vi.fn(async () => {}),
sessionLogger: createLogger(),
};
// Default (false): the directory survives. The explicit workspaceId is
// resolved by path, so no last-reference deletion runs.
expect(existsSync(created.worktreePath)).toBe(true);
// First archive: a sibling workspace still references the directory, so the
// retained deleteWorktreeFromDisk:true flag must NOT force removal.
await handlePaseoWorktreeArchiveRequest(deps, {
type: "paseo_worktree_archive_request",
requestId: "req-delete-flag-first",
worktreePath: sharedCwd,
repoRoot: repoDir,
workspaceId: workspaceA,
scope: "workspace",
deleteWorktreeFromDisk: true,
});
expect(archivedWorkspaceRecords).toEqual([workspaceA]);
expect(activeWorkspaces).toHaveLength(1);
expect(activeWorkspaces[0]?.workspaceId).toBe(workspaceB);
expect(existsSync(sharedCwd)).toBe(true);
archivedWorkspaceRecords.length = 0;
// Second archive: last reference, so removal is derived even though the flag
// is still ignored.
await handlePaseoWorktreeArchiveRequest(deps, {
type: "paseo_worktree_archive_request",
requestId: "req-delete-flag-second",
worktreePath: sharedCwd,
repoRoot: repoDir,
workspaceId: workspaceB,
scope: "workspace",
deleteWorktreeFromDisk: true,
});
expect(archivedWorkspaceRecords).toEqual([workspaceB]);
expect(existsSync(sharedCwd)).toBe(false);
expect(
emitted.filter((message) => message.type === "paseo_worktree_archive_response"),
).toHaveLength(2);
});
});
@@ -40,10 +40,10 @@ import type {
CreatePaseoWorktreeInput,
CreatePaseoWorktreeResult,
} from "./paseo-worktree-service.js";
import type { ArchivePaseoWorktreeDependencies } from "./paseo-worktree-archive-service.js";
import type { ArchiveDependencies } from "./workspace-archive-service.js";
import { toWorktreeWireError } from "./worktree-errors.js";
import {
archivePaseoWorktreeCommand,
archiveCommand,
createPaseoWorktreeCommand,
listPaseoWorktreesCommand,
} from "./worktree/commands.js";
@@ -436,7 +436,7 @@ export async function handlePaseoWorktreeListRequest(
export async function handlePaseoWorktreeArchiveRequest(
dependencies: Omit<
ArchivePaseoWorktreeDependencies,
ArchiveDependencies,
"emitWorkspaceUpdatesForWorkspaceIds" | "workspaceGitService"
> & {
emit: EmitSessionMessage;
@@ -448,13 +448,13 @@ export async function handlePaseoWorktreeArchiveRequest(
const { requestId } = msg;
try {
const result = await archivePaseoWorktreeCommand(dependencies, {
const result = await archiveCommand(dependencies, {
requestId,
worktreePath: msg.worktreePath,
repoRoot: msg.repoRoot,
branchName: msg.branchName,
workspaceId: msg.workspaceId,
deleteWorktreeFromDisk: msg.deleteWorktreeFromDisk,
scope: msg.scope,
});
if (!result.ok) {
dependencies.emit({
+62 -33
View File
@@ -2,9 +2,11 @@ import { join } from "node:path";
import { getPaseoWorktreesRoot, isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
import {
archivePaseoWorktree,
type ArchivePaseoWorktreeDependencies,
} from "../paseo-worktree-archive-service.js";
archiveByScope,
resolveWorkspaceIdAtPath,
type ArchiveDependencies,
type ArchiveScope,
} from "../workspace-archive-service.js";
import type {
CreatePaseoWorktreeInput,
CreatePaseoWorktreeResult,
@@ -87,24 +89,24 @@ export async function createPaseoWorktreeCommand<Result extends CreatePaseoWorkt
}
}
export interface ArchivePaseoWorktreeCommandDependencies extends Omit<
ArchivePaseoWorktreeDependencies,
export interface ArchiveCommandDependencies extends Omit<
ArchiveDependencies,
"workspaceGitService"
> {
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
}
export interface ArchivePaseoWorktreeCommandInput {
export interface ArchiveCommandInput {
requestId: string;
repoRoot?: string | null;
worktreePath?: string;
worktreeSlug?: string;
branchName?: string;
workspaceId?: string;
deleteWorktreeFromDisk?: boolean;
scope?: ArchiveScope["kind"];
}
export type ArchivePaseoWorktreeCommandResult =
export type ArchiveCommandResult =
| {
ok: true;
removedAgents: string[];
@@ -116,39 +118,66 @@ export type ArchivePaseoWorktreeCommandResult =
removedAgents: [];
};
export async function archivePaseoWorktreeCommand(
dependencies: ArchivePaseoWorktreeCommandDependencies,
input: ArchivePaseoWorktreeCommandInput,
): Promise<ArchivePaseoWorktreeCommandResult> {
export async function archiveCommand(
dependencies: ArchiveCommandDependencies,
input: ArchiveCommandInput,
): Promise<ArchiveCommandResult> {
const resolvedTarget = await resolveArchiveTarget(dependencies, input);
const ownership = await isPaseoOwnedWorktreeCwd(resolvedTarget.targetPath, {
paseoHome: dependencies.paseoHome,
worktreesRoot: dependencies.worktreesRoot,
});
const scope = input.scope ?? "workspace";
if (scope === "worktree") {
const ownership = await isPaseoOwnedWorktreeCwd(resolvedTarget.targetPath, {
paseoHome: dependencies.paseoHome,
worktreesRoot: dependencies.paseoWorktreesBaseRoot,
});
if (!ownership.allowed) {
return {
ok: false,
code: "NOT_ALLOWED",
message: "Worktree is not a Paseo-owned worktree",
removedAgents: [],
};
}
const result = await archiveByScope(dependencies, {
scope: { kind: "worktree", targetPath: resolvedTarget.targetPath },
repoRoot: ownership.repoRoot ?? resolvedTarget.repoRoot ?? null,
repoWorktreesRoot: ownership.worktreeRoot,
paseoWorktreesBaseRoot: dependencies.paseoWorktreesBaseRoot,
requestId: input.requestId,
});
if (!ownership.allowed) {
return {
ok: false,
code: "NOT_ALLOWED",
message: "Worktree is not a Paseo-owned worktree",
ok: true,
removedAgents: result.archivedAgentIds,
};
}
const workspaceId =
input.workspaceId ?? (await resolveWorkspaceIdAtPath(dependencies, resolvedTarget.targetPath));
if (!workspaceId) {
dependencies.sessionLogger?.warn(
{ targetPath: resolvedTarget.targetPath },
"Could not resolve workspace for archive; skipping",
);
return {
ok: true,
removedAgents: [],
};
}
const repoRoot = ownership.repoRoot ?? resolvedTarget.repoRoot ?? null;
const removedAgents = await archivePaseoWorktree(dependencies, {
targetPath: resolvedTarget.targetPath,
repoRoot,
worktreesRoot: ownership.worktreeRoot,
worktreesBaseRoot: dependencies.worktreesRoot,
workspaceId: input.workspaceId,
deleteWorktreeFromDisk: input.deleteWorktreeFromDisk,
const result = await archiveByScope(dependencies, {
scope: { kind: "workspace", workspaceId },
repoRoot: resolvedTarget.repoRoot,
paseoWorktreesBaseRoot: dependencies.paseoWorktreesBaseRoot,
requestId: input.requestId,
});
return {
ok: true,
removedAgents,
removedAgents: result.archivedAgentIds,
};
}
@@ -158,8 +187,8 @@ interface ResolvedArchiveTarget {
}
async function resolveArchiveTarget(
dependencies: ArchivePaseoWorktreeCommandDependencies,
input: ArchivePaseoWorktreeCommandInput,
dependencies: ArchiveCommandDependencies,
input: ArchiveCommandInput,
): Promise<ResolvedArchiveTarget> {
const repoRoot = input.repoRoot ?? null;
if (input.worktreePath) {
@@ -189,14 +218,14 @@ async function resolveArchiveTarget(
}
async function resolveWorktreeSlugPath(
dependencies: ArchivePaseoWorktreeCommandDependencies,
dependencies: ArchiveCommandDependencies,
repoRoot: string,
worktreeSlug: string,
): Promise<string> {
const worktreesRoot = await getPaseoWorktreesRoot(
repoRoot,
dependencies.paseoHome,
dependencies.worktreesRoot,
dependencies.paseoWorktreesBaseRoot,
);
return join(worktreesRoot, worktreeSlug);
}
@@ -13,7 +13,7 @@ import type {
UnsubscribeTerminalRequest,
UnsubscribeTerminalsRequest,
} from "../server/messages.js";
import { killTerminalsForWorkspace as killWorkspaceTerminals } from "../server/paseo-worktree-archive-service.js";
import { killTerminalsForWorkspace as killWorkspaceTerminals } from "../server/workspace-archive-service.js";
import {
TerminalStreamOpcode,
decodeTerminalResizePayload,