feat: terminal reattach, explorer tab memory, worktree terminals
This commit is contained in:
@@ -28,7 +28,8 @@ describe("TerminalStreamManager", () => {
|
||||
manager.receiveChunk({
|
||||
chunk: createChunk({ streamId: 7, offset: 4, data: "hello" }),
|
||||
});
|
||||
expect(sendAck).not.toHaveBeenCalled();
|
||||
expect(sendAck).toHaveBeenCalledTimes(1);
|
||||
expect(sendAck).toHaveBeenCalledWith({ streamId: 7, offset: 9 });
|
||||
|
||||
manager.subscribe({
|
||||
streamId: 7,
|
||||
@@ -77,6 +78,9 @@ describe("TerminalStreamManager", () => {
|
||||
manager.receiveChunk({
|
||||
chunk: createChunk({ streamId: 5, offset: 2, data: "C" }),
|
||||
});
|
||||
expect(sendAck).toHaveBeenNthCalledWith(1, { streamId: 5, offset: 1 });
|
||||
expect(sendAck).toHaveBeenNthCalledWith(2, { streamId: 5, offset: 2 });
|
||||
expect(sendAck).toHaveBeenNthCalledWith(3, { streamId: 5, offset: 3 });
|
||||
|
||||
const seen: string[] = [];
|
||||
manager.subscribe({
|
||||
@@ -87,8 +91,7 @@ describe("TerminalStreamManager", () => {
|
||||
});
|
||||
|
||||
expect(seen).toEqual(["B", "C"]);
|
||||
expect(sendAck).toHaveBeenNthCalledWith(1, { streamId: 5, offset: 2 });
|
||||
expect(sendAck).toHaveBeenNthCalledWith(2, { streamId: 5, offset: 3 });
|
||||
expect(sendAck).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test("tracks explicit ack offsets and skips stale auto-acks", () => {
|
||||
|
||||
@@ -79,6 +79,12 @@ export class TerminalStreamManager {
|
||||
const streamHandlers = this.handlers.get(chunk.streamId);
|
||||
if (!streamHandlers || streamHandlers.size === 0) {
|
||||
this.bufferChunk({ chunk });
|
||||
// Ack buffered chunks immediately to keep the daemon-side stream window
|
||||
// moving even before a UI subscriber is attached.
|
||||
this.maybeAckChunk({
|
||||
streamId: chunk.streamId,
|
||||
endOffset: chunk.endOffset,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,14 +46,21 @@ import { toAgentPayload } from "./agent-projections.js";
|
||||
import { curateAgentActivity } from "./activity-curator.js";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { createWorktree } from "../../utils/worktree.js";
|
||||
import { appendTimelineItemIfAgentKnown } from "./timeline-append.js";
|
||||
import { type WorktreeConfig } from "../../utils/worktree.js";
|
||||
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
||||
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||
import { expandUserPath } from "../path-utils.js";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import {
|
||||
createAgentWorktree,
|
||||
runAsyncWorktreeBootstrap,
|
||||
} from "../worktree-bootstrap.js";
|
||||
|
||||
export interface AgentManagementMcpOptions {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
terminalManager?: TerminalManager | null;
|
||||
paseoHome?: string;
|
||||
logger: Logger;
|
||||
}
|
||||
@@ -313,12 +320,13 @@ export async function createAgentManagementMcpServer(
|
||||
};
|
||||
|
||||
let resolvedCwd = expandUserPath(cwd);
|
||||
let worktreeConfig: WorktreeConfig | undefined;
|
||||
|
||||
if (worktreeName) {
|
||||
if (!baseBranch) {
|
||||
throw new Error("baseBranch is required when creating a worktree");
|
||||
}
|
||||
const worktree = await createWorktree({
|
||||
const worktree = await createAgentWorktree({
|
||||
branchName: worktreeName,
|
||||
cwd: resolvedCwd,
|
||||
baseBranch,
|
||||
@@ -326,6 +334,7 @@ export async function createAgentManagementMcpServer(
|
||||
paseoHome: options.paseoHome,
|
||||
});
|
||||
resolvedCwd = worktree.worktreePath;
|
||||
worktreeConfig = worktree;
|
||||
}
|
||||
|
||||
const provider: AgentProvider = agentType ?? "claude";
|
||||
@@ -337,6 +346,21 @@ export async function createAgentManagementMcpServer(
|
||||
title: normalizedTitle ?? undefined,
|
||||
});
|
||||
|
||||
if (worktreeConfig) {
|
||||
void runAsyncWorktreeBootstrap({
|
||||
agentId: snapshot.id,
|
||||
worktree: worktreeConfig,
|
||||
terminalManager: options.terminalManager ?? null,
|
||||
appendTimelineItem: (item) =>
|
||||
appendTimelineItemIfAgentKnown({
|
||||
agentManager,
|
||||
agentId: snapshot.id,
|
||||
item,
|
||||
}),
|
||||
logger: childLogger,
|
||||
});
|
||||
}
|
||||
|
||||
const trimmedPrompt = initialPrompt?.trim();
|
||||
if (trimmedPrompt) {
|
||||
scheduleAgentMetadataGeneration({
|
||||
|
||||
@@ -23,6 +23,39 @@ type McpClient = {
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
async function withTimeout<T>(
|
||||
options: { promise: Promise<T>; timeoutMs: number; label: string }
|
||||
): Promise<T> {
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
reject(new Error(`Timed out after ${options.timeoutMs}ms (${options.label})`));
|
||||
}, options.timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([options.promise, timeout]);
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPathExists(
|
||||
options: { targetPath: string; timeoutMs: number }
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < options.timeoutMs) {
|
||||
if (existsSync(options.targetPath)) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out after ${options.timeoutMs}ms waiting for path: ${options.targetPath}`
|
||||
);
|
||||
}
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
@@ -53,10 +86,13 @@ function getStructuredContent(result: McpToolResult): StructuredContent | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function waitForAgentCompletion(client: McpClient, agentId: string): Promise<void> {
|
||||
const waitResult = (await client.callTool({
|
||||
async function waitForAgentCompletion(options: {
|
||||
client: McpClient;
|
||||
agentId: string;
|
||||
}): Promise<void> {
|
||||
const waitResult = (await options.client.callTool({
|
||||
name: "wait_for_agent",
|
||||
args: { agentId },
|
||||
args: { agentId: options.agentId },
|
||||
})) as McpToolResult;
|
||||
const payload = getStructuredContent(waitResult);
|
||||
if (!payload) {
|
||||
@@ -127,7 +163,7 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
agentId = (payload?.agentId as string | undefined) ?? null;
|
||||
expect(agentId).toBeTruthy();
|
||||
|
||||
await waitForAgentCompletion(client, agentId!);
|
||||
await waitForAgentCompletion({ client, agentId: agentId! });
|
||||
|
||||
if (existsSync(filePath)) {
|
||||
const contents = await readFile(filePath, "utf8");
|
||||
@@ -148,4 +184,115 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
},
|
||||
30_000
|
||||
);
|
||||
|
||||
test(
|
||||
"create_agent with worktree is async and boots terminals only after setup success",
|
||||
async () => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-worktree-repo-"));
|
||||
const port = await getAvailablePort();
|
||||
|
||||
const daemonConfig: PaseoDaemonConfig = {
|
||||
listen: `127.0.0.1:${port}`,
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
allowedHosts: true,
|
||||
mcpEnabled: true,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
agentClients: createTestAgentClients(),
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
};
|
||||
|
||||
const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" }));
|
||||
await daemon.start();
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(
|
||||
new URL(`http://127.0.0.1:${port}/mcp/agents`)
|
||||
);
|
||||
const client = (await experimental_createMCPClient({ transport })) as McpClient;
|
||||
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
const { execSync } = await import("node:child_process");
|
||||
execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git config user.email 'test@test.com'", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" });
|
||||
await writeFile(path.join(repoRoot, "file.txt"), "hello\n", "utf8");
|
||||
execSync("git add .", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoRoot, stdio: "pipe" });
|
||||
|
||||
const setupCommand =
|
||||
'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"';
|
||||
await writeFile(
|
||||
path.join(repoRoot, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: [setupCommand],
|
||||
terminals: [
|
||||
{
|
||||
name: "Dev Server",
|
||||
command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
"utf8"
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add worktree config'", {
|
||||
cwd: repoRoot,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const result = (await withTimeout({
|
||||
promise: client.callTool({
|
||||
name: "create_agent",
|
||||
args: {
|
||||
cwd: repoRoot,
|
||||
title: "MCP worktree setup terminals",
|
||||
agentType: "claude",
|
||||
initialMode: "bypassPermissions",
|
||||
initialPrompt: "say done and stop",
|
||||
worktreeName: "mcp-worktree-setup-test",
|
||||
baseBranch: "main",
|
||||
background: true,
|
||||
},
|
||||
}),
|
||||
timeoutMs: 2500,
|
||||
label: "create_agent should not block on setup",
|
||||
})) as McpToolResult;
|
||||
|
||||
const payload = getStructuredContent(result);
|
||||
agentId = (payload?.agentId as string | undefined) ?? null;
|
||||
expect(agentId).toBeTruthy();
|
||||
const worktreePath = (payload?.cwd as string | undefined) ?? "";
|
||||
expect(worktreePath).toContain(`${path.sep}worktrees${path.sep}`);
|
||||
expect(existsSync(path.join(worktreePath, "setup-done.txt"))).toBe(false);
|
||||
expect(existsSync(path.join(worktreePath, "dev-terminal.txt"))).toBe(false);
|
||||
|
||||
await writeFile(path.join(worktreePath, "allow-setup"), "ok\n", "utf8");
|
||||
|
||||
await waitForPathExists({
|
||||
targetPath: path.join(worktreePath, "setup-done.txt"),
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
await waitForPathExists({
|
||||
targetPath: path.join(worktreePath, "dev-terminal.txt"),
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
} finally {
|
||||
if (agentId) {
|
||||
await client.callTool({ name: "kill_agent", args: { agentId } });
|
||||
}
|
||||
await client.close();
|
||||
await daemon.stop();
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
await rm(staticDir, { recursive: true, force: true });
|
||||
await rm(repoRoot, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
60_000
|
||||
);
|
||||
});
|
||||
|
||||
@@ -28,7 +28,8 @@ import { toAgentPayload } from "./agent-projections.js";
|
||||
import { curateAgentActivity } from "./activity-curator.js";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { createWorktree } from "../../utils/worktree.js";
|
||||
import { appendTimelineItemIfAgentKnown } from "./timeline-append.js";
|
||||
import { type WorktreeConfig } from "../../utils/worktree.js";
|
||||
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
||||
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||
import type {
|
||||
@@ -36,10 +37,16 @@ import type {
|
||||
VoiceSpeakHandler,
|
||||
} from "../voice-types.js";
|
||||
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import {
|
||||
createAgentWorktree,
|
||||
runAsyncWorktreeBootstrap,
|
||||
} from "../worktree-bootstrap.js";
|
||||
|
||||
export interface AgentMcpServerOptions {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
terminalManager?: TerminalManager | null;
|
||||
paseoHome?: string;
|
||||
/**
|
||||
* ID of the agent that is connecting to this MCP server.
|
||||
@@ -289,6 +296,7 @@ export async function createAgentMcpServer(
|
||||
const {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
terminalManager,
|
||||
callerAgentId,
|
||||
resolveSpeakHandler,
|
||||
resolveCallerContext,
|
||||
@@ -469,6 +477,7 @@ export async function createAgentMcpServer(
|
||||
|
||||
let resolvedCwd: string;
|
||||
let resolvedMode: string | undefined;
|
||||
let worktreeConfig: WorktreeConfig | undefined;
|
||||
|
||||
if (callerAgentId) {
|
||||
const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args);
|
||||
@@ -514,7 +523,7 @@ export async function createAgentMcpServer(
|
||||
if (!baseBranch) {
|
||||
throw new Error("baseBranch is required when creating a worktree");
|
||||
}
|
||||
const worktree = await createWorktree({
|
||||
const worktree = await createAgentWorktree({
|
||||
branchName: worktreeName,
|
||||
cwd: resolvedCwd,
|
||||
baseBranch,
|
||||
@@ -522,6 +531,7 @@ export async function createAgentMcpServer(
|
||||
paseoHome: options.paseoHome,
|
||||
});
|
||||
resolvedCwd = worktree.worktreePath;
|
||||
worktreeConfig = worktree;
|
||||
}
|
||||
|
||||
resolvedMode = initialMode;
|
||||
@@ -542,6 +552,21 @@ export async function createAgentMcpServer(
|
||||
childAgentDefaultLabels ? { labels: childAgentDefaultLabels } : undefined
|
||||
);
|
||||
|
||||
if (worktreeConfig) {
|
||||
void runAsyncWorktreeBootstrap({
|
||||
agentId: snapshot.id,
|
||||
worktree: worktreeConfig,
|
||||
terminalManager: terminalManager ?? null,
|
||||
appendTimelineItem: (item) =>
|
||||
appendTimelineItemIfAgentKnown({
|
||||
agentManager,
|
||||
agentId: snapshot.id,
|
||||
item,
|
||||
}),
|
||||
logger: childLogger,
|
||||
});
|
||||
}
|
||||
|
||||
const trimmedPrompt = initialPrompt.trim();
|
||||
scheduleAgentMetadataGeneration({
|
||||
agentManager,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { AgentManager } from "./agent-manager.js";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
|
||||
export interface AppendTimelineItemIfAgentKnownOptions {
|
||||
agentManager: AgentManager;
|
||||
agentId: string;
|
||||
item: AgentTimelineItem;
|
||||
}
|
||||
|
||||
export async function appendTimelineItemIfAgentKnown(
|
||||
options: AppendTimelineItemIfAgentKnownOptions
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await options.agentManager.appendTimelineItem(options.agentId, options.item);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("Unknown agent")) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -306,6 +306,7 @@ export async function createPaseoDaemon(
|
||||
const agentMcpServer = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
terminalManager,
|
||||
paseoHome: config.paseoHome,
|
||||
enableVoiceTools: false,
|
||||
resolveSpeakHandler: (callerAgentId) => wsServer?.resolveVoiceSpeakHandler(callerAgentId) ?? null,
|
||||
@@ -329,6 +330,7 @@ export async function createPaseoDaemon(
|
||||
const agentMcpServer = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
terminalManager,
|
||||
paseoHome: config.paseoHome,
|
||||
callerAgentId,
|
||||
enableVoiceTools: false,
|
||||
@@ -448,6 +450,7 @@ export async function createPaseoDaemon(
|
||||
return createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
terminalManager,
|
||||
paseoHome: config.paseoHome,
|
||||
callerAgentId,
|
||||
voiceOnly: true,
|
||||
|
||||
@@ -16,18 +16,16 @@ function tmpCwd(): string {
|
||||
}
|
||||
|
||||
async function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
label: string
|
||||
options: { promise: Promise<T>; timeoutMs: number; label: string }
|
||||
): Promise<T> {
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
reject(new Error(`Timed out after ${timeoutMs}ms (${label})`));
|
||||
}, timeoutMs);
|
||||
reject(new Error(`Timed out after ${options.timeoutMs}ms (${options.label})`));
|
||||
}, options.timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
return await Promise.race([options.promise, timeout]);
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
@@ -92,6 +90,21 @@ async function waitForTimelineToolCall(
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForPathExists(
|
||||
options: { targetPath: string; timeoutMs: number; label: string }
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < options.timeoutMs) {
|
||||
if (existsSync(options.targetPath)) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out after ${options.timeoutMs}ms waiting for ${options.label}: ${options.targetPath}`
|
||||
);
|
||||
}
|
||||
|
||||
// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
|
||||
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||
const CODEX_TEST_THINKING_OPTION_ID = "low";
|
||||
@@ -352,8 +365,8 @@ describe("daemon E2E", () => {
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const agent = await withTimeout(
|
||||
ctx.client.createAgent({
|
||||
const agent = await withTimeout({
|
||||
promise: ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
@@ -367,9 +380,9 @@ describe("daemon E2E", () => {
|
||||
worktreeSlug: "async-setup-test",
|
||||
},
|
||||
}),
|
||||
2500,
|
||||
"createAgent should not block on setup"
|
||||
);
|
||||
timeoutMs: 2500,
|
||||
label: "createAgent should not block on setup",
|
||||
});
|
||||
|
||||
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
|
||||
expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(false);
|
||||
@@ -396,6 +409,107 @@ describe("daemon E2E", () => {
|
||||
60000
|
||||
);
|
||||
|
||||
test(
|
||||
"bootstraps configured worktree terminals after setup succeeds",
|
||||
async () => {
|
||||
const repoRoot = tmpCwd();
|
||||
|
||||
const { execSync } = await import("child_process");
|
||||
execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git config user.email 'test@test.com'", {
|
||||
cwd: repoRoot,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" });
|
||||
|
||||
writeFileSync(path.join(repoRoot, "file.txt"), "hello\n");
|
||||
execSync("git add .", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", {
|
||||
cwd: repoRoot,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" });
|
||||
|
||||
const setupCommand =
|
||||
'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"';
|
||||
writeFileSync(
|
||||
path.join(repoRoot, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: [setupCommand],
|
||||
terminals: [
|
||||
{
|
||||
name: "Dev Server",
|
||||
command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null',
|
||||
},
|
||||
{
|
||||
command: 'echo "lint-watch" > lint-terminal.txt; tail -f /dev/null',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add setup and terminals'", {
|
||||
cwd: repoRoot,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const agent = await withTimeout({
|
||||
promise: ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd: repoRoot,
|
||||
title: "Async Worktree Setup + Terminals Test",
|
||||
git: {
|
||||
createWorktree: true,
|
||||
createNewBranch: true,
|
||||
baseBranch: "main",
|
||||
newBranchName: "async-setup-terminals-test",
|
||||
worktreeSlug: "async-setup-terminals-test",
|
||||
},
|
||||
}),
|
||||
timeoutMs: 2500,
|
||||
label: "createAgent should not block on setup",
|
||||
});
|
||||
|
||||
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
|
||||
expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(false);
|
||||
expect(existsSync(path.join(agent.cwd, "dev-terminal.txt"))).toBe(false);
|
||||
expect(existsSync(path.join(agent.cwd, "lint-terminal.txt"))).toBe(false);
|
||||
|
||||
writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n");
|
||||
|
||||
await waitForTimelineToolCall(
|
||||
collector.messages,
|
||||
agent.id,
|
||||
(item) => item.name === "paseo_worktree_setup" && item.status === "completed",
|
||||
20000
|
||||
);
|
||||
|
||||
await waitForPathExists({
|
||||
targetPath: path.join(agent.cwd, "dev-terminal.txt"),
|
||||
timeoutMs: 15000,
|
||||
label: "dev terminal marker",
|
||||
});
|
||||
await waitForPathExists({
|
||||
targetPath: path.join(agent.cwd, "lint-terminal.txt"),
|
||||
timeoutMs: 15000,
|
||||
label: "lint terminal marker",
|
||||
});
|
||||
|
||||
const list = await ctx.client.listTerminals(agent.cwd);
|
||||
expect(list.error).toBeUndefined();
|
||||
expect(list.terminals.some((terminal) => terminal.name === "Dev Server")).toBe(true);
|
||||
expect(list.terminals.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(repoRoot, { recursive: true, force: true });
|
||||
},
|
||||
60000
|
||||
);
|
||||
|
||||
test(
|
||||
"reports failures via timeline tool_call without deleting the created worktree",
|
||||
async () => {
|
||||
@@ -421,7 +535,17 @@ describe("daemon E2E", () => {
|
||||
'echo "started" > "$PASEO_WORKTREE_PATH/setup-start.txt"; sleep 0.1; echo "boom" 1>&2; exit 7';
|
||||
writeFileSync(
|
||||
path.join(repoRoot, "paseo.json"),
|
||||
JSON.stringify({ worktree: { setup: [setupCommand] } })
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: [setupCommand],
|
||||
terminals: [
|
||||
{
|
||||
name: "Should Not Start",
|
||||
command: 'echo "should-not-run" > should-not-run.txt; tail -f /dev/null',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add failing setup'", {
|
||||
@@ -429,8 +553,8 @@ describe("daemon E2E", () => {
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const agent = await withTimeout(
|
||||
ctx.client.createAgent({
|
||||
const agent = await withTimeout({
|
||||
promise: ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
@@ -444,9 +568,9 @@ describe("daemon E2E", () => {
|
||||
worktreeSlug: "async-setup-failure-test",
|
||||
},
|
||||
}),
|
||||
2500,
|
||||
"createAgent should not block on failing setup"
|
||||
);
|
||||
timeoutMs: 2500,
|
||||
label: "createAgent should not block on failing setup",
|
||||
});
|
||||
|
||||
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
|
||||
expect(existsSync(agent.cwd)).toBe(true);
|
||||
@@ -469,6 +593,7 @@ describe("daemon E2E", () => {
|
||||
);
|
||||
|
||||
expect(existsSync(path.join(agent.cwd, "setup-start.txt"))).toBe(true);
|
||||
expect(existsSync(path.join(agent.cwd, "should-not-run.txt"))).toBe(false);
|
||||
|
||||
const output = failed.detail.type === "unknown" ? failed.detail.output as any : undefined;
|
||||
const commands = output?.commands as any[] | undefined;
|
||||
|
||||
@@ -68,6 +68,7 @@ import type {
|
||||
} from "./agent/agent-manager.js";
|
||||
import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js";
|
||||
import { toAgentPayload } from "./agent/agent-projections.js";
|
||||
import { appendTimelineItemIfAgentKnown } from "./agent/timeline-append.js";
|
||||
import { projectTimelineRows, type TimelineProjectionMode } from "./agent/timeline-projection.js";
|
||||
import {
|
||||
StructuredAgentResponseError,
|
||||
@@ -83,7 +84,6 @@ import type {
|
||||
AgentStreamEvent,
|
||||
AgentProvider,
|
||||
AgentPersistenceHandle,
|
||||
AgentTimelineItem,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
|
||||
@@ -101,18 +101,18 @@ import {
|
||||
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import { PushTokenStore } from "./push/token-store.js";
|
||||
import {
|
||||
createWorktree,
|
||||
runWorktreeSetupCommands,
|
||||
WorktreeSetupError,
|
||||
type WorktreeConfig,
|
||||
type WorktreeSetupCommandResult,
|
||||
slugify,
|
||||
validateBranchSlug,
|
||||
listPaseoWorktrees,
|
||||
deletePaseoWorktree,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
resolvePaseoWorktreeRootForCwd,
|
||||
} from "../utils/worktree.js";
|
||||
slugify,
|
||||
validateBranchSlug,
|
||||
listPaseoWorktrees,
|
||||
deletePaseoWorktree,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
resolvePaseoWorktreeRootForCwd,
|
||||
} from "../utils/worktree.js";
|
||||
import {
|
||||
createAgentWorktree,
|
||||
runAsyncWorktreeBootstrap,
|
||||
} from "./worktree-bootstrap.js";
|
||||
import {
|
||||
getCheckoutDiff,
|
||||
getCheckoutStatus,
|
||||
@@ -208,19 +208,22 @@ function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
|
||||
return `remote:${cleanedHost}/${cleanedPath}`;
|
||||
}
|
||||
|
||||
function deriveProjectGroupingKey(cwd: string, remoteUrl: string | null): string {
|
||||
const remoteKey = deriveRemoteProjectKey(remoteUrl);
|
||||
function deriveProjectGroupingKey(options: {
|
||||
cwd: string;
|
||||
remoteUrl: string | null;
|
||||
}): string {
|
||||
const remoteKey = deriveRemoteProjectKey(options.remoteUrl);
|
||||
if (remoteKey) {
|
||||
return remoteKey;
|
||||
}
|
||||
|
||||
const worktreeMarker = ".paseo/worktrees/";
|
||||
const idx = cwd.indexOf(worktreeMarker);
|
||||
const idx = options.cwd.indexOf(worktreeMarker);
|
||||
if (idx !== -1) {
|
||||
return cwd.slice(0, idx).replace(/\/$/, "");
|
||||
return options.cwd.slice(0, idx).replace(/\/$/, "");
|
||||
}
|
||||
|
||||
return cwd;
|
||||
return options.cwd;
|
||||
}
|
||||
|
||||
function deriveProjectGroupingName(projectKey: string): string {
|
||||
@@ -1123,7 +1126,10 @@ export class Session {
|
||||
const checkout = await getCheckoutStatusLite(cwd, { paseoHome: this.paseoHome })
|
||||
.then((status) => this.toProjectCheckoutLite(cwd, status))
|
||||
.catch(() => this.buildFallbackProjectCheckout(cwd));
|
||||
const projectKey = deriveProjectGroupingKey(cwd, checkout.remoteUrl);
|
||||
const projectKey = deriveProjectGroupingKey({
|
||||
cwd,
|
||||
remoteUrl: checkout.remoteUrl,
|
||||
});
|
||||
return {
|
||||
projectKey,
|
||||
projectName: deriveProjectGroupingName(projectKey),
|
||||
@@ -2398,7 +2404,18 @@ export class Session {
|
||||
}
|
||||
|
||||
if (worktreeConfig) {
|
||||
void this.runAsyncWorktreeSetup(snapshot.id, worktreeConfig);
|
||||
void runAsyncWorktreeBootstrap({
|
||||
agentId: snapshot.id,
|
||||
worktree: worktreeConfig,
|
||||
terminalManager: this.terminalManager,
|
||||
appendTimelineItem: (item) =>
|
||||
appendTimelineItemIfAgentKnown({
|
||||
agentManager: this.agentManager,
|
||||
agentId: snapshot.id,
|
||||
item,
|
||||
}),
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
}
|
||||
|
||||
this.sessionLogger.info(
|
||||
@@ -2627,12 +2644,11 @@ export class Session {
|
||||
}' for branch ${targetBranch}`
|
||||
);
|
||||
|
||||
const createdWorktree = await createWorktree({
|
||||
const createdWorktree = await createAgentWorktree({
|
||||
branchName: targetBranch,
|
||||
cwd,
|
||||
baseBranch: normalized.baseBranch!,
|
||||
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
|
||||
runSetup: false,
|
||||
paseoHome: this.paseoHome,
|
||||
});
|
||||
cwd = createdWorktree.worktreePath;
|
||||
@@ -2656,108 +2672,6 @@ export class Session {
|
||||
};
|
||||
}
|
||||
|
||||
private async runAsyncWorktreeSetup(
|
||||
agentId: string,
|
||||
worktree: WorktreeConfig
|
||||
): Promise<void> {
|
||||
const callId = uuidv4();
|
||||
let results: WorktreeSetupCommandResult[] = [];
|
||||
try {
|
||||
const started = await this.safeAppendTimelineItem(agentId, {
|
||||
type: "tool_call",
|
||||
name: "paseo_worktree_setup",
|
||||
callId,
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: {
|
||||
worktreePath: worktree.worktreePath,
|
||||
branchName: worktree.branchName,
|
||||
},
|
||||
output: null,
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
if (!started) {
|
||||
return;
|
||||
}
|
||||
|
||||
results = await runWorktreeSetupCommands({
|
||||
worktreePath: worktree.worktreePath,
|
||||
branchName: worktree.branchName,
|
||||
cleanupOnFailure: false,
|
||||
});
|
||||
|
||||
await this.safeAppendTimelineItem(agentId, {
|
||||
type: "tool_call",
|
||||
name: "paseo_worktree_setup",
|
||||
callId,
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: {
|
||||
worktreePath: worktree.worktreePath,
|
||||
branchName: worktree.branchName,
|
||||
},
|
||||
output: {
|
||||
worktreePath: worktree.worktreePath,
|
||||
commands: results.map((result) => ({
|
||||
command: result.command,
|
||||
cwd: result.cwd,
|
||||
exitCode: result.exitCode,
|
||||
output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(),
|
||||
})),
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error instanceof WorktreeSetupError) {
|
||||
results = error.results;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await this.safeAppendTimelineItem(agentId, {
|
||||
type: "tool_call",
|
||||
name: "paseo_worktree_setup",
|
||||
callId,
|
||||
status: "failed",
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: {
|
||||
worktreePath: worktree.worktreePath,
|
||||
branchName: worktree.branchName,
|
||||
},
|
||||
output: {
|
||||
worktreePath: worktree.worktreePath,
|
||||
commands: results.map((result) => ({
|
||||
command: result.command,
|
||||
cwd: result.cwd,
|
||||
exitCode: result.exitCode,
|
||||
output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(),
|
||||
})),
|
||||
},
|
||||
},
|
||||
error: { message },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async safeAppendTimelineItem(
|
||||
agentId: string,
|
||||
item: AgentTimelineItem
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await this.agentManager.appendTimelineItem(agentId, item);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("Unknown agent")) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleListProviderModelsRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "list_provider_models_request" }>
|
||||
): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { Logger } from "pino";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import {
|
||||
createWorktree,
|
||||
getWorktreeTerminalSpecs,
|
||||
runWorktreeSetupCommands,
|
||||
WorktreeSetupError,
|
||||
type WorktreeConfig,
|
||||
type WorktreeSetupCommandResult,
|
||||
} from "../utils/worktree.js";
|
||||
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
|
||||
|
||||
export interface WorktreeBootstrapTerminalResult {
|
||||
name: string | null;
|
||||
command: string;
|
||||
status: "started" | "failed";
|
||||
terminalId: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface RunAsyncWorktreeBootstrapOptions {
|
||||
agentId: string;
|
||||
worktree: WorktreeConfig;
|
||||
terminalManager: TerminalManager | null;
|
||||
appendTimelineItem: (item: AgentTimelineItem) => Promise<boolean>;
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
export interface CreateAgentWorktreeOptions {
|
||||
cwd: string;
|
||||
branchName: string;
|
||||
baseBranch: string;
|
||||
worktreeSlug: string;
|
||||
paseoHome?: string;
|
||||
}
|
||||
|
||||
export async function createAgentWorktree(
|
||||
options: CreateAgentWorktreeOptions
|
||||
): Promise<WorktreeConfig> {
|
||||
return createWorktree({
|
||||
branchName: options.branchName,
|
||||
cwd: options.cwd,
|
||||
baseBranch: options.baseBranch,
|
||||
worktreeSlug: options.worktreeSlug,
|
||||
runSetup: false,
|
||||
paseoHome: options.paseoHome,
|
||||
});
|
||||
}
|
||||
|
||||
function buildSetupTimelineItem(input: {
|
||||
callId: string;
|
||||
status: "running" | "completed" | "failed";
|
||||
worktree: WorktreeConfig;
|
||||
results: WorktreeSetupCommandResult[];
|
||||
errorMessage: string | null;
|
||||
}): AgentTimelineItem {
|
||||
const detailInput = {
|
||||
worktreePath: input.worktree.worktreePath,
|
||||
branchName: input.worktree.branchName,
|
||||
};
|
||||
const detailOutput = {
|
||||
worktreePath: input.worktree.worktreePath,
|
||||
commands: input.results.map((result) => ({
|
||||
command: result.command,
|
||||
cwd: result.cwd,
|
||||
exitCode: result.exitCode,
|
||||
output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(),
|
||||
})),
|
||||
};
|
||||
|
||||
if (input.status === "running") {
|
||||
return {
|
||||
type: "tool_call",
|
||||
name: "paseo_worktree_setup",
|
||||
callId: input.callId,
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: detailInput,
|
||||
output: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.status === "completed") {
|
||||
return {
|
||||
type: "tool_call",
|
||||
name: "paseo_worktree_setup",
|
||||
callId: input.callId,
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: detailInput,
|
||||
output: detailOutput,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "tool_call",
|
||||
name: "paseo_worktree_setup",
|
||||
callId: input.callId,
|
||||
status: "failed",
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: detailInput,
|
||||
output: detailOutput,
|
||||
},
|
||||
error: { message: input.errorMessage ?? "Worktree setup failed" },
|
||||
};
|
||||
}
|
||||
|
||||
function buildTerminalTimelineItem(input: {
|
||||
callId: string;
|
||||
status: "running" | "completed" | "failed";
|
||||
worktree: WorktreeConfig;
|
||||
results: WorktreeBootstrapTerminalResult[];
|
||||
errorMessage: string | null;
|
||||
}): AgentTimelineItem {
|
||||
const detailInput = {
|
||||
worktreePath: input.worktree.worktreePath,
|
||||
branchName: input.worktree.branchName,
|
||||
};
|
||||
const detailOutput = {
|
||||
worktreePath: input.worktree.worktreePath,
|
||||
terminals: input.results,
|
||||
};
|
||||
|
||||
if (input.status === "running") {
|
||||
return {
|
||||
type: "tool_call",
|
||||
name: "paseo_worktree_terminals",
|
||||
callId: input.callId,
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: detailInput,
|
||||
output: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.status === "completed") {
|
||||
return {
|
||||
type: "tool_call",
|
||||
name: "paseo_worktree_terminals",
|
||||
callId: input.callId,
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: detailInput,
|
||||
output: detailOutput,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "tool_call",
|
||||
name: "paseo_worktree_terminals",
|
||||
callId: input.callId,
|
||||
status: "failed",
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: detailInput,
|
||||
output: detailOutput,
|
||||
},
|
||||
error: { message: input.errorMessage ?? "Worktree terminal bootstrap failed" },
|
||||
};
|
||||
}
|
||||
|
||||
async function runWorktreeTerminalBootstrap(
|
||||
options: RunAsyncWorktreeBootstrapOptions
|
||||
): Promise<void> {
|
||||
const terminalSpecs = getWorktreeTerminalSpecs(options.worktree.worktreePath);
|
||||
if (terminalSpecs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callId = uuidv4();
|
||||
const started = await options.appendTimelineItem(
|
||||
buildTerminalTimelineItem({
|
||||
callId,
|
||||
status: "running",
|
||||
worktree: options.worktree,
|
||||
results: [],
|
||||
errorMessage: null,
|
||||
})
|
||||
);
|
||||
if (!started) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.terminalManager) {
|
||||
await options.appendTimelineItem(
|
||||
buildTerminalTimelineItem({
|
||||
callId,
|
||||
status: "failed",
|
||||
worktree: options.worktree,
|
||||
results: [],
|
||||
errorMessage: "Terminal manager not available",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const results: WorktreeBootstrapTerminalResult[] = [];
|
||||
for (const spec of terminalSpecs) {
|
||||
try {
|
||||
const terminal = await options.terminalManager.createTerminal({
|
||||
cwd: options.worktree.worktreePath,
|
||||
name: spec.name,
|
||||
});
|
||||
terminal.send({
|
||||
type: "input",
|
||||
data: `${spec.command}\r`,
|
||||
});
|
||||
results.push({
|
||||
name: terminal.name ?? spec.name ?? null,
|
||||
command: spec.command,
|
||||
status: "started",
|
||||
terminalId: terminal.id,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.logger?.warn(
|
||||
{ agentId: options.agentId, command: spec.command, err: error },
|
||||
"Failed to bootstrap worktree terminal"
|
||||
);
|
||||
results.push({
|
||||
name: spec.name ?? null,
|
||||
command: spec.command,
|
||||
status: "failed",
|
||||
terminalId: null,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await options.appendTimelineItem(
|
||||
buildTerminalTimelineItem({
|
||||
callId,
|
||||
status: "completed",
|
||||
worktree: options.worktree,
|
||||
results,
|
||||
errorMessage: null,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function runAsyncWorktreeBootstrap(
|
||||
options: RunAsyncWorktreeBootstrapOptions
|
||||
): Promise<void> {
|
||||
const setupCallId = uuidv4();
|
||||
let setupResults: WorktreeSetupCommandResult[] = [];
|
||||
|
||||
try {
|
||||
const started = await options.appendTimelineItem(
|
||||
buildSetupTimelineItem({
|
||||
callId: setupCallId,
|
||||
status: "running",
|
||||
worktree: options.worktree,
|
||||
results: [],
|
||||
errorMessage: null,
|
||||
})
|
||||
);
|
||||
if (!started) {
|
||||
return;
|
||||
}
|
||||
|
||||
setupResults = await runWorktreeSetupCommands({
|
||||
worktreePath: options.worktree.worktreePath,
|
||||
branchName: options.worktree.branchName,
|
||||
cleanupOnFailure: false,
|
||||
});
|
||||
|
||||
await options.appendTimelineItem(
|
||||
buildSetupTimelineItem({
|
||||
callId: setupCallId,
|
||||
status: "completed",
|
||||
worktree: options.worktree,
|
||||
results: setupResults,
|
||||
errorMessage: null,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof WorktreeSetupError) {
|
||||
setupResults = error.results;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await options.appendTimelineItem(
|
||||
buildSetupTimelineItem({
|
||||
callId: setupCallId,
|
||||
status: "failed",
|
||||
worktree: options.worktree,
|
||||
results: setupResults,
|
||||
errorMessage: message,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await runWorktreeTerminalBootstrap(options);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
createWorktree,
|
||||
deletePaseoWorktree,
|
||||
getWorktreeTerminalSpecs,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
listPaseoWorktrees,
|
||||
slugify,
|
||||
@@ -262,6 +263,43 @@ describe("createWorktree", () => {
|
||||
// Verify worktree was cleaned up
|
||||
expect(existsSync(expectedWorktreePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("reads worktree terminal specs from paseo.json with optional name", async () => {
|
||||
const paseoConfig = {
|
||||
worktree: {
|
||||
terminals: [
|
||||
{ name: "Dev Server", command: "npm run dev" },
|
||||
{ command: "cd packages/app && npm run dev" },
|
||||
],
|
||||
},
|
||||
};
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
||||
|
||||
expect(getWorktreeTerminalSpecs(repoDir)).toEqual([
|
||||
{ name: "Dev Server", command: "npm run dev" },
|
||||
{ command: "cd packages/app && npm run dev" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters invalid worktree terminal specs", async () => {
|
||||
const paseoConfig = {
|
||||
worktree: {
|
||||
terminals: [
|
||||
null,
|
||||
{},
|
||||
{ name: " ", command: " " },
|
||||
{ name: " Watch ", command: "npm run watch", cwd: "packages/app" },
|
||||
{ name: 123, command: "npm run test" },
|
||||
],
|
||||
},
|
||||
};
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
||||
|
||||
expect(getWorktreeTerminalSpecs(repoDir)).toEqual([
|
||||
{ name: "Watch", command: "npm run watch" },
|
||||
{ command: "npm run test" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("paseo worktree manager", () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ interface PaseoConfig {
|
||||
worktree?: {
|
||||
setup?: string[];
|
||||
destroy?: string[];
|
||||
terminals?: WorktreeTerminalConfig[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,6 +34,11 @@ export type WorktreeSetupCommandResult = {
|
||||
exitCode: number | null;
|
||||
};
|
||||
|
||||
export interface WorktreeTerminalConfig {
|
||||
name?: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
export class WorktreeSetupError extends Error {
|
||||
readonly results: WorktreeSetupCommandResult[];
|
||||
|
||||
@@ -107,6 +113,42 @@ export function getWorktreeDestroyCommands(repoRoot: string): string[] {
|
||||
return destroyCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0);
|
||||
}
|
||||
|
||||
export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConfig[] {
|
||||
const config = readPaseoConfig(repoRoot);
|
||||
const terminals = config?.worktree?.terminals;
|
||||
if (!Array.isArray(terminals) || terminals.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const specs: WorktreeTerminalConfig[] = [];
|
||||
for (const terminal of terminals) {
|
||||
if (!terminal || typeof terminal !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawCommand = terminal.command;
|
||||
if (typeof rawCommand !== "string") {
|
||||
continue;
|
||||
}
|
||||
const command = rawCommand.trim();
|
||||
if (!command) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawName = terminal.name;
|
||||
const name = typeof rawName === "string" && rawName.trim().length > 0
|
||||
? rawName.trim()
|
||||
: undefined;
|
||||
|
||||
specs.push({
|
||||
...(name ? { name } : {}),
|
||||
command,
|
||||
});
|
||||
}
|
||||
|
||||
return specs;
|
||||
}
|
||||
|
||||
async function execSetupCommand(
|
||||
command: string,
|
||||
options: { cwd: string; env: NodeJS.ProcessEnv }
|
||||
|
||||
Reference in New Issue
Block a user