refactor(server): extract workspace service env and port registry
Pull service env construction and port allocation out of worktree-bootstrap into dedicated modules with their own tests, so the bootstrap path only orchestrates. Adds peer collision detection and a port plan that is computed once and reused across scripts.
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildWorkspaceServiceEnv, normalizeServiceEnvName } from "./workspace-service-env.js";
|
||||
|
||||
describe("normalizeServiceEnvName", () => {
|
||||
it("normalizes punctuation and spaces to stable env names", () => {
|
||||
expect(normalizeServiceEnvName("app-server")).toBe("APP_SERVER");
|
||||
expect(normalizeServiceEnvName("app.server")).toBe("APP_SERVER");
|
||||
expect(normalizeServiceEnvName("app server")).toBe("APP_SERVER");
|
||||
});
|
||||
|
||||
it("trims leading and trailing separators", () => {
|
||||
expect(normalizeServiceEnvName(" app server ")).toBe("APP_SERVER");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWorkspaceServiceEnv", () => {
|
||||
it("uses loopback host binding when daemon listen host is loopback or absent", () => {
|
||||
expect(
|
||||
buildWorkspaceServiceEnv({
|
||||
scriptName: "daemon",
|
||||
branchName: "main",
|
||||
daemonPort: 6767,
|
||||
daemonListenHost: null,
|
||||
peers: [{ scriptName: "daemon", port: 5173 }],
|
||||
}).HOST,
|
||||
).toBe("127.0.0.1");
|
||||
|
||||
expect(
|
||||
buildWorkspaceServiceEnv({
|
||||
scriptName: "daemon",
|
||||
branchName: "main",
|
||||
daemonPort: 6767,
|
||||
daemonListenHost: "localhost",
|
||||
peers: [{ scriptName: "daemon", port: 5173 }],
|
||||
}).HOST,
|
||||
).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("uses network host binding when daemon listen host is non-loopback", () => {
|
||||
expect(
|
||||
buildWorkspaceServiceEnv({
|
||||
scriptName: "daemon",
|
||||
branchName: "main",
|
||||
daemonPort: 6767,
|
||||
daemonListenHost: "100.64.0.20",
|
||||
peers: [{ scriptName: "daemon", port: 5173 }],
|
||||
}).HOST,
|
||||
).toBe("0.0.0.0");
|
||||
});
|
||||
|
||||
it("builds default branch self and service URLs", () => {
|
||||
expect(
|
||||
buildWorkspaceServiceEnv({
|
||||
scriptName: "daemon",
|
||||
branchName: "main",
|
||||
daemonPort: 6767,
|
||||
daemonListenHost: null,
|
||||
peers: [{ scriptName: "daemon", port: 5173 }],
|
||||
}),
|
||||
).toEqual({
|
||||
HOST: "127.0.0.1",
|
||||
PASEO_PORT: "5173",
|
||||
PASEO_URL: "http://daemon.localhost:6767",
|
||||
PASEO_SERVICE_DAEMON_PORT: "5173",
|
||||
PASEO_SERVICE_DAEMON_URL: "http://daemon.localhost:6767",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds feature branch self and service URLs", () => {
|
||||
expect(
|
||||
buildWorkspaceServiceEnv({
|
||||
scriptName: "daemon",
|
||||
branchName: "feature-x",
|
||||
daemonPort: 6767,
|
||||
daemonListenHost: null,
|
||||
peers: [{ scriptName: "daemon", port: 5173 }],
|
||||
}),
|
||||
).toEqual({
|
||||
HOST: "127.0.0.1",
|
||||
PASEO_PORT: "5173",
|
||||
PASEO_URL: "http://feature-x.daemon.localhost:6767",
|
||||
PASEO_SERVICE_DAEMON_PORT: "5173",
|
||||
PASEO_SERVICE_DAEMON_URL: "http://feature-x.daemon.localhost:6767",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits PORT while keeping PASEO_PORT", () => {
|
||||
const env = buildWorkspaceServiceEnv({
|
||||
scriptName: "daemon",
|
||||
branchName: "main",
|
||||
daemonPort: 6767,
|
||||
daemonListenHost: null,
|
||||
peers: [{ scriptName: "daemon", port: 5173 }],
|
||||
});
|
||||
|
||||
expect(env.PASEO_PORT).toBe("5173");
|
||||
expect(env).not.toHaveProperty("PORT");
|
||||
});
|
||||
|
||||
it("omits URL variables when daemon port is absent while keeping port aliases", () => {
|
||||
expect(
|
||||
buildWorkspaceServiceEnv({
|
||||
scriptName: "daemon",
|
||||
branchName: "main",
|
||||
daemonPort: null,
|
||||
daemonListenHost: null,
|
||||
peers: [{ scriptName: "daemon", port: 5173 }],
|
||||
}),
|
||||
).toEqual({
|
||||
HOST: "127.0.0.1",
|
||||
PASEO_PORT: "5173",
|
||||
PASEO_SERVICE_DAEMON_PORT: "5173",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds peer service ports and URLs", () => {
|
||||
expect(
|
||||
buildWorkspaceServiceEnv({
|
||||
scriptName: "web",
|
||||
branchName: "feature-x",
|
||||
daemonPort: 6767,
|
||||
daemonListenHost: null,
|
||||
peers: [
|
||||
{ scriptName: "api", port: 4000 },
|
||||
{ scriptName: "web", port: 5173 },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
HOST: "127.0.0.1",
|
||||
PASEO_PORT: "5173",
|
||||
PASEO_URL: "http://feature-x.web.localhost:6767",
|
||||
PASEO_SERVICE_API_PORT: "4000",
|
||||
PASEO_SERVICE_API_URL: "http://feature-x.api.localhost:6767",
|
||||
PASEO_SERVICE_WEB_PORT: "5173",
|
||||
PASEO_SERVICE_WEB_URL: "http://feature-x.web.localhost:6767",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when normalized peer env names collide", () => {
|
||||
expect(() =>
|
||||
buildWorkspaceServiceEnv({
|
||||
scriptName: "app-server",
|
||||
branchName: "main",
|
||||
daemonPort: 6767,
|
||||
daemonListenHost: null,
|
||||
peers: [
|
||||
{ scriptName: "app-server", port: 5173 },
|
||||
{ scriptName: "app.server", port: 4000 },
|
||||
],
|
||||
}),
|
||||
).toThrow("Service env name collision for APP_SERVER: app-server, app.server");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { buildScriptHostname } from "../utils/script-hostname.js";
|
||||
|
||||
export interface WorkspaceServicePeer {
|
||||
scriptName: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface BuildWorkspaceServiceEnvOptions {
|
||||
scriptName: string;
|
||||
branchName: string | null;
|
||||
daemonPort: number | null | undefined;
|
||||
daemonListenHost: string | null | undefined;
|
||||
peers: readonly WorkspaceServicePeer[];
|
||||
}
|
||||
|
||||
export function normalizeServiceEnvName(scriptName: string): string {
|
||||
return scriptName
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
}
|
||||
|
||||
export function buildWorkspaceServiceEnv(
|
||||
options: BuildWorkspaceServiceEnvOptions,
|
||||
): Record<string, string> {
|
||||
const scriptNames = options.peers.map((peer) => peer.scriptName);
|
||||
assertNoServiceEnvNameCollisions(scriptNames);
|
||||
|
||||
const selfPeer = options.peers.find((peer) => peer.scriptName === options.scriptName);
|
||||
if (!selfPeer) {
|
||||
throw new Error(`Service '${options.scriptName}' is missing from workspace service peers`);
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {
|
||||
HOST: resolveServiceBindHost(options.daemonListenHost),
|
||||
PASEO_PORT: String(selfPeer.port),
|
||||
};
|
||||
|
||||
if (options.daemonPort !== null && options.daemonPort !== undefined) {
|
||||
env.PASEO_URL = buildServiceProxyUrl({
|
||||
branchName: options.branchName,
|
||||
scriptName: options.scriptName,
|
||||
daemonPort: options.daemonPort,
|
||||
});
|
||||
}
|
||||
|
||||
for (const peer of options.peers) {
|
||||
const envName = normalizeServiceEnvName(peer.scriptName);
|
||||
env[`PASEO_SERVICE_${envName}_PORT`] = String(peer.port);
|
||||
|
||||
if (options.daemonPort !== null && options.daemonPort !== undefined) {
|
||||
env[`PASEO_SERVICE_${envName}_URL`] = buildServiceProxyUrl({
|
||||
branchName: options.branchName,
|
||||
scriptName: peer.scriptName,
|
||||
daemonPort: options.daemonPort,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
export function resolveServiceBindHost(daemonListenHost: string | null | undefined): string {
|
||||
return isLoopbackListenHost(daemonListenHost) ? "127.0.0.1" : "0.0.0.0";
|
||||
}
|
||||
|
||||
interface BuildServiceProxyUrlOptions {
|
||||
branchName: string | null;
|
||||
scriptName: string;
|
||||
daemonPort: number;
|
||||
}
|
||||
|
||||
function buildServiceProxyUrl(options: BuildServiceProxyUrlOptions): string {
|
||||
const hostname = buildScriptHostname(options.branchName, options.scriptName);
|
||||
return `http://${hostname}:${options.daemonPort}`;
|
||||
}
|
||||
|
||||
function isLoopbackListenHost(host: string | null | undefined): boolean {
|
||||
if (!host) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedHost = host.trim().toLowerCase();
|
||||
return (
|
||||
normalizedHost === "localhost" ||
|
||||
normalizedHost === "127.0.0.1" ||
|
||||
normalizedHost === "::1" ||
|
||||
normalizedHost === "[::1]"
|
||||
);
|
||||
}
|
||||
|
||||
export function assertNoServiceEnvNameCollisions(scriptNames: readonly string[]): void {
|
||||
const scriptNamesByEnvName = new Map<string, string[]>();
|
||||
|
||||
for (const scriptName of scriptNames) {
|
||||
const envName = normalizeServiceEnvName(scriptName);
|
||||
const namesForEnvName = scriptNamesByEnvName.get(envName) ?? [];
|
||||
namesForEnvName.push(scriptName);
|
||||
scriptNamesByEnvName.set(envName, namesForEnvName);
|
||||
}
|
||||
|
||||
const collisions: string[] = [];
|
||||
for (const [envName, scriptNames] of scriptNamesByEnvName) {
|
||||
if (scriptNames.length > 1) {
|
||||
collisions.push(`Service env name collision for ${envName}: ${scriptNames.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (collisions.length > 0) {
|
||||
throw new Error(collisions.join("; "));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ensureWorkspaceServicePortPlan,
|
||||
refreshWorkspaceServicePort,
|
||||
} from "./workspace-service-port-registry.js";
|
||||
|
||||
describe("ensureWorkspaceServicePortPlan", () => {
|
||||
it("allocates ports for all declared services in declaration order", async () => {
|
||||
let nextPort = 4100;
|
||||
let allocationCount = 0;
|
||||
|
||||
const plan = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId: "registry-order-workspace",
|
||||
services: [{ scriptName: "api" }, { scriptName: "web" }, { scriptName: "worker" }],
|
||||
allocatePort: async () => {
|
||||
allocationCount += 1;
|
||||
const port = nextPort;
|
||||
nextPort += 1;
|
||||
return port;
|
||||
},
|
||||
});
|
||||
|
||||
expect(Array.from(plan.entries())).toEqual([
|
||||
["api", 4100],
|
||||
["web", 4101],
|
||||
["worker", 4102],
|
||||
]);
|
||||
expect(allocationCount).toBe(3);
|
||||
});
|
||||
|
||||
it("returns the existing plan without calling the allocator on later calls", async () => {
|
||||
let allocationCount = 0;
|
||||
|
||||
const firstPlan = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId: "registry-first-wins-workspace",
|
||||
services: [{ scriptName: "api" }, { scriptName: "web" }],
|
||||
allocatePort: async () => {
|
||||
allocationCount += 1;
|
||||
return 4200 + allocationCount;
|
||||
},
|
||||
});
|
||||
|
||||
const secondPlan = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId: "registry-first-wins-workspace",
|
||||
services: [{ scriptName: "new-service" }],
|
||||
allocatePort: async () => {
|
||||
allocationCount += 1;
|
||||
return 4300;
|
||||
},
|
||||
});
|
||||
|
||||
expect(Array.from(secondPlan.entries())).toEqual(Array.from(firstPlan.entries()));
|
||||
expect(secondPlan.has("new-service")).toBe(false);
|
||||
expect(allocationCount).toBe(2);
|
||||
});
|
||||
|
||||
it("shares one first-plan build across concurrent callers", async () => {
|
||||
let allocationCount = 0;
|
||||
const firstAllocation = createDeferredPort();
|
||||
const secondAllocation = createDeferredPort();
|
||||
|
||||
const firstPlanPromise = ensureWorkspaceServicePortPlan({
|
||||
workspaceId: "registry-concurrent-first-plan-workspace",
|
||||
services: [{ scriptName: "api" }, { scriptName: "web" }],
|
||||
allocatePort: async () => {
|
||||
allocationCount += 1;
|
||||
if (allocationCount === 1) {
|
||||
return await firstAllocation.promise;
|
||||
}
|
||||
return await secondAllocation.promise;
|
||||
},
|
||||
});
|
||||
const secondPlanPromise = ensureWorkspaceServicePortPlan({
|
||||
workspaceId: "registry-concurrent-first-plan-workspace",
|
||||
services: [{ scriptName: "api" }, { scriptName: "web" }],
|
||||
allocatePort: async () => {
|
||||
allocationCount += 1;
|
||||
return 4300 + allocationCount;
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(allocationCount).toBe(1);
|
||||
firstAllocation.resolve(4301);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(allocationCount).toBe(2);
|
||||
secondAllocation.resolve(4302);
|
||||
|
||||
const [firstPlan, secondPlan] = await Promise.all([firstPlanPromise, secondPlanPromise]);
|
||||
|
||||
expect(Array.from(firstPlan.entries())).toEqual([
|
||||
["api", 4301],
|
||||
["web", 4302],
|
||||
]);
|
||||
expect(Array.from(secondPlan.entries())).toEqual(Array.from(firstPlan.entries()));
|
||||
expect(allocationCount).toBe(2);
|
||||
});
|
||||
|
||||
it("uses explicit configured ports without calling the allocator", async () => {
|
||||
let allocationCount = 0;
|
||||
|
||||
const plan = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId: "registry-explicit-port-workspace",
|
||||
services: [
|
||||
{ scriptName: "api", port: 4410 },
|
||||
{ scriptName: "web", port: 4411 },
|
||||
],
|
||||
allocatePort: async () => {
|
||||
allocationCount += 1;
|
||||
return 4400;
|
||||
},
|
||||
});
|
||||
|
||||
expect(Array.from(plan.entries())).toEqual([
|
||||
["api", 4410],
|
||||
["web", 4411],
|
||||
]);
|
||||
expect(allocationCount).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps workspace plans independent", async () => {
|
||||
const firstPlan = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId: "registry-independent-workspace-a",
|
||||
services: [{ scriptName: "api" }],
|
||||
allocatePort: async () => 4500,
|
||||
});
|
||||
|
||||
const secondPlan = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId: "registry-independent-workspace-b",
|
||||
services: [{ scriptName: "api" }],
|
||||
allocatePort: async () => 4600,
|
||||
});
|
||||
|
||||
expect(firstPlan.get("api")).toBe(4500);
|
||||
expect(secondPlan.get("api")).toBe(4600);
|
||||
});
|
||||
|
||||
it("returns defensive snapshots", async () => {
|
||||
const first = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId: "registry-defensive-snapshot-workspace",
|
||||
services: [{ scriptName: "api" }],
|
||||
allocatePort: async () => 4700,
|
||||
});
|
||||
|
||||
const second = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId: "registry-defensive-snapshot-workspace",
|
||||
services: [],
|
||||
allocatePort: async () => 4701,
|
||||
});
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
expect(Array.from(first.entries())).toEqual([["api", 4700]]);
|
||||
expect(Array.from(second.entries())).toEqual(Array.from(first.entries()));
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshWorkspaceServicePort", () => {
|
||||
it("reallocates only the named service", async () => {
|
||||
await ensureWorkspaceServicePortPlan({
|
||||
workspaceId: "registry-refresh-workspace",
|
||||
services: [{ scriptName: "api" }, { scriptName: "web" }],
|
||||
allocatePort: createSequentialPortAllocator(4800),
|
||||
});
|
||||
|
||||
const refreshedPort = await refreshWorkspaceServicePort({
|
||||
workspaceId: "registry-refresh-workspace",
|
||||
service: { scriptName: "api" },
|
||||
allocatePort: async () => 4900,
|
||||
});
|
||||
|
||||
const snapshot = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId: "registry-refresh-workspace",
|
||||
services: [],
|
||||
allocatePort: async () => 4901,
|
||||
});
|
||||
|
||||
expect(refreshedPort).toBe(4900);
|
||||
expect(Array.from(snapshot.entries())).toEqual([
|
||||
["api", 4900],
|
||||
["web", 4801],
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses an explicit configured port without calling the allocator", async () => {
|
||||
let allocationCount = 0;
|
||||
|
||||
await ensureWorkspaceServicePortPlan({
|
||||
workspaceId: "registry-refresh-explicit-workspace",
|
||||
services: [{ scriptName: "api" }],
|
||||
allocatePort: async () => 5000,
|
||||
});
|
||||
|
||||
const refreshedPort = await refreshWorkspaceServicePort({
|
||||
workspaceId: "registry-refresh-explicit-workspace",
|
||||
service: { scriptName: "api", port: 5100 },
|
||||
allocatePort: async () => {
|
||||
allocationCount += 1;
|
||||
return 5001;
|
||||
},
|
||||
});
|
||||
|
||||
expect(refreshedPort).toBe(5100);
|
||||
expect(allocationCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
function createSequentialPortAllocator(startPort: number): () => Promise<number> {
|
||||
let nextPort = startPort;
|
||||
|
||||
return async function allocatePort(): Promise<number> {
|
||||
const port = nextPort;
|
||||
nextPort += 1;
|
||||
return port;
|
||||
};
|
||||
}
|
||||
|
||||
interface DeferredPort {
|
||||
promise: Promise<number>;
|
||||
resolve: (port: number) => void;
|
||||
}
|
||||
|
||||
function createDeferredPort(): DeferredPort {
|
||||
let resolvePort: (port: number) => void = () => {};
|
||||
const promise = new Promise<number>((resolve) => {
|
||||
resolvePort = resolve;
|
||||
});
|
||||
|
||||
return {
|
||||
promise,
|
||||
resolve: resolvePort,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
interface WorkspaceServicePortDeclaration {
|
||||
scriptName: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
interface EnsureWorkspaceServicePortPlanOptions {
|
||||
workspaceId: string;
|
||||
services: readonly WorkspaceServicePortDeclaration[];
|
||||
allocatePort: () => Promise<number>;
|
||||
}
|
||||
|
||||
interface RefreshWorkspaceServicePortOptions {
|
||||
workspaceId: string;
|
||||
service: WorkspaceServicePortDeclaration;
|
||||
allocatePort: () => Promise<number>;
|
||||
}
|
||||
|
||||
const workspaceServicePortPlans = new Map<string, Map<string, number>>();
|
||||
const pendingWorkspaceServicePortPlans = new Map<string, Promise<Map<string, number>>>();
|
||||
|
||||
export async function ensureWorkspaceServicePortPlan(
|
||||
options: EnsureWorkspaceServicePortPlanOptions,
|
||||
): Promise<ReadonlyMap<string, number>> {
|
||||
const existingPlan = workspaceServicePortPlans.get(options.workspaceId);
|
||||
if (existingPlan) {
|
||||
return new Map(existingPlan);
|
||||
}
|
||||
|
||||
let pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId);
|
||||
if (!pendingPlan) {
|
||||
pendingPlan = createPendingWorkspaceServicePortPlan({
|
||||
workspaceId: options.workspaceId,
|
||||
services: options.services,
|
||||
allocatePort: options.allocatePort,
|
||||
});
|
||||
pendingWorkspaceServicePortPlans.set(options.workspaceId, pendingPlan);
|
||||
}
|
||||
|
||||
return new Map(await pendingPlan);
|
||||
}
|
||||
|
||||
export function requirePlannedWorkspaceServicePort(
|
||||
plan: ReadonlyMap<string, number>,
|
||||
scriptName: string,
|
||||
): number {
|
||||
const port = plan.get(scriptName);
|
||||
if (port === undefined) {
|
||||
throw new Error(`Service '${scriptName}' is missing from workspace service port plan`);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
async function createPendingWorkspaceServicePortPlan(options: {
|
||||
workspaceId: string;
|
||||
services: readonly WorkspaceServicePortDeclaration[];
|
||||
allocatePort: () => Promise<number>;
|
||||
}): Promise<Map<string, number>> {
|
||||
try {
|
||||
const plan = await buildWorkspaceServicePortPlan({
|
||||
services: options.services,
|
||||
allocatePort: options.allocatePort,
|
||||
});
|
||||
workspaceServicePortPlans.set(options.workspaceId, plan);
|
||||
return plan;
|
||||
} finally {
|
||||
pendingWorkspaceServicePortPlans.delete(options.workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildWorkspaceServicePortPlan(options: {
|
||||
services: readonly WorkspaceServicePortDeclaration[];
|
||||
allocatePort: () => Promise<number>;
|
||||
}): Promise<Map<string, number>> {
|
||||
const plan = new Map<string, number>();
|
||||
for (const service of options.services) {
|
||||
plan.set(service.scriptName, await resolveServicePort(service, options.allocatePort));
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
export async function refreshWorkspaceServicePort(
|
||||
options: RefreshWorkspaceServicePortOptions,
|
||||
): Promise<number> {
|
||||
const plan = workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>();
|
||||
|
||||
const port = await resolveServicePort(options.service, options.allocatePort);
|
||||
plan.set(options.service.scriptName, port);
|
||||
workspaceServicePortPlans.set(options.workspaceId, plan);
|
||||
return port;
|
||||
}
|
||||
|
||||
async function resolveServicePort(
|
||||
service: WorkspaceServicePortDeclaration,
|
||||
allocatePort: () => Promise<number>,
|
||||
): Promise<number> {
|
||||
if (service.port !== undefined) {
|
||||
return service.port;
|
||||
}
|
||||
|
||||
return await allocatePort();
|
||||
}
|
||||
@@ -5,14 +5,19 @@ import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
|
||||
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
|
||||
import { runAsyncWorktreeBootstrap, spawnWorktreeScripts } from "./worktree-bootstrap.js";
|
||||
import {
|
||||
runAsyncWorktreeBootstrap,
|
||||
spawnWorkspaceScript,
|
||||
spawnWorktreeScripts,
|
||||
} from "./worktree-bootstrap.js";
|
||||
import { ensureWorkspaceServicePortPlan } from "./workspace-service-port-registry.js";
|
||||
import { ScriptRouteStore } from "./script-proxy.js";
|
||||
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||
import {
|
||||
createWorktree as createWorktreePrimitive,
|
||||
type WorktreeConfig,
|
||||
} from "../utils/worktree.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type { TerminalSession } from "../terminal/terminal.js";
|
||||
|
||||
interface CreateAgentWorktreeTestOptions {
|
||||
@@ -49,6 +54,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
let tempDir: string;
|
||||
let repoDir: string;
|
||||
let paseoHome: string;
|
||||
let realTerminalManagers: TerminalManager[];
|
||||
|
||||
async function waitForPathExists(targetPath: string, timeoutMs = 10000): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
@@ -62,6 +68,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
realTerminalManagers = [];
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-bootstrap-test-")));
|
||||
repoDir = join(tempDir, "repo");
|
||||
paseoHome = join(tempDir, "paseo-home");
|
||||
@@ -75,7 +82,19 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
for (const terminalManager of realTerminalManagers) {
|
||||
for (const cwd of terminalManager.listDirectories()) {
|
||||
const terminals = await terminalManager.getTerminals(cwd);
|
||||
for (const terminal of terminals) {
|
||||
await terminalManager.killTerminalAndWait(terminal.id, {
|
||||
gracefulTimeoutMs: 100,
|
||||
forceTimeoutMs: 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
terminalManager.killAll();
|
||||
}
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -540,26 +559,53 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
expect(terminalToolCall?.status).toBe("completed");
|
||||
});
|
||||
|
||||
interface CreateTerminalCall {
|
||||
cwd: string;
|
||||
name?: string;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface StubTerminalRecord {
|
||||
triggerExit: (exitCode: number) => void;
|
||||
}
|
||||
|
||||
function createStubTerminalManager(
|
||||
createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record<string, string> }>,
|
||||
createTerminalCalls: CreateTerminalCall[],
|
||||
terminalRecords: StubTerminalRecord[] = [],
|
||||
): TerminalManager {
|
||||
let terminalCounter = 0;
|
||||
|
||||
return {
|
||||
async getTerminals() {
|
||||
return [];
|
||||
},
|
||||
async createTerminal(options: {
|
||||
cwd: string;
|
||||
name?: string;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<TerminalSession> {
|
||||
async createTerminal(options: CreateTerminalCall): Promise<TerminalSession> {
|
||||
createTerminalCalls.push(options);
|
||||
terminalCounter += 1;
|
||||
const terminalId = `term-service-${terminalCounter}`;
|
||||
let exitHandler: ((info: { exitCode: number | null }) => void) | null = null;
|
||||
terminalRecords.push({
|
||||
triggerExit: (exitCode) => {
|
||||
if (exitHandler) {
|
||||
exitHandler({ exitCode });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: "term-service",
|
||||
id: terminalId,
|
||||
name: options.name ?? "Terminal",
|
||||
cwd: options.cwd,
|
||||
send: () => {},
|
||||
subscribe: () => () => {},
|
||||
onExit: () => () => {},
|
||||
onExit: (handler) => {
|
||||
exitHandler = handler;
|
||||
return () => {
|
||||
if (exitHandler === handler) {
|
||||
exitHandler = null;
|
||||
}
|
||||
};
|
||||
},
|
||||
getState: () => ({
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
@@ -591,6 +637,21 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
};
|
||||
}
|
||||
|
||||
function readEnvFile(path: string): Record<string, string> {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error(`Expected env file to contain a JSON object: ${path}`);
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (typeof value === "string") {
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
it("spawns plain scripts without env injection or routes", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
@@ -610,8 +671,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
|
||||
const routeStore = new ScriptRouteStore();
|
||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||
const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record<string, string> }> =
|
||||
[];
|
||||
const createTerminalCalls: CreateTerminalCall[] = [];
|
||||
|
||||
const results = await spawnWorktreeScripts({
|
||||
repoRoot: repoDir,
|
||||
@@ -633,18 +693,22 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
type: "script",
|
||||
lifecycle: "running",
|
||||
exitCode: null,
|
||||
terminalId: "term-service",
|
||||
terminalId: "term-service-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("spawns services with route registration and injected service env vars", async () => {
|
||||
it("spawns services with route registration and injected peer service env vars", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
scripts: {
|
||||
web: {
|
||||
api: {
|
||||
type: "service",
|
||||
command: "npm run dev",
|
||||
command: "npm run api",
|
||||
},
|
||||
"app-server": {
|
||||
type: "service",
|
||||
command: "npm run app",
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -657,43 +721,333 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
|
||||
const routeStore = new ScriptRouteStore();
|
||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||
const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record<string, string> }> =
|
||||
[];
|
||||
const createTerminalCalls: CreateTerminalCall[] = [];
|
||||
|
||||
const results = await spawnWorktreeScripts({
|
||||
const result = await spawnWorkspaceScript({
|
||||
repoRoot: repoDir,
|
||||
workspaceId: repoDir,
|
||||
branchName: "feature-socket-service",
|
||||
scriptName: "api",
|
||||
daemonPort: 6767,
|
||||
routeStore,
|
||||
runtimeStore,
|
||||
terminalManager: createStubTerminalManager(createTerminalCalls),
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(result.scriptName).toBe("api");
|
||||
expect(routeStore.listRoutes()).toEqual([
|
||||
{
|
||||
hostname: "feature-socket-service.web.localhost",
|
||||
hostname: "feature-socket-service.api.localhost",
|
||||
port: expect.any(Number),
|
||||
workspaceId: repoDir,
|
||||
scriptName: "web",
|
||||
scriptName: "api",
|
||||
},
|
||||
]);
|
||||
expect(createTerminalCalls).toHaveLength(1);
|
||||
expect(createTerminalCalls[0]?.cwd).toBe(repoDir);
|
||||
expect(createTerminalCalls[0]?.name).toBe("web");
|
||||
expect(createTerminalCalls[0]?.env?.PORT).toEqual(expect.any(String));
|
||||
expect(createTerminalCalls[0]?.name).toBe("api");
|
||||
expect(createTerminalCalls[0]?.env).not.toHaveProperty("PORT");
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_PORT).toEqual(expect.any(String));
|
||||
expect(createTerminalCalls[0]?.env?.HOST).toBe("127.0.0.1");
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_SCRIPT_URL).toBe(
|
||||
"http://feature-socket-service.web.localhost:6767",
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe(
|
||||
"http://feature-socket-service.api.localhost:6767",
|
||||
);
|
||||
expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "web" })).toMatchObject({
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_PORT).toBe(
|
||||
createTerminalCalls[0]?.env?.PASEO_PORT,
|
||||
);
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_URL).toBe(
|
||||
"http://feature-socket-service.api.localhost:6767",
|
||||
);
|
||||
const plannedPorts = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId: repoDir,
|
||||
services: [{ scriptName: "api" }, { scriptName: "app-server" }],
|
||||
allocatePort: async () => {
|
||||
throw new Error("Peer env test should reuse the existing service port plan");
|
||||
},
|
||||
});
|
||||
const plannedAppServerPort = plannedPorts.get("app-server");
|
||||
if (plannedAppServerPort === undefined) {
|
||||
throw new Error("Expected app-server to be present in the service port plan");
|
||||
}
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_APP_SERVER_PORT).toBe(
|
||||
String(plannedAppServerPort),
|
||||
);
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_APP_SERVER_URL).toBe(
|
||||
"http://feature-socket-service.app-server.localhost:6767",
|
||||
);
|
||||
expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "api" })).toMatchObject({
|
||||
type: "service",
|
||||
lifecycle: "running",
|
||||
exitCode: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes a stopped service port on respawn and updates the route", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
scripts: {
|
||||
api: {
|
||||
type: "service",
|
||||
command: "npm run api",
|
||||
},
|
||||
worker: {
|
||||
type: "service",
|
||||
command: "npm run worker",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add respawn service script config'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const routeStore = new ScriptRouteStore();
|
||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||
const createTerminalCalls: CreateTerminalCall[] = [];
|
||||
const terminalRecords: StubTerminalRecord[] = [];
|
||||
const terminalManager = createStubTerminalManager(createTerminalCalls, terminalRecords);
|
||||
|
||||
const firstResult = await spawnWorkspaceScript({
|
||||
repoRoot: repoDir,
|
||||
workspaceId: repoDir,
|
||||
branchName: "feature-respawn-service",
|
||||
scriptName: "api",
|
||||
daemonPort: 6767,
|
||||
routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
});
|
||||
|
||||
const workerResult = await spawnWorkspaceScript({
|
||||
repoRoot: repoDir,
|
||||
workspaceId: repoDir,
|
||||
branchName: "feature-respawn-service",
|
||||
scriptName: "worker",
|
||||
daemonPort: 6767,
|
||||
routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
});
|
||||
|
||||
expect(firstResult.port).toEqual(expect.any(Number));
|
||||
const firstPort = firstResult.port;
|
||||
if (firstPort === null) {
|
||||
throw new Error("Expected first service spawn to return a port");
|
||||
}
|
||||
const workerPort = workerResult.port;
|
||||
if (workerPort === null) {
|
||||
throw new Error("Expected worker service spawn to return a port");
|
||||
}
|
||||
|
||||
const firstTerminal = terminalRecords[0];
|
||||
if (!firstTerminal) {
|
||||
throw new Error("Expected first terminal record");
|
||||
}
|
||||
firstTerminal.triggerExit(0);
|
||||
|
||||
expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "api" })).toMatchObject({
|
||||
lifecycle: "stopped",
|
||||
exitCode: 0,
|
||||
});
|
||||
expect(routeStore.getRouteEntry("feature-respawn-service.api.localhost")).toBeNull();
|
||||
|
||||
const secondResult = await spawnWorkspaceScript({
|
||||
repoRoot: repoDir,
|
||||
workspaceId: repoDir,
|
||||
branchName: "feature-respawn-service",
|
||||
scriptName: "api",
|
||||
daemonPort: 6767,
|
||||
routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
});
|
||||
|
||||
expect(secondResult.port).toEqual(expect.any(Number));
|
||||
const secondPort = secondResult.port;
|
||||
if (secondPort === null) {
|
||||
throw new Error("Expected second service spawn to return a port");
|
||||
}
|
||||
expect(secondPort).not.toBe(firstPort);
|
||||
expect(secondPort).toEqual(expect.any(Number));
|
||||
expect(createTerminalCalls[2]?.env?.PASEO_SERVICE_WORKER_PORT).toBe(String(workerPort));
|
||||
expect(routeStore.getRouteEntry("feature-respawn-service.api.localhost")).toMatchObject({
|
||||
hostname: "feature-respawn-service.api.localhost",
|
||||
port: secondPort,
|
||||
workspaceId: repoDir,
|
||||
scriptName: "api",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails normalized service env name collisions before terminal creation", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
scripts: {
|
||||
"app-server": {
|
||||
type: "service",
|
||||
command: "npm run app-server",
|
||||
},
|
||||
"app.server": {
|
||||
type: "service",
|
||||
command: "npm run app-dot-server",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add colliding service config'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const routeStore = new ScriptRouteStore();
|
||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||
const createTerminalCalls: CreateTerminalCall[] = [];
|
||||
|
||||
await expect(
|
||||
spawnWorkspaceScript({
|
||||
repoRoot: repoDir,
|
||||
workspaceId: repoDir,
|
||||
branchName: "feature-collision-service",
|
||||
scriptName: "app-server",
|
||||
daemonPort: 6767,
|
||||
routeStore,
|
||||
runtimeStore,
|
||||
terminalManager: createStubTerminalManager(createTerminalCalls),
|
||||
}),
|
||||
).rejects.toThrow("Service env name collision for APP_SERVER: app-server, app.server");
|
||||
|
||||
expect(createTerminalCalls).toHaveLength(0);
|
||||
expect(routeStore.listRoutes()).toEqual([]);
|
||||
expect(routeStore.getRouteEntry("feature-collision-service.app-server.localhost")).toBeNull();
|
||||
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
scripts: {
|
||||
"app-server": {
|
||||
type: "service",
|
||||
command: "npm run app-server",
|
||||
},
|
||||
worker: {
|
||||
type: "service",
|
||||
command: "npm run worker",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await spawnWorkspaceScript({
|
||||
repoRoot: repoDir,
|
||||
workspaceId: repoDir,
|
||||
branchName: "feature-collision-service",
|
||||
scriptName: "app-server",
|
||||
daemonPort: 6767,
|
||||
routeStore,
|
||||
runtimeStore,
|
||||
terminalManager: createStubTerminalManager(createTerminalCalls),
|
||||
});
|
||||
|
||||
const plan = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId: repoDir,
|
||||
services: [{ scriptName: "app-server" }, { scriptName: "worker" }],
|
||||
allocatePort: async () => {
|
||||
throw new Error("Collision recovery should reuse the fixed service port plan");
|
||||
},
|
||||
});
|
||||
|
||||
expect(Array.from(plan.keys())).toEqual(["app-server", "worker"]);
|
||||
expect(createTerminalCalls).toHaveLength(1);
|
||||
expect(createTerminalCalls[0]?.env).toHaveProperty("PASEO_SERVICE_APP_SERVER_PORT");
|
||||
expect(createTerminalCalls[0]?.env).toHaveProperty("PASEO_SERVICE_WORKER_PORT");
|
||||
});
|
||||
|
||||
it("injects real peer service env into terminal-backed services", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
scripts: {
|
||||
api: {
|
||||
type: "service",
|
||||
command:
|
||||
"node -e \"const fs=require('fs'); fs.writeFileSync('api-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"",
|
||||
},
|
||||
web: {
|
||||
type: "service",
|
||||
command:
|
||||
"node -e \"const fs=require('fs'); fs.writeFileSync('web-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add real peer env services'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const routeStore = new ScriptRouteStore();
|
||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||
const terminalManager = createTerminalManager();
|
||||
realTerminalManagers.push(terminalManager);
|
||||
|
||||
await spawnWorktreeScripts({
|
||||
repoRoot: repoDir,
|
||||
workspaceId: repoDir,
|
||||
branchName: "feature-peer-env",
|
||||
daemonPort: 6767,
|
||||
routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
});
|
||||
|
||||
const apiEnvPath = join(repoDir, "api-env.json");
|
||||
const webEnvPath = join(repoDir, "web-env.json");
|
||||
await waitForPathExists(apiEnvPath);
|
||||
await waitForPathExists(webEnvPath);
|
||||
|
||||
const apiEnv = readEnvFile(apiEnvPath);
|
||||
const webEnv = readEnvFile(webEnvPath);
|
||||
|
||||
expect(apiEnv.PASEO_SERVICE_API_URL).toBe("http://feature-peer-env.api.localhost:6767");
|
||||
expect(apiEnv.PASEO_SERVICE_WEB_URL).toBe("http://feature-peer-env.web.localhost:6767");
|
||||
expect(apiEnv.PASEO_SERVICE_API_PORT).toEqual(expect.stringMatching(/^\d+$/));
|
||||
expect(apiEnv.PASEO_SERVICE_WEB_PORT).toEqual(expect.stringMatching(/^\d+$/));
|
||||
expect(apiEnv.PASEO_URL).toBe(apiEnv.PASEO_SERVICE_API_URL);
|
||||
expect(apiEnv.PASEO_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT);
|
||||
expect(apiEnv).not.toHaveProperty("PORT");
|
||||
|
||||
expect(webEnv.PASEO_SERVICE_API_URL).toBe("http://feature-peer-env.api.localhost:6767");
|
||||
expect(webEnv.PASEO_SERVICE_WEB_URL).toBe("http://feature-peer-env.web.localhost:6767");
|
||||
expect(webEnv.PASEO_SERVICE_API_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT);
|
||||
expect(webEnv.PASEO_SERVICE_WEB_PORT).toBe(apiEnv.PASEO_SERVICE_WEB_PORT);
|
||||
expect(webEnv.PASEO_URL).toBe(webEnv.PASEO_SERVICE_WEB_URL);
|
||||
expect(webEnv.PASEO_PORT).toBe(webEnv.PASEO_SERVICE_WEB_PORT);
|
||||
expect(webEnv).not.toHaveProperty("PORT");
|
||||
|
||||
const apiPort = Number(apiEnv.PASEO_SERVICE_API_PORT);
|
||||
const webPort = Number(apiEnv.PASEO_SERVICE_WEB_PORT);
|
||||
expect(Number.isInteger(apiPort)).toBe(true);
|
||||
expect(Number.isInteger(webPort)).toBe(true);
|
||||
expect(routeStore.listRoutes()).toEqual([
|
||||
{
|
||||
hostname: "feature-peer-env.api.localhost",
|
||||
port: apiPort,
|
||||
workspaceId: repoDir,
|
||||
scriptName: "api",
|
||||
},
|
||||
{
|
||||
hostname: "feature-peer-env.web.localhost",
|
||||
port: webPort,
|
||||
workspaceId: repoDir,
|
||||
scriptName: "web",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("binds services to the network when the daemon listens on a non-loopback host", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
@@ -714,8 +1068,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
|
||||
const routeStore = new ScriptRouteStore();
|
||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||
const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record<string, string> }> =
|
||||
[];
|
||||
const createTerminalCalls: CreateTerminalCall[] = [];
|
||||
|
||||
await spawnWorktreeScripts({
|
||||
repoRoot: repoDir,
|
||||
@@ -730,7 +1083,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
|
||||
expect(createTerminalCalls).toHaveLength(1);
|
||||
expect(createTerminalCalls[0]?.env?.HOST).toBe("0.0.0.0");
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_SCRIPT_URL).toBe(
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe(
|
||||
"http://feature-remote-service.web.localhost:6767",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,16 @@ import {
|
||||
import { findFreePort, type ScriptRouteStore } from "./script-proxy.js";
|
||||
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||
import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js";
|
||||
import {
|
||||
assertNoServiceEnvNameCollisions,
|
||||
buildWorkspaceServiceEnv,
|
||||
type WorkspaceServicePeer,
|
||||
} from "./workspace-service-env.js";
|
||||
import {
|
||||
ensureWorkspaceServicePortPlan,
|
||||
requirePlannedWorkspaceServicePort,
|
||||
refreshWorkspaceServicePort,
|
||||
} from "./workspace-service-port-registry.js";
|
||||
|
||||
export interface WorktreeBootstrapTerminalResult {
|
||||
name: string | null;
|
||||
@@ -675,7 +685,12 @@ export interface WorktreeScriptResult {
|
||||
terminalId: string;
|
||||
}
|
||||
|
||||
type SpawnWorkspaceScriptOptions = {
|
||||
interface WorkspaceServiceDeclaration {
|
||||
scriptName: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
interface SpawnWorkspaceScriptOptions {
|
||||
repoRoot: string;
|
||||
workspaceId: string;
|
||||
branchName: string | null;
|
||||
@@ -687,24 +702,6 @@ type SpawnWorkspaceScriptOptions = {
|
||||
terminalManager: TerminalManager;
|
||||
logger?: Logger;
|
||||
onLifecycleChanged?: () => void;
|
||||
};
|
||||
|
||||
function isLoopbackListenHost(host: string | null | undefined): boolean {
|
||||
if (!host) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedHost = host.trim().toLowerCase();
|
||||
return (
|
||||
normalizedHost === "localhost" ||
|
||||
normalizedHost === "127.0.0.1" ||
|
||||
normalizedHost === "::1" ||
|
||||
normalizedHost === "[::1]"
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveServiceBindHost(daemonListenHost: string | null | undefined): string {
|
||||
return isLoopbackListenHost(daemonListenHost) ? "127.0.0.1" : "0.0.0.0";
|
||||
}
|
||||
|
||||
export async function spawnWorkspaceScript(
|
||||
@@ -734,45 +731,82 @@ export async function spawnWorkspaceScript(
|
||||
let port: number | null = null;
|
||||
let terminal: TerminalSession | null = null;
|
||||
let runtimeRegistered = false;
|
||||
let routeRegistered = false;
|
||||
|
||||
try {
|
||||
if (runtimeStore.isRunning({ workspaceId, scriptName })) {
|
||||
throw new Error(`Script '${scriptName}' is already running`);
|
||||
}
|
||||
|
||||
let env: Record<string, string> | undefined;
|
||||
if (serviceScript) {
|
||||
port = config.port ?? (await findFreePort());
|
||||
const serviceDeclarations: WorkspaceServiceDeclaration[] = [];
|
||||
for (const [configuredScriptName, scriptConfig] of scriptConfigs) {
|
||||
if (isServiceScript(scriptConfig)) {
|
||||
serviceDeclarations.push({
|
||||
scriptName: configuredScriptName,
|
||||
port: scriptConfig.port,
|
||||
});
|
||||
}
|
||||
}
|
||||
assertNoServiceEnvNameCollisions(
|
||||
serviceDeclarations.map((serviceDeclaration) => serviceDeclaration.scriptName),
|
||||
);
|
||||
|
||||
const plannedPorts = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: serviceDeclarations,
|
||||
allocatePort: findFreePort,
|
||||
});
|
||||
const existingRuntimeEntry = runtimeStore.get({ workspaceId, scriptName });
|
||||
const servicePort =
|
||||
existingRuntimeEntry?.lifecycle === "stopped"
|
||||
? await refreshWorkspaceServicePort({
|
||||
workspaceId,
|
||||
service: { scriptName, port: config.port },
|
||||
allocatePort: findFreePort,
|
||||
})
|
||||
: requirePlannedWorkspaceServicePort(plannedPorts, scriptName);
|
||||
|
||||
port = servicePort;
|
||||
|
||||
const peers: WorkspaceServicePeer[] = [];
|
||||
for (const [peerScriptName, peerPort] of plannedPorts) {
|
||||
peers.push({
|
||||
scriptName: peerScriptName,
|
||||
port: peerScriptName === scriptName ? servicePort : peerPort,
|
||||
});
|
||||
}
|
||||
|
||||
env = buildWorkspaceServiceEnv({
|
||||
scriptName,
|
||||
branchName,
|
||||
daemonPort,
|
||||
daemonListenHost,
|
||||
peers,
|
||||
});
|
||||
|
||||
routeStore.registerRoute({
|
||||
hostname: hostname!,
|
||||
port,
|
||||
port: servicePort,
|
||||
workspaceId,
|
||||
scriptName,
|
||||
});
|
||||
routeRegistered = true;
|
||||
}
|
||||
|
||||
const env = serviceScript
|
||||
? {
|
||||
PORT: String(port),
|
||||
HOST: resolveServiceBindHost(daemonListenHost),
|
||||
...(daemonPort !== null && daemonPort !== undefined
|
||||
? {
|
||||
PASEO_SCRIPT_URL: `http://${hostname!}:${daemonPort}`,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
terminal = await terminalManager.createTerminal({
|
||||
const createdTerminal = await terminalManager.createTerminal({
|
||||
cwd: repoRoot,
|
||||
name: scriptName,
|
||||
env,
|
||||
});
|
||||
terminal = createdTerminal;
|
||||
runtimeStore.set({
|
||||
workspaceId,
|
||||
scriptName,
|
||||
type: serviceScript ? "service" : "script",
|
||||
lifecycle: "running",
|
||||
terminalId: terminal.id,
|
||||
terminalId: createdTerminal.id,
|
||||
exitCode: null,
|
||||
});
|
||||
runtimeRegistered = true;
|
||||
@@ -786,7 +820,7 @@ export async function spawnWorkspaceScript(
|
||||
scriptName,
|
||||
type: serviceScript ? "service" : "script",
|
||||
lifecycle: "stopped",
|
||||
terminalId: terminal!.id,
|
||||
terminalId: createdTerminal.id,
|
||||
exitCode: info.exitCode,
|
||||
});
|
||||
onLifecycleChanged?.();
|
||||
@@ -795,7 +829,7 @@ export async function spawnWorkspaceScript(
|
||||
scriptName,
|
||||
hostname,
|
||||
exitCode: info.exitCode,
|
||||
terminalId: terminal!.id,
|
||||
terminalId: createdTerminal.id,
|
||||
},
|
||||
"Stopped worktree script",
|
||||
);
|
||||
@@ -825,7 +859,7 @@ export async function spawnWorkspaceScript(
|
||||
terminalId: terminal.id,
|
||||
};
|
||||
} catch (error) {
|
||||
if (hostname && port !== null) {
|
||||
if (routeRegistered && hostname) {
|
||||
routeStore.removeRoute(hostname);
|
||||
}
|
||||
if (runtimeRegistered) {
|
||||
|
||||
Reference in New Issue
Block a user