From 9292f58896e27b9d230e1f705f699ad4c4c8c98e Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Mon, 20 Jul 2026 11:50:01 -0700 Subject: [PATCH] feat(server): configure workspace service port allocation (#2165) * starr * fix(server): honor service port allocator contracts * fix(server): pass workspace context to port scripts * fix(server): cancel released service port plans * test(server): support Windows port allocator fixtures * docs(worktrees): clarify shell-less portScript execution --- docs/data-model.md | 4 + docs/development.md | 9 + .../protocol/src/paseo-config-schema.test.ts | 22 +++ packages/protocol/src/paseo-config-schema.ts | 23 +++ .../src/server/persisted-config.test.ts | 10 + .../server/src/server/persisted-config.ts | 2 + packages/server/src/server/session.ts | 4 + .../workspace-scripts-service.ts | 4 + .../workspace-service-port-allocator.test.ts | 174 ++++++++++++++++++ .../workspace-service-port-allocator.ts | 129 +++++++++++++ .../workspace-service-port-registry.test.ts | 132 ++++++++++++- .../server/workspace-service-port-registry.ts | 165 +++++++++++++++-- .../server/src/server/worktree-bootstrap.ts | 32 +++- public-docs/worktrees.md | 43 +++++ 14 files changed, 738 insertions(+), 15 deletions(-) create mode 100644 packages/server/src/server/workspace-service-port-allocator.test.ts create mode 100644 packages/server/src/server/workspace-service-port-allocator.ts diff --git a/docs/data-model.md b/docs/data-model.md index 829ca612a..e1841045d 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -190,6 +190,10 @@ Single file, validated with `PersistedConfigSchema`. }, worktrees?: { root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees + servicePorts?: { // optional dynamic service port allocation policy + range?: string // inclusive range, e.g. "3000-4000" + portScript?: string // executable that receives service/workspace context and prints one TCP port + } }, providers: { openai: { diff --git a/docs/development.md b/docs/development.md index c36de231f..2bb5a5403 100644 --- a/docs/development.md +++ b/docs/development.md @@ -278,6 +278,15 @@ Service proxy hostnames use the double-dash shape: `web--feature-auth--project.l } ``` +Service ports use OS ephemeral allocation by default. Set `worktrees.servicePorts` in +`$PASEO_HOME/config.json`, or replace it for one project with `worktree.servicePorts` in +`paseo.json`. The block accepts an inclusive `range` such as `"3000-4000"` or a `portScript` +executable. Since `portScript` is executed directly without a shell, it must point to a real executable (e.g., a binary or a script with a proper shebang like `#!/bin/sh`) rather than an inline shell command or shell pipeline. For inline shell commands or pipelines, wrap them in a small script. `portScript` runs in the workspace directory with four arguments: service name, +workspace ID, branch name, and worktree path. A missing branch is passed as an empty string. The same +values are available as `PASEO_SCRIPTNAME`, `PASEO_WORKSPACE_ID`, `PASEO_BRANCH_NAME`, and +`PASEO_WORKTREE_PATH`. The script must print one valid TCP port. Paseo trusts the external allocator, +so the port may already be bound. `portScript` takes precedence when both values are present. + ## Bundled daemon web UI > The user-facing guide for this feature (enabling it, reverse proxy, TLS, tunnels, security) lives at [public-docs/web-ui.md](../public-docs/web-ui.md). This section is the contributor/build reference: how the artifact is produced, bundled, and excluded from desktop packaging. diff --git a/packages/protocol/src/paseo-config-schema.test.ts b/packages/protocol/src/paseo-config-schema.test.ts index 62499e0be..2ab1ce82a 100644 --- a/packages/protocol/src/paseo-config-schema.test.ts +++ b/packages/protocol/src/paseo-config-schema.test.ts @@ -33,6 +33,28 @@ describe("paseo config schema", () => { }); }); + it("parses service port allocation", () => { + expect( + PaseoConfigSchema.parse({ + worktree: { + servicePorts: { range: "3000-4000", portScript: "/usr/bin/portmake" }, + }, + }), + ).toEqual({ + worktree: { + setup: [], + teardown: [], + servicePorts: { range: "3000-4000", portScript: "/usr/bin/portmake" }, + }, + }); + }); + + it("rejects invalid service port ranges", () => { + expect(() => + PaseoConfigRawSchema.parse({ worktree: { servicePorts: { range: "4000-3000" } } }), + ).toThrow("Expected an inclusive TCP port range"); + }); + it("normalizes partial worktree lifecycle config without dropping present commands", () => { expect( PaseoConfigSchema.parse({ diff --git a/packages/protocol/src/paseo-config-schema.ts b/packages/protocol/src/paseo-config-schema.ts index 804e1cdf0..31935bd79 100644 --- a/packages/protocol/src/paseo-config-schema.ts +++ b/packages/protocol/src/paseo-config-schema.ts @@ -1,5 +1,26 @@ import { z } from "zod"; +const TCP_PORT_RANGE_PATTERN = /^(\d{1,5})-(\d{1,5})$/; + +export const PaseoServicePortAllocationSchema = z + .object({ + range: z.string().trim().regex(TCP_PORT_RANGE_PATTERN).optional(), + portScript: z.string().trim().min(1).optional(), + }) + .strict() + .refine( + (value) => value.range !== undefined || value.portScript !== undefined, + "Expected range or portScript", + ) + .refine((value) => { + if (!value.range) return true; + const match = TCP_PORT_RANGE_PATTERN.exec(value.range); + if (!match) return false; + const start = Number(match[1]); + const end = Number(match[2]); + return start >= 1 && end <= 65_535 && start <= end; + }, "Expected an inclusive TCP port range from 1-65535"); + export function normalizeLifecycleCommands(commands: unknown): string[] { if (typeof commands === "string") { return commands.trim().length > 0 ? [commands] : []; @@ -27,6 +48,7 @@ export const PaseoWorktreeConfigRawSchema = z setup: PaseoLifecycleCommandRawSchema.optional(), teardown: PaseoLifecycleCommandRawSchema.optional(), terminals: z.unknown().optional(), + servicePorts: PaseoServicePortAllocationSchema.optional(), }) .passthrough(); @@ -92,6 +114,7 @@ export const ProjectConfigRpcErrorSchema = z.discriminatedUnion("code", [ export type PaseoScriptEntryRaw = z.infer; export type PaseoMetadataGenerationEntry = z.infer; export type PaseoMetadataGeneration = z.infer; +export type PaseoServicePortAllocation = z.infer; export type PaseoConfigRaw = z.infer; export type PaseoConfig = z.infer; export type PaseoConfigRevision = z.infer; diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index da846342d..aff3e6535 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -125,6 +125,16 @@ describe("PersistedConfigSchema worktrees config", () => { expect(parsed.worktrees?.root).toBe("/mnt/fast/paseo-worktrees"); }); + + test("accepts service port allocation", () => { + const parsed = PersistedConfigSchema.parse({ + worktrees: { + servicePorts: { range: "3000-4000" }, + }, + }); + + expect(parsed.worktrees?.servicePorts).toEqual({ range: "3000-4000" }); + }); }); describe("PersistedConfigSchema provider credentials", () => { diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 5bc4f3cc9..79a84a416 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -10,6 +10,7 @@ import { import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js"; import { TerminalProfileSchema } from "@getpaseo/protocol/messages"; +import { PaseoServicePortAllocationSchema } from "@getpaseo/protocol/paseo-config-schema"; export const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]); export const LogFormatSchema = z.enum(["pretty", "json"]); @@ -77,6 +78,7 @@ const ProvidersSchema = z const WorktreesConfigSchema = z .object({ root: z.string().min(1).optional(), + servicePorts: PaseoServicePortAllocationSchema.optional(), }) .strict(); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 1b9e57b5a..b03fd97d0 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -57,6 +57,8 @@ import { type WorkspaceScriptsService, } from "./session/workspace-scripts/workspace-scripts-service.js"; import type { DaemonConfigStore } from "./daemon-config-store.js"; +import { loadPersistedConfig } from "./persisted-config.js"; +import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js"; import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils"; import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket"; import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; @@ -935,6 +937,7 @@ export class Session { logger: this.sessionLogger, emit: (message) => this.emit(message), spawnWorkspaceScript, + globalServicePorts: loadPersistedConfig(this.paseoHome).worktrees?.servicePorts, }); this.subscribeToOptionalManagers(); this.workspaceDirectory = new WorkspaceDirectory({ @@ -4394,6 +4397,7 @@ export class Session { private async teardownArchivedWorkspace(workspaceId: string): Promise { this.workspaceGitObserver.removeForWorkspaceId(workspaceId); this.scriptRuntimeStore?.removeForWorkspace(workspaceId); + releaseWorkspaceServicePortPlan(workspaceId); } private async reconcileAndEmitWorkspaceUpdates(): Promise { diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts index a17c34b0b..800179a1f 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts @@ -24,6 +24,7 @@ import { readPaseoConfigForProjection, } from "../../script-status-projection.js"; import { deriveProjectServiceSlug, deriveProjectSlug } from "../../workspace-git-metadata.js"; +import type { PaseoServicePortAllocation } from "@getpaseo/protocol/paseo-config-schema"; type WorkspaceScriptsPayload = WorkspaceDescriptorPayload["scripts"]; @@ -58,6 +59,7 @@ export function createWorkspaceScriptsService(deps: { getDaemonTcpHost: (() => string | null) | null; serviceProxyPublicBaseUrl: string | null; resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | null; + globalServicePorts?: PaseoServicePortAllocation; logger: pino.Logger; emit: (message: SessionOutboundMessage) => void; spawnWorkspaceScript: (options: SpawnWorkspaceScriptOptions) => Promise; @@ -73,6 +75,7 @@ export function createWorkspaceScriptsService(deps: { getDaemonTcpHost, serviceProxyPublicBaseUrl, resolveScriptHealth, + globalServicePorts, logger, emit, spawnWorkspaceScript, @@ -168,6 +171,7 @@ export function createWorkspaceScriptsService(deps: { serviceProxy, runtimeStore: scriptRuntimeStore, terminalManager, + globalServicePorts, logger, onLifecycleChanged: () => { void emitStatusUpdate(workspace.workspaceId, workspace.cwd); diff --git a/packages/server/src/server/workspace-service-port-allocator.test.ts b/packages/server/src/server/workspace-service-port-allocator.test.ts new file mode 100644 index 000000000..c95543487 --- /dev/null +++ b/packages/server/src/server/workspace-service-port-allocator.test.ts @@ -0,0 +1,174 @@ +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import net from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { allocateWorkspaceServicePort } from "./workspace-service-port-allocator.js"; + +describe("allocateWorkspaceServicePort", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("allocates an available port within the configured range", async () => { + const port = await getFreePort(); + + await expect( + allocateWorkspaceServicePort({ + allocation: { range: `${port}-${port}` }, + cwd: tmpdir(), + scriptName: "web", + workspaceId: "wks_range_available", + branchName: "feature/range-available", + }), + ).resolves.toBe(port); + }); + + it("fails when every port in the configured range is occupied", async () => { + const server = net.createServer(); + const port = await listen(server); + + await expect( + allocateWorkspaceServicePort({ + allocation: { range: `${port}-${port}` }, + cwd: tmpdir(), + scriptName: "web", + workspaceId: "wks_range_occupied", + branchName: "feature/range-occupied", + }), + ).rejects.toThrow(`No available service port in configured range ${port}-${port}`); + + await close(server); + }); + + it("passes service and workspace context to portScript", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "workspace-service-port-allocator-")); + tempDirs.push(tempDir); + const port = await getFreePort(); + const scriptPath = createContextPortScript(tempDir, port); + + await expect( + allocateWorkspaceServicePort({ + allocation: { range: "1-1", portScript: scriptPath }, + cwd: tempDir, + scriptName: "app-server", + workspaceId: "wks_port_allocator", + branchName: "feature/allocator-context", + }), + ).resolves.toBe(port); + expect(readFileSync(join(tempDir, "cwd"), "utf8")).toBe(tempDir); + expect(readFileSync(join(tempDir, "argv"), "utf8")).toBe( + `app-server|wks_port_allocator|feature/allocator-context|${tempDir}`, + ); + expect(readFileSync(join(tempDir, "env"), "utf8")).toBe( + `app-server|wks_port_allocator|feature/allocator-context|${tempDir}`, + ); + }); + + it("accepts a valid portScript result that is already occupied", async () => { + const server = net.createServer(); + const port = await listen(server); + const scriptPath = createPortScript(String(port)); + + await expect( + allocateWorkspaceServicePort({ + allocation: { portScript: scriptPath }, + cwd: tmpdir(), + scriptName: "api", + workspaceId: "wks_occupied_script_port", + branchName: null, + }), + ).resolves.toBe(port); + + await close(server); + }); + + it("rejects invalid portScript output", async () => { + const scriptPath = createPortScript("not-a-port"); + + await expect( + allocateWorkspaceServicePort({ + allocation: { portScript: scriptPath }, + cwd: tmpdir(), + scriptName: "web", + workspaceId: "wks_invalid_script_output", + branchName: "feature/invalid-output", + }), + ).rejects.toThrow("must print exactly one TCP port"); + }); + + function createPortScript(output: string): string { + const tempDir = mkdtempSync(join(tmpdir(), "workspace-service-port-allocator-")); + tempDirs.push(tempDir); + const contents = + process.platform === "win32" + ? `@echo off\r\necho ${output}\r\n` + : `#!/bin/sh\nprintf '%s\\n' '${output}'\n`; + return writePortScript(tempDir, contents); + } + + function createContextPortScript(tempDir: string, port: number): string { + const contents = + process.platform === "win32" + ? `@echo off\r\n cwd\r\n argv\r\n env\r\necho ${port}\r\n` + : `#!/bin/sh\nprintf '%s' "$PWD" > cwd\nprintf '%s|%s|%s|%s' "$1" "$2" "$3" "$4" > argv\nprintf '%s|%s|%s|%s' "$PASEO_SCRIPTNAME" "$PASEO_WORKSPACE_ID" "$PASEO_BRANCH_NAME" "$PASEO_WORKTREE_PATH" > env\nprintf '${port}\\n'\n`; + return writePortScript(tempDir, contents); + } + + function writePortScript(tempDir: string, contents: string): string { + const fileName = process.platform === "win32" ? "portmake.cmd" : "portmake"; + const scriptPath = join(tempDir, fileName); + writeFileSync(scriptPath, contents); + if (process.platform !== "win32") chmodSync(scriptPath, 0o755); + return scriptPath; + } +}); + +function getFreePort(): Promise { + const server = net.createServer(); + return new Promise((resolve, reject) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("Expected TCP server address")); + return; + } + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(address.port); + }); + }); + server.on("error", reject); + }); +} + +function listen(server: net.Server): Promise { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected TCP server address"); + } + resolve(address.port); + }); + }); +} + +function close(server: net.Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} diff --git a/packages/server/src/server/workspace-service-port-allocator.ts b/packages/server/src/server/workspace-service-port-allocator.ts new file mode 100644 index 000000000..7d0dd7888 --- /dev/null +++ b/packages/server/src/server/workspace-service-port-allocator.ts @@ -0,0 +1,129 @@ +import net from "node:net"; +import { execCommand } from "../utils/spawn.js"; +import { findFreePort } from "./service-proxy.js"; +import type { PaseoServicePortAllocation } from "@getpaseo/protocol/paseo-config-schema"; + +const PORT_SCRIPT_TIMEOUT_MS = 10_000; +const PORT_SCRIPT_MAX_OUTPUT_BYTES = 1024; +const TCP_PORT_MIN = 1; +const TCP_PORT_MAX = 65_535; + +interface PortRange { + start: number; + end: number; +} + +export interface AllocateWorkspaceServicePortOptions { + allocation: PaseoServicePortAllocation | undefined; + cwd: string; + scriptName: string; + workspaceId: string; + branchName: string | null; + reservedPorts?: ReadonlySet; +} + +export async function allocateWorkspaceServicePort( + options: AllocateWorkspaceServicePortOptions, +): Promise { + if (options.allocation?.portScript) { + return await allocatePortFromScript({ + cwd: options.cwd, + command: options.allocation.portScript, + scriptName: options.scriptName, + workspaceId: options.workspaceId, + branchName: options.branchName, + reservedPorts: options.reservedPorts, + }); + } + if (options.allocation?.range) { + return await allocatePortFromRange( + parsePortRange(options.allocation.range), + options.reservedPorts ?? new Set(), + ); + } + return await findFreePort(); +} + +async function allocatePortFromScript(options: { + cwd: string; + command: string; + scriptName: string; + workspaceId: string; + branchName: string | null; + reservedPorts: ReadonlySet | undefined; +}): Promise { + let result: { stdout: string; stderr: string }; + try { + result = await execCommand( + options.command, + [options.scriptName, options.workspaceId, options.branchName ?? "", options.cwd], + { + cwd: options.cwd, + envOverlay: { + PASEO_SCRIPTNAME: options.scriptName, + PASEO_WORKSPACE_ID: options.workspaceId, + PASEO_BRANCH_NAME: options.branchName ?? "", + PASEO_WORKTREE_PATH: options.cwd, + }, + timeout: PORT_SCRIPT_TIMEOUT_MS, + maxBuffer: PORT_SCRIPT_MAX_OUTPUT_BYTES, + shell: false, + }, + ); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`Service port script '${options.command}' failed: ${detail}`, { cause: error }); + } + + const output = result.stdout.trim(); + if (!/^\d+$/.test(output)) { + throw new Error(`Service port script '${options.command}' must print exactly one TCP port`); + } + const port = Number(output); + if (!isValidTcpPort(port)) { + throw new Error( + `Service port script '${options.command}' returned invalid TCP port '${output}'`, + ); + } + if (options.reservedPorts?.has(port)) { + throw new Error(`Service port script '${options.command}' returned reserved port ${port}`); + } + return port; +} + +async function allocatePortFromRange( + range: PortRange, + reservedPorts: ReadonlySet, +): Promise { + const count = range.end - range.start + 1; + const startOffset = Math.floor(Math.random() * count); + for (let offset = 0; offset < count; offset += 1) { + const port = range.start + ((startOffset + offset) % count); + if (reservedPorts.has(port)) continue; + if (await isPortAvailable(port)) return port; + } + throw new Error(`No available service port in configured range ${range.start}-${range.end}`); +} + +function parsePortRange(value: string): PortRange { + const [start, end] = value.split("-").map(Number); + if (!isValidTcpPort(start) || !isValidTcpPort(end) || start > end) { + throw new Error(`Invalid service port range '${value}'`); + } + return { start, end }; +} + +function isValidTcpPort(port: number): boolean { + return Number.isInteger(port) && port >= TCP_PORT_MIN && port <= TCP_PORT_MAX; +} + +async function isPortAvailable(port: number): Promise { + return await new Promise((resolve) => { + const server = net.createServer(); + server.unref(); + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => { + server.close((error) => resolve(!error)); + }); + }); +} diff --git a/packages/server/src/server/workspace-service-port-registry.test.ts b/packages/server/src/server/workspace-service-port-registry.test.ts index 610fe8bbe..3bf455ba5 100644 --- a/packages/server/src/server/workspace-service-port-registry.test.ts +++ b/packages/server/src/server/workspace-service-port-registry.test.ts @@ -3,18 +3,21 @@ import { describe, expect, it } from "vitest"; import { ensureWorkspaceServicePortPlan, refreshWorkspaceServicePort, + releaseWorkspaceServicePortPlan, } 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 allocatedServices: string[] = []; const plan = await ensureWorkspaceServicePortPlan({ workspaceId: "registry-order-workspace", services: [{ scriptName: "api" }, { scriptName: "web" }, { scriptName: "worker" }], - allocatePort: async () => { + allocatePort: async ({ scriptName }) => { allocationCount += 1; + allocatedServices.push(scriptName); const port = nextPort; nextPort += 1; return port; @@ -27,6 +30,7 @@ describe("ensureWorkspaceServicePortPlan", () => { ["worker", 4102], ]); expect(allocationCount).toBe(3); + expect(allocatedServices).toEqual(["api", "web", "worker"]); }); it("returns the existing plan without calling the allocator on later calls", async () => { @@ -119,6 +123,51 @@ describe("ensureWorkspaceServicePortPlan", () => { expect(allocationCount).toBe(0); }); + it("retries dynamic allocation when a port is already planned", async () => { + const ports = [4400, 4400, 4401]; + + const plan = await ensureWorkspaceServicePortPlan({ + workspaceId: "registry-retry-duplicate-workspace", + services: [{ scriptName: "api" }, { scriptName: "web" }], + allocatePort: async () => { + const port = ports.shift(); + if (port === undefined) throw new Error("Expected another allocated port"); + return port; + }, + }); + + expect(Array.from(plan.entries())).toEqual([ + ["api", 4400], + ["web", 4401], + ]); + }); + + it("rejects duplicate explicit ports", async () => { + await expect( + ensureWorkspaceServicePortPlan({ + workspaceId: "registry-duplicate-explicit-port-workspace", + services: [ + { scriptName: "api", port: 4400 }, + { scriptName: "web", port: 4400 }, + ], + allocatePort: async () => 4401, + }), + ).rejects.toThrow("Service 'web' has a duplicate port 4400"); + }); + + it("reserves later explicit ports before allocating dynamic services", async () => { + const plan = await ensureWorkspaceServicePortPlan({ + workspaceId: "registry-later-explicit-port-workspace", + services: [{ scriptName: "web" }, { scriptName: "api", port: 5500 }], + allocatePort: async ({ reservedPorts }) => (reservedPorts.has(5500) ? 5501 : 5500), + }); + + expect(Array.from(plan.entries())).toEqual([ + ["web", 5501], + ["api", 5500], + ]); + }); + it("keeps workspace plans independent", async () => { const firstPlan = await ensureWorkspaceServicePortPlan({ workspaceId: "registry-independent-workspace-a", @@ -136,6 +185,87 @@ describe("ensureWorkspaceServicePortPlan", () => { expect(secondPlan.get("api")).toBe(4600); }); + it("does not reserve the same dynamic port for separate workspaces", async () => { + const firstPlan = await ensureWorkspaceServicePortPlan({ + workspaceId: "registry-daemon-reservation-workspace-a", + services: [{ scriptName: "api" }], + allocatePort: async () => 5200, + }); + const candidatePorts = [5200, 5201]; + const secondPlan = await ensureWorkspaceServicePortPlan({ + workspaceId: "registry-daemon-reservation-workspace-b", + services: [{ scriptName: "api" }], + allocatePort: async () => { + const port = candidatePorts.shift(); + if (port === undefined) throw new Error("Expected another allocated port"); + return port; + }, + }); + + expect(firstPlan.get("api")).toBe(5200); + expect(secondPlan.get("api")).toBe(5201); + }); + + it("releases dynamic reservations with the workspace plan", async () => { + await ensureWorkspaceServicePortPlan({ + workspaceId: "registry-release-workspace-a", + services: [{ scriptName: "api" }], + allocatePort: async () => 5300, + }); + + releaseWorkspaceServicePortPlan("registry-release-workspace-a"); + + const reusedPlan = await ensureWorkspaceServicePortPlan({ + workspaceId: "registry-release-workspace-b", + services: [{ scriptName: "api" }], + allocatePort: async () => 5300, + }); + expect(reusedPlan.get("api")).toBe(5300); + }); + + it("rolls back a plan released while allocation is pending", async () => { + const allocation = createDeferredPort(); + const pendingPlan = ensureWorkspaceServicePortPlan({ + workspaceId: "registry-pending-release-workspace", + services: [{ scriptName: "api" }], + allocatePort: async () => await allocation.promise, + }); + + releaseWorkspaceServicePortPlan("registry-pending-release-workspace"); + allocation.resolve(5350); + + await expect(pendingPlan).rejects.toThrow("Workspace service port plan was released"); + + const reusedPlan = await ensureWorkspaceServicePortPlan({ + workspaceId: "registry-after-pending-release-workspace", + services: [{ scriptName: "api" }], + allocatePort: async () => 5350, + }); + expect(reusedPlan.get("api")).toBe(5350); + }); + + it("rolls back dynamic reservations when plan creation fails", async () => { + let allocationCount = 0; + await expect( + ensureWorkspaceServicePortPlan({ + workspaceId: "registry-failed-plan-workspace", + services: [{ scriptName: "api" }, { scriptName: "web" }], + allocatePort: async () => { + allocationCount += 1; + if (allocationCount === 1) return 5400; + throw new Error("Allocation failed"); + }, + }), + ).rejects.toThrow("Allocation failed"); + + const recoveredPlan = await ensureWorkspaceServicePortPlan({ + workspaceId: "registry-after-failed-plan-workspace", + services: [{ scriptName: "api" }], + allocatePort: async () => 5400, + }); + expect(recoveredPlan.get("api")).toBe(5400); + }); + it("returns defensive snapshots", async () => { const first = await ensureWorkspaceServicePortPlan({ workspaceId: "registry-defensive-snapshot-workspace", diff --git a/packages/server/src/server/workspace-service-port-registry.ts b/packages/server/src/server/workspace-service-port-registry.ts index 44877980a..be41a8d12 100644 --- a/packages/server/src/server/workspace-service-port-registry.ts +++ b/packages/server/src/server/workspace-service-port-registry.ts @@ -3,20 +3,36 @@ interface WorkspaceServicePortDeclaration { port?: number; } +interface WorkspaceServicePortAllocationRequest { + scriptName: string; + reservedPorts: ReadonlySet; +} + interface EnsureWorkspaceServicePortPlanOptions { workspaceId: string; services: readonly WorkspaceServicePortDeclaration[]; - allocatePort: () => Promise; + allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise; } interface RefreshWorkspaceServicePortOptions { workspaceId: string; service: WorkspaceServicePortDeclaration; - allocatePort: () => Promise; + allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise; +} + +interface PendingWorkspaceServicePortPlanToken { + isReleased: boolean; } const workspaceServicePortPlans = new Map>(); const pendingWorkspaceServicePortPlans = new Map>>(); +const pendingWorkspaceServicePortPlanTokens = new Map< + string, + PendingWorkspaceServicePortPlanToken +>(); +const dynamicPortOwners = new Map(); +const dynamicPortsByWorkspace = new Map>(); +const MAX_DYNAMIC_PORT_ALLOCATION_ATTEMPTS = 10; export async function ensureWorkspaceServicePortPlan( options: EnsureWorkspaceServicePortPlanOptions, @@ -28,12 +44,15 @@ export async function ensureWorkspaceServicePortPlan( let pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId); if (!pendingPlan) { + const token: PendingWorkspaceServicePortPlanToken = { isReleased: false }; pendingPlan = createPendingWorkspaceServicePortPlan({ workspaceId: options.workspaceId, services: options.services, allocatePort: options.allocatePort, + token, }); pendingWorkspaceServicePortPlans.set(options.workspaceId, pendingPlan); + pendingWorkspaceServicePortPlanTokens.set(options.workspaceId, token); } return new Map(await pendingPlan); @@ -50,30 +69,77 @@ export function requirePlannedWorkspaceServicePort( return port; } +export function releaseWorkspaceServicePortPlan(workspaceId: string): void { + const pendingToken = pendingWorkspaceServicePortPlanTokens.get(workspaceId); + if (pendingToken) pendingToken.isReleased = true; + workspaceServicePortPlans.delete(workspaceId); + const dynamicPorts = dynamicPortsByWorkspace.get(workspaceId); + if (!dynamicPorts) return; + + for (const [scriptName, port] of dynamicPorts) { + releaseDynamicPort({ workspaceId, scriptName, port }); + } + dynamicPortsByWorkspace.delete(workspaceId); +} + async function createPendingWorkspaceServicePortPlan(options: { workspaceId: string; services: readonly WorkspaceServicePortDeclaration[]; - allocatePort: () => Promise; + allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise; + token: PendingWorkspaceServicePortPlanToken; }): Promise> { try { const plan = await buildWorkspaceServicePortPlan({ + workspaceId: options.workspaceId, services: options.services, allocatePort: options.allocatePort, }); + if (options.token.isReleased) { + throw new Error( + `Workspace service port plan was released while being created for '${options.workspaceId}'`, + ); + } workspaceServicePortPlans.set(options.workspaceId, plan); return plan; + } catch (error) { + releaseWorkspaceServicePortPlan(options.workspaceId); + throw error; } finally { pendingWorkspaceServicePortPlans.delete(options.workspaceId); + pendingWorkspaceServicePortPlanTokens.delete(options.workspaceId); } } async function buildWorkspaceServicePortPlan(options: { + workspaceId: string; services: readonly WorkspaceServicePortDeclaration[]; - allocatePort: () => Promise; + allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise; }): Promise> { + const explicitPortOwners = new Map(); + for (const service of options.services) { + if (service.port === undefined) continue; + if (explicitPortOwners.has(service.port)) { + throw new Error(`Service '${service.scriptName}' has a duplicate port ${service.port}`); + } + explicitPortOwners.set(service.port, service.scriptName); + } + const plan = new Map(); for (const service of options.services) { - plan.set(service.scriptName, await resolveServicePort(service, options.allocatePort)); + if (service.port !== undefined) { + plan.set(service.scriptName, service.port); + continue; + } + const reservedPorts = new Set([...explicitPortOwners.keys(), ...plan.values()]); + plan.set( + service.scriptName, + await resolveServicePort({ + service, + workspaceId: options.workspaceId, + allocatePort: options.allocatePort, + reservedPorts, + }), + ); } return plan; @@ -84,19 +150,96 @@ export async function refreshWorkspaceServicePort( ): Promise { const plan = workspaceServicePortPlans.get(options.workspaceId) ?? new Map(); - const port = await resolveServicePort(options.service, options.allocatePort); + const reservedPorts = new Set(plan.values()); + const previousPort = plan.get(options.service.scriptName); + if (previousPort !== undefined) { + reservedPorts.delete(previousPort); + } + const oldDynamicPort = dynamicPortsByWorkspace + .get(options.workspaceId) + ?.get(options.service.scriptName); + const port = await resolveServicePort({ + service: options.service, + workspaceId: options.workspaceId, + allocatePort: options.allocatePort, + reservedPorts, + }); + if (oldDynamicPort !== undefined && oldDynamicPort !== port) { + releaseDynamicPort({ + workspaceId: options.workspaceId, + scriptName: options.service.scriptName, + port: oldDynamicPort, + }); + } plan.set(options.service.scriptName, port); workspaceServicePortPlans.set(options.workspaceId, plan); return port; } -async function resolveServicePort( - service: WorkspaceServicePortDeclaration, - allocatePort: () => Promise, -): Promise { +async function resolveServicePort(options: { + service: WorkspaceServicePortDeclaration; + workspaceId: string; + allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise; + reservedPorts: ReadonlySet; +}): Promise { + const { service, workspaceId, allocatePort, reservedPorts } = options; if (service.port !== undefined) { + if (reservedPorts.has(service.port)) { + throw new Error(`Service '${service.scriptName}' has a duplicate port ${service.port}`); + } return service.port; } - return await allocatePort(); + for (let attempt = 0; attempt < MAX_DYNAMIC_PORT_ALLOCATION_ATTEMPTS; attempt += 1) { + const unavailablePorts = new Set(reservedPorts); + const serviceOwner = toServiceOwner(workspaceId, service.scriptName); + for (const [port, owner] of dynamicPortOwners) { + if (owner !== serviceOwner) unavailablePorts.add(port); + } + const port = await allocatePort({ + scriptName: service.scriptName, + reservedPorts: unavailablePorts, + }); + if (reservedPorts.has(port)) continue; + const owner = dynamicPortOwners.get(port); + if (owner !== undefined && owner !== serviceOwner) continue; + reserveDynamicPort({ workspaceId, scriptName: service.scriptName, port }); + return port; + } + throw new Error( + `Could not allocate a unique port for service '${service.scriptName}' after ${MAX_DYNAMIC_PORT_ALLOCATION_ATTEMPTS} attempts`, + ); +} + +function reserveDynamicPort(options: { + workspaceId: string; + scriptName: string; + port: number; +}): void { + dynamicPortOwners.set(options.port, toServiceOwner(options.workspaceId, options.scriptName)); + const workspacePorts = dynamicPortsByWorkspace.get(options.workspaceId) ?? new Map(); + workspacePorts.set(options.scriptName, options.port); + dynamicPortsByWorkspace.set(options.workspaceId, workspacePorts); +} + +function releaseDynamicPort(options: { + workspaceId: string; + scriptName: string; + port: number; +}): void { + const owner = toServiceOwner(options.workspaceId, options.scriptName); + if (dynamicPortOwners.get(options.port) === owner) { + dynamicPortOwners.delete(options.port); + } + const workspacePorts = dynamicPortsByWorkspace.get(options.workspaceId); + if (workspacePorts?.get(options.scriptName) === options.port) { + workspacePorts.delete(options.scriptName); + if (workspacePorts.size === 0) { + dynamicPortsByWorkspace.delete(options.workspaceId); + } + } +} + +function toServiceOwner(workspaceId: string, scriptName: string): string { + return `${workspaceId}\0${scriptName}`; } diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 895ab2337..75785057e 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -16,7 +16,8 @@ import { type WorktreeSetupCommandResult, type WorktreeRuntimeEnv, } from "../utils/worktree.js"; -import { findFreePort, type ServiceProxySubsystem } from "./service-proxy.js"; +import type { ServiceProxySubsystem } from "./service-proxy.js"; +import { allocateWorkspaceServicePort } from "./workspace-service-port-allocator.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js"; import { @@ -29,6 +30,7 @@ import { requirePlannedWorkspaceServicePort, refreshWorkspaceServicePort, } from "./workspace-service-port-registry.js"; +import type { PaseoServicePortAllocation } from "@getpaseo/protocol/paseo-config-schema"; export interface WorktreeBootstrapTerminalResult { name: string | null; @@ -709,6 +711,7 @@ export interface SpawnWorkspaceScriptOptions { serviceProxy: ServiceProxySubsystem; runtimeStore: WorkspaceScriptRuntimeStore; terminalManager: TerminalManager; + globalServicePorts?: PaseoServicePortAllocation; logger?: Logger; onLifecycleChanged?: () => void; } @@ -720,6 +723,7 @@ interface ServiceScriptSetupResult { } async function setupServiceScriptRoute(params: { + repoRoot: string; scriptConfigs: ReturnType; config: { port?: number }; scriptName: string; @@ -731,9 +735,11 @@ async function setupServiceScriptRoute(params: { serviceProxyPublicBaseUrl: string | null | undefined; existingRuntimeEntry: ReturnType; serviceProxy: ServiceProxySubsystem; + servicePortAllocation: PaseoServicePortAllocation | undefined; }): Promise { const { scriptConfigs, + repoRoot, config, scriptName, projectSlug, @@ -744,6 +750,7 @@ async function setupServiceScriptRoute(params: { serviceProxyPublicBaseUrl, existingRuntimeEntry, serviceProxy, + servicePortAllocation, } = params; const serviceDeclarations: Array<{ scriptName: string; port?: number }> = []; @@ -762,14 +769,30 @@ async function setupServiceScriptRoute(params: { const plannedPorts = await ensureWorkspaceServicePortPlan({ workspaceId, services: serviceDeclarations, - allocatePort: findFreePort, + allocatePort: ({ scriptName: serviceScriptName, reservedPorts }) => + allocateWorkspaceServicePort({ + allocation: servicePortAllocation, + cwd: repoRoot, + scriptName: serviceScriptName, + workspaceId, + branchName, + reservedPorts, + }), }); const port = existingRuntimeEntry?.lifecycle === "stopped" ? await refreshWorkspaceServicePort({ workspaceId, service: { scriptName, port: config.port }, - allocatePort: findFreePort, + allocatePort: ({ scriptName: serviceScriptName, reservedPorts }) => + allocateWorkspaceServicePort({ + allocation: servicePortAllocation, + cwd: repoRoot, + scriptName: serviceScriptName, + workspaceId, + branchName, + reservedPorts, + }), }) : requirePlannedWorkspaceServicePort(plannedPorts, scriptName); @@ -851,6 +874,7 @@ export async function spawnWorkspaceScript( serviceProxy, runtimeStore, terminalManager, + globalServicePorts, logger, onLifecycleChanged, } = options; @@ -881,6 +905,7 @@ export async function spawnWorkspaceScript( let env: Record | undefined; if (serviceScript) { const serviceSetup = await setupServiceScriptRoute({ + repoRoot, scriptConfigs, config, scriptName, @@ -892,6 +917,7 @@ export async function spawnWorkspaceScript( serviceProxyPublicBaseUrl, existingRuntimeEntry, serviceProxy, + servicePortAllocation: configResult.config?.worktree?.servicePorts ?? globalServicePorts, }); hostname = serviceSetup.hostname; port = serviceSetup.port; diff --git a/public-docs/worktrees.md b/public-docs/worktrees.md index e413202b5..23a9bc828 100644 --- a/public-docs/worktrees.md +++ b/public-docs/worktrees.md @@ -146,6 +146,49 @@ Commands run with the worktree as `cwd`. Use `$PASEO_SOURCE_CHECKOUT_PATH` to re Omit `port` to let Paseo auto-assign one. Bind your process to `$PASEO_PORT` rather than hard-coding, each worktree gets a distinct port so multiple copies of the same service coexist. +### Dynamic port allocation + +By default, Paseo asks the OS for an available ephemeral port. Configure a range globally in +`~/.paseo/config.json` or per project in `paseo.json`: + +```json +// ~/.paseo/config.json +{ + "worktrees": { + "servicePorts": { "range": "3000-4000" } + } +} +``` + +```json +// paseo.json +{ + "worktree": { + "servicePorts": { "range": "3000-4000" } + } +} +``` + +The range is inclusive. A project `servicePorts` block replaces the global block. An explicit +service `port` always wins over either setting. + +For an external allocator, configure `portScript` instead: + +```json +{ + "worktree": { + "servicePorts": { "portScript": "/usr/bin/portmake" } + } +} +``` + +Paseo runs the executable in the workspace directory with four arguments: service name, workspace +ID, branch name, and worktree path. Since the script is executed directly without a shell, `portScript` must point to a real executable (such as a compiled binary or a script with a proper shebang line like `#!/bin/bash`) rather than an inline shell command or pipeline. If you need shell evaluation or pipelines, wrap them in a small executable script. A missing branch is passed as an empty string. The same values +are available as `PASEO_SCRIPTNAME`, `PASEO_WORKSPACE_ID`, `PASEO_BRANCH_NAME`, and +`PASEO_WORKTREE_PATH`. It must print one valid TCP port to stdout. `portScript` wins over `range` in +the same block. Paseo trusts the external allocator, so the returned port may already be in use, for +example by a service Paseo will attach to. + ### Reverse proxy Every service is reachable through the daemon at a deterministic hostname: