Support remote workspace service links
This commit is contained in:
@@ -708,6 +708,7 @@ export async function createPaseoDaemon(
|
||||
scriptRuntimeStore,
|
||||
handleBranchChange,
|
||||
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
||||
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null),
|
||||
(hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
|
||||
);
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ describe("script-status-projection", () => {
|
||||
type: "script",
|
||||
hostname: "typecheck",
|
||||
port: null,
|
||||
url: null,
|
||||
proxyUrl: null,
|
||||
lifecycle: "stopped",
|
||||
health: null,
|
||||
exitCode: 0,
|
||||
@@ -92,7 +92,7 @@ describe("script-status-projection", () => {
|
||||
type: "service",
|
||||
hostname: "web.localhost",
|
||||
port: 3000,
|
||||
url: "http://web.localhost:6767",
|
||||
proxyUrl: "http://web.localhost:6767",
|
||||
lifecycle: "stopped",
|
||||
health: null,
|
||||
exitCode: null,
|
||||
@@ -144,7 +144,7 @@ describe("script-status-projection", () => {
|
||||
type: "service",
|
||||
hostname: "feature-card.web.localhost",
|
||||
port: 4321,
|
||||
url: "http://feature-card.web.localhost:6767",
|
||||
proxyUrl: "http://feature-card.web.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
@@ -195,7 +195,7 @@ describe("script-status-projection", () => {
|
||||
type: "service",
|
||||
hostname: "web.localhost",
|
||||
port: 4321,
|
||||
url: "http://web.localhost:6767",
|
||||
proxyUrl: "http://web.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: null,
|
||||
exitCode: null,
|
||||
@@ -239,7 +239,7 @@ describe("script-status-projection", () => {
|
||||
type: "service",
|
||||
hostname: "docs.localhost",
|
||||
port: 3002,
|
||||
url: "http://docs.localhost:6767",
|
||||
proxyUrl: "http://docs.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: null,
|
||||
exitCode: null,
|
||||
@@ -277,7 +277,7 @@ describe("script-status-projection", () => {
|
||||
type: "script",
|
||||
hostname: "typecheck",
|
||||
port: null,
|
||||
url: null,
|
||||
proxyUrl: null,
|
||||
lifecycle: "running",
|
||||
health: null,
|
||||
exitCode: null,
|
||||
@@ -342,7 +342,7 @@ describe("script-status-projection", () => {
|
||||
type: "service",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
url: "http://api.localhost:6767",
|
||||
proxyUrl: "http://api.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
@@ -352,7 +352,7 @@ describe("script-status-projection", () => {
|
||||
type: "script",
|
||||
hostname: "typecheck",
|
||||
port: null,
|
||||
url: null,
|
||||
proxyUrl: null,
|
||||
lifecycle: "stopped",
|
||||
health: null,
|
||||
exitCode: null,
|
||||
|
||||
@@ -33,7 +33,7 @@ function resolveWorkspaceBranchName(workspaceDirectory: string): string | null {
|
||||
return readGitCommand(workspaceDirectory, "git symbolic-ref --short HEAD");
|
||||
}
|
||||
|
||||
function toServiceUrl(hostname: string, daemonPort: number | null): string | null {
|
||||
function toServiceProxyUrl(hostname: string, daemonPort: number | null): string | null {
|
||||
if (daemonPort === null) {
|
||||
return null;
|
||||
}
|
||||
@@ -64,7 +64,7 @@ function createConfiguredPayload(input: {
|
||||
type: input.type,
|
||||
hostname,
|
||||
port: input.type === "service" ? input.configuredPort : null,
|
||||
url: input.type === "service" ? toServiceUrl(hostname, input.daemonPort) : null,
|
||||
proxyUrl: input.type === "service" ? toServiceProxyUrl(hostname, input.daemonPort) : null,
|
||||
lifecycle: "stopped",
|
||||
health: null,
|
||||
exitCode: null,
|
||||
@@ -117,9 +117,9 @@ export function buildWorkspaceScriptPayloads(
|
||||
hostname:
|
||||
type === "service" ? (routeEntry?.hostname ?? payload.hostname) : payload.scriptName,
|
||||
port: type === "service" ? (routeEntry?.port ?? payload.port) : null,
|
||||
url:
|
||||
proxyUrl:
|
||||
type === "service"
|
||||
? toServiceUrl(routeEntry?.hostname ?? payload.hostname, options.daemonPort)
|
||||
? toServiceProxyUrl(routeEntry?.hostname ?? payload.hostname, options.daemonPort)
|
||||
: null,
|
||||
lifecycle: runtimeEntry?.lifecycle ?? payload.lifecycle,
|
||||
health:
|
||||
@@ -146,7 +146,7 @@ export function buildWorkspaceScriptPayloads(
|
||||
type,
|
||||
hostname,
|
||||
port: type === "service" ? (routeEntry?.port ?? null) : null,
|
||||
url: type === "service" ? toServiceUrl(hostname, options.daemonPort) : null,
|
||||
proxyUrl: type === "service" ? toServiceProxyUrl(hostname, options.daemonPort) : null,
|
||||
lifecycle: runtimeEntry.lifecycle,
|
||||
health:
|
||||
type === "service" && routeEntry
|
||||
|
||||
@@ -437,6 +437,7 @@ export type SessionOptions = {
|
||||
newBranch: string | null,
|
||||
) => void;
|
||||
getDaemonTcpPort?: () => number | null;
|
||||
getDaemonTcpHost?: () => string | null;
|
||||
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null;
|
||||
voice?: {
|
||||
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
|
||||
@@ -621,6 +622,7 @@ export class Session {
|
||||
newBranch: string | null,
|
||||
) => void;
|
||||
private readonly getDaemonTcpPort: (() => number | null) | null;
|
||||
private readonly getDaemonTcpHost: (() => string | null) | null;
|
||||
private readonly resolveScriptHealth:
|
||||
| ((hostname: string) => ScriptHealthState | null)
|
||||
| null;
|
||||
@@ -684,6 +686,7 @@ export class Session {
|
||||
scriptRuntimeStore,
|
||||
onBranchChanged,
|
||||
getDaemonTcpPort,
|
||||
getDaemonTcpHost,
|
||||
resolveScriptHealth,
|
||||
voice,
|
||||
voiceBridge,
|
||||
@@ -728,6 +731,7 @@ export class Session {
|
||||
this.scriptRuntimeStore = scriptRuntimeStore ?? null;
|
||||
this.onBranchChanged = onBranchChanged;
|
||||
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
|
||||
this.getDaemonTcpHost = getDaemonTcpHost ?? null;
|
||||
this.resolveScriptHealth = resolveScriptHealth ?? null;
|
||||
if (this.terminalManager) {
|
||||
this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) =>
|
||||
@@ -6429,6 +6433,7 @@ export class Session {
|
||||
branchName: readGitCommand(workspace.directory, "git symbolic-ref --short HEAD"),
|
||||
scriptName: request.scriptName,
|
||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||
daemonListenHost: this.getDaemonTcpHost?.() ?? null,
|
||||
routeStore: this.scriptRouteStore,
|
||||
runtimeStore: this.scriptRuntimeStore,
|
||||
terminalManager: this.terminalManager,
|
||||
|
||||
@@ -253,6 +253,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly scriptRouteStore: ScriptRouteStore | null;
|
||||
private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
|
||||
private readonly getDaemonTcpPort: (() => number | null) | null;
|
||||
private readonly getDaemonTcpHost: (() => string | null) | null;
|
||||
private readonly resolveScriptHealth:
|
||||
| ((hostname: string) => ScriptHealthState | null)
|
||||
| null;
|
||||
@@ -334,6 +335,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
newBranch: string | null,
|
||||
) => void,
|
||||
getDaemonTcpPort?: () => number | null,
|
||||
getDaemonTcpHost?: () => string | null,
|
||||
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null,
|
||||
) {
|
||||
this.logger = logger.child({ module: "websocket-server" });
|
||||
@@ -385,6 +387,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.scriptRuntimeStore = scriptRuntimeStore ?? null;
|
||||
this.onBranchChanged = onBranchChanged ?? null;
|
||||
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
|
||||
this.getDaemonTcpHost = getDaemonTcpHost ?? null;
|
||||
this.resolveScriptHealth = resolveScriptHealth ?? null;
|
||||
this.serverCapabilities = buildServerCapabilities({
|
||||
readiness: this.speech?.getReadiness() ?? null,
|
||||
@@ -698,6 +701,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
scriptRuntimeStore: this.scriptRuntimeStore ?? undefined,
|
||||
onBranchChanged: this.onBranchChanged ?? undefined,
|
||||
getDaemonTcpPort: this.getDaemonTcpPort ?? undefined,
|
||||
getDaemonTcpHost: this.getDaemonTcpHost ?? undefined,
|
||||
resolveScriptHealth: this.resolveScriptHealth ?? undefined,
|
||||
voice: {
|
||||
...(this.voice ?? {}),
|
||||
|
||||
@@ -657,4 +657,45 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
exitCode: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("binds services to the network when the daemon listens on a non-loopback host", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
scripts: {
|
||||
web: {
|
||||
type: "service",
|
||||
command: "npm run dev",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add remote service script config'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const routeStore = new ScriptRouteStore();
|
||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||
const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record<string, string> }> =
|
||||
[];
|
||||
|
||||
await spawnWorktreeScripts({
|
||||
repoRoot: repoDir,
|
||||
workspaceId: repoDir,
|
||||
branchName: "feature-remote-service",
|
||||
daemonPort: 6767,
|
||||
daemonListenHost: "100.64.0.20",
|
||||
routeStore,
|
||||
runtimeStore,
|
||||
terminalManager: createStubTerminalManager(createTerminalCalls) as any,
|
||||
});
|
||||
|
||||
expect(createTerminalCalls).toHaveLength(1);
|
||||
expect(createTerminalCalls[0]?.env?.HOST).toBe("0.0.0.0");
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_SCRIPT_URL).toBe(
|
||||
"http://feature-remote-service.web.localhost:6767",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -753,6 +753,7 @@ type SpawnWorkspaceScriptOptions = {
|
||||
branchName: string | null;
|
||||
scriptName: string;
|
||||
daemonPort?: number | null;
|
||||
daemonListenHost?: string | null;
|
||||
routeStore: ScriptRouteStore;
|
||||
runtimeStore: WorkspaceScriptRuntimeStore;
|
||||
terminalManager: TerminalManager;
|
||||
@@ -760,6 +761,24 @@ type SpawnWorkspaceScriptOptions = {
|
||||
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(
|
||||
options: SpawnWorkspaceScriptOptions,
|
||||
): Promise<WorktreeScriptResult> {
|
||||
@@ -769,6 +788,7 @@ export async function spawnWorkspaceScript(
|
||||
branchName,
|
||||
scriptName,
|
||||
daemonPort,
|
||||
daemonListenHost,
|
||||
routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
@@ -805,7 +825,7 @@ export async function spawnWorkspaceScript(
|
||||
const env = serviceScript
|
||||
? {
|
||||
PORT: String(port),
|
||||
HOST: "127.0.0.1",
|
||||
HOST: resolveServiceBindHost(daemonListenHost),
|
||||
...(daemonPort !== null && daemonPort !== undefined
|
||||
? {
|
||||
PASEO_SCRIPT_URL: `http://${hostname!}:${daemonPort}`,
|
||||
@@ -898,6 +918,7 @@ export async function spawnWorktreeScripts(options: {
|
||||
workspaceId: string;
|
||||
branchName: string | null;
|
||||
daemonPort?: number | null;
|
||||
daemonListenHost?: string | null;
|
||||
routeStore: ScriptRouteStore;
|
||||
runtimeStore: WorkspaceScriptRuntimeStore;
|
||||
terminalManager: TerminalManager;
|
||||
|
||||
@@ -1866,7 +1866,7 @@ export const WorkspaceScriptPayloadSchema = z.object({
|
||||
type: z.enum(["script", "service"]).optional().default("service"),
|
||||
hostname: z.string(),
|
||||
port: z.number().int().positive().nullable(),
|
||||
url: z.string().nullable(),
|
||||
proxyUrl: z.string().nullable().optional().default(null),
|
||||
lifecycle: WorkspaceScriptLifecycleSchema,
|
||||
health: WorkspaceScriptHealthSchema.nullable(),
|
||||
exitCode: z.number().nullable().optional().default(null),
|
||||
|
||||
@@ -139,7 +139,7 @@ describe("workspace message schemas", () => {
|
||||
scriptName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: 3000,
|
||||
url: "http://web.localhost:6767",
|
||||
proxyUrl: "http://web.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
},
|
||||
@@ -158,7 +158,7 @@ describe("workspace message schemas", () => {
|
||||
type: "service",
|
||||
hostname: "web.localhost",
|
||||
port: 3000,
|
||||
url: "http://web.localhost:6767",
|
||||
proxyUrl: "http://web.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
@@ -205,7 +205,7 @@ describe("workspace message schemas", () => {
|
||||
scriptName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: null,
|
||||
url: null,
|
||||
proxyUrl: null,
|
||||
lifecycle: "stopped",
|
||||
health: null,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user