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
This commit is contained in:
Matt Cowger
2026-07-20 11:50:01 -07:00
committed by GitHub
parent 2ead7e7719
commit 9292f58896
14 changed files with 738 additions and 15 deletions

View File

@@ -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: {

View File

@@ -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.

View File

@@ -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({

View File

@@ -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<typeof PaseoScriptEntryRawSchema>;
export type PaseoMetadataGenerationEntry = z.infer<typeof PaseoMetadataGenerationEntrySchema>;
export type PaseoMetadataGeneration = z.infer<typeof PaseoMetadataGenerationSchema>;
export type PaseoServicePortAllocation = z.infer<typeof PaseoServicePortAllocationSchema>;
export type PaseoConfigRaw = z.infer<typeof PaseoConfigRawSchema>;
export type PaseoConfig = z.infer<typeof PaseoConfigSchema>;
export type PaseoConfigRevision = z.infer<typeof PaseoConfigRevisionSchema>;

View File

@@ -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", () => {

View File

@@ -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();

View File

@@ -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<void> {
this.workspaceGitObserver.removeForWorkspaceId(workspaceId);
this.scriptRuntimeStore?.removeForWorkspace(workspaceId);
releaseWorkspaceServicePortPlan(workspaceId);
}
private async reconcileAndEmitWorkspaceUpdates(): Promise<void> {

View File

@@ -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<WorktreeScriptResult>;
@@ -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);

View File

@@ -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<nul set /p "=%CD%" > cwd\r\n<nul set /p "=%~1|%~2|%~3|%~4" > argv\r\n<nul set /p "=%PASEO_SCRIPTNAME%|%PASEO_WORKSPACE_ID%|%PASEO_BRANCH_NAME%|%PASEO_WORKTREE_PATH%" > 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<number> {
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<number> {
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<void> {
return new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}

View File

@@ -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<number>;
}
export async function allocateWorkspaceServicePort(
options: AllocateWorkspaceServicePortOptions,
): Promise<number> {
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<number> | undefined;
}): Promise<number> {
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<number>,
): Promise<number> {
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<boolean> {
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));
});
});
}

View File

@@ -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",

View File

@@ -3,20 +3,36 @@ interface WorkspaceServicePortDeclaration {
port?: number;
}
interface WorkspaceServicePortAllocationRequest {
scriptName: string;
reservedPorts: ReadonlySet<number>;
}
interface EnsureWorkspaceServicePortPlanOptions {
workspaceId: string;
services: readonly WorkspaceServicePortDeclaration[];
allocatePort: () => Promise<number>;
allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>;
}
interface RefreshWorkspaceServicePortOptions {
workspaceId: string;
service: WorkspaceServicePortDeclaration;
allocatePort: () => Promise<number>;
allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>;
}
interface PendingWorkspaceServicePortPlanToken {
isReleased: boolean;
}
const workspaceServicePortPlans = new Map<string, Map<string, number>>();
const pendingWorkspaceServicePortPlans = new Map<string, Promise<Map<string, number>>>();
const pendingWorkspaceServicePortPlanTokens = new Map<
string,
PendingWorkspaceServicePortPlanToken
>();
const dynamicPortOwners = new Map<number, string>();
const dynamicPortsByWorkspace = new Map<string, Map<string, number>>();
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<number>;
allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>;
token: PendingWorkspaceServicePortPlanToken;
}): Promise<Map<string, number>> {
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<number>;
allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>;
}): Promise<Map<string, number>> {
const explicitPortOwners = new Map<number, string>();
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<string, number>();
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<number> {
const plan = workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>();
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<number>,
): Promise<number> {
async function resolveServicePort(options: {
service: WorkspaceServicePortDeclaration;
workspaceId: string;
allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>;
reservedPorts: ReadonlySet<number>;
}): Promise<number> {
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}`;
}

View File

@@ -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<typeof getScriptConfigs>;
config: { port?: number };
scriptName: string;
@@ -731,9 +735,11 @@ async function setupServiceScriptRoute(params: {
serviceProxyPublicBaseUrl: string | null | undefined;
existingRuntimeEntry: ReturnType<WorkspaceScriptRuntimeStore["get"]>;
serviceProxy: ServiceProxySubsystem;
servicePortAllocation: PaseoServicePortAllocation | undefined;
}): Promise<ServiceScriptSetupResult> {
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<string, string> | 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;

View File

@@ -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: