fix(server): keep project config work off the daemon hot path (#7944)

Project settings and script health updates synchronously read unbounded user files. Share admitted async snapshots across consumers, budget script validation, and serialize revision-checked edits while keeping lexical project identity.
This commit is contained in:
Mohamed Boudra
2026-10-27 09:56:51 +07:00
committed by GitHub
parent f14a4cc112
commit 67a2459254
14 changed files with 615 additions and 246 deletions
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { tmpdir } from "node:os";
@@ -7,6 +7,7 @@ import { ScriptRouteStore } from "./script-proxy.js";
import {
buildWorkspaceScriptPayloads,
createScriptStatusEmitter,
readPaseoConfigForProjection,
} from "./script-status-projection.js";
import { WorkspaceScriptPayloadSchema } from "@getpaseo/protocol/messages";
import type { ScriptHealthState } from "./script-health-monitor.js";
@@ -50,7 +51,7 @@ function createWorkspaceRepo(options?: {
};
}
function buildPayloads(input: {
async function buildPayloads(input: {
workspaceId: string;
workspaceDirectory: string;
paseoConfig?: PaseoConfig | null;
@@ -63,7 +64,9 @@ function buildPayloads(input: {
resolveHealth?: (hostname: string) => ScriptHealthState | null;
}) {
const paseoConfig =
input.paseoConfig !== undefined ? input.paseoConfig : loadConfig(input.workspaceDirectory);
input.paseoConfig !== undefined
? input.paseoConfig
: await loadConfig(input.workspaceDirectory);
const { routeStore, serviceProxy, ...rest } = input;
return buildWorkspaceScriptPayloads({
...rest,
@@ -72,8 +75,8 @@ function buildPayloads(input: {
});
}
function loadConfig(repoRoot: string): PaseoConfig | null {
const result = readPaseoConfig(repoRoot);
async function loadConfig(repoRoot: string): Promise<PaseoConfig | null> {
const result = await readPaseoConfig(repoRoot);
return result.ok ? result.config : null;
}
@@ -93,7 +96,7 @@ describe("script-status-projection", () => {
).toBeNull();
});
it("projects plain scripts and services differently", () => {
it("projects plain scripts and services differently", async () => {
const workspaceId = "workspace-plain-and-service";
const workspace = createWorkspaceRepo({
paseoConfig: {
@@ -116,7 +119,7 @@ describe("script-status-projection", () => {
try {
expect(
buildPayloads({
await buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir,
routeStore,
@@ -154,7 +157,7 @@ describe("script-status-projection", () => {
}
});
it("builds service hostnames from service-provided git metadata", () => {
it("builds service hostnames from service-provided git metadata", async () => {
const workspaceId = "workspace-service-metadata";
const workspace = createWorkspaceRepo({
branchName: "local-branch-that-should-not-be-read",
@@ -168,7 +171,7 @@ describe("script-status-projection", () => {
const runtimeStore = new WorkspaceScriptRuntimeStore();
try {
const payloads = buildPayloads({
const payloads = await buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir,
serviceProxy: routeStore,
@@ -200,7 +203,7 @@ describe("script-status-projection", () => {
}
});
it("projects local and public service URLs while keeping proxyUrl public-first", () => {
it("projects local and public service URLs while keeping proxyUrl public-first", async () => {
const workspaceId = "workspace-public-service";
const workspace = createWorkspaceRepo({
paseoConfig: {
@@ -214,7 +217,7 @@ describe("script-status-projection", () => {
try {
expect(
buildPayloads({
await buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir,
routeStore,
@@ -243,7 +246,7 @@ describe("script-status-projection", () => {
}
});
it("overlays runtime, route, and health state for running services", () => {
it("overlays runtime, route, and health state for running services", async () => {
const workspaceId = "workspace-running-service";
const workspace = createWorkspaceRepo({
branchName: "feature/card",
@@ -273,7 +276,7 @@ describe("script-status-projection", () => {
try {
expect(
buildPayloads({
await buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir,
routeStore,
@@ -301,7 +304,7 @@ describe("script-status-projection", () => {
}
});
it("maps internal pending health to null on the wire", () => {
it("maps internal pending health to null on the wire", async () => {
const workspaceId = "workspace-pending-health";
const workspace = createWorkspaceRepo({
paseoConfig: {
@@ -330,7 +333,7 @@ describe("script-status-projection", () => {
try {
expect(
buildPayloads({
await buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir,
routeStore,
@@ -358,7 +361,7 @@ describe("script-status-projection", () => {
}
});
it("includes orphaned running runtime entries even after config removal", () => {
it("includes orphaned running runtime entries even after config removal", async () => {
const workspaceId = "workspace-orphaned-service";
const workspace = createWorkspaceRepo();
const routeStore = new ScriptRouteStore();
@@ -381,7 +384,7 @@ describe("script-status-projection", () => {
try {
expect(
buildPayloads({
await buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir,
routeStore,
@@ -408,7 +411,7 @@ describe("script-status-projection", () => {
}
});
it("projects orphaned plain scripts as scripts instead of services", () => {
it("projects orphaned plain scripts as scripts instead of services", async () => {
const workspaceId = "workspace-orphaned-script";
const workspace = createWorkspaceRepo();
const routeStore = new ScriptRouteStore();
@@ -424,7 +427,7 @@ describe("script-status-projection", () => {
try {
expect(
buildPayloads({
await buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir,
routeStore,
@@ -449,7 +452,7 @@ describe("script-status-projection", () => {
}
});
it("readPaseoConfig fails with configPath and error when paseo.json is malformed", () => {
it("readPaseoConfig fails with configPath and error when paseo.json is malformed", async () => {
const workspace = createWorkspaceRepo();
const configPath = path.join(workspace.repoDir, "paseo.json");
writeFileSync(
@@ -458,7 +461,7 @@ describe("script-status-projection", () => {
);
try {
const result = readPaseoConfig(workspace.repoDir);
const result = await readPaseoConfig(workspace.repoDir);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("unreachable");
expect(result.configPath).toBe(configPath);
@@ -468,7 +471,7 @@ describe("script-status-projection", () => {
}
});
it("buildWorkspaceScriptPayloads given paseoConfig=null still surfaces orphaned runtime scripts", () => {
it("buildWorkspaceScriptPayloads given paseoConfig=null still surfaces orphaned runtime scripts", async () => {
const workspaceId = "workspace-null-config";
const workspace = createWorkspaceRepo();
const routeStore = new ScriptRouteStore();
@@ -484,7 +487,7 @@ describe("script-status-projection", () => {
try {
expect(
buildPayloads({
await buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir,
paseoConfig: null,
@@ -538,7 +541,12 @@ describe("script-status-projection", () => {
exitCode: null,
});
const session = { emit: vi.fn() };
const messages: unknown[] = [];
const session = {
emit: (message: unknown) => {
messages.push(message);
},
};
const emitUpdate = createScriptStatusEmitter({
sessions: () => [session],
serviceProxy: routeStore,
@@ -550,7 +558,11 @@ describe("script-status-projection", () => {
});
try {
emitUpdate(workspaceId, [
const originalConfig = await readPaseoConfigForProjection(
workspace.repoDir,
createTestLogger(),
);
await emitUpdate(workspaceId, [
{
scriptName: "api",
hostname: "api--repo.localhost",
@@ -558,9 +570,25 @@ describe("script-status-projection", () => {
health: "healthy",
},
]);
await Promise.resolve();
expect(session.emit).toHaveBeenCalledWith({
expect(await readPaseoConfigForProjection(workspace.repoDir, createTestLogger())).toBe(
originalConfig,
);
await emitUpdate(workspaceId, [
{ scriptName: "api", hostname: "api--repo.localhost", port: 3001, health: "unhealthy" },
]);
expect(messages.at(-1)).toMatchObject({
payload: {
scripts: [
{ scriptName: "api", health: "unhealthy" },
{ scriptName: "typecheck", health: null },
],
},
});
expect(await readPaseoConfigForProjection(workspace.repoDir, createTestLogger())).toBe(
originalConfig,
);
expect(messages).toContainEqual({
type: "script_status_update",
payload: {
workspaceId,
@@ -33,11 +33,11 @@ interface BuildWorkspaceScriptPayloadsOptions {
resolveHealth?: (hostname: string) => ScriptHealthState | null;
}
export function readPaseoConfigForProjection(
export async function readPaseoConfigForProjection(
workspaceDirectory: string,
logger: Logger,
): PaseoConfig | null {
const result = readPaseoConfig(workspaceDirectory);
): Promise<PaseoConfig | null> {
const result = await readPaseoConfig(workspaceDirectory);
if (result.ok) {
return result.config;
}
@@ -288,9 +288,9 @@ export function createScriptStatusEmitter({
serviceProxyPublicBaseUrl?: string | null;
resolveWorkspaceDirectory: (workspaceId: string) => string | null | Promise<string | null>;
logger: Logger;
}): (workspaceId: string, scripts: ScriptHealthEntry[]) => void {
return (workspaceId, scripts) => {
void (async () => {
}): (workspaceId: string, scripts: ScriptHealthEntry[]) => Promise<void> {
return async (workspaceId, scripts) => {
try {
const workspaceDirectory = await resolveWorkspaceDirectory(workspaceId);
if (!workspaceDirectory) {
return;
@@ -304,7 +304,7 @@ export function createScriptStatusEmitter({
const projected = buildWorkspaceScriptPayloads({
workspaceId,
workspaceDirectory,
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger),
paseoConfig: await readPaseoConfigForProjection(workspaceDirectory, logger),
serviceProxy,
runtimeStore,
daemonPort: resolvedDaemonPort,
@@ -320,6 +320,8 @@ export function createScriptStatusEmitter({
for (const session of sessions()) {
session.emit(message);
}
})();
} catch (error) {
logger.warn({ err: error, workspaceId }, "Failed to project workspace script status");
}
};
}
+2 -2
View File
@@ -5669,7 +5669,7 @@ export class Session {
activityAt: null,
createdAt: workspace.createdAt,
diffStat,
scripts: this.buildWorkspaceScriptPayloadSnapshot(workspace, resolvedProjectRecord),
scripts: await this.buildWorkspaceScriptPayloadSnapshot(workspace, resolvedProjectRecord),
...(resolvedProjectRecord
? {
project: await this.buildProjectPlacementForWorkspace(workspace, resolvedProjectRecord),
@@ -7262,7 +7262,7 @@ export class Session {
private buildWorkspaceScriptPayloadSnapshot(
workspace: PersistedWorkspaceRecord,
project: PersistedProjectRecord | null,
): WorkspaceDescriptorPayload["scripts"] {
): Promise<WorkspaceDescriptorPayload["scripts"]> {
return this.workspaceScripts.buildSnapshot(workspace, project);
}
@@ -228,7 +228,7 @@ describe("ProjectConfigSession", () => {
requestId: "write-uncommitted-setup",
repoRoot,
config: { worktree: { setup: "npm install" } },
expectedRevision: statPaseoConfigPath(repoRoot),
expectedRevision: await statPaseoConfigPath(repoRoot),
});
expect(emitted).toEqual([
@@ -1,10 +1,10 @@
import { realpathSync } from "node:fs";
import { resolve, sep } from "path";
import { resolve } from "path";
import type pino from "pino";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
import type { ProjectRegistry } from "../../workspace-registry.js";
import {
readPaseoConfigForEdit,
resolveProjectConfigRoot,
writePaseoConfigForEdit,
type ProjectConfigRpcError,
} from "../../../utils/paseo-config-file.js";
@@ -47,7 +47,7 @@ export class ProjectConfigSession {
return;
}
const result = readPaseoConfigForEdit(repoRoot);
const result = await readPaseoConfigForEdit(repoRoot);
if (!result.ok) {
this.logger.warn(
{ repoRoot, requestId: msg.requestId, outcome: result.error.code },
@@ -94,7 +94,7 @@ export class ProjectConfigSession {
{ repoRoot, requestId: msg.requestId, outcome: "write_attempt" },
"Writing project config",
);
const result = writePaseoConfigForEdit({
const result = await writePaseoConfigForEdit({
repoRoot,
config: msg.config,
expectedRevision: msg.expectedRevision,
@@ -173,34 +173,21 @@ export class ProjectConfigSession {
}
private async resolveKnownProjectRoot(repoRoot: string): Promise<string | null> {
const requestedRoot = canonicalizeConfigRoot(repoRoot);
const requestedRoot = resolve(repoRoot);
const projects = await this.projectRegistry.list();
for (const project of projects) {
if (project.archivedAt !== null) {
continue;
}
const projectRoot = canonicalizeConfigRoot(project.rootPath);
if (requestedRoot === projectRoot) {
return projectRoot;
}
}
return null;
const activeRoots = new Set(
projects
.filter((project) => project.archivedAt === null)
.map((project) => resolve(project.rootPath)),
);
const root = await resolveProjectConfigRoot(requestedRoot, activeRoots);
if (!root) return null;
// Authorization follows live registry state after alias IO, including archive/removal.
const current = await this.projectRegistry.list();
return current.some(
(project) => project.archivedAt === null && resolve(project.rootPath) === root,
)
? root
: null;
}
}
function canonicalizeConfigRoot(repoRoot: string): string {
const resolved = resolve(repoRoot);
try {
return stripTrailingPathSeparators(realpathSync(resolved));
} catch {
return stripTrailingPathSeparators(resolved);
}
}
function stripTrailingPathSeparators(path: string): string {
let normalized = path;
while (normalized.length > 1 && normalized.endsWith(sep)) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
@@ -178,14 +178,20 @@ describe("buildSnapshot", () => {
test("returns no scripts when the service proxy is unavailable", async () => {
const { service } = buildService({ serviceProxy: null });
expect(
service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord),
await service.buildSnapshot({
workspaceId: "ws-1",
cwd: "/tmp/repo",
} as PersistedWorkspaceRecord),
).toEqual([]);
});
test("returns no scripts when the runtime store is unavailable", async () => {
const { service } = buildService({ scriptRuntimeStore: null });
expect(
service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord),
await service.buildSnapshot({
workspaceId: "ws-1",
cwd: "/tmp/repo",
} as PersistedWorkspaceRecord),
).toEqual([]);
});
@@ -194,7 +200,7 @@ describe("buildSnapshot", () => {
tempDirs.push(dir);
const { service } = buildService();
expect(
service.buildSnapshot({ workspaceId: "ws-1", cwd: dir } as PersistedWorkspaceRecord),
await service.buildSnapshot({ workspaceId: "ws-1", cwd: dir } as PersistedWorkspaceRecord),
).toEqual([]);
});
@@ -229,7 +235,7 @@ describe("buildSnapshot", () => {
runtimeStore: { get: () => null },
});
expect(service.buildSnapshot(workspace, project)[0]?.hostname).toBe(
expect((await service.buildSnapshot(workspace, project))[0]?.hostname).toBe(
serviceProxy.projectWorkspaceService({
projectSlug: deriveProjectServiceSlug(project),
branchName: workspace.branch,
@@ -374,10 +380,12 @@ describe("start", () => {
daemonPort: 6767,
daemonListenHost: "127.0.0.1",
});
expect(published).toContainEqual({
type: "script_status_update",
payload: { workspaceId: "ws-1", scripts: [] },
});
await expect
.poll(() => published)
.toContainEqual({
type: "script_status_update",
payload: { workspaceId: "ws-1", scripts: [] },
});
expect(emitted).toContainEqual({
type: "start_workspace_script_response",
payload: {
@@ -471,7 +479,7 @@ describe("start", () => {
const serviceProxy = createServiceProxySubsystem({ logger });
const { service, spawnCalls } = buildService({ workspace, project, serviceProxy });
const snapshot = service.buildSnapshot(workspace, project);
const snapshot = await service.buildSnapshot(workspace, project);
await service.start({ ...request, workspaceId: workspace.workspaceId });
const started = spawnCalls[0]!;
@@ -43,7 +43,7 @@ export interface WorkspaceScriptsService {
buildSnapshot(
workspace: PersistedWorkspaceRecord,
project?: PersistedProjectRecord | null,
): WorkspaceScriptsPayload;
): Promise<WorkspaceScriptsPayload>;
emitStatusUpdate(workspaceId: string, workspaceDirectory: string): Promise<void>;
list(workspaceId: string): Promise<WorkspaceScriptPayload[]>;
launch(input: { workspaceId: string; scriptName: string }): Promise<WorkspaceScriptPayload>;
@@ -110,17 +110,17 @@ export function createWorkspaceScriptsService(deps: {
};
}
function buildSnapshot(
async function buildSnapshot(
workspace: PersistedWorkspaceRecord,
project: PersistedProjectRecord | null = null,
): WorkspaceScriptsPayload {
): Promise<WorkspaceScriptsPayload> {
if (!serviceProxy || !scriptRuntimeStore) {
return [];
}
return buildWorkspaceScriptPayloads({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.cwd,
paseoConfig: readPaseoConfigForProjection(workspace.cwd, logger),
paseoConfig: await readPaseoConfigForProjection(workspace.cwd, logger),
serviceProxy,
runtimeStore: scriptRuntimeStore,
daemonPort: getDaemonTcpPort?.() ?? null,
@@ -137,7 +137,7 @@ export function createWorkspaceScriptsService(deps: {
const project = await projectRegistry.get(workspace.projectId);
publishStatusUpdate({
type: "script_status_update",
payload: { workspaceId, scripts: buildSnapshot(workspace, project) },
payload: { workspaceId, scripts: await buildSnapshot(workspace, project) },
});
} catch (error) {
logger.warn({ err: error, workspaceId }, "Failed to project workspace script status");
@@ -203,7 +203,7 @@ export function createWorkspaceScriptsService(deps: {
scriptName: string;
}): Promise<WorkspaceScriptPayload> {
const { workspace, project } = await launchProcess(input);
const script = buildSnapshot(workspace, project).find(
const script = (await buildSnapshot(workspace, project)).find(
(entry) => entry.scriptName === input.scriptName,
);
if (!script) {
@@ -231,7 +231,7 @@ export function createWorkspaceScriptsService(deps: {
// The launcher's terminal exit listener owns route removal and runtime state updates.
await available.terminalManager.killTerminalAndWait(runtime.terminalId);
const script = buildSnapshot(workspace, project).find(
const script = (await buildSnapshot(workspace, project)).find(
(entry) => entry.scriptName === input.scriptName,
);
if (!script) {
@@ -335,7 +335,7 @@ export async function runWorktreeAutoTerminals(options: {
if (!options.terminalManager) return;
const terminalManager = options.terminalManager;
await Promise.all(
getWorktreeTerminalSpecs(options.workspaceCwd).map(async (spec) => {
(await getWorktreeTerminalSpecs(options.workspaceCwd)).map(async (spec) => {
try {
const terminal = await terminalManager.createTerminal({
cwd: options.workspaceCwd,
@@ -545,7 +545,7 @@ export async function spawnWorkspaceScript(
logger,
onLifecycleChanged,
} = options;
const configResult = readPaseoConfig(repoRoot);
const configResult = await readPaseoConfig(repoRoot);
if (!configResult.ok) {
throw paseoConfigParseError(configResult);
}
@@ -554,7 +554,7 @@ export async function runWorktreeSetupInBackground(
emitSetupProgress("completed", null);
} else {
const workspaceCwd = options.workspaceCwd ?? worktree.worktreePath;
const setupCommands = getWorktreeSetupCommands(workspaceCwd);
const setupCommands = await getWorktreeSetupCommands(workspaceCwd);
const runtimeEnv = await resolveWorktreeRuntimeEnv({
worktreePath: worktree.worktreePath,
branchName: worktree.branchName,
@@ -1,8 +1,5 @@
import { readPaseoConfigJson } from "./paseo-config-file.js";
import {
PaseoConfigSchema,
type PaseoMetadataGeneration,
} from "@getpaseo/protocol/paseo-config-schema";
import { readPaseoConfig } from "./paseo-config-file.js";
import { type PaseoMetadataGeneration } from "@getpaseo/protocol/paseo-config-schema";
export type MetadataConfigKey = "title" | "branchName" | "commitMessage" | "pullRequest";
@@ -52,8 +49,8 @@ async function readProjectMetadataOverrides(
}
try {
const repoRoot = await options.workspaceGitService.resolveRepoRoot(options.cwd);
const json = readPaseoConfigJson(repoRoot);
return PaseoConfigSchema.parse(json).metadataGeneration;
const result = await readPaseoConfig(repoRoot);
return result.ok ? result.config?.metadataGeneration : undefined;
} catch {
return undefined;
}
@@ -1,9 +1,20 @@
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { writeFile, truncate, rm } from "node:fs/promises";
import { setImmediate as nextTurn } from "node:timers/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { isPlatform } from "../test-utils/platform.js";
import { getWorktreeSetupCommands, getWorktreeTeardownCommands } from "./worktree.js";
import {
readPaseoConfig,
PaseoConfigSchema,
PaseoConfigRawSchema,
getWorktreeSetupCommands,
getWorktreeTeardownCommands,
} from "./worktree.js";
import {
readPaseoConfigForEdit,
statPaseoConfigPath,
@@ -21,16 +32,179 @@ describe("paseo config file substrate", () => {
rmSync(tempDir, { recursive: true, force: true });
});
it("returns null config and revision when paseo.json is missing", () => {
const result = readPaseoConfigForEdit(tempDir);
it("rejects an oversized config before parsing its otherwise valid JSON", async () => {
writeFileSync(
join(tempDir, "paseo.json"),
JSON.stringify({ extra: "x".repeat(2 * 1024 * 1024) }),
);
expect(await readPaseoConfigForEdit(tempDir)).toEqual({
ok: false,
error: { code: "invalid_project_config" },
});
});
it("rejects a sparse oversized file without allocating its contents", async () => {
const configPath = join(tempDir, "paseo.json");
await writeFile(configPath, "");
await truncate(configPath, 1024 * 1024 * 1024);
expect(await readPaseoConfigForEdit(tempDir)).toEqual({
ok: false,
error: { code: "invalid_project_config" },
});
});
it.skipIf(isPlatform("win32"))("rejects a FIFO without waiting for a writer", async () => {
await promisify(execFile)("mkfifo", [join(tempDir, "paseo.json")]);
const modulePath = fileURLToPath(new URL("./paseo-config-file.ts", import.meta.url));
const script = `import { readPaseoConfigForEdit } from ${JSON.stringify(modulePath)};
const result = await readPaseoConfigForEdit(${JSON.stringify(tempDir)});
if (JSON.stringify(result) !== JSON.stringify({ok:false,error:{code:"invalid_project_config"}})) process.exit(1);`;
// Isolate the regression: the old sync reader blocks on the pipe and must be killed.
await promisify(execFile)(
process.execPath,
["--import", "tsx", "--input-type=module", "-e", script],
{ timeout: 2_000 },
);
});
it("keeps the longest loop stall below 20ms while admitting large configs", async () => {
const config = {
scripts: Object.fromEntries(
Array.from({ length: 7_000 }, (_, i) => [`script${i}`, { command: "echo hi" }]),
),
worktree: { setup: "npm ci" },
};
await writeFile(join(tempDir, "paseo.json"), JSON.stringify(config));
let previous = performance.now();
let longestGap = 0;
const probe = setInterval(() => {
const now = performance.now();
longestGap = Math.max(longestGap, now - previous);
previous = now;
}, 1);
let result: Awaited<ReturnType<typeof readPaseoConfigForEdit>>;
try {
result = await readPaseoConfigForEdit(tempDir);
await nextTurn();
longestGap = Math.max(longestGap, performance.now() - previous);
} finally {
clearInterval(probe);
}
expect(result).toEqual({ ok: true, config, revision: await statPaseoConfigPath(tempDir) });
process.stdout.write(`project config 7,000 scripts max loop gap: ${longestGap.toFixed(2)}ms\n`);
expect(longestGap).toBeLessThan(20);
});
it("rejects a 32MiB config with a loop stall below 20ms", async () => {
await writeFile(
join(tempDir, "paseo.json"),
JSON.stringify({ extra: "x".repeat(32 * 1024 * 1024) }),
);
let previous = performance.now();
let longestGap = 0;
const probe = setInterval(() => {
const now = performance.now();
longestGap = Math.max(longestGap, now - previous);
previous = now;
}, 1);
let result: Awaited<ReturnType<typeof readPaseoConfigForEdit>>;
try {
result = await readPaseoConfigForEdit(tempDir);
await nextTurn();
longestGap = Math.max(longestGap, performance.now() - previous);
} finally {
clearInterval(probe);
}
process.stdout.write(`project config 32MiB max loop gap: ${longestGap.toFixed(2)}ms\n`);
expect(longestGap).toBeLessThan(20);
expect(result).toEqual({ ok: false, error: { code: "invalid_project_config" } });
});
it.each([
{
scripts: Object.fromEntries(
Array.from({ length: 130 }, (_, i) => [
`script${i}`,
{ command: "echo hi", extra: { keep: true } },
]),
),
},
{
scripts: { valid: { command: "echo hi" }, invalid: 42 },
worktree: { setup: ["one", "", 42] },
},
{ scripts: ["invalid record"], worktree: { setup: "echo hi" } },
JSON.parse(
'{"scripts":{"__proto__":{"command":"echo hi"},"constructor":{"command":"echo hi"}}}',
),
])("preserves edit and runtime schema behavior with grouped validation (%#)", async (config) => {
await writeFile(join(tempDir, "paseo.json"), JSON.stringify(config));
const expected = PaseoConfigRawSchema.safeParse(config);
expect(await readPaseoConfigForEdit(tempDir)).toEqual(
expected.success
? { ok: true, config: expected.data, revision: await statPaseoConfigPath(tempDir) }
: { ok: false, error: { code: "invalid_project_config" } },
);
expect(await readPaseoConfig(tempDir)).toEqual({
ok: true,
config: PaseoConfigSchema.parse(config),
});
});
it("refreshes snapshots after an external edit, repair, and removal", async () => {
const configPath = join(tempDir, "paseo.json");
await writeFile(configPath, JSON.stringify({ worktree: { setup: "old" } }));
const original = await readPaseoConfig(tempDir);
expect(original).toEqual({ ok: true, config: { worktree: { setup: ["old"], teardown: [] } } });
await writeFile(configPath, "{ malformed");
expect(await readPaseoConfigForEdit(tempDir)).toEqual({
ok: false,
error: { code: "invalid_project_config" },
});
await writeFile(configPath, JSON.stringify({ worktree: { setup: "repaired" } }));
expect(await readPaseoConfig(tempDir)).toEqual({
ok: true,
config: { worktree: { setup: ["repaired"], teardown: [] } },
});
await rm(configPath);
expect(await readPaseoConfigForEdit(tempDir)).toEqual({
ok: true,
config: null,
revision: null,
});
});
it("serializes competing edits so exactly one matching revision commits", async () => {
const results = await Promise.all(
["first", "second"].map((setup) =>
writePaseoConfigForEdit({
repoRoot: tempDir,
config: { worktree: { setup } },
expectedRevision: null,
}),
),
);
const saved = await readPaseoConfigForEdit(tempDir);
expect(results[0]).toEqual(saved);
expect(results[1]).toEqual({
ok: false,
error: {
code: "stale_project_config",
currentRevision: await statPaseoConfigPath(tempDir),
},
});
});
it("returns null config and revision when paseo.json is missing", async () => {
const result = await readPaseoConfigForEdit(tempDir);
expect(result).toEqual({ ok: true, config: null, revision: null });
});
it("returns invalid_project_config for invalid JSON", () => {
it("returns invalid_project_config for invalid JSON", async () => {
writeFileSync(join(tempDir, "paseo.json"), "{ invalid json\n");
const result = readPaseoConfigForEdit(tempDir);
const result = await readPaseoConfigForEdit(tempDir);
expect(result).toEqual({
ok: false,
@@ -38,7 +212,7 @@ describe("paseo config file substrate", () => {
});
});
it("preserves raw lifecycle string and array forms with a revision token", () => {
it("preserves raw lifecycle string and array forms with a revision token", async () => {
writeFileSync(
join(tempDir, "paseo.json"),
JSON.stringify({
@@ -49,7 +223,7 @@ describe("paseo config file substrate", () => {
}),
);
const result = readPaseoConfigForEdit(tempDir);
const result = await readPaseoConfigForEdit(tempDir);
expect(result).toEqual({
ok: true,
@@ -59,11 +233,11 @@ describe("paseo config file substrate", () => {
teardown: ["npm run clean", "npm run reset"],
},
},
revision: statPaseoConfigPath(tempDir),
revision: await statPaseoConfigPath(tempDir),
});
});
it("keeps runtime lifecycle commands normalized for execution", () => {
it("keeps runtime lifecycle commands normalized for execution", async () => {
writeFileSync(
join(tempDir, "paseo.json"),
JSON.stringify({
@@ -74,15 +248,15 @@ describe("paseo config file substrate", () => {
}),
);
expect(getWorktreeSetupCommands(tempDir)).toEqual(["npm install"]);
expect(getWorktreeTeardownCommands(tempDir)).toEqual(["npm run clean", "npm run reset"]);
expect(await getWorktreeSetupCommands(tempDir)).toEqual(["npm install"]);
expect(await getWorktreeTeardownCommands(tempDir)).toEqual(["npm run clean", "npm run reset"]);
});
it("writes pretty JSON with a trailing newline when revision matches", () => {
it("writes pretty JSON with a trailing newline when revision matches", async () => {
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
const expectedRevision = statPaseoConfigPath(tempDir);
const expectedRevision = await statPaseoConfigPath(tempDir);
const result = writePaseoConfigForEdit({
const result = await writePaseoConfigForEdit({
repoRoot: tempDir,
config: { worktree: { setup: "npm install" } },
expectedRevision,
@@ -91,7 +265,7 @@ describe("paseo config file substrate", () => {
expect(result).toEqual({
ok: true,
config: { worktree: { setup: "npm install" } },
revision: statPaseoConfigPath(tempDir),
revision: await statPaseoConfigPath(tempDir),
});
expect(readFileSync(join(tempDir, "paseo.json"), "utf8")).toBe(
'{\n "worktree": {\n "setup": "npm install"\n }\n}\n',
@@ -101,13 +275,13 @@ describe("paseo config file substrate", () => {
// POSIX-only: Windows mtime granularity can collapse the two revisions in this fixture.
it.skipIf(isPlatform("win32"))(
"rejects stale writes when the current revision changed before rename",
() => {
async () => {
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
const expectedRevision = statPaseoConfigPath(tempDir);
const expectedRevision = await statPaseoConfigPath(tempDir);
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "new" } }));
const currentRevision = statPaseoConfigPath(tempDir);
const currentRevision = await statPaseoConfigPath(tempDir);
const result = writePaseoConfigForEdit({
const result = await writePaseoConfigForEdit({
repoRoot: tempDir,
config: { worktree: { setup: "from editor" } },
expectedRevision,
@@ -123,7 +297,7 @@ describe("paseo config file substrate", () => {
},
);
it("round-trips unknown top-level, worktree, and script-entry fields", () => {
it("round-trips unknown top-level, worktree, and script-entry fields", async () => {
const config = {
extraTop: { keep: true },
worktree: {
@@ -139,7 +313,7 @@ describe("paseo config file substrate", () => {
},
};
const result = writePaseoConfigForEdit({
const result = await writePaseoConfigForEdit({
repoRoot: tempDir,
config,
expectedRevision: null,
@@ -148,20 +322,20 @@ describe("paseo config file substrate", () => {
expect(result).toEqual({
ok: true,
config,
revision: statPaseoConfigPath(tempDir),
revision: await statPaseoConfigPath(tempDir),
});
expect(readPaseoConfigForEdit(tempDir)).toEqual({
expect(await readPaseoConfigForEdit(tempDir)).toEqual({
ok: true,
config,
revision: statPaseoConfigPath(tempDir),
revision: await statPaseoConfigPath(tempDir),
});
});
it("returns write_failed for filesystem write exceptions", () => {
it("returns write_failed for filesystem write exceptions", async () => {
const fileRoot = join(tempDir, "not-a-directory");
writeFileSync(fileRoot, "file");
const result = writePaseoConfigForEdit({
const result = await writePaseoConfigForEdit({
repoRoot: fileRoot,
config: { worktree: { setup: "npm install" } },
expectedRevision: null,
@@ -173,10 +347,10 @@ describe("paseo config file substrate", () => {
});
});
it("creates paseo.json when the file is still missing and expected revision is null", () => {
it("creates paseo.json when the file is still missing and expected revision is null", async () => {
mkdirSync(join(tempDir, "nested"));
const result = writePaseoConfigForEdit({
const result = await writePaseoConfigForEdit({
repoRoot: join(tempDir, "nested"),
config: { scripts: { dev: { command: "npm run dev" } } },
expectedRevision: null,
@@ -185,7 +359,7 @@ describe("paseo config file substrate", () => {
expect(result).toEqual({
ok: true,
config: { scripts: { dev: { command: "npm run dev" } } },
revision: statPaseoConfigPath(join(tempDir, "nested")),
revision: await statPaseoConfigPath(join(tempDir, "nested")),
});
});
});
+264 -79
View File
@@ -1,8 +1,14 @@
import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { constants, type Stats } from "node:fs";
import { open, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import pLimit from "p-limit";
import { withTimeout } from "./promise-timeout.js";
import { setImmediate as nextTurn } from "node:timers/promises";
import { randomUUID } from "node:crypto";
import {
PaseoConfigRawSchema,
PaseoConfigSchema,
type PaseoConfig,
type PaseoConfigRaw,
type PaseoConfigRevision,
type ProjectConfigRpcError,
@@ -15,15 +21,18 @@ export {
} from "@getpaseo/protocol/paseo-config-schema";
export const PASEO_CONFIG_FILE_NAME = "paseo.json";
// Configuration is deliberately small. Bound both IO and main-thread JSON/schema work.
export const MAX_PASEO_CONFIG_BYTES = 256 * 1024;
export type ReadPaseoConfigForEditResult =
| { ok: true; config: PaseoConfigRaw | null; revision: PaseoConfigRevision | null }
| { ok: false; error: ProjectConfigRpcError };
export type ReadPaseoConfigResult =
| { ok: true; config: PaseoConfig | null }
| { ok: false; configPath: string; error: unknown };
export type WritePaseoConfigForEditResult =
| { ok: true; config: PaseoConfigRaw; revision: PaseoConfigRevision }
| { ok: false; error: ProjectConfigRpcError };
export interface WritePaseoConfigForEditInput {
repoRoot: string;
config: PaseoConfigRaw;
@@ -34,96 +43,272 @@ export function resolvePaseoConfigPath(repoRoot: string): string {
return join(repoRoot, PASEO_CONFIG_FILE_NAME);
}
export function statPaseoConfigPath(repoRoot: string): PaseoConfigRevision | null {
const configPath = resolvePaseoConfigPath(repoRoot);
if (!existsSync(configPath)) {
return null;
}
const stats = statSync(configPath);
return {
mtimeMs: stats.mtimeMs,
size: stats.size,
};
function revision(stats: Stats): PaseoConfigRevision {
return { mtimeMs: stats.mtimeMs, size: stats.size };
}
export function readPaseoConfigJson(repoRoot: string): unknown {
const configPath = resolvePaseoConfigPath(repoRoot);
if (!existsSync(configPath)) {
return null;
}
return JSON.parse(readFileSync(configPath, "utf8"));
}
export function readPaseoConfigForEdit(repoRoot: string): ReadPaseoConfigForEditResult {
async function inspect(repoRoot: string): Promise<Stats | null> {
try {
const json = readPaseoConfigJson(repoRoot);
if (json === null) {
return { ok: true, config: null, revision: null };
return await stat(resolvePaseoConfigPath(repoRoot));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
}
export async function statPaseoConfigPath(repoRoot: string): Promise<PaseoConfigRevision | null> {
const stats = await inspect(repoRoot);
return stats ? revision(stats) : null;
}
function admit(stats: Stats): void {
if (!stats.isFile() || stats.size > MAX_PASEO_CONFIG_BYTES) {
throw new Error(`paseo.json must be a regular file of at most ${MAX_PASEO_CONFIG_BYTES} bytes`);
}
}
function fingerprint(stats: Stats | null): string {
return stats
? `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}`
: "missing";
}
// The record schema validates every script synchronously. Validate bounded groups
// through the same schemas so thousands of script entries do not monopolize a turn.
async function validateConfig(json: unknown): Promise<{
edit: ReturnType<typeof PaseoConfigRawSchema.safeParse>;
runtime: PaseoConfig;
}> {
if (json === null || typeof json !== "object" || Array.isArray(json)) {
return { edit: PaseoConfigRawSchema.safeParse(json), runtime: PaseoConfigSchema.parse(json) };
}
const scripts = (json as Record<string, unknown>).scripts;
if (scripts === null || typeof scripts !== "object" || Array.isArray(scripts)) {
return { edit: PaseoConfigRawSchema.safeParse(json), runtime: PaseoConfigSchema.parse(json) };
}
const withoutScripts = { ...json, scripts: {} };
let edit = PaseoConfigRawSchema.safeParse(withoutScripts);
const runtime = PaseoConfigSchema.parse(withoutScripts);
const entries = Object.entries(scripts);
if (entries.length > 32) await nextTurn();
let lastYield = performance.now();
for (let offset = 0; offset < entries.length; offset += 32) {
const group = { scripts: Object.fromEntries(entries.slice(offset, offset + 32)) };
if (edit.success) {
const parsed = PaseoConfigRawSchema.safeParse(group);
if (parsed.success) Object.assign(edit.data.scripts!, parsed.data.scripts);
else edit = parsed;
}
return {
ok: true,
config: PaseoConfigRawSchema.parse(json),
revision: statPaseoConfigPath(repoRoot),
};
Object.assign(runtime.scripts!, PaseoConfigSchema.parse(group).scripts);
if (performance.now() - lastYield >= 2) {
await nextTurn();
lastYield = performance.now();
}
}
return { edit, runtime };
}
interface Snapshot {
fingerprint: string;
edit: ReadPaseoConfigForEditResult;
runtime: ReadPaseoConfigResult;
}
/** Owns config admission, snapshots and daemon read/modify/write ordering for every consumer. */
class ProjectConfigOwner {
// A stalled mount retains its slot until the OS returns; never consume the whole fs pool.
private readonly filesystemOperations = pLimit(2);
private readonly pendingAliases = new Map<string, Promise<string>>();
private readonly snapshots = new Map<string, Snapshot>();
private readonly pending = new Map<
string,
{ kind: "read" | "write"; promise: Promise<unknown> }
>();
async resolveRoot(repoRoot: string, activeRoots: Set<string>): Promise<string | null> {
const root = resolve(repoRoot);
if (activeRoots.has(root)) return root;
let pending = this.pendingAliases.get(root);
if (!pending) {
pending = this.filesystemOperations(() => realpath(root)).finally(() =>
this.pendingAliases.delete(root),
);
this.pendingAliases.set(root, pending);
}
try {
const alias = await withTimeout(pending, 5_000, "Project root unavailable");
return activeRoots.has(alias) ? alias : null;
} catch {
return null;
}
}
private async serialized<T>(
root: string,
kind: "read" | "write",
operation: () => Promise<T>,
): Promise<T> {
const previous = this.pending.get(root);
if (kind === "read" && previous?.kind === "read") return previous.promise as Promise<T>;
const next = (async () => {
await previous?.promise.catch(() => undefined);
return this.filesystemOperations(operation);
})();
this.pending.set(root, { kind, promise: next });
try {
return await next;
} finally {
if (this.pending.get(root)?.promise === next) this.pending.delete(root);
}
}
private remember(root: string, snapshot: Snapshot): Snapshot {
this.snapshots.delete(root);
this.snapshots.set(root, snapshot);
// Worktrees come and go; retain only a bounded set of reusable snapshots.
if (this.snapshots.size > 256) this.snapshots.delete(this.snapshots.keys().next().value!);
return snapshot;
}
async read(repoRoot: string): Promise<Snapshot> {
const root = resolve(repoRoot);
return withTimeout(
this.serialized(root, "read", () => this.load(root)),
5_000,
"Project config unavailable",
);
}
private async load(root: string): Promise<Snapshot> {
const stats = await inspect(root);
if (stats) admit(stats);
const key = fingerprint(stats);
const cached = this.snapshots.get(root);
if (cached?.fingerprint === key) return this.remember(root, cached);
let json: unknown = null;
if (stats) {
// O_NONBLOCK prevents a regular-file -> FIFO replacement between stat and open
// from waiting for a writer. Check the opened descriptor again before any read.
const handle = await open(
resolvePaseoConfigPath(root),
constants.O_RDONLY | constants.O_NONBLOCK,
);
try {
const openedStats = await handle.stat();
admit(openedStats);
const bytes = Buffer.alloc(MAX_PASEO_CONFIG_BYTES + 1);
let length = 0;
while (length < bytes.length) {
const { bytesRead } = await handle.read(bytes, length, bytes.length - length, null);
if (bytesRead === 0) break;
length += bytesRead;
}
if (length > MAX_PASEO_CONFIG_BYTES) throw new Error("paseo.json exceeds byte limit");
// Never publish bytes under a revision that did not describe the opened file.
if (fingerprint(await handle.stat()) !== fingerprint(openedStats)) {
throw new Error("paseo.json changed while reading; retry the operation");
}
try {
json = JSON.parse(bytes.toString("utf8", 0, length));
} catch (error) {
return this.remember(root, {
fingerprint: key,
edit: { ok: false, error: { code: "invalid_project_config" } },
runtime: { ok: false, configPath: resolvePaseoConfigPath(root), error },
});
}
if (fingerprint(openedStats) !== key) {
throw new Error("paseo.json was replaced while reading; retry the operation");
}
} finally {
await handle.close();
}
}
const validated = json === null ? null : await validateConfig(json);
const parsed = validated?.edit ?? null;
let edit: ReadPaseoConfigForEditResult = { ok: true, config: null, revision: null };
if (parsed !== null) {
edit = parsed.success
? { ok: true, config: parsed.data, revision: stats ? revision(stats) : null }
: { ok: false, error: { code: "invalid_project_config" } };
}
return this.remember(root, {
fingerprint: key,
edit,
runtime: { ok: true, config: validated?.runtime ?? null },
});
}
async write(input: WritePaseoConfigForEditInput): Promise<WritePaseoConfigForEditResult> {
const root = resolve(input.repoRoot);
return this.serialized(root, "write", async () => {
const { edit: parsed } = await validateConfig(input.config);
if (!parsed.success) return { ok: false, error: { code: "invalid_project_config" } };
const content = `${JSON.stringify(parsed.data, null, 2)}\n`;
if (Buffer.byteLength(content) > MAX_PASEO_CONFIG_BYTES) {
return { ok: false, error: { code: "invalid_project_config" } };
}
const tempPath = join(root, `.${PASEO_CONFIG_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`);
try {
await writeFile(tempPath, content, { flag: "wx" });
const savedRevision = revision(await stat(tempPath));
const currentStats = await inspect(root);
if (currentStats) admit(currentStats);
const currentRevision = currentStats ? revision(currentStats) : null;
if (!paseoConfigRevisionsEqual(currentRevision, input.expectedRevision)) {
return { ok: false, error: { code: "stale_project_config", currentRevision } };
}
// Serialized against daemon edits. External writers still have the existing
// optimistic stat/rename race; revision tokens remain mtime + size.
await rename(tempPath, resolvePaseoConfigPath(root));
this.snapshots.delete(root);
return { ok: true, config: parsed.data, revision: savedRevision };
} catch {
return { ok: false, error: { code: "write_failed" } };
} finally {
await rm(tempPath, { force: true }).catch(() => undefined);
}
});
}
}
const projectConfig = new ProjectConfigOwner();
export function resolveProjectConfigRoot(
repoRoot: string,
activeRoots: Set<string>,
): Promise<string | null> {
return projectConfig.resolveRoot(repoRoot, activeRoots);
}
export async function readPaseoConfig(repoRoot: string): Promise<ReadPaseoConfigResult> {
try {
return (await projectConfig.read(repoRoot)).runtime;
} catch (error) {
return { ok: false, configPath: resolvePaseoConfigPath(repoRoot), error };
}
}
export async function readPaseoConfigForEdit(
repoRoot: string,
): Promise<ReadPaseoConfigForEditResult> {
try {
return (await projectConfig.read(repoRoot)).edit;
} catch {
return {
ok: false,
error: { code: "invalid_project_config" },
};
return { ok: false, error: { code: "invalid_project_config" } };
}
}
export function writePaseoConfigForEdit(
input: WritePaseoConfigForEditInput,
): WritePaseoConfigForEditResult {
const parsed = PaseoConfigRawSchema.safeParse(input.config);
if (!parsed.success) {
return { ok: false, error: { code: "invalid_project_config" } };
}
const configPath = resolvePaseoConfigPath(input.repoRoot);
const tempPath = join(
input.repoRoot,
`.${PASEO_CONFIG_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`,
);
try {
writeFileSync(tempPath, `${JSON.stringify(parsed.data, null, 2)}\n`);
const currentRevision = statPaseoConfigPath(input.repoRoot);
if (!paseoConfigRevisionsEqual(currentRevision, input.expectedRevision)) {
removeTempPaseoConfig(tempPath);
return {
ok: false,
error: { code: "stale_project_config", currentRevision },
};
}
renameSync(tempPath, configPath);
const revision = statPaseoConfigPath(input.repoRoot);
if (!revision) {
return { ok: false, error: { code: "write_failed" } };
}
return { ok: true, config: parsed.data, revision };
} catch {
removeTempPaseoConfig(tempPath);
return { ok: false, error: { code: "write_failed" } };
}
): Promise<WritePaseoConfigForEditResult> {
return projectConfig.write(input);
}
function paseoConfigRevisionsEqual(
left: PaseoConfigRevision | null,
right: PaseoConfigRevision | null,
): boolean {
if (left === null || right === null) {
return left === right;
}
if (left === null || right === null) return left === right;
return left.mtimeMs === right.mtimeMs && left.size === right.size;
}
function removeTempPaseoConfig(tempPath: string): void {
try {
rmSync(tempPath, { force: true });
} catch {
// Best-effort cleanup only; callers need the original write outcome.
}
}
@@ -48,8 +48,8 @@ import { delimiter, dirname, join } from "path";
import { tmpdir } from "os";
import net from "node:net";
function loadConfigForTest(repoRoot: string): PaseoConfig | null {
const result = readPaseoConfig(repoRoot);
async function loadConfigForTest(repoRoot: string): Promise<PaseoConfig | null> {
const result = await readPaseoConfig(repoRoot);
return result.ok ? result.config : null;
}
@@ -894,7 +894,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
worktreePath: result.worktreePath,
branchName: result.branchName,
});
expect(getWorktreeSetupCommands(result.worktreePath)).toEqual([
expect(await getWorktreeSetupCommands(result.worktreePath)).toEqual([
'greeting="hello from string setup"\necho "$greeting" > setup.log',
]);
expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8").trim()).toBe(
@@ -963,7 +963,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
expect(readFileSync(join(repoDir, "setup-path.log"), "utf8").trim()).toBe("shim:ok");
});
it("treats blank lifecycle strings as empty", () => {
it("treats blank lifecycle strings as empty", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
@@ -974,11 +974,11 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
}),
);
expect(getWorktreeSetupCommands(repoDir)).toEqual([]);
expect(getWorktreeTeardownCommands(repoDir)).toEqual([]);
expect(await getWorktreeSetupCommands(repoDir)).toEqual([]);
expect(await getWorktreeTeardownCommands(repoDir)).toEqual([]);
});
it("filters non-string and blank entries from lifecycle arrays", () => {
it("filters non-string and blank entries from lifecycle arrays", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
@@ -999,11 +999,11 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
}),
);
expect(getWorktreeSetupCommands(repoDir)).toEqual([
expect(await getWorktreeSetupCommands(repoDir)).toEqual([
'echo "first" > setup-array.log',
'echo "second" >> setup-array.log',
]);
expect(getWorktreeTeardownCommands(repoDir)).toEqual([
expect(await getWorktreeTeardownCommands(repoDir)).toEqual([
'echo "first" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"',
'echo "second" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"',
]);
@@ -1134,7 +1134,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
};
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
expect(getWorktreeTerminalSpecs(repoDir)).toEqual([
expect(await getWorktreeTerminalSpecs(repoDir)).toEqual([
{ name: "Dev Server", command: "npm run dev" },
{ command: "cd packages/app && npm run dev" },
]);
@@ -1154,7 +1154,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
};
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
expect(getWorktreeTerminalSpecs(repoDir)).toEqual([
expect(await getWorktreeTerminalSpecs(repoDir)).toEqual([
{ name: "Watch", command: "npm run watch" },
{ command: "npm run test" },
]);
@@ -1172,7 +1172,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
}),
);
const scriptConfigs = getScriptConfigs(loadConfigForTest(repoDir));
const scriptConfigs = getScriptConfigs(await loadConfigForTest(repoDir));
const typecheck = scriptConfigs.get("typecheck");
expect(typecheck).toEqual({
@@ -1196,7 +1196,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
}),
);
const scriptConfigs = getScriptConfigs(loadConfigForTest(repoDir));
const scriptConfigs = getScriptConfigs(await loadConfigForTest(repoDir));
const server = scriptConfigs.get("server");
expect(server).toEqual({
@@ -1236,7 +1236,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
}),
);
expect(getScriptConfigs(loadConfigForTest(repoDir))).toEqual(
expect(getScriptConfigs(await loadConfigForTest(repoDir))).toEqual(
new Map([
["valid", { command: "npm run valid" }],
["invalidType", { command: "npm run worker" }],
@@ -1547,7 +1547,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome });
expect(getWorktreeTeardownCommands(repoDir)).toEqual([
expect(await getWorktreeTeardownCommands(repoDir)).toEqual([
'cleanup_message="teardown string"\necho "$cleanup_message" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
]);
expect(readFileSync(join(repoDir, "teardown.log"), "utf8").trim()).toBe("teardown string");
+15 -27
View File
@@ -10,7 +10,7 @@ import {
buildStringCommandShellInvocation,
createStringCommandShellEnv,
} from "./string-command-shell.js";
import { readPaseoConfigJson, resolvePaseoConfigPath } from "./paseo-config-file.js";
import { readPaseoConfig } from "./paseo-config-file.js";
export {
PaseoConfigRawSchema,
PaseoLifecycleCommandRawSchema,
@@ -20,7 +20,7 @@ export {
type PaseoConfig,
type PaseoConfigRaw,
} from "@getpaseo/protocol/paseo-config-schema";
import { PaseoConfigSchema, type PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
import { type PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
import {
createPaseoWorktreeChangeRequestHint,
normalizeBaseRefName,
@@ -246,21 +246,7 @@ export class InvalidGitBranchNameError extends Error {
}
}
export type ReadPaseoConfigResult =
| { ok: true; config: PaseoConfig | null }
| { ok: false; configPath: string; error: unknown };
export function readPaseoConfig(repoRoot: string): ReadPaseoConfigResult {
try {
const json = readPaseoConfigJson(repoRoot);
if (json === null) {
return { ok: true, config: null };
}
return { ok: true, config: PaseoConfigSchema.parse(json) };
} catch (error) {
return { ok: false, configPath: resolvePaseoConfigPath(repoRoot), error };
}
}
export { readPaseoConfig, type ReadPaseoConfigResult } from "./paseo-config-file.js";
export function paseoConfigParseError(failure: { configPath: string; error: unknown }): Error {
const detail = failure.error instanceof Error ? failure.error.message : String(failure.error);
@@ -269,24 +255,26 @@ export function paseoConfigParseError(failure: { configPath: string; error: unkn
});
}
function readPaseoConfigOrThrow(repoRoot: string): PaseoConfig | null {
const result = readPaseoConfig(repoRoot);
async function readPaseoConfigOrThrow(repoRoot: string): Promise<PaseoConfig | null> {
const result = await readPaseoConfig(repoRoot);
if (!result.ok) {
throw paseoConfigParseError(result);
}
return result.config;
}
export function getWorktreeSetupCommands(repoRoot: string): string[] {
return readPaseoConfigOrThrow(repoRoot)?.worktree?.setup ?? [];
export async function getWorktreeSetupCommands(repoRoot: string): Promise<string[]> {
return (await readPaseoConfigOrThrow(repoRoot))?.worktree?.setup ?? [];
}
export function getWorktreeTeardownCommands(repoRoot: string): string[] {
return readPaseoConfigOrThrow(repoRoot)?.worktree?.teardown ?? [];
export async function getWorktreeTeardownCommands(repoRoot: string): Promise<string[]> {
return (await readPaseoConfigOrThrow(repoRoot))?.worktree?.teardown ?? [];
}
export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConfig[] {
const terminals = readPaseoConfigOrThrow(repoRoot)?.worktree?.terminals;
export async function getWorktreeTerminalSpecs(
repoRoot: string,
): Promise<WorktreeTerminalConfig[]> {
const terminals = (await readPaseoConfigOrThrow(repoRoot))?.worktree?.terminals;
if (!Array.isArray(terminals) || terminals.length === 0) {
return [];
}
@@ -641,7 +629,7 @@ export async function runWorktreeSetupCommands(options: {
onEvent?: (event: WorktreeSetupCommandProgressEvent) => void;
}): Promise<WorktreeSetupCommandResult[]> {
// Read paseo.json from the worktree (it will have the same content as the source repo)
const setupCommands = getWorktreeSetupCommands(options.worktreePath);
const setupCommands = await getWorktreeSetupCommands(options.worktreePath);
if (setupCommands.length === 0) {
return [];
}
@@ -745,7 +733,7 @@ export async function runWorktreeTeardownCommands(options: {
if (getRealpathAwareRelativePath(options.worktreePath, teardownCwd) === null) {
throw new Error(`Worktree teardown cwd is outside the worktree: ${teardownCwd}`);
}
const teardownCommands = getWorktreeTeardownCommands(teardownCwd);
const teardownCommands = await getWorktreeTeardownCommands(teardownCwd);
if (teardownCommands.length === 0) {
return [];
}