From 6b5282ad43a864284caedec5e6f77e50fcadc8bc Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 18 Apr 2026 16:03:18 +0700 Subject: [PATCH] refactor(server): extract workspace service env and port registry Pull service env construction and port allocation out of worktree-bootstrap into dedicated modules with their own tests, so the bootstrap path only orchestrates. Adds peer collision detection and a port plan that is computed once and reused across scripts. --- .../src/server/workspace-service-env.test.ts | 154 +++++++ .../src/server/workspace-service-env.ts | 112 +++++ .../workspace-service-port-registry.test.ts | 233 ++++++++++ .../server/workspace-service-port-registry.ts | 102 +++++ .../src/server/worktree-bootstrap.test.ts | 415 ++++++++++++++++-- .../server/src/server/worktree-bootstrap.ts | 110 +++-- 6 files changed, 1057 insertions(+), 69 deletions(-) create mode 100644 packages/server/src/server/workspace-service-env.test.ts create mode 100644 packages/server/src/server/workspace-service-env.ts create mode 100644 packages/server/src/server/workspace-service-port-registry.test.ts create mode 100644 packages/server/src/server/workspace-service-port-registry.ts diff --git a/packages/server/src/server/workspace-service-env.test.ts b/packages/server/src/server/workspace-service-env.test.ts new file mode 100644 index 000000000..199a3e62e --- /dev/null +++ b/packages/server/src/server/workspace-service-env.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; + +import { buildWorkspaceServiceEnv, normalizeServiceEnvName } from "./workspace-service-env.js"; + +describe("normalizeServiceEnvName", () => { + it("normalizes punctuation and spaces to stable env names", () => { + expect(normalizeServiceEnvName("app-server")).toBe("APP_SERVER"); + expect(normalizeServiceEnvName("app.server")).toBe("APP_SERVER"); + expect(normalizeServiceEnvName("app server")).toBe("APP_SERVER"); + }); + + it("trims leading and trailing separators", () => { + expect(normalizeServiceEnvName(" app server ")).toBe("APP_SERVER"); + }); +}); + +describe("buildWorkspaceServiceEnv", () => { + it("uses loopback host binding when daemon listen host is loopback or absent", () => { + expect( + buildWorkspaceServiceEnv({ + scriptName: "daemon", + branchName: "main", + daemonPort: 6767, + daemonListenHost: null, + peers: [{ scriptName: "daemon", port: 5173 }], + }).HOST, + ).toBe("127.0.0.1"); + + expect( + buildWorkspaceServiceEnv({ + scriptName: "daemon", + branchName: "main", + daemonPort: 6767, + daemonListenHost: "localhost", + peers: [{ scriptName: "daemon", port: 5173 }], + }).HOST, + ).toBe("127.0.0.1"); + }); + + it("uses network host binding when daemon listen host is non-loopback", () => { + expect( + buildWorkspaceServiceEnv({ + scriptName: "daemon", + branchName: "main", + daemonPort: 6767, + daemonListenHost: "100.64.0.20", + peers: [{ scriptName: "daemon", port: 5173 }], + }).HOST, + ).toBe("0.0.0.0"); + }); + + it("builds default branch self and service URLs", () => { + expect( + buildWorkspaceServiceEnv({ + scriptName: "daemon", + branchName: "main", + daemonPort: 6767, + daemonListenHost: null, + peers: [{ scriptName: "daemon", port: 5173 }], + }), + ).toEqual({ + HOST: "127.0.0.1", + PASEO_PORT: "5173", + PASEO_URL: "http://daemon.localhost:6767", + PASEO_SERVICE_DAEMON_PORT: "5173", + PASEO_SERVICE_DAEMON_URL: "http://daemon.localhost:6767", + }); + }); + + it("builds feature branch self and service URLs", () => { + expect( + buildWorkspaceServiceEnv({ + scriptName: "daemon", + branchName: "feature-x", + daemonPort: 6767, + daemonListenHost: null, + peers: [{ scriptName: "daemon", port: 5173 }], + }), + ).toEqual({ + HOST: "127.0.0.1", + PASEO_PORT: "5173", + PASEO_URL: "http://feature-x.daemon.localhost:6767", + PASEO_SERVICE_DAEMON_PORT: "5173", + PASEO_SERVICE_DAEMON_URL: "http://feature-x.daemon.localhost:6767", + }); + }); + + it("omits PORT while keeping PASEO_PORT", () => { + const env = buildWorkspaceServiceEnv({ + scriptName: "daemon", + branchName: "main", + daemonPort: 6767, + daemonListenHost: null, + peers: [{ scriptName: "daemon", port: 5173 }], + }); + + expect(env.PASEO_PORT).toBe("5173"); + expect(env).not.toHaveProperty("PORT"); + }); + + it("omits URL variables when daemon port is absent while keeping port aliases", () => { + expect( + buildWorkspaceServiceEnv({ + scriptName: "daemon", + branchName: "main", + daemonPort: null, + daemonListenHost: null, + peers: [{ scriptName: "daemon", port: 5173 }], + }), + ).toEqual({ + HOST: "127.0.0.1", + PASEO_PORT: "5173", + PASEO_SERVICE_DAEMON_PORT: "5173", + }); + }); + + it("adds peer service ports and URLs", () => { + expect( + buildWorkspaceServiceEnv({ + scriptName: "web", + branchName: "feature-x", + daemonPort: 6767, + daemonListenHost: null, + peers: [ + { scriptName: "api", port: 4000 }, + { scriptName: "web", port: 5173 }, + ], + }), + ).toEqual({ + HOST: "127.0.0.1", + PASEO_PORT: "5173", + PASEO_URL: "http://feature-x.web.localhost:6767", + PASEO_SERVICE_API_PORT: "4000", + PASEO_SERVICE_API_URL: "http://feature-x.api.localhost:6767", + PASEO_SERVICE_WEB_PORT: "5173", + PASEO_SERVICE_WEB_URL: "http://feature-x.web.localhost:6767", + }); + }); + + it("throws when normalized peer env names collide", () => { + expect(() => + buildWorkspaceServiceEnv({ + scriptName: "app-server", + branchName: "main", + daemonPort: 6767, + daemonListenHost: null, + peers: [ + { scriptName: "app-server", port: 5173 }, + { scriptName: "app.server", port: 4000 }, + ], + }), + ).toThrow("Service env name collision for APP_SERVER: app-server, app.server"); + }); +}); diff --git a/packages/server/src/server/workspace-service-env.ts b/packages/server/src/server/workspace-service-env.ts new file mode 100644 index 000000000..a81f6bcaf --- /dev/null +++ b/packages/server/src/server/workspace-service-env.ts @@ -0,0 +1,112 @@ +import { buildScriptHostname } from "../utils/script-hostname.js"; + +export interface WorkspaceServicePeer { + scriptName: string; + port: number; +} + +export interface BuildWorkspaceServiceEnvOptions { + scriptName: string; + branchName: string | null; + daemonPort: number | null | undefined; + daemonListenHost: string | null | undefined; + peers: readonly WorkspaceServicePeer[]; +} + +export function normalizeServiceEnvName(scriptName: string): string { + return scriptName + .toUpperCase() + .replace(/[^A-Z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); +} + +export function buildWorkspaceServiceEnv( + options: BuildWorkspaceServiceEnvOptions, +): Record { + const scriptNames = options.peers.map((peer) => peer.scriptName); + assertNoServiceEnvNameCollisions(scriptNames); + + const selfPeer = options.peers.find((peer) => peer.scriptName === options.scriptName); + if (!selfPeer) { + throw new Error(`Service '${options.scriptName}' is missing from workspace service peers`); + } + + const env: Record = { + HOST: resolveServiceBindHost(options.daemonListenHost), + PASEO_PORT: String(selfPeer.port), + }; + + if (options.daemonPort !== null && options.daemonPort !== undefined) { + env.PASEO_URL = buildServiceProxyUrl({ + branchName: options.branchName, + scriptName: options.scriptName, + daemonPort: options.daemonPort, + }); + } + + for (const peer of options.peers) { + const envName = normalizeServiceEnvName(peer.scriptName); + env[`PASEO_SERVICE_${envName}_PORT`] = String(peer.port); + + if (options.daemonPort !== null && options.daemonPort !== undefined) { + env[`PASEO_SERVICE_${envName}_URL`] = buildServiceProxyUrl({ + branchName: options.branchName, + scriptName: peer.scriptName, + daemonPort: options.daemonPort, + }); + } + } + + return env; +} + +export function resolveServiceBindHost(daemonListenHost: string | null | undefined): string { + return isLoopbackListenHost(daemonListenHost) ? "127.0.0.1" : "0.0.0.0"; +} + +interface BuildServiceProxyUrlOptions { + branchName: string | null; + scriptName: string; + daemonPort: number; +} + +function buildServiceProxyUrl(options: BuildServiceProxyUrlOptions): string { + const hostname = buildScriptHostname(options.branchName, options.scriptName); + return `http://${hostname}:${options.daemonPort}`; +} + +function isLoopbackListenHost(host: string | null | undefined): boolean { + if (!host) { + return true; + } + + const normalizedHost = host.trim().toLowerCase(); + return ( + normalizedHost === "localhost" || + normalizedHost === "127.0.0.1" || + normalizedHost === "::1" || + normalizedHost === "[::1]" + ); +} + +export function assertNoServiceEnvNameCollisions(scriptNames: readonly string[]): void { + const scriptNamesByEnvName = new Map(); + + for (const scriptName of scriptNames) { + const envName = normalizeServiceEnvName(scriptName); + const namesForEnvName = scriptNamesByEnvName.get(envName) ?? []; + namesForEnvName.push(scriptName); + scriptNamesByEnvName.set(envName, namesForEnvName); + } + + const collisions: string[] = []; + for (const [envName, scriptNames] of scriptNamesByEnvName) { + if (scriptNames.length > 1) { + collisions.push(`Service env name collision for ${envName}: ${scriptNames.join(", ")}`); + } + } + + if (collisions.length > 0) { + throw new Error(collisions.join("; ")); + } +} diff --git a/packages/server/src/server/workspace-service-port-registry.test.ts b/packages/server/src/server/workspace-service-port-registry.test.ts new file mode 100644 index 000000000..610fe8bbe --- /dev/null +++ b/packages/server/src/server/workspace-service-port-registry.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from "vitest"; + +import { + ensureWorkspaceServicePortPlan, + refreshWorkspaceServicePort, +} from "./workspace-service-port-registry.js"; + +describe("ensureWorkspaceServicePortPlan", () => { + it("allocates ports for all declared services in declaration order", async () => { + let nextPort = 4100; + let allocationCount = 0; + + const plan = await ensureWorkspaceServicePortPlan({ + workspaceId: "registry-order-workspace", + services: [{ scriptName: "api" }, { scriptName: "web" }, { scriptName: "worker" }], + allocatePort: async () => { + allocationCount += 1; + const port = nextPort; + nextPort += 1; + return port; + }, + }); + + expect(Array.from(plan.entries())).toEqual([ + ["api", 4100], + ["web", 4101], + ["worker", 4102], + ]); + expect(allocationCount).toBe(3); + }); + + it("returns the existing plan without calling the allocator on later calls", async () => { + let allocationCount = 0; + + const firstPlan = await ensureWorkspaceServicePortPlan({ + workspaceId: "registry-first-wins-workspace", + services: [{ scriptName: "api" }, { scriptName: "web" }], + allocatePort: async () => { + allocationCount += 1; + return 4200 + allocationCount; + }, + }); + + const secondPlan = await ensureWorkspaceServicePortPlan({ + workspaceId: "registry-first-wins-workspace", + services: [{ scriptName: "new-service" }], + allocatePort: async () => { + allocationCount += 1; + return 4300; + }, + }); + + expect(Array.from(secondPlan.entries())).toEqual(Array.from(firstPlan.entries())); + expect(secondPlan.has("new-service")).toBe(false); + expect(allocationCount).toBe(2); + }); + + it("shares one first-plan build across concurrent callers", async () => { + let allocationCount = 0; + const firstAllocation = createDeferredPort(); + const secondAllocation = createDeferredPort(); + + const firstPlanPromise = ensureWorkspaceServicePortPlan({ + workspaceId: "registry-concurrent-first-plan-workspace", + services: [{ scriptName: "api" }, { scriptName: "web" }], + allocatePort: async () => { + allocationCount += 1; + if (allocationCount === 1) { + return await firstAllocation.promise; + } + return await secondAllocation.promise; + }, + }); + const secondPlanPromise = ensureWorkspaceServicePortPlan({ + workspaceId: "registry-concurrent-first-plan-workspace", + services: [{ scriptName: "api" }, { scriptName: "web" }], + allocatePort: async () => { + allocationCount += 1; + return 4300 + allocationCount; + }, + }); + + await Promise.resolve(); + expect(allocationCount).toBe(1); + firstAllocation.resolve(4301); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(allocationCount).toBe(2); + secondAllocation.resolve(4302); + + const [firstPlan, secondPlan] = await Promise.all([firstPlanPromise, secondPlanPromise]); + + expect(Array.from(firstPlan.entries())).toEqual([ + ["api", 4301], + ["web", 4302], + ]); + expect(Array.from(secondPlan.entries())).toEqual(Array.from(firstPlan.entries())); + expect(allocationCount).toBe(2); + }); + + it("uses explicit configured ports without calling the allocator", async () => { + let allocationCount = 0; + + const plan = await ensureWorkspaceServicePortPlan({ + workspaceId: "registry-explicit-port-workspace", + services: [ + { scriptName: "api", port: 4410 }, + { scriptName: "web", port: 4411 }, + ], + allocatePort: async () => { + allocationCount += 1; + return 4400; + }, + }); + + expect(Array.from(plan.entries())).toEqual([ + ["api", 4410], + ["web", 4411], + ]); + expect(allocationCount).toBe(0); + }); + + it("keeps workspace plans independent", async () => { + const firstPlan = await ensureWorkspaceServicePortPlan({ + workspaceId: "registry-independent-workspace-a", + services: [{ scriptName: "api" }], + allocatePort: async () => 4500, + }); + + const secondPlan = await ensureWorkspaceServicePortPlan({ + workspaceId: "registry-independent-workspace-b", + services: [{ scriptName: "api" }], + allocatePort: async () => 4600, + }); + + expect(firstPlan.get("api")).toBe(4500); + expect(secondPlan.get("api")).toBe(4600); + }); + + it("returns defensive snapshots", async () => { + const first = await ensureWorkspaceServicePortPlan({ + workspaceId: "registry-defensive-snapshot-workspace", + services: [{ scriptName: "api" }], + allocatePort: async () => 4700, + }); + + const second = await ensureWorkspaceServicePortPlan({ + workspaceId: "registry-defensive-snapshot-workspace", + services: [], + allocatePort: async () => 4701, + }); + + expect(first).not.toBe(second); + expect(Array.from(first.entries())).toEqual([["api", 4700]]); + expect(Array.from(second.entries())).toEqual(Array.from(first.entries())); + }); +}); + +describe("refreshWorkspaceServicePort", () => { + it("reallocates only the named service", async () => { + await ensureWorkspaceServicePortPlan({ + workspaceId: "registry-refresh-workspace", + services: [{ scriptName: "api" }, { scriptName: "web" }], + allocatePort: createSequentialPortAllocator(4800), + }); + + const refreshedPort = await refreshWorkspaceServicePort({ + workspaceId: "registry-refresh-workspace", + service: { scriptName: "api" }, + allocatePort: async () => 4900, + }); + + const snapshot = await ensureWorkspaceServicePortPlan({ + workspaceId: "registry-refresh-workspace", + services: [], + allocatePort: async () => 4901, + }); + + expect(refreshedPort).toBe(4900); + expect(Array.from(snapshot.entries())).toEqual([ + ["api", 4900], + ["web", 4801], + ]); + }); + + it("uses an explicit configured port without calling the allocator", async () => { + let allocationCount = 0; + + await ensureWorkspaceServicePortPlan({ + workspaceId: "registry-refresh-explicit-workspace", + services: [{ scriptName: "api" }], + allocatePort: async () => 5000, + }); + + const refreshedPort = await refreshWorkspaceServicePort({ + workspaceId: "registry-refresh-explicit-workspace", + service: { scriptName: "api", port: 5100 }, + allocatePort: async () => { + allocationCount += 1; + return 5001; + }, + }); + + expect(refreshedPort).toBe(5100); + expect(allocationCount).toBe(0); + }); +}); + +function createSequentialPortAllocator(startPort: number): () => Promise { + let nextPort = startPort; + + return async function allocatePort(): Promise { + const port = nextPort; + nextPort += 1; + return port; + }; +} + +interface DeferredPort { + promise: Promise; + resolve: (port: number) => void; +} + +function createDeferredPort(): DeferredPort { + let resolvePort: (port: number) => void = () => {}; + const promise = new Promise((resolve) => { + resolvePort = resolve; + }); + + return { + promise, + resolve: resolvePort, + }; +} diff --git a/packages/server/src/server/workspace-service-port-registry.ts b/packages/server/src/server/workspace-service-port-registry.ts new file mode 100644 index 000000000..44877980a --- /dev/null +++ b/packages/server/src/server/workspace-service-port-registry.ts @@ -0,0 +1,102 @@ +interface WorkspaceServicePortDeclaration { + scriptName: string; + port?: number; +} + +interface EnsureWorkspaceServicePortPlanOptions { + workspaceId: string; + services: readonly WorkspaceServicePortDeclaration[]; + allocatePort: () => Promise; +} + +interface RefreshWorkspaceServicePortOptions { + workspaceId: string; + service: WorkspaceServicePortDeclaration; + allocatePort: () => Promise; +} + +const workspaceServicePortPlans = new Map>(); +const pendingWorkspaceServicePortPlans = new Map>>(); + +export async function ensureWorkspaceServicePortPlan( + options: EnsureWorkspaceServicePortPlanOptions, +): Promise> { + const existingPlan = workspaceServicePortPlans.get(options.workspaceId); + if (existingPlan) { + return new Map(existingPlan); + } + + let pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId); + if (!pendingPlan) { + pendingPlan = createPendingWorkspaceServicePortPlan({ + workspaceId: options.workspaceId, + services: options.services, + allocatePort: options.allocatePort, + }); + pendingWorkspaceServicePortPlans.set(options.workspaceId, pendingPlan); + } + + return new Map(await pendingPlan); +} + +export function requirePlannedWorkspaceServicePort( + plan: ReadonlyMap, + scriptName: string, +): number { + const port = plan.get(scriptName); + if (port === undefined) { + throw new Error(`Service '${scriptName}' is missing from workspace service port plan`); + } + return port; +} + +async function createPendingWorkspaceServicePortPlan(options: { + workspaceId: string; + services: readonly WorkspaceServicePortDeclaration[]; + allocatePort: () => Promise; +}): Promise> { + try { + const plan = await buildWorkspaceServicePortPlan({ + services: options.services, + allocatePort: options.allocatePort, + }); + workspaceServicePortPlans.set(options.workspaceId, plan); + return plan; + } finally { + pendingWorkspaceServicePortPlans.delete(options.workspaceId); + } +} + +async function buildWorkspaceServicePortPlan(options: { + services: readonly WorkspaceServicePortDeclaration[]; + allocatePort: () => Promise; +}): Promise> { + const plan = new Map(); + for (const service of options.services) { + plan.set(service.scriptName, await resolveServicePort(service, options.allocatePort)); + } + + return plan; +} + +export async function refreshWorkspaceServicePort( + options: RefreshWorkspaceServicePortOptions, +): Promise { + const plan = workspaceServicePortPlans.get(options.workspaceId) ?? new Map(); + + const port = await resolveServicePort(options.service, options.allocatePort); + plan.set(options.service.scriptName, port); + workspaceServicePortPlans.set(options.workspaceId, plan); + return port; +} + +async function resolveServicePort( + service: WorkspaceServicePortDeclaration, + allocatePort: () => Promise, +): Promise { + if (service.port !== undefined) { + return service.port; + } + + return await allocatePort(); +} diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index 2aff201b6..06ec7ef1f 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -5,14 +5,19 @@ import { join } from "path"; import { tmpdir } from "os"; import type { AgentTimelineItem } from "./agent/agent-sdk-types.js"; -import { runAsyncWorktreeBootstrap, spawnWorktreeScripts } from "./worktree-bootstrap.js"; +import { + runAsyncWorktreeBootstrap, + spawnWorkspaceScript, + spawnWorktreeScripts, +} from "./worktree-bootstrap.js"; +import { ensureWorkspaceServicePortPlan } from "./workspace-service-port-registry.js"; import { ScriptRouteStore } from "./script-proxy.js"; import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import { createWorktree as createWorktreePrimitive, type WorktreeConfig, } from "../utils/worktree.js"; -import type { TerminalManager } from "../terminal/terminal-manager.js"; +import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalSession } from "../terminal/terminal.js"; interface CreateAgentWorktreeTestOptions { @@ -49,6 +54,7 @@ describe("runAsyncWorktreeBootstrap", () => { let tempDir: string; let repoDir: string; let paseoHome: string; + let realTerminalManagers: TerminalManager[]; async function waitForPathExists(targetPath: string, timeoutMs = 10000): Promise { const startedAt = Date.now(); @@ -62,6 +68,7 @@ describe("runAsyncWorktreeBootstrap", () => { } beforeEach(() => { + realTerminalManagers = []; tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-bootstrap-test-"))); repoDir = join(tempDir, "repo"); paseoHome = join(tempDir, "paseo-home"); @@ -75,7 +82,19 @@ describe("runAsyncWorktreeBootstrap", () => { execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" }); }); - afterEach(() => { + afterEach(async () => { + for (const terminalManager of realTerminalManagers) { + for (const cwd of terminalManager.listDirectories()) { + const terminals = await terminalManager.getTerminals(cwd); + for (const terminal of terminals) { + await terminalManager.killTerminalAndWait(terminal.id, { + gracefulTimeoutMs: 100, + forceTimeoutMs: 100, + }); + } + } + terminalManager.killAll(); + } rmSync(tempDir, { recursive: true, force: true }); }); @@ -540,26 +559,53 @@ describe("runAsyncWorktreeBootstrap", () => { expect(terminalToolCall?.status).toBe("completed"); }); + interface CreateTerminalCall { + cwd: string; + name?: string; + env?: Record; + } + + interface StubTerminalRecord { + triggerExit: (exitCode: number) => void; + } + function createStubTerminalManager( - createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record }>, + createTerminalCalls: CreateTerminalCall[], + terminalRecords: StubTerminalRecord[] = [], ): TerminalManager { + let terminalCounter = 0; + return { async getTerminals() { return []; }, - async createTerminal(options: { - cwd: string; - name?: string; - env?: Record; - }): Promise { + async createTerminal(options: CreateTerminalCall): Promise { createTerminalCalls.push(options); + terminalCounter += 1; + const terminalId = `term-service-${terminalCounter}`; + let exitHandler: ((info: { exitCode: number | null }) => void) | null = null; + terminalRecords.push({ + triggerExit: (exitCode) => { + if (exitHandler) { + exitHandler({ exitCode }); + } + }, + }); + return { - id: "term-service", + id: terminalId, name: options.name ?? "Terminal", cwd: options.cwd, send: () => {}, subscribe: () => () => {}, - onExit: () => () => {}, + onExit: (handler) => { + exitHandler = handler; + return () => { + if (exitHandler === handler) { + exitHandler = null; + } + }; + }, getState: () => ({ rows: 1, cols: 1, @@ -591,6 +637,21 @@ describe("runAsyncWorktreeBootstrap", () => { }; } + function readEnvFile(path: string): Record { + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Expected env file to contain a JSON object: ${path}`); + } + + const env: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === "string") { + env[key] = value; + } + } + return env; + } + it("spawns plain scripts without env injection or routes", async () => { writeFileSync( join(repoDir, "paseo.json"), @@ -610,8 +671,7 @@ describe("runAsyncWorktreeBootstrap", () => { const routeStore = new ScriptRouteStore(); const runtimeStore = new WorkspaceScriptRuntimeStore(); - const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record }> = - []; + const createTerminalCalls: CreateTerminalCall[] = []; const results = await spawnWorktreeScripts({ repoRoot: repoDir, @@ -633,18 +693,22 @@ describe("runAsyncWorktreeBootstrap", () => { type: "script", lifecycle: "running", exitCode: null, - terminalId: "term-service", + terminalId: "term-service-1", }); }); - it("spawns services with route registration and injected service env vars", async () => { + it("spawns services with route registration and injected peer service env vars", async () => { writeFileSync( join(repoDir, "paseo.json"), JSON.stringify({ scripts: { - web: { + api: { type: "service", - command: "npm run dev", + command: "npm run api", + }, + "app-server": { + type: "service", + command: "npm run app", }, }, }), @@ -657,43 +721,333 @@ describe("runAsyncWorktreeBootstrap", () => { const routeStore = new ScriptRouteStore(); const runtimeStore = new WorkspaceScriptRuntimeStore(); - const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record }> = - []; + const createTerminalCalls: CreateTerminalCall[] = []; - const results = await spawnWorktreeScripts({ + const result = await spawnWorkspaceScript({ repoRoot: repoDir, workspaceId: repoDir, branchName: "feature-socket-service", + scriptName: "api", daemonPort: 6767, routeStore, runtimeStore, terminalManager: createStubTerminalManager(createTerminalCalls), }); - expect(results).toHaveLength(1); + expect(result.scriptName).toBe("api"); expect(routeStore.listRoutes()).toEqual([ { - hostname: "feature-socket-service.web.localhost", + hostname: "feature-socket-service.api.localhost", port: expect.any(Number), workspaceId: repoDir, - scriptName: "web", + scriptName: "api", }, ]); expect(createTerminalCalls).toHaveLength(1); expect(createTerminalCalls[0]?.cwd).toBe(repoDir); - expect(createTerminalCalls[0]?.name).toBe("web"); - expect(createTerminalCalls[0]?.env?.PORT).toEqual(expect.any(String)); + expect(createTerminalCalls[0]?.name).toBe("api"); + expect(createTerminalCalls[0]?.env).not.toHaveProperty("PORT"); + expect(createTerminalCalls[0]?.env?.PASEO_PORT).toEqual(expect.any(String)); expect(createTerminalCalls[0]?.env?.HOST).toBe("127.0.0.1"); - expect(createTerminalCalls[0]?.env?.PASEO_SCRIPT_URL).toBe( - "http://feature-socket-service.web.localhost:6767", + expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe( + "http://feature-socket-service.api.localhost:6767", ); - expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "web" })).toMatchObject({ + expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_PORT).toBe( + createTerminalCalls[0]?.env?.PASEO_PORT, + ); + expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_URL).toBe( + "http://feature-socket-service.api.localhost:6767", + ); + const plannedPorts = await ensureWorkspaceServicePortPlan({ + workspaceId: repoDir, + services: [{ scriptName: "api" }, { scriptName: "app-server" }], + allocatePort: async () => { + throw new Error("Peer env test should reuse the existing service port plan"); + }, + }); + const plannedAppServerPort = plannedPorts.get("app-server"); + if (plannedAppServerPort === undefined) { + throw new Error("Expected app-server to be present in the service port plan"); + } + expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_APP_SERVER_PORT).toBe( + String(plannedAppServerPort), + ); + expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_APP_SERVER_URL).toBe( + "http://feature-socket-service.app-server.localhost:6767", + ); + expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "api" })).toMatchObject({ type: "service", lifecycle: "running", exitCode: null, }); }); + it("refreshes a stopped service port on respawn and updates the route", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + scripts: { + api: { + type: "service", + command: "npm run api", + }, + worker: { + type: "service", + command: "npm run worker", + }, + }, + }), + ); + execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add respawn service script config'", { + cwd: repoDir, + stdio: "pipe", + }); + + const routeStore = new ScriptRouteStore(); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + const createTerminalCalls: CreateTerminalCall[] = []; + const terminalRecords: StubTerminalRecord[] = []; + const terminalManager = createStubTerminalManager(createTerminalCalls, terminalRecords); + + const firstResult = await spawnWorkspaceScript({ + repoRoot: repoDir, + workspaceId: repoDir, + branchName: "feature-respawn-service", + scriptName: "api", + daemonPort: 6767, + routeStore, + runtimeStore, + terminalManager, + }); + + const workerResult = await spawnWorkspaceScript({ + repoRoot: repoDir, + workspaceId: repoDir, + branchName: "feature-respawn-service", + scriptName: "worker", + daemonPort: 6767, + routeStore, + runtimeStore, + terminalManager, + }); + + expect(firstResult.port).toEqual(expect.any(Number)); + const firstPort = firstResult.port; + if (firstPort === null) { + throw new Error("Expected first service spawn to return a port"); + } + const workerPort = workerResult.port; + if (workerPort === null) { + throw new Error("Expected worker service spawn to return a port"); + } + + const firstTerminal = terminalRecords[0]; + if (!firstTerminal) { + throw new Error("Expected first terminal record"); + } + firstTerminal.triggerExit(0); + + expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "api" })).toMatchObject({ + lifecycle: "stopped", + exitCode: 0, + }); + expect(routeStore.getRouteEntry("feature-respawn-service.api.localhost")).toBeNull(); + + const secondResult = await spawnWorkspaceScript({ + repoRoot: repoDir, + workspaceId: repoDir, + branchName: "feature-respawn-service", + scriptName: "api", + daemonPort: 6767, + routeStore, + runtimeStore, + terminalManager, + }); + + expect(secondResult.port).toEqual(expect.any(Number)); + const secondPort = secondResult.port; + if (secondPort === null) { + throw new Error("Expected second service spawn to return a port"); + } + expect(secondPort).not.toBe(firstPort); + expect(secondPort).toEqual(expect.any(Number)); + expect(createTerminalCalls[2]?.env?.PASEO_SERVICE_WORKER_PORT).toBe(String(workerPort)); + expect(routeStore.getRouteEntry("feature-respawn-service.api.localhost")).toMatchObject({ + hostname: "feature-respawn-service.api.localhost", + port: secondPort, + workspaceId: repoDir, + scriptName: "api", + }); + }); + + it("fails normalized service env name collisions before terminal creation", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + scripts: { + "app-server": { + type: "service", + command: "npm run app-server", + }, + "app.server": { + type: "service", + command: "npm run app-dot-server", + }, + }, + }), + ); + execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add colliding service config'", { + cwd: repoDir, + stdio: "pipe", + }); + + const routeStore = new ScriptRouteStore(); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + const createTerminalCalls: CreateTerminalCall[] = []; + + await expect( + spawnWorkspaceScript({ + repoRoot: repoDir, + workspaceId: repoDir, + branchName: "feature-collision-service", + scriptName: "app-server", + daemonPort: 6767, + routeStore, + runtimeStore, + terminalManager: createStubTerminalManager(createTerminalCalls), + }), + ).rejects.toThrow("Service env name collision for APP_SERVER: app-server, app.server"); + + expect(createTerminalCalls).toHaveLength(0); + expect(routeStore.listRoutes()).toEqual([]); + expect(routeStore.getRouteEntry("feature-collision-service.app-server.localhost")).toBeNull(); + + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + scripts: { + "app-server": { + type: "service", + command: "npm run app-server", + }, + worker: { + type: "service", + command: "npm run worker", + }, + }, + }), + ); + + await spawnWorkspaceScript({ + repoRoot: repoDir, + workspaceId: repoDir, + branchName: "feature-collision-service", + scriptName: "app-server", + daemonPort: 6767, + routeStore, + runtimeStore, + terminalManager: createStubTerminalManager(createTerminalCalls), + }); + + const plan = await ensureWorkspaceServicePortPlan({ + workspaceId: repoDir, + services: [{ scriptName: "app-server" }, { scriptName: "worker" }], + allocatePort: async () => { + throw new Error("Collision recovery should reuse the fixed service port plan"); + }, + }); + + expect(Array.from(plan.keys())).toEqual(["app-server", "worker"]); + expect(createTerminalCalls).toHaveLength(1); + expect(createTerminalCalls[0]?.env).toHaveProperty("PASEO_SERVICE_APP_SERVER_PORT"); + expect(createTerminalCalls[0]?.env).toHaveProperty("PASEO_SERVICE_WORKER_PORT"); + }); + + it("injects real peer service env into terminal-backed services", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + scripts: { + api: { + type: "service", + command: + "node -e \"const fs=require('fs'); fs.writeFileSync('api-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"", + }, + web: { + type: "service", + command: + "node -e \"const fs=require('fs'); fs.writeFileSync('web-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"", + }, + }, + }), + ); + execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add real peer env services'", { + cwd: repoDir, + stdio: "pipe", + }); + + const routeStore = new ScriptRouteStore(); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + const terminalManager = createTerminalManager(); + realTerminalManagers.push(terminalManager); + + await spawnWorktreeScripts({ + repoRoot: repoDir, + workspaceId: repoDir, + branchName: "feature-peer-env", + daemonPort: 6767, + routeStore, + runtimeStore, + terminalManager, + }); + + const apiEnvPath = join(repoDir, "api-env.json"); + const webEnvPath = join(repoDir, "web-env.json"); + await waitForPathExists(apiEnvPath); + await waitForPathExists(webEnvPath); + + const apiEnv = readEnvFile(apiEnvPath); + const webEnv = readEnvFile(webEnvPath); + + expect(apiEnv.PASEO_SERVICE_API_URL).toBe("http://feature-peer-env.api.localhost:6767"); + expect(apiEnv.PASEO_SERVICE_WEB_URL).toBe("http://feature-peer-env.web.localhost:6767"); + expect(apiEnv.PASEO_SERVICE_API_PORT).toEqual(expect.stringMatching(/^\d+$/)); + expect(apiEnv.PASEO_SERVICE_WEB_PORT).toEqual(expect.stringMatching(/^\d+$/)); + expect(apiEnv.PASEO_URL).toBe(apiEnv.PASEO_SERVICE_API_URL); + expect(apiEnv.PASEO_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT); + expect(apiEnv).not.toHaveProperty("PORT"); + + expect(webEnv.PASEO_SERVICE_API_URL).toBe("http://feature-peer-env.api.localhost:6767"); + expect(webEnv.PASEO_SERVICE_WEB_URL).toBe("http://feature-peer-env.web.localhost:6767"); + expect(webEnv.PASEO_SERVICE_API_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT); + expect(webEnv.PASEO_SERVICE_WEB_PORT).toBe(apiEnv.PASEO_SERVICE_WEB_PORT); + expect(webEnv.PASEO_URL).toBe(webEnv.PASEO_SERVICE_WEB_URL); + expect(webEnv.PASEO_PORT).toBe(webEnv.PASEO_SERVICE_WEB_PORT); + expect(webEnv).not.toHaveProperty("PORT"); + + const apiPort = Number(apiEnv.PASEO_SERVICE_API_PORT); + const webPort = Number(apiEnv.PASEO_SERVICE_WEB_PORT); + expect(Number.isInteger(apiPort)).toBe(true); + expect(Number.isInteger(webPort)).toBe(true); + expect(routeStore.listRoutes()).toEqual([ + { + hostname: "feature-peer-env.api.localhost", + port: apiPort, + workspaceId: repoDir, + scriptName: "api", + }, + { + hostname: "feature-peer-env.web.localhost", + port: webPort, + workspaceId: repoDir, + scriptName: "web", + }, + ]); + }); + it("binds services to the network when the daemon listens on a non-loopback host", async () => { writeFileSync( join(repoDir, "paseo.json"), @@ -714,8 +1068,7 @@ describe("runAsyncWorktreeBootstrap", () => { const routeStore = new ScriptRouteStore(); const runtimeStore = new WorkspaceScriptRuntimeStore(); - const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record }> = - []; + const createTerminalCalls: CreateTerminalCall[] = []; await spawnWorktreeScripts({ repoRoot: repoDir, @@ -730,7 +1083,7 @@ describe("runAsyncWorktreeBootstrap", () => { expect(createTerminalCalls).toHaveLength(1); expect(createTerminalCalls[0]?.env?.HOST).toBe("0.0.0.0"); - expect(createTerminalCalls[0]?.env?.PASEO_SCRIPT_URL).toBe( + expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe( "http://feature-remote-service.web.localhost:6767", ); }); diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index b9b57bf67..e53b6bafa 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -18,6 +18,16 @@ import { 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"; +import { + assertNoServiceEnvNameCollisions, + buildWorkspaceServiceEnv, + type WorkspaceServicePeer, +} from "./workspace-service-env.js"; +import { + ensureWorkspaceServicePortPlan, + requirePlannedWorkspaceServicePort, + refreshWorkspaceServicePort, +} from "./workspace-service-port-registry.js"; export interface WorktreeBootstrapTerminalResult { name: string | null; @@ -675,7 +685,12 @@ export interface WorktreeScriptResult { terminalId: string; } -type SpawnWorkspaceScriptOptions = { +interface WorkspaceServiceDeclaration { + scriptName: string; + port?: number; +} + +interface SpawnWorkspaceScriptOptions { repoRoot: string; workspaceId: string; branchName: string | null; @@ -687,24 +702,6 @@ type SpawnWorkspaceScriptOptions = { terminalManager: TerminalManager; logger?: Logger; onLifecycleChanged?: () => void; -}; - -function isLoopbackListenHost(host: string | null | undefined): boolean { - if (!host) { - return true; - } - - const normalizedHost = host.trim().toLowerCase(); - return ( - normalizedHost === "localhost" || - normalizedHost === "127.0.0.1" || - normalizedHost === "::1" || - normalizedHost === "[::1]" - ); -} - -export function resolveServiceBindHost(daemonListenHost: string | null | undefined): string { - return isLoopbackListenHost(daemonListenHost) ? "127.0.0.1" : "0.0.0.0"; } export async function spawnWorkspaceScript( @@ -734,45 +731,82 @@ export async function spawnWorkspaceScript( let port: number | null = null; let terminal: TerminalSession | null = null; let runtimeRegistered = false; + let routeRegistered = false; try { if (runtimeStore.isRunning({ workspaceId, scriptName })) { throw new Error(`Script '${scriptName}' is already running`); } + let env: Record | undefined; if (serviceScript) { - port = config.port ?? (await findFreePort()); + const serviceDeclarations: WorkspaceServiceDeclaration[] = []; + for (const [configuredScriptName, scriptConfig] of scriptConfigs) { + if (isServiceScript(scriptConfig)) { + serviceDeclarations.push({ + scriptName: configuredScriptName, + port: scriptConfig.port, + }); + } + } + assertNoServiceEnvNameCollisions( + serviceDeclarations.map((serviceDeclaration) => serviceDeclaration.scriptName), + ); + + const plannedPorts = await ensureWorkspaceServicePortPlan({ + workspaceId, + services: serviceDeclarations, + allocatePort: findFreePort, + }); + const existingRuntimeEntry = runtimeStore.get({ workspaceId, scriptName }); + const servicePort = + existingRuntimeEntry?.lifecycle === "stopped" + ? await refreshWorkspaceServicePort({ + workspaceId, + service: { scriptName, port: config.port }, + allocatePort: findFreePort, + }) + : requirePlannedWorkspaceServicePort(plannedPorts, scriptName); + + port = servicePort; + + const peers: WorkspaceServicePeer[] = []; + for (const [peerScriptName, peerPort] of plannedPorts) { + peers.push({ + scriptName: peerScriptName, + port: peerScriptName === scriptName ? servicePort : peerPort, + }); + } + + env = buildWorkspaceServiceEnv({ + scriptName, + branchName, + daemonPort, + daemonListenHost, + peers, + }); + routeStore.registerRoute({ hostname: hostname!, - port, + port: servicePort, workspaceId, scriptName, }); + routeRegistered = true; } - const env = serviceScript - ? { - PORT: String(port), - HOST: resolveServiceBindHost(daemonListenHost), - ...(daemonPort !== null && daemonPort !== undefined - ? { - PASEO_SCRIPT_URL: `http://${hostname!}:${daemonPort}`, - } - : {}), - } - : undefined; - - terminal = await terminalManager.createTerminal({ + const createdTerminal = await terminalManager.createTerminal({ cwd: repoRoot, name: scriptName, env, }); + terminal = createdTerminal; runtimeStore.set({ workspaceId, scriptName, type: serviceScript ? "service" : "script", lifecycle: "running", - terminalId: terminal.id, + terminalId: createdTerminal.id, exitCode: null, }); runtimeRegistered = true; @@ -786,7 +820,7 @@ export async function spawnWorkspaceScript( scriptName, type: serviceScript ? "service" : "script", lifecycle: "stopped", - terminalId: terminal!.id, + terminalId: createdTerminal.id, exitCode: info.exitCode, }); onLifecycleChanged?.(); @@ -795,7 +829,7 @@ export async function spawnWorkspaceScript( scriptName, hostname, exitCode: info.exitCode, - terminalId: terminal!.id, + terminalId: createdTerminal.id, }, "Stopped worktree script", ); @@ -825,7 +859,7 @@ export async function spawnWorkspaceScript( terminalId: terminal.id, }; } catch (error) { - if (hostname && port !== null) { + if (routeRegistered && hostname) { routeStore.removeRoute(hostname); } if (runtimeRegistered) {