feat: improve agent workspace flows and directory suggestions
This commit is contained in:
@@ -347,6 +347,63 @@ describe("DaemonClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("requests directory suggestions via RPC", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const promise = client.getDirectorySuggestions(
|
||||
{ query: "proj", limit: 10 },
|
||||
"req-directories"
|
||||
);
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "directory_suggestions_request";
|
||||
query: string;
|
||||
limit?: number;
|
||||
requestId: string;
|
||||
};
|
||||
};
|
||||
expect(request.message.type).toBe("directory_suggestions_request");
|
||||
expect(request.message.query).toBe("proj");
|
||||
expect(request.message.limit).toBe(10);
|
||||
expect(request.message.requestId).toBe("req-directories");
|
||||
|
||||
mock.triggerMessage(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "directory_suggestions_response",
|
||||
payload: {
|
||||
directories: ["/Users/test/projects/paseo"],
|
||||
error: null,
|
||||
requestId: "req-directories",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
directories: ["/Users/test/projects/paseo"],
|
||||
error: null,
|
||||
requestId: "req-directories",
|
||||
});
|
||||
});
|
||||
|
||||
test("requests checkout merge from base via RPC", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
CheckoutPrStatusResponse,
|
||||
ValidateBranchResponse,
|
||||
BranchSuggestionsResponse,
|
||||
DirectorySuggestionsResponse,
|
||||
PaseoWorktreeListResponse,
|
||||
PaseoWorktreeArchiveResponse,
|
||||
ProjectIconResponse,
|
||||
@@ -197,6 +198,7 @@ type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
|
||||
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
||||
type ValidateBranchPayload = ValidateBranchResponse["payload"];
|
||||
type BranchSuggestionsPayload = BranchSuggestionsResponse["payload"];
|
||||
type DirectorySuggestionsPayload = DirectorySuggestionsResponse["payload"];
|
||||
type PaseoWorktreeListPayload = PaseoWorktreeListResponse["payload"];
|
||||
type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse["payload"];
|
||||
type FileExplorerPayload = FileExplorerResponse["payload"];
|
||||
@@ -2082,6 +2084,22 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getDirectorySuggestions(
|
||||
options: { query: string; limit?: number },
|
||||
requestId?: string
|
||||
): Promise<DirectorySuggestionsPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "directory_suggestions_request",
|
||||
query: options.query,
|
||||
limit: options.limit,
|
||||
},
|
||||
responseType: "directory_suggestions_response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// File Explorer
|
||||
// ============================================================================
|
||||
|
||||
@@ -168,6 +168,32 @@ describe("daemon client E2E", () => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 30000);
|
||||
|
||||
test("returns home-scoped directory suggestions", async () => {
|
||||
const insideHomeDir = mkdtempSync(path.join(homedir(), "paseo-dir-suggestion-"));
|
||||
const outsideHomeDir = mkdtempSync(path.join(tmpdir(), "paseo-dir-suggestion-outside-"));
|
||||
|
||||
try {
|
||||
const insideQuery = path.basename(insideHomeDir);
|
||||
const insideResult = await ctx.client.getDirectorySuggestions({
|
||||
query: insideQuery,
|
||||
limit: 25,
|
||||
});
|
||||
expect(insideResult.error).toBeNull();
|
||||
expect(insideResult.directories).toContain(insideHomeDir);
|
||||
|
||||
const outsideQuery = path.basename(outsideHomeDir);
|
||||
const outsideResult = await ctx.client.getDirectorySuggestions({
|
||||
query: outsideQuery,
|
||||
limit: 25,
|
||||
});
|
||||
expect(outsideResult.error).toBeNull();
|
||||
expect(outsideResult.directories).not.toContain(outsideHomeDir);
|
||||
} finally {
|
||||
rmSync(insideHomeDir, { recursive: true, force: true });
|
||||
rmSync(outsideHomeDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test("emits server_info on websocket connect", async () => {
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
|
||||
|
||||
@@ -105,6 +105,43 @@ async function waitForPathExists(
|
||||
);
|
||||
}
|
||||
|
||||
async function withShell<T>(shell: string, run: () => Promise<T>): Promise<T> {
|
||||
const originalShell = process.env.SHELL;
|
||||
process.env.SHELL = shell;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
if (originalShell === undefined) {
|
||||
delete process.env.SHELL;
|
||||
} else {
|
||||
process.env.SHELL = originalShell;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface WorktreeTerminalBootstrapEntry {
|
||||
name: string | null;
|
||||
command: string;
|
||||
status: "started" | "failed";
|
||||
terminalId: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function getWorktreeTerminalBootstrapEntries(
|
||||
item: Extract<AgentTimelineItem, { type: "tool_call" }>
|
||||
): WorktreeTerminalBootstrapEntry[] | null {
|
||||
const detail = item.detail;
|
||||
if (!detail || detail.type !== "unknown" || !detail.output) {
|
||||
return null;
|
||||
}
|
||||
const output = detail.output as Record<string, unknown>;
|
||||
const terminals = output.terminals;
|
||||
if (!Array.isArray(terminals)) {
|
||||
return null;
|
||||
}
|
||||
return terminals as WorktreeTerminalBootstrapEntry[];
|
||||
}
|
||||
|
||||
// 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";
|
||||
@@ -413,100 +450,136 @@ describe("daemon E2E", () => {
|
||||
test(
|
||||
"bootstraps configured worktree terminals after setup succeeds",
|
||||
async () => {
|
||||
const repoRoot = tmpCwd();
|
||||
await withShell("/bin/sh", 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,
|
||||
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,
|
||||
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",
|
||||
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"; echo "$PASEO_WORKTREE_PORT" > "$PASEO_WORKTREE_PATH/setup-port.txt"';
|
||||
writeFileSync(
|
||||
path.join(repoRoot, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: [setupCommand],
|
||||
terminals: [
|
||||
{
|
||||
name: "Dev Server",
|
||||
command: "tail -f /dev/null",
|
||||
},
|
||||
{
|
||||
command: "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
|
||||
);
|
||||
const terminalsBootstrapToolCall = await waitForTimelineToolCall(
|
||||
collector.messages,
|
||||
agent.id,
|
||||
(item) => item.name === "paseo_worktree_terminals" && item.status === "completed",
|
||||
30000
|
||||
);
|
||||
const bootstrappedTerminals = getWorktreeTerminalBootstrapEntries(
|
||||
terminalsBootstrapToolCall
|
||||
);
|
||||
expect(bootstrappedTerminals).toBeTruthy();
|
||||
expect(bootstrappedTerminals?.length ?? 0).toBeGreaterThanOrEqual(2);
|
||||
const failedBootstraps =
|
||||
bootstrappedTerminals?.filter((terminal) => terminal.status === "failed") ?? [];
|
||||
expect(failedBootstraps).toEqual([]);
|
||||
|
||||
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 waitForPathExists({
|
||||
targetPath: path.join(agent.cwd, "setup-port.txt"),
|
||||
timeoutMs: 30000,
|
||||
label: "setup runtime port marker",
|
||||
});
|
||||
|
||||
const setupPort = readFileSync(path.join(agent.cwd, "setup-port.txt"), "utf8").trim();
|
||||
expect(setupPort.length).toBeGreaterThan(0);
|
||||
|
||||
const createdTerminal = await ctx.client.createTerminal(agent.cwd, "Manual Port Check");
|
||||
expect(createdTerminal.error).toBeNull();
|
||||
expect(createdTerminal.terminal).toBeTruthy();
|
||||
const manualTerminalId = createdTerminal.terminal?.id;
|
||||
expect(manualTerminalId).toBeTruthy();
|
||||
if (!manualTerminalId) {
|
||||
throw new Error("Expected manual terminal id");
|
||||
}
|
||||
ctx.client.sendTerminalInput(manualTerminalId, {
|
||||
type: "input",
|
||||
data: 'echo "$PASEO_WORKTREE_PORT" > "$PASEO_WORKTREE_PATH/manual-terminal-port.txt"\r',
|
||||
});
|
||||
await waitForPathExists({
|
||||
targetPath: path.join(agent.cwd, "manual-terminal-port.txt"),
|
||||
timeoutMs: 30000,
|
||||
label: "manual terminal runtime port marker",
|
||||
});
|
||||
const manualTerminalPort = readFileSync(
|
||||
path.join(agent.cwd, "manual-terminal-port.txt"),
|
||||
"utf8"
|
||||
).trim();
|
||||
expect(manualTerminalPort).toBe(setupPort);
|
||||
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(repoRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { stat } from "fs/promises";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { join, resolve, sep } from "path";
|
||||
import { homedir } from "node:os";
|
||||
import { z } from "zod";
|
||||
import type { ToolSet } from "ai";
|
||||
import {
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
type DetachTerminalStreamRequest,
|
||||
type SubscribeCheckoutDiffRequest,
|
||||
type UnsubscribeCheckoutDiffRequest,
|
||||
type DirectorySuggestionsRequest,
|
||||
type ProjectCheckoutLitePayload,
|
||||
type ProjectPlacementPayload,
|
||||
} from "./messages.js";
|
||||
@@ -138,6 +140,7 @@ import {
|
||||
} from "../utils/checkout-git.js";
|
||||
import { getProjectIcon } from "../utils/project-icon.js";
|
||||
import { expandTilde } from "../utils/path.js";
|
||||
import { searchHomeDirectories } from "../utils/directory-suggestions.js";
|
||||
import {
|
||||
ensureLocalSpeechModels,
|
||||
getLocalSpeechModelDir,
|
||||
@@ -1347,6 +1350,10 @@ export class Session {
|
||||
await this.handleBranchSuggestionsRequest(msg);
|
||||
break;
|
||||
|
||||
case "directory_suggestions_request":
|
||||
await this.handleDirectorySuggestionsRequest(msg);
|
||||
break;
|
||||
|
||||
case "subscribe_checkout_diff_request":
|
||||
await this.handleSubscribeCheckoutDiffRequest(msg);
|
||||
break;
|
||||
@@ -3563,9 +3570,10 @@ export class Session {
|
||||
msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>
|
||||
): Promise<void> {
|
||||
const { cwd, requestId } = msg;
|
||||
const resolvedCwd = expandTilde(cwd);
|
||||
|
||||
try {
|
||||
const status = await getCheckoutStatus(cwd, { paseoHome: this.paseoHome });
|
||||
const status = await getCheckoutStatus(resolvedCwd, { paseoHome: this.paseoHome });
|
||||
if (!status.isGit) {
|
||||
this.emit({
|
||||
type: "checkout_status_response",
|
||||
@@ -3754,6 +3762,37 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDirectorySuggestionsRequest(
|
||||
msg: DirectorySuggestionsRequest
|
||||
): Promise<void> {
|
||||
const { query, limit, requestId } = msg;
|
||||
|
||||
try {
|
||||
const directories = await searchHomeDirectories({
|
||||
homeDir: process.env.HOME ?? homedir(),
|
||||
query,
|
||||
limit,
|
||||
});
|
||||
this.emit({
|
||||
type: "directory_suggestions_response",
|
||||
payload: {
|
||||
directories,
|
||||
error: null,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.emit({
|
||||
type: "directory_suggestions_response",
|
||||
payload: {
|
||||
directories: [],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeCheckoutDiffCompare(compare: CheckoutDiffCompareInput): CheckoutDiffCompareInput {
|
||||
if (compare.mode === "uncommitted") {
|
||||
return { mode: "uncommitted" };
|
||||
|
||||
@@ -5,6 +5,7 @@ import { parser as cssParser } from "@lezer/css";
|
||||
import { parser as htmlParser } from "@lezer/html";
|
||||
import { parser as pythonParser } from "@lezer/python";
|
||||
import { parser as markdownParser } from "@lezer/markdown";
|
||||
import { parser as elixirParser } from "lezer-elixir";
|
||||
import type { Parser } from "@lezer/common";
|
||||
|
||||
// Map file extensions to parsers
|
||||
@@ -26,6 +27,9 @@ const parsersByExtension: Record<string, Parser> = {
|
||||
htm: htmlParser,
|
||||
// Python
|
||||
py: pythonParser,
|
||||
// Elixir
|
||||
ex: elixirParser,
|
||||
exs: elixirParser,
|
||||
// Markdown
|
||||
md: markdownParser,
|
||||
mdx: markdownParser,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { execSync } from "child_process";
|
||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs";
|
||||
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
|
||||
@@ -15,6 +15,17 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
let repoDir: string;
|
||||
let paseoHome: string;
|
||||
|
||||
async function waitForPathExists(targetPath: string, timeoutMs = 10000): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (existsSync(targetPath)) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
throw new Error(`Timed out waiting for path: ${targetPath}`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-bootstrap-test-")));
|
||||
repoDir = join(tempDir, "repo");
|
||||
@@ -236,4 +247,216 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
expect(persistedSetupItem.detail.log).toContain("-suffix");
|
||||
expect(persistedSetupItem.detail.log).toContain("...<output truncated in the middle>...");
|
||||
});
|
||||
|
||||
it("waits for terminal output before sending bootstrap commands", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
terminals: [
|
||||
{
|
||||
name: "Ready Terminal",
|
||||
command: "echo ready",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add terminal bootstrap config'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const worktree = await createAgentWorktree({
|
||||
cwd: repoDir,
|
||||
branchName: "feature-terminal-readiness",
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-terminal-readiness",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
let readyAt = 0;
|
||||
let sendAt = 0;
|
||||
let outputListener: ((chunk: { data: string }) => void) | null = null;
|
||||
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-terminal-readiness",
|
||||
worktree,
|
||||
terminalManager: {
|
||||
async getTerminals() {
|
||||
return [];
|
||||
},
|
||||
async createTerminal(options) {
|
||||
setTimeout(() => {
|
||||
readyAt = Date.now();
|
||||
outputListener?.({ data: "$ " });
|
||||
}, 25);
|
||||
return {
|
||||
id: "term-ready",
|
||||
name: options.name ?? "Terminal",
|
||||
cwd: options.cwd,
|
||||
send: () => {
|
||||
sendAt = Date.now();
|
||||
},
|
||||
subscribe: () => () => {},
|
||||
onExit: () => () => {},
|
||||
subscribeRaw: (listener) => {
|
||||
outputListener = (chunk) =>
|
||||
listener({
|
||||
data: chunk.data,
|
||||
startOffset: 0,
|
||||
endOffset: chunk.data.length,
|
||||
replay: false,
|
||||
});
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
outputListener = null;
|
||||
},
|
||||
replayedFrom: 0,
|
||||
currentOffset: 0,
|
||||
earliestAvailableOffset: 0,
|
||||
reset: false,
|
||||
};
|
||||
},
|
||||
getOutputOffset: () => 0,
|
||||
getState: () => ({
|
||||
rows: 0,
|
||||
cols: 0,
|
||||
grid: [],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 0 },
|
||||
}),
|
||||
kill: () => {},
|
||||
};
|
||||
},
|
||||
registerCwdEnv() {},
|
||||
getTerminal() {
|
||||
return undefined;
|
||||
},
|
||||
killTerminal() {},
|
||||
listDirectories() {
|
||||
return [];
|
||||
},
|
||||
killAll() {},
|
||||
subscribeTerminalsChanged() {
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
appendTimelineItem: async () => true,
|
||||
emitLiveTimelineItem: async () => true,
|
||||
});
|
||||
|
||||
expect(readyAt).toBeGreaterThan(0);
|
||||
expect(sendAt).toBeGreaterThan(0);
|
||||
expect(sendAt).toBeGreaterThanOrEqual(readyAt);
|
||||
});
|
||||
|
||||
it("shares the same worktree runtime port across setup and bootstrap terminals", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: ['echo "$PASEO_WORKTREE_PORT" > setup-port.txt'],
|
||||
terminals: [
|
||||
{
|
||||
name: "Port Terminal",
|
||||
command: "true",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add port setup and terminals'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const worktree = await createAgentWorktree({
|
||||
cwd: repoDir,
|
||||
branchName: "feature-shared-runtime-port",
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-shared-runtime-port",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const registeredEnvs: Array<{ cwd: string; env: Record<string, string> }> = [];
|
||||
const createTerminalEnvs: Record<string, string>[] = [];
|
||||
const persisted: AgentTimelineItem[] = [];
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-shared-runtime-port",
|
||||
worktree,
|
||||
terminalManager: {
|
||||
async getTerminals() {
|
||||
return [];
|
||||
},
|
||||
async createTerminal(options) {
|
||||
createTerminalEnvs.push(options.env ?? {});
|
||||
return {
|
||||
id: "term-1",
|
||||
name: options.name ?? "Terminal",
|
||||
cwd: options.cwd,
|
||||
send: () => {},
|
||||
subscribe: () => () => {},
|
||||
onExit: () => () => {},
|
||||
subscribeRaw: () => ({
|
||||
unsubscribe: () => {},
|
||||
replayedFrom: 0,
|
||||
currentOffset: 0,
|
||||
earliestAvailableOffset: 0,
|
||||
reset: false,
|
||||
}),
|
||||
getOutputOffset: () => 1,
|
||||
getState: () => ({
|
||||
rows: 0,
|
||||
cols: 0,
|
||||
grid: [],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 0 },
|
||||
}),
|
||||
kill: () => {},
|
||||
};
|
||||
},
|
||||
registerCwdEnv(options) {
|
||||
registeredEnvs.push({ cwd: options.cwd, env: options.env });
|
||||
},
|
||||
getTerminal() {
|
||||
return undefined;
|
||||
},
|
||||
killTerminal() {},
|
||||
listDirectories() {
|
||||
return [];
|
||||
},
|
||||
killAll() {},
|
||||
subscribeTerminalsChanged() {
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
appendTimelineItem: async (item) => {
|
||||
persisted.push(item);
|
||||
return true;
|
||||
},
|
||||
emitLiveTimelineItem: async () => true,
|
||||
});
|
||||
|
||||
const setupPortPath = join(worktree.worktreePath, "setup-port.txt");
|
||||
await waitForPathExists(setupPortPath);
|
||||
|
||||
const setupPort = readFileSync(setupPortPath, "utf8").trim();
|
||||
expect(setupPort.length).toBeGreaterThan(0);
|
||||
expect(registeredEnvs).toHaveLength(1);
|
||||
expect(registeredEnvs[0]?.cwd).toBe(worktree.worktreePath);
|
||||
expect(registeredEnvs[0]?.env.PASEO_WORKTREE_PORT).toBe(setupPort);
|
||||
expect(createTerminalEnvs.length).toBeGreaterThan(0);
|
||||
expect(createTerminalEnvs[0]?.PASEO_WORKTREE_PORT).toBe(setupPort);
|
||||
|
||||
const terminalToolCall = persisted.find(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
|
||||
item.type === "tool_call" &&
|
||||
item.name === "paseo_worktree_terminals" &&
|
||||
item.status === "completed"
|
||||
);
|
||||
expect(terminalToolCall?.status).toBe("completed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { Logger } from "pino";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type { TerminalSession } from "../terminal/terminal.js";
|
||||
import {
|
||||
createWorktree,
|
||||
getWorktreeTerminalSpecs,
|
||||
resolveWorktreeRuntimeEnv,
|
||||
runWorktreeSetupCommands,
|
||||
WorktreeSetupError,
|
||||
type WorktreeConfig,
|
||||
type WorktreeSetupCommandResult,
|
||||
type WorktreeRuntimeEnv,
|
||||
} from "../utils/worktree.js";
|
||||
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
|
||||
|
||||
@@ -38,6 +41,7 @@ export interface CreateAgentWorktreeOptions {
|
||||
|
||||
const MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES = 64 * 1024;
|
||||
const WORKTREE_SETUP_TRUNCATION_MARKER = "\n...<output truncated in the middle>...\n";
|
||||
const WORKTREE_BOOTSTRAP_TERMINAL_READY_TIMEOUT_MS = 1_500;
|
||||
|
||||
type MiddleTruncationAccumulator = {
|
||||
totalBytes: number;
|
||||
@@ -335,8 +339,51 @@ function buildTerminalTimelineItem(input: {
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForTerminalBootstrapReadiness(
|
||||
terminal: Pick<TerminalSession, "getOutputOffset" | "subscribeRaw">
|
||||
): Promise<void> {
|
||||
if (terminal.getOutputOffset() > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
const finish = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = null;
|
||||
}
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribe = null;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
const rawSubscription = terminal.subscribeRaw(() => {
|
||||
finish();
|
||||
});
|
||||
unsubscribe = rawSubscription.unsubscribe;
|
||||
|
||||
if (terminal.getOutputOffset() > 0) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
timeout = setTimeout(finish, WORKTREE_BOOTSTRAP_TERMINAL_READY_TIMEOUT_MS);
|
||||
});
|
||||
}
|
||||
|
||||
async function runWorktreeTerminalBootstrap(
|
||||
options: RunAsyncWorktreeBootstrapOptions
|
||||
options: RunAsyncWorktreeBootstrapOptions,
|
||||
runtimeEnv: WorktreeRuntimeEnv
|
||||
): Promise<void> {
|
||||
const terminalSpecs = getWorktreeTerminalSpecs(options.worktree.worktreePath);
|
||||
if (terminalSpecs.length === 0) {
|
||||
@@ -376,7 +423,9 @@ async function runWorktreeTerminalBootstrap(
|
||||
const terminal = await options.terminalManager.createTerminal({
|
||||
cwd: options.worktree.worktreePath,
|
||||
name: spec.name,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
await waitForTerminalBootstrapReadiness(terminal);
|
||||
terminal.send({
|
||||
type: "input",
|
||||
data: `${spec.command}\r`,
|
||||
@@ -420,6 +469,7 @@ export async function runAsyncWorktreeBootstrap(
|
||||
): Promise<void> {
|
||||
const setupCallId = uuidv4();
|
||||
let setupResults: WorktreeSetupCommandResult[] = [];
|
||||
let runtimeEnv: WorktreeRuntimeEnv | null = null;
|
||||
const emitLiveTimelineItem = options.emitLiveTimelineItem;
|
||||
const runningResultsByIndex = new Map<number, WorktreeSetupCommandResult>();
|
||||
const outputAccumulatorsByIndex = new Map<number, MiddleTruncationAccumulator>();
|
||||
@@ -454,10 +504,20 @@ export async function runAsyncWorktreeBootstrap(
|
||||
};
|
||||
|
||||
try {
|
||||
runtimeEnv = await resolveWorktreeRuntimeEnv({
|
||||
worktreePath: options.worktree.worktreePath,
|
||||
branchName: options.worktree.branchName,
|
||||
});
|
||||
options.terminalManager?.registerCwdEnv({
|
||||
cwd: options.worktree.worktreePath,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
|
||||
setupResults = await runWorktreeSetupCommands({
|
||||
worktreePath: options.worktree.worktreePath,
|
||||
branchName: options.worktree.branchName,
|
||||
cleanupOnFailure: false,
|
||||
runtimeEnv,
|
||||
onEvent: (event) => {
|
||||
const existing = runningResultsByIndex.get(event.index);
|
||||
const baseResult: WorktreeSetupCommandResult = existing ?? {
|
||||
@@ -533,5 +593,5 @@ export async function runAsyncWorktreeBootstrap(
|
||||
return;
|
||||
}
|
||||
|
||||
await runWorktreeTerminalBootstrap(options);
|
||||
await runWorktreeTerminalBootstrap(options, runtimeEnv);
|
||||
}
|
||||
|
||||
@@ -100,6 +100,26 @@ describe("shared messages stream parsing", () => {
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("parses directory suggestions request and response payloads", () => {
|
||||
const requestParsed = SessionInboundMessageSchema.safeParse({
|
||||
type: "directory_suggestions_request",
|
||||
query: "proj",
|
||||
limit: 20,
|
||||
requestId: "req-dir-1",
|
||||
});
|
||||
expect(requestParsed.success).toBe(true);
|
||||
|
||||
const responseParsed = SessionOutboundMessageSchema.safeParse({
|
||||
type: "directory_suggestions_response",
|
||||
payload: {
|
||||
directories: ["/Users/test/projects/paseo"],
|
||||
error: null,
|
||||
requestId: "req-dir-1",
|
||||
},
|
||||
});
|
||||
expect(responseParsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects websocket envelope for removed agent_stream_snapshot message type", () => {
|
||||
const fixture = {
|
||||
type: "agent_stream_snapshot",
|
||||
|
||||
@@ -826,6 +826,13 @@ export const BranchSuggestionsRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const DirectorySuggestionsRequestSchema = z.object({
|
||||
type: z.literal("directory_suggestions_request"),
|
||||
query: z.string(),
|
||||
limit: z.number().int().min(1).max(100).optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const PaseoWorktreeListRequestSchema = z.object({
|
||||
type: z.literal("paseo_worktree_list_request"),
|
||||
cwd: z.string().optional(),
|
||||
@@ -1075,6 +1082,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
CheckoutPrStatusRequestSchema,
|
||||
ValidateBranchRequestSchema,
|
||||
BranchSuggestionsRequestSchema,
|
||||
DirectorySuggestionsRequestSchema,
|
||||
PaseoWorktreeListRequestSchema,
|
||||
PaseoWorktreeArchiveRequestSchema,
|
||||
FileExplorerRequestSchema,
|
||||
@@ -1722,6 +1730,15 @@ export const BranchSuggestionsResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const DirectorySuggestionsResponseSchema = z.object({
|
||||
type: z.literal("directory_suggestions_response"),
|
||||
payload: z.object({
|
||||
directories: z.array(z.string()),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const PaseoWorktreeSchema = z.object({
|
||||
worktreePath: z.string(),
|
||||
branchName: z.string().nullable().optional(),
|
||||
@@ -2031,6 +2048,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
CheckoutPrStatusResponseSchema,
|
||||
ValidateBranchResponseSchema,
|
||||
BranchSuggestionsResponseSchema,
|
||||
DirectorySuggestionsResponseSchema,
|
||||
PaseoWorktreeListResponseSchema,
|
||||
PaseoWorktreeArchiveResponseSchema,
|
||||
FileExplorerResponseSchema,
|
||||
@@ -2164,6 +2182,8 @@ export type ValidateBranchRequest = z.infer<typeof ValidateBranchRequestSchema>;
|
||||
export type ValidateBranchResponse = z.infer<typeof ValidateBranchResponseSchema>;
|
||||
export type BranchSuggestionsRequest = z.infer<typeof BranchSuggestionsRequestSchema>;
|
||||
export type BranchSuggestionsResponse = z.infer<typeof BranchSuggestionsResponseSchema>;
|
||||
export type DirectorySuggestionsRequest = z.infer<typeof DirectorySuggestionsRequestSchema>;
|
||||
export type DirectorySuggestionsResponse = z.infer<typeof DirectorySuggestionsResponseSchema>;
|
||||
export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSchema>;
|
||||
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
|
||||
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
async function waitForCondition(
|
||||
predicate: () => boolean,
|
||||
@@ -16,13 +19,34 @@ async function waitForCondition(
|
||||
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
|
||||
}
|
||||
|
||||
async function withShell<T>(shell: string, run: () => Promise<T>): Promise<T> {
|
||||
const originalShell = process.env.SHELL;
|
||||
process.env.SHELL = shell;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
if (originalShell === undefined) {
|
||||
delete process.env.SHELL;
|
||||
} else {
|
||||
process.env.SHELL = originalShell;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("TerminalManager", () => {
|
||||
let manager: TerminalManager;
|
||||
const temporaryDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
if (manager) {
|
||||
manager.killAll();
|
||||
}
|
||||
while (temporaryDirs.length > 0) {
|
||||
const dir = temporaryDirs.pop();
|
||||
if (dir) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("getTerminals", () => {
|
||||
@@ -94,6 +118,58 @@ describe("TerminalManager", () => {
|
||||
manager = createTerminalManager();
|
||||
await expect(manager.createTerminal({ cwd: "tmp" })).rejects.toThrow("cwd must be absolute path");
|
||||
});
|
||||
|
||||
it("inherits registered env for the worktree root cwd", async () => {
|
||||
await withShell("/bin/sh", async () => {
|
||||
manager = createTerminalManager();
|
||||
const cwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-root-"));
|
||||
temporaryDirs.push(cwd);
|
||||
const markerPath = join(cwd, "root-port.txt");
|
||||
|
||||
manager.registerCwdEnv({
|
||||
cwd,
|
||||
env: { PASEO_WORKTREE_PORT: "45678" },
|
||||
});
|
||||
const session = await manager.createTerminal({ cwd });
|
||||
for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) {
|
||||
session.send({
|
||||
type: "input",
|
||||
data: `printf '%s' \"$PASEO_WORKTREE_PORT\" > ${JSON.stringify(markerPath)}\r`,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
await waitForCondition(() => existsSync(markerPath), 10000);
|
||||
expect(readFileSync(markerPath, "utf8")).toBe("45678");
|
||||
});
|
||||
});
|
||||
|
||||
it("inherits registered env for subdirectories within the worktree", async () => {
|
||||
await withShell("/bin/sh", async () => {
|
||||
manager = createTerminalManager();
|
||||
const rootCwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-subdir-"));
|
||||
const subdirCwd = join(rootCwd, "packages", "app");
|
||||
mkdirSync(subdirCwd, { recursive: true });
|
||||
temporaryDirs.push(rootCwd);
|
||||
const markerPath = join(subdirCwd, "subdir-port.txt");
|
||||
|
||||
manager.registerCwdEnv({
|
||||
cwd: rootCwd,
|
||||
env: { PASEO_WORKTREE_PORT: "45679" },
|
||||
});
|
||||
const session = await manager.createTerminal({ cwd: subdirCwd });
|
||||
for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) {
|
||||
session.send({
|
||||
type: "input",
|
||||
data: `printf '%s' \"$PASEO_WORKTREE_PORT\" > ${JSON.stringify(markerPath)}\r`,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
await waitForCondition(() => existsSync(markerPath), 10000);
|
||||
expect(readFileSync(markerPath, "utf8")).toBe("45679");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTerminal", () => {
|
||||
@@ -156,7 +232,7 @@ describe("TerminalManager", () => {
|
||||
manager = createTerminalManager();
|
||||
const terminals = await manager.getTerminals("/tmp");
|
||||
const exitedId = terminals[0].id;
|
||||
terminals[0].send({ type: "input", data: "\u0004" });
|
||||
terminals[0].kill();
|
||||
|
||||
await waitForCondition(() => manager.getTerminal(exitedId) === undefined, 10000);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createTerminal, type TerminalSession } from "./terminal.js";
|
||||
import { resolve, sep } from "node:path";
|
||||
|
||||
export interface TerminalListItem {
|
||||
id: string;
|
||||
@@ -15,7 +16,12 @@ export type TerminalsChangedListener = (input: TerminalsChangedEvent) => void;
|
||||
|
||||
export interface TerminalManager {
|
||||
getTerminals(cwd: string): Promise<TerminalSession[]>;
|
||||
createTerminal(options: { cwd: string; name?: string }): Promise<TerminalSession>;
|
||||
createTerminal(options: {
|
||||
cwd: string;
|
||||
name?: string;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<TerminalSession>;
|
||||
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
|
||||
getTerminal(id: string): TerminalSession | undefined;
|
||||
killTerminal(id: string): void;
|
||||
listDirectories(): string[];
|
||||
@@ -28,6 +34,7 @@ export function createTerminalManager(): TerminalManager {
|
||||
const terminalsById = new Map<string, TerminalSession>();
|
||||
const terminalExitUnsubscribeById = new Map<string, () => void>();
|
||||
const terminalsChangedListeners = new Set<TerminalsChangedListener>();
|
||||
const defaultEnvByRootCwd = new Map<string, Record<string, string>>();
|
||||
|
||||
function assertAbsolutePath(cwd: string): void {
|
||||
if (!cwd.startsWith("/")) {
|
||||
@@ -67,6 +74,23 @@ export function createTerminalManager(): TerminalManager {
|
||||
emitTerminalsChanged({ cwd: session.cwd });
|
||||
}
|
||||
|
||||
function resolveDefaultEnvForCwd(cwd: string): Record<string, string> | undefined {
|
||||
const normalizedCwd = resolve(cwd);
|
||||
let bestMatchRoot: string | null = null;
|
||||
|
||||
for (const rootCwd of defaultEnvByRootCwd.keys()) {
|
||||
const matches = normalizedCwd === rootCwd || normalizedCwd.startsWith(`${rootCwd}${sep}`);
|
||||
if (!matches) {
|
||||
continue;
|
||||
}
|
||||
if (!bestMatchRoot || rootCwd.length > bestMatchRoot.length) {
|
||||
bestMatchRoot = rootCwd;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatchRoot ? defaultEnvByRootCwd.get(bestMatchRoot) : undefined;
|
||||
}
|
||||
|
||||
function registerSession(session: TerminalSession): TerminalSession {
|
||||
terminalsById.set(session.id, session);
|
||||
const unsubscribeExit = session.onExit(() => {
|
||||
@@ -112,8 +136,13 @@ export function createTerminalManager(): TerminalManager {
|
||||
|
||||
let terminals = terminalsByCwd.get(cwd);
|
||||
if (!terminals || terminals.length === 0) {
|
||||
const inheritedEnv = resolveDefaultEnvForCwd(cwd);
|
||||
const session = registerSession(
|
||||
await createTerminal({ cwd, name: "Terminal 1" })
|
||||
await createTerminal({
|
||||
cwd,
|
||||
name: "Terminal 1",
|
||||
...(inheritedEnv ? { env: inheritedEnv } : {}),
|
||||
})
|
||||
);
|
||||
terminals = [session];
|
||||
terminalsByCwd.set(cwd, terminals);
|
||||
@@ -122,15 +151,25 @@ export function createTerminalManager(): TerminalManager {
|
||||
return terminals;
|
||||
},
|
||||
|
||||
async createTerminal(options: { cwd: string; name?: string }): Promise<TerminalSession> {
|
||||
async createTerminal(options: {
|
||||
cwd: string;
|
||||
name?: string;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<TerminalSession> {
|
||||
assertAbsolutePath(options.cwd);
|
||||
|
||||
const terminals = terminalsByCwd.get(options.cwd) ?? [];
|
||||
const defaultName = `Terminal ${terminals.length + 1}`;
|
||||
const inheritedEnv = resolveDefaultEnvForCwd(options.cwd);
|
||||
const mergedEnv =
|
||||
inheritedEnv || options.env
|
||||
? { ...(inheritedEnv ?? {}), ...(options.env ?? {}) }
|
||||
: undefined;
|
||||
const session = registerSession(
|
||||
await createTerminal({
|
||||
cwd: options.cwd,
|
||||
name: options.name ?? defaultName,
|
||||
...(mergedEnv ? { env: mergedEnv } : {}),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -141,6 +180,11 @@ export function createTerminalManager(): TerminalManager {
|
||||
return session;
|
||||
},
|
||||
|
||||
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void {
|
||||
assertAbsolutePath(options.cwd);
|
||||
defaultEnvByRootCwd.set(resolve(options.cwd), { ...options.env });
|
||||
},
|
||||
|
||||
getTerminal(id: string): TerminalSession | undefined {
|
||||
return terminalsById.get(id);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { searchHomeDirectories } from "./directory-suggestions.js";
|
||||
|
||||
describe("searchHomeDirectories", () => {
|
||||
let tempRoot: string;
|
||||
let homeDir: string;
|
||||
let outsideDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "directory-suggestions-")));
|
||||
homeDir = path.join(tempRoot, "home");
|
||||
outsideDir = path.join(tempRoot, "outside");
|
||||
|
||||
mkdirSync(homeDir, { recursive: true });
|
||||
mkdirSync(outsideDir, { recursive: true });
|
||||
|
||||
mkdirSync(path.join(homeDir, "projects", "paseo"), { recursive: true });
|
||||
mkdirSync(path.join(homeDir, "projects", "playground"), { recursive: true });
|
||||
mkdirSync(path.join(homeDir, "documents", "plans"), { recursive: true });
|
||||
mkdirSync(path.join(homeDir, ".hidden", "cache"), { recursive: true });
|
||||
writeFileSync(path.join(homeDir, "projects", "README.md"), "not a directory\n");
|
||||
|
||||
mkdirSync(path.join(outsideDir, "outside-match"), { recursive: true });
|
||||
symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns an empty list for blank queries", async () => {
|
||||
await expect(
|
||||
searchHomeDirectories({
|
||||
homeDir,
|
||||
query: " ",
|
||||
limit: 10,
|
||||
})
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("returns only existing directories", async () => {
|
||||
const results = await searchHomeDirectories({
|
||||
homeDir,
|
||||
query: "proj",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(results).toContain(path.join(homeDir, "projects"));
|
||||
expect(results).toContain(path.join(homeDir, "projects", "paseo"));
|
||||
expect(results).not.toContain(path.join(homeDir, "projects", "README.md"));
|
||||
});
|
||||
|
||||
it("supports home-relative path query syntax", async () => {
|
||||
const results = await searchHomeDirectories({
|
||||
homeDir,
|
||||
query: "~/projects/pa",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(results).toEqual([
|
||||
path.join(homeDir, "projects", "paseo"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("prioritizes exact segment matches before segment-prefix matches", async () => {
|
||||
const exactSegmentPath = path.join(
|
||||
homeDir,
|
||||
"something",
|
||||
"faro",
|
||||
"something-else"
|
||||
);
|
||||
const prefixSegmentPath = path.join(
|
||||
homeDir,
|
||||
"something",
|
||||
"somethingelse",
|
||||
"faro-bla"
|
||||
);
|
||||
mkdirSync(exactSegmentPath, { recursive: true });
|
||||
mkdirSync(prefixSegmentPath, { recursive: true });
|
||||
|
||||
const results = await searchHomeDirectories({
|
||||
homeDir,
|
||||
query: "faro",
|
||||
limit: 30,
|
||||
});
|
||||
|
||||
const exactIndex = results.indexOf(exactSegmentPath);
|
||||
const prefixIndex = results.indexOf(prefixSegmentPath);
|
||||
expect(exactIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(prefixIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(exactIndex).toBeLessThan(prefixIndex);
|
||||
});
|
||||
|
||||
it("prioritizes partial matches that appear earlier in the path", async () => {
|
||||
const earlierPath = path.join(homeDir, "farofoo");
|
||||
const laterPath = path.join(homeDir, "x", "y", "farofoo");
|
||||
mkdirSync(earlierPath, { recursive: true });
|
||||
mkdirSync(laterPath, { recursive: true });
|
||||
|
||||
const results = await searchHomeDirectories({
|
||||
homeDir,
|
||||
query: "arofo",
|
||||
limit: 30,
|
||||
});
|
||||
|
||||
const earlierIndex = results.indexOf(earlierPath);
|
||||
const laterIndex = results.indexOf(laterPath);
|
||||
expect(earlierIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(laterIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(earlierIndex).toBeLessThan(laterIndex);
|
||||
});
|
||||
|
||||
it("returns home-root suggestions when query is '~'", async () => {
|
||||
const results = await searchHomeDirectories({
|
||||
homeDir,
|
||||
query: "~",
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
expect(results).toContain(path.join(homeDir, "projects"));
|
||||
expect(results).toContain(path.join(homeDir, "documents"));
|
||||
});
|
||||
|
||||
it("does not return paths that escape home through symlinks", async () => {
|
||||
const results = await searchHomeDirectories({
|
||||
homeDir,
|
||||
query: "outside",
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
expect(results).not.toContain(path.join(homeDir, "outside-link"));
|
||||
expect(results).not.toContain(path.join(outsideDir, "outside-match"));
|
||||
});
|
||||
|
||||
it("respects the result limit", async () => {
|
||||
const results = await searchHomeDirectories({
|
||||
homeDir,
|
||||
query: "p",
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
import type { Dirent } from "node:fs";
|
||||
import { readdir, realpath, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export interface SearchHomeDirectoriesOptions {
|
||||
homeDir: string;
|
||||
query: string;
|
||||
limit?: number;
|
||||
maxDepth?: number;
|
||||
maxDirectoriesScanned?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_LIMIT = 30;
|
||||
const MAX_LIMIT = 100;
|
||||
const DEFAULT_MAX_DEPTH = 6;
|
||||
const DEFAULT_MAX_DIRECTORIES_SCANNED = 5000;
|
||||
const DIRECTORY_LIST_CACHE_TTL_MS = 8_000;
|
||||
const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000;
|
||||
|
||||
type QueryParts = {
|
||||
isPathQuery: boolean;
|
||||
parentPart: string;
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
type RankedDirectory = {
|
||||
absolutePath: string;
|
||||
matchTier: number;
|
||||
segmentIndex: number;
|
||||
matchOffset: number;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
type ChildDirectoryEntry = {
|
||||
name: string;
|
||||
absolutePath: string;
|
||||
};
|
||||
|
||||
type DirectoryListCacheEntry = {
|
||||
expiresAt: number;
|
||||
entries: ChildDirectoryEntry[];
|
||||
};
|
||||
|
||||
const directoryListCache = new Map<string, DirectoryListCacheEntry>();
|
||||
const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER;
|
||||
const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
export async function searchHomeDirectories(
|
||||
options: SearchHomeDirectoriesOptions
|
||||
): Promise<string[]> {
|
||||
const query = options.query.trim();
|
||||
if (!query) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const limit = normalizeLimit(options.limit);
|
||||
const homeRoot = await resolveDirectory(options.homeDir);
|
||||
if (!homeRoot) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const queryParts = normalizeQueryParts(query, homeRoot);
|
||||
if (!queryParts) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (queryParts.isPathQuery) {
|
||||
return searchWithinParentDirectory({
|
||||
homeRoot,
|
||||
parentPart: queryParts.parentPart,
|
||||
searchTerm: queryParts.searchTerm,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
return searchAcrossHomeTree({
|
||||
homeRoot,
|
||||
searchTerm: queryParts.searchTerm,
|
||||
limit,
|
||||
maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,
|
||||
maxDirectoriesScanned:
|
||||
options.maxDirectoriesScanned ?? DEFAULT_MAX_DIRECTORIES_SCANNED,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeLimit(limit: number | undefined): number {
|
||||
const candidate = limit ?? DEFAULT_LIMIT;
|
||||
if (!Number.isFinite(candidate)) {
|
||||
return DEFAULT_LIMIT;
|
||||
}
|
||||
const bounded = Math.trunc(candidate);
|
||||
return Math.max(1, Math.min(MAX_LIMIT, bounded));
|
||||
}
|
||||
|
||||
async function searchWithinParentDirectory(input: {
|
||||
homeRoot: string;
|
||||
parentPart: string;
|
||||
searchTerm: string;
|
||||
limit: number;
|
||||
}): Promise<string[]> {
|
||||
const parentPath = path.resolve(input.homeRoot, input.parentPart || ".");
|
||||
const parentRoot = await resolveDirectory(parentPath);
|
||||
if (!parentRoot || !isPathInsideRoot(input.homeRoot, parentRoot)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const searchLower = input.searchTerm.toLowerCase();
|
||||
const ranked: RankedDirectory[] = [];
|
||||
const entries = await listChildDirectories({
|
||||
directory: parentRoot,
|
||||
homeRoot: input.homeRoot,
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
if (searchLower && !entry.name.toLowerCase().includes(searchLower)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ranked.push(
|
||||
rankDirectory({
|
||||
absolutePath: entry.absolutePath,
|
||||
homeRoot: input.homeRoot,
|
||||
searchLower,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return dedupeAndSort(ranked).slice(0, input.limit);
|
||||
}
|
||||
|
||||
async function searchAcrossHomeTree(input: {
|
||||
homeRoot: string;
|
||||
searchTerm: string;
|
||||
limit: number;
|
||||
maxDepth: number;
|
||||
maxDirectoriesScanned: number;
|
||||
}): Promise<string[]> {
|
||||
const queue: Array<{ directory: string; depth: number }> = [
|
||||
{ directory: input.homeRoot, depth: 0 },
|
||||
];
|
||||
const visited = new Set<string>([input.homeRoot]);
|
||||
const ranked: RankedDirectory[] = [];
|
||||
let scanned = 0;
|
||||
const searchLower = input.searchTerm.toLowerCase();
|
||||
|
||||
for (
|
||||
let queueIndex = 0;
|
||||
queueIndex < queue.length && scanned < input.maxDirectoriesScanned;
|
||||
queueIndex += 1
|
||||
) {
|
||||
const current = queue[queueIndex];
|
||||
if (!current) continue;
|
||||
const entries = await listChildDirectories({
|
||||
directory: current.directory,
|
||||
homeRoot: input.homeRoot,
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
const resolvedCandidate = entry.absolutePath;
|
||||
if (visited.has(resolvedCandidate)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(resolvedCandidate);
|
||||
scanned += 1;
|
||||
|
||||
const relativePath = normalizeRelativePath(input.homeRoot, resolvedCandidate);
|
||||
if (
|
||||
relativePath.toLowerCase().includes(searchLower) ||
|
||||
entry.name.toLowerCase().includes(searchLower)
|
||||
) {
|
||||
ranked.push(
|
||||
rankDirectory({
|
||||
absolutePath: resolvedCandidate,
|
||||
homeRoot: input.homeRoot,
|
||||
searchLower,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
current.depth < input.maxDepth &&
|
||||
scanned < input.maxDirectoriesScanned
|
||||
) {
|
||||
queue.push({ directory: resolvedCandidate, depth: current.depth + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeAndSort(ranked).slice(0, input.limit);
|
||||
}
|
||||
|
||||
function dedupeAndSort(ranked: RankedDirectory[]): string[] {
|
||||
const byPath = new Map<string, RankedDirectory>();
|
||||
for (const entry of ranked) {
|
||||
const existing = byPath.get(entry.absolutePath);
|
||||
if (!existing || compareRankedDirectories(entry, existing) < 0) {
|
||||
byPath.set(entry.absolutePath, entry);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byPath.values())
|
||||
.sort(compareRankedDirectories)
|
||||
.map((entry) => entry.absolutePath);
|
||||
}
|
||||
|
||||
function compareRankedDirectories(
|
||||
left: RankedDirectory,
|
||||
right: RankedDirectory
|
||||
): number {
|
||||
if (left.matchTier !== right.matchTier) {
|
||||
return left.matchTier - right.matchTier;
|
||||
}
|
||||
if (left.segmentIndex !== right.segmentIndex) {
|
||||
return left.segmentIndex - right.segmentIndex;
|
||||
}
|
||||
if (left.matchOffset !== right.matchOffset) {
|
||||
return left.matchOffset - right.matchOffset;
|
||||
}
|
||||
if (left.depth !== right.depth) {
|
||||
return left.depth - right.depth;
|
||||
}
|
||||
return left.absolutePath.localeCompare(right.absolutePath);
|
||||
}
|
||||
|
||||
function rankDirectory(input: {
|
||||
absolutePath: string;
|
||||
homeRoot: string;
|
||||
searchLower: string;
|
||||
}): RankedDirectory {
|
||||
const relative = normalizeRelativePath(input.homeRoot, input.absolutePath);
|
||||
const relativeLower = relative.toLowerCase();
|
||||
const depth = relative === "." ? 0 : relative.split("/").length;
|
||||
const searchLower = input.searchLower;
|
||||
if (!searchLower) {
|
||||
return {
|
||||
absolutePath: input.absolutePath,
|
||||
matchTier: 3,
|
||||
segmentIndex: NO_SEGMENT_INDEX,
|
||||
matchOffset: 0,
|
||||
depth,
|
||||
};
|
||||
}
|
||||
const segments = relativeLower === "." ? [] : relativeLower.split("/");
|
||||
const exactSegmentIndex = findSegmentMatchIndex(
|
||||
segments,
|
||||
(segment) => segment === searchLower
|
||||
);
|
||||
const prefixSegmentIndex = findSegmentMatchIndex(
|
||||
segments,
|
||||
(segment) => segment.startsWith(searchLower)
|
||||
);
|
||||
const partialSegmentIndex = findSegmentMatchIndex(
|
||||
segments,
|
||||
(segment) => segment.includes(searchLower)
|
||||
);
|
||||
const matchOffset = relativeLower.indexOf(searchLower);
|
||||
let matchTier = 4;
|
||||
let segmentIndex = NO_SEGMENT_INDEX;
|
||||
|
||||
if (exactSegmentIndex >= 0) {
|
||||
matchTier = 0;
|
||||
segmentIndex = exactSegmentIndex;
|
||||
} else if (prefixSegmentIndex >= 0) {
|
||||
matchTier = 1;
|
||||
segmentIndex = prefixSegmentIndex;
|
||||
} else if (partialSegmentIndex >= 0) {
|
||||
matchTier = 2;
|
||||
segmentIndex = partialSegmentIndex;
|
||||
} else if (relativeLower.startsWith(searchLower)) {
|
||||
matchTier = 3;
|
||||
}
|
||||
|
||||
return {
|
||||
absolutePath: input.absolutePath,
|
||||
matchTier,
|
||||
segmentIndex,
|
||||
matchOffset: matchOffset >= 0 ? matchOffset : NO_MATCH_OFFSET,
|
||||
depth,
|
||||
};
|
||||
}
|
||||
|
||||
function findSegmentMatchIndex(
|
||||
segments: string[],
|
||||
predicate: (segment: string) => boolean
|
||||
): number {
|
||||
for (let index = 0; index < segments.length; index += 1) {
|
||||
const segment = segments[index];
|
||||
if (!segment) {
|
||||
continue;
|
||||
}
|
||||
if (predicate(segment)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function normalizeRelativePath(homeRoot: string, absolutePath: string): string {
|
||||
const relative = path.relative(homeRoot, absolutePath);
|
||||
if (!relative) {
|
||||
return ".";
|
||||
}
|
||||
return relative.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function isPathInsideRoot(root: string, target: string): boolean {
|
||||
const relative = path.relative(root, target);
|
||||
return (
|
||||
relative === "" ||
|
||||
(!relative.startsWith("..") && !path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeQueryParts(query: string, homeRoot: string): QueryParts | null {
|
||||
const typedQuery = query.trim().replace(/\\/g, "/");
|
||||
let normalized = typedQuery;
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (normalized.startsWith("~")) {
|
||||
normalized = normalized.slice(1);
|
||||
if (normalized.startsWith("/")) {
|
||||
normalized = normalized.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (path.isAbsolute(normalized)) {
|
||||
const absolute = path.resolve(normalized);
|
||||
if (!isPathInsideRoot(homeRoot, absolute)) {
|
||||
return null;
|
||||
}
|
||||
normalized = normalizeRelativePath(homeRoot, absolute);
|
||||
}
|
||||
|
||||
normalized = normalized.replace(/^\.\/+/, "").replace(/\/{2,}/g, "/");
|
||||
if (!normalized) {
|
||||
// Treat "~" and "~/" as a request to browse the home root.
|
||||
if (typedQuery === "~" || typedQuery === "~/") {
|
||||
return {
|
||||
isPathQuery: true,
|
||||
parentPart: "",
|
||||
searchTerm: "",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPathQuery = normalized.includes("/");
|
||||
const slashIndex = normalized.lastIndexOf("/");
|
||||
const parentPart = slashIndex >= 0 ? normalized.slice(0, slashIndex) : "";
|
||||
const searchTerm = slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized;
|
||||
|
||||
return {
|
||||
isPathQuery,
|
||||
parentPart,
|
||||
searchTerm,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveDirectory(inputPath: string): Promise<string | null> {
|
||||
try {
|
||||
const resolved = await realpath(path.resolve(inputPath));
|
||||
const stats = await stat(resolved);
|
||||
if (!stats.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function listChildDirectories(input: {
|
||||
directory: string;
|
||||
homeRoot: string;
|
||||
}): Promise<ChildDirectoryEntry[]> {
|
||||
const now = Date.now();
|
||||
const cached = directoryListCache.get(input.directory);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.entries;
|
||||
}
|
||||
|
||||
const dirents = await readdir(input.directory, { withFileTypes: true }).catch(
|
||||
() => [] as Dirent[]
|
||||
);
|
||||
const entries: ChildDirectoryEntry[] = [];
|
||||
for (const dirent of dirents) {
|
||||
if (!dirent.isDirectory() && !dirent.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
const candidatePath = path.join(input.directory, dirent.name);
|
||||
const absolutePath = await resolveDirectoryCandidate({
|
||||
candidatePath,
|
||||
dirent,
|
||||
homeRoot: input.homeRoot,
|
||||
});
|
||||
if (!absolutePath) {
|
||||
continue;
|
||||
}
|
||||
entries.push({
|
||||
name: dirent.name,
|
||||
absolutePath,
|
||||
});
|
||||
}
|
||||
|
||||
setDirectoryListCache(input.directory, {
|
||||
expiresAt: now + DIRECTORY_LIST_CACHE_TTL_MS,
|
||||
entries,
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function resolveDirectoryCandidate(input: {
|
||||
candidatePath: string;
|
||||
dirent: Dirent;
|
||||
homeRoot: string;
|
||||
}): Promise<string | null> {
|
||||
if (input.dirent.isDirectory()) {
|
||||
const resolved = path.resolve(input.candidatePath);
|
||||
return isPathInsideRoot(input.homeRoot, resolved) ? resolved : null;
|
||||
}
|
||||
|
||||
const resolved = await resolveDirectory(input.candidatePath);
|
||||
if (!resolved || !isPathInsideRoot(input.homeRoot, resolved)) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function setDirectoryListCache(cacheKey: string, entry: DirectoryListCacheEntry): void {
|
||||
directoryListCache.set(cacheKey, entry);
|
||||
pruneDirectoryListCache();
|
||||
}
|
||||
|
||||
function pruneDirectoryListCache(): void {
|
||||
if (directoryListCache.size <= DIRECTORY_LIST_CACHE_MAX_ENTRIES) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
for (const [cacheKey, entry] of directoryListCache) {
|
||||
if (entry.expiresAt <= now) {
|
||||
directoryListCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
while (directoryListCache.size > DIRECTORY_LIST_CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = directoryListCache.keys().next().value as string | undefined;
|
||||
if (!oldestKey) {
|
||||
return;
|
||||
}
|
||||
directoryListCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,26 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||
import { isAbsolute, join, resolve } from "path";
|
||||
import { z } from "zod";
|
||||
|
||||
const PaseoWorktreeMetadataSchema = z.object({
|
||||
const PaseoWorktreeMetadataV1Schema = z.object({
|
||||
version: z.literal(1),
|
||||
baseRefName: z.string().min(1),
|
||||
});
|
||||
|
||||
const PaseoWorktreeMetadataV2Schema = z.object({
|
||||
version: z.literal(2),
|
||||
baseRefName: z.string().min(1),
|
||||
runtime: z
|
||||
.object({
|
||||
worktreePort: z.number().int().positive(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const PaseoWorktreeMetadataSchema = z.union([
|
||||
PaseoWorktreeMetadataV1Schema,
|
||||
PaseoWorktreeMetadataV2Schema,
|
||||
]);
|
||||
|
||||
export type PaseoWorktreeMetadata = z.infer<typeof PaseoWorktreeMetadataSchema>;
|
||||
|
||||
function getGitDirForWorktreeRoot(worktreeRoot: string): string {
|
||||
@@ -68,6 +83,31 @@ export function writePaseoWorktreeMetadata(
|
||||
writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
export function writePaseoWorktreeRuntimeMetadata(
|
||||
worktreeRoot: string,
|
||||
options: { worktreePort: number }
|
||||
): void {
|
||||
if (!Number.isInteger(options.worktreePort) || options.worktreePort <= 0) {
|
||||
throw new Error(`Invalid worktree runtime port: ${options.worktreePort}`);
|
||||
}
|
||||
|
||||
const current = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
if (!current) {
|
||||
throw new Error("Cannot persist worktree runtime metadata: missing base metadata");
|
||||
}
|
||||
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true });
|
||||
const next: PaseoWorktreeMetadata = {
|
||||
version: 2,
|
||||
baseRefName: current.baseRefName,
|
||||
runtime: {
|
||||
worktreePort: options.worktreePort,
|
||||
},
|
||||
};
|
||||
writeFileSync(metadataPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
export function readPaseoWorktreeMetadata(worktreeRoot: string): PaseoWorktreeMetadata | null {
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
if (!existsSync(metadataPath)) {
|
||||
@@ -85,3 +125,14 @@ export function requirePaseoWorktreeBaseRefName(worktreeRoot: string): string {
|
||||
}
|
||||
return metadata.baseRefName;
|
||||
}
|
||||
|
||||
export function readPaseoWorktreeRuntimePort(worktreeRoot: string): number | null {
|
||||
const metadata = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
if (metadata.version === 2 && metadata.runtime?.worktreePort) {
|
||||
return metadata.runtime.worktreePort;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getWorktreeTerminalSpecs,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
listPaseoWorktrees,
|
||||
resolveWorktreeRuntimeEnv,
|
||||
type WorktreeSetupCommandProgressEvent,
|
||||
runWorktreeSetupCommands,
|
||||
slugify,
|
||||
@@ -14,6 +15,7 @@ import { execSync } from "child_process";
|
||||
import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import net from "node:net";
|
||||
|
||||
describe("createWorktree", () => {
|
||||
let tempDir: string;
|
||||
@@ -265,6 +267,68 @@ describe("createWorktree", () => {
|
||||
expect(progressEvents.some((event) => event.type === "command_completed")).toBe(true);
|
||||
});
|
||||
|
||||
it("reuses persisted worktree runtime port across resolutions", async () => {
|
||||
const result = await createWorktree({
|
||||
branchName: "main",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "runtime-env-port-reuse",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const first = await resolveWorktreeRuntimeEnv({
|
||||
worktreePath: result.worktreePath,
|
||||
branchName: result.branchName,
|
||||
});
|
||||
const second = await resolveWorktreeRuntimeEnv({
|
||||
worktreePath: result.worktreePath,
|
||||
branchName: result.branchName,
|
||||
});
|
||||
|
||||
expect(second.PASEO_WORKTREE_PORT).toBe(first.PASEO_WORKTREE_PORT);
|
||||
});
|
||||
|
||||
it("fails runtime env resolution when persisted port is in use", async () => {
|
||||
const result = await createWorktree({
|
||||
branchName: "main",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "runtime-env-port-conflict",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const env = await resolveWorktreeRuntimeEnv({
|
||||
worktreePath: result.worktreePath,
|
||||
branchName: result.branchName,
|
||||
});
|
||||
const port = Number(env.PASEO_WORKTREE_PORT);
|
||||
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, () => resolve());
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveWorktreeRuntimeEnv({
|
||||
worktreePath: result.worktreePath,
|
||||
branchName: result.branchName,
|
||||
})
|
||||
).rejects.toThrow(`Persisted worktree port ${port} is already in use`);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("cleans up worktree if setup command fails", async () => {
|
||||
// Create paseo.json with failing setup command
|
||||
const paseoConfig = {
|
||||
|
||||
@@ -4,7 +4,13 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "fs";
|
||||
import { join, basename, dirname, resolve, sep } from "path";
|
||||
import net from "node:net";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import { normalizeBaseRefName, writePaseoWorktreeMetadata } from "./worktree-metadata.js";
|
||||
import {
|
||||
normalizeBaseRefName,
|
||||
readPaseoWorktreeMetadata,
|
||||
readPaseoWorktreeRuntimePort,
|
||||
writePaseoWorktreeMetadata,
|
||||
writePaseoWorktreeRuntimeMetadata,
|
||||
} from "./worktree-metadata.js";
|
||||
import { resolvePaseoHome } from "../server/paseo-home.js";
|
||||
|
||||
interface PaseoConfig {
|
||||
@@ -26,6 +32,14 @@ export interface WorktreeConfig {
|
||||
worktreePath: string;
|
||||
}
|
||||
|
||||
export type WorktreeRuntimeEnv = {
|
||||
PASEO_SOURCE_CHECKOUT_PATH: string;
|
||||
PASEO_ROOT_PATH: string;
|
||||
PASEO_WORKTREE_PATH: string;
|
||||
PASEO_BRANCH_NAME: string;
|
||||
PASEO_WORKTREE_PORT: string;
|
||||
};
|
||||
|
||||
export type WorktreeSetupCommandResult = {
|
||||
command: string;
|
||||
cwd: string;
|
||||
@@ -327,6 +341,29 @@ async function getAvailablePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
async function assertPortAvailable(port: number): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", (error: NodeJS.ErrnoException) => {
|
||||
const message = error?.code === "EADDRINUSE"
|
||||
? `Persisted worktree port ${port} is already in use`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
reject(new Error(message));
|
||||
});
|
||||
server.listen(port, () => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise<string> {
|
||||
try {
|
||||
const commonDir = await getGitCommonDir(worktreePath);
|
||||
@@ -360,6 +397,7 @@ export async function runWorktreeSetupCommands(options: {
|
||||
branchName: string;
|
||||
cleanupOnFailure: boolean;
|
||||
repoRootPath?: string;
|
||||
runtimeEnv?: WorktreeRuntimeEnv;
|
||||
onEvent?: (event: WorktreeSetupCommandProgressEvent) => void;
|
||||
}): Promise<WorktreeSetupCommandResult[]> {
|
||||
// Read paseo.json from the worktree (it will have the same content as the source repo)
|
||||
@@ -368,21 +406,16 @@ export async function runWorktreeSetupCommands(options: {
|
||||
return [];
|
||||
}
|
||||
|
||||
const repoRootPath =
|
||||
options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath));
|
||||
const worktreePort = await getAvailablePort();
|
||||
|
||||
const runtimeEnv =
|
||||
options.runtimeEnv ??
|
||||
(await resolveWorktreeRuntimeEnv({
|
||||
worktreePath: options.worktreePath,
|
||||
branchName: options.branchName,
|
||||
...(options.repoRootPath ? { repoRootPath: options.repoRootPath } : {}),
|
||||
}));
|
||||
const setupEnv = {
|
||||
...process.env,
|
||||
// Source checkout path is the original git repo root (shared across worktrees), not the
|
||||
// worktree itself. This allows setup scripts to copy local files (e.g. .env) from the
|
||||
// source checkout.
|
||||
PASEO_SOURCE_CHECKOUT_PATH: repoRootPath,
|
||||
// Backward-compatible alias.
|
||||
PASEO_ROOT_PATH: repoRootPath,
|
||||
PASEO_WORKTREE_PATH: options.worktreePath,
|
||||
PASEO_BRANCH_NAME: options.branchName,
|
||||
PASEO_WORKTREE_PORT: String(worktreePort),
|
||||
...runtimeEnv,
|
||||
};
|
||||
|
||||
const results: WorktreeSetupCommandResult[] = [];
|
||||
@@ -439,6 +472,40 @@ async function resolveBranchNameForWorktreePath(worktreePath: string): Promise<s
|
||||
return basename(worktreePath);
|
||||
}
|
||||
|
||||
export async function resolveWorktreeRuntimeEnv(options: {
|
||||
worktreePath: string;
|
||||
branchName?: string;
|
||||
repoRootPath?: string;
|
||||
}): Promise<WorktreeRuntimeEnv> {
|
||||
const repoRootPath =
|
||||
options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath));
|
||||
const branchName =
|
||||
options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath));
|
||||
|
||||
let worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath);
|
||||
if (worktreePort === null) {
|
||||
worktreePort = await getAvailablePort();
|
||||
const metadata = readPaseoWorktreeMetadata(options.worktreePath);
|
||||
if (metadata) {
|
||||
writePaseoWorktreeRuntimeMetadata(options.worktreePath, { worktreePort });
|
||||
}
|
||||
} else {
|
||||
await assertPortAvailable(worktreePort);
|
||||
}
|
||||
|
||||
return {
|
||||
// Source checkout path is the original git repo root (shared across worktrees), not the
|
||||
// worktree itself. This allows setup scripts to copy local files (e.g. .env) from the
|
||||
// source checkout.
|
||||
PASEO_SOURCE_CHECKOUT_PATH: repoRootPath,
|
||||
// Backward-compatible alias.
|
||||
PASEO_ROOT_PATH: repoRootPath,
|
||||
PASEO_WORKTREE_PATH: options.worktreePath,
|
||||
PASEO_BRANCH_NAME: branchName,
|
||||
PASEO_WORKTREE_PORT: String(worktreePort),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runWorktreeDestroyCommands(options: {
|
||||
worktreePath: string;
|
||||
branchName?: string;
|
||||
|
||||
Reference in New Issue
Block a user