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:
@@ -190,6 +190,10 @@ Single file, validated with `PersistedConfigSchema`.
|
|||||||
},
|
},
|
||||||
worktrees?: {
|
worktrees?: {
|
||||||
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/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: {
|
providers: {
|
||||||
openai: {
|
openai: {
|
||||||
|
|||||||
@@ -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
|
## 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.
|
> 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.
|
||||||
|
|||||||
@@ -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", () => {
|
it("normalizes partial worktree lifecycle config without dropping present commands", () => {
|
||||||
expect(
|
expect(
|
||||||
PaseoConfigSchema.parse({
|
PaseoConfigSchema.parse({
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
import { z } from "zod";
|
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[] {
|
export function normalizeLifecycleCommands(commands: unknown): string[] {
|
||||||
if (typeof commands === "string") {
|
if (typeof commands === "string") {
|
||||||
return commands.trim().length > 0 ? [commands] : [];
|
return commands.trim().length > 0 ? [commands] : [];
|
||||||
@@ -27,6 +48,7 @@ export const PaseoWorktreeConfigRawSchema = z
|
|||||||
setup: PaseoLifecycleCommandRawSchema.optional(),
|
setup: PaseoLifecycleCommandRawSchema.optional(),
|
||||||
teardown: PaseoLifecycleCommandRawSchema.optional(),
|
teardown: PaseoLifecycleCommandRawSchema.optional(),
|
||||||
terminals: z.unknown().optional(),
|
terminals: z.unknown().optional(),
|
||||||
|
servicePorts: PaseoServicePortAllocationSchema.optional(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|
||||||
@@ -92,6 +114,7 @@ export const ProjectConfigRpcErrorSchema = z.discriminatedUnion("code", [
|
|||||||
export type PaseoScriptEntryRaw = z.infer<typeof PaseoScriptEntryRawSchema>;
|
export type PaseoScriptEntryRaw = z.infer<typeof PaseoScriptEntryRawSchema>;
|
||||||
export type PaseoMetadataGenerationEntry = z.infer<typeof PaseoMetadataGenerationEntrySchema>;
|
export type PaseoMetadataGenerationEntry = z.infer<typeof PaseoMetadataGenerationEntrySchema>;
|
||||||
export type PaseoMetadataGeneration = z.infer<typeof PaseoMetadataGenerationSchema>;
|
export type PaseoMetadataGeneration = z.infer<typeof PaseoMetadataGenerationSchema>;
|
||||||
|
export type PaseoServicePortAllocation = z.infer<typeof PaseoServicePortAllocationSchema>;
|
||||||
export type PaseoConfigRaw = z.infer<typeof PaseoConfigRawSchema>;
|
export type PaseoConfigRaw = z.infer<typeof PaseoConfigRawSchema>;
|
||||||
export type PaseoConfig = z.infer<typeof PaseoConfigSchema>;
|
export type PaseoConfig = z.infer<typeof PaseoConfigSchema>;
|
||||||
export type PaseoConfigRevision = z.infer<typeof PaseoConfigRevisionSchema>;
|
export type PaseoConfigRevision = z.infer<typeof PaseoConfigRevisionSchema>;
|
||||||
|
|||||||
@@ -125,6 +125,16 @@ describe("PersistedConfigSchema worktrees config", () => {
|
|||||||
|
|
||||||
expect(parsed.worktrees?.root).toBe("/mnt/fast/paseo-worktrees");
|
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", () => {
|
describe("PersistedConfigSchema provider credentials", () => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
||||||
import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js";
|
import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js";
|
||||||
import { TerminalProfileSchema } from "@getpaseo/protocol/messages";
|
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 LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
|
||||||
export const LogFormatSchema = z.enum(["pretty", "json"]);
|
export const LogFormatSchema = z.enum(["pretty", "json"]);
|
||||||
@@ -77,6 +78,7 @@ const ProvidersSchema = z
|
|||||||
const WorktreesConfigSchema = z
|
const WorktreesConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
root: z.string().min(1).optional(),
|
root: z.string().min(1).optional(),
|
||||||
|
servicePorts: PaseoServicePortAllocationSchema.optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ import {
|
|||||||
type WorkspaceScriptsService,
|
type WorkspaceScriptsService,
|
||||||
} from "./session/workspace-scripts/workspace-scripts-service.js";
|
} from "./session/workspace-scripts/workspace-scripts-service.js";
|
||||||
import type { DaemonConfigStore } from "./daemon-config-store.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 { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
|
||||||
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
|
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
|
||||||
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
|
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
|
||||||
@@ -935,6 +937,7 @@ export class Session {
|
|||||||
logger: this.sessionLogger,
|
logger: this.sessionLogger,
|
||||||
emit: (message) => this.emit(message),
|
emit: (message) => this.emit(message),
|
||||||
spawnWorkspaceScript,
|
spawnWorkspaceScript,
|
||||||
|
globalServicePorts: loadPersistedConfig(this.paseoHome).worktrees?.servicePorts,
|
||||||
});
|
});
|
||||||
this.subscribeToOptionalManagers();
|
this.subscribeToOptionalManagers();
|
||||||
this.workspaceDirectory = new WorkspaceDirectory({
|
this.workspaceDirectory = new WorkspaceDirectory({
|
||||||
@@ -4394,6 +4397,7 @@ export class Session {
|
|||||||
private async teardownArchivedWorkspace(workspaceId: string): Promise<void> {
|
private async teardownArchivedWorkspace(workspaceId: string): Promise<void> {
|
||||||
this.workspaceGitObserver.removeForWorkspaceId(workspaceId);
|
this.workspaceGitObserver.removeForWorkspaceId(workspaceId);
|
||||||
this.scriptRuntimeStore?.removeForWorkspace(workspaceId);
|
this.scriptRuntimeStore?.removeForWorkspace(workspaceId);
|
||||||
|
releaseWorkspaceServicePortPlan(workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async reconcileAndEmitWorkspaceUpdates(): Promise<void> {
|
private async reconcileAndEmitWorkspaceUpdates(): Promise<void> {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
readPaseoConfigForProjection,
|
readPaseoConfigForProjection,
|
||||||
} from "../../script-status-projection.js";
|
} from "../../script-status-projection.js";
|
||||||
import { deriveProjectServiceSlug, deriveProjectSlug } from "../../workspace-git-metadata.js";
|
import { deriveProjectServiceSlug, deriveProjectSlug } from "../../workspace-git-metadata.js";
|
||||||
|
import type { PaseoServicePortAllocation } from "@getpaseo/protocol/paseo-config-schema";
|
||||||
|
|
||||||
type WorkspaceScriptsPayload = WorkspaceDescriptorPayload["scripts"];
|
type WorkspaceScriptsPayload = WorkspaceDescriptorPayload["scripts"];
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ export function createWorkspaceScriptsService(deps: {
|
|||||||
getDaemonTcpHost: (() => string | null) | null;
|
getDaemonTcpHost: (() => string | null) | null;
|
||||||
serviceProxyPublicBaseUrl: string | null;
|
serviceProxyPublicBaseUrl: string | null;
|
||||||
resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | null;
|
resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | null;
|
||||||
|
globalServicePorts?: PaseoServicePortAllocation;
|
||||||
logger: pino.Logger;
|
logger: pino.Logger;
|
||||||
emit: (message: SessionOutboundMessage) => void;
|
emit: (message: SessionOutboundMessage) => void;
|
||||||
spawnWorkspaceScript: (options: SpawnWorkspaceScriptOptions) => Promise<WorktreeScriptResult>;
|
spawnWorkspaceScript: (options: SpawnWorkspaceScriptOptions) => Promise<WorktreeScriptResult>;
|
||||||
@@ -73,6 +75,7 @@ export function createWorkspaceScriptsService(deps: {
|
|||||||
getDaemonTcpHost,
|
getDaemonTcpHost,
|
||||||
serviceProxyPublicBaseUrl,
|
serviceProxyPublicBaseUrl,
|
||||||
resolveScriptHealth,
|
resolveScriptHealth,
|
||||||
|
globalServicePorts,
|
||||||
logger,
|
logger,
|
||||||
emit,
|
emit,
|
||||||
spawnWorkspaceScript,
|
spawnWorkspaceScript,
|
||||||
@@ -168,6 +171,7 @@ export function createWorkspaceScriptsService(deps: {
|
|||||||
serviceProxy,
|
serviceProxy,
|
||||||
runtimeStore: scriptRuntimeStore,
|
runtimeStore: scriptRuntimeStore,
|
||||||
terminalManager,
|
terminalManager,
|
||||||
|
globalServicePorts,
|
||||||
logger,
|
logger,
|
||||||
onLifecycleChanged: () => {
|
onLifecycleChanged: () => {
|
||||||
void emitStatusUpdate(workspace.workspaceId, workspace.cwd);
|
void emitStatusUpdate(workspace.workspaceId, workspace.cwd);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,18 +3,21 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
ensureWorkspaceServicePortPlan,
|
ensureWorkspaceServicePortPlan,
|
||||||
refreshWorkspaceServicePort,
|
refreshWorkspaceServicePort,
|
||||||
|
releaseWorkspaceServicePortPlan,
|
||||||
} from "./workspace-service-port-registry.js";
|
} from "./workspace-service-port-registry.js";
|
||||||
|
|
||||||
describe("ensureWorkspaceServicePortPlan", () => {
|
describe("ensureWorkspaceServicePortPlan", () => {
|
||||||
it("allocates ports for all declared services in declaration order", async () => {
|
it("allocates ports for all declared services in declaration order", async () => {
|
||||||
let nextPort = 4100;
|
let nextPort = 4100;
|
||||||
let allocationCount = 0;
|
let allocationCount = 0;
|
||||||
|
const allocatedServices: string[] = [];
|
||||||
|
|
||||||
const plan = await ensureWorkspaceServicePortPlan({
|
const plan = await ensureWorkspaceServicePortPlan({
|
||||||
workspaceId: "registry-order-workspace",
|
workspaceId: "registry-order-workspace",
|
||||||
services: [{ scriptName: "api" }, { scriptName: "web" }, { scriptName: "worker" }],
|
services: [{ scriptName: "api" }, { scriptName: "web" }, { scriptName: "worker" }],
|
||||||
allocatePort: async () => {
|
allocatePort: async ({ scriptName }) => {
|
||||||
allocationCount += 1;
|
allocationCount += 1;
|
||||||
|
allocatedServices.push(scriptName);
|
||||||
const port = nextPort;
|
const port = nextPort;
|
||||||
nextPort += 1;
|
nextPort += 1;
|
||||||
return port;
|
return port;
|
||||||
@@ -27,6 +30,7 @@ describe("ensureWorkspaceServicePortPlan", () => {
|
|||||||
["worker", 4102],
|
["worker", 4102],
|
||||||
]);
|
]);
|
||||||
expect(allocationCount).toBe(3);
|
expect(allocationCount).toBe(3);
|
||||||
|
expect(allocatedServices).toEqual(["api", "web", "worker"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns the existing plan without calling the allocator on later calls", async () => {
|
it("returns the existing plan without calling the allocator on later calls", async () => {
|
||||||
@@ -119,6 +123,51 @@ describe("ensureWorkspaceServicePortPlan", () => {
|
|||||||
expect(allocationCount).toBe(0);
|
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 () => {
|
it("keeps workspace plans independent", async () => {
|
||||||
const firstPlan = await ensureWorkspaceServicePortPlan({
|
const firstPlan = await ensureWorkspaceServicePortPlan({
|
||||||
workspaceId: "registry-independent-workspace-a",
|
workspaceId: "registry-independent-workspace-a",
|
||||||
@@ -136,6 +185,87 @@ describe("ensureWorkspaceServicePortPlan", () => {
|
|||||||
expect(secondPlan.get("api")).toBe(4600);
|
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 () => {
|
it("returns defensive snapshots", async () => {
|
||||||
const first = await ensureWorkspaceServicePortPlan({
|
const first = await ensureWorkspaceServicePortPlan({
|
||||||
workspaceId: "registry-defensive-snapshot-workspace",
|
workspaceId: "registry-defensive-snapshot-workspace",
|
||||||
|
|||||||
@@ -3,20 +3,36 @@ interface WorkspaceServicePortDeclaration {
|
|||||||
port?: number;
|
port?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WorkspaceServicePortAllocationRequest {
|
||||||
|
scriptName: string;
|
||||||
|
reservedPorts: ReadonlySet<number>;
|
||||||
|
}
|
||||||
|
|
||||||
interface EnsureWorkspaceServicePortPlanOptions {
|
interface EnsureWorkspaceServicePortPlanOptions {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
services: readonly WorkspaceServicePortDeclaration[];
|
services: readonly WorkspaceServicePortDeclaration[];
|
||||||
allocatePort: () => Promise<number>;
|
allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RefreshWorkspaceServicePortOptions {
|
interface RefreshWorkspaceServicePortOptions {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
service: WorkspaceServicePortDeclaration;
|
service: WorkspaceServicePortDeclaration;
|
||||||
allocatePort: () => Promise<number>;
|
allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingWorkspaceServicePortPlanToken {
|
||||||
|
isReleased: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceServicePortPlans = new Map<string, Map<string, number>>();
|
const workspaceServicePortPlans = new Map<string, Map<string, number>>();
|
||||||
const pendingWorkspaceServicePortPlans = new Map<string, Promise<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(
|
export async function ensureWorkspaceServicePortPlan(
|
||||||
options: EnsureWorkspaceServicePortPlanOptions,
|
options: EnsureWorkspaceServicePortPlanOptions,
|
||||||
@@ -28,12 +44,15 @@ export async function ensureWorkspaceServicePortPlan(
|
|||||||
|
|
||||||
let pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId);
|
let pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId);
|
||||||
if (!pendingPlan) {
|
if (!pendingPlan) {
|
||||||
|
const token: PendingWorkspaceServicePortPlanToken = { isReleased: false };
|
||||||
pendingPlan = createPendingWorkspaceServicePortPlan({
|
pendingPlan = createPendingWorkspaceServicePortPlan({
|
||||||
workspaceId: options.workspaceId,
|
workspaceId: options.workspaceId,
|
||||||
services: options.services,
|
services: options.services,
|
||||||
allocatePort: options.allocatePort,
|
allocatePort: options.allocatePort,
|
||||||
|
token,
|
||||||
});
|
});
|
||||||
pendingWorkspaceServicePortPlans.set(options.workspaceId, pendingPlan);
|
pendingWorkspaceServicePortPlans.set(options.workspaceId, pendingPlan);
|
||||||
|
pendingWorkspaceServicePortPlanTokens.set(options.workspaceId, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Map(await pendingPlan);
|
return new Map(await pendingPlan);
|
||||||
@@ -50,30 +69,77 @@ export function requirePlannedWorkspaceServicePort(
|
|||||||
return port;
|
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: {
|
async function createPendingWorkspaceServicePortPlan(options: {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
services: readonly WorkspaceServicePortDeclaration[];
|
services: readonly WorkspaceServicePortDeclaration[];
|
||||||
allocatePort: () => Promise<number>;
|
allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>;
|
||||||
|
token: PendingWorkspaceServicePortPlanToken;
|
||||||
}): Promise<Map<string, number>> {
|
}): Promise<Map<string, number>> {
|
||||||
try {
|
try {
|
||||||
const plan = await buildWorkspaceServicePortPlan({
|
const plan = await buildWorkspaceServicePortPlan({
|
||||||
|
workspaceId: options.workspaceId,
|
||||||
services: options.services,
|
services: options.services,
|
||||||
allocatePort: options.allocatePort,
|
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);
|
workspaceServicePortPlans.set(options.workspaceId, plan);
|
||||||
return plan;
|
return plan;
|
||||||
|
} catch (error) {
|
||||||
|
releaseWorkspaceServicePortPlan(options.workspaceId);
|
||||||
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
pendingWorkspaceServicePortPlans.delete(options.workspaceId);
|
pendingWorkspaceServicePortPlans.delete(options.workspaceId);
|
||||||
|
pendingWorkspaceServicePortPlanTokens.delete(options.workspaceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildWorkspaceServicePortPlan(options: {
|
async function buildWorkspaceServicePortPlan(options: {
|
||||||
|
workspaceId: string;
|
||||||
services: readonly WorkspaceServicePortDeclaration[];
|
services: readonly WorkspaceServicePortDeclaration[];
|
||||||
allocatePort: () => Promise<number>;
|
allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>;
|
||||||
}): Promise<Map<string, 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>();
|
const plan = new Map<string, number>();
|
||||||
for (const service of options.services) {
|
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;
|
return plan;
|
||||||
@@ -84,19 +150,96 @@ export async function refreshWorkspaceServicePort(
|
|||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const plan = workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, 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);
|
plan.set(options.service.scriptName, port);
|
||||||
workspaceServicePortPlans.set(options.workspaceId, plan);
|
workspaceServicePortPlans.set(options.workspaceId, plan);
|
||||||
return port;
|
return port;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveServicePort(
|
async function resolveServicePort(options: {
|
||||||
service: WorkspaceServicePortDeclaration,
|
service: WorkspaceServicePortDeclaration;
|
||||||
allocatePort: () => Promise<number>,
|
workspaceId: string;
|
||||||
): Promise<number> {
|
allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>;
|
||||||
|
reservedPorts: ReadonlySet<number>;
|
||||||
|
}): Promise<number> {
|
||||||
|
const { service, workspaceId, allocatePort, reservedPorts } = options;
|
||||||
if (service.port !== undefined) {
|
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 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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
type WorktreeSetupCommandResult,
|
type WorktreeSetupCommandResult,
|
||||||
type WorktreeRuntimeEnv,
|
type WorktreeRuntimeEnv,
|
||||||
} from "../utils/worktree.js";
|
} 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 { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||||
import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js";
|
import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js";
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
requirePlannedWorkspaceServicePort,
|
requirePlannedWorkspaceServicePort,
|
||||||
refreshWorkspaceServicePort,
|
refreshWorkspaceServicePort,
|
||||||
} from "./workspace-service-port-registry.js";
|
} from "./workspace-service-port-registry.js";
|
||||||
|
import type { PaseoServicePortAllocation } from "@getpaseo/protocol/paseo-config-schema";
|
||||||
|
|
||||||
export interface WorktreeBootstrapTerminalResult {
|
export interface WorktreeBootstrapTerminalResult {
|
||||||
name: string | null;
|
name: string | null;
|
||||||
@@ -709,6 +711,7 @@ export interface SpawnWorkspaceScriptOptions {
|
|||||||
serviceProxy: ServiceProxySubsystem;
|
serviceProxy: ServiceProxySubsystem;
|
||||||
runtimeStore: WorkspaceScriptRuntimeStore;
|
runtimeStore: WorkspaceScriptRuntimeStore;
|
||||||
terminalManager: TerminalManager;
|
terminalManager: TerminalManager;
|
||||||
|
globalServicePorts?: PaseoServicePortAllocation;
|
||||||
logger?: Logger;
|
logger?: Logger;
|
||||||
onLifecycleChanged?: () => void;
|
onLifecycleChanged?: () => void;
|
||||||
}
|
}
|
||||||
@@ -720,6 +723,7 @@ interface ServiceScriptSetupResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function setupServiceScriptRoute(params: {
|
async function setupServiceScriptRoute(params: {
|
||||||
|
repoRoot: string;
|
||||||
scriptConfigs: ReturnType<typeof getScriptConfigs>;
|
scriptConfigs: ReturnType<typeof getScriptConfigs>;
|
||||||
config: { port?: number };
|
config: { port?: number };
|
||||||
scriptName: string;
|
scriptName: string;
|
||||||
@@ -731,9 +735,11 @@ async function setupServiceScriptRoute(params: {
|
|||||||
serviceProxyPublicBaseUrl: string | null | undefined;
|
serviceProxyPublicBaseUrl: string | null | undefined;
|
||||||
existingRuntimeEntry: ReturnType<WorkspaceScriptRuntimeStore["get"]>;
|
existingRuntimeEntry: ReturnType<WorkspaceScriptRuntimeStore["get"]>;
|
||||||
serviceProxy: ServiceProxySubsystem;
|
serviceProxy: ServiceProxySubsystem;
|
||||||
|
servicePortAllocation: PaseoServicePortAllocation | undefined;
|
||||||
}): Promise<ServiceScriptSetupResult> {
|
}): Promise<ServiceScriptSetupResult> {
|
||||||
const {
|
const {
|
||||||
scriptConfigs,
|
scriptConfigs,
|
||||||
|
repoRoot,
|
||||||
config,
|
config,
|
||||||
scriptName,
|
scriptName,
|
||||||
projectSlug,
|
projectSlug,
|
||||||
@@ -744,6 +750,7 @@ async function setupServiceScriptRoute(params: {
|
|||||||
serviceProxyPublicBaseUrl,
|
serviceProxyPublicBaseUrl,
|
||||||
existingRuntimeEntry,
|
existingRuntimeEntry,
|
||||||
serviceProxy,
|
serviceProxy,
|
||||||
|
servicePortAllocation,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const serviceDeclarations: Array<{ scriptName: string; port?: number }> = [];
|
const serviceDeclarations: Array<{ scriptName: string; port?: number }> = [];
|
||||||
@@ -762,14 +769,30 @@ async function setupServiceScriptRoute(params: {
|
|||||||
const plannedPorts = await ensureWorkspaceServicePortPlan({
|
const plannedPorts = await ensureWorkspaceServicePortPlan({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
services: serviceDeclarations,
|
services: serviceDeclarations,
|
||||||
allocatePort: findFreePort,
|
allocatePort: ({ scriptName: serviceScriptName, reservedPorts }) =>
|
||||||
|
allocateWorkspaceServicePort({
|
||||||
|
allocation: servicePortAllocation,
|
||||||
|
cwd: repoRoot,
|
||||||
|
scriptName: serviceScriptName,
|
||||||
|
workspaceId,
|
||||||
|
branchName,
|
||||||
|
reservedPorts,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const port =
|
const port =
|
||||||
existingRuntimeEntry?.lifecycle === "stopped"
|
existingRuntimeEntry?.lifecycle === "stopped"
|
||||||
? await refreshWorkspaceServicePort({
|
? await refreshWorkspaceServicePort({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
service: { scriptName, port: config.port },
|
service: { scriptName, port: config.port },
|
||||||
allocatePort: findFreePort,
|
allocatePort: ({ scriptName: serviceScriptName, reservedPorts }) =>
|
||||||
|
allocateWorkspaceServicePort({
|
||||||
|
allocation: servicePortAllocation,
|
||||||
|
cwd: repoRoot,
|
||||||
|
scriptName: serviceScriptName,
|
||||||
|
workspaceId,
|
||||||
|
branchName,
|
||||||
|
reservedPorts,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
: requirePlannedWorkspaceServicePort(plannedPorts, scriptName);
|
: requirePlannedWorkspaceServicePort(plannedPorts, scriptName);
|
||||||
|
|
||||||
@@ -851,6 +874,7 @@ export async function spawnWorkspaceScript(
|
|||||||
serviceProxy,
|
serviceProxy,
|
||||||
runtimeStore,
|
runtimeStore,
|
||||||
terminalManager,
|
terminalManager,
|
||||||
|
globalServicePorts,
|
||||||
logger,
|
logger,
|
||||||
onLifecycleChanged,
|
onLifecycleChanged,
|
||||||
} = options;
|
} = options;
|
||||||
@@ -881,6 +905,7 @@ export async function spawnWorkspaceScript(
|
|||||||
let env: Record<string, string> | undefined;
|
let env: Record<string, string> | undefined;
|
||||||
if (serviceScript) {
|
if (serviceScript) {
|
||||||
const serviceSetup = await setupServiceScriptRoute({
|
const serviceSetup = await setupServiceScriptRoute({
|
||||||
|
repoRoot,
|
||||||
scriptConfigs,
|
scriptConfigs,
|
||||||
config,
|
config,
|
||||||
scriptName,
|
scriptName,
|
||||||
@@ -892,6 +917,7 @@ export async function spawnWorkspaceScript(
|
|||||||
serviceProxyPublicBaseUrl,
|
serviceProxyPublicBaseUrl,
|
||||||
existingRuntimeEntry,
|
existingRuntimeEntry,
|
||||||
serviceProxy,
|
serviceProxy,
|
||||||
|
servicePortAllocation: configResult.config?.worktree?.servicePorts ?? globalServicePorts,
|
||||||
});
|
});
|
||||||
hostname = serviceSetup.hostname;
|
hostname = serviceSetup.hostname;
|
||||||
port = serviceSetup.port;
|
port = serviceSetup.port;
|
||||||
|
|||||||
@@ -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.
|
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
|
### Reverse proxy
|
||||||
|
|
||||||
Every service is reachable through the daemon at a deterministic hostname:
|
Every service is reachable through the daemon at a deterministic hostname:
|
||||||
|
|||||||
Reference in New Issue
Block a user