fix(server): isolate paseo.json parse failures so they don't block fetch_workspaces

A malformed paseo.json (e.g. git-conflict markers) in any registered
workspace caused readPaseoConfig to throw, which propagated through
getScriptConfigs -> buildWorkspaceScriptPayloads ->
Session.describeWorkspaceRecord and failed the entire
fetch_workspaces_request with code=fetch_workspaces_failed. The client
showed no workspaces at all.

Reshape: separate loading (mechanism) from policy (per-caller).

- readPaseoConfig now returns a discriminated result
  ({ ok: true; config } | { ok: false; configPath; error }). Pure load,
  no logging, no opinions.
- Workspace projection owns its own policy via readPaseoConfigForProjection
  in script-status-projection.ts: warn via pino with configPath +
  workspaceDirectory + err, treat workspace as having no scripts, keep the
  list alive.
- spawnWorkspaceScript throws with cause so the existing
  handleStartWorkspaceScriptRequest catch logs it once with full context.
- The setup/teardown/terminal wrappers retain their pre-existing silent
  degradation on missing/broken config.
- getScriptConfigs takes a parsed PaseoConfig | null instead of repoRoot
  so the projection can pass the result through without double-reading.

Closes https://github.com/getpaseo/paseo/issues/598
This commit is contained in:
Mohamed Boudra
2026-04-28 19:48:24 +07:00
parent fbf10ba4c6
commit 00219cfd04
7 changed files with 139 additions and 22 deletions
+1
View File
@@ -269,6 +269,7 @@ export async function createPaseoDaemon(
daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
resolveWorkspaceDirectory: async (workspaceId) => resolveWorkspaceDirectory: async (workspaceId) =>
(await workspaceRegistry?.get(workspaceId))?.cwd ?? null, (await workspaceRegistry?.get(workspaceId))?.cwd ?? null,
logger,
}), }),
}); });
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
@@ -11,6 +11,9 @@ import {
import { WorkspaceScriptPayloadSchema } from "../shared/messages.js"; import { WorkspaceScriptPayloadSchema } from "../shared/messages.js";
import type { ScriptHealthState } from "./script-health-monitor.js"; import type { ScriptHealthState } from "./script-health-monitor.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import { readPaseoConfig } from "../utils/worktree.js";
import type { PaseoConfig } from "../utils/paseo-config-schema.js";
import { createTestLogger } from "../test-utils/test-logger.js";
function createWorkspaceRepo(options?: { function createWorkspaceRepo(options?: {
branchName?: string; branchName?: string;
@@ -41,13 +44,21 @@ function createWorkspaceRepo(options?: {
function buildPayloads(input: { function buildPayloads(input: {
workspaceId: string; workspaceId: string;
workspaceDirectory: string; workspaceDirectory: string;
paseoConfig?: PaseoConfig | null;
routeStore: ScriptRouteStore; routeStore: ScriptRouteStore;
runtimeStore: WorkspaceScriptRuntimeStore; runtimeStore: WorkspaceScriptRuntimeStore;
daemonPort: number | null; daemonPort: number | null;
gitMetadata?: { projectSlug: string; currentBranch: string | null }; gitMetadata?: { projectSlug: string; currentBranch: string | null };
resolveHealth?: (hostname: string) => ScriptHealthState | null; resolveHealth?: (hostname: string) => ScriptHealthState | null;
}) { }) {
return buildWorkspaceScriptPayloads(input); const paseoConfig =
input.paseoConfig !== undefined ? input.paseoConfig : loadConfig(input.workspaceDirectory);
return buildWorkspaceScriptPayloads({ ...input, paseoConfig });
}
function loadConfig(repoRoot: string): PaseoConfig | null {
const result = readPaseoConfig(repoRoot);
return result.ok ? result.config : null;
} }
describe("script-status-projection", () => { describe("script-status-projection", () => {
@@ -369,6 +380,67 @@ describe("script-status-projection", () => {
} }
}); });
it("readPaseoConfig fails with configPath and error when paseo.json is malformed", () => {
const workspace = createWorkspaceRepo();
const configPath = path.join(workspace.repoDir, "paseo.json");
writeFileSync(
configPath,
'{\n<<<<<<< HEAD\n "scripts": {}\n=======\n "scripts": {}\n>>>>>>> origin/main\n}\n',
);
try {
const result = readPaseoConfig(workspace.repoDir);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("unreachable");
expect(result.configPath).toBe(configPath);
expect(result.error).toBeInstanceOf(SyntaxError);
} finally {
workspace.cleanup();
}
});
it("buildWorkspaceScriptPayloads given paseoConfig=null still surfaces orphaned runtime scripts", () => {
const workspaceId = "workspace-null-config";
const workspace = createWorkspaceRepo();
const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore();
runtimeStore.set({
workspaceId,
scriptName: "typecheck",
type: "script",
lifecycle: "running",
terminalId: "term-typecheck",
exitCode: null,
});
try {
expect(
buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir,
paseoConfig: null,
routeStore,
runtimeStore,
daemonPort: 6767,
}),
).toEqual([
{
scriptName: "typecheck",
type: "script",
hostname: "typecheck",
port: null,
proxyUrl: null,
lifecycle: "running",
health: null,
exitCode: null,
terminalId: "term-typecheck",
},
]);
} finally {
workspace.cleanup();
}
});
it("createScriptStatusEmitter overlays health onto the projected workspace script list", async () => { it("createScriptStatusEmitter overlays health onto the projected workspace script list", async () => {
const workspaceId = "workspace-emitter"; const workspaceId = "workspace-emitter";
const workspace = createWorkspaceRepo({ const workspace = createWorkspaceRepo({
@@ -405,6 +477,7 @@ describe("script-status-projection", () => {
daemonPort: 6767, daemonPort: 6767,
resolveWorkspaceDirectory: async (requestedWorkspaceId) => resolveWorkspaceDirectory: async (requestedWorkspaceId) =>
requestedWorkspaceId === "workspace-emitter" ? workspace.repoDir : null, requestedWorkspaceId === "workspace-emitter" ? workspace.repoDir : null,
logger: createTestLogger(),
}); });
try { try {
@@ -1,10 +1,12 @@
import type { Logger } from "pino";
import type { import type {
ScriptStatusUpdateMessage, ScriptStatusUpdateMessage,
SessionOutboundMessage, SessionOutboundMessage,
WorkspaceScriptPayload, WorkspaceScriptPayload,
} from "../shared/messages.js"; } from "../shared/messages.js";
import type { PaseoConfig } from "../utils/paseo-config-schema.js";
import { buildScriptHostname } from "../utils/script-hostname.js"; import { buildScriptHostname } from "../utils/script-hostname.js";
import { getScriptConfigs, isServiceScript } from "../utils/worktree.js"; import { getScriptConfigs, isServiceScript, readPaseoConfig } from "../utils/worktree.js";
import { deriveProjectSlug } from "./workspace-git-metadata.js"; import { deriveProjectSlug } from "./workspace-git-metadata.js";
import type { ScriptHealthEntry, ScriptHealthState } from "./script-health-monitor.js"; import type { ScriptHealthEntry, ScriptHealthState } from "./script-health-monitor.js";
import type { ScriptRouteStore } from "./script-proxy.js"; import type { ScriptRouteStore } from "./script-proxy.js";
@@ -17,6 +19,7 @@ interface SessionEmitter {
interface BuildWorkspaceScriptPayloadsOptions { interface BuildWorkspaceScriptPayloadsOptions {
workspaceId: string; workspaceId: string;
workspaceDirectory: string; workspaceDirectory: string;
paseoConfig: PaseoConfig | null;
routeStore: ScriptRouteStore; routeStore: ScriptRouteStore;
runtimeStore: WorkspaceScriptRuntimeStore; runtimeStore: WorkspaceScriptRuntimeStore;
daemonPort: number | null; daemonPort: number | null;
@@ -27,6 +30,21 @@ interface BuildWorkspaceScriptPayloadsOptions {
resolveHealth?: (hostname: string) => ScriptHealthState | null; resolveHealth?: (hostname: string) => ScriptHealthState | null;
} }
export function readPaseoConfigForProjection(
workspaceDirectory: string,
logger: Logger,
): PaseoConfig | null {
const result = readPaseoConfig(workspaceDirectory);
if (result.ok) {
return result.config;
}
logger.warn(
{ configPath: result.configPath, workspaceDirectory, err: result.error },
"Failed to parse paseo.json; treating workspace as having no scripts",
);
return null;
}
function resolveDaemonPort(daemonPort: number | null | (() => number | null)): number | null { function resolveDaemonPort(daemonPort: number | null | (() => number | null)): number | null {
if (typeof daemonPort === "function") { if (typeof daemonPort === "function") {
return daemonPort(); return daemonPort();
@@ -136,7 +154,7 @@ export function buildWorkspaceScriptPayloads(
const workspaceDirectory = options.workspaceDirectory; const workspaceDirectory = options.workspaceDirectory;
const projectSlug = options.gitMetadata?.projectSlug ?? deriveProjectSlug(workspaceDirectory); const projectSlug = options.gitMetadata?.projectSlug ?? deriveProjectSlug(workspaceDirectory);
const branchName = options.gitMetadata?.currentBranch ?? null; const branchName = options.gitMetadata?.currentBranch ?? null;
const scriptConfigs = getScriptConfigs(workspaceDirectory); const scriptConfigs = getScriptConfigs(options.paseoConfig);
const runtimeEntries = new Map( const runtimeEntries = new Map(
options.runtimeStore options.runtimeStore
.listForWorkspace(workspaceId) .listForWorkspace(workspaceId)
@@ -193,12 +211,14 @@ export function createScriptStatusEmitter({
runtimeStore, runtimeStore,
daemonPort, daemonPort,
resolveWorkspaceDirectory, resolveWorkspaceDirectory,
logger,
}: { }: {
sessions: () => SessionEmitter[]; sessions: () => SessionEmitter[];
routeStore: ScriptRouteStore; routeStore: ScriptRouteStore;
runtimeStore: WorkspaceScriptRuntimeStore; runtimeStore: WorkspaceScriptRuntimeStore;
daemonPort: number | null | (() => number | null); daemonPort: number | null | (() => number | null);
resolveWorkspaceDirectory: (workspaceId: string) => string | null | Promise<string | null>; resolveWorkspaceDirectory: (workspaceId: string) => string | null | Promise<string | null>;
logger: Logger;
}): (workspaceId: string, scripts: ScriptHealthEntry[]) => void { }): (workspaceId: string, scripts: ScriptHealthEntry[]) => void {
return (workspaceId, scripts) => { return (workspaceId, scripts) => {
void (async () => { void (async () => {
@@ -215,6 +235,7 @@ export function createScriptStatusEmitter({
const projected = buildWorkspaceScriptPayloads({ const projected = buildWorkspaceScriptPayloads({
workspaceId, workspaceId,
workspaceDirectory, workspaceDirectory,
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger),
routeStore, routeStore,
runtimeStore, runtimeStore,
daemonPort: resolvedDaemonPort, daemonPort: resolvedDaemonPort,
+6 -1
View File
@@ -77,7 +77,10 @@ import { sendPromptToAgent, unarchiveAgentState } from "./agent/mcp-shared.js";
import { experimental_createMCPClient } from "ai"; import { experimental_createMCPClient } from "ai";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
import { buildWorkspaceScriptPayloads } from "./script-status-projection.js"; import {
buildWorkspaceScriptPayloads,
readPaseoConfigForProjection,
} from "./script-status-projection.js";
import { deriveProjectSlug } from "./workspace-git-metadata.js"; import { deriveProjectSlug } from "./workspace-git-metadata.js";
import type { ScriptHealthState } from "./script-health-monitor.js"; import type { ScriptHealthState } from "./script-health-monitor.js";
import { spawnWorkspaceScript } from "./worktree-bootstrap.js"; import { spawnWorkspaceScript } from "./worktree-bootstrap.js";
@@ -6069,6 +6072,7 @@ export class Session {
? buildWorkspaceScriptPayloads({ ? buildWorkspaceScriptPayloads({
workspaceId: workspace.workspaceId, workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.cwd, workspaceDirectory: workspace.cwd,
paseoConfig: readPaseoConfigForProjection(workspace.cwd, this.sessionLogger),
routeStore: this.scriptRouteStore, routeStore: this.scriptRouteStore,
runtimeStore: this.scriptRuntimeStore, runtimeStore: this.scriptRuntimeStore,
daemonPort: this.getDaemonTcpPort?.() ?? null, daemonPort: this.getDaemonTcpPort?.() ?? null,
@@ -7133,6 +7137,7 @@ export class Session {
return buildWorkspaceScriptPayloads({ return buildWorkspaceScriptPayloads({
workspaceId, workspaceId,
workspaceDirectory, workspaceDirectory,
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, this.sessionLogger),
routeStore: this.scriptRouteStore, routeStore: this.scriptRouteStore,
runtimeStore: this.scriptRuntimeStore, runtimeStore: this.scriptRuntimeStore,
daemonPort: this.getDaemonTcpPort?.() ?? null, daemonPort: this.getDaemonTcpPort?.() ?? null,
@@ -8,6 +8,7 @@ import {
getWorktreeTerminalSpecs, getWorktreeTerminalSpecs,
isServiceScript, isServiceScript,
processCarriageReturns, processCarriageReturns,
readPaseoConfig,
resolveWorktreeRuntimeEnv, resolveWorktreeRuntimeEnv,
runWorktreeSetupCommands, runWorktreeSetupCommands,
WorktreeSetupError, WorktreeSetupError,
@@ -832,7 +833,13 @@ export async function spawnWorkspaceScript(
logger, logger,
onLifecycleChanged, onLifecycleChanged,
} = options; } = options;
const scriptConfigs = getScriptConfigs(repoRoot); const configResult = readPaseoConfig(repoRoot);
if (!configResult.ok) {
throw new Error(`Failed to parse ${configResult.configPath}`, {
cause: configResult.error,
});
}
const scriptConfigs = getScriptConfigs(configResult.config);
const config = scriptConfigs.get(scriptName); const config = scriptConfigs.get(scriptName);
if (!config) { if (!config) {
throw new Error(`Script '${scriptName}' is not configured in paseo.json`); throw new Error(`Script '${scriptName}' is not configured in paseo.json`);
+10 -3
View File
@@ -11,6 +11,7 @@ import {
isServiceScript, isServiceScript,
isPaseoOwnedWorktreeCwd, isPaseoOwnedWorktreeCwd,
listPaseoWorktrees, listPaseoWorktrees,
readPaseoConfig,
resolveWorktreeRuntimeEnv, resolveWorktreeRuntimeEnv,
type WorktreeSetupCommandProgressEvent, type WorktreeSetupCommandProgressEvent,
runWorktreeSetupCommands, runWorktreeSetupCommands,
@@ -18,6 +19,12 @@ import {
type CreateWorktreeOptions, type CreateWorktreeOptions,
type WorktreeConfig, type WorktreeConfig,
} from "./worktree"; } from "./worktree";
import type { PaseoConfig } from "./paseo-config-schema.js";
function loadConfigForTest(repoRoot: string): PaseoConfig | null {
const result = readPaseoConfig(repoRoot);
return result.ok ? result.config : null;
}
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js"; import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
import { execSync } from "child_process"; import { execSync } from "child_process";
import { import {
@@ -707,7 +714,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
}), }),
); );
const scriptConfigs = getScriptConfigs(repoDir); const scriptConfigs = getScriptConfigs(loadConfigForTest(repoDir));
const typecheck = scriptConfigs.get("typecheck"); const typecheck = scriptConfigs.get("typecheck");
expect(typecheck).toEqual({ expect(typecheck).toEqual({
@@ -731,7 +738,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
}), }),
); );
const scriptConfigs = getScriptConfigs(repoDir); const scriptConfigs = getScriptConfigs(loadConfigForTest(repoDir));
const server = scriptConfigs.get("server"); const server = scriptConfigs.get("server");
expect(server).toEqual({ expect(server).toEqual({
@@ -771,7 +778,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
}), }),
); );
expect(getScriptConfigs(repoDir)).toEqual( expect(getScriptConfigs(loadConfigForTest(repoDir))).toEqual(
new Map([ new Map([
["valid", { command: "npm run valid" }], ["valid", { command: "npm run valid" }],
["invalidType", { command: "npm run worker" }], ["invalidType", { command: "npm run worker" }],
+17 -14
View File
@@ -8,7 +8,7 @@ import { createHash } from "node:crypto";
import * as pty from "node-pty"; import * as pty from "node-pty";
import stripAnsi from "strip-ansi"; import stripAnsi from "strip-ansi";
import { buildStringCommandShellInvocation } from "./string-command-shell.js"; import { buildStringCommandShellInvocation } from "./string-command-shell.js";
import { readPaseoConfigJson } from "./paseo-config-file.js"; import { readPaseoConfigJson, resolvePaseoConfigPath } from "./paseo-config-file.js";
export { export {
PaseoConfigRawSchema, PaseoConfigRawSchema,
PaseoLifecycleCommandRawSchema, PaseoLifecycleCommandRawSchema,
@@ -197,31 +197,35 @@ export class UnknownBranchError extends Error {
} }
} }
export function readPaseoConfig(repoRoot: string): PaseoConfig | null { export type ReadPaseoConfigResult =
| { ok: true; config: PaseoConfig | null }
| { ok: false; configPath: string; error: unknown };
export function readPaseoConfig(repoRoot: string): ReadPaseoConfigResult {
try { try {
const json = readPaseoConfigJson(repoRoot); const json = readPaseoConfigJson(repoRoot);
if (json === null) { if (json === null) {
return null; return { ok: true, config: null };
} }
return PaseoConfigSchema.parse(json); return { ok: true, config: PaseoConfigSchema.parse(json) };
} catch { } catch (error) {
throw new Error(`Failed to parse paseo.json`); return { ok: false, configPath: resolvePaseoConfigPath(repoRoot), error };
} }
} }
export function getWorktreeSetupCommands(repoRoot: string): string[] { export function getWorktreeSetupCommands(repoRoot: string): string[] {
const config = readPaseoConfig(repoRoot); const result = readPaseoConfig(repoRoot);
return config?.worktree?.setup ?? []; return result.ok ? (result.config?.worktree?.setup ?? []) : [];
} }
export function getWorktreeTeardownCommands(repoRoot: string): string[] { export function getWorktreeTeardownCommands(repoRoot: string): string[] {
const config = readPaseoConfig(repoRoot); const result = readPaseoConfig(repoRoot);
return config?.worktree?.teardown ?? []; return result.ok ? (result.config?.worktree?.teardown ?? []) : [];
} }
export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConfig[] { export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConfig[] {
const config = readPaseoConfig(repoRoot); const result = readPaseoConfig(repoRoot);
const terminals = config?.worktree?.terminals; const terminals = result.ok ? result.config?.worktree?.terminals : undefined;
if (!Array.isArray(terminals) || terminals.length === 0) { if (!Array.isArray(terminals) || terminals.length === 0) {
return []; return [];
} }
@@ -254,8 +258,7 @@ export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConf
return specs; return specs;
} }
export function getScriptConfigs(repoRoot: string): Map<string, ScriptConfig> { export function getScriptConfigs(config: PaseoConfig | null): Map<string, ScriptConfig> {
const config = readPaseoConfig(repoRoot);
const scripts = config?.scripts; const scripts = config?.scripts;
if (!scripts || typeof scripts !== "object") { if (!scripts || typeof scripts !== "object") {
return new Map(); return new Map();