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.
This commit is contained in:
Mohamed Boudra
2026-04-18 16:03:18 +07:00
parent 72e4476acc
commit 6b5282ad43
6 changed files with 1057 additions and 69 deletions
@@ -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");
});
});
@@ -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<string, string> {
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<string, string> = {
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<string, string[]>();
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("; "));
}
}
@@ -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<number> {
let nextPort = startPort;
return async function allocatePort(): Promise<number> {
const port = nextPort;
nextPort += 1;
return port;
};
}
interface DeferredPort {
promise: Promise<number>;
resolve: (port: number) => void;
}
function createDeferredPort(): DeferredPort {
let resolvePort: (port: number) => void = () => {};
const promise = new Promise<number>((resolve) => {
resolvePort = resolve;
});
return {
promise,
resolve: resolvePort,
};
}
@@ -0,0 +1,102 @@
interface WorkspaceServicePortDeclaration {
scriptName: string;
port?: number;
}
interface EnsureWorkspaceServicePortPlanOptions {
workspaceId: string;
services: readonly WorkspaceServicePortDeclaration[];
allocatePort: () => Promise<number>;
}
interface RefreshWorkspaceServicePortOptions {
workspaceId: string;
service: WorkspaceServicePortDeclaration;
allocatePort: () => Promise<number>;
}
const workspaceServicePortPlans = new Map<string, Map<string, number>>();
const pendingWorkspaceServicePortPlans = new Map<string, Promise<Map<string, number>>>();
export async function ensureWorkspaceServicePortPlan(
options: EnsureWorkspaceServicePortPlanOptions,
): Promise<ReadonlyMap<string, number>> {
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<string, number>,
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<number>;
}): Promise<Map<string, number>> {
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<number>;
}): Promise<Map<string, number>> {
const plan = new Map<string, number>();
for (const service of options.services) {
plan.set(service.scriptName, await resolveServicePort(service, options.allocatePort));
}
return plan;
}
export async function refreshWorkspaceServicePort(
options: RefreshWorkspaceServicePortOptions,
): Promise<number> {
const plan = workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>();
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<number>,
): Promise<number> {
if (service.port !== undefined) {
return service.port;
}
return await allocatePort();
}
@@ -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<void> {
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<string, string>;
}
interface StubTerminalRecord {
triggerExit: (exitCode: number) => void;
}
function createStubTerminalManager(
createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record<string, string> }>,
createTerminalCalls: CreateTerminalCall[],
terminalRecords: StubTerminalRecord[] = [],
): TerminalManager {
let terminalCounter = 0;
return {
async getTerminals() {
return [];
},
async createTerminal(options: {
cwd: string;
name?: string;
env?: Record<string, string>;
}): Promise<TerminalSession> {
async createTerminal(options: CreateTerminalCall): Promise<TerminalSession> {
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<string, string> {
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<string, string> = {};
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<string, string> }> =
[];
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<string, string> }> =
[];
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<string, string> }> =
[];
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",
);
});
@@ -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<string, string> | 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) {