mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
refactor: unify worktree creation workflow (#636)
Route app, MCP, and agent worktree creation through one workflow boundary. Move branch auto-naming into workspace creation and keep agent metadata title-only.
This commit is contained in:
@@ -243,7 +243,7 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
|
||||
|
||||
export interface CreatePaseoWorktreeInput extends Pick<
|
||||
CreatePaseoWorktreeRequest,
|
||||
"cwd" | "worktreeSlug" | "attachments" | "refName" | "action" | "githubPrNumber"
|
||||
"cwd" | "worktreeSlug" | "nameContext" | "attachments" | "refName" | "action" | "githubPrNumber"
|
||||
> {}
|
||||
|
||||
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
@@ -2757,6 +2757,7 @@ export class DaemonClient {
|
||||
type: "create_paseo_worktree_request",
|
||||
cwd: input.cwd,
|
||||
worktreeSlug: input.worktreeSlug,
|
||||
...(input.nameContext !== undefined ? { nameContext: input.nameContext } : {}),
|
||||
...(input.attachments && input.attachments.length > 0
|
||||
? { attachments: input.attachments }
|
||||
: {}),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { basename } from "path";
|
||||
import { z } from "zod";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
@@ -9,15 +8,10 @@ import {
|
||||
StructuredAgentResponseError,
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
} from "./agent-response-loop.js";
|
||||
import { validateBranchSlug } from "../../utils/worktree.js";
|
||||
import { renameCurrentBranch } from "../../utils/checkout-git.js";
|
||||
import { MAX_AUTO_AGENT_TITLE_CHARS } from "./agent-title-limits.js";
|
||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "../workspace-git-service.js";
|
||||
|
||||
export interface AgentMetadataGeneratorDeps {
|
||||
generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback;
|
||||
renameCurrentBranch?: typeof renameCurrentBranch;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot">;
|
||||
}
|
||||
|
||||
export interface AgentMetadataGenerationOptions {
|
||||
@@ -34,7 +28,6 @@ export interface AgentMetadataGenerationOptions {
|
||||
interface AgentMetadataNeeds {
|
||||
prompt: string | null;
|
||||
needsTitle: boolean;
|
||||
needsBranch: boolean;
|
||||
}
|
||||
|
||||
function hasExplicitTitle(title?: string | null): boolean {
|
||||
@@ -49,33 +42,6 @@ function normalizeAutoTitle(title: string): string | null {
|
||||
return normalized.slice(0, MAX_AUTO_AGENT_TITLE_CHARS).trim() || null;
|
||||
}
|
||||
|
||||
async function canRenameBranch(
|
||||
cwd: string,
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot"> | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!workspaceGitService) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let snapshot: WorkspaceGitRuntimeSnapshot;
|
||||
try {
|
||||
snapshot = await workspaceGitService.getSnapshot(cwd);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!snapshot.git.isGit || !snapshot.git.isPaseoOwnedWorktree) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!snapshot.git.currentBranch || !snapshot.git.repoRoot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const worktreeDirName = basename(snapshot.git.repoRoot);
|
||||
return snapshot.git.currentBranch === worktreeDirName;
|
||||
}
|
||||
|
||||
export async function determineAgentMetadataNeeds(
|
||||
options: Pick<
|
||||
AgentMetadataGenerationOptions,
|
||||
@@ -84,23 +50,21 @@ export async function determineAgentMetadataNeeds(
|
||||
): Promise<AgentMetadataNeeds> {
|
||||
const prompt = options.initialPrompt?.trim();
|
||||
if (!prompt) {
|
||||
return { prompt: null, needsTitle: false, needsBranch: false };
|
||||
return { prompt: null, needsTitle: false };
|
||||
}
|
||||
|
||||
const needsTitle = !hasExplicitTitle(options.explicitTitle);
|
||||
const needsBranch = await canRenameBranch(options.cwd, options.deps?.workspaceGitService);
|
||||
|
||||
return {
|
||||
prompt,
|
||||
needsTitle,
|
||||
needsBranch,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMetadataSchema(
|
||||
needs: AgentMetadataNeeds,
|
||||
): z.ZodObject<Record<string, z.ZodTypeAny>> | null {
|
||||
if (!needs.needsTitle && !needs.needsBranch) {
|
||||
if (!needs.needsTitle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -108,33 +72,16 @@ function buildMetadataSchema(
|
||||
if (needs.needsTitle) {
|
||||
shape.title = z.string().min(1).max(MAX_AUTO_AGENT_TITLE_CHARS);
|
||||
}
|
||||
if (needs.needsBranch) {
|
||||
shape.branch = z.string().min(1).max(100);
|
||||
}
|
||||
return z.object(shape);
|
||||
}
|
||||
|
||||
function buildPrompt(needs: AgentMetadataNeeds): string {
|
||||
const fields = [needs.needsTitle ? "title" : null, needs.needsBranch ? "branch" : null].filter(
|
||||
Boolean,
|
||||
) as string[];
|
||||
|
||||
const instructions: string[] = ["Generate metadata for a coding agent based on the user prompt."];
|
||||
|
||||
if (needs.needsTitle) {
|
||||
instructions.push(`Title: short descriptive label (<= ${MAX_AUTO_AGENT_TITLE_CHARS} chars).`);
|
||||
}
|
||||
if (needs.needsBranch) {
|
||||
instructions.push(
|
||||
"Branch: lowercase slug using letters, numbers, hyphens, and slashes only; no spaces, no uppercase, no leading/trailing hyphen, no consecutive hyphens.",
|
||||
);
|
||||
}
|
||||
|
||||
if (fields.length === 1) {
|
||||
instructions.push(`Return JSON only with a single field '${fields[0]}'.`);
|
||||
} else {
|
||||
instructions.push(`Return JSON only with fields '${fields.join("' and '")}'.`);
|
||||
}
|
||||
instructions.push("Return JSON only with a single field 'title'.");
|
||||
|
||||
instructions.push("", "User prompt:", needs.prompt ?? "");
|
||||
return instructions.join("\n");
|
||||
@@ -156,9 +103,8 @@ export async function generateAndApplyAgentMetadata(
|
||||
const generator =
|
||||
options.deps?.generateStructuredAgentResponseWithFallback ??
|
||||
generateStructuredAgentResponseWithFallback;
|
||||
const renameCurrentBranchImpl = options.deps?.renameCurrentBranch ?? renameCurrentBranch;
|
||||
|
||||
let result: { title?: string; branch?: string };
|
||||
let result: { title?: string };
|
||||
|
||||
try {
|
||||
result = await generator({
|
||||
@@ -198,78 +144,6 @@ export async function generateAndApplyAgentMetadata(
|
||||
await options.agentManager.setTitle(options.agentId, normalizedTitle);
|
||||
}
|
||||
}
|
||||
|
||||
if (needs.needsBranch && typeof result.branch === "string") {
|
||||
await applyGeneratedBranchRename({
|
||||
options,
|
||||
branch: result.branch,
|
||||
renameCurrentBranchImpl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function applyGeneratedBranchRename(params: {
|
||||
options: AgentMetadataGenerationOptions;
|
||||
branch: string;
|
||||
renameCurrentBranchImpl: typeof renameCurrentBranch;
|
||||
}): Promise<void> {
|
||||
const { options, branch, renameCurrentBranchImpl } = params;
|
||||
const normalizedBranch = branch.trim();
|
||||
const validation = validateBranchSlug(normalizedBranch);
|
||||
if (!validation.valid) {
|
||||
options.logger.warn(
|
||||
{ agentId: options.agentId, branch: normalizedBranch, error: validation.error },
|
||||
"Generated branch name is invalid",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceGitService = options.deps?.workspaceGitService;
|
||||
if (!workspaceGitService) {
|
||||
return;
|
||||
}
|
||||
|
||||
let snapshot: WorkspaceGitRuntimeSnapshot;
|
||||
try {
|
||||
snapshot = await workspaceGitService.getSnapshot(options.cwd);
|
||||
} catch (error) {
|
||||
options.logger.warn(
|
||||
{ err: error, agentId: options.agentId },
|
||||
"Failed to re-check branch eligibility",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!snapshot.git.isGit || !snapshot.git.isPaseoOwnedWorktree || !snapshot.git.currentBranch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const worktreeDirName = snapshot.git.repoRoot ? basename(snapshot.git.repoRoot) : null;
|
||||
if (snapshot.git.currentBranch !== worktreeDirName) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await renameCurrentBranchImpl(options.cwd, normalizedBranch);
|
||||
try {
|
||||
await workspaceGitService.getSnapshot(options.cwd, {
|
||||
force: true,
|
||||
reason: "rename-branch",
|
||||
});
|
||||
} catch (error) {
|
||||
options.logger.warn(
|
||||
{ err: error, agentId: options.agentId, cwd: options.cwd },
|
||||
"Failed to force-refresh workspace git snapshot after branch rename",
|
||||
);
|
||||
}
|
||||
options.agentManager.notifyAgentState(options.agentId);
|
||||
await options.agentManager.flush();
|
||||
} catch (error) {
|
||||
options.logger.warn(
|
||||
{ err: error, agentId: options.agentId, branch: normalizedBranch },
|
||||
"Failed to rename branch",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function scheduleAgentMetadataGeneration(options: AgentMetadataGenerationOptions): void {
|
||||
|
||||
@@ -7,34 +7,9 @@ import {
|
||||
type AgentMetadataGeneratorDeps,
|
||||
} from "./agent-metadata-generator.js";
|
||||
import type { AgentManager } from "./agent-manager.js";
|
||||
import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js";
|
||||
|
||||
const logger = createTestLogger();
|
||||
|
||||
const ELIGIBLE_WORKTREE_SNAPSHOT: WorkspaceGitRuntimeSnapshot = {
|
||||
cwd: "/tmp/repo/metadata-worktree",
|
||||
git: {
|
||||
isGit: true,
|
||||
repoRoot: "/tmp/repo/metadata-worktree",
|
||||
mainRepoRoot: "/tmp/repo",
|
||||
currentBranch: "metadata-worktree",
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: true,
|
||||
isDirty: false,
|
||||
baseRef: "main",
|
||||
aheadBehind: null,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
hasRemote: false,
|
||||
diffStat: null,
|
||||
},
|
||||
github: {
|
||||
featuresEnabled: false,
|
||||
pullRequest: null,
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
|
||||
function createDeps(
|
||||
generateStructuredAgentResponseWithFallback: NonNullable<
|
||||
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
|
||||
@@ -89,134 +64,30 @@ describe("agent metadata generator auto-title", () => {
|
||||
expect(setTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("notifies agent state after successfully renaming a generated branch", async () => {
|
||||
it("generates titles independently from workspace branch naming", async () => {
|
||||
const setTitle = vi.fn().mockResolvedValue(undefined);
|
||||
const notifyAgentState = vi.fn();
|
||||
const flush = vi.fn().mockResolvedValue(undefined);
|
||||
const manager = {
|
||||
setTitle,
|
||||
notifyAgentState,
|
||||
flush,
|
||||
} as unknown as AgentManager;
|
||||
const renameCurrentBranch = vi.fn().mockResolvedValue({
|
||||
previousBranch: "metadata-worktree",
|
||||
currentBranch: "feature/metadata-worktree",
|
||||
}) as NonNullable<AgentMetadataGeneratorDeps["renameCurrentBranch"]>;
|
||||
const generateStructured = vi.fn().mockResolvedValue({
|
||||
branch: "feature/metadata-worktree",
|
||||
}) as NonNullable<AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]>;
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn().mockResolvedValue(ELIGIBLE_WORKTREE_SNAPSHOT),
|
||||
};
|
||||
const manager = { setTitle } as unknown as AgentManager;
|
||||
const generateStructured = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ title: "Generated title" }) as NonNullable<
|
||||
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
|
||||
>;
|
||||
|
||||
await generateAndApplyAgentMetadata({
|
||||
agentManager: manager,
|
||||
agentId: "agent-branch",
|
||||
agentId: "agent-suppressed-branch",
|
||||
cwd: "/tmp/repo/metadata-worktree",
|
||||
initialPrompt: "Rename this worktree branch.",
|
||||
explicitTitle: "Keep explicit title",
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
initialPrompt: "Implement this feature",
|
||||
explicitTitle: null,
|
||||
logger,
|
||||
deps: {
|
||||
generateStructuredAgentResponseWithFallback: generateStructured,
|
||||
renameCurrentBranch,
|
||||
workspaceGitService,
|
||||
},
|
||||
});
|
||||
|
||||
expect(renameCurrentBranch).toHaveBeenCalledWith(
|
||||
"/tmp/repo/metadata-worktree",
|
||||
"feature/metadata-worktree",
|
||||
);
|
||||
expect(notifyAgentState).toHaveBeenCalledWith("agent-branch");
|
||||
expect(setTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forces a workspace git snapshot refresh after renaming a generated branch", async () => {
|
||||
const manager = {
|
||||
setTitle: vi.fn().mockResolvedValue(undefined),
|
||||
notifyAgentState: vi.fn(),
|
||||
flush: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as AgentManager;
|
||||
const renameCurrentBranch = vi.fn().mockResolvedValue({
|
||||
previousBranch: "metadata-worktree",
|
||||
currentBranch: "feature/metadata-worktree",
|
||||
}) as NonNullable<AgentMetadataGeneratorDeps["renameCurrentBranch"]>;
|
||||
const generateStructured = vi.fn().mockResolvedValue({
|
||||
branch: "feature/metadata-worktree",
|
||||
}) as NonNullable<AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]>;
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn().mockResolvedValue(ELIGIBLE_WORKTREE_SNAPSHOT),
|
||||
};
|
||||
|
||||
await generateAndApplyAgentMetadata({
|
||||
agentManager: manager,
|
||||
agentId: "agent-branch-refresh",
|
||||
cwd: "/tmp/repo/metadata-worktree",
|
||||
initialPrompt: "Rename this worktree branch.",
|
||||
explicitTitle: "Keep explicit title",
|
||||
logger,
|
||||
deps: {
|
||||
generateStructuredAgentResponseWithFallback: generateStructured,
|
||||
renameCurrentBranch,
|
||||
workspaceGitService: workspaceGitService as unknown as Pick<
|
||||
import("../workspace-git-service.js").WorkspaceGitService,
|
||||
"getSnapshot"
|
||||
>,
|
||||
},
|
||||
});
|
||||
|
||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo/metadata-worktree", {
|
||||
force: true,
|
||||
reason: "rename-branch",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the workspace git service snapshot for branch rename eligibility checks", async () => {
|
||||
const setTitle = vi.fn().mockResolvedValue(undefined);
|
||||
const notifyAgentState = vi.fn();
|
||||
const manager = {
|
||||
setTitle,
|
||||
notifyAgentState,
|
||||
flush: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as AgentManager;
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn().mockResolvedValue(ELIGIBLE_WORKTREE_SNAPSHOT),
|
||||
};
|
||||
const renameCurrentBranch = vi.fn().mockResolvedValue({
|
||||
previousBranch: "metadata-worktree",
|
||||
currentBranch: "feature/metadata-worktree",
|
||||
}) as NonNullable<AgentMetadataGeneratorDeps["renameCurrentBranch"]>;
|
||||
const generateStructured = vi.fn().mockResolvedValue({
|
||||
branch: "feature/metadata-worktree",
|
||||
}) as NonNullable<AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]>;
|
||||
|
||||
await generateAndApplyAgentMetadata({
|
||||
agentManager: manager,
|
||||
agentId: "agent-service-branch",
|
||||
cwd: "/tmp/repo/metadata-worktree",
|
||||
initialPrompt: "Rename this worktree branch.",
|
||||
explicitTitle: "Keep explicit title",
|
||||
logger,
|
||||
deps: {
|
||||
generateStructuredAgentResponseWithFallback: generateStructured,
|
||||
renameCurrentBranch,
|
||||
workspaceGitService: workspaceGitService as unknown as Pick<
|
||||
import("../workspace-git-service.js").WorkspaceGitService,
|
||||
"getSnapshot"
|
||||
>,
|
||||
},
|
||||
});
|
||||
|
||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledTimes(3);
|
||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo/metadata-worktree");
|
||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo/metadata-worktree", {
|
||||
force: true,
|
||||
reason: "rename-branch",
|
||||
});
|
||||
expect(renameCurrentBranch).toHaveBeenCalledWith(
|
||||
"/tmp/repo/metadata-worktree",
|
||||
"feature/metadata-worktree",
|
||||
expect(generateStructured).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: "/tmp/repo/metadata-worktree" }),
|
||||
);
|
||||
expect(setTitle).toHaveBeenCalledWith("agent-suppressed-branch", "Generated title");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,8 +18,9 @@ import type { AgentProvider } from "./agent-sdk-types.js";
|
||||
import type { WorkspaceGitService } from "../workspace-git-service.js";
|
||||
import {
|
||||
createPaseoWorktree as createPaseoWorktreeService,
|
||||
type CreatePaseoWorktreeFn,
|
||||
type CreatePaseoWorktreeInput,
|
||||
} from "../paseo-worktree-service.js";
|
||||
import type { CreatePaseoWorktreeWorkflowFn } from "../worktree-session.js";
|
||||
import { createWorktreeCoreDeps } from "../worktree-core.js";
|
||||
import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
|
||||
import type { GitHubService } from "../../services/github-service.js";
|
||||
@@ -292,7 +293,9 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
paseoHome: string;
|
||||
broadcasts: string[];
|
||||
createdWorkspaceIds?: string[];
|
||||
}): CreatePaseoWorktreeFn {
|
||||
setupContinuations?: Array<"workspace" | "agent" | undefined>;
|
||||
startedAgentSetupIds?: string[];
|
||||
}): CreatePaseoWorktreeWorkflowFn {
|
||||
const projects = new Map<string, PersistedProjectRecord>();
|
||||
const workspaces = new Map<string, PersistedWorkspaceRecord>();
|
||||
const github = createGitHubServiceStub();
|
||||
@@ -303,6 +306,7 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
});
|
||||
|
||||
return async (input, serviceOptions) => {
|
||||
options.setupContinuations?.push(serviceOptions?.setupContinuation?.kind);
|
||||
const coreDeps = createWorktreeCoreDeps(github);
|
||||
const result = await createPaseoWorktreeService(input, {
|
||||
...coreDeps,
|
||||
@@ -326,6 +330,17 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
});
|
||||
options.broadcasts.push(result.workspace.workspaceId);
|
||||
options.createdWorkspaceIds?.push(result.workspace.workspaceId);
|
||||
if (serviceOptions?.setupContinuation?.kind === "agent") {
|
||||
return {
|
||||
...result,
|
||||
setupContinuation: {
|
||||
kind: "agent",
|
||||
startAfterAgentCreate: ({ agentId }) => {
|
||||
options.startedAgentSetupIds?.push(agentId);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -478,13 +493,26 @@ describe("create_agent MCP tool", () => {
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts optional name context in create_worktree input validation", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = registeredTool(server, "create_worktree");
|
||||
|
||||
const parsed = await tool.inputSchema.safeParseAsync({
|
||||
cwd: existingCwd,
|
||||
nameContext: "Fix workspace creation naming",
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects create_worktree without a branch name or checkout intent", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = registeredTool(server, "create_worktree");
|
||||
|
||||
await expect(tool.callback({})).rejects.toThrow(
|
||||
"create_worktree requires branchName, refName, or githubPrNumber",
|
||||
"create_worktree requires branchName, nameContext, refName, or githubPrNumber",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -608,6 +636,8 @@ describe("create_agent MCP tool", () => {
|
||||
const paseoHome = join(tempDir, ".paseo");
|
||||
const broadcasts: string[] = [];
|
||||
const createdWorkspaceIds: string[] = [];
|
||||
const setupContinuations: Array<"workspace" | "agent" | undefined> = [];
|
||||
const startedAgentSetupIds: string[] = [];
|
||||
|
||||
try {
|
||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||
@@ -636,6 +666,8 @@ describe("create_agent MCP tool", () => {
|
||||
paseoHome,
|
||||
broadcasts,
|
||||
createdWorkspaceIds,
|
||||
setupContinuations,
|
||||
startedAgentSetupIds,
|
||||
}),
|
||||
logger,
|
||||
});
|
||||
@@ -653,6 +685,8 @@ describe("create_agent MCP tool", () => {
|
||||
expect(broadcasts).toHaveLength(1);
|
||||
expect(createdWorkspaceIds).toHaveLength(1);
|
||||
expect(broadcasts[0]).toBe(createdWorkspaceIds[0]);
|
||||
expect(setupContinuations).toEqual(["agent"]);
|
||||
expect(startedAgentSetupIds).toEqual(["agent-with-worktree"]);
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: expect.stringContaining("agent-worktree"),
|
||||
@@ -665,12 +699,243 @@ describe("create_agent MCP tool", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-names a create_agent branch-off worktree from the initial prompt without metadata branch rename", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-worktree-name-context-"));
|
||||
const repoDir = join(tempDir, "repo");
|
||||
const paseoHome = join(tempDir, ".paseo");
|
||||
const broadcasts: string[] = [];
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn(async () => {
|
||||
throw new Error("agent metadata branch rename should not run");
|
||||
}),
|
||||
};
|
||||
|
||||
try {
|
||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
||||
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
||||
id: "agent-auto-named-worktree",
|
||||
cwd: config.cwd,
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "Worktree agent" },
|
||||
}));
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
paseoHome,
|
||||
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
|
||||
workspaceGitService: workspaceGitService as unknown as Pick<
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees"
|
||||
>,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.callback({
|
||||
cwd: repoDir,
|
||||
title: "Worktree agent",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Fix workspace creation naming",
|
||||
action: "branch-off",
|
||||
baseBranch: "main",
|
||||
background: true,
|
||||
});
|
||||
|
||||
const agentCwd = spies.agentManager.createAgent.mock.calls[0]?.[0].cwd as string;
|
||||
expect(
|
||||
execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" }).toString().trim(),
|
||||
).toBe("fix-workspace-creation-naming");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(broadcasts).toHaveLength(1);
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not auto-rename a create_agent checkout worktree from the initial prompt", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-checkout-name-context-"));
|
||||
const repoDir = join(tempDir, "repo");
|
||||
const paseoHome = join(tempDir, ".paseo");
|
||||
const broadcasts: string[] = [];
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn(async () => {
|
||||
throw new Error("agent metadata branch rename should not run");
|
||||
}),
|
||||
};
|
||||
|
||||
try {
|
||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
||||
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git checkout -b existing-feature", { cwd: repoDir, stdio: "pipe" });
|
||||
await writeFile(join(repoDir, "feature.txt"), "feature\n");
|
||||
execSync("git add feature.txt", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m feature", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
||||
id: "agent-checkout-worktree",
|
||||
cwd: config.cwd,
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "Checkout agent" },
|
||||
}));
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
paseoHome,
|
||||
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
|
||||
workspaceGitService: workspaceGitService as unknown as Pick<
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees"
|
||||
>,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.callback({
|
||||
cwd: repoDir,
|
||||
title: "Checkout agent",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Rename this checkout from the prompt",
|
||||
action: "checkout",
|
||||
refName: "existing-feature",
|
||||
background: true,
|
||||
});
|
||||
|
||||
const agentCwd = spies.agentManager.createAgent.mock.calls[0]?.[0].cwd as string;
|
||||
expect(
|
||||
execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" }).toString().trim(),
|
||||
).toBe("existing-feature");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(broadcasts).toHaveLength(1);
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("passes create_agent GitHub PR worktrees through workspace creation without metadata branch rename", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const startedAgentSetupIds: string[] = [];
|
||||
const createPaseoWorktree = vi.fn(
|
||||
async (
|
||||
input: CreatePaseoWorktreeInput,
|
||||
options?: Parameters<CreatePaseoWorktreeWorkflowFn>[1],
|
||||
) => ({
|
||||
worktree: {
|
||||
branchName: "pr-123",
|
||||
worktreePath: "/tmp/worktrees/pr-123",
|
||||
},
|
||||
intent: {
|
||||
kind: "checkout-github-pr" as const,
|
||||
githubPrNumber: input.githubPrNumber ?? 123,
|
||||
headRef: "pr-123",
|
||||
baseRefName: "main",
|
||||
},
|
||||
workspace: {
|
||||
workspaceId: "/tmp/worktrees/pr-123",
|
||||
projectId: "/tmp/repo",
|
||||
cwd: "/tmp/worktrees/pr-123",
|
||||
kind: "worktree" as const,
|
||||
displayName: "pr-123",
|
||||
createdAt: "2026-04-30T00:00:00.000Z",
|
||||
updatedAt: "2026-04-30T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
},
|
||||
repoRoot: "/tmp/repo",
|
||||
created: true,
|
||||
...(options?.setupContinuation?.kind === "agent"
|
||||
? {
|
||||
setupContinuation: {
|
||||
kind: "agent" as const,
|
||||
startAfterAgentCreate: ({ agentId }: { agentId: string }) => {
|
||||
startedAgentSetupIds.push(agentId);
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn(async () => {
|
||||
throw new Error("agent metadata branch rename should not run");
|
||||
}),
|
||||
};
|
||||
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
||||
id: "agent-pr-worktree",
|
||||
cwd: config.cwd,
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "PR agent" },
|
||||
}));
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
createPaseoWorktree,
|
||||
workspaceGitService: workspaceGitService as unknown as Pick<
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees"
|
||||
>,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.callback({
|
||||
cwd: "/tmp/repo",
|
||||
title: "PR agent",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Rename this PR branch from prompt",
|
||||
githubPrNumber: 123,
|
||||
background: true,
|
||||
});
|
||||
|
||||
expect(createPaseoWorktree).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
githubPrNumber: 123,
|
||||
nameContext: "Rename this PR branch from prompt",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
setupContinuation: expect.objectContaining({ kind: "agent" }),
|
||||
}),
|
||||
);
|
||||
expect(startedAgentSetupIds).toEqual(["agent-pr-worktree"]);
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: "/tmp/worktrees/pr-123" }),
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers and broadcasts a workspace when create_worktree creates a worktree", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-create-worktree-"));
|
||||
const repoDir = join(tempDir, "repo");
|
||||
const paseoHome = join(tempDir, ".paseo");
|
||||
const broadcasts: string[] = [];
|
||||
const setupContinuations: Array<"workspace" | "agent" | undefined> = [];
|
||||
|
||||
try {
|
||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||
@@ -689,7 +954,11 @@ describe("create_agent MCP tool", () => {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
paseoHome,
|
||||
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
|
||||
createPaseoWorktree: createPaseoWorktreeForMcpTest({
|
||||
paseoHome,
|
||||
broadcasts,
|
||||
setupContinuations,
|
||||
}),
|
||||
workspaceGitService: workspaceGitService as unknown as Pick<
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees"
|
||||
@@ -705,17 +974,8 @@ describe("create_agent MCP tool", () => {
|
||||
|
||||
expect(response.structuredContent.branchName).toBe("tool-worktree");
|
||||
expect(response.structuredContent.worktreePath).toContain("tool-worktree");
|
||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, {
|
||||
force: true,
|
||||
reason: "create-worktree",
|
||||
});
|
||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(
|
||||
response.structuredContent.worktreePath,
|
||||
{
|
||||
force: true,
|
||||
reason: "create-worktree",
|
||||
},
|
||||
);
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(setupContinuations).toEqual([undefined]);
|
||||
expect(broadcasts).toHaveLength(1);
|
||||
expect(broadcasts[0]).toContain("tool-worktree");
|
||||
} finally {
|
||||
@@ -723,6 +983,106 @@ describe("create_agent MCP tool", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-names a standalone branch-off worktree from create_worktree name context", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-create-worktree-name-context-"));
|
||||
const repoDir = join(tempDir, "repo");
|
||||
const paseoHome = join(tempDir, ".paseo");
|
||||
const broadcasts: string[] = [];
|
||||
|
||||
try {
|
||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
||||
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
paseoHome,
|
||||
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_worktree");
|
||||
const response = await tool.callback({
|
||||
cwd: repoDir,
|
||||
nameContext: "Fix workspace creation naming",
|
||||
baseBranch: "main",
|
||||
});
|
||||
|
||||
expect(response.structuredContent.branchName).toBe("fix-workspace-creation-naming");
|
||||
expect(
|
||||
execSync("git branch --show-current", {
|
||||
cwd: response.structuredContent.worktreePath as string,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim(),
|
||||
).toBe("fix-workspace-creation-naming");
|
||||
expect(spies.agentManager.createAgent).not.toHaveBeenCalled();
|
||||
expect(broadcasts).toHaveLength(1);
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not rename checkout-created worktrees from create_worktree name context", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-checkout-worktree-name-context-"));
|
||||
const repoDir = join(tempDir, "repo");
|
||||
const paseoHome = join(tempDir, ".paseo");
|
||||
const broadcasts: string[] = [];
|
||||
|
||||
try {
|
||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
||||
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git checkout -b existing-feature", { cwd: repoDir, stdio: "pipe" });
|
||||
await writeFile(join(repoDir, "feature.txt"), "feature\n");
|
||||
execSync("git add feature.txt", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m feature", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
paseoHome,
|
||||
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_worktree");
|
||||
const response = await tool.callback({
|
||||
cwd: repoDir,
|
||||
action: "checkout",
|
||||
refName: "existing-feature",
|
||||
nameContext: "Should Not Rename Checkout",
|
||||
});
|
||||
|
||||
expect(response.structuredContent.branchName).toBe("existing-feature");
|
||||
expect(
|
||||
execSync("git branch --show-current", {
|
||||
cwd: response.structuredContent.worktreePath as string,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim(),
|
||||
).toBe("existing-feature");
|
||||
expect(spies.agentManager.createAgent).not.toHaveBeenCalled();
|
||||
expect(broadcasts).toHaveLength(1);
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-"));
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
appendTimelineItemIfAgentKnown,
|
||||
emitLiveTimelineItemIfAgentKnown,
|
||||
} from "./timeline-append.js";
|
||||
import { getPaseoWorktreesRoot, type WorktreeConfig } from "../../utils/worktree.js";
|
||||
import { getPaseoWorktreesRoot } from "../../utils/worktree.js";
|
||||
import {
|
||||
archivePaseoWorktree,
|
||||
killTerminalsUnderPath,
|
||||
@@ -39,7 +39,12 @@ import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
|
||||
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../path-utils.js";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import { captureTerminalLines } from "../../terminal/terminal.js";
|
||||
import { runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
|
||||
import type {
|
||||
AgentWorktreeSetupContinuation,
|
||||
CreatePaseoWorktreeSetupContinuationInput,
|
||||
CreatePaseoWorktreeWorkflowFn,
|
||||
CreatePaseoWorktreeWorkflowResult,
|
||||
} from "../worktree-session.js";
|
||||
import type { ScheduleService } from "../schedule/service.js";
|
||||
import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js";
|
||||
import type { ProviderDefinition } from "./provider-registry.js";
|
||||
@@ -61,11 +66,7 @@ import {
|
||||
} from "./mcp-shared.js";
|
||||
import type { GitHubService } from "../../services/github-service.js";
|
||||
import type { WorkspaceGitService } from "../workspace-git-service.js";
|
||||
import type {
|
||||
CreatePaseoWorktreeFn,
|
||||
CreatePaseoWorktreeInput,
|
||||
CreatePaseoWorktreeResult,
|
||||
} from "../paseo-worktree-service.js";
|
||||
import type { CreatePaseoWorktreeInput } from "../paseo-worktree-service.js";
|
||||
import { toWorktreeRequestError } from "../worktree-errors.js";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -83,7 +84,7 @@ export interface AgentMcpServerOptions {
|
||||
markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"];
|
||||
clearWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["clearWorkspaceArchiving"];
|
||||
emitSessionMessage?: ArchivePaseoWorktreeDependencies["emit"];
|
||||
createPaseoWorktree?: CreatePaseoWorktreeFn;
|
||||
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
|
||||
paseoHome?: string;
|
||||
/**
|
||||
* ID of the agent that is connecting to this MCP server.
|
||||
@@ -625,8 +626,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
notifyOnFinish: boolean;
|
||||
resolvedCwd: string;
|
||||
resolvedMode: string | undefined;
|
||||
worktreeConfig: WorktreeConfig | undefined;
|
||||
shouldBootstrapWorktree: boolean | undefined;
|
||||
setupContinuation: AgentWorktreeSetupContinuation | undefined;
|
||||
}
|
||||
|
||||
const resolveCallerCreateAgentArgs = (
|
||||
@@ -661,8 +661,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
notifyOnFinish: callerArgs.notifyOnFinish ?? false,
|
||||
resolvedCwd,
|
||||
resolvedMode,
|
||||
worktreeConfig: undefined,
|
||||
shouldBootstrapWorktree: undefined,
|
||||
setupContinuation: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -673,11 +672,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider);
|
||||
const { cwd, mode, worktreeName, baseBranch, refName, action, githubPrNumber } = topLevelArgs;
|
||||
let resolvedCwd = expandUserPath(cwd);
|
||||
let worktreeConfig: WorktreeConfig | undefined;
|
||||
let shouldBootstrapWorktree: boolean | undefined;
|
||||
let setupContinuation: AgentWorktreeSetupContinuation | undefined;
|
||||
|
||||
if (worktreeName) {
|
||||
if (!baseBranch && !refName && !action && githubPrNumber === undefined) {
|
||||
const shouldCreateWorktree = Boolean(worktreeName || refName || action || githubPrNumber);
|
||||
if (shouldCreateWorktree) {
|
||||
if (worktreeName && !baseBranch && !refName && !action && githubPrNumber === undefined) {
|
||||
throw new Error("baseBranch is required when creating a worktree");
|
||||
}
|
||||
const createdWorktree = await createMcpWorktree({
|
||||
@@ -687,17 +686,32 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
refName,
|
||||
action,
|
||||
githubPrNumber,
|
||||
nameContext: topLevelArgs.initialPrompt,
|
||||
runSetup: false,
|
||||
paseoHome: options.paseoHome,
|
||||
},
|
||||
createPaseoWorktree: options.createPaseoWorktree,
|
||||
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,
|
||||
workspaceGitService: options.workspaceGitService,
|
||||
logger: options.logger,
|
||||
setupContinuation: {
|
||||
kind: "agent",
|
||||
terminalManager: terminalManager ?? null,
|
||||
appendTimelineItem: ({ agentId, item }) =>
|
||||
appendTimelineItemIfAgentKnown({
|
||||
agentManager,
|
||||
agentId,
|
||||
item,
|
||||
}),
|
||||
emitLiveTimelineItem: ({ agentId, item }) =>
|
||||
emitLiveTimelineItemIfAgentKnown({
|
||||
agentManager,
|
||||
agentId,
|
||||
item,
|
||||
}),
|
||||
logger: childLogger,
|
||||
},
|
||||
});
|
||||
resolvedCwd = createdWorktree.worktree.worktreePath;
|
||||
worktreeConfig = createdWorktree.worktree;
|
||||
shouldBootstrapWorktree = createdWorktree.created;
|
||||
setupContinuation = createdWorktree.setupContinuation;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -711,8 +725,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
notifyOnFinish: topLevelArgs.notifyOnFinish ?? false,
|
||||
resolvedCwd,
|
||||
resolvedMode: mode,
|
||||
worktreeConfig,
|
||||
shouldBootstrapWorktree,
|
||||
setupContinuation,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -755,8 +768,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
notifyOnFinish,
|
||||
resolvedCwd,
|
||||
resolvedMode,
|
||||
worktreeConfig,
|
||||
shouldBootstrapWorktree,
|
||||
setupContinuation,
|
||||
} = resolved;
|
||||
|
||||
const childAgentDefaultLabels = callerContext?.childAgentDefaultLabels;
|
||||
@@ -778,27 +790,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
Object.keys(mergedLabels).length > 0 ? { labels: mergedLabels } : undefined,
|
||||
);
|
||||
|
||||
if (worktreeConfig) {
|
||||
void runAsyncWorktreeBootstrap({
|
||||
agentId: snapshot.id,
|
||||
worktree: worktreeConfig,
|
||||
shouldBootstrap: shouldBootstrapWorktree,
|
||||
terminalManager: terminalManager ?? null,
|
||||
appendTimelineItem: (item) =>
|
||||
appendTimelineItemIfAgentKnown({
|
||||
agentManager,
|
||||
agentId: snapshot.id,
|
||||
item,
|
||||
}),
|
||||
emitLiveTimelineItem: (item) =>
|
||||
emitLiveTimelineItemIfAgentKnown({
|
||||
agentManager,
|
||||
agentId: snapshot.id,
|
||||
item,
|
||||
}),
|
||||
logger: childLogger,
|
||||
});
|
||||
}
|
||||
setupContinuation?.startAfterAgentCreate({
|
||||
agentId: snapshot.id,
|
||||
});
|
||||
|
||||
const trimmedPrompt = initialPrompt.trim();
|
||||
scheduleAgentMetadataGeneration({
|
||||
@@ -809,11 +803,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
explicitTitle: snapshot.config.title,
|
||||
paseoHome: options.paseoHome,
|
||||
logger: childLogger,
|
||||
deps: options.workspaceGitService
|
||||
? {
|
||||
workspaceGitService: options.workspaceGitService,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -1779,6 +1768,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
.optional()
|
||||
.describe("Optional repository cwd. Defaults to the caller agent cwd."),
|
||||
branchName: z.string().optional(),
|
||||
nameContext: z.string().optional(),
|
||||
baseBranch: z.string().optional(),
|
||||
refName: z.string().min(1).optional(),
|
||||
action: z.enum(["branch-off", "checkout"]).optional(),
|
||||
@@ -1789,15 +1779,18 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
worktreePath: z.string(),
|
||||
},
|
||||
},
|
||||
async ({ cwd, branchName, baseBranch, refName, action, githubPrNumber }) => {
|
||||
if (!branchName && !refName && githubPrNumber === undefined) {
|
||||
throw new Error("create_worktree requires branchName, refName, or githubPrNumber");
|
||||
async ({ cwd, branchName, nameContext, baseBranch, refName, action, githubPrNumber }) => {
|
||||
if (!branchName && !nameContext && !refName && githubPrNumber === undefined) {
|
||||
throw new Error(
|
||||
"create_worktree requires branchName, nameContext, refName, or githubPrNumber",
|
||||
);
|
||||
}
|
||||
const repoRoot = resolveScopedCwd(cwd, { required: true });
|
||||
const createdWorktree = await createMcpWorktree({
|
||||
input: {
|
||||
cwd: repoRoot,
|
||||
worktreeSlug: branchName,
|
||||
nameContext,
|
||||
refName,
|
||||
action,
|
||||
githubPrNumber,
|
||||
@@ -1806,8 +1799,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
createPaseoWorktree: options.createPaseoWorktree,
|
||||
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,
|
||||
workspaceGitService: options.workspaceGitService,
|
||||
logger: options.logger,
|
||||
});
|
||||
const { worktree } = createdWorktree;
|
||||
|
||||
@@ -2050,46 +2041,24 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
|
||||
interface CreateMcpWorktreeOptions {
|
||||
input: CreatePaseoWorktreeInput;
|
||||
createPaseoWorktree: CreatePaseoWorktreeFn | undefined;
|
||||
createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined;
|
||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot">;
|
||||
logger: Logger;
|
||||
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
|
||||
}
|
||||
|
||||
async function createMcpWorktree(
|
||||
options: CreateMcpWorktreeOptions,
|
||||
): Promise<CreatePaseoWorktreeResult> {
|
||||
): Promise<CreatePaseoWorktreeWorkflowResult> {
|
||||
try {
|
||||
if (!options.createPaseoWorktree) {
|
||||
throw new Error("Paseo worktree service is not configured");
|
||||
}
|
||||
const result = await options.createPaseoWorktree(options.input, {
|
||||
resolveDefaultBranch: options.resolveDefaultBranch,
|
||||
...(options.resolveDefaultBranch
|
||||
? { resolveDefaultBranch: options.resolveDefaultBranch }
|
||||
: {}),
|
||||
...(options.setupContinuation ? { setupContinuation: options.setupContinuation } : {}),
|
||||
});
|
||||
if (options.workspaceGitService) {
|
||||
const refreshResults = await Promise.allSettled([
|
||||
options.workspaceGitService.getSnapshot(options.input.cwd, {
|
||||
force: true,
|
||||
reason: "create-worktree",
|
||||
}),
|
||||
options.workspaceGitService.getSnapshot(result.worktree.worktreePath, {
|
||||
force: true,
|
||||
reason: "create-worktree",
|
||||
}),
|
||||
]);
|
||||
for (const [index, refreshResult] of refreshResults.entries()) {
|
||||
if (refreshResult.status === "fulfilled") {
|
||||
continue;
|
||||
}
|
||||
options.logger.warn(
|
||||
{
|
||||
err: refreshResult.reason,
|
||||
cwd: index === 0 ? options.input.cwd : result.worktree.worktreePath,
|
||||
},
|
||||
"Failed to force-refresh workspace git snapshot after creating worktree",
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw toWorktreeRequestError(error);
|
||||
|
||||
@@ -88,7 +88,8 @@ function formatListenTarget(listenTarget: ListenTarget | null): string | null {
|
||||
|
||||
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
|
||||
import { createGitHubService } from "../services/github-service.js";
|
||||
import { createPaseoWorktree } from "./paseo-worktree-service.js";
|
||||
import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-worktree-service.js";
|
||||
import { createPaseoWorktreeWorkflow } from "./worktree-session.js";
|
||||
import { createWorktreeCoreDeps } from "./worktree-core.js";
|
||||
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
|
||||
@@ -562,24 +563,54 @@ export async function createPaseoDaemon(
|
||||
clearWorkspaceArchiving: clearWorkspaceArchivingForMcpArchive,
|
||||
emitSessionMessage: emitMcpArchiveSessionMessage,
|
||||
createPaseoWorktree: async (input, serviceOptions) => {
|
||||
const coreDeps = createWorktreeCoreDeps(github);
|
||||
const result = await createPaseoWorktree(input, {
|
||||
...coreDeps,
|
||||
...(serviceOptions?.resolveDefaultBranch
|
||||
? {
|
||||
resolveDefaultBranch: serviceOptions.resolveDefaultBranch,
|
||||
}
|
||||
: {}),
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
});
|
||||
await Promise.all(
|
||||
wsServer
|
||||
?.listActiveSessions()
|
||||
.map((session) => session.warmWorkspaceGitDataForWorkspace(result.workspace)) ?? [],
|
||||
return createPaseoWorktreeWorkflow(
|
||||
{
|
||||
paseoHome: config.paseoHome,
|
||||
createPaseoWorktree: async (workflowInput, workflowOptions) => {
|
||||
const coreDeps = createWorktreeCoreDeps(github);
|
||||
return createRegisteredPaseoWorktree(workflowInput, {
|
||||
...coreDeps,
|
||||
...(workflowOptions?.resolveDefaultBranch
|
||||
? {
|
||||
resolveDefaultBranch: workflowOptions.resolveDefaultBranch,
|
||||
}
|
||||
: {}),
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
});
|
||||
},
|
||||
warmWorkspaceGitData: async (workspace) => {
|
||||
await Promise.all(
|
||||
wsServer
|
||||
?.listActiveSessions()
|
||||
.map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [],
|
||||
);
|
||||
},
|
||||
emitWorkspaceUpdateForCwd: async (cwd, emitOptions) => {
|
||||
await Promise.all(
|
||||
wsServer
|
||||
?.listActiveSessions()
|
||||
.map((session) => session.emitWorkspaceUpdatesForExternalCwds([cwd])) ?? [],
|
||||
);
|
||||
void emitOptions;
|
||||
},
|
||||
cacheWorkspaceSetupSnapshot: () => {},
|
||||
emit: emitMcpArchiveSessionMessage,
|
||||
sessionLogger: logger,
|
||||
terminalManager,
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordForMcp,
|
||||
scriptRouteStore,
|
||||
scriptRuntimeStore,
|
||||
getDaemonTcpPort: () =>
|
||||
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
|
||||
getDaemonTcpHost: () =>
|
||||
boundListenTarget?.type === "tcp" ? boundListenTarget.host : null,
|
||||
onScriptsChanged: null,
|
||||
},
|
||||
input,
|
||||
serviceOptions,
|
||||
);
|
||||
return result;
|
||||
},
|
||||
paseoHome: config.paseoHome,
|
||||
callerAgentId,
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import type { GitHubService } from "../services/github-service.js";
|
||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
import { createPaseoWorktree, type CreatePaseoWorktreeDeps } from "./paseo-worktree-service.js";
|
||||
import {
|
||||
attemptFirstAgentBranchAutoName,
|
||||
createPaseoWorktree,
|
||||
type CreatePaseoWorktreeDeps,
|
||||
} from "./paseo-worktree-service.js";
|
||||
import { createWorktreeCoreDeps } from "./worktree-core.js";
|
||||
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
|
||||
|
||||
const cleanupPaths: string[] = [];
|
||||
|
||||
@@ -98,6 +102,150 @@ test("reuses an existing worktree and still upserts the workspace", async () =>
|
||||
expect(events).toContain(`workspace:${second.workspace.workspaceId}`);
|
||||
});
|
||||
|
||||
test("renames an eligible unnamed branch-off worktree once on first agent context", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const deps = createDeps({
|
||||
generateBranchName: (seed) => (seed ? "renamed-from-agent-context" : "unnamed-placeholder"),
|
||||
});
|
||||
|
||||
const created = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
runSetup: false,
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(created.worktree.branchName).toBe("unnamed-placeholder");
|
||||
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "pending",
|
||||
placeholderBranchName: "unnamed-placeholder",
|
||||
},
|
||||
});
|
||||
|
||||
const first = await attemptFirstAgentBranchAutoName({
|
||||
cwd: created.worktree.worktreePath,
|
||||
nameContext: "Build the agent context name",
|
||||
generateBranchName: deps.generateBranchName,
|
||||
});
|
||||
const branchAfterFirst = execSync("git branch --show-current", {
|
||||
cwd: created.worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
expect(first).toEqual({
|
||||
attempted: true,
|
||||
renamed: true,
|
||||
branchName: "renamed-from-agent-context",
|
||||
});
|
||||
expect(branchAfterFirst).toBe("renamed-from-agent-context");
|
||||
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "attempted",
|
||||
placeholderBranchName: "unnamed-placeholder",
|
||||
},
|
||||
});
|
||||
|
||||
const second = await attemptFirstAgentBranchAutoName({
|
||||
cwd: created.worktree.worktreePath,
|
||||
nameContext: "Try another name",
|
||||
generateBranchName: () => "second-agent-name",
|
||||
});
|
||||
const branchAfterSecond = execSync("git branch --show-current", {
|
||||
cwd: created.worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
expect(second).toEqual({ attempted: false, renamed: false, branchName: null });
|
||||
expect(branchAfterSecond).toBe("renamed-from-agent-context");
|
||||
});
|
||||
|
||||
test("does not mark checkout branch worktrees as eligible for first-agent rename", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
execSync("git checkout -b dev", { cwd: repoDir, stdio: "pipe" });
|
||||
writeFileSync(path.join(repoDir, "README.md"), "dev branch\n");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m dev", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
const created = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
action: "checkout",
|
||||
refName: "dev",
|
||||
runSetup: false,
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
createDeps(),
|
||||
);
|
||||
|
||||
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
version: 1,
|
||||
baseRefName: "dev",
|
||||
});
|
||||
await expect(
|
||||
attemptFirstAgentBranchAutoName({
|
||||
cwd: created.worktree.worktreePath,
|
||||
nameContext: "Rename checkout branch",
|
||||
generateBranchName: () => "must-not-rename",
|
||||
}),
|
||||
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
|
||||
expect(
|
||||
execSync("git branch --show-current", {
|
||||
cwd: created.worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim(),
|
||||
).toBe("dev");
|
||||
});
|
||||
|
||||
test("does not mark GitHub PR checkout worktrees as eligible for first-agent rename", async () => {
|
||||
const { repoDir, tempDir } = createGitHubPrRemoteRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
const created = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
action: "checkout",
|
||||
githubPrNumber: 123,
|
||||
runSetup: false,
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
createDeps(),
|
||||
);
|
||||
|
||||
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
version: 1,
|
||||
baseRefName: "main",
|
||||
});
|
||||
await expect(
|
||||
attemptFirstAgentBranchAutoName({
|
||||
cwd: created.worktree.worktreePath,
|
||||
nameContext: "Rename PR checkout",
|
||||
generateBranchName: () => "must-not-rename",
|
||||
}),
|
||||
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
|
||||
expect(
|
||||
execSync("git branch --show-current", {
|
||||
cwd: created.worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim(),
|
||||
).toBe("pr-123");
|
||||
});
|
||||
|
||||
test("does not mutate registries or broadcast when core worktree creation fails", async () => {
|
||||
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-"));
|
||||
cleanupPaths.push(tempDir);
|
||||
@@ -119,21 +267,6 @@ test("does not mutate registries or broadcast when core worktree creation fails"
|
||||
expect(deps.workspaces.size).toBe(0);
|
||||
});
|
||||
|
||||
test("keeps direct core worktree creation calls behind the service boundary", () => {
|
||||
// Keep this literal in the test file so the grep invariant sees createWorktreeCore( here.
|
||||
const serverSrc = path.dirname(fileURLToPath(import.meta.url));
|
||||
const matches = listTypeScriptFiles(serverSrc).flatMap((filePath) => {
|
||||
if (path.basename(filePath) === "worktree-core.ts") {
|
||||
return [];
|
||||
}
|
||||
const contents = readFileSync(filePath, "utf8");
|
||||
const pattern = new RegExp(["createWorktreeCore", "\\("].join(""), "g");
|
||||
return Array.from(contents.matchAll(pattern), () => path.relative(serverSrc, filePath));
|
||||
});
|
||||
|
||||
expect(matches).toEqual(["paseo-worktree-service.test.ts", "paseo-worktree-service.ts"]);
|
||||
});
|
||||
|
||||
interface TestDeps extends CreatePaseoWorktreeDeps {
|
||||
projects: Map<string, PersistedProjectRecord>;
|
||||
workspaces: Map<string, PersistedWorkspaceRecord>;
|
||||
@@ -143,6 +276,7 @@ function createDeps(options?: {
|
||||
events?: string[];
|
||||
projects?: Map<string, PersistedProjectRecord>;
|
||||
workspaces?: Map<string, PersistedWorkspaceRecord>;
|
||||
generateBranchName?: (seed: string | undefined) => string;
|
||||
}): TestDeps {
|
||||
const events = options?.events ?? [];
|
||||
const projects = options?.projects ?? new Map<string, PersistedProjectRecord>();
|
||||
@@ -150,6 +284,7 @@ function createDeps(options?: {
|
||||
|
||||
return {
|
||||
...createWorktreeCoreDeps(createGitHubServiceStub()),
|
||||
...(options?.generateBranchName ? { generateBranchName: options.generateBranchName } : {}),
|
||||
projects,
|
||||
workspaces,
|
||||
projectRegistry: {
|
||||
@@ -310,14 +445,24 @@ function createGitRepo(): { tempDir: string; repoDir: string } {
|
||||
return { tempDir, repoDir };
|
||||
}
|
||||
|
||||
function listTypeScriptFiles(directory: string): string[] {
|
||||
const entries = readdirSync(directory);
|
||||
return entries.flatMap((entry) => {
|
||||
const fullPath = path.join(directory, entry);
|
||||
const stats = statSync(fullPath);
|
||||
if (stats.isDirectory()) {
|
||||
return listTypeScriptFiles(fullPath);
|
||||
}
|
||||
return fullPath.endsWith(".ts") ? [fullPath] : [];
|
||||
function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string } {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
execSync("git checkout -b pr-123", { cwd: repoDir, stdio: "pipe" });
|
||||
writeFileSync(path.join(repoDir, "README.md"), "pr branch\n");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m pr-branch", { cwd: repoDir, stdio: "pipe" });
|
||||
const prHead = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" }).toString().trim();
|
||||
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git branch -D pr-123", { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
const remoteDir = path.join(tempDir, "remote.git");
|
||||
execSync(`git clone --bare ${JSON.stringify(repoDir)} ${JSON.stringify(remoteDir)}`, {
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync(`git --git-dir=${JSON.stringify(remoteDir)} update-ref refs/pull/123/head ${prHead}`, {
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync(`git remote add origin ${JSON.stringify(remoteDir)}`, { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git fetch origin", { cwd: repoDir, stdio: "pipe" });
|
||||
return { tempDir, repoDir };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { basename } from "node:path";
|
||||
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import {
|
||||
type PersistedWorkspaceRecord,
|
||||
@@ -13,6 +15,13 @@ import {
|
||||
type CreateWorktreeCoreInput,
|
||||
} from "./worktree-core.js";
|
||||
import type { WorktreeConfig } from "../utils/worktree.js";
|
||||
import { validateBranchSlug } from "../utils/worktree.js";
|
||||
import { renameCurrentBranch } from "../utils/checkout-git.js";
|
||||
import {
|
||||
markPaseoWorktreeFirstAgentBranchAutoNameAttempted,
|
||||
readPaseoWorktreeMetadata,
|
||||
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
|
||||
} from "../utils/worktree-metadata.js";
|
||||
import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent.js";
|
||||
|
||||
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {}
|
||||
@@ -32,6 +41,12 @@ export type CreatePaseoWorktreeFn = (
|
||||
},
|
||||
) => Promise<CreatePaseoWorktreeResult>;
|
||||
|
||||
export interface AttemptFirstAgentBranchAutoNameResult {
|
||||
attempted: boolean;
|
||||
renamed: boolean;
|
||||
branchName: string | null;
|
||||
}
|
||||
|
||||
export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps {
|
||||
projectRegistry: Pick<ProjectRegistry, "get" | "upsert">;
|
||||
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "list" | "upsert">;
|
||||
@@ -43,17 +58,23 @@ export async function createPaseoWorktree(
|
||||
deps: CreatePaseoWorktreeDeps,
|
||||
): Promise<CreatePaseoWorktreeResult> {
|
||||
const createdWorktree = await createWorktreeCore(input, deps);
|
||||
maybeMarkFirstAgentBranchAutoNameEligible({ input, createdWorktree });
|
||||
const worktree = await maybeAutoNameCreatedWorktree({
|
||||
input,
|
||||
createdWorktree,
|
||||
deps,
|
||||
});
|
||||
const workspace = await upsertWorkspaceForWorktree({
|
||||
inputCwd: input.cwd,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
worktree: createdWorktree.worktree,
|
||||
worktree,
|
||||
deps,
|
||||
});
|
||||
|
||||
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });
|
||||
deps.github.invalidate({ cwd: worktree.worktreePath });
|
||||
|
||||
return {
|
||||
worktree: createdWorktree.worktree,
|
||||
worktree,
|
||||
intent: createdWorktree.intent,
|
||||
workspace,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
@@ -61,6 +82,108 @@ export async function createPaseoWorktree(
|
||||
};
|
||||
}
|
||||
|
||||
export async function attemptFirstAgentBranchAutoName(options: {
|
||||
cwd: string;
|
||||
nameContext?: string;
|
||||
generateBranchName: (seed: string | undefined) => string;
|
||||
renameCurrentBranch?: typeof renameCurrentBranch;
|
||||
}): Promise<AttemptFirstAgentBranchAutoNameResult> {
|
||||
const nameContext = options.nameContext?.trim();
|
||||
if (!nameContext) {
|
||||
return { attempted: false, renamed: false, branchName: null };
|
||||
}
|
||||
|
||||
let metadata: ReturnType<typeof readPaseoWorktreeMetadata>;
|
||||
try {
|
||||
metadata = readPaseoWorktreeMetadata(options.cwd);
|
||||
} catch {
|
||||
return { attempted: false, renamed: false, branchName: null };
|
||||
}
|
||||
if (
|
||||
!metadata ||
|
||||
metadata.version !== 2 ||
|
||||
metadata.firstAgentBranchAutoName?.status !== "pending"
|
||||
) {
|
||||
return { attempted: false, renamed: false, branchName: null };
|
||||
}
|
||||
|
||||
markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
|
||||
|
||||
const branchName = options.generateBranchName(nameContext);
|
||||
const validation = validateBranchSlug(branchName);
|
||||
if (!validation.valid || branchName === metadata.firstAgentBranchAutoName.placeholderBranchName) {
|
||||
return { attempted: true, renamed: false, branchName: null };
|
||||
}
|
||||
|
||||
const renameCurrentBranchImpl = options.renameCurrentBranch ?? renameCurrentBranch;
|
||||
const renamedBranch = await renameCurrentBranchImpl(options.cwd, branchName);
|
||||
return {
|
||||
attempted: true,
|
||||
renamed: true,
|
||||
branchName: renamedBranch.currentBranch ?? branchName,
|
||||
};
|
||||
}
|
||||
|
||||
function maybeMarkFirstAgentBranchAutoNameEligible(options: {
|
||||
input: CreatePaseoWorktreeInput;
|
||||
createdWorktree: Awaited<ReturnType<typeof createWorktreeCore>>;
|
||||
}): void {
|
||||
const { input, createdWorktree } = options;
|
||||
if (
|
||||
!createdWorktree.created ||
|
||||
input.worktreeSlug ||
|
||||
createdWorktree.intent.kind !== "branch-off"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
writePaseoWorktreeFirstAgentBranchAutoNameMetadata(createdWorktree.worktree.worktreePath, {
|
||||
placeholderBranchName: createdWorktree.worktree.branchName,
|
||||
});
|
||||
}
|
||||
|
||||
async function maybeAutoNameCreatedWorktree(options: {
|
||||
input: CreatePaseoWorktreeInput;
|
||||
createdWorktree: Awaited<ReturnType<typeof createWorktreeCore>>;
|
||||
deps: Pick<CreatePaseoWorktreeDeps, "generateBranchName">;
|
||||
}): Promise<WorktreeConfig> {
|
||||
const { input, createdWorktree, deps } = options;
|
||||
const nameContext = input.nameContext?.trim();
|
||||
if (
|
||||
!nameContext ||
|
||||
input.worktreeSlug ||
|
||||
!createdWorktree.created ||
|
||||
createdWorktree.intent.kind !== "branch-off"
|
||||
) {
|
||||
return createdWorktree.worktree;
|
||||
}
|
||||
|
||||
const generatedPlaceholderName = basename(createdWorktree.worktree.worktreePath);
|
||||
if (
|
||||
!generatedPlaceholderName ||
|
||||
createdWorktree.worktree.branchName !== generatedPlaceholderName
|
||||
) {
|
||||
return createdWorktree.worktree;
|
||||
}
|
||||
|
||||
markPaseoWorktreeFirstAgentBranchAutoNameAttempted(createdWorktree.worktree.worktreePath);
|
||||
|
||||
const branchName = deps.generateBranchName(nameContext);
|
||||
const validation = validateBranchSlug(branchName);
|
||||
if (!validation.valid || branchName === createdWorktree.worktree.branchName) {
|
||||
return createdWorktree.worktree;
|
||||
}
|
||||
|
||||
const renamedBranch = await renameCurrentBranch(
|
||||
createdWorktree.worktree.worktreePath,
|
||||
branchName,
|
||||
);
|
||||
return {
|
||||
...createdWorktree.worktree,
|
||||
branchName: renamedBranch.currentBranch ?? branchName,
|
||||
};
|
||||
}
|
||||
|
||||
async function upsertWorkspaceForWorktree(options: {
|
||||
inputCwd: string;
|
||||
repoRoot: string;
|
||||
|
||||
@@ -165,13 +165,11 @@ import {
|
||||
} from "./file-explorer/service.js";
|
||||
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import { PushTokenStore } from "./push/token-store.js";
|
||||
import { type WorktreeConfig } from "../utils/worktree.js";
|
||||
import {
|
||||
readPaseoConfigForEdit,
|
||||
writePaseoConfigForEdit,
|
||||
type ProjectConfigRpcError,
|
||||
} from "../utils/paseo-config-file.js";
|
||||
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
|
||||
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
|
||||
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
||||
import type { ScriptRouteStore } from "./script-proxy.js";
|
||||
@@ -206,6 +204,7 @@ import {
|
||||
type PullRequestTimelineItem,
|
||||
} from "../services/github-service.js";
|
||||
import {
|
||||
attemptFirstAgentBranchAutoName,
|
||||
createPaseoWorktree,
|
||||
type CreatePaseoWorktreeInput,
|
||||
type CreatePaseoWorktreeResult,
|
||||
@@ -214,7 +213,10 @@ import { createWorktreeCoreDeps } from "./worktree-core.js";
|
||||
import {
|
||||
assertSafeGitRef as assertWorktreeSafeGitRef,
|
||||
buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
|
||||
runWorktreeSetupInBackground as runWorktreeSetupInBackgroundSession,
|
||||
buildAgentWorktreeNameContext,
|
||||
createPaseoWorktreeWorkflow as createWorktreeWorkflow,
|
||||
type CreatePaseoWorktreeSetupContinuationInput,
|
||||
type CreatePaseoWorktreeWorkflowResult,
|
||||
handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest,
|
||||
handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest,
|
||||
handlePaseoWorktreeListRequest as handleWorktreeListRequest,
|
||||
@@ -274,6 +276,7 @@ type GitMutationRefreshReason =
|
||||
| "create-pr"
|
||||
| "switch-branch"
|
||||
| "create-branch"
|
||||
| "rename-branch"
|
||||
| "stash-push"
|
||||
| "stash-pop"
|
||||
| "create-worktree";
|
||||
@@ -3105,19 +3108,28 @@ export class Session {
|
||||
...(provisionalTitle ? { title: provisionalTitle } : {}),
|
||||
};
|
||||
|
||||
const { sessionConfig, worktreeBootstrap } = await this.buildAgentSessionConfig(
|
||||
const agentNameContext = buildAgentWorktreeNameContext({
|
||||
initialPrompt: trimmedPrompt,
|
||||
attachments,
|
||||
});
|
||||
const { sessionConfig, setupContinuation } = await this.buildAgentSessionConfig(
|
||||
resolvedConfig,
|
||||
git,
|
||||
worktreeName,
|
||||
attachments,
|
||||
agentNameContext,
|
||||
);
|
||||
const resolvedWorkspace = msg.workspaceId
|
||||
let resolvedWorkspace = msg.workspaceId
|
||||
? await this.workspaceRegistry.get(msg.workspaceId)
|
||||
: ((await this.findWorkspaceByDirectory(sessionConfig.cwd)) ??
|
||||
(await this.findOrCreateWorkspaceForDirectory(sessionConfig.cwd)));
|
||||
if (!resolvedWorkspace) {
|
||||
throw new Error(`Workspace not found: ${msg.workspaceId}`);
|
||||
}
|
||||
resolvedWorkspace = await this.maybeAutoNameWorkspaceBranchForFirstAgent({
|
||||
workspace: resolvedWorkspace,
|
||||
nameContext: agentNameContext,
|
||||
});
|
||||
const snapshot = await this.agentManager.createAgent(
|
||||
{
|
||||
...sessionConfig,
|
||||
@@ -3155,27 +3167,9 @@ export class Session {
|
||||
});
|
||||
}
|
||||
|
||||
if (worktreeBootstrap) {
|
||||
void runAsyncWorktreeBootstrap({
|
||||
agentId: snapshot.id,
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: this.terminalManager,
|
||||
appendTimelineItem: (item) =>
|
||||
appendTimelineItemIfAgentKnown({
|
||||
agentManager: this.agentManager,
|
||||
agentId: snapshot.id,
|
||||
item,
|
||||
}),
|
||||
emitLiveTimelineItem: (item) =>
|
||||
emitLiveTimelineItemIfAgentKnown({
|
||||
agentManager: this.agentManager,
|
||||
agentId: snapshot.id,
|
||||
item,
|
||||
}),
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
}
|
||||
setupContinuation?.startAfterAgentCreate({
|
||||
agentId: snapshot.id,
|
||||
});
|
||||
|
||||
this.sessionLogger.info(
|
||||
{ agentId: snapshot.id, provider: snapshot.provider },
|
||||
@@ -3231,9 +3225,6 @@ export class Session {
|
||||
explicitTitle: params.explicitTitle,
|
||||
paseoHome: this.paseoHome,
|
||||
logger: this.sessionLogger,
|
||||
deps: {
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
},
|
||||
});
|
||||
|
||||
const started = await this.handleSendAgentMessage(
|
||||
@@ -3460,9 +3451,10 @@ export class Session {
|
||||
gitOptions?: GitSetupOptions,
|
||||
legacyWorktreeName?: string,
|
||||
attachments?: AgentAttachment[],
|
||||
nameContext?: string,
|
||||
): Promise<{
|
||||
sessionConfig: AgentSessionConfig;
|
||||
worktreeBootstrap?: { worktree: WorktreeConfig; shouldBootstrap: boolean };
|
||||
setupContinuation?: CreatePaseoWorktreeWorkflowResult["setupContinuation"];
|
||||
}> {
|
||||
return buildWorktreeAgentSessionConfig(
|
||||
{
|
||||
@@ -3470,7 +3462,26 @@ export class Session {
|
||||
sessionLogger: this.sessionLogger,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
createPaseoWorktree: (input, serviceOptions) =>
|
||||
this.createPaseoWorktree(input, serviceOptions),
|
||||
this.createPaseoWorktreeWorkflow(input, {
|
||||
...serviceOptions,
|
||||
setupContinuation: {
|
||||
kind: "agent",
|
||||
terminalManager: this.terminalManager,
|
||||
appendTimelineItem: ({ agentId, item }) =>
|
||||
appendTimelineItemIfAgentKnown({
|
||||
agentManager: this.agentManager,
|
||||
agentId,
|
||||
item,
|
||||
}),
|
||||
emitLiveTimelineItem: ({ agentId, item }) =>
|
||||
emitLiveTimelineItemIfAgentKnown({
|
||||
agentManager: this.agentManager,
|
||||
agentId,
|
||||
item,
|
||||
}),
|
||||
logger: this.sessionLogger,
|
||||
},
|
||||
}),
|
||||
checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch),
|
||||
createBranchFromBase: (params) => this.createBranchFromBase(params),
|
||||
github: this.github,
|
||||
@@ -3479,9 +3490,35 @@ export class Session {
|
||||
gitOptions,
|
||||
legacyWorktreeName,
|
||||
attachments,
|
||||
nameContext,
|
||||
);
|
||||
}
|
||||
|
||||
private async maybeAutoNameWorkspaceBranchForFirstAgent(input: {
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
nameContext?: string;
|
||||
}): Promise<PersistedWorkspaceRecord> {
|
||||
const coreDeps = createWorktreeCoreDeps(this.github);
|
||||
const result = await attemptFirstAgentBranchAutoName({
|
||||
cwd: input.workspace.cwd,
|
||||
nameContext: input.nameContext,
|
||||
generateBranchName: coreDeps.generateBranchName,
|
||||
});
|
||||
if (!result.renamed || !result.branchName) {
|
||||
return input.workspace;
|
||||
}
|
||||
|
||||
const updatedWorkspace: PersistedWorkspaceRecord = {
|
||||
...input.workspace,
|
||||
displayName: result.branchName,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await this.workspaceRegistry.upsert(updatedWorkspace);
|
||||
await this.notifyGitMutation(input.workspace.cwd, "rename-branch");
|
||||
await this.emitWorkspaceUpdateForCwd(input.workspace.cwd);
|
||||
return updatedWorkspace;
|
||||
}
|
||||
|
||||
private emitProviderDisabledResponse(
|
||||
kind: "models" | "modes",
|
||||
provider: AgentProvider,
|
||||
@@ -7447,27 +7484,26 @@ export class Session {
|
||||
paseoHome: this.paseoHome,
|
||||
describeWorkspaceRecord: (result) => this.describeCreatedWorktreeWorkspace(result),
|
||||
emit: (message) => this.emit(message),
|
||||
createPaseoWorktree: (input) => this.createPaseoWorktree(input),
|
||||
warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),
|
||||
sessionLogger: this.sessionLogger,
|
||||
runWorktreeSetupInBackground: (options) => this.runWorktreeSetupInBackground(options),
|
||||
createPaseoWorktreeWorkflow: (input) => this.createPaseoWorktreeWorkflow(input),
|
||||
},
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
private async runWorktreeSetupInBackground(options: {
|
||||
requestCwd: string;
|
||||
repoRoot: string;
|
||||
workspaceId: string;
|
||||
worktree: { branchName: string; worktreePath: string };
|
||||
shouldBootstrap: boolean;
|
||||
slug: string;
|
||||
worktreePath: string;
|
||||
}): Promise<void> {
|
||||
return runWorktreeSetupInBackgroundSession(
|
||||
private async createPaseoWorktreeWorkflow(
|
||||
input: CreatePaseoWorktreeInput,
|
||||
options?: {
|
||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
|
||||
},
|
||||
): Promise<CreatePaseoWorktreeWorkflowResult> {
|
||||
return createWorktreeWorkflow(
|
||||
{
|
||||
paseoHome: this.paseoHome,
|
||||
createPaseoWorktree: (workflowInput, serviceOptions) =>
|
||||
this.createPaseoWorktree(workflowInput, serviceOptions),
|
||||
warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),
|
||||
emitWorkspaceUpdateForCwd: (cwd, emitOptions) =>
|
||||
this.emitWorkspaceUpdateForCwd(cwd, emitOptions),
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => {
|
||||
@@ -7485,6 +7521,7 @@ export class Session {
|
||||
this.emitWorkspaceScriptStatusUpdate(workspaceId, workspaceDirectory);
|
||||
},
|
||||
},
|
||||
input,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { execSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, test, afterEach, vi } from "vitest";
|
||||
|
||||
import type { GitHubService } from "../services/github-service.js";
|
||||
@@ -60,42 +58,6 @@ function createCoreDeps(options?: {
|
||||
};
|
||||
}
|
||||
|
||||
function findDirectCreateWorktreeCallSites(serverSrc: string): string[] {
|
||||
const matches: string[] = [];
|
||||
|
||||
function walk(directory: string) {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const absolutePath = path.join(directory, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
walk(absolutePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const relativePath = path
|
||||
.relative(serverSrc, absolutePath)
|
||||
.split(path.sep)
|
||||
.join(path.posix.sep);
|
||||
|
||||
if (relativePath === "utils/worktree.ts" || relativePath.endsWith(".test.ts")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keep this literal in the test file so the invariant proves tests are allowed to inspect createWorktree(.
|
||||
if (/createWorktree\(/.test(readFileSync(absolutePath, "utf8"))) {
|
||||
matches.push(relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(serverSrc);
|
||||
return matches.sort();
|
||||
}
|
||||
|
||||
function createGitRepo(): { tempDir: string; repoDir: string; paseoHome: string } {
|
||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "worktree-core-test-")));
|
||||
const repoDir = path.join(tempDir, "repo");
|
||||
@@ -651,11 +613,6 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => {
|
||||
});
|
||||
expect(result.worktree.branchName).toBe("feature/from-service");
|
||||
});
|
||||
|
||||
test("keeps direct createWorktree calls isolated to the core layer", () => {
|
||||
const serverSrc = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
expect(findDirectCreateWorktreeCallSites(serverSrc)).toEqual(["server/worktree-core.ts"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWorktreeRepoRoot", () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
|
||||
export interface CreateWorktreeCoreInput extends ResolveWorktreeCreationIntentInput {
|
||||
cwd: string;
|
||||
nameContext?: string;
|
||||
paseoHome?: string;
|
||||
runSetup?: boolean;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import type { SessionOutboundMessage, WorkspaceDescriptorPayload } from "./messa
|
||||
import { archivePaseoWorktree } from "./paseo-worktree-archive-service.js";
|
||||
import {
|
||||
buildAgentSessionConfig,
|
||||
buildAgentWorktreeNameContext,
|
||||
createPaseoWorktreeWorkflow,
|
||||
handlePaseoWorktreeArchiveRequest,
|
||||
handlePaseoWorktreeListRequest,
|
||||
resolveGitCreateBaseBranch,
|
||||
@@ -79,6 +81,53 @@ function createLogger(): Logger {
|
||||
return logger;
|
||||
}
|
||||
|
||||
function createWorkflowForRequestTest(options: {
|
||||
paseoHome: string;
|
||||
createPaseoWorktree?: CreatePaseoWorktreeFn;
|
||||
warmWorkspaceGitData?: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
||||
onSetupStarted?: (input: {
|
||||
requestCwd: string;
|
||||
repoRoot: string;
|
||||
workspaceId: string;
|
||||
worktree: WorktreeConfig;
|
||||
shouldBootstrap: boolean;
|
||||
}) => void;
|
||||
}) {
|
||||
return async (input: Parameters<CreatePaseoWorktreeFn>[0]) => {
|
||||
const createPaseoWorktree =
|
||||
options.createPaseoWorktree ?? createPaseoWorktreeForTest({ paseoHome: options.paseoHome });
|
||||
return createPaseoWorktreeWorkflow(
|
||||
{
|
||||
paseoHome: options.paseoHome,
|
||||
createPaseoWorktree,
|
||||
warmWorkspaceGitData: options.warmWorkspaceGitData ?? (async () => {}),
|
||||
emitWorkspaceUpdateForCwd: async () => {},
|
||||
cacheWorkspaceSetupSnapshot: () => {},
|
||||
emit: () => {},
|
||||
sessionLogger: createLogger(),
|
||||
terminalManager: null,
|
||||
archiveWorkspaceRecord: async () => {},
|
||||
scriptRouteStore: null,
|
||||
scriptRuntimeStore: null,
|
||||
getDaemonTcpPort: null,
|
||||
getDaemonTcpHost: null,
|
||||
onScriptsChanged: null,
|
||||
},
|
||||
input,
|
||||
{ setupContinuation: { kind: "workspace" } },
|
||||
).then((result) => {
|
||||
options.onSetupStarted?.({
|
||||
requestCwd: input.cwd,
|
||||
repoRoot: result.repoRoot,
|
||||
workspaceId: result.workspace.workspaceId,
|
||||
worktree: result.worktree,
|
||||
shouldBootstrap: result.created,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function createGitHubServiceStub(): GitHubService {
|
||||
return {
|
||||
listPullRequests: async () => [],
|
||||
@@ -347,6 +396,81 @@ describe("resolveGitCreateBaseBranch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("create-agent worktree setup boundary", () => {
|
||||
test("agent setup continuation starts setup for the created agent timeline", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const appendedItems: Array<{ name: string; status: string }> = [];
|
||||
const liveItems: Array<{ name: string; status: string }> = [];
|
||||
const workspaceSetupEvents: SessionOutboundMessage[] = [];
|
||||
|
||||
try {
|
||||
const result = await createPaseoWorktreeWorkflow(
|
||||
{
|
||||
paseoHome,
|
||||
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
|
||||
warmWorkspaceGitData: async () => {},
|
||||
emitWorkspaceUpdateForCwd: async () => {},
|
||||
cacheWorkspaceSetupSnapshot: () => {},
|
||||
emit: (message) => workspaceSetupEvents.push(message),
|
||||
sessionLogger: createLogger(),
|
||||
terminalManager: null,
|
||||
archiveWorkspaceRecord: async () => {},
|
||||
scriptRouteStore: null,
|
||||
scriptRuntimeStore: null,
|
||||
getDaemonTcpPort: null,
|
||||
getDaemonTcpHost: null,
|
||||
onScriptsChanged: null,
|
||||
},
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "agent-setup-after-create",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
},
|
||||
{
|
||||
setupContinuation: {
|
||||
kind: "agent",
|
||||
terminalManager: createTerminalManagerStub().manager,
|
||||
appendTimelineItem: async ({ agentId, item }) => {
|
||||
expect(agentId).toBe("agent-after-create");
|
||||
if (item.type !== "tool_call") {
|
||||
throw new Error(`Expected tool call timeline item, got ${item.type}`);
|
||||
}
|
||||
appendedItems.push({ name: item.name, status: item.status });
|
||||
return true;
|
||||
},
|
||||
emitLiveTimelineItem: async ({ agentId, item }) => {
|
||||
expect(agentId).toBe("agent-after-create");
|
||||
if (item.type !== "tool_call") {
|
||||
throw new Error(`Expected tool call timeline item, got ${item.type}`);
|
||||
}
|
||||
liveItems.push({ name: item.name, status: item.status });
|
||||
return true;
|
||||
},
|
||||
logger: createLogger(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.setupContinuation?.kind).toBe("agent");
|
||||
expect(workspaceSetupEvents).toEqual([]);
|
||||
|
||||
result.setupContinuation?.startAfterAgentCreate({ agentId: "agent-after-create" });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(appendedItems).toContainEqual({
|
||||
name: "paseo_worktree_setup",
|
||||
status: "completed",
|
||||
});
|
||||
});
|
||||
expect(liveItems).toEqual([]);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function createAgentStorageStub(): Pick<AgentStorage, "list" | "remove"> {
|
||||
return {
|
||||
list: async (): Promise<StoredAgentRecord[]> => [],
|
||||
@@ -1048,10 +1172,8 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
describeWorkspaceRecord: async (result) =>
|
||||
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
|
||||
emit: (message) => emitted.push(message),
|
||||
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
|
||||
warmWorkspaceGitData: async () => {},
|
||||
sessionLogger: logger,
|
||||
runWorktreeSetupInBackground: async () => {},
|
||||
createPaseoWorktreeWorkflow: createWorkflowForRequestTest({ paseoHome }),
|
||||
},
|
||||
{
|
||||
type: "create_paseo_worktree_request",
|
||||
@@ -1139,8 +1261,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
],
|
||||
);
|
||||
|
||||
expect(result.worktreeBootstrap?.worktree.branchName).toBe("feature/review-pr");
|
||||
expect(result.worktreeBootstrap?.worktree.worktreePath).toContain("agent-review-pr-123");
|
||||
expect(result.sessionConfig.cwd).toContain("agent-review-pr-123");
|
||||
expect(events.some((event) => event.startsWith("workspace:"))).toBe(true);
|
||||
|
||||
const branch = execSync("git branch --show-current", {
|
||||
@@ -1184,8 +1305,93 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.worktreeBootstrap?.worktree.branchName).toBe("feature-x");
|
||||
expect(path.basename(result.worktreeBootstrap?.worktree.worktreePath ?? "")).toBe("feature-x");
|
||||
expect(path.basename(result.sessionConfig.cwd)).toBe("feature-x");
|
||||
});
|
||||
|
||||
test("buildAgentSessionConfig passes prompt and attachment context into worktree creation", async () => {
|
||||
const createPaseoWorktree = vi.fn(async () => ({
|
||||
worktree: {
|
||||
branchName: "fix-attached-pr-context",
|
||||
worktreePath: "/tmp/worktrees/fix-attached-pr-context",
|
||||
},
|
||||
intent: {
|
||||
kind: "branch-off" as const,
|
||||
baseBranch: "main",
|
||||
newBranchName: "fix-attached-pr-context",
|
||||
},
|
||||
workspace: {
|
||||
workspaceId: "/tmp/worktrees/fix-attached-pr-context",
|
||||
projectId: "/tmp/repo",
|
||||
cwd: "/tmp/worktrees/fix-attached-pr-context",
|
||||
kind: "worktree" as const,
|
||||
displayName: "fix-attached-pr-context",
|
||||
createdAt: "2026-04-30T00:00:00.000Z",
|
||||
updatedAt: "2026-04-30T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
},
|
||||
repoRoot: "/tmp/repo",
|
||||
created: true,
|
||||
}));
|
||||
const nameContext = buildAgentWorktreeNameContext({
|
||||
initialPrompt: "Create a worktree name from this prompt",
|
||||
attachments: [
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 123,
|
||||
title: "Fix worktree naming",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
baseRefName: "main",
|
||||
headRefName: "fix/worktree-naming",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await buildAgentSessionConfig(
|
||||
{
|
||||
sessionLogger: createLogger(),
|
||||
workspaceGitService: {
|
||||
resolveDefaultBranch: vi.fn(async () => "main"),
|
||||
} as unknown as WorkspaceGitService,
|
||||
createPaseoWorktree,
|
||||
checkoutExistingBranch: async () => {
|
||||
throw new Error("should not checkout existing branch");
|
||||
},
|
||||
createBranchFromBase: async () => {
|
||||
throw new Error("should not create a branch outside the worktree service");
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: "/tmp/repo",
|
||||
},
|
||||
{
|
||||
createWorktree: true,
|
||||
action: "branch-off",
|
||||
},
|
||||
undefined,
|
||||
[
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 123,
|
||||
title: "Fix worktree naming",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
baseRefName: "main",
|
||||
headRefName: "fix/worktree-naming",
|
||||
},
|
||||
],
|
||||
nameContext,
|
||||
);
|
||||
|
||||
expect(createPaseoWorktree).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nameContext:
|
||||
"Create a worktree name from this prompt\n\nGitHub PR #123: Fix worktree naming\nhttps://github.com/getpaseo/paseo/pull/123\nBase: main\nHead: fix/worktree-naming",
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(result.sessionConfig.cwd).toBe("/tmp/worktrees/fix-attached-pr-context");
|
||||
});
|
||||
|
||||
test("buildAgentSessionConfig invalidates GitHub cache after branch setup mutations", async () => {
|
||||
@@ -1284,7 +1490,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
paseoHome,
|
||||
sessionLogger: createLogger(),
|
||||
emit: (message) => emitted.push(message),
|
||||
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome, events }),
|
||||
createPaseoWorktreeWorkflow: createWorkflowForRequestTest({
|
||||
paseoHome,
|
||||
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome, events }),
|
||||
}),
|
||||
describeWorkspaceRecord: vi.fn(async (result) => ({
|
||||
id: result.workspace.workspaceId,
|
||||
projectId: result.workspace.projectId,
|
||||
@@ -1308,8 +1517,6 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
},
|
||||
githubRuntime: null,
|
||||
})),
|
||||
warmWorkspaceGitData: async () => {},
|
||||
runWorktreeSetupInBackground: vi.fn(async () => {}),
|
||||
},
|
||||
{
|
||||
type: "create_paseo_worktree_request",
|
||||
@@ -1346,17 +1553,20 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
paseoHome,
|
||||
sessionLogger: createLogger(),
|
||||
emit: (message) => emitted.push(message),
|
||||
createPaseoWorktree: async (input) => {
|
||||
const result = await createPaseoWorktreeForTest({ paseoHome })(input);
|
||||
expect(existsSync(result.worktree.worktreePath)).toBe(true);
|
||||
registeredWorktreePath = result.worktree.worktreePath;
|
||||
return result;
|
||||
},
|
||||
createPaseoWorktreeWorkflow: createWorkflowForRequestTest({
|
||||
paseoHome,
|
||||
createPaseoWorktree: async (input) => {
|
||||
const result = await createPaseoWorktreeForTest({ paseoHome })(input);
|
||||
expect(existsSync(result.worktree.worktreePath)).toBe(true);
|
||||
registeredWorktreePath = result.worktree.worktreePath;
|
||||
return result;
|
||||
},
|
||||
warmWorkspaceGitData,
|
||||
onSetupStarted: backgroundWork,
|
||||
}),
|
||||
describeWorkspaceRecord: vi.fn(async (result) =>
|
||||
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
|
||||
),
|
||||
warmWorkspaceGitData,
|
||||
runWorktreeSetupInBackground: backgroundWork,
|
||||
},
|
||||
{
|
||||
type: "create_paseo_worktree_request",
|
||||
@@ -1388,6 +1598,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
});
|
||||
expect(registeredWorktreePath).toBeTruthy();
|
||||
expect(existsSync(registeredWorktreePath!)).toBe(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(warmWorkspaceGitData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceId: response?.payload.workspace?.id,
|
||||
@@ -1421,12 +1632,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
paseoHome,
|
||||
sessionLogger: createLogger(),
|
||||
emit: (message) => emitted.push(message),
|
||||
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
|
||||
createPaseoWorktreeWorkflow: createWorkflowForRequestTest({ paseoHome }),
|
||||
describeWorkspaceRecord: vi.fn(async (result) =>
|
||||
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
|
||||
),
|
||||
warmWorkspaceGitData: async () => {},
|
||||
runWorktreeSetupInBackground: vi.fn(async () => {}),
|
||||
},
|
||||
{
|
||||
type: "create_paseo_worktree_request",
|
||||
@@ -1462,12 +1671,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
paseoHome,
|
||||
sessionLogger: createLogger(),
|
||||
emit: (message) => emitted.push(message),
|
||||
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
|
||||
createPaseoWorktreeWorkflow: createWorkflowForRequestTest({ paseoHome }),
|
||||
describeWorkspaceRecord: vi.fn(async (result) =>
|
||||
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
|
||||
),
|
||||
warmWorkspaceGitData: async () => {},
|
||||
runWorktreeSetupInBackground: vi.fn(async () => {}),
|
||||
},
|
||||
{
|
||||
type: "create_paseo_worktree_request",
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import {
|
||||
runAsyncWorktreeBootstrap,
|
||||
applyWorktreeSetupProgressEvent,
|
||||
buildWorktreeSetupDetail,
|
||||
createWorktreeSetupProgressAccumulator,
|
||||
@@ -23,6 +24,7 @@ import type { ScriptRouteStore } from "./script-proxy.js";
|
||||
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||
import type { GitHubService } from "../services/github-service.js";
|
||||
import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js";
|
||||
import { renderPromptAttachmentAsText } from "./agent/prompt-attachments.js";
|
||||
import { expandTilde } from "../utils/path.js";
|
||||
import {
|
||||
getWorktreeSetupCommands,
|
||||
@@ -61,6 +63,15 @@ export interface NormalizedGitOptions {
|
||||
}
|
||||
|
||||
type EmitSessionMessage = (message: SessionOutboundMessage) => void;
|
||||
type AgentWorktreeSetupTimelineItem = Parameters<
|
||||
typeof runAsyncWorktreeBootstrap
|
||||
>[0]["appendTimelineItem"] extends (item: infer Item) => unknown
|
||||
? Item
|
||||
: never;
|
||||
type AgentWorktreeSetupTimelineWriter = (input: {
|
||||
agentId: string;
|
||||
item: AgentWorktreeSetupTimelineItem;
|
||||
}) => Promise<boolean>;
|
||||
|
||||
interface BuildAgentSessionConfigDependencies {
|
||||
paseoHome?: string;
|
||||
@@ -70,8 +81,9 @@ interface BuildAgentSessionConfigDependencies {
|
||||
input: CreatePaseoWorktreeInput,
|
||||
options?: {
|
||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
|
||||
},
|
||||
) => Promise<CreatePaseoWorktreeResult>;
|
||||
) => Promise<CreatePaseoWorktreeWorkflowResult>;
|
||||
checkoutExistingBranch: (cwd: string, branch: string) => Promise<CheckoutExistingBranchResult>;
|
||||
createBranchFromBase: (params: {
|
||||
cwd: string;
|
||||
@@ -96,6 +108,45 @@ interface CreatePaseoWorktreeInBackgroundDependencies {
|
||||
onScriptsChanged: ((workspaceId: string, workspaceDirectory: string) => void) | null;
|
||||
}
|
||||
|
||||
interface CreatePaseoWorktreeWorkflowDependencies extends CreatePaseoWorktreeInBackgroundDependencies {
|
||||
createPaseoWorktree: (
|
||||
input: CreatePaseoWorktreeInput,
|
||||
options?: {
|
||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
},
|
||||
) => Promise<CreatePaseoWorktreeResult>;
|
||||
warmWorkspaceGitData: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
||||
}
|
||||
|
||||
interface AgentWorktreeSetupContinuationInput {
|
||||
kind: "agent";
|
||||
terminalManager: TerminalManager | null;
|
||||
appendTimelineItem: AgentWorktreeSetupTimelineWriter;
|
||||
emitLiveTimelineItem: AgentWorktreeSetupTimelineWriter;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export type CreatePaseoWorktreeSetupContinuationInput =
|
||||
| { kind: "workspace" }
|
||||
| AgentWorktreeSetupContinuationInput;
|
||||
|
||||
export interface AgentWorktreeSetupContinuation {
|
||||
kind: "agent";
|
||||
startAfterAgentCreate: (input: { agentId: string }) => void;
|
||||
}
|
||||
|
||||
export type CreatePaseoWorktreeWorkflowResult = CreatePaseoWorktreeResult & {
|
||||
setupContinuation?: AgentWorktreeSetupContinuation;
|
||||
};
|
||||
|
||||
export type CreatePaseoWorktreeWorkflowFn = (
|
||||
input: CreatePaseoWorktreeInput,
|
||||
options?: {
|
||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
|
||||
},
|
||||
) => Promise<CreatePaseoWorktreeWorkflowResult>;
|
||||
|
||||
interface HandleWorkspaceSetupStatusRequestDependencies {
|
||||
emit: EmitSessionMessage;
|
||||
workspaceSetupSnapshots: ReadonlyMap<string, WorkspaceSetupSnapshot>;
|
||||
@@ -107,18 +158,10 @@ interface HandleCreatePaseoWorktreeRequestDependencies {
|
||||
result: CreatePaseoWorktreeResult,
|
||||
) => Promise<WorkspaceDescriptorPayload>;
|
||||
emit: EmitSessionMessage;
|
||||
createPaseoWorktree: (input: CreatePaseoWorktreeInput) => Promise<CreatePaseoWorktreeResult>;
|
||||
warmWorkspaceGitData: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
||||
sessionLogger: Logger;
|
||||
runWorktreeSetupInBackground: (options: {
|
||||
requestCwd: string;
|
||||
repoRoot: string;
|
||||
workspaceId: string;
|
||||
worktree: WorktreeConfig;
|
||||
shouldBootstrap: boolean;
|
||||
slug: string;
|
||||
worktreePath: string;
|
||||
}) => Promise<void>;
|
||||
createPaseoWorktreeWorkflow: (
|
||||
input: CreatePaseoWorktreeInput,
|
||||
) => Promise<CreatePaseoWorktreeWorkflowResult>;
|
||||
}
|
||||
|
||||
export async function buildAgentSessionConfig(
|
||||
@@ -127,13 +170,14 @@ export async function buildAgentSessionConfig(
|
||||
gitOptions?: GitSetupOptions,
|
||||
legacyWorktreeName?: string,
|
||||
attachments?: AgentAttachment[],
|
||||
nameContext?: string,
|
||||
): Promise<{
|
||||
sessionConfig: AgentSessionConfig;
|
||||
worktreeBootstrap?: { worktree: WorktreeConfig; shouldBootstrap: boolean };
|
||||
setupContinuation?: AgentWorktreeSetupContinuation;
|
||||
}> {
|
||||
let cwd = expandTilde(config.cwd);
|
||||
const normalized = normalizeGitOptions(gitOptions, legacyWorktreeName);
|
||||
let worktreeBootstrap: { worktree: WorktreeConfig; shouldBootstrap: boolean } | undefined;
|
||||
let setupContinuation: AgentWorktreeSetupContinuation | undefined;
|
||||
|
||||
if (!normalized) {
|
||||
return {
|
||||
@@ -157,6 +201,7 @@ export async function buildAgentSessionConfig(
|
||||
refName: normalized.refName,
|
||||
action: normalized.action,
|
||||
githubPrNumber: normalized.githubPrNumber,
|
||||
nameContext,
|
||||
attachments,
|
||||
runSetup: false,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
@@ -173,10 +218,7 @@ export async function buildAgentSessionConfig(
|
||||
},
|
||||
);
|
||||
cwd = createdWorktree.worktree.worktreePath;
|
||||
worktreeBootstrap = {
|
||||
worktree: createdWorktree.worktree,
|
||||
shouldBootstrap: createdWorktree.created,
|
||||
};
|
||||
setupContinuation = createdWorktree.setupContinuation;
|
||||
} else if (normalized.createNewBranch) {
|
||||
const baseBranch =
|
||||
normalized.baseBranch ??
|
||||
@@ -201,10 +243,28 @@ export async function buildAgentSessionConfig(
|
||||
...config,
|
||||
cwd,
|
||||
},
|
||||
worktreeBootstrap,
|
||||
setupContinuation,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAgentWorktreeNameContext(input: {
|
||||
initialPrompt?: string | null;
|
||||
attachments?: readonly AgentAttachment[];
|
||||
}): string | undefined {
|
||||
const parts: string[] = [];
|
||||
const prompt = input.initialPrompt?.trim();
|
||||
if (prompt) {
|
||||
parts.push(prompt);
|
||||
}
|
||||
for (const attachment of input.attachments ?? []) {
|
||||
const rendered = renderPromptAttachmentAsText(attachment).trim();
|
||||
if (rendered) {
|
||||
parts.push(rendered);
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join("\n\n") : undefined;
|
||||
}
|
||||
|
||||
interface ValidateNormalizedGitOptionsInput {
|
||||
baseBranch: string | undefined;
|
||||
createNewBranch: boolean;
|
||||
@@ -450,9 +510,10 @@ export async function handleCreatePaseoWorktreeRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const createdWorktree = await dependencies.createPaseoWorktree({
|
||||
const createdWorktree = await dependencies.createPaseoWorktreeWorkflow({
|
||||
cwd: request.cwd,
|
||||
worktreeSlug: request.worktreeSlug,
|
||||
nameContext: request.nameContext,
|
||||
refName: request.refName,
|
||||
action: request.action,
|
||||
githubPrNumber: request.githubPrNumber,
|
||||
@@ -460,8 +521,6 @@ export async function handleCreatePaseoWorktreeRequest(
|
||||
runSetup: false,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
const slug = basename(createdWorktree.worktree.worktreePath);
|
||||
const workspace = createdWorktree.workspace;
|
||||
|
||||
const descriptor = await dependencies.describeWorkspaceRecord(createdWorktree);
|
||||
dependencies.emit({
|
||||
@@ -480,22 +539,6 @@ export async function handleCreatePaseoWorktreeRequest(
|
||||
workspace: descriptor,
|
||||
},
|
||||
});
|
||||
|
||||
void dependencies.warmWorkspaceGitData(workspace).catch((error) => {
|
||||
dependencies.sessionLogger.warn(
|
||||
{ err: error, workspaceId: workspace.workspaceId },
|
||||
"Failed to warm workspace git data after creating worktree",
|
||||
);
|
||||
});
|
||||
void dependencies.runWorktreeSetupInBackground({
|
||||
requestCwd: request.cwd,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
workspaceId: workspace.workspaceId,
|
||||
worktree: createdWorktree.worktree,
|
||||
shouldBootstrap: createdWorktree.created,
|
||||
slug,
|
||||
worktreePath: createdWorktree.worktree.worktreePath,
|
||||
});
|
||||
} catch (error) {
|
||||
const wireError = toWorktreeWireError(error);
|
||||
dependencies.sessionLogger.error(
|
||||
@@ -515,6 +558,72 @@ export async function handleCreatePaseoWorktreeRequest(
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPaseoWorktreeWorkflow(
|
||||
dependencies: CreatePaseoWorktreeWorkflowDependencies,
|
||||
input: CreatePaseoWorktreeInput,
|
||||
options?: {
|
||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
|
||||
},
|
||||
): Promise<CreatePaseoWorktreeWorkflowResult> {
|
||||
const createdWorktree = await dependencies.createPaseoWorktree(
|
||||
{
|
||||
...input,
|
||||
runSetup: false,
|
||||
paseoHome: input.paseoHome ?? dependencies.paseoHome,
|
||||
},
|
||||
options?.resolveDefaultBranch
|
||||
? { resolveDefaultBranch: options.resolveDefaultBranch }
|
||||
: undefined,
|
||||
);
|
||||
const slug = basename(createdWorktree.worktree.worktreePath);
|
||||
const workspace = createdWorktree.workspace;
|
||||
const setupContinuation = options?.setupContinuation ?? { kind: "workspace" };
|
||||
|
||||
setTimeout(() => {
|
||||
void dependencies.warmWorkspaceGitData(workspace).catch((error) => {
|
||||
dependencies.sessionLogger.warn(
|
||||
{ err: error, workspaceId: workspace.workspaceId },
|
||||
"Failed to warm workspace git data after creating worktree",
|
||||
);
|
||||
});
|
||||
if (setupContinuation.kind === "workspace") {
|
||||
void runWorktreeSetupInBackground(dependencies, {
|
||||
requestCwd: input.cwd,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
workspaceId: workspace.workspaceId,
|
||||
worktree: createdWorktree.worktree,
|
||||
shouldBootstrap: createdWorktree.created,
|
||||
slug,
|
||||
worktreePath: createdWorktree.worktree.worktreePath,
|
||||
});
|
||||
}
|
||||
}, 0);
|
||||
|
||||
if (setupContinuation.kind === "agent") {
|
||||
return {
|
||||
...createdWorktree,
|
||||
setupContinuation: {
|
||||
kind: "agent",
|
||||
startAfterAgentCreate: ({ agentId }) => {
|
||||
void runAsyncWorktreeBootstrap({
|
||||
agentId,
|
||||
worktree: createdWorktree.worktree,
|
||||
shouldBootstrap: createdWorktree.created,
|
||||
terminalManager: setupContinuation.terminalManager,
|
||||
appendTimelineItem: (item) => setupContinuation.appendTimelineItem({ agentId, item }),
|
||||
emitLiveTimelineItem: (item) =>
|
||||
setupContinuation.emitLiveTimelineItem({ agentId, item }),
|
||||
logger: setupContinuation.logger,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return createdWorktree;
|
||||
}
|
||||
|
||||
export async function handleWorkspaceSetupStatusRequest(
|
||||
dependencies: HandleWorkspaceSetupStatusRequestDependencies,
|
||||
request: Extract<SessionInboundMessage, { type: "workspace_setup_status_request" }>,
|
||||
|
||||
@@ -1385,6 +1385,7 @@ export const CreatePaseoWorktreeRequestSchema = z.object({
|
||||
type: z.literal("create_paseo_worktree_request"),
|
||||
cwd: z.string(),
|
||||
worktreeSlug: z.string().optional(),
|
||||
nameContext: z.string().optional(),
|
||||
attachments: AgentAttachmentsSchema,
|
||||
refName: z.string().min(1).optional(),
|
||||
action: z.enum(["branch-off", "checkout"]).optional(),
|
||||
|
||||
@@ -10,6 +10,19 @@ const PaseoWorktreeMetadataV1Schema = z.object({
|
||||
const PaseoWorktreeMetadataV2Schema = z.object({
|
||||
version: z.literal(2),
|
||||
baseRefName: z.string().min(1),
|
||||
firstAgentBranchAutoName: z
|
||||
.discriminatedUnion("status", [
|
||||
z.object({
|
||||
status: z.literal("pending"),
|
||||
placeholderBranchName: z.string().min(1),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("attempted"),
|
||||
placeholderBranchName: z.string().min(1),
|
||||
attemptedAt: z.string().min(1),
|
||||
}),
|
||||
])
|
||||
.optional(),
|
||||
runtime: z
|
||||
.object({
|
||||
worktreePort: z.number().int().positive(),
|
||||
@@ -101,6 +114,9 @@ export function writePaseoWorktreeRuntimeMetadata(
|
||||
const next: PaseoWorktreeMetadata = {
|
||||
version: 2,
|
||||
baseRefName: current.baseRefName,
|
||||
...(current.version === 2 && current.firstAgentBranchAutoName
|
||||
? { firstAgentBranchAutoName: current.firstAgentBranchAutoName }
|
||||
: {}),
|
||||
runtime: {
|
||||
worktreePort: options.worktreePort,
|
||||
},
|
||||
@@ -108,6 +124,54 @@ export function writePaseoWorktreeRuntimeMetadata(
|
||||
writeFileSync(metadataPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
export function writePaseoWorktreeFirstAgentBranchAutoNameMetadata(
|
||||
worktreeRoot: string,
|
||||
options: { placeholderBranchName: string },
|
||||
): void {
|
||||
const placeholderBranchName = options.placeholderBranchName.trim();
|
||||
if (!placeholderBranchName) {
|
||||
throw new Error("Placeholder branch name is required");
|
||||
}
|
||||
|
||||
const current = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
if (!current) {
|
||||
throw new Error("Cannot persist first-agent branch auto-name metadata: missing base metadata");
|
||||
}
|
||||
|
||||
writePaseoWorktreeMetadataFile(worktreeRoot, {
|
||||
version: 2,
|
||||
baseRefName: current.baseRefName,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "pending",
|
||||
placeholderBranchName,
|
||||
},
|
||||
...(current.version === 2 && current.runtime ? { runtime: current.runtime } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function markPaseoWorktreeFirstAgentBranchAutoNameAttempted(
|
||||
worktreeRoot: string,
|
||||
options: { attemptedAt?: string } = {},
|
||||
): PaseoWorktreeMetadata | null {
|
||||
const current = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
if (!current || current.version !== 2 || current.firstAgentBranchAutoName?.status !== "pending") {
|
||||
return current;
|
||||
}
|
||||
|
||||
const next: PaseoWorktreeMetadata = {
|
||||
version: 2,
|
||||
baseRefName: current.baseRefName,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "attempted",
|
||||
placeholderBranchName: current.firstAgentBranchAutoName.placeholderBranchName,
|
||||
attemptedAt: options.attemptedAt ?? new Date().toISOString(),
|
||||
},
|
||||
...(current.runtime ? { runtime: current.runtime } : {}),
|
||||
};
|
||||
writePaseoWorktreeMetadataFile(worktreeRoot, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function readPaseoWorktreeMetadata(worktreeRoot: string): PaseoWorktreeMetadata | null {
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
if (!existsSync(metadataPath)) {
|
||||
@@ -136,3 +200,12 @@ export function readPaseoWorktreeRuntimePort(worktreeRoot: string): number | nul
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writePaseoWorktreeMetadataFile(
|
||||
worktreeRoot: string,
|
||||
metadata: PaseoWorktreeMetadata,
|
||||
): void {
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true });
|
||||
writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user