feat: improve agent workspace flows and directory suggestions
This commit is contained in:
@@ -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