Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion packages/server/src/server/agent/mcp-server.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { execFileSync } from "node:child_process";
import { describe, expect, it, vi } from "vitest";
import { realpathSync } from "node:fs";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { access, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { join, resolve as resolvePath } from "node:path";
import { tmpdir } from "node:os";
import { z } from "zod";
Expand Down Expand Up @@ -1149,6 +1149,75 @@ describe("create_agent MCP tool", () => {
}
});

it("archives a worktree by slug", async () => {
const { agentManager, agentStorage } = createTestDeps();
const tempDir = realpathSync.native(
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-slug-")),
);
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),
};
const server = await createAgentMcpServer({
agentManager,
agentStorage,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts: [] }),
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees"
>,
archiveWorkspaceRecord: vi.fn(async () => undefined),
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => undefined),
markWorkspaceArchiving: vi.fn(),
clearWorkspaceArchiving: vi.fn(),
emitSessionMessage: 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-slug-worktree", base: "main" },
});

const response = await archiveTool.handler({
cwd: repoDir,
worktreeSlug: "archive-slug-worktree",
});

expect(response.structuredContent).toEqual({ success: true });
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, {
force: true,
reason: "archive-worktree",
});
await expect(
access(z.string().parse(created.structuredContent.worktreePath)),
).rejects.toThrow();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});

it("routes list_worktrees through WorkspaceGitService", async () => {
const { agentManager, agentStorage } = createTestDeps();
const workspaceGitService = {
Expand Down
128 changes: 74 additions & 54 deletions packages/server/src/server/agent/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,7 @@ import { selectItemsByProjectedLimit } from "./timeline-projection.js";
import type { AgentStorage } from "./agent-storage.js";
import { ensureAgentLoaded } from "./agent-loading.js";
import { isStoredAgentProviderAvailable } from "../persistence-hooks.js";
import { getPaseoWorktreesRoot } from "../../utils/worktree.js";
import {
archivePaseoWorktree,
killTerminalsUnderPath,
type ArchivePaseoWorktreeDependencies,
} from "../paseo-worktree-archive-service.js";
Expand Down Expand Up @@ -69,7 +67,10 @@ import type { GitHubService } from "../../services/github-service.js";
import type { WorkspaceGitService } from "../workspace-git-service.js";
import type { CreatePaseoWorktreeInput } from "../paseo-worktree-service.js";
import { toWorktreeRequestError } from "../worktree-errors.js";
import { join } from "node:path";
import {
archivePaseoWorktreeCommand,
type ArchivePaseoWorktreeCommandDependencies,
} from "../worktree/commands.js";

export interface AgentMcpServerOptions {
agentManager: AgentManager;
Expand Down Expand Up @@ -1722,63 +1723,23 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
if (!worktreePath && !worktreeSlug) {
throw new Error("worktreePath or worktreeSlug is required");
}
if (!options.github) {
throw new Error("GitHub service is required to archive worktrees");
}
if (!options.workspaceGitService) {
throw new Error("WorkspaceGitService is required to archive worktrees");
}
if (!options.archiveWorkspaceRecord) {
throw new Error("Workspace registry archiver is required to archive worktrees");
}
if (!options.emitWorkspaceUpdatesForWorkspaceIds) {
throw new Error("Workspace update emitter is required to archive worktrees");
}
if (!options.markWorkspaceArchiving) {
throw new Error("Workspace archiving marker is required to archive worktrees");
}
if (!options.clearWorkspaceArchiving) {
throw new Error("Workspace archiving clearer is required to archive worktrees");
}
if (!options.emitSessionMessage) {
throw new Error("Session message emitter is required to archive worktrees");
}

const targetPath =
worktreePath ??
join(await getPaseoWorktreesRoot(repoRoot, options.paseoHome), worktreeSlug!);

await archivePaseoWorktree(
{
paseoHome: options.paseoHome,
github: options.github,
workspaceGitService: options.workspaceGitService,
const result = await archivePaseoWorktreeCommand(
archiveWorktreeDependencies(options, {
agentManager,
agentStorage,
archiveWorkspaceRecord: options.archiveWorkspaceRecord,
emit: options.emitSessionMessage,
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: options.markWorkspaceArchiving,
clearWorkspaceArchiving: options.clearWorkspaceArchiving,
isPathWithinRoot: isSameOrDescendantPath,
killTerminalsUnderPath: (rootPath) =>
killTerminalsUnderPath(
{
terminalManager: terminalManager ?? null,
isPathWithinRoot: isSameOrDescendantPath,
killTrackedTerminal: () => {},
sessionLogger: childLogger,
},
rootPath,
),
sessionLogger: childLogger,
},
terminalManager: terminalManager ?? null,
logger: childLogger,
}),
{
targetPath,
repoRoot,
requestId: "mcp:archive_worktree",
repoRoot,
worktreePath,
worktreeSlug,
},
);
if (!result.ok) {
throw new Error(result.message);
}

return {
content: [],
Expand Down Expand Up @@ -1939,6 +1900,65 @@ type McpCreateWorktreeTarget =
| { mode: "checkout-branch"; branch: string }
| { mode: "checkout-pr"; prNumber: number };

interface ArchiveWorktreeCommandContext {
agentManager: AgentManager;
agentStorage: AgentStorage;
terminalManager: TerminalManager | null;
logger: Logger;
}

function archiveWorktreeDependencies(
options: AgentMcpServerOptions,
context: ArchiveWorktreeCommandContext,
): ArchivePaseoWorktreeCommandDependencies {
if (!options.github) {
throw new Error("GitHub service is required to archive worktrees");
}
if (!options.workspaceGitService) {
throw new Error("WorkspaceGitService is required to archive worktrees");
}
if (!options.archiveWorkspaceRecord) {
throw new Error("Workspace registry archiver is required to archive worktrees");
}
if (!options.emitWorkspaceUpdatesForWorkspaceIds) {
throw new Error("Workspace update emitter is required to archive worktrees");
}
if (!options.markWorkspaceArchiving) {
throw new Error("Workspace archiving marker is required to archive worktrees");
}
if (!options.clearWorkspaceArchiving) {
throw new Error("Workspace archiving clearer is required to archive worktrees");
}
if (!options.emitSessionMessage) {
throw new Error("Session message emitter is required to archive worktrees");
}

return {
paseoHome: options.paseoHome,
github: options.github,
workspaceGitService: options.workspaceGitService,
agentManager: context.agentManager,
agentStorage: context.agentStorage,
archiveWorkspaceRecord: options.archiveWorkspaceRecord,
emit: options.emitSessionMessage,
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: options.markWorkspaceArchiving,
clearWorkspaceArchiving: options.clearWorkspaceArchiving,
isPathWithinRoot: isSameOrDescendantPath,
killTerminalsUnderPath: (rootPath: string) =>
killTerminalsUnderPath(
{
terminalManager: context.terminalManager,
isPathWithinRoot: isSameOrDescendantPath,
killTrackedTerminal: () => {},
sessionLogger: context.logger,
},
rootPath,
),
sessionLogger: context.logger,
};
}

function mcpCreateWorktreeInput(
repoRoot: string,
target: McpCreateWorktreeTarget,
Expand Down
53 changes: 12 additions & 41 deletions packages/server/src/server/worktree-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js";
import { expandTilde } from "../utils/path.js";
import {
getWorktreeSetupCommands,
isPaseoOwnedWorktreeCwd,
resolveWorktreeRuntimeEnv,
runWorktreeSetupCommands,
slugify,
Expand All @@ -41,11 +40,9 @@ import type {
CreatePaseoWorktreeInput,
CreatePaseoWorktreeResult,
} from "./paseo-worktree-service.js";
import {
archivePaseoWorktree,
type ArchivePaseoWorktreeDependencies,
} from "./paseo-worktree-archive-service.js";
import type { ArchivePaseoWorktreeDependencies } from "./paseo-worktree-archive-service.js";
import { toWorktreeWireError } from "./worktree-errors.js";
import { archivePaseoWorktreeCommand } from "./worktree/commands.js";

const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._/-]+$/;

Expand Down Expand Up @@ -433,61 +430,35 @@ export async function handlePaseoWorktreeArchiveRequest(
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>,
): Promise<void> {
const { requestId } = msg;
let targetPath = msg.worktreePath;
let repoRoot = msg.repoRoot ?? null;

try {
if (!targetPath) {
if (!repoRoot || !msg.branchName) {
throw new Error("worktreePath or repoRoot+branchName is required");
}
const worktrees = await dependencies.workspaceGitService.listWorktrees(repoRoot);
const match = worktrees.find((entry) => entry.branchName === msg.branchName);
if (!match) {
throw new Error(`Paseo worktree not found for branch ${msg.branchName}`);
}
targetPath = match.path;
}
if (!targetPath) {
throw new Error("worktreePath could not be resolved");
}

const ownership = await isPaseoOwnedWorktreeCwd(targetPath, {
paseoHome: dependencies.paseoHome,
const result = await archivePaseoWorktreeCommand(dependencies, {
requestId,
worktreePath: msg.worktreePath,
repoRoot: msg.repoRoot,
branchName: msg.branchName,
});
if (!ownership.allowed) {
if (!result.ok) {
dependencies.emit({
type: "paseo_worktree_archive_response",
payload: {
success: false,
removedAgents: [],
removedAgents: result.removedAgents,
error: {
code: "NOT_ALLOWED",
message: "Worktree is not a Paseo-owned worktree",
code: result.code,
message: result.message,
},
requestId,
},
});
return;
}

// repoRoot is best-effort: if git has forgotten about the worktree we
// still proceed using the path-derived worktreesRoot, since the ownership
// check already proved the path lives under $PASEO_HOME/worktrees.
repoRoot = ownership.repoRoot ?? repoRoot ?? null;

const removedAgents = await archivePaseoWorktree(dependencies, {
targetPath,
repoRoot,
worktreesRoot: ownership.worktreeRoot,
requestId,
});

dependencies.emit({
type: "paseo_worktree_archive_response",
payload: {
success: true,
removedAgents,
removedAgents: result.removedAgents,
error: null,
requestId,
},
Expand Down
Loading