mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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?: {
|
||||
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: {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
129
packages/server/src/server/workspace-service-port-allocator.ts
Normal file
129
packages/server/src/server/workspace-service-port-allocator.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user