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<
|
export interface CreatePaseoWorktreeInput extends Pick<
|
||||||
CreatePaseoWorktreeRequest,
|
CreatePaseoWorktreeRequest,
|
||||||
"cwd" | "worktreeSlug" | "attachments" | "refName" | "action" | "githubPrNumber"
|
"cwd" | "worktreeSlug" | "nameContext" | "attachments" | "refName" | "action" | "githubPrNumber"
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||||
@@ -2757,6 +2757,7 @@ export class DaemonClient {
|
|||||||
type: "create_paseo_worktree_request",
|
type: "create_paseo_worktree_request",
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
worktreeSlug: input.worktreeSlug,
|
worktreeSlug: input.worktreeSlug,
|
||||||
|
...(input.nameContext !== undefined ? { nameContext: input.nameContext } : {}),
|
||||||
...(input.attachments && input.attachments.length > 0
|
...(input.attachments && input.attachments.length > 0
|
||||||
? { attachments: input.attachments }
|
? { attachments: input.attachments }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { basename } from "path";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
@@ -9,15 +8,10 @@ import {
|
|||||||
StructuredAgentResponseError,
|
StructuredAgentResponseError,
|
||||||
generateStructuredAgentResponseWithFallback,
|
generateStructuredAgentResponseWithFallback,
|
||||||
} from "./agent-response-loop.js";
|
} 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 { MAX_AUTO_AGENT_TITLE_CHARS } from "./agent-title-limits.js";
|
||||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "../workspace-git-service.js";
|
|
||||||
|
|
||||||
export interface AgentMetadataGeneratorDeps {
|
export interface AgentMetadataGeneratorDeps {
|
||||||
generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback;
|
generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback;
|
||||||
renameCurrentBranch?: typeof renameCurrentBranch;
|
|
||||||
workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot">;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentMetadataGenerationOptions {
|
export interface AgentMetadataGenerationOptions {
|
||||||
@@ -34,7 +28,6 @@ export interface AgentMetadataGenerationOptions {
|
|||||||
interface AgentMetadataNeeds {
|
interface AgentMetadataNeeds {
|
||||||
prompt: string | null;
|
prompt: string | null;
|
||||||
needsTitle: boolean;
|
needsTitle: boolean;
|
||||||
needsBranch: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasExplicitTitle(title?: string | null): 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;
|
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(
|
export async function determineAgentMetadataNeeds(
|
||||||
options: Pick<
|
options: Pick<
|
||||||
AgentMetadataGenerationOptions,
|
AgentMetadataGenerationOptions,
|
||||||
@@ -84,23 +50,21 @@ export async function determineAgentMetadataNeeds(
|
|||||||
): Promise<AgentMetadataNeeds> {
|
): Promise<AgentMetadataNeeds> {
|
||||||
const prompt = options.initialPrompt?.trim();
|
const prompt = options.initialPrompt?.trim();
|
||||||
if (!prompt) {
|
if (!prompt) {
|
||||||
return { prompt: null, needsTitle: false, needsBranch: false };
|
return { prompt: null, needsTitle: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const needsTitle = !hasExplicitTitle(options.explicitTitle);
|
const needsTitle = !hasExplicitTitle(options.explicitTitle);
|
||||||
const needsBranch = await canRenameBranch(options.cwd, options.deps?.workspaceGitService);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prompt,
|
prompt,
|
||||||
needsTitle,
|
needsTitle,
|
||||||
needsBranch,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMetadataSchema(
|
function buildMetadataSchema(
|
||||||
needs: AgentMetadataNeeds,
|
needs: AgentMetadataNeeds,
|
||||||
): z.ZodObject<Record<string, z.ZodTypeAny>> | null {
|
): z.ZodObject<Record<string, z.ZodTypeAny>> | null {
|
||||||
if (!needs.needsTitle && !needs.needsBranch) {
|
if (!needs.needsTitle) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,33 +72,16 @@ function buildMetadataSchema(
|
|||||||
if (needs.needsTitle) {
|
if (needs.needsTitle) {
|
||||||
shape.title = z.string().min(1).max(MAX_AUTO_AGENT_TITLE_CHARS);
|
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);
|
return z.object(shape);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPrompt(needs: AgentMetadataNeeds): string {
|
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."];
|
const instructions: string[] = ["Generate metadata for a coding agent based on the user prompt."];
|
||||||
|
|
||||||
if (needs.needsTitle) {
|
if (needs.needsTitle) {
|
||||||
instructions.push(`Title: short descriptive label (<= ${MAX_AUTO_AGENT_TITLE_CHARS} chars).`);
|
instructions.push(`Title: short descriptive label (<= ${MAX_AUTO_AGENT_TITLE_CHARS} chars).`);
|
||||||
}
|
}
|
||||||
if (needs.needsBranch) {
|
instructions.push("Return JSON only with a single field 'title'.");
|
||||||
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("", "User prompt:", needs.prompt ?? "");
|
instructions.push("", "User prompt:", needs.prompt ?? "");
|
||||||
return instructions.join("\n");
|
return instructions.join("\n");
|
||||||
@@ -156,9 +103,8 @@ export async function generateAndApplyAgentMetadata(
|
|||||||
const generator =
|
const generator =
|
||||||
options.deps?.generateStructuredAgentResponseWithFallback ??
|
options.deps?.generateStructuredAgentResponseWithFallback ??
|
||||||
generateStructuredAgentResponseWithFallback;
|
generateStructuredAgentResponseWithFallback;
|
||||||
const renameCurrentBranchImpl = options.deps?.renameCurrentBranch ?? renameCurrentBranch;
|
|
||||||
|
|
||||||
let result: { title?: string; branch?: string };
|
let result: { title?: string };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
result = await generator({
|
result = await generator({
|
||||||
@@ -198,78 +144,6 @@ export async function generateAndApplyAgentMetadata(
|
|||||||
await options.agentManager.setTitle(options.agentId, normalizedTitle);
|
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 {
|
export function scheduleAgentMetadataGeneration(options: AgentMetadataGenerationOptions): void {
|
||||||
|
|||||||
@@ -7,34 +7,9 @@ import {
|
|||||||
type AgentMetadataGeneratorDeps,
|
type AgentMetadataGeneratorDeps,
|
||||||
} from "./agent-metadata-generator.js";
|
} from "./agent-metadata-generator.js";
|
||||||
import type { AgentManager } from "./agent-manager.js";
|
import type { AgentManager } from "./agent-manager.js";
|
||||||
import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js";
|
|
||||||
|
|
||||||
const logger = createTestLogger();
|
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(
|
function createDeps(
|
||||||
generateStructuredAgentResponseWithFallback: NonNullable<
|
generateStructuredAgentResponseWithFallback: NonNullable<
|
||||||
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
|
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
|
||||||
@@ -89,134 +64,30 @@ describe("agent metadata generator auto-title", () => {
|
|||||||
expect(setTitle).not.toHaveBeenCalled();
|
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 setTitle = vi.fn().mockResolvedValue(undefined);
|
||||||
const notifyAgentState = vi.fn();
|
const manager = { setTitle } as unknown as AgentManager;
|
||||||
const flush = vi.fn().mockResolvedValue(undefined);
|
const generateStructured = vi
|
||||||
const manager = {
|
.fn()
|
||||||
setTitle,
|
.mockResolvedValue({ title: "Generated title" }) as NonNullable<
|
||||||
notifyAgentState,
|
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
|
||||||
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),
|
|
||||||
};
|
|
||||||
|
|
||||||
await generateAndApplyAgentMetadata({
|
await generateAndApplyAgentMetadata({
|
||||||
agentManager: manager,
|
agentManager: manager,
|
||||||
agentId: "agent-branch",
|
agentId: "agent-suppressed-branch",
|
||||||
cwd: "/tmp/repo/metadata-worktree",
|
cwd: "/tmp/repo/metadata-worktree",
|
||||||
initialPrompt: "Rename this worktree branch.",
|
initialPrompt: "Implement this feature",
|
||||||
explicitTitle: "Keep explicit title",
|
explicitTitle: null,
|
||||||
paseoHome: "/tmp/paseo-home",
|
|
||||||
logger,
|
logger,
|
||||||
deps: {
|
deps: {
|
||||||
generateStructuredAgentResponseWithFallback: generateStructured,
|
generateStructuredAgentResponseWithFallback: generateStructured,
|
||||||
renameCurrentBranch,
|
|
||||||
workspaceGitService,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(renameCurrentBranch).toHaveBeenCalledWith(
|
expect(generateStructured).toHaveBeenCalledWith(
|
||||||
"/tmp/repo/metadata-worktree",
|
expect.objectContaining({ cwd: "/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(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 type { WorkspaceGitService } from "../workspace-git-service.js";
|
||||||
import {
|
import {
|
||||||
createPaseoWorktree as createPaseoWorktreeService,
|
createPaseoWorktree as createPaseoWorktreeService,
|
||||||
type CreatePaseoWorktreeFn,
|
type CreatePaseoWorktreeInput,
|
||||||
} from "../paseo-worktree-service.js";
|
} from "../paseo-worktree-service.js";
|
||||||
|
import type { CreatePaseoWorktreeWorkflowFn } from "../worktree-session.js";
|
||||||
import { createWorktreeCoreDeps } from "../worktree-core.js";
|
import { createWorktreeCoreDeps } from "../worktree-core.js";
|
||||||
import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
|
import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
|
||||||
import type { GitHubService } from "../../services/github-service.js";
|
import type { GitHubService } from "../../services/github-service.js";
|
||||||
@@ -292,7 +293,9 @@ function createPaseoWorktreeForMcpTest(options: {
|
|||||||
paseoHome: string;
|
paseoHome: string;
|
||||||
broadcasts: string[];
|
broadcasts: string[];
|
||||||
createdWorkspaceIds?: string[];
|
createdWorkspaceIds?: string[];
|
||||||
}): CreatePaseoWorktreeFn {
|
setupContinuations?: Array<"workspace" | "agent" | undefined>;
|
||||||
|
startedAgentSetupIds?: string[];
|
||||||
|
}): CreatePaseoWorktreeWorkflowFn {
|
||||||
const projects = new Map<string, PersistedProjectRecord>();
|
const projects = new Map<string, PersistedProjectRecord>();
|
||||||
const workspaces = new Map<string, PersistedWorkspaceRecord>();
|
const workspaces = new Map<string, PersistedWorkspaceRecord>();
|
||||||
const github = createGitHubServiceStub();
|
const github = createGitHubServiceStub();
|
||||||
@@ -303,6 +306,7 @@ function createPaseoWorktreeForMcpTest(options: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return async (input, serviceOptions) => {
|
return async (input, serviceOptions) => {
|
||||||
|
options.setupContinuations?.push(serviceOptions?.setupContinuation?.kind);
|
||||||
const coreDeps = createWorktreeCoreDeps(github);
|
const coreDeps = createWorktreeCoreDeps(github);
|
||||||
const result = await createPaseoWorktreeService(input, {
|
const result = await createPaseoWorktreeService(input, {
|
||||||
...coreDeps,
|
...coreDeps,
|
||||||
@@ -326,6 +330,17 @@ function createPaseoWorktreeForMcpTest(options: {
|
|||||||
});
|
});
|
||||||
options.broadcasts.push(result.workspace.workspaceId);
|
options.broadcasts.push(result.workspace.workspaceId);
|
||||||
options.createdWorkspaceIds?.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;
|
return result;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -478,13 +493,26 @@ describe("create_agent MCP tool", () => {
|
|||||||
expect(parsed.success).toBe(true);
|
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 () => {
|
it("rejects create_worktree without a branch name or checkout intent", async () => {
|
||||||
const { agentManager, agentStorage } = createTestDeps();
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||||
const tool = registeredTool(server, "create_worktree");
|
const tool = registeredTool(server, "create_worktree");
|
||||||
|
|
||||||
await expect(tool.callback({})).rejects.toThrow(
|
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 paseoHome = join(tempDir, ".paseo");
|
||||||
const broadcasts: string[] = [];
|
const broadcasts: string[] = [];
|
||||||
const createdWorkspaceIds: string[] = [];
|
const createdWorkspaceIds: string[] = [];
|
||||||
|
const setupContinuations: Array<"workspace" | "agent" | undefined> = [];
|
||||||
|
const startedAgentSetupIds: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||||
@@ -636,6 +666,8 @@ describe("create_agent MCP tool", () => {
|
|||||||
paseoHome,
|
paseoHome,
|
||||||
broadcasts,
|
broadcasts,
|
||||||
createdWorkspaceIds,
|
createdWorkspaceIds,
|
||||||
|
setupContinuations,
|
||||||
|
startedAgentSetupIds,
|
||||||
}),
|
}),
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
@@ -653,6 +685,8 @@ describe("create_agent MCP tool", () => {
|
|||||||
expect(broadcasts).toHaveLength(1);
|
expect(broadcasts).toHaveLength(1);
|
||||||
expect(createdWorkspaceIds).toHaveLength(1);
|
expect(createdWorkspaceIds).toHaveLength(1);
|
||||||
expect(broadcasts[0]).toBe(createdWorkspaceIds[0]);
|
expect(broadcasts[0]).toBe(createdWorkspaceIds[0]);
|
||||||
|
expect(setupContinuations).toEqual(["agent"]);
|
||||||
|
expect(startedAgentSetupIds).toEqual(["agent-with-worktree"]);
|
||||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
cwd: expect.stringContaining("agent-worktree"),
|
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 () => {
|
it("registers and broadcasts a workspace when create_worktree creates a worktree", async () => {
|
||||||
const { agentManager, agentStorage } = createTestDeps();
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-create-worktree-"));
|
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-create-worktree-"));
|
||||||
const repoDir = join(tempDir, "repo");
|
const repoDir = join(tempDir, "repo");
|
||||||
const paseoHome = join(tempDir, ".paseo");
|
const paseoHome = join(tempDir, ".paseo");
|
||||||
const broadcasts: string[] = [];
|
const broadcasts: string[] = [];
|
||||||
|
const setupContinuations: Array<"workspace" | "agent" | undefined> = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||||
@@ -689,7 +954,11 @@ describe("create_agent MCP tool", () => {
|
|||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
paseoHome,
|
paseoHome,
|
||||||
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
|
createPaseoWorktree: createPaseoWorktreeForMcpTest({
|
||||||
|
paseoHome,
|
||||||
|
broadcasts,
|
||||||
|
setupContinuations,
|
||||||
|
}),
|
||||||
workspaceGitService: workspaceGitService as unknown as Pick<
|
workspaceGitService: workspaceGitService as unknown as Pick<
|
||||||
WorkspaceGitService,
|
WorkspaceGitService,
|
||||||
"getSnapshot" | "listWorktrees"
|
"getSnapshot" | "listWorktrees"
|
||||||
@@ -705,17 +974,8 @@ describe("create_agent MCP tool", () => {
|
|||||||
|
|
||||||
expect(response.structuredContent.branchName).toBe("tool-worktree");
|
expect(response.structuredContent.branchName).toBe("tool-worktree");
|
||||||
expect(response.structuredContent.worktreePath).toContain("tool-worktree");
|
expect(response.structuredContent.worktreePath).toContain("tool-worktree");
|
||||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, {
|
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||||
force: true,
|
expect(setupContinuations).toEqual([undefined]);
|
||||||
reason: "create-worktree",
|
|
||||||
});
|
|
||||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(
|
|
||||||
response.structuredContent.worktreePath,
|
|
||||||
{
|
|
||||||
force: true,
|
|
||||||
reason: "create-worktree",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(broadcasts).toHaveLength(1);
|
expect(broadcasts).toHaveLength(1);
|
||||||
expect(broadcasts[0]).toContain("tool-worktree");
|
expect(broadcasts[0]).toContain("tool-worktree");
|
||||||
} finally {
|
} 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 () => {
|
it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => {
|
||||||
const { agentManager, agentStorage } = createTestDeps();
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-"));
|
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-"));
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
appendTimelineItemIfAgentKnown,
|
appendTimelineItemIfAgentKnown,
|
||||||
emitLiveTimelineItemIfAgentKnown,
|
emitLiveTimelineItemIfAgentKnown,
|
||||||
} from "./timeline-append.js";
|
} from "./timeline-append.js";
|
||||||
import { getPaseoWorktreesRoot, type WorktreeConfig } from "../../utils/worktree.js";
|
import { getPaseoWorktreesRoot } from "../../utils/worktree.js";
|
||||||
import {
|
import {
|
||||||
archivePaseoWorktree,
|
archivePaseoWorktree,
|
||||||
killTerminalsUnderPath,
|
killTerminalsUnderPath,
|
||||||
@@ -39,7 +39,12 @@ import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
|
|||||||
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../path-utils.js";
|
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../path-utils.js";
|
||||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||||
import { captureTerminalLines } from "../../terminal/terminal.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 type { ScheduleService } from "../schedule/service.js";
|
||||||
import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js";
|
import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js";
|
||||||
import type { ProviderDefinition } from "./provider-registry.js";
|
import type { ProviderDefinition } from "./provider-registry.js";
|
||||||
@@ -61,11 +66,7 @@ import {
|
|||||||
} from "./mcp-shared.js";
|
} from "./mcp-shared.js";
|
||||||
import type { GitHubService } from "../../services/github-service.js";
|
import type { GitHubService } from "../../services/github-service.js";
|
||||||
import type { WorkspaceGitService } from "../workspace-git-service.js";
|
import type { WorkspaceGitService } from "../workspace-git-service.js";
|
||||||
import type {
|
import type { CreatePaseoWorktreeInput } from "../paseo-worktree-service.js";
|
||||||
CreatePaseoWorktreeFn,
|
|
||||||
CreatePaseoWorktreeInput,
|
|
||||||
CreatePaseoWorktreeResult,
|
|
||||||
} from "../paseo-worktree-service.js";
|
|
||||||
import { toWorktreeRequestError } from "../worktree-errors.js";
|
import { toWorktreeRequestError } from "../worktree-errors.js";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
@@ -83,7 +84,7 @@ export interface AgentMcpServerOptions {
|
|||||||
markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"];
|
markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"];
|
||||||
clearWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["clearWorkspaceArchiving"];
|
clearWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["clearWorkspaceArchiving"];
|
||||||
emitSessionMessage?: ArchivePaseoWorktreeDependencies["emit"];
|
emitSessionMessage?: ArchivePaseoWorktreeDependencies["emit"];
|
||||||
createPaseoWorktree?: CreatePaseoWorktreeFn;
|
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
|
||||||
paseoHome?: string;
|
paseoHome?: string;
|
||||||
/**
|
/**
|
||||||
* ID of the agent that is connecting to this MCP server.
|
* ID of the agent that is connecting to this MCP server.
|
||||||
@@ -625,8 +626,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
notifyOnFinish: boolean;
|
notifyOnFinish: boolean;
|
||||||
resolvedCwd: string;
|
resolvedCwd: string;
|
||||||
resolvedMode: string | undefined;
|
resolvedMode: string | undefined;
|
||||||
worktreeConfig: WorktreeConfig | undefined;
|
setupContinuation: AgentWorktreeSetupContinuation | undefined;
|
||||||
shouldBootstrapWorktree: boolean | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveCallerCreateAgentArgs = (
|
const resolveCallerCreateAgentArgs = (
|
||||||
@@ -661,8 +661,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
notifyOnFinish: callerArgs.notifyOnFinish ?? false,
|
notifyOnFinish: callerArgs.notifyOnFinish ?? false,
|
||||||
resolvedCwd,
|
resolvedCwd,
|
||||||
resolvedMode,
|
resolvedMode,
|
||||||
worktreeConfig: undefined,
|
setupContinuation: undefined,
|
||||||
shouldBootstrapWorktree: undefined,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -673,11 +672,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider);
|
const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider);
|
||||||
const { cwd, mode, worktreeName, baseBranch, refName, action, githubPrNumber } = topLevelArgs;
|
const { cwd, mode, worktreeName, baseBranch, refName, action, githubPrNumber } = topLevelArgs;
|
||||||
let resolvedCwd = expandUserPath(cwd);
|
let resolvedCwd = expandUserPath(cwd);
|
||||||
let worktreeConfig: WorktreeConfig | undefined;
|
let setupContinuation: AgentWorktreeSetupContinuation | undefined;
|
||||||
let shouldBootstrapWorktree: boolean | undefined;
|
|
||||||
|
|
||||||
if (worktreeName) {
|
const shouldCreateWorktree = Boolean(worktreeName || refName || action || githubPrNumber);
|
||||||
if (!baseBranch && !refName && !action && githubPrNumber === undefined) {
|
if (shouldCreateWorktree) {
|
||||||
|
if (worktreeName && !baseBranch && !refName && !action && githubPrNumber === undefined) {
|
||||||
throw new Error("baseBranch is required when creating a worktree");
|
throw new Error("baseBranch is required when creating a worktree");
|
||||||
}
|
}
|
||||||
const createdWorktree = await createMcpWorktree({
|
const createdWorktree = await createMcpWorktree({
|
||||||
@@ -687,17 +686,32 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
refName,
|
refName,
|
||||||
action,
|
action,
|
||||||
githubPrNumber,
|
githubPrNumber,
|
||||||
|
nameContext: topLevelArgs.initialPrompt,
|
||||||
runSetup: false,
|
runSetup: false,
|
||||||
paseoHome: options.paseoHome,
|
paseoHome: options.paseoHome,
|
||||||
},
|
},
|
||||||
createPaseoWorktree: options.createPaseoWorktree,
|
createPaseoWorktree: options.createPaseoWorktree,
|
||||||
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,
|
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,
|
||||||
workspaceGitService: options.workspaceGitService,
|
setupContinuation: {
|
||||||
logger: options.logger,
|
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;
|
resolvedCwd = createdWorktree.worktree.worktreePath;
|
||||||
worktreeConfig = createdWorktree.worktree;
|
setupContinuation = createdWorktree.setupContinuation;
|
||||||
shouldBootstrapWorktree = createdWorktree.created;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -711,8 +725,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
notifyOnFinish: topLevelArgs.notifyOnFinish ?? false,
|
notifyOnFinish: topLevelArgs.notifyOnFinish ?? false,
|
||||||
resolvedCwd,
|
resolvedCwd,
|
||||||
resolvedMode: mode,
|
resolvedMode: mode,
|
||||||
worktreeConfig,
|
setupContinuation,
|
||||||
shouldBootstrapWorktree,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -755,8 +768,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
notifyOnFinish,
|
notifyOnFinish,
|
||||||
resolvedCwd,
|
resolvedCwd,
|
||||||
resolvedMode,
|
resolvedMode,
|
||||||
worktreeConfig,
|
setupContinuation,
|
||||||
shouldBootstrapWorktree,
|
|
||||||
} = resolved;
|
} = resolved;
|
||||||
|
|
||||||
const childAgentDefaultLabels = callerContext?.childAgentDefaultLabels;
|
const childAgentDefaultLabels = callerContext?.childAgentDefaultLabels;
|
||||||
@@ -778,27 +790,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
Object.keys(mergedLabels).length > 0 ? { labels: mergedLabels } : undefined,
|
Object.keys(mergedLabels).length > 0 ? { labels: mergedLabels } : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (worktreeConfig) {
|
setupContinuation?.startAfterAgentCreate({
|
||||||
void runAsyncWorktreeBootstrap({
|
agentId: snapshot.id,
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedPrompt = initialPrompt.trim();
|
const trimmedPrompt = initialPrompt.trim();
|
||||||
scheduleAgentMetadataGeneration({
|
scheduleAgentMetadataGeneration({
|
||||||
@@ -809,11 +803,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
explicitTitle: snapshot.config.title,
|
explicitTitle: snapshot.config.title,
|
||||||
paseoHome: options.paseoHome,
|
paseoHome: options.paseoHome,
|
||||||
logger: childLogger,
|
logger: childLogger,
|
||||||
deps: options.workspaceGitService
|
|
||||||
? {
|
|
||||||
workspaceGitService: options.workspaceGitService,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1779,6 +1768,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
.optional()
|
.optional()
|
||||||
.describe("Optional repository cwd. Defaults to the caller agent cwd."),
|
.describe("Optional repository cwd. Defaults to the caller agent cwd."),
|
||||||
branchName: z.string().optional(),
|
branchName: z.string().optional(),
|
||||||
|
nameContext: z.string().optional(),
|
||||||
baseBranch: z.string().optional(),
|
baseBranch: z.string().optional(),
|
||||||
refName: z.string().min(1).optional(),
|
refName: z.string().min(1).optional(),
|
||||||
action: z.enum(["branch-off", "checkout"]).optional(),
|
action: z.enum(["branch-off", "checkout"]).optional(),
|
||||||
@@ -1789,15 +1779,18 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
worktreePath: z.string(),
|
worktreePath: z.string(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ cwd, branchName, baseBranch, refName, action, githubPrNumber }) => {
|
async ({ cwd, branchName, nameContext, baseBranch, refName, action, githubPrNumber }) => {
|
||||||
if (!branchName && !refName && githubPrNumber === undefined) {
|
if (!branchName && !nameContext && !refName && githubPrNumber === undefined) {
|
||||||
throw new Error("create_worktree requires branchName, refName, or githubPrNumber");
|
throw new Error(
|
||||||
|
"create_worktree requires branchName, nameContext, refName, or githubPrNumber",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const repoRoot = resolveScopedCwd(cwd, { required: true });
|
const repoRoot = resolveScopedCwd(cwd, { required: true });
|
||||||
const createdWorktree = await createMcpWorktree({
|
const createdWorktree = await createMcpWorktree({
|
||||||
input: {
|
input: {
|
||||||
cwd: repoRoot,
|
cwd: repoRoot,
|
||||||
worktreeSlug: branchName,
|
worktreeSlug: branchName,
|
||||||
|
nameContext,
|
||||||
refName,
|
refName,
|
||||||
action,
|
action,
|
||||||
githubPrNumber,
|
githubPrNumber,
|
||||||
@@ -1806,8 +1799,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
createPaseoWorktree: options.createPaseoWorktree,
|
createPaseoWorktree: options.createPaseoWorktree,
|
||||||
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,
|
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,
|
||||||
workspaceGitService: options.workspaceGitService,
|
|
||||||
logger: options.logger,
|
|
||||||
});
|
});
|
||||||
const { worktree } = createdWorktree;
|
const { worktree } = createdWorktree;
|
||||||
|
|
||||||
@@ -2050,46 +2041,24 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
|
|
||||||
interface CreateMcpWorktreeOptions {
|
interface CreateMcpWorktreeOptions {
|
||||||
input: CreatePaseoWorktreeInput;
|
input: CreatePaseoWorktreeInput;
|
||||||
createPaseoWorktree: CreatePaseoWorktreeFn | undefined;
|
createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined;
|
||||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||||
workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot">;
|
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
|
||||||
logger: Logger;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createMcpWorktree(
|
async function createMcpWorktree(
|
||||||
options: CreateMcpWorktreeOptions,
|
options: CreateMcpWorktreeOptions,
|
||||||
): Promise<CreatePaseoWorktreeResult> {
|
): Promise<CreatePaseoWorktreeWorkflowResult> {
|
||||||
try {
|
try {
|
||||||
if (!options.createPaseoWorktree) {
|
if (!options.createPaseoWorktree) {
|
||||||
throw new Error("Paseo worktree service is not configured");
|
throw new Error("Paseo worktree service is not configured");
|
||||||
}
|
}
|
||||||
const result = await options.createPaseoWorktree(options.input, {
|
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;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw toWorktreeRequestError(error);
|
throw toWorktreeRequestError(error);
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ function formatListenTarget(listenTarget: ListenTarget | null): string | null {
|
|||||||
|
|
||||||
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
|
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
|
||||||
import { createGitHubService } from "../services/github-service.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 { createWorktreeCoreDeps } from "./worktree-core.js";
|
||||||
import { DownloadTokenStore } from "./file-download/token-store.js";
|
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||||
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
|
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
|
||||||
@@ -562,24 +563,54 @@ export async function createPaseoDaemon(
|
|||||||
clearWorkspaceArchiving: clearWorkspaceArchivingForMcpArchive,
|
clearWorkspaceArchiving: clearWorkspaceArchivingForMcpArchive,
|
||||||
emitSessionMessage: emitMcpArchiveSessionMessage,
|
emitSessionMessage: emitMcpArchiveSessionMessage,
|
||||||
createPaseoWorktree: async (input, serviceOptions) => {
|
createPaseoWorktree: async (input, serviceOptions) => {
|
||||||
const coreDeps = createWorktreeCoreDeps(github);
|
return createPaseoWorktreeWorkflow(
|
||||||
const result = await createPaseoWorktree(input, {
|
{
|
||||||
...coreDeps,
|
paseoHome: config.paseoHome,
|
||||||
...(serviceOptions?.resolveDefaultBranch
|
createPaseoWorktree: async (workflowInput, workflowOptions) => {
|
||||||
? {
|
const coreDeps = createWorktreeCoreDeps(github);
|
||||||
resolveDefaultBranch: serviceOptions.resolveDefaultBranch,
|
return createRegisteredPaseoWorktree(workflowInput, {
|
||||||
}
|
...coreDeps,
|
||||||
: {}),
|
...(workflowOptions?.resolveDefaultBranch
|
||||||
projectRegistry,
|
? {
|
||||||
workspaceRegistry,
|
resolveDefaultBranch: workflowOptions.resolveDefaultBranch,
|
||||||
workspaceGitService,
|
}
|
||||||
});
|
: {}),
|
||||||
await Promise.all(
|
projectRegistry,
|
||||||
wsServer
|
workspaceRegistry,
|
||||||
?.listActiveSessions()
|
workspaceGitService,
|
||||||
.map((session) => session.warmWorkspaceGitDataForWorkspace(result.workspace)) ?? [],
|
});
|
||||||
|
},
|
||||||
|
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,
|
paseoHome: config.paseoHome,
|
||||||
callerAgentId,
|
callerAgentId,
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { execSync } from "node:child_process";
|
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 { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import type { GitHubService } from "../services/github-service.js";
|
import type { GitHubService } from "../services/github-service.js";
|
||||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
|
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
|
||||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.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 { createWorktreeCoreDeps } from "./worktree-core.js";
|
||||||
|
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
|
||||||
|
|
||||||
const cleanupPaths: string[] = [];
|
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}`);
|
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 () => {
|
test("does not mutate registries or broadcast when core worktree creation fails", async () => {
|
||||||
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-"));
|
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-"));
|
||||||
cleanupPaths.push(tempDir);
|
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);
|
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 {
|
interface TestDeps extends CreatePaseoWorktreeDeps {
|
||||||
projects: Map<string, PersistedProjectRecord>;
|
projects: Map<string, PersistedProjectRecord>;
|
||||||
workspaces: Map<string, PersistedWorkspaceRecord>;
|
workspaces: Map<string, PersistedWorkspaceRecord>;
|
||||||
@@ -143,6 +276,7 @@ function createDeps(options?: {
|
|||||||
events?: string[];
|
events?: string[];
|
||||||
projects?: Map<string, PersistedProjectRecord>;
|
projects?: Map<string, PersistedProjectRecord>;
|
||||||
workspaces?: Map<string, PersistedWorkspaceRecord>;
|
workspaces?: Map<string, PersistedWorkspaceRecord>;
|
||||||
|
generateBranchName?: (seed: string | undefined) => string;
|
||||||
}): TestDeps {
|
}): TestDeps {
|
||||||
const events = options?.events ?? [];
|
const events = options?.events ?? [];
|
||||||
const projects = options?.projects ?? new Map<string, PersistedProjectRecord>();
|
const projects = options?.projects ?? new Map<string, PersistedProjectRecord>();
|
||||||
@@ -150,6 +284,7 @@ function createDeps(options?: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...createWorktreeCoreDeps(createGitHubServiceStub()),
|
...createWorktreeCoreDeps(createGitHubServiceStub()),
|
||||||
|
...(options?.generateBranchName ? { generateBranchName: options.generateBranchName } : {}),
|
||||||
projects,
|
projects,
|
||||||
workspaces,
|
workspaces,
|
||||||
projectRegistry: {
|
projectRegistry: {
|
||||||
@@ -310,14 +445,24 @@ function createGitRepo(): { tempDir: string; repoDir: string } {
|
|||||||
return { tempDir, repoDir };
|
return { tempDir, repoDir };
|
||||||
}
|
}
|
||||||
|
|
||||||
function listTypeScriptFiles(directory: string): string[] {
|
function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string } {
|
||||||
const entries = readdirSync(directory);
|
const { tempDir, repoDir } = createGitRepo();
|
||||||
return entries.flatMap((entry) => {
|
execSync("git checkout -b pr-123", { cwd: repoDir, stdio: "pipe" });
|
||||||
const fullPath = path.join(directory, entry);
|
writeFileSync(path.join(repoDir, "README.md"), "pr branch\n");
|
||||||
const stats = statSync(fullPath);
|
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||||
if (stats.isDirectory()) {
|
execSync("git commit -m pr-branch", { cwd: repoDir, stdio: "pipe" });
|
||||||
return listTypeScriptFiles(fullPath);
|
const prHead = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" }).toString().trim();
|
||||||
}
|
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
|
||||||
return fullPath.endsWith(".ts") ? [fullPath] : [];
|
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 { WorkspaceGitService } from "./workspace-git-service.js";
|
||||||
import {
|
import {
|
||||||
type PersistedWorkspaceRecord,
|
type PersistedWorkspaceRecord,
|
||||||
@@ -13,6 +15,13 @@ import {
|
|||||||
type CreateWorktreeCoreInput,
|
type CreateWorktreeCoreInput,
|
||||||
} from "./worktree-core.js";
|
} from "./worktree-core.js";
|
||||||
import type { WorktreeConfig } from "../utils/worktree.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";
|
import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent.js";
|
||||||
|
|
||||||
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {}
|
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {}
|
||||||
@@ -32,6 +41,12 @@ export type CreatePaseoWorktreeFn = (
|
|||||||
},
|
},
|
||||||
) => Promise<CreatePaseoWorktreeResult>;
|
) => Promise<CreatePaseoWorktreeResult>;
|
||||||
|
|
||||||
|
export interface AttemptFirstAgentBranchAutoNameResult {
|
||||||
|
attempted: boolean;
|
||||||
|
renamed: boolean;
|
||||||
|
branchName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps {
|
export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps {
|
||||||
projectRegistry: Pick<ProjectRegistry, "get" | "upsert">;
|
projectRegistry: Pick<ProjectRegistry, "get" | "upsert">;
|
||||||
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "list" | "upsert">;
|
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "list" | "upsert">;
|
||||||
@@ -43,17 +58,23 @@ export async function createPaseoWorktree(
|
|||||||
deps: CreatePaseoWorktreeDeps,
|
deps: CreatePaseoWorktreeDeps,
|
||||||
): Promise<CreatePaseoWorktreeResult> {
|
): Promise<CreatePaseoWorktreeResult> {
|
||||||
const createdWorktree = await createWorktreeCore(input, deps);
|
const createdWorktree = await createWorktreeCore(input, deps);
|
||||||
|
maybeMarkFirstAgentBranchAutoNameEligible({ input, createdWorktree });
|
||||||
|
const worktree = await maybeAutoNameCreatedWorktree({
|
||||||
|
input,
|
||||||
|
createdWorktree,
|
||||||
|
deps,
|
||||||
|
});
|
||||||
const workspace = await upsertWorkspaceForWorktree({
|
const workspace = await upsertWorkspaceForWorktree({
|
||||||
inputCwd: input.cwd,
|
inputCwd: input.cwd,
|
||||||
repoRoot: createdWorktree.repoRoot,
|
repoRoot: createdWorktree.repoRoot,
|
||||||
worktree: createdWorktree.worktree,
|
worktree,
|
||||||
deps,
|
deps,
|
||||||
});
|
});
|
||||||
|
|
||||||
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });
|
deps.github.invalidate({ cwd: worktree.worktreePath });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
worktree: createdWorktree.worktree,
|
worktree,
|
||||||
intent: createdWorktree.intent,
|
intent: createdWorktree.intent,
|
||||||
workspace,
|
workspace,
|
||||||
repoRoot: createdWorktree.repoRoot,
|
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: {
|
async function upsertWorkspaceForWorktree(options: {
|
||||||
inputCwd: string;
|
inputCwd: string;
|
||||||
repoRoot: string;
|
repoRoot: string;
|
||||||
|
|||||||
@@ -165,13 +165,11 @@ import {
|
|||||||
} from "./file-explorer/service.js";
|
} from "./file-explorer/service.js";
|
||||||
import { DownloadTokenStore } from "./file-download/token-store.js";
|
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||||
import { PushTokenStore } from "./push/token-store.js";
|
import { PushTokenStore } from "./push/token-store.js";
|
||||||
import { type WorktreeConfig } from "../utils/worktree.js";
|
|
||||||
import {
|
import {
|
||||||
readPaseoConfigForEdit,
|
readPaseoConfigForEdit,
|
||||||
writePaseoConfigForEdit,
|
writePaseoConfigForEdit,
|
||||||
type ProjectConfigRpcError,
|
type ProjectConfigRpcError,
|
||||||
} from "../utils/paseo-config-file.js";
|
} from "../utils/paseo-config-file.js";
|
||||||
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
|
|
||||||
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
|
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
|
||||||
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
||||||
import type { ScriptRouteStore } from "./script-proxy.js";
|
import type { ScriptRouteStore } from "./script-proxy.js";
|
||||||
@@ -206,6 +204,7 @@ import {
|
|||||||
type PullRequestTimelineItem,
|
type PullRequestTimelineItem,
|
||||||
} from "../services/github-service.js";
|
} from "../services/github-service.js";
|
||||||
import {
|
import {
|
||||||
|
attemptFirstAgentBranchAutoName,
|
||||||
createPaseoWorktree,
|
createPaseoWorktree,
|
||||||
type CreatePaseoWorktreeInput,
|
type CreatePaseoWorktreeInput,
|
||||||
type CreatePaseoWorktreeResult,
|
type CreatePaseoWorktreeResult,
|
||||||
@@ -214,7 +213,10 @@ import { createWorktreeCoreDeps } from "./worktree-core.js";
|
|||||||
import {
|
import {
|
||||||
assertSafeGitRef as assertWorktreeSafeGitRef,
|
assertSafeGitRef as assertWorktreeSafeGitRef,
|
||||||
buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
|
buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
|
||||||
runWorktreeSetupInBackground as runWorktreeSetupInBackgroundSession,
|
buildAgentWorktreeNameContext,
|
||||||
|
createPaseoWorktreeWorkflow as createWorktreeWorkflow,
|
||||||
|
type CreatePaseoWorktreeSetupContinuationInput,
|
||||||
|
type CreatePaseoWorktreeWorkflowResult,
|
||||||
handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest,
|
handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest,
|
||||||
handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest,
|
handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest,
|
||||||
handlePaseoWorktreeListRequest as handleWorktreeListRequest,
|
handlePaseoWorktreeListRequest as handleWorktreeListRequest,
|
||||||
@@ -274,6 +276,7 @@ type GitMutationRefreshReason =
|
|||||||
| "create-pr"
|
| "create-pr"
|
||||||
| "switch-branch"
|
| "switch-branch"
|
||||||
| "create-branch"
|
| "create-branch"
|
||||||
|
| "rename-branch"
|
||||||
| "stash-push"
|
| "stash-push"
|
||||||
| "stash-pop"
|
| "stash-pop"
|
||||||
| "create-worktree";
|
| "create-worktree";
|
||||||
@@ -3105,19 +3108,28 @@ export class Session {
|
|||||||
...(provisionalTitle ? { title: provisionalTitle } : {}),
|
...(provisionalTitle ? { title: provisionalTitle } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const { sessionConfig, worktreeBootstrap } = await this.buildAgentSessionConfig(
|
const agentNameContext = buildAgentWorktreeNameContext({
|
||||||
|
initialPrompt: trimmedPrompt,
|
||||||
|
attachments,
|
||||||
|
});
|
||||||
|
const { sessionConfig, setupContinuation } = await this.buildAgentSessionConfig(
|
||||||
resolvedConfig,
|
resolvedConfig,
|
||||||
git,
|
git,
|
||||||
worktreeName,
|
worktreeName,
|
||||||
attachments,
|
attachments,
|
||||||
|
agentNameContext,
|
||||||
);
|
);
|
||||||
const resolvedWorkspace = msg.workspaceId
|
let resolvedWorkspace = msg.workspaceId
|
||||||
? await this.workspaceRegistry.get(msg.workspaceId)
|
? await this.workspaceRegistry.get(msg.workspaceId)
|
||||||
: ((await this.findWorkspaceByDirectory(sessionConfig.cwd)) ??
|
: ((await this.findWorkspaceByDirectory(sessionConfig.cwd)) ??
|
||||||
(await this.findOrCreateWorkspaceForDirectory(sessionConfig.cwd)));
|
(await this.findOrCreateWorkspaceForDirectory(sessionConfig.cwd)));
|
||||||
if (!resolvedWorkspace) {
|
if (!resolvedWorkspace) {
|
||||||
throw new Error(`Workspace not found: ${msg.workspaceId}`);
|
throw new Error(`Workspace not found: ${msg.workspaceId}`);
|
||||||
}
|
}
|
||||||
|
resolvedWorkspace = await this.maybeAutoNameWorkspaceBranchForFirstAgent({
|
||||||
|
workspace: resolvedWorkspace,
|
||||||
|
nameContext: agentNameContext,
|
||||||
|
});
|
||||||
const snapshot = await this.agentManager.createAgent(
|
const snapshot = await this.agentManager.createAgent(
|
||||||
{
|
{
|
||||||
...sessionConfig,
|
...sessionConfig,
|
||||||
@@ -3155,27 +3167,9 @@ export class Session {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (worktreeBootstrap) {
|
setupContinuation?.startAfterAgentCreate({
|
||||||
void runAsyncWorktreeBootstrap({
|
agentId: snapshot.id,
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sessionLogger.info(
|
this.sessionLogger.info(
|
||||||
{ agentId: snapshot.id, provider: snapshot.provider },
|
{ agentId: snapshot.id, provider: snapshot.provider },
|
||||||
@@ -3231,9 +3225,6 @@ export class Session {
|
|||||||
explicitTitle: params.explicitTitle,
|
explicitTitle: params.explicitTitle,
|
||||||
paseoHome: this.paseoHome,
|
paseoHome: this.paseoHome,
|
||||||
logger: this.sessionLogger,
|
logger: this.sessionLogger,
|
||||||
deps: {
|
|
||||||
workspaceGitService: this.workspaceGitService,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const started = await this.handleSendAgentMessage(
|
const started = await this.handleSendAgentMessage(
|
||||||
@@ -3460,9 +3451,10 @@ export class Session {
|
|||||||
gitOptions?: GitSetupOptions,
|
gitOptions?: GitSetupOptions,
|
||||||
legacyWorktreeName?: string,
|
legacyWorktreeName?: string,
|
||||||
attachments?: AgentAttachment[],
|
attachments?: AgentAttachment[],
|
||||||
|
nameContext?: string,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
sessionConfig: AgentSessionConfig;
|
sessionConfig: AgentSessionConfig;
|
||||||
worktreeBootstrap?: { worktree: WorktreeConfig; shouldBootstrap: boolean };
|
setupContinuation?: CreatePaseoWorktreeWorkflowResult["setupContinuation"];
|
||||||
}> {
|
}> {
|
||||||
return buildWorktreeAgentSessionConfig(
|
return buildWorktreeAgentSessionConfig(
|
||||||
{
|
{
|
||||||
@@ -3470,7 +3462,26 @@ export class Session {
|
|||||||
sessionLogger: this.sessionLogger,
|
sessionLogger: this.sessionLogger,
|
||||||
workspaceGitService: this.workspaceGitService,
|
workspaceGitService: this.workspaceGitService,
|
||||||
createPaseoWorktree: (input, serviceOptions) =>
|
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),
|
checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch),
|
||||||
createBranchFromBase: (params) => this.createBranchFromBase(params),
|
createBranchFromBase: (params) => this.createBranchFromBase(params),
|
||||||
github: this.github,
|
github: this.github,
|
||||||
@@ -3479,9 +3490,35 @@ export class Session {
|
|||||||
gitOptions,
|
gitOptions,
|
||||||
legacyWorktreeName,
|
legacyWorktreeName,
|
||||||
attachments,
|
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(
|
private emitProviderDisabledResponse(
|
||||||
kind: "models" | "modes",
|
kind: "models" | "modes",
|
||||||
provider: AgentProvider,
|
provider: AgentProvider,
|
||||||
@@ -7447,27 +7484,26 @@ export class Session {
|
|||||||
paseoHome: this.paseoHome,
|
paseoHome: this.paseoHome,
|
||||||
describeWorkspaceRecord: (result) => this.describeCreatedWorktreeWorkspace(result),
|
describeWorkspaceRecord: (result) => this.describeCreatedWorktreeWorkspace(result),
|
||||||
emit: (message) => this.emit(message),
|
emit: (message) => this.emit(message),
|
||||||
createPaseoWorktree: (input) => this.createPaseoWorktree(input),
|
|
||||||
warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),
|
|
||||||
sessionLogger: this.sessionLogger,
|
sessionLogger: this.sessionLogger,
|
||||||
runWorktreeSetupInBackground: (options) => this.runWorktreeSetupInBackground(options),
|
createPaseoWorktreeWorkflow: (input) => this.createPaseoWorktreeWorkflow(input),
|
||||||
},
|
},
|
||||||
request,
|
request,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runWorktreeSetupInBackground(options: {
|
private async createPaseoWorktreeWorkflow(
|
||||||
requestCwd: string;
|
input: CreatePaseoWorktreeInput,
|
||||||
repoRoot: string;
|
options?: {
|
||||||
workspaceId: string;
|
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||||
worktree: { branchName: string; worktreePath: string };
|
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
|
||||||
shouldBootstrap: boolean;
|
},
|
||||||
slug: string;
|
): Promise<CreatePaseoWorktreeWorkflowResult> {
|
||||||
worktreePath: string;
|
return createWorktreeWorkflow(
|
||||||
}): Promise<void> {
|
|
||||||
return runWorktreeSetupInBackgroundSession(
|
|
||||||
{
|
{
|
||||||
paseoHome: this.paseoHome,
|
paseoHome: this.paseoHome,
|
||||||
|
createPaseoWorktree: (workflowInput, serviceOptions) =>
|
||||||
|
this.createPaseoWorktree(workflowInput, serviceOptions),
|
||||||
|
warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),
|
||||||
emitWorkspaceUpdateForCwd: (cwd, emitOptions) =>
|
emitWorkspaceUpdateForCwd: (cwd, emitOptions) =>
|
||||||
this.emitWorkspaceUpdateForCwd(cwd, emitOptions),
|
this.emitWorkspaceUpdateForCwd(cwd, emitOptions),
|
||||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => {
|
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => {
|
||||||
@@ -7485,6 +7521,7 @@ export class Session {
|
|||||||
this.emitWorkspaceScriptStatusUpdate(workspaceId, workspaceDirectory);
|
this.emitWorkspaceScriptStatusUpdate(workspaceId, workspaceDirectory);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
input,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { execSync } from "node:child_process";
|
|||||||
import {
|
import {
|
||||||
existsSync,
|
existsSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
readdirSync,
|
|
||||||
readFileSync,
|
readFileSync,
|
||||||
realpathSync,
|
realpathSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
@@ -10,7 +9,6 @@ import {
|
|||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { describe, expect, test, afterEach, vi } from "vitest";
|
import { describe, expect, test, afterEach, vi } from "vitest";
|
||||||
|
|
||||||
import type { GitHubService } from "../services/github-service.js";
|
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 } {
|
function createGitRepo(): { tempDir: string; repoDir: string; paseoHome: string } {
|
||||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "worktree-core-test-")));
|
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "worktree-core-test-")));
|
||||||
const repoDir = path.join(tempDir, "repo");
|
const repoDir = path.join(tempDir, "repo");
|
||||||
@@ -651,11 +613,6 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => {
|
|||||||
});
|
});
|
||||||
expect(result.worktree.branchName).toBe("feature/from-service");
|
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", () => {
|
describe("resolveWorktreeRepoRoot", () => {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type { WorkspaceGitService } from "./workspace-git-service.js";
|
|||||||
|
|
||||||
export interface CreateWorktreeCoreInput extends ResolveWorktreeCreationIntentInput {
|
export interface CreateWorktreeCoreInput extends ResolveWorktreeCreationIntentInput {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
nameContext?: string;
|
||||||
paseoHome?: string;
|
paseoHome?: string;
|
||||||
runSetup?: boolean;
|
runSetup?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import type { SessionOutboundMessage, WorkspaceDescriptorPayload } from "./messa
|
|||||||
import { archivePaseoWorktree } from "./paseo-worktree-archive-service.js";
|
import { archivePaseoWorktree } from "./paseo-worktree-archive-service.js";
|
||||||
import {
|
import {
|
||||||
buildAgentSessionConfig,
|
buildAgentSessionConfig,
|
||||||
|
buildAgentWorktreeNameContext,
|
||||||
|
createPaseoWorktreeWorkflow,
|
||||||
handlePaseoWorktreeArchiveRequest,
|
handlePaseoWorktreeArchiveRequest,
|
||||||
handlePaseoWorktreeListRequest,
|
handlePaseoWorktreeListRequest,
|
||||||
resolveGitCreateBaseBranch,
|
resolveGitCreateBaseBranch,
|
||||||
@@ -79,6 +81,53 @@ function createLogger(): Logger {
|
|||||||
return 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 {
|
function createGitHubServiceStub(): GitHubService {
|
||||||
return {
|
return {
|
||||||
listPullRequests: async () => [],
|
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"> {
|
function createAgentStorageStub(): Pick<AgentStorage, "list" | "remove"> {
|
||||||
return {
|
return {
|
||||||
list: async (): Promise<StoredAgentRecord[]> => [],
|
list: async (): Promise<StoredAgentRecord[]> => [],
|
||||||
@@ -1048,10 +1172,8 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
describeWorkspaceRecord: async (result) =>
|
describeWorkspaceRecord: async (result) =>
|
||||||
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
|
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
|
||||||
emit: (message) => emitted.push(message),
|
emit: (message) => emitted.push(message),
|
||||||
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
|
|
||||||
warmWorkspaceGitData: async () => {},
|
|
||||||
sessionLogger: logger,
|
sessionLogger: logger,
|
||||||
runWorktreeSetupInBackground: async () => {},
|
createPaseoWorktreeWorkflow: createWorkflowForRequestTest({ paseoHome }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "create_paseo_worktree_request",
|
type: "create_paseo_worktree_request",
|
||||||
@@ -1139,8 +1261,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.worktreeBootstrap?.worktree.branchName).toBe("feature/review-pr");
|
expect(result.sessionConfig.cwd).toContain("agent-review-pr-123");
|
||||||
expect(result.worktreeBootstrap?.worktree.worktreePath).toContain("agent-review-pr-123");
|
|
||||||
expect(events.some((event) => event.startsWith("workspace:"))).toBe(true);
|
expect(events.some((event) => event.startsWith("workspace:"))).toBe(true);
|
||||||
|
|
||||||
const branch = execSync("git branch --show-current", {
|
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.sessionConfig.cwd)).toBe("feature-x");
|
||||||
expect(path.basename(result.worktreeBootstrap?.worktree.worktreePath ?? "")).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 () => {
|
test("buildAgentSessionConfig invalidates GitHub cache after branch setup mutations", async () => {
|
||||||
@@ -1284,7 +1490,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
paseoHome,
|
paseoHome,
|
||||||
sessionLogger: createLogger(),
|
sessionLogger: createLogger(),
|
||||||
emit: (message) => emitted.push(message),
|
emit: (message) => emitted.push(message),
|
||||||
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome, events }),
|
createPaseoWorktreeWorkflow: createWorkflowForRequestTest({
|
||||||
|
paseoHome,
|
||||||
|
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome, events }),
|
||||||
|
}),
|
||||||
describeWorkspaceRecord: vi.fn(async (result) => ({
|
describeWorkspaceRecord: vi.fn(async (result) => ({
|
||||||
id: result.workspace.workspaceId,
|
id: result.workspace.workspaceId,
|
||||||
projectId: result.workspace.projectId,
|
projectId: result.workspace.projectId,
|
||||||
@@ -1308,8 +1517,6 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
},
|
},
|
||||||
githubRuntime: null,
|
githubRuntime: null,
|
||||||
})),
|
})),
|
||||||
warmWorkspaceGitData: async () => {},
|
|
||||||
runWorktreeSetupInBackground: vi.fn(async () => {}),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "create_paseo_worktree_request",
|
type: "create_paseo_worktree_request",
|
||||||
@@ -1346,17 +1553,20 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
paseoHome,
|
paseoHome,
|
||||||
sessionLogger: createLogger(),
|
sessionLogger: createLogger(),
|
||||||
emit: (message) => emitted.push(message),
|
emit: (message) => emitted.push(message),
|
||||||
createPaseoWorktree: async (input) => {
|
createPaseoWorktreeWorkflow: createWorkflowForRequestTest({
|
||||||
const result = await createPaseoWorktreeForTest({ paseoHome })(input);
|
paseoHome,
|
||||||
expect(existsSync(result.worktree.worktreePath)).toBe(true);
|
createPaseoWorktree: async (input) => {
|
||||||
registeredWorktreePath = result.worktree.worktreePath;
|
const result = await createPaseoWorktreeForTest({ paseoHome })(input);
|
||||||
return result;
|
expect(existsSync(result.worktree.worktreePath)).toBe(true);
|
||||||
},
|
registeredWorktreePath = result.worktree.worktreePath;
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
warmWorkspaceGitData,
|
||||||
|
onSetupStarted: backgroundWork,
|
||||||
|
}),
|
||||||
describeWorkspaceRecord: vi.fn(async (result) =>
|
describeWorkspaceRecord: vi.fn(async (result) =>
|
||||||
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
|
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
|
||||||
),
|
),
|
||||||
warmWorkspaceGitData,
|
|
||||||
runWorktreeSetupInBackground: backgroundWork,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "create_paseo_worktree_request",
|
type: "create_paseo_worktree_request",
|
||||||
@@ -1388,6 +1598,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
});
|
});
|
||||||
expect(registeredWorktreePath).toBeTruthy();
|
expect(registeredWorktreePath).toBeTruthy();
|
||||||
expect(existsSync(registeredWorktreePath!)).toBe(true);
|
expect(existsSync(registeredWorktreePath!)).toBe(true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
expect(warmWorkspaceGitData).toHaveBeenCalledWith(
|
expect(warmWorkspaceGitData).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
workspaceId: response?.payload.workspace?.id,
|
workspaceId: response?.payload.workspace?.id,
|
||||||
@@ -1421,12 +1632,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
paseoHome,
|
paseoHome,
|
||||||
sessionLogger: createLogger(),
|
sessionLogger: createLogger(),
|
||||||
emit: (message) => emitted.push(message),
|
emit: (message) => emitted.push(message),
|
||||||
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
|
createPaseoWorktreeWorkflow: createWorkflowForRequestTest({ paseoHome }),
|
||||||
describeWorkspaceRecord: vi.fn(async (result) =>
|
describeWorkspaceRecord: vi.fn(async (result) =>
|
||||||
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
|
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
|
||||||
),
|
),
|
||||||
warmWorkspaceGitData: async () => {},
|
|
||||||
runWorktreeSetupInBackground: vi.fn(async () => {}),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "create_paseo_worktree_request",
|
type: "create_paseo_worktree_request",
|
||||||
@@ -1462,12 +1671,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
paseoHome,
|
paseoHome,
|
||||||
sessionLogger: createLogger(),
|
sessionLogger: createLogger(),
|
||||||
emit: (message) => emitted.push(message),
|
emit: (message) => emitted.push(message),
|
||||||
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
|
createPaseoWorktreeWorkflow: createWorkflowForRequestTest({ paseoHome }),
|
||||||
describeWorkspaceRecord: vi.fn(async (result) =>
|
describeWorkspaceRecord: vi.fn(async (result) =>
|
||||||
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
|
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
|
||||||
),
|
),
|
||||||
warmWorkspaceGitData: async () => {},
|
|
||||||
runWorktreeSetupInBackground: vi.fn(async () => {}),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "create_paseo_worktree_request",
|
type: "create_paseo_worktree_request",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
|
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||||
import {
|
import {
|
||||||
|
runAsyncWorktreeBootstrap,
|
||||||
applyWorktreeSetupProgressEvent,
|
applyWorktreeSetupProgressEvent,
|
||||||
buildWorktreeSetupDetail,
|
buildWorktreeSetupDetail,
|
||||||
createWorktreeSetupProgressAccumulator,
|
createWorktreeSetupProgressAccumulator,
|
||||||
@@ -23,6 +24,7 @@ import type { ScriptRouteStore } from "./script-proxy.js";
|
|||||||
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||||
import type { GitHubService } from "../services/github-service.js";
|
import type { GitHubService } from "../services/github-service.js";
|
||||||
import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js";
|
import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js";
|
||||||
|
import { renderPromptAttachmentAsText } from "./agent/prompt-attachments.js";
|
||||||
import { expandTilde } from "../utils/path.js";
|
import { expandTilde } from "../utils/path.js";
|
||||||
import {
|
import {
|
||||||
getWorktreeSetupCommands,
|
getWorktreeSetupCommands,
|
||||||
@@ -61,6 +63,15 @@ export interface NormalizedGitOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type EmitSessionMessage = (message: SessionOutboundMessage) => void;
|
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 {
|
interface BuildAgentSessionConfigDependencies {
|
||||||
paseoHome?: string;
|
paseoHome?: string;
|
||||||
@@ -70,8 +81,9 @@ interface BuildAgentSessionConfigDependencies {
|
|||||||
input: CreatePaseoWorktreeInput,
|
input: CreatePaseoWorktreeInput,
|
||||||
options?: {
|
options?: {
|
||||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||||
|
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
|
||||||
},
|
},
|
||||||
) => Promise<CreatePaseoWorktreeResult>;
|
) => Promise<CreatePaseoWorktreeWorkflowResult>;
|
||||||
checkoutExistingBranch: (cwd: string, branch: string) => Promise<CheckoutExistingBranchResult>;
|
checkoutExistingBranch: (cwd: string, branch: string) => Promise<CheckoutExistingBranchResult>;
|
||||||
createBranchFromBase: (params: {
|
createBranchFromBase: (params: {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -96,6 +108,45 @@ interface CreatePaseoWorktreeInBackgroundDependencies {
|
|||||||
onScriptsChanged: ((workspaceId: string, workspaceDirectory: string) => void) | null;
|
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 {
|
interface HandleWorkspaceSetupStatusRequestDependencies {
|
||||||
emit: EmitSessionMessage;
|
emit: EmitSessionMessage;
|
||||||
workspaceSetupSnapshots: ReadonlyMap<string, WorkspaceSetupSnapshot>;
|
workspaceSetupSnapshots: ReadonlyMap<string, WorkspaceSetupSnapshot>;
|
||||||
@@ -107,18 +158,10 @@ interface HandleCreatePaseoWorktreeRequestDependencies {
|
|||||||
result: CreatePaseoWorktreeResult,
|
result: CreatePaseoWorktreeResult,
|
||||||
) => Promise<WorkspaceDescriptorPayload>;
|
) => Promise<WorkspaceDescriptorPayload>;
|
||||||
emit: EmitSessionMessage;
|
emit: EmitSessionMessage;
|
||||||
createPaseoWorktree: (input: CreatePaseoWorktreeInput) => Promise<CreatePaseoWorktreeResult>;
|
|
||||||
warmWorkspaceGitData: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
|
||||||
sessionLogger: Logger;
|
sessionLogger: Logger;
|
||||||
runWorktreeSetupInBackground: (options: {
|
createPaseoWorktreeWorkflow: (
|
||||||
requestCwd: string;
|
input: CreatePaseoWorktreeInput,
|
||||||
repoRoot: string;
|
) => Promise<CreatePaseoWorktreeWorkflowResult>;
|
||||||
workspaceId: string;
|
|
||||||
worktree: WorktreeConfig;
|
|
||||||
shouldBootstrap: boolean;
|
|
||||||
slug: string;
|
|
||||||
worktreePath: string;
|
|
||||||
}) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildAgentSessionConfig(
|
export async function buildAgentSessionConfig(
|
||||||
@@ -127,13 +170,14 @@ export async function buildAgentSessionConfig(
|
|||||||
gitOptions?: GitSetupOptions,
|
gitOptions?: GitSetupOptions,
|
||||||
legacyWorktreeName?: string,
|
legacyWorktreeName?: string,
|
||||||
attachments?: AgentAttachment[],
|
attachments?: AgentAttachment[],
|
||||||
|
nameContext?: string,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
sessionConfig: AgentSessionConfig;
|
sessionConfig: AgentSessionConfig;
|
||||||
worktreeBootstrap?: { worktree: WorktreeConfig; shouldBootstrap: boolean };
|
setupContinuation?: AgentWorktreeSetupContinuation;
|
||||||
}> {
|
}> {
|
||||||
let cwd = expandTilde(config.cwd);
|
let cwd = expandTilde(config.cwd);
|
||||||
const normalized = normalizeGitOptions(gitOptions, legacyWorktreeName);
|
const normalized = normalizeGitOptions(gitOptions, legacyWorktreeName);
|
||||||
let worktreeBootstrap: { worktree: WorktreeConfig; shouldBootstrap: boolean } | undefined;
|
let setupContinuation: AgentWorktreeSetupContinuation | undefined;
|
||||||
|
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return {
|
return {
|
||||||
@@ -157,6 +201,7 @@ export async function buildAgentSessionConfig(
|
|||||||
refName: normalized.refName,
|
refName: normalized.refName,
|
||||||
action: normalized.action,
|
action: normalized.action,
|
||||||
githubPrNumber: normalized.githubPrNumber,
|
githubPrNumber: normalized.githubPrNumber,
|
||||||
|
nameContext,
|
||||||
attachments,
|
attachments,
|
||||||
runSetup: false,
|
runSetup: false,
|
||||||
paseoHome: dependencies.paseoHome,
|
paseoHome: dependencies.paseoHome,
|
||||||
@@ -173,10 +218,7 @@ export async function buildAgentSessionConfig(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
cwd = createdWorktree.worktree.worktreePath;
|
cwd = createdWorktree.worktree.worktreePath;
|
||||||
worktreeBootstrap = {
|
setupContinuation = createdWorktree.setupContinuation;
|
||||||
worktree: createdWorktree.worktree,
|
|
||||||
shouldBootstrap: createdWorktree.created,
|
|
||||||
};
|
|
||||||
} else if (normalized.createNewBranch) {
|
} else if (normalized.createNewBranch) {
|
||||||
const baseBranch =
|
const baseBranch =
|
||||||
normalized.baseBranch ??
|
normalized.baseBranch ??
|
||||||
@@ -201,10 +243,28 @@ export async function buildAgentSessionConfig(
|
|||||||
...config,
|
...config,
|
||||||
cwd,
|
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 {
|
interface ValidateNormalizedGitOptionsInput {
|
||||||
baseBranch: string | undefined;
|
baseBranch: string | undefined;
|
||||||
createNewBranch: boolean;
|
createNewBranch: boolean;
|
||||||
@@ -450,9 +510,10 @@ export async function handleCreatePaseoWorktreeRequest(
|
|||||||
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
|
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const createdWorktree = await dependencies.createPaseoWorktree({
|
const createdWorktree = await dependencies.createPaseoWorktreeWorkflow({
|
||||||
cwd: request.cwd,
|
cwd: request.cwd,
|
||||||
worktreeSlug: request.worktreeSlug,
|
worktreeSlug: request.worktreeSlug,
|
||||||
|
nameContext: request.nameContext,
|
||||||
refName: request.refName,
|
refName: request.refName,
|
||||||
action: request.action,
|
action: request.action,
|
||||||
githubPrNumber: request.githubPrNumber,
|
githubPrNumber: request.githubPrNumber,
|
||||||
@@ -460,8 +521,6 @@ export async function handleCreatePaseoWorktreeRequest(
|
|||||||
runSetup: false,
|
runSetup: false,
|
||||||
paseoHome: dependencies.paseoHome,
|
paseoHome: dependencies.paseoHome,
|
||||||
});
|
});
|
||||||
const slug = basename(createdWorktree.worktree.worktreePath);
|
|
||||||
const workspace = createdWorktree.workspace;
|
|
||||||
|
|
||||||
const descriptor = await dependencies.describeWorkspaceRecord(createdWorktree);
|
const descriptor = await dependencies.describeWorkspaceRecord(createdWorktree);
|
||||||
dependencies.emit({
|
dependencies.emit({
|
||||||
@@ -480,22 +539,6 @@ export async function handleCreatePaseoWorktreeRequest(
|
|||||||
workspace: descriptor,
|
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) {
|
} catch (error) {
|
||||||
const wireError = toWorktreeWireError(error);
|
const wireError = toWorktreeWireError(error);
|
||||||
dependencies.sessionLogger.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(
|
export async function handleWorkspaceSetupStatusRequest(
|
||||||
dependencies: HandleWorkspaceSetupStatusRequestDependencies,
|
dependencies: HandleWorkspaceSetupStatusRequestDependencies,
|
||||||
request: Extract<SessionInboundMessage, { type: "workspace_setup_status_request" }>,
|
request: Extract<SessionInboundMessage, { type: "workspace_setup_status_request" }>,
|
||||||
|
|||||||
@@ -1385,6 +1385,7 @@ export const CreatePaseoWorktreeRequestSchema = z.object({
|
|||||||
type: z.literal("create_paseo_worktree_request"),
|
type: z.literal("create_paseo_worktree_request"),
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
worktreeSlug: z.string().optional(),
|
worktreeSlug: z.string().optional(),
|
||||||
|
nameContext: z.string().optional(),
|
||||||
attachments: AgentAttachmentsSchema,
|
attachments: AgentAttachmentsSchema,
|
||||||
refName: z.string().min(1).optional(),
|
refName: z.string().min(1).optional(),
|
||||||
action: z.enum(["branch-off", "checkout"]).optional(),
|
action: z.enum(["branch-off", "checkout"]).optional(),
|
||||||
|
|||||||
@@ -10,6 +10,19 @@ const PaseoWorktreeMetadataV1Schema = z.object({
|
|||||||
const PaseoWorktreeMetadataV2Schema = z.object({
|
const PaseoWorktreeMetadataV2Schema = z.object({
|
||||||
version: z.literal(2),
|
version: z.literal(2),
|
||||||
baseRefName: z.string().min(1),
|
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
|
runtime: z
|
||||||
.object({
|
.object({
|
||||||
worktreePort: z.number().int().positive(),
|
worktreePort: z.number().int().positive(),
|
||||||
@@ -101,6 +114,9 @@ export function writePaseoWorktreeRuntimeMetadata(
|
|||||||
const next: PaseoWorktreeMetadata = {
|
const next: PaseoWorktreeMetadata = {
|
||||||
version: 2,
|
version: 2,
|
||||||
baseRefName: current.baseRefName,
|
baseRefName: current.baseRefName,
|
||||||
|
...(current.version === 2 && current.firstAgentBranchAutoName
|
||||||
|
? { firstAgentBranchAutoName: current.firstAgentBranchAutoName }
|
||||||
|
: {}),
|
||||||
runtime: {
|
runtime: {
|
||||||
worktreePort: options.worktreePort,
|
worktreePort: options.worktreePort,
|
||||||
},
|
},
|
||||||
@@ -108,6 +124,54 @@ export function writePaseoWorktreeRuntimeMetadata(
|
|||||||
writeFileSync(metadataPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
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 {
|
export function readPaseoWorktreeMetadata(worktreeRoot: string): PaseoWorktreeMetadata | null {
|
||||||
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||||
if (!existsSync(metadataPath)) {
|
if (!existsSync(metadataPath)) {
|
||||||
@@ -136,3 +200,12 @@ export function readPaseoWorktreeRuntimePort(worktreeRoot: string): number | nul
|
|||||||
}
|
}
|
||||||
return null;
|
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