From b1b85833bb528941f80067df6ed0c4debe76c912 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 9 Apr 2026 23:45:00 +0700 Subject: [PATCH] Distinguish services from scripts with type system and runtime tracking --- .../src/components/workspace-hover-card.tsx | 49 +++- .../session-context.service-status.test.ts | 2 + .../hooks/use-sidebar-workspaces-list.test.ts | 4 + .../workspace/workspace-scripts-button.tsx | 33 ++- packages/app/src/stores/session-store.test.ts | 8 + .../src/server/agent/agent-management-mcp.ts | 4 - .../server/src/server/agent/mcp-server.ts | 4 - packages/server/src/server/bootstrap.ts | 20 +- .../src/server/script-health-monitor.test.ts | 252 ++++++++++++------ .../src/server/script-health-monitor.ts | 55 +++- .../script-route-branch-handler.test.ts | 114 ++++++-- .../src/server/script-route-branch-handler.ts | 13 +- .../server/script-status-projection.test.ts | 247 +++++++++++++---- .../src/server/script-status-projection.ts | 182 +++++++++---- packages/server/src/server/session.ts | 46 ++-- .../server/src/server/websocket-server.ts | 10 +- .../workspace-script-runtime-store.test.ts | 99 +++++++ .../server/workspace-script-runtime-store.ts | 105 ++++++++ .../src/server/worktree-bootstrap.test.ts | 144 +++++++--- .../server/src/server/worktree-bootstrap.ts | 119 +++++---- .../src/server/worktree-session.test.ts | 85 ++---- .../server/src/server/worktree-session.ts | 33 --- packages/server/src/shared/messages.ts | 2 + .../src/shared/messages.workspaces.test.ts | 6 + packages/server/src/utils/worktree.test.ts | 87 ++++++ packages/server/src/utils/worktree.ts | 34 ++- paseo.json | 1 + 27 files changed, 1265 insertions(+), 493 deletions(-) create mode 100644 packages/server/src/server/workspace-script-runtime-store.test.ts create mode 100644 packages/server/src/server/workspace-script-runtime-store.ts diff --git a/packages/app/src/components/workspace-hover-card.tsx b/packages/app/src/components/workspace-hover-card.tsx index 610d7aff7..27f919525 100644 --- a/packages/app/src/components/workspace-hover-card.tsx +++ b/packages/app/src/components/workspace-hover-card.tsx @@ -355,12 +355,43 @@ function WorkspaceHoverCardContent({ {workspace.scripts.map((script) => { const isRunning = script.lifecycle === "running"; - const isLinkable = isRunning && !!script.url; + const isService = (script.type ?? "service") === "service"; + const isLinkable = isService && isRunning && !!script.url; + const exitCode = script.exitCode ?? null; + + let dotColor: string; + if (isService) { + dotColor = isRunning + ? getScriptHealthColor({ health: script.health, theme }) + : theme.colors.foregroundMuted; + } else if (isRunning) { + dotColor = theme.colors.palette.green[500]; + } else if (exitCode === 0) { + dotColor = theme.colors.palette.green[500]; + } else if (exitCode !== null) { + dotColor = theme.colors.palette.red[500]; + } else { + dotColor = theme.colors.foregroundMuted; + } + + let accessibilityStatus: string; + if (isService) { + accessibilityStatus = isRunning ? getScriptHealthLabel(script.health) : "Stopped"; + } else if (isRunning) { + accessibilityStatus = "Running"; + } else if (exitCode === 0) { + accessibilityStatus = "Completed"; + } else if (exitCode !== null) { + accessibilityStatus = `Failed (exit ${exitCode})`; + } else { + accessibilityStatus = "Stopped"; + } + return ( [ styles.listRow, @@ -375,11 +406,7 @@ function WorkspaceHoverCardContent({ testID={`hover-card-script-health-${script.scriptName}`} style={[ styles.statusDot, - { - backgroundColor: isRunning - ? getScriptHealthColor({ health: script.health, theme }) - : theme.colors.foregroundMuted, - }, + { backgroundColor: dotColor }, ]} /> {script.scriptName} - {isRunning && script.url ? ( + {isService && isRunning && script.url ? ( {script.url.replace(/^https?:\/\//, "")} + ) : !isService && !isRunning && exitCode !== null && exitCode !== 0 ? ( + + exit {exitCode} + ) : ( )} {isRunning ? ( - script.url && hovered ? ( + isLinkable && hovered ? ( { diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts index 26a33eed2..a00e24bf9 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts @@ -47,20 +47,24 @@ function workspace( const runningScript: WorkspaceScriptPayload = { scriptName: "web", + type: "service", hostname: "main.web.localhost", port: 3000, url: "http://main.web.localhost:6767", lifecycle: "running", health: "healthy", + exitCode: null, }; const stoppedScript: WorkspaceScriptPayload = { scriptName: "api", + type: "service", hostname: "main.api.localhost", port: 3001, url: "http://main.api.localhost:6767", lifecycle: "stopped", health: null, + exitCode: null, }; describe("applyStoredOrdering", () => { diff --git a/packages/app/src/screens/workspace/workspace-scripts-button.tsx b/packages/app/src/screens/workspace/workspace-scripts-button.tsx index ef524e159..98720993a 100644 --- a/packages/app/src/screens/workspace/workspace-scripts-button.tsx +++ b/packages/app/src/screens/workspace/workspace-scripts-button.tsx @@ -104,7 +104,24 @@ export function WorkspaceScriptsButton({ {scripts.map((script) => { const isRunning = script.lifecycle === "running"; - const isLinkable = isRunning && !!script.url; + const isService = (script.type ?? "service") === "service"; + const isLinkable = isService && isRunning && !!script.url; + const exitCode = script.exitCode ?? null; + + let dotColor: string; + if (isService) { + dotColor = isRunning + ? getScriptHealthColor(script.health, theme) + : theme.colors.foregroundMuted; + } else if (isRunning) { + dotColor = theme.colors.palette.green[500]; + } else if (exitCode === 0) { + dotColor = theme.colors.palette.green[500]; + } else if (exitCode !== null) { + dotColor = theme.colors.palette.red[500]; + } else { + dotColor = theme.colors.foregroundMuted; + } return ( {script.scriptName} - {isRunning && script.url ? ( + {isService && isRunning && script.url ? ( {script.url.replace(/^https?:\/\//, "")} + ) : !isService && !isRunning && exitCode !== null && exitCode !== 0 ? ( + + exit {exitCode} + ) : ( )} {isRunning ? ( - script.url && hovered ? ( + isLinkable && hovered ? ( { const scripts = [ { scriptName: "web", + type: "service" as const, hostname: "main.web.localhost", port: 3000, url: "http://main.web.localhost:6767", lifecycle: "running" as const, health: "healthy" as const, + exitCode: null, }, ]; const workspace = normalizeWorkspaceDescriptor({ @@ -64,11 +66,13 @@ describe("normalizeWorkspaceDescriptor", () => { expect(workspace.scripts).toEqual([ { scriptName: "web", + type: "service", hostname: "main.web.localhost", port: 3000, url: "http://main.web.localhost:6767", lifecycle: "running", health: "healthy", + exitCode: null, }, ]); expect(workspace.scripts).not.toBe(scripts); @@ -111,11 +115,13 @@ describe("mergeWorkspaces", () => { scripts: [ { scriptName: "web", + type: "service", hostname: "main.web.localhost", port: 3000, url: "http://main.web.localhost:6767", lifecycle: "running", health: "healthy", + exitCode: null, }, ], }), @@ -124,11 +130,13 @@ describe("mergeWorkspaces", () => { expect(store.getSession("test-server")?.workspaces.get("/repo/main")?.scripts).toEqual([ { scriptName: "web", + type: "service", hostname: "main.web.localhost", port: 3000, url: "http://main.web.localhost:6767", lifecycle: "running", health: "healthy", + exitCode: null, }, ]); }); diff --git a/packages/server/src/server/agent/agent-management-mcp.ts b/packages/server/src/server/agent/agent-management-mcp.ts index 8922c45e2..b0d98611f 100644 --- a/packages/server/src/server/agent/agent-management-mcp.ts +++ b/packages/server/src/server/agent/agent-management-mcp.ts @@ -48,13 +48,11 @@ import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; import { expandUserPath } from "../path-utils.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js"; -import type { ScriptRouteStore } from "../script-proxy.js"; export interface AgentManagementMcpOptions { agentManager: AgentManager; agentStorage: AgentSnapshotStore; terminalManager?: TerminalManager | null; - scriptRouteStore?: ScriptRouteStore; getDaemonTcpPort?: () => number | null; paseoHome?: string; logger: Logger; @@ -350,8 +348,6 @@ export async function createAgentManagementMcpServer( agentId: snapshot.id, item, }), - scriptRouteStore: options.scriptRouteStore, - daemonPort: options.getDaemonTcpPort?.() ?? null, logger: childLogger, }); } diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 9223d509e..ae83386b5 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -28,13 +28,11 @@ import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js"; import { expandUserPath, resolvePathFromBase } from "../path-utils.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js"; -import type { ScriptRouteStore } from "../script-proxy.js"; export interface AgentMcpServerOptions { agentManager: AgentManager; agentStorage: AgentSnapshotStore; terminalManager?: TerminalManager | null; - scriptRouteStore?: ScriptRouteStore; getDaemonTcpPort?: () => number | null; paseoHome?: string; /** @@ -519,8 +517,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom agentId: snapshot.id, item, }), - scriptRouteStore: options.scriptRouteStore, - daemonPort: options.getDaemonTcpPort?.() ?? null, logger: childLogger, }); } diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index deb87f3cc..615fa0bd7 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -125,6 +125,7 @@ import { } from "./script-proxy.js"; import { ScriptHealthMonitor } from "./script-health-monitor.js"; import { createScriptStatusEmitter } from "./script-status-projection.js"; +import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import { createVoiceMcpSocketBridgeManager, type VoiceMcpSocketBridgeManager, @@ -209,6 +210,7 @@ export interface PaseoDaemon { agentStorage: AgentSnapshotStore; terminalManager: TerminalManager; scriptRouteStore: ScriptRouteStore; + scriptRuntimeStore: WorkspaceScriptRuntimeStore; start(): Promise; stop(): Promise; getListenTarget(): ListenTarget | null; @@ -240,6 +242,7 @@ export async function createPaseoDaemon( let boundListenTarget: ListenTarget | null = null; const scriptRouteStore = new ScriptRouteStore(); + const scriptRuntimeStore = new WorkspaceScriptRuntimeStore(); let wsServer: VoiceAssistantWebSocketServer | null = null; const scriptHealthMonitor = new ScriptHealthMonitor({ routeStore: scriptRouteStore, @@ -249,20 +252,14 @@ export async function createPaseoDaemon( emit: (message) => session.emitServerMessage(message), })) ?? [], routeStore: scriptRouteStore, + runtimeStore: scriptRuntimeStore, daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), }), }); const handleBranchChange = createBranchChangeRouteHandler({ routeStore: scriptRouteStore, - emitScriptStatusUpdate: (workspaceId, scripts) => { - const message = { - type: "script_status_update" as const, - payload: { workspaceId, scripts }, - }; - const activeSessions = wsServer?.listActiveSessions() ?? []; - for (const session of activeSessions) { - session.emitServerMessage(message); - } + onRoutesChanged: (workspaceId) => { + scriptHealthMonitor.invalidateWorkspace(workspaceId); }, logger, }); @@ -492,7 +489,6 @@ export async function createPaseoDaemon( agentManager, agentStorage, terminalManager, - scriptRouteStore, getDaemonTcpPort: () => boundListenTarget?.type === "tcp" ? boundListenTarget.port : null, paseoHome: config.paseoHome, @@ -521,7 +517,6 @@ export async function createPaseoDaemon( agentManager, agentStorage, terminalManager, - scriptRouteStore, getDaemonTcpPort: () => boundListenTarget?.type === "tcp" ? boundListenTarget.port : null, paseoHome: config.paseoHome, @@ -644,7 +639,6 @@ export async function createPaseoDaemon( agentManager, agentStorage, terminalManager, - scriptRouteStore, getDaemonTcpPort: () => boundListenTarget?.type === "tcp" ? boundListenTarget.port : null, paseoHome: config.paseoHome, @@ -711,6 +705,7 @@ export async function createPaseoDaemon( scheduleService, checkoutDiffManager, scriptRouteStore, + scriptRuntimeStore, handleBranchChange, () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), (hostname) => scriptHealthMonitor.getHealthForHostname(hostname), @@ -841,6 +836,7 @@ export async function createPaseoDaemon( agentStorage, terminalManager, scriptRouteStore, + scriptRuntimeStore, start, stop, getListenTarget: () => boundListenTarget, diff --git a/packages/server/src/server/script-health-monitor.test.ts b/packages/server/src/server/script-health-monitor.test.ts index 1212b11c1..116366e63 100644 --- a/packages/server/src/server/script-health-monitor.test.ts +++ b/packages/server/src/server/script-health-monitor.test.ts @@ -1,4 +1,8 @@ +import { execSync } from "node:child_process"; +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import net from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { scheduler } from "node:timers/promises"; import { afterEach, describe, expect, it, vi } from "vitest"; import { findFreePort, ScriptRouteStore } from "./script-proxy.js"; @@ -6,12 +10,89 @@ import { ScriptHealthMonitor, type ScriptHealthEntry, } from "./script-health-monitor.js"; +import { spawnWorktreeScripts } from "./worktree-bootstrap.js"; +import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; type TcpServerHandle = { port: number; server: net.Server; }; +function createWorkspaceRepo(options?: { + branchName?: string; + paseoConfig?: Record; +}): { tempDir: string; repoDir: string; cleanup: () => void } { + const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-health-monitor-"))); + const repoDir = path.join(tempDir, "repo"); + execSync(`mkdir -p ${JSON.stringify(repoDir)}`); + execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" }); + execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" }); + writeFileSync(path.join(repoDir, "README.md"), "hello\n"); + if (options?.paseoConfig) { + writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2)); + } + execSync("git add .", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" }); + + return { + tempDir, + repoDir, + cleanup: () => { + rmSync(tempDir, { recursive: true, force: true }); + }, + }; +} + +function createStubTerminalManager( + createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record }>, +) { + return { + async getTerminals() { + return []; + }, + async createTerminal(options: { + cwd: string; + name?: string; + env?: Record; + }) { + createTerminalCalls.push(options); + return { + id: `term-${options.name ?? "terminal"}`, + name: options.name ?? "Terminal", + cwd: options.cwd, + send: () => {}, + subscribe: () => () => {}, + onExit: () => () => {}, + getState: () => ({ + rows: 1, + cols: 1, + grid: [[{ char: "$" }]], + scrollback: [], + cursor: { row: 0, col: 0 }, + }), + kill: () => {}, + onTitleChange: () => () => {}, + getSize: () => ({ rows: 1, cols: 1 }), + getTitle: () => undefined, + getExitInfo: () => null, + }; + }, + registerCwdEnv() {}, + getTerminal() { + return undefined; + }, + killTerminal() {}, + listDirectories() { + return []; + }, + killAll() {}, + subscribeTerminalsChanged() { + return () => {}; + }, + }; +} + async function startTcpServer(): Promise { const server = net.createServer((socket) => { socket.end(); @@ -68,13 +149,23 @@ describe("ScriptHealthMonitor", () => { servers.clear(); }); - it("marks a healthy port as running after successful TCP connect", async () => { + it("starts new service routes in pending and transitions to healthy after the grace period", async () => { vi.useFakeTimers(); const healthy = await startTcpServer(); servers.add(healthy.server); const routeStore = new ScriptRouteStore(); + const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); + const monitor = new ScriptHealthMonitor({ + routeStore, + onChange, + pollIntervalMs: 1_000, + probeTimeoutMs: 100, + graceMs: 5_000, + }); + + monitor.start(); routeStore.registerRoute({ hostname: "api.localhost", port: healthy.port, @@ -82,19 +173,16 @@ describe("ScriptHealthMonitor", () => { scriptName: "api", }); - const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); - const monitor = new ScriptHealthMonitor({ - routeStore, - onChange, - pollIntervalMs: 1_000, - probeTimeoutMs: 100, - graceMs: 0, - }); + expect(monitor.getHealthForHostname("api.localhost")).toBe("pending"); + + await advancePoll(4_000); + expect(monitor.getHealthForHostname("api.localhost")).toBe("pending"); + expect(onChange).not.toHaveBeenCalled(); - monitor.start(); await advancePoll(1_000); monitor.stop(); + expect(monitor.getHealthForHostname("api.localhost")).toBe("healthy"); expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith("workspace-a", [ { @@ -106,7 +194,7 @@ describe("ScriptHealthMonitor", () => { ]); }); - it("marks an unreachable port as stopped after consecutive failures", async () => { + it("transitions pending services to unhealthy after the grace period and required failures", async () => { vi.useFakeTimers(); const deadPort = await findFreePort(); @@ -124,17 +212,21 @@ describe("ScriptHealthMonitor", () => { onChange, pollIntervalMs: 1_000, probeTimeoutMs: 100, - graceMs: 0, + graceMs: 2_000, failuresBeforeStopped: 2, }); + expect(monitor.getHealthForHostname("api.localhost")).toBe("pending"); + monitor.start(); - await advancePoll(1_000); + await advancePoll(2_000); + expect(monitor.getHealthForHostname("api.localhost")).toBe("pending"); expect(onChange).not.toHaveBeenCalled(); await advancePoll(1_000); monitor.stop(); + expect(monitor.getHealthForHostname("api.localhost")).toBe("unhealthy"); expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith("workspace-a", [ { @@ -176,48 +268,7 @@ describe("ScriptHealthMonitor", () => { expect(onChange).toHaveBeenCalledTimes(1); }); - it("respects startup grace period — does not probe newly registered routes for 5 seconds", async () => { - vi.useFakeTimers(); - - const healthy = await startTcpServer(); - servers.add(healthy.server); - - const routeStore = new ScriptRouteStore(); - routeStore.registerRoute({ - hostname: "api.localhost", - port: healthy.port, - workspaceId: "workspace-a", - scriptName: "api", - }); - - const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); - const monitor = new ScriptHealthMonitor({ - routeStore, - onChange, - pollIntervalMs: 1_000, - probeTimeoutMs: 100, - graceMs: 5_000, - }); - - monitor.start(); - await advancePoll(4_000); - expect(onChange).not.toHaveBeenCalled(); - - await advancePoll(1_000); - monitor.stop(); - - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith("workspace-a", [ - { - scriptName: "api", - hostname: "api.localhost", - port: healthy.port, - health: "healthy", - }, - ]); - }); - - it("requires 2 consecutive failures before marking stopped (debounce)", async () => { + it("requires 2 consecutive failures before marking a previously healthy service unhealthy", async () => { vi.useFakeTimers(); const healthy = await startTcpServer(); @@ -249,11 +300,13 @@ describe("ScriptHealthMonitor", () => { servers.delete(healthy.server); await advancePoll(1_000); + expect(monitor.getHealthForHostname("api.localhost")).toBe("healthy"); expect(onChange).toHaveBeenCalledTimes(1); await advancePoll(1_000); monitor.stop(); + expect(monitor.getHealthForHostname("api.localhost")).toBe("unhealthy"); expect(onChange).toHaveBeenCalledTimes(2); expect(onChange).toHaveBeenLastCalledWith("workspace-a", [ { @@ -265,7 +318,7 @@ describe("ScriptHealthMonitor", () => { ]); }); - it("stops probing routes that are removed from the store", async () => { + it("stops polling removed service routes and clears their health state", async () => { vi.useFakeTimers(); const healthy = await startTcpServer(); @@ -291,6 +344,7 @@ describe("ScriptHealthMonitor", () => { monitor.start(); await advancePoll(1_000); + expect(monitor.getHealthForHostname("api.localhost")).toBe("healthy"); expect(onChange).toHaveBeenCalledTimes(1); routeStore.removeRoute("api.localhost"); @@ -300,10 +354,11 @@ describe("ScriptHealthMonitor", () => { await advancePoll(3_000); monitor.stop(); + expect(monitor.getHealthForHostname("api.localhost")).toBeNull(); expect(onChange).toHaveBeenCalledTimes(1); }); - it("calls onChange with workspaceId and full service list when status transitions", async () => { + it("calls onChange with the full service list when multiple services change in one workspace", async () => { vi.useFakeTimers(); const api = await startTcpServer(); @@ -355,40 +410,75 @@ describe("ScriptHealthMonitor", () => { ]); }); - it("getHealthForHostname returns current health after probe", async () => { + it("only probes service routes because plain scripts never register routes", async () => { vi.useFakeTimers(); - const healthy = await startTcpServer(); - servers.add(healthy.server); + const service = await startTcpServer(); + servers.add(service.server); + const workspace = createWorkspaceRepo({ + paseoConfig: { + scripts: { + typecheck: { command: "npm run typecheck" }, + api: { type: "service", command: "npm run api", port: service.port }, + }, + }, + }); const routeStore = new ScriptRouteStore(); - routeStore.registerRoute({ - hostname: "api.localhost", - port: healthy.port, - workspaceId: "workspace-a", - scriptName: "api", - }); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record }> = + []; - const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); - const monitor = new ScriptHealthMonitor({ - routeStore, - onChange, - pollIntervalMs: 1_000, - probeTimeoutMs: 100, - graceMs: 0, - }); + try { + await spawnWorktreeScripts({ + repoRoot: workspace.repoDir, + workspaceId: workspace.repoDir, + branchName: null, + daemonPort: null, + routeStore, + runtimeStore, + terminalManager: createStubTerminalManager(createTerminalCalls) as any, + }); - expect(monitor.getHealthForHostname("api.localhost")).toBeNull(); + expect(createTerminalCalls).toHaveLength(2); + expect(routeStore.listRoutes()).toEqual([ + { + hostname: "api.localhost", + port: service.port, + workspaceId: workspace.repoDir, + scriptName: "api", + }, + ]); - monitor.start(); - await advancePoll(1_000); - monitor.stop(); + const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); + const monitor = new ScriptHealthMonitor({ + routeStore, + onChange, + pollIntervalMs: 1_000, + probeTimeoutMs: 100, + graceMs: 0, + }); - expect(monitor.getHealthForHostname("api.localhost")).toBe("healthy"); - expect(monitor.getHealthForHostname("unknown.localhost")).toBeNull(); + monitor.start(); + await advancePoll(1_000); + monitor.stop(); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(workspace.repoDir, [ + { + scriptName: "api", + hostname: "api.localhost", + port: service.port, + health: "healthy", + }, + ]); + expect(monitor.getHealthForHostname("typecheck")).toBeNull(); + } finally { + workspace.cleanup(); + } }); - it("coalesces multiple service changes in same workspace into one onChange call per poll cycle", async () => { + it("coalesces multiple service changes in the same workspace into one onChange call per poll cycle", async () => { vi.useFakeTimers(); const api = await startTcpServer(); diff --git a/packages/server/src/server/script-health-monitor.ts b/packages/server/src/server/script-health-monitor.ts index e18ddfb2f..9370169f1 100644 --- a/packages/server/src/server/script-health-monitor.ts +++ b/packages/server/src/server/script-health-monitor.ts @@ -1,15 +1,18 @@ import net from "node:net"; import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js"; +export type ScriptHealthState = "pending" | "healthy" | "unhealthy"; + export interface ScriptHealthEntry { scriptName: string; hostname: string; port: number; - health: "healthy" | "unhealthy"; + health: ScriptHealthState; } type RouteHealthState = { - health: ScriptHealthEntry["health"] | null; + workspaceId: string; + health: ScriptHealthState; consecutiveFailures: number; registeredAt: number; }; @@ -60,7 +63,7 @@ export class ScriptHealthMonitor { const now = Date.now(); for (const route of this.routeStore.listRoutes()) { - this.getOrCreateState(route.hostname, now); + this.getOrCreateState(route, now); } this.intervalHandle = setInterval(() => { @@ -75,6 +78,17 @@ export class ScriptHealthMonitor { } } + invalidateWorkspace(workspaceId: string): void { + const scripts = this.buildWorkspaceScriptList(workspaceId); + const snapshot = JSON.stringify(scripts); + if (snapshot === this.lastEmittedSnapshots.get(workspaceId)) { + return; + } + + this.lastEmittedSnapshots.set(workspaceId, snapshot); + this.onChange(workspaceId, scripts); + } + private async poll(): Promise { if (this.pollInFlight) { return; @@ -88,7 +102,7 @@ export class ScriptHealthMonitor { const now = Date.now(); for (const route of routes) { - const state = this.getOrCreateState(route.hostname, now); + const state = this.getOrCreateState(route, now); if (now - state.registeredAt < this.graceMs) { continue; } @@ -106,7 +120,7 @@ export class ScriptHealthMonitor { } } - if (state.health !== null && state.health !== previousHealth) { + if (state.health !== previousHealth) { changedWorkspaceIds.add(route.workspaceId); } } @@ -128,27 +142,32 @@ export class ScriptHealthMonitor { } } - private getOrCreateState(hostname: string, registeredAt: number): RouteHealthState { - const existing = this.routeStates.get(hostname); + private getOrCreateState( + route: Pick, + registeredAt: number, + ): RouteHealthState { + const existing = this.routeStates.get(route.hostname); if (existing) { return existing; } const state: RouteHealthState = { - health: null, + workspaceId: route.workspaceId, + health: "pending", consecutiveFailures: 0, registeredAt, }; - this.routeStates.set(hostname, state); + this.routeStates.set(route.hostname, state); return state; } private pruneRemovedRoutes(activeHostnames: Set): void { - for (const hostname of this.routeStates.keys()) { + for (const [hostname, state] of this.routeStates.entries()) { if (activeHostnames.has(hostname)) { continue; } this.routeStates.delete(hostname); + this.lastEmittedSnapshots.delete(state.workspaceId); } } @@ -157,15 +176,25 @@ export class ScriptHealthMonitor { .listRoutesForWorkspace(workspaceId) .flatMap((route) => { const state = this.routeStates.get(route.hostname); - if (!state?.health) { + if (!state) { return []; } return [this.toScriptHealthEntry(route, state.health)]; }); } - getHealthForHostname(hostname: string): ScriptHealthEntry["health"] | null { - return this.routeStates.get(hostname)?.health ?? null; + getHealthForHostname(hostname: string): ScriptHealthState | null { + const state = this.routeStates.get(hostname); + if (state) { + return state.health; + } + + const route = this.routeStore.getRouteEntry(hostname); + if (!route) { + return null; + } + + return this.getOrCreateState(route, Date.now()).health; } private toScriptHealthEntry( diff --git a/packages/server/src/server/script-route-branch-handler.test.ts b/packages/server/src/server/script-route-branch-handler.test.ts index ca7e9880e..b03281fb9 100644 --- a/packages/server/src/server/script-route-branch-handler.test.ts +++ b/packages/server/src/server/script-route-branch-handler.test.ts @@ -1,7 +1,37 @@ +import { execSync } from "node:child_process"; +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { ScriptRouteStore } from "./script-proxy.js"; import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js"; +function createWorkspaceRepo(options?: { + branchName?: string; + paseoConfig?: Record; +}): { tempDir: string; repoDir: string; cleanup: () => void } { + const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-branch-handler-"))); + const repoDir = path.join(tempDir, "repo"); + execSync(`mkdir -p ${JSON.stringify(repoDir)}`); + execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" }); + execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" }); + writeFileSync(path.join(repoDir, "README.md"), "hello\n"); + if (options?.paseoConfig) { + writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2)); + } + execSync("git add .", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" }); + + return { + tempDir, + repoDir, + cleanup: () => { + rmSync(tempDir, { recursive: true, force: true }); + }, + }; +} + function registerRoute( routeStore: ScriptRouteStore, { @@ -33,10 +63,10 @@ describe("script-route-branch-handler", () => { scriptName: "api", }); - const emitScriptStatusUpdate = vi.fn(); + const onRoutesChanged = vi.fn(); const handleBranchChange = createBranchChangeRouteHandler({ routeStore, - emitScriptStatusUpdate, + onRoutesChanged, }); handleBranchChange("workspace-a", "feature/auth", "feature/billing"); @@ -50,16 +80,16 @@ describe("script-route-branch-handler", () => { it("is a no-op when the workspace has no routes", () => { const routeStore = new ScriptRouteStore(); - const emitScriptStatusUpdate = vi.fn(); + const onRoutesChanged = vi.fn(); const handleBranchChange = createBranchChangeRouteHandler({ routeStore, - emitScriptStatusUpdate, + onRoutesChanged, }); handleBranchChange("workspace-a", "feature/auth", "feature/billing"); expect(routeStore.listRoutes()).toEqual([]); - expect(emitScriptStatusUpdate).not.toHaveBeenCalled(); + expect(onRoutesChanged).not.toHaveBeenCalled(); }); it("is a no-op when the resolved hostnames do not change", () => { @@ -70,10 +100,10 @@ describe("script-route-branch-handler", () => { scriptName: "api", }); - const emitScriptStatusUpdate = vi.fn(); + const onRoutesChanged = vi.fn(); const handleBranchChange = createBranchChangeRouteHandler({ routeStore, - emitScriptStatusUpdate, + onRoutesChanged, }); handleBranchChange("workspace-a", "main", "master"); @@ -86,10 +116,10 @@ describe("script-route-branch-handler", () => { scriptName: "api", }, ]); - expect(emitScriptStatusUpdate).not.toHaveBeenCalled(); + expect(onRoutesChanged).not.toHaveBeenCalled(); }); - it("emits a status update with the refreshed route payload after a route change", () => { + it("triggers shared reprojection after a route change", () => { const routeStore = new ScriptRouteStore(); registerRoute(routeStore, { hostname: "feature-auth.api.localhost", @@ -97,24 +127,15 @@ describe("script-route-branch-handler", () => { scriptName: "api", }); - const emitScriptStatusUpdate = vi.fn(); + const onRoutesChanged = vi.fn(); const handleBranchChange = createBranchChangeRouteHandler({ routeStore, - emitScriptStatusUpdate, + onRoutesChanged, }); handleBranchChange("workspace-a", "feature/auth", "feature/billing"); - expect(emitScriptStatusUpdate).toHaveBeenCalledWith("workspace-a", [ - { - scriptName: "api", - hostname: "feature-billing.api.localhost", - port: 3001, - url: null, - lifecycle: "running", - health: null, - }, - ]); + expect(onRoutesChanged).toHaveBeenCalledWith("workspace-a"); }); it("updates all services for a workspace when multiple routes are registered", () => { @@ -136,10 +157,10 @@ describe("script-route-branch-handler", () => { scriptName: "docs", }); - const emitScriptStatusUpdate = vi.fn(); + const onRoutesChanged = vi.fn(); const handleBranchChange = createBranchChangeRouteHandler({ routeStore, - emitScriptStatusUpdate, + onRoutesChanged, }); handleBranchChange("workspace-a", "feature/auth", "feature/billing"); @@ -176,14 +197,55 @@ describe("script-route-branch-handler", () => { scriptName: "web", }); - const emitScriptStatusUpdate = vi.fn(); + const onRoutesChanged = vi.fn(); const handleBranchChange = createBranchChangeRouteHandler({ routeStore, - emitScriptStatusUpdate, + onRoutesChanged, }); handleBranchChange("workspace-a", null, "main"); - expect(emitScriptStatusUpdate).not.toHaveBeenCalled(); + expect(onRoutesChanged).not.toHaveBeenCalled(); + }); + + it("renames only service routes and leaves plain scripts unaffected", () => { + const workspace = createWorkspaceRepo({ + branchName: "feature/auth", + paseoConfig: { + scripts: { + api: { type: "service", command: "npm run api" }, + typecheck: { command: "npm run typecheck" }, + }, + }, + }); + const routeStore = new ScriptRouteStore(); + registerRoute(routeStore, { + hostname: "feature-auth.api.localhost", + port: 3001, + workspaceId: workspace.repoDir, + scriptName: "api", + }); + + const onRoutesChanged = vi.fn(); + const handleBranchChange = createBranchChangeRouteHandler({ + routeStore, + onRoutesChanged, + }); + + try { + handleBranchChange(workspace.repoDir, "feature/auth", "feature/billing"); + + expect(routeStore.listRoutesForWorkspace(workspace.repoDir)).toEqual([ + { + hostname: "feature-billing.api.localhost", + port: 3001, + workspaceId: workspace.repoDir, + scriptName: "api", + }, + ]); + expect(onRoutesChanged).toHaveBeenCalledWith(workspace.repoDir); + } finally { + workspace.cleanup(); + } }); }); diff --git a/packages/server/src/server/script-route-branch-handler.ts b/packages/server/src/server/script-route-branch-handler.ts index ddc4dc7aa..5d20a553d 100644 --- a/packages/server/src/server/script-route-branch-handler.ts +++ b/packages/server/src/server/script-route-branch-handler.ts @@ -1,15 +1,10 @@ import type { Logger } from "pino"; -import type { WorkspaceScriptPayload } from "../shared/messages.js"; import { buildScriptHostname } from "../utils/script-hostname.js"; -import { buildWorkspaceScriptPayloads } from "./script-status-projection.js"; import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js"; interface BranchChangeRouteHandlerOptions { routeStore: ScriptRouteStore; - emitScriptStatusUpdate: ( - workspaceId: string, - scripts: WorkspaceScriptPayload[], - ) => void; + onRoutesChanged: (workspaceId: string) => void; logger?: Logger; } @@ -23,6 +18,7 @@ export function createBranchChangeRouteHandler( options: BranchChangeRouteHandlerOptions, ): (workspaceId: string, oldBranch: string | null, newBranch: string | null) => void { return (workspaceId, _oldBranch, newBranch) => { + // Only service scripts register routes, so branch renames only touch services. const routes = options.routeStore.listRoutesForWorkspace(workspaceId); if (routes.length === 0) { return; @@ -62,9 +58,6 @@ export function createBranchChangeRouteHandler( ); } - options.emitScriptStatusUpdate( - workspaceId, - buildWorkspaceScriptPayloads(options.routeStore, workspaceId, null), - ); + options.onRoutesChanged(workspaceId); }; } diff --git a/packages/server/src/server/script-status-projection.test.ts b/packages/server/src/server/script-status-projection.test.ts index bb7933beb..f11eb3190 100644 --- a/packages/server/src/server/script-status-projection.test.ts +++ b/packages/server/src/server/script-status-projection.test.ts @@ -8,6 +8,8 @@ import { buildWorkspaceScriptPayloads, createScriptStatusEmitter, } from "./script-status-projection.js"; +import type { ScriptHealthState } from "./script-health-monitor.js"; +import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; function createWorkspaceRepo(options?: { branchName?: string; @@ -35,35 +37,65 @@ function createWorkspaceRepo(options?: { }; } +function buildPayloads(input: { + workspaceDirectory: string; + routeStore: ScriptRouteStore; + runtimeStore: WorkspaceScriptRuntimeStore; + daemonPort: number | null; + resolveHealth?: (hostname: string) => ScriptHealthState | null; +}) { + return buildWorkspaceScriptPayloads(input); +} + describe("script-status-projection", () => { - it("shows configured scripts even before they have routes", () => { + it("projects plain scripts and services differently", () => { const workspace = createWorkspaceRepo({ paseoConfig: { scripts: { - api: { command: "npm run api" }, - web: { command: "npm run web", port: 3000 }, + typecheck: { command: "npm run typecheck" }, + web: { type: "service", command: "npm run web", port: 3000 }, }, }, }); const routeStore = new ScriptRouteStore(); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + runtimeStore.set({ + workspaceId: workspace.repoDir, + scriptName: "typecheck", + type: "script", + lifecycle: "stopped", + terminalId: "term-script", + exitCode: 0, + }); try { - expect(buildWorkspaceScriptPayloads(routeStore, workspace.repoDir, 6767)).toEqual([ + expect( + buildPayloads({ + workspaceDirectory: workspace.repoDir, + routeStore, + runtimeStore, + daemonPort: 6767, + }), + ).toEqual([ { - scriptName: "api", - hostname: "api.localhost", + scriptName: "typecheck", + type: "script", + hostname: "typecheck", port: null, - url: "http://api.localhost:6767", + url: null, lifecycle: "stopped", health: null, + exitCode: 0, }, { scriptName: "web", + type: "service", hostname: "web.localhost", port: 3000, url: "http://web.localhost:6767", lifecycle: "stopped", health: null, + exitCode: null, }, ]); } finally { @@ -71,12 +103,12 @@ describe("script-status-projection", () => { } }); - it("uses the active route port and branch-aware hostname for running scripts", () => { + it("overlays runtime, route, and health state for running services", () => { const workspace = createWorkspaceRepo({ branchName: "feature/card", paseoConfig: { scripts: { - web: { command: "npm run web" }, + web: { type: "service", command: "npm run web" }, }, }, }); @@ -87,16 +119,35 @@ describe("script-status-projection", () => { workspaceId: workspace.repoDir, scriptName: "web", }); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + runtimeStore.set({ + workspaceId: workspace.repoDir, + scriptName: "web", + type: "service", + lifecycle: "running", + terminalId: "term-web", + exitCode: null, + }); try { - expect(buildWorkspaceScriptPayloads(routeStore, workspace.repoDir, 6767)).toEqual([ + expect( + buildPayloads({ + workspaceDirectory: workspace.repoDir, + routeStore, + runtimeStore, + daemonPort: 6767, + resolveHealth: () => "healthy", + }), + ).toEqual([ { scriptName: "web", + type: "service", hostname: "feature-card.web.localhost", port: 4321, url: "http://feature-card.web.localhost:6767", lifecycle: "running", - health: null, + health: "healthy", + exitCode: null, }, ]); } finally { @@ -104,7 +155,58 @@ describe("script-status-projection", () => { } }); - it("includes orphaned active routes even if the current config no longer declares them", () => { + it("maps internal pending health to null on the wire", () => { + const workspace = createWorkspaceRepo({ + paseoConfig: { + scripts: { + web: { type: "service", command: "npm run web" }, + }, + }, + }); + const routeStore = new ScriptRouteStore(); + routeStore.registerRoute({ + hostname: "web.localhost", + port: 4321, + workspaceId: workspace.repoDir, + scriptName: "web", + }); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + runtimeStore.set({ + workspaceId: workspace.repoDir, + scriptName: "web", + type: "service", + lifecycle: "running", + terminalId: "term-web", + exitCode: null, + }); + + try { + expect( + buildPayloads({ + workspaceDirectory: workspace.repoDir, + routeStore, + runtimeStore, + daemonPort: 6767, + resolveHealth: () => "pending", + }), + ).toEqual([ + { + scriptName: "web", + type: "service", + hostname: "web.localhost", + port: 4321, + url: "http://web.localhost:6767", + lifecycle: "running", + health: null, + exitCode: null, + }, + ]); + } finally { + workspace.cleanup(); + } + }); + + it("includes orphaned running runtime entries even after config removal", () => { const workspace = createWorkspaceRepo(); const routeStore = new ScriptRouteStore(); routeStore.registerRoute({ @@ -113,16 +215,34 @@ describe("script-status-projection", () => { workspaceId: workspace.repoDir, scriptName: "docs", }); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + runtimeStore.set({ + workspaceId: workspace.repoDir, + scriptName: "docs", + type: "service", + lifecycle: "running", + terminalId: "term-docs", + exitCode: null, + }); try { - expect(buildWorkspaceScriptPayloads(routeStore, workspace.repoDir, 6767)).toEqual([ + expect( + buildPayloads({ + workspaceDirectory: workspace.repoDir, + routeStore, + runtimeStore, + daemonPort: 6767, + }), + ).toEqual([ { scriptName: "docs", + type: "service", hostname: "docs.localhost", port: 3002, url: "http://docs.localhost:6767", lifecycle: "running", health: null, + exitCode: null, }, ]); } finally { @@ -130,12 +250,50 @@ describe("script-status-projection", () => { } }); - it("createScriptStatusEmitter overlays health onto the full workspace script list", () => { + it("projects orphaned plain scripts as scripts instead of services", () => { + const workspace = createWorkspaceRepo(); + const routeStore = new ScriptRouteStore(); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + runtimeStore.set({ + workspaceId: workspace.repoDir, + scriptName: "typecheck", + type: "script", + lifecycle: "running", + terminalId: "term-typecheck", + exitCode: null, + }); + + try { + expect( + buildPayloads({ + workspaceDirectory: workspace.repoDir, + routeStore, + runtimeStore, + daemonPort: 6767, + }), + ).toEqual([ + { + scriptName: "typecheck", + type: "script", + hostname: "typecheck", + port: null, + url: null, + lifecycle: "running", + health: null, + exitCode: null, + }, + ]); + } finally { + workspace.cleanup(); + } + }); + + it("createScriptStatusEmitter overlays health onto the projected workspace script list", () => { const workspace = createWorkspaceRepo({ paseoConfig: { scripts: { - api: { command: "npm run api" }, - web: { command: "npm run web" }, + api: { type: "service", command: "npm run api" }, + typecheck: { command: "npm run typecheck" }, }, }, }); @@ -146,11 +304,21 @@ describe("script-status-projection", () => { workspaceId: workspace.repoDir, scriptName: "api", }); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + runtimeStore.set({ + workspaceId: workspace.repoDir, + scriptName: "api", + type: "service", + lifecycle: "running", + terminalId: "term-api", + exitCode: null, + }); const session = { emit: vi.fn() }; const emitUpdate = createScriptStatusEmitter({ sessions: () => [session], routeStore, + runtimeStore, daemonPort: 6767, }); @@ -171,19 +339,23 @@ describe("script-status-projection", () => { scripts: [ { scriptName: "api", + type: "service", hostname: "api.localhost", port: 3001, url: "http://api.localhost:6767", lifecycle: "running", health: "healthy", + exitCode: null, }, { - scriptName: "web", - hostname: "web.localhost", + scriptName: "typecheck", + type: "script", + hostname: "typecheck", port: null, - url: "http://web.localhost:6767", + url: null, lifecycle: "stopped", health: null, + exitCode: null, }, ], }, @@ -192,41 +364,4 @@ describe("script-status-projection", () => { workspace.cleanup(); } }); - - it("computes URLs with and without a daemon port", () => { - const workspace = createWorkspaceRepo({ - paseoConfig: { - scripts: { - api: { command: "npm run api" }, - }, - }, - }); - const routeStore = new ScriptRouteStore(); - - try { - expect(buildWorkspaceScriptPayloads(routeStore, workspace.repoDir, 6767)).toEqual([ - { - scriptName: "api", - hostname: "api.localhost", - port: null, - url: "http://api.localhost:6767", - lifecycle: "stopped", - health: null, - }, - ]); - - expect(buildWorkspaceScriptPayloads(routeStore, workspace.repoDir, null)).toEqual([ - { - scriptName: "api", - hostname: "api.localhost", - port: null, - url: null, - lifecycle: "stopped", - health: null, - }, - ]); - } finally { - workspace.cleanup(); - } - }); }); diff --git a/packages/server/src/server/script-status-projection.ts b/packages/server/src/server/script-status-projection.ts index afd0c78c6..ea9154816 100644 --- a/packages/server/src/server/script-status-projection.ts +++ b/packages/server/src/server/script-status-projection.ts @@ -4,15 +4,24 @@ import type { WorkspaceScriptPayload, } from "../shared/messages.js"; import { buildScriptHostname } from "../utils/script-hostname.js"; -import { getScriptConfigs } from "../utils/worktree.js"; +import { getScriptConfigs, isServiceScript } from "../utils/worktree.js"; import { readGitCommand } from "./workspace-git-metadata.js"; -import type { ScriptHealthEntry } from "./script-health-monitor.js"; -import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js"; +import type { ScriptHealthEntry, ScriptHealthState } from "./script-health-monitor.js"; +import type { ScriptRouteStore } from "./script-proxy.js"; +import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; type SessionEmitter = { emit(message: SessionOutboundMessage): void; }; +type BuildWorkspaceScriptPayloadsOptions = { + workspaceDirectory: string; + routeStore: ScriptRouteStore; + runtimeStore: WorkspaceScriptRuntimeStore; + daemonPort: number | null; + resolveHealth?: (hostname: string) => ScriptHealthState | null; +}; + function resolveDaemonPort(daemonPort: number | null | (() => number | null)): number | null { if (typeof daemonPort === "function") { return daemonPort(); @@ -20,6 +29,10 @@ function resolveDaemonPort(daemonPort: number | null | (() => number | null)): n return daemonPort; } +function resolveWorkspaceBranchName(workspaceDirectory: string): string | null { + return readGitCommand(workspaceDirectory, "git symbolic-ref --short HEAD"); +} + function toServiceUrl(hostname: string, daemonPort: number | null): string | null { if (daemonPort === null) { return null; @@ -27,41 +40,39 @@ function toServiceUrl(hostname: string, daemonPort: number | null): string | nul return `http://${hostname}:${daemonPort}`; } -type ConfiguredWorkspaceScript = { +function toWireHealth(health: ScriptHealthState | null): WorkspaceScriptPayload["health"] { + if (health === "pending" || health === null) { + return null; + } + return health; +} + +function createConfiguredPayload(input: { scriptName: string; - hostname: string; - port: number | null; -}; + type: WorkspaceScriptPayload["type"]; + branchName: string | null; + daemonPort: number | null; + configuredPort: number | null; +}): WorkspaceScriptPayload { + const hostname = + input.type === "service" + ? buildScriptHostname(input.branchName, input.scriptName) + : input.scriptName; -function resolveWorkspaceBranchName(workspaceDirectory: string): string | null { - return readGitCommand(workspaceDirectory, "git symbolic-ref --short HEAD"); + return { + scriptName: input.scriptName, + type: input.type, + hostname, + port: input.type === "service" ? input.configuredPort : null, + url: input.type === "service" ? toServiceUrl(hostname, input.daemonPort) : null, + lifecycle: "stopped", + health: null, + exitCode: null, + }; } -function listConfiguredWorkspaceScripts(workspaceDirectory: string): ConfiguredWorkspaceScript[] { - const branchName = resolveWorkspaceBranchName(workspaceDirectory); - const scriptConfigs = getScriptConfigs(workspaceDirectory); - return Array.from(scriptConfigs.entries()).map(([scriptName, config]) => ({ - scriptName, - hostname: buildScriptHostname(branchName, scriptName), - port: config.port ?? null, - })); -} - -function mergeWorkspaceScriptDefinitions( - workspaceDirectory: string, - routeStore: ScriptRouteStore, -): Array { - const merged = new Map(); - - for (const script of listConfiguredWorkspaceScripts(workspaceDirectory)) { - merged.set(script.hostname, script); - } - - for (const route of routeStore.listRoutesForWorkspace(workspaceDirectory)) { - merged.set(route.hostname, route); - } - - return Array.from(merged.values()).sort((left, right) => +function sortPayloads(payloads: WorkspaceScriptPayload[]): WorkspaceScriptPayload[] { + return payloads.sort((left, right) => left.scriptName.localeCompare(right.scriptName, undefined, { numeric: true, sensitivity: "base", @@ -70,22 +81,82 @@ function mergeWorkspaceScriptDefinitions( } export function buildWorkspaceScriptPayloads( - routeStore: ScriptRouteStore, - workspaceDirectory: string, - daemonPort: number | null, - resolveHealth?: (hostname: string) => "healthy" | "unhealthy" | null, + options: BuildWorkspaceScriptPayloadsOptions, ): WorkspaceScriptPayload[] { - return mergeWorkspaceScriptDefinitions(workspaceDirectory, routeStore).map((script) => { - const route = routeStore.getRouteEntry(script.hostname); - return { - scriptName: script.scriptName, - hostname: script.hostname, - port: route?.port ?? script.port, - url: toServiceUrl(script.hostname, daemonPort), - lifecycle: route ? "running" : "stopped", - health: resolveHealth?.(script.hostname) ?? null, - }; - }); + const workspaceDirectory = options.workspaceDirectory; + const branchName = resolveWorkspaceBranchName(workspaceDirectory); + const scriptConfigs = getScriptConfigs(workspaceDirectory); + const runtimeEntries = new Map( + options.runtimeStore + .listForWorkspace(workspaceDirectory) + .map((entry) => [entry.scriptName, entry] as const), + ); + const routesByScriptName = new Map( + options.routeStore + .listRoutesForWorkspace(workspaceDirectory) + .map((entry) => [entry.scriptName, entry] as const), + ); + + const payloads: WorkspaceScriptPayload[] = []; + + for (const [scriptName, config] of scriptConfigs.entries()) { + const type = isServiceScript(config) ? "service" : "script"; + const runtimeEntry = runtimeEntries.get(scriptName) ?? null; + const routeEntry = routesByScriptName.get(scriptName) ?? null; + + const payload = createConfiguredPayload({ + scriptName, + type, + branchName, + daemonPort: options.daemonPort, + configuredPort: isServiceScript(config) ? config.port ?? null : null, + }); + + payloads.push({ + ...payload, + hostname: + type === "service" ? (routeEntry?.hostname ?? payload.hostname) : payload.scriptName, + port: type === "service" ? (routeEntry?.port ?? payload.port) : null, + url: + type === "service" + ? toServiceUrl(routeEntry?.hostname ?? payload.hostname, options.daemonPort) + : null, + lifecycle: runtimeEntry?.lifecycle ?? payload.lifecycle, + health: + type === "service" + ? toWireHealth(options.resolveHealth?.(routeEntry?.hostname ?? payload.hostname) ?? null) + : null, + exitCode: runtimeEntry?.exitCode ?? null, + }); + } + + for (const runtimeEntry of runtimeEntries.values()) { + if (scriptConfigs.has(runtimeEntry.scriptName) || runtimeEntry.lifecycle !== "running") { + continue; + } + + const routeEntry = routesByScriptName.get(runtimeEntry.scriptName) ?? null; + const type = runtimeEntry.type; + const hostname = + type === "service" + ? (routeEntry?.hostname ?? buildScriptHostname(branchName, runtimeEntry.scriptName)) + : runtimeEntry.scriptName; + payloads.push({ + scriptName: runtimeEntry.scriptName, + type, + hostname, + port: type === "service" ? (routeEntry?.port ?? null) : null, + url: type === "service" ? toServiceUrl(hostname, options.daemonPort) : null, + lifecycle: runtimeEntry.lifecycle, + health: + type === "service" && routeEntry + ? toWireHealth(options.resolveHealth?.(hostname) ?? null) + : null, + exitCode: runtimeEntry.exitCode, + }); + } + + return sortPayloads(payloads); } function buildScriptStatusUpdateMessage(params: { @@ -104,10 +175,12 @@ function buildScriptStatusUpdateMessage(params: { export function createScriptStatusEmitter({ sessions, routeStore, + runtimeStore, daemonPort, }: { sessions: () => SessionEmitter[]; routeStore: ScriptRouteStore; + runtimeStore: WorkspaceScriptRuntimeStore; daemonPort: number | null | (() => number | null); }): (workspaceId: string, scripts: ScriptHealthEntry[]) => void { return (workspaceId, scripts) => { @@ -116,12 +189,13 @@ export function createScriptStatusEmitter({ scripts.map((script) => [script.hostname, script.health] as const), ); - const projected = buildWorkspaceScriptPayloads( + const projected = buildWorkspaceScriptPayloads({ + workspaceDirectory: workspaceId, routeStore, - workspaceId, - resolvedDaemonPort, - (hostname) => scriptHealthByHostname.get(hostname) ?? null, - ); + runtimeStore, + daemonPort: resolvedDaemonPort, + resolveHealth: (hostname) => scriptHealthByHostname.get(hostname) ?? null, + }); const message = buildScriptStatusUpdateMessage({ workspaceId, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 48e8ec35a..fb62fcdc5 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -68,9 +68,11 @@ import { experimental_createMCPClient } from "ai"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js"; import { buildWorkspaceScriptPayloads } from "./script-status-projection.js"; +import type { ScriptHealthState } from "./script-health-monitor.js"; import { spawnWorkspaceScript } from "./worktree-bootstrap.js"; import { readGitCommand } from "./workspace-git-metadata.js"; import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js"; +import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; export type AgentMcpTransportFactory = () => Promise; import { buildProviderRegistry } from "./agent/provider-registry.js"; @@ -430,13 +432,14 @@ export type SessionOptions = { terminalManager: TerminalManager | null; providerSnapshotManager?: ProviderSnapshotManager; scriptRouteStore?: ScriptRouteStore; + scriptRuntimeStore?: WorkspaceScriptRuntimeStore; onBranchChanged?: ( workspaceId: string, oldBranch: string | null, newBranch: string | null, ) => void; getDaemonTcpPort?: () => number | null; - resolveScriptHealth?: (hostname: string) => "healthy" | "unhealthy" | null; + resolveScriptHealth?: (hostname: string) => ScriptHealthState | null; voice?: { voiceAgentMcpStdio?: VoiceMcpStdioConfig | null; turnDetection?: Resolvable; @@ -614,6 +617,7 @@ export class Session { private readonly providerSnapshotManager: ProviderSnapshotManager | null; private unsubscribeProviderSnapshotEvents: (() => void) | null = null; private readonly scriptRouteStore: ScriptRouteStore | null; + private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; private readonly onBranchChanged?: ( workspaceId: string, oldBranch: string | null, @@ -621,7 +625,7 @@ export class Session { ) => void; private readonly getDaemonTcpPort: (() => number | null) | null; private readonly resolveScriptHealth: - | ((hostname: string) => "healthy" | "unhealthy" | null) + | ((hostname: string) => ScriptHealthState | null) | null; private readonly subscribedTerminalDirectories = new Set(); private unsubscribeTerminalsChanged: (() => void) | null = null; @@ -681,6 +685,7 @@ export class Session { terminalManager, providerSnapshotManager, scriptRouteStore, + scriptRuntimeStore, onBranchChanged, getDaemonTcpPort, resolveScriptHealth, @@ -725,6 +730,7 @@ export class Session { this.terminalManager = terminalManager; this.providerSnapshotManager = providerSnapshotManager ?? null; this.scriptRouteStore = scriptRouteStore ?? null; + this.scriptRuntimeStore = scriptRuntimeStore ?? null; this.onBranchChanged = onBranchChanged; this.getDaemonTcpPort = getDaemonTcpPort ?? null; this.resolveScriptHealth = resolveScriptHealth ?? null; @@ -2930,8 +2936,6 @@ export class Session { agentId: snapshot.id, item, }), - scriptRouteStore: this.scriptRouteStore ?? undefined, - daemonPort: this.getDaemonTcpPort?.() ?? null, logger: this.sessionLogger, }); } @@ -5363,13 +5367,14 @@ export class Session { status: "done", activityAt: null, diffStat, - scripts: this.scriptRouteStore - ? buildWorkspaceScriptPayloads( - this.scriptRouteStore, - workspace.directory, - this.getDaemonTcpPort?.() ?? null, - this.resolveScriptHealth ?? undefined, - ) + scripts: this.scriptRouteStore && this.scriptRuntimeStore + ? buildWorkspaceScriptPayloads({ + workspaceDirectory: workspace.directory, + routeStore: this.scriptRouteStore, + runtimeStore: this.scriptRuntimeStore, + daemonPort: this.getDaemonTcpPort?.() ?? null, + resolveHealth: this.resolveScriptHealth ?? undefined, + }) : [], }; } @@ -5901,6 +5906,7 @@ export class Session { const nextArchivedAt = archivedAt ?? new Date().toISOString(); await this.workspaceRegistry.archive(workspaceId, nextArchivedAt); await this.removeWorkspaceGitWatchTarget(existingWorkspace.directory); + this.scriptRuntimeStore?.removeForWorkspace(existingWorkspace.directory); const siblingWorkspaces = (await this.workspaceRegistry.list()).filter( (workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt, @@ -6195,15 +6201,16 @@ export class Session { private buildWorkspaceScriptPayloadSnapshot( workspaceDirectory: string, ): WorkspaceDescriptorPayload["scripts"] { - if (!this.scriptRouteStore) { + if (!this.scriptRouteStore || !this.scriptRuntimeStore) { return []; } - return buildWorkspaceScriptPayloads( - this.scriptRouteStore, + return buildWorkspaceScriptPayloads({ workspaceDirectory, - this.getDaemonTcpPort?.() ?? null, - this.resolveScriptHealth ?? undefined, - ); + routeStore: this.scriptRouteStore, + runtimeStore: this.scriptRuntimeStore, + daemonPort: this.getDaemonTcpPort?.() ?? null, + resolveHealth: this.resolveScriptHealth ?? undefined, + }); } private emitWorkspaceScriptStatusUpdate(workspaceDirectory: string): void { @@ -6228,7 +6235,7 @@ export class Session { request: StartWorkspaceScriptRequest, ): Promise { try { - if (!this.terminalManager || !this.scriptRouteStore) { + if (!this.terminalManager || !this.scriptRouteStore || !this.scriptRuntimeStore) { throw new Error("Workspace scripts are not available on this daemon"); } @@ -6244,6 +6251,7 @@ export class Session { scriptName: request.scriptName, daemonPort: this.getDaemonTcpPort?.() ?? null, routeStore: this.scriptRouteStore, + runtimeStore: this.scriptRuntimeStore, terminalManager: this.terminalManager, logger: this.sessionLogger, onLifecycleChanged: () => { @@ -6384,8 +6392,6 @@ export class Session { sessionLogger: this.sessionLogger, terminalManager: this.terminalManager, archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), - scriptRouteStore: this.scriptRouteStore, - daemonPort: this.getDaemonTcpPort?.() ?? null, }, options, ); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 41dc1c74b..faa605078 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -36,7 +36,9 @@ import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import { buildProviderRegistry } from "./agent/provider-registry.js"; import { PushTokenStore } from "./push/token-store.js"; import { PushService } from "./push/push-service.js"; +import type { ScriptHealthState } from "./script-health-monitor.js"; import type { ScriptRouteStore } from "./script-proxy.js"; +import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js"; import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js"; import { @@ -249,9 +251,10 @@ export class VoiceAssistantWebSocketServer { private readonly speech: SpeechService | null; private readonly terminalManager: TerminalManager | null; private readonly scriptRouteStore: ScriptRouteStore | null; + private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; private readonly getDaemonTcpPort: (() => number | null) | null; private readonly resolveScriptHealth: - | ((hostname: string) => "healthy" | "unhealthy" | null) + | ((hostname: string) => ScriptHealthState | null) | null; private readonly dictation: { finalTimeoutMs?: number; @@ -324,13 +327,14 @@ export class VoiceAssistantWebSocketServer { scheduleService?: ScheduleService, checkoutDiffManager?: CheckoutDiffManager, scriptRouteStore?: ScriptRouteStore | null, + scriptRuntimeStore?: WorkspaceScriptRuntimeStore | null, onBranchChanged?: ( workspaceId: string, oldBranch: string | null, newBranch: string | null, ) => void, getDaemonTcpPort?: () => number | null, - resolveScriptHealth?: (hostname: string) => "healthy" | "unhealthy" | null, + resolveScriptHealth?: (hostname: string) => ScriptHealthState | null, ) { this.logger = logger.child({ module: "websocket-server" }); this.serverId = serverId; @@ -378,6 +382,7 @@ export class VoiceAssistantWebSocketServer { ); this.onLifecycleIntent = onLifecycleIntent ?? null; this.scriptRouteStore = scriptRouteStore ?? null; + this.scriptRuntimeStore = scriptRuntimeStore ?? null; this.onBranchChanged = onBranchChanged ?? null; this.getDaemonTcpPort = getDaemonTcpPort ?? null; this.resolveScriptHealth = resolveScriptHealth ?? null; @@ -700,6 +705,7 @@ export class VoiceAssistantWebSocketServer { terminalManager: this.terminalManager, providerSnapshotManager: this.providerSnapshotManager, scriptRouteStore: this.scriptRouteStore ?? undefined, + scriptRuntimeStore: this.scriptRuntimeStore ?? undefined, onBranchChanged: this.onBranchChanged ?? undefined, getDaemonTcpPort: this.getDaemonTcpPort ?? undefined, resolveScriptHealth: this.resolveScriptHealth ?? undefined, diff --git a/packages/server/src/server/workspace-script-runtime-store.test.ts b/packages/server/src/server/workspace-script-runtime-store.test.ts new file mode 100644 index 000000000..41ce11797 --- /dev/null +++ b/packages/server/src/server/workspace-script-runtime-store.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { + WorkspaceScriptRuntimeStore, + type ScriptRuntimeEntry, +} from "./workspace-script-runtime-store.js"; + +function createEntry(overrides: Partial = {}): ScriptRuntimeEntry { + return { + workspaceId: 101, + scriptName: "web", + type: "service", + lifecycle: "running", + terminalId: "terminal-1", + exitCode: null, + ...overrides, + }; +} + +describe("WorkspaceScriptRuntimeStore", () => { + it("stores and returns entries by workspace and script name", () => { + const store = new WorkspaceScriptRuntimeStore(); + const entry = createEntry(); + + store.set(entry); + + expect(store.get({ workspaceId: 101, scriptName: "web" })).toEqual(entry); + expect(store.listForWorkspace(101)).toEqual([entry]); + }); + + it("preserves whether the runtime entry is a plain script or service", () => { + const store = new WorkspaceScriptRuntimeStore(); + const entry = createEntry({ + scriptName: "typecheck", + type: "script", + }); + + store.set(entry); + + expect(store.get({ workspaceId: 101, scriptName: "typecheck" })).toEqual(entry); + }); + + it("reports whether a script is currently running", () => { + const store = new WorkspaceScriptRuntimeStore(); + store.set(createEntry()); + store.set( + createEntry({ + workspaceId: 101, + scriptName: "typecheck", + lifecycle: "stopped", + terminalId: "terminal-2", + exitCode: 0, + }), + ); + + expect(store.isRunning({ workspaceId: 101, scriptName: "web" })).toBe(true); + expect(store.isRunning({ workspaceId: 101, scriptName: "typecheck" })).toBe(false); + expect(store.isRunning({ workspaceId: 101, scriptName: "missing" })).toBe(false); + }); + + it("removes individual entries", () => { + const store = new WorkspaceScriptRuntimeStore(); + store.set(createEntry()); + + store.remove({ workspaceId: 101, scriptName: "web" }); + + expect(store.get({ workspaceId: 101, scriptName: "web" })).toBeNull(); + expect(store.listForWorkspace(101)).toEqual([]); + }); + + it("removes all entries for a workspace without touching others", () => { + const store = new WorkspaceScriptRuntimeStore(); + store.set(createEntry()); + store.set( + createEntry({ + workspaceId: 101, + scriptName: "api", + terminalId: "terminal-2", + }), + ); + store.set( + createEntry({ + workspaceId: 202, + scriptName: "docs", + terminalId: "terminal-3", + }), + ); + + store.removeForWorkspace(101); + + expect(store.listForWorkspace(101)).toEqual([]); + expect(store.listForWorkspace(202)).toEqual([ + createEntry({ + workspaceId: 202, + scriptName: "docs", + terminalId: "terminal-3", + }), + ]); + }); +}); diff --git a/packages/server/src/server/workspace-script-runtime-store.ts b/packages/server/src/server/workspace-script-runtime-store.ts new file mode 100644 index 000000000..3e7218f08 --- /dev/null +++ b/packages/server/src/server/workspace-script-runtime-store.ts @@ -0,0 +1,105 @@ +export interface ScriptRuntimeEntry { + workspaceId: number | string; + scriptName: string; + type: "script" | "service"; + lifecycle: "running" | "stopped"; + terminalId: string; + exitCode: number | null; +} + +type RuntimeEntryKey = { + workspaceId: number | string; + scriptName: string; +}; + +export class WorkspaceScriptRuntimeStore { + private readonly entries = new Map(); + private readonly scriptsByWorkspace = new Map>(); + + get(key: RuntimeEntryKey): ScriptRuntimeEntry | null { + const entry = this.entries.get(this.toEntryKey(key)); + return entry ? { ...entry } : null; + } + + set(entry: ScriptRuntimeEntry): void { + const workspaceKey = this.toWorkspaceKey(entry.workspaceId); + const entryKey = this.toEntryKey(entry); + const previous = this.entries.get(entryKey); + if (previous) { + this.removeScriptFromWorkspaceIndex(previous.workspaceId, previous.scriptName); + } + + this.entries.set(entryKey, { ...entry }); + this.addScriptToWorkspaceIndex(workspaceKey, entry.scriptName); + } + + remove(key: RuntimeEntryKey): void { + const entryKey = this.toEntryKey(key); + const existing = this.entries.get(entryKey); + if (!existing) { + return; + } + + this.entries.delete(entryKey); + this.removeScriptFromWorkspaceIndex(existing.workspaceId, existing.scriptName); + } + + listForWorkspace(workspaceId: number | string): ScriptRuntimeEntry[] { + const scriptNames = this.scriptsByWorkspace.get(this.toWorkspaceKey(workspaceId)); + if (!scriptNames) { + return []; + } + + const entries: ScriptRuntimeEntry[] = []; + for (const scriptName of scriptNames) { + const entry = this.entries.get( + this.toEntryKey({ + workspaceId, + scriptName, + }), + ); + if (entry) { + entries.push({ ...entry }); + } + } + return entries; + } + + removeForWorkspace(workspaceId: number | string): void { + for (const entry of this.listForWorkspace(workspaceId)) { + this.entries.delete(this.toEntryKey(entry)); + } + this.scriptsByWorkspace.delete(this.toWorkspaceKey(workspaceId)); + } + + isRunning(key: RuntimeEntryKey): boolean { + return this.get(key)?.lifecycle === "running"; + } + + private addScriptToWorkspaceIndex(workspaceKey: string, scriptName: string): void { + const scripts = this.scriptsByWorkspace.get(workspaceKey) ?? new Set(); + scripts.add(scriptName); + this.scriptsByWorkspace.set(workspaceKey, scripts); + } + + private removeScriptFromWorkspaceIndex(workspaceId: number | string, scriptName: string): void { + const workspaceKey = this.toWorkspaceKey(workspaceId); + const scripts = this.scriptsByWorkspace.get(workspaceKey); + if (!scripts) { + return; + } + + scripts.delete(scriptName); + if (scripts.size === 0) { + this.scriptsByWorkspace.delete(workspaceKey); + } + } + + private toEntryKey(key: RuntimeEntryKey): string { + return `${this.toWorkspaceKey(key.workspaceId)}::${key.scriptName}`; + } + + private toWorkspaceKey(workspaceId: number | string): string { + return String(workspaceId); + } +} diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index b0ca17184..5f5592ae2 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -11,6 +11,7 @@ import { spawnWorktreeScripts, } from "./worktree-bootstrap.js"; import { ScriptRouteStore } from "./script-proxy.js"; +import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; describe("runAsyncWorktreeBootstrap", () => { let tempDir: string; @@ -507,7 +508,56 @@ describe("runAsyncWorktreeBootstrap", () => { expect(terminalToolCall?.status).toBe("completed"); }); - it("spawns scripts without PASEO_SCRIPT_URL when the daemon has no TCP port", async () => { + function createStubTerminalManager( + createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record }>, + ) { + return { + async getTerminals() { + return []; + }, + async createTerminal(options: { + cwd: string; + name?: string; + env?: Record; + }) { + createTerminalCalls.push(options); + return { + id: "term-service", + name: options.name ?? "Terminal", + cwd: options.cwd, + send: () => {}, + subscribe: () => () => {}, + onExit: () => () => {}, + getState: () => ({ + rows: 1, + cols: 1, + grid: [[{ char: "$" }]], + scrollback: [], + cursor: { row: 0, col: 0 }, + }), + kill: () => {}, + onTitleChange: () => () => {}, + getSize: () => ({ rows: 1, cols: 1 }), + getTitle: () => undefined, + getExitInfo: () => null, + }; + }, + registerCwdEnv() {}, + getTerminal() { + return undefined; + }, + killTerminal() {}, + listDirectories() { + return []; + }, + killAll() {}, + subscribeTerminalsChanged() { + return () => {}; + }, + }; + } + + it("spawns plain scripts without env injection or routes", async () => { writeFileSync( join(repoDir, "paseo.json"), JSON.stringify({ @@ -525,6 +575,7 @@ describe("runAsyncWorktreeBootstrap", () => { }); const routeStore = new ScriptRouteStore(); + const runtimeStore = new WorkspaceScriptRuntimeStore(); const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record }> = []; const results = await spawnWorktreeScripts({ @@ -533,42 +584,54 @@ describe("runAsyncWorktreeBootstrap", () => { branchName: "feature-socket-service", daemonPort: null, routeStore, - terminalManager: { - async getTerminals() { - return []; + runtimeStore, + terminalManager: createStubTerminalManager(createTerminalCalls) as any, + }); + + expect(results).toHaveLength(1); + expect(routeStore.listRoutes()).toEqual([]); + expect(createTerminalCalls).toHaveLength(1); + expect(createTerminalCalls[0]?.cwd).toBe(repoDir); + expect(createTerminalCalls[0]?.name).toBe("web"); + expect(createTerminalCalls[0]?.env).toBeUndefined(); + expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "web" })).toMatchObject({ + type: "script", + lifecycle: "running", + exitCode: null, + terminalId: "term-service", + }); + }); + + it("spawns services with route registration and injected service env vars", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + scripts: { + web: { + type: "service", + command: "npm run dev", + }, }, - async createTerminal(options) { - createTerminalCalls.push(options); - return { - id: "term-service", - name: options.name ?? "Terminal", - cwd: options.cwd, - send: () => {}, - subscribe: () => () => {}, - onExit: () => () => {}, - getState: () => ({ - rows: 1, - cols: 1, - grid: [[{ char: "$" }]], - scrollback: [], - cursor: { row: 0, col: 0 }, - }), - kill: () => {}, - }; - }, - registerCwdEnv() {}, - getTerminal() { - return undefined; - }, - killTerminal() {}, - listDirectories() { - return []; - }, - killAll() {}, - subscribeTerminalsChanged() { - return () => {}; - }, - }, + }), + ); + execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add service script config'", { + cwd: repoDir, + stdio: "pipe", + }); + + const routeStore = new ScriptRouteStore(); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record }> = []; + + const results = await spawnWorktreeScripts({ + repoRoot: repoDir, + workspaceId: repoDir, + branchName: "feature-socket-service", + daemonPort: 6767, + routeStore, + runtimeStore, + terminalManager: createStubTerminalManager(createTerminalCalls) as any, }); expect(results).toHaveLength(1); @@ -585,6 +648,13 @@ describe("runAsyncWorktreeBootstrap", () => { expect(createTerminalCalls[0]?.name).toBe("web"); expect(createTerminalCalls[0]?.env?.PORT).toEqual(expect.any(String)); expect(createTerminalCalls[0]?.env?.HOST).toBe("127.0.0.1"); - expect(createTerminalCalls[0]?.env?.PASEO_SCRIPT_URL).toBeUndefined(); + expect(createTerminalCalls[0]?.env?.PASEO_SCRIPT_URL).toBe( + "http://feature-socket-service.web.localhost:6767", + ); + expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "web" })).toMatchObject({ + type: "service", + lifecycle: "running", + exitCode: null, + }); }); }); diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 5bb8d7ff9..7a517d9db 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -10,6 +10,7 @@ import { createWorktree, getScriptConfigs, getWorktreeTerminalSpecs, + isServiceScript, listPaseoWorktrees, processCarriageReturns, resolveWorktreeRuntimeEnv, @@ -20,6 +21,7 @@ import { type WorktreeRuntimeEnv, } from "../utils/worktree.js"; import { findFreePort, type ScriptRouteStore } from "./script-proxy.js"; +import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js"; export interface WorktreeBootstrapTerminalResult { @@ -35,8 +37,6 @@ export interface RunAsyncWorktreeBootstrapOptions { worktree: WorktreeConfig; shouldBootstrap?: boolean; terminalManager: TerminalManager | null; - scriptRouteStore?: ScriptRouteStore; - daemonPort?: number | null; appendTimelineItem: (item: AgentTimelineItem) => Promise; emitLiveTimelineItem?: (item: AgentTimelineItem) => Promise; logger?: Logger; @@ -734,27 +734,6 @@ export async function runAsyncWorktreeBootstrap( } await runWorktreeTerminalBootstrap(options, runtimeEnv); - - if (!options.terminalManager || !options.scriptRouteStore) { - return; - } - - try { - await spawnWorktreeScripts({ - repoRoot: options.worktree.worktreePath, - workspaceId: options.worktree.worktreePath, - branchName: options.worktree.branchName, - daemonPort: options.daemonPort, - routeStore: options.scriptRouteStore, - terminalManager: options.terminalManager, - logger: options.logger, - }); - } catch (error) { - options.logger?.warn( - { err: error, agentId: options.agentId, worktreePath: options.worktree.worktreePath }, - "Failed to spawn worktree scripts", - ); - } } // --------------------------------------------------------------------------- @@ -763,8 +742,8 @@ export async function runAsyncWorktreeBootstrap( export interface WorktreeScriptResult { scriptName: string; - hostname: string; - port: number; + hostname: string | null; + port: number | null; terminalId: string; } @@ -775,6 +754,7 @@ type SpawnWorkspaceScriptOptions = { scriptName: string; daemonPort?: number | null; routeStore: ScriptRouteStore; + runtimeStore: WorkspaceScriptRuntimeStore; terminalManager: TerminalManager; logger?: Logger; onLifecycleChanged?: () => void; @@ -790,6 +770,7 @@ export async function spawnWorkspaceScript( scriptName, daemonPort, routeStore, + runtimeStore, terminalManager, logger, onLifecycleChanged, @@ -800,44 +781,74 @@ export async function spawnWorkspaceScript( throw new Error(`Script '${scriptName}' is not configured in paseo.json`); } - let hostname: string | null = null; + const serviceScript = isServiceScript(config); + const hostname = serviceScript ? buildScriptHostname(branchName, scriptName) : null; let port: number | null = null; + let terminal: TerminalSession | null = null; + let runtimeRegistered = false; try { - hostname = buildScriptHostname(branchName, scriptName); - const resolvedHostname = hostname; - if (routeStore.getRouteEntry(resolvedHostname)) { + if (runtimeStore.isRunning({ workspaceId, scriptName })) { throw new Error(`Script '${scriptName}' is already running`); } - port = config.port ?? (await findFreePort()); - - routeStore.registerRoute({ - hostname: resolvedHostname, - port, - workspaceId, - scriptName, - }); - - const env: Record = { - PORT: String(port), - HOST: "127.0.0.1", - }; - if (daemonPort !== null && daemonPort !== undefined) { - env.PASEO_SCRIPT_URL = `http://${resolvedHostname}:${daemonPort}`; + if (serviceScript) { + port = config.port ?? (await findFreePort()); + routeStore.registerRoute({ + hostname: hostname!, + port, + workspaceId, + scriptName, + }); } - const terminal = await terminalManager.createTerminal({ + const env = serviceScript + ? { + PORT: String(port), + HOST: "127.0.0.1", + ...(daemonPort !== null && daemonPort !== undefined + ? { + PASEO_SCRIPT_URL: `http://${hostname!}:${daemonPort}`, + } + : {}), + } + : undefined; + + terminal = await terminalManager.createTerminal({ cwd: repoRoot, name: scriptName, env, }); + runtimeStore.set({ + workspaceId, + scriptName, + type: serviceScript ? "service" : "script", + lifecycle: "running", + terminalId: terminal.id, + exitCode: null, + }); + runtimeRegistered = true; - terminal.onExit(() => { - routeStore.removeRoute(resolvedHostname); + terminal.onExit((info) => { + if (hostname) { + routeStore.removeRoute(hostname); + } + runtimeStore.set({ + workspaceId, + scriptName, + type: serviceScript ? "service" : "script", + lifecycle: "stopped", + terminalId: terminal!.id, + exitCode: info.exitCode, + }); onLifecycleChanged?.(); logger?.info( - { scriptName, hostname: resolvedHostname, terminalId: terminal.id }, + { + scriptName, + hostname, + exitCode: info.exitCode, + terminalId: terminal!.id, + }, "Stopped worktree script", ); }); @@ -846,14 +857,16 @@ export async function spawnWorkspaceScript( terminal.send({ type: "input", data: `${config.command}\r` }); logger?.info( - { scriptName, hostname: resolvedHostname, port, terminalId: terminal.id }, - `Registered script proxy: ${resolvedHostname} -> 127.0.0.1:${port}`, + { scriptName, hostname, port, terminalId: terminal.id, type: serviceScript ? "service" : "script" }, + serviceScript + ? `Registered script proxy: ${hostname} -> 127.0.0.1:${port}` + : "Started workspace script", ); onLifecycleChanged?.(); return { scriptName, - hostname: resolvedHostname, + hostname, port, terminalId: terminal.id, }; @@ -861,6 +874,9 @@ export async function spawnWorkspaceScript( if (hostname && port !== null) { routeStore.removeRoute(hostname); } + if (runtimeRegistered) { + runtimeStore.remove({ workspaceId, scriptName }); + } logger?.error( { err: error, @@ -883,6 +899,7 @@ export async function spawnWorktreeScripts(options: { branchName: string | null; daemonPort?: number | null; routeStore: ScriptRouteStore; + runtimeStore: WorkspaceScriptRuntimeStore; terminalManager: TerminalManager; logger?: Logger; onLifecycleChanged?: () => void; diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index 488930047..0f8cf7ba0 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -102,7 +102,7 @@ describe("createPaseoWorktreeInBackground", () => { } }); - test("emits running then completed snapshots for no-setup workspaces and then launches scripts", async () => { + test("emits running then completed snapshots for no-setup workspaces without auto-starting scripts", async () => { const { tempDir, repoDir } = createGitRepo({ paseoConfig: { scripts: { @@ -126,7 +126,6 @@ describe("createPaseoWorktreeInBackground", () => { const worktreePath = createdWorktree.worktreePath; const emitted: SessionOutboundMessage[] = []; const snapshots = new Map(); - const routeStore = new ScriptRouteStore(); const logger = createLogger(); const terminalManager = createTerminalManagerStub(); const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); @@ -141,8 +140,6 @@ describe("createPaseoWorktreeInBackground", () => { sessionLogger: logger, terminalManager: terminalManager.manager, archiveWorkspaceRecord, - scriptRouteStore: routeStore, - daemonPort: 6767, }, { requestCwd: repoDir, @@ -197,19 +194,7 @@ describe("createPaseoWorktreeInBackground", () => { }, }); - expect(routeStore.listRoutes()).toEqual([ - { - hostname: "feature-no-setup.web.localhost", - port: expect.any(Number), - workspaceId: worktreePath, - scriptName: "web", - }, - ]); - expect(terminalManager.terminals).toHaveLength(1); - expect(terminalManager.terminals[0]?.cwd).toBe(worktreePath); - expect(terminalManager.terminals[0]?.env?.PORT).toEqual(expect.any(String)); - expect(terminalManager.terminals[0]?.env?.PASEO_SCRIPT_URL).toBeDefined(); - expect(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]); + expect(terminalManager.terminals).toHaveLength(0); expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath); }); @@ -251,8 +236,6 @@ describe("createPaseoWorktreeInBackground", () => { sessionLogger: logger, terminalManager: null, archiveWorkspaceRecord, - scriptRouteStore: null, - daemonPort: null, }, { requestCwd: repoDir, @@ -319,8 +302,6 @@ describe("createPaseoWorktreeInBackground", () => { sessionLogger: logger, terminalManager: null, archiveWorkspaceRecord, - scriptRouteStore: null, - daemonPort: null, }, { requestCwd: repoDir, @@ -394,7 +375,7 @@ describe("createPaseoWorktreeInBackground", () => { }); }); - test("emits completed when reusing an existing worktree without bootstrapping", async () => { + test("emits completed when reusing an existing worktree without bootstrapping or auto-starting scripts", async () => { const { tempDir, repoDir } = createGitRepo({ paseoConfig: { worktree: { @@ -421,7 +402,6 @@ describe("createPaseoWorktreeInBackground", () => { const emitted: SessionOutboundMessage[] = []; const snapshots = new Map(); - const routeStore = new ScriptRouteStore(); const logger = createLogger(); const terminalManager = createTerminalManagerStub(); const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); @@ -436,8 +416,6 @@ describe("createPaseoWorktreeInBackground", () => { sessionLogger: logger, terminalManager: terminalManager.manager, archiveWorkspaceRecord, - scriptRouteStore: routeStore, - daemonPort: 6767, }, { requestCwd: repoDir, @@ -473,20 +451,7 @@ describe("createPaseoWorktreeInBackground", () => { commands: [], }, }); - expect(routeStore.listRoutes()).toEqual([ - { - hostname: "reused-worktree.web.localhost", - port: expect.any(Number), - workspaceId: existingWorktree.worktreePath, - scriptName: "web", - }, - ]); - expect(terminalManager.terminals).toHaveLength(1); - expect(terminalManager.terminals[0]?.cwd).toBe(existingWorktree.worktreePath); - expect(terminalManager.terminals[0]?.name).toBe("web"); - expect(terminalManager.terminals[0]?.env?.PORT).toEqual(expect.any(String)); - expect(terminalManager.terminals[0]?.env?.PASEO_SCRIPT_URL).toBeDefined(); - expect(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]); + expect(terminalManager.terminals).toHaveLength(0); expect( readFileSync(path.join(existingWorktree.worktreePath, "README.md"), "utf8"), ).toContain("hello"); @@ -499,7 +464,7 @@ describe("createPaseoWorktreeInBackground", () => { expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(existingWorktree.worktreePath); }); - test("keeps setup completed when service launch fails afterward", async () => { + test("keeps setup completed without attempting script launch afterward", async () => { const { tempDir, repoDir } = createGitRepo({ paseoConfig: { scripts: { @@ -523,7 +488,6 @@ describe("createPaseoWorktreeInBackground", () => { const worktreePath = createdWorktree.worktreePath; const emitted: SessionOutboundMessage[] = []; const snapshots = new Map(); - const routeStore = new ScriptRouteStore(); const logger = createLogger(); const terminalManager = createTerminalManagerStub({ createTerminal: async () => { @@ -542,8 +506,6 @@ describe("createPaseoWorktreeInBackground", () => { sessionLogger: logger, terminalManager: terminalManager.manager, archiveWorkspaceRecord, - scriptRouteStore: routeStore, - daemonPort: 6767, }, { requestCwd: repoDir, @@ -566,17 +528,17 @@ describe("createPaseoWorktreeInBackground", () => { expect(progressMessages[0]?.payload.error).toBeNull(); expect(progressMessages[1]?.payload.status).toBe("completed"); expect(progressMessages[1]?.payload.error).toBeNull(); - expect(emitted.some((message) => message.type === "workspace_setup_progress" && message.payload.status === "failed")).toBe(false); - expect(logger.error).toHaveBeenCalledWith( - expect.objectContaining({ - err: expect.any(Error), - cwd: repoDir, - repoRoot: repoDir, - worktreeSlug: "feature-service-failure", - worktreePath, - }), + expect( + emitted.some( + (message) => + message.type === "workspace_setup_progress" && message.payload.status === "failed", + ), + ).toBe(false); + expect(logger.error).not.toHaveBeenCalledWith( + expect.anything(), "Failed to spawn worktree scripts after workspace setup completed", ); + expect(terminalManager.terminals).toHaveLength(0); expect(snapshots.get("45")).toMatchObject({ status: "completed", error: null, @@ -585,7 +547,7 @@ describe("createPaseoWorktreeInBackground", () => { expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath); }); - test("launches scripts in socket mode without requiring a daemon TCP port", async () => { + test("does not auto-start scripts in socket mode", async () => { const { tempDir, repoDir } = createGitRepo({ paseoConfig: { scripts: { @@ -609,7 +571,6 @@ describe("createPaseoWorktreeInBackground", () => { const worktreePath = createdWorktree.worktreePath; const emitted: SessionOutboundMessage[] = []; const snapshots = new Map(); - const routeStore = new ScriptRouteStore(); const logger = createLogger(); const terminalManager = createTerminalManagerStub(); const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); @@ -624,8 +585,6 @@ describe("createPaseoWorktreeInBackground", () => { sessionLogger: logger, terminalManager: terminalManager.manager, archiveWorkspaceRecord, - scriptRouteStore: routeStore, - daemonPort: null, }, { requestCwd: repoDir, @@ -639,19 +598,7 @@ describe("createPaseoWorktreeInBackground", () => { }, ); - expect(routeStore.listRoutes()).toEqual([ - { - hostname: "feature-socket-mode.web.localhost", - port: expect.any(Number), - workspaceId: worktreePath, - scriptName: "web", - }, - ]); - expect(terminalManager.terminals).toHaveLength(1); - expect(terminalManager.terminals[0]?.cwd).toBe(worktreePath); - expect(terminalManager.terminals[0]?.env?.PORT).toEqual(expect.any(String)); - expect(terminalManager.terminals[0]?.env?.PASEO_SCRIPT_URL).toBeUndefined(); - expect(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]); + expect(terminalManager.terminals).toHaveLength(0); expect(snapshots.get("46")).toMatchObject({ status: "completed", error: null, diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index c1108a01d..6510f83e6 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -26,10 +26,8 @@ import { createAgentWorktree, createWorktreeSetupProgressAccumulator, getWorktreeSetupProgressResults, - spawnWorktreeScripts, } from "./worktree-bootstrap.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; -import type { ScriptRouteStore } from "./script-proxy.js"; import { getCheckoutStatusLite, resolveRepositoryDefaultBranch, @@ -110,8 +108,6 @@ type CreatePaseoWorktreeInBackgroundDependencies = { sessionLogger: Logger; terminalManager: TerminalManager | null; archiveWorkspaceRecord: (workspaceId: number) => Promise; - scriptRouteStore: ScriptRouteStore | null; - daemonPort?: number | null; }; type HandleWorkspaceSetupStatusRequestDependencies = { @@ -765,35 +761,6 @@ export async function createPaseoWorktreeInBackground( return; } - if (!dependencies.terminalManager || !dependencies.scriptRouteStore) { - return; - } - - try { - await spawnWorktreeScripts({ - repoRoot: worktree.worktreePath, - workspaceId: worktree.worktreePath, - branchName: worktree.branchName, - daemonPort: dependencies.daemonPort, - routeStore: dependencies.scriptRouteStore, - terminalManager: dependencies.terminalManager, - logger: dependencies.sessionLogger, - onLifecycleChanged: () => { - void dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath); - }, - }); - } catch (error) { - dependencies.sessionLogger.error( - { - err: error, - cwd: options.requestCwd, - repoRoot: options.repoRoot, - worktreeSlug: worktree.branchName, - worktreePath: worktree.worktreePath, - }, - "Failed to spawn worktree scripts after workspace setup completed", - ); - } } finally { await dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath); } diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 88edd33c7..1f6e51915 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1820,11 +1820,13 @@ export const WorkspaceScriptHealthSchema = z.enum(["healthy", "unhealthy"]); export const WorkspaceScriptPayloadSchema = z.object({ scriptName: z.string(), + type: z.enum(["script", "service"]).optional().default("service"), hostname: z.string(), port: z.number().int().positive().nullable(), url: z.string().nullable(), lifecycle: WorkspaceScriptLifecycleSchema, health: WorkspaceScriptHealthSchema.nullable(), + exitCode: z.number().nullable().optional().default(null), }); export const WorkspaceDescriptorPayloadSchema = z.object({ diff --git a/packages/server/src/shared/messages.workspaces.test.ts b/packages/server/src/shared/messages.workspaces.test.ts index a837d1139..e84f78c65 100644 --- a/packages/server/src/shared/messages.workspaces.test.ts +++ b/packages/server/src/shared/messages.workspaces.test.ts @@ -155,11 +155,13 @@ describe("workspace message schemas", () => { expect(parsed.payload.workspace.scripts).toEqual([ { scriptName: "web", + type: "service", hostname: "web.localhost", port: 3000, url: "http://web.localhost:6767", lifecycle: "running", health: "healthy", + exitCode: null, }, ]); }); @@ -213,6 +215,10 @@ describe("workspace message schemas", () => { expect(parsed.type).toBe("script_status_update"); expect(parsed.payload.workspaceId).toBe("/repo"); + expect(parsed.payload.scripts[0]).toMatchObject({ + type: "service", + exitCode: null, + }); }); test("parses workspace_setup_progress payload", () => { diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index e8ebb57dc..f54462c9d 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -3,7 +3,9 @@ import { createWorktree, deriveWorktreeProjectHash, deletePaseoWorktree, + getScriptConfigs, getWorktreeTerminalSpecs, + isServiceScript, isPaseoOwnedWorktreeCwd, listPaseoWorktrees, resolveWorktreeRuntimeEnv, @@ -483,6 +485,91 @@ describe("createWorktree", () => { { command: "npm run test" }, ]); }); + + it("parses omitted script type as a plain script", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + scripts: { + typecheck: { + command: " npm run typecheck ", + }, + }, + }), + ); + + const scriptConfigs = getScriptConfigs(repoDir); + const typecheck = scriptConfigs.get("typecheck"); + + expect(typecheck).toEqual({ + command: "npm run typecheck", + }); + expect(typecheck).toBeDefined(); + expect(isServiceScript(typecheck!)).toBe(false); + }); + + it("parses service scripts and preserves optional port", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + scripts: { + server: { + type: "service", + command: "npm run dev", + port: 4321, + }, + }, + }), + ); + + const scriptConfigs = getScriptConfigs(repoDir); + const server = scriptConfigs.get("server"); + + expect(server).toEqual({ + type: "service", + command: "npm run dev", + port: 4321, + }); + expect(server).toBeDefined(); + expect(isServiceScript(server!)).toBe(true); + }); + + it("ignores invalid script entries gracefully", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + scripts: { + valid: { + command: "npm run valid", + }, + invalidType: { + type: "worker", + command: "npm run worker", + }, + missingCommand: { + type: "service", + }, + blankCommand: { + command: " ", + }, + nonObject: "npm run nope", + invalidPort: { + type: "service", + command: "npm run dev", + port: "3000", + }, + }, + }), + ); + + expect(getScriptConfigs(repoDir)).toEqual( + new Map([ + ["valid", { command: "npm run valid" }], + ["invalidType", { command: "npm run worker" }], + ["invalidPort", { type: "service", command: "npm run dev" }], + ]), + ); + }); }); describe("paseo worktree manager", () => { diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 35296a81d..be9a5c9be 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -24,7 +24,14 @@ interface PaseoConfig { teardown?: string[]; terminals?: WorktreeTerminalConfig[]; }; - scripts?: Record; + scripts?: Record< + string, + { + type?: unknown; + command?: unknown; + port?: unknown; + } + >; } const execAsync = promisify(exec); @@ -90,11 +97,24 @@ export interface WorktreeTerminalConfig { command: string; } -export interface ScriptConfig { +export interface PlainScriptConfig { + type?: undefined; + command: string; + port?: undefined; +} + +export interface ServiceScriptConfig { + type: "service"; command: string; port?: number; // explicit port override, otherwise auto-assigned } +export type ScriptConfig = PlainScriptConfig | ServiceScriptConfig; + +export function isServiceScript(config: ScriptConfig): config is ServiceScriptConfig { + return "type" in config && config.type === "service"; +} + export class WorktreeSetupError extends Error { readonly results: WorktreeSetupCommandResult[]; @@ -227,9 +247,15 @@ export function getScriptConfigs(repoRoot: string): Map { continue; } - const scriptConfig: ScriptConfig = { command }; + const scriptConfig: ScriptConfig = + entry.type === "service" + ? { + type: "service", + command, + } + : { command }; - if (typeof entry.port === "number" && Number.isFinite(entry.port)) { + if (isServiceScript(scriptConfig) && typeof entry.port === "number" && Number.isFinite(entry.port)) { scriptConfig.port = entry.port; } diff --git a/paseo.json b/paseo.json index 408392e7e..239a49f52 100644 --- a/paseo.json +++ b/paseo.json @@ -8,6 +8,7 @@ }, "scripts": { "server": { + "type": "service", "command": "npm run dev" }, "typecheck": {