Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
+3
-1
@@ -158,12 +158,14 @@ Every `scripts` entry with `"type": "service"` receives these environment variab
|
||||
|
||||
| Variable | Value |
|
||||
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PASEO_SERVICE_<NAME>_URL` | Proxied daemon URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
|
||||
| `PASEO_SERVICE_<NAME>_URL` | Proxied URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
|
||||
| `PASEO_SERVICE_<NAME>_PORT` | Raw ephemeral port for a declared peer service. Use only as a bypass escape hatch; it can go stale if that peer restarts. |
|
||||
| `PASEO_URL` | Self alias for `PASEO_SERVICE_<SELF>_URL`. |
|
||||
| `PASEO_PORT` | Self alias for `PASEO_SERVICE_<SELF>_PORT`. |
|
||||
| `HOST` | Bind host for the service process. |
|
||||
|
||||
Service proxy hostnames use the double-dash shape: `web--feature-auth--project.localhost` or, on the default branch, `web--project.localhost`. Optional public aliases use the same leftmost label under the configured public base host.
|
||||
|
||||
`<NAME>` is normalized from the script name by uppercasing it, replacing each run of non-`A-Z0-9` characters with `_`, and trimming leading or trailing `_`. For example, `app-server` and `app.server` both normalize to `APP_SERVER`; that collision fails at spawn time with an actionable error.
|
||||
|
||||
`PORT` is not injected by default. If a framework requires `PORT`, set it in the command:
|
||||
|
||||
+30
-28
@@ -1,6 +1,6 @@
|
||||
# Service Proxy
|
||||
|
||||
Paseo can proxy HTTP traffic to services running inside your workspaces and expose them at stable, publicly-accessible URLs. This lets you open a running dev server from your phone or share it with a teammate without any tunneling tools.
|
||||
Paseo proxies HTTP traffic to services running inside your workspaces. Localhost service URLs are always enabled; optional public aliases and a separate service-only listener can be layered on through config.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -9,22 +9,22 @@ When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a loc
|
||||
The generated hostname is built from the script name, branch, and project:
|
||||
|
||||
```
|
||||
<script>--<branch>--<project>.<base-domain>
|
||||
<script>--<branch>--<project>.localhost
|
||||
```
|
||||
|
||||
If the branch is `main` or `master`, the branch segment is omitted:
|
||||
|
||||
```
|
||||
<script>--<project>.<base-domain>
|
||||
<script>--<project>.localhost
|
||||
```
|
||||
|
||||
**Example:** a script named `dev` in the `miniweb` project on branch `feature/auth` would be reachable at:
|
||||
|
||||
```
|
||||
dev--feature-auth--miniweb.paseoapps.my.domain.com
|
||||
dev--feature-auth--miniweb.localhost
|
||||
```
|
||||
|
||||
The public route uses one combined leftmost label (`script--branch--project`) rather than the local development shape (`script.branch.project.localhost`). This is intentional: dotted script/branch/project hostnames would require multi-level wildcard DNS and certificates for arbitrary branch names, while a single label works with normal `*.base-domain` DNS and wildcard TLS. If the combined label would exceed DNS's 63-character label limit, Paseo truncates it.
|
||||
Local and public routes use one combined leftmost label (`script--branch--project`). This keeps the hostname compatible with normal single-level wildcard DNS and TLS. If the combined label would exceed DNS's 63-character label limit, Paseo truncates it with a deterministic hash suffix to avoid collisions.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -35,7 +35,6 @@ Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`:
|
||||
"version": 1,
|
||||
"daemon": {
|
||||
"serviceProxy": {
|
||||
"enabled": true,
|
||||
"listen": "0.0.0.0:8080",
|
||||
"publicBaseUrl": "https://paseoapps.my.domain.com"
|
||||
}
|
||||
@@ -43,11 +42,12 @@ Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`:
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
| --------------- | -------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `enabled` | No | Enables the proxy. Defaults to `true` if `publicBaseUrl` is set, `false` otherwise. |
|
||||
| `listen` | No | Address and port the proxy listens on. Defaults to `127.0.0.1:6868`. |
|
||||
| `publicBaseUrl` | No | Base URL used to generate public service links. If omitted, links use localhost addresses only. |
|
||||
| Field | Required | Description |
|
||||
| --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `listen` | No | Starts a separate service-only listener at this address. If omitted, services are still reachable on the daemon listener via localhost hosts. |
|
||||
| `publicBaseUrl` | No | Adds public service host aliases and public service links. If omitted, links use localhost addresses only. |
|
||||
|
||||
`enabled` is accepted for old configs but no longer enables a mode. `enabled: false` suppresses optional `listen`/`publicBaseUrl` layers only; localhost service proxying remains always enabled.
|
||||
|
||||
## DNS and reverse proxy setup
|
||||
|
||||
@@ -63,28 +63,30 @@ For generated URLs to be reachable, you need wildcard DNS pointing to the machin
|
||||
|
||||
2. Set `publicBaseUrl` to `https://paseoapps.my.domain.com` in your config.
|
||||
|
||||
3. If you put a reverse proxy (nginx, Caddy, Traefik, etc.) in front of the daemon, ensure it forwards the `Host` header unchanged. The proxy uses the `Host` header to route requests to the correct service — rewriting it will break routing.
|
||||
3. If you put a reverse proxy (nginx, Caddy, Traefik, etc.) in front of Paseo, point it at either the daemon listener or the optional service-only listener and ensure it forwards the `Host` header unchanged. The proxy uses the `Host` header to route requests to the correct service — rewriting it will break routing.
|
||||
|
||||
Nginx example:
|
||||
Public service URLs expose the workspace service itself. Daemon password authentication protects daemon APIs; it does not protect proxied dev services.
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name *.paseoapps.my.domain.com;
|
||||
Nginx example:
|
||||
|
||||
location / {
|
||||
proxy_pass http://10.1.1.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
```
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name *.paseoapps.my.domain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://10.1.1.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
The listen address and public base URL can also be set via environment variables, which take precedence over `config.json`:
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------------------- | ----------------------------------- |
|
||||
| `PASEO_SERVICE_PROXY_ENABLED` | `true` or `false` |
|
||||
| `PASEO_SERVICE_PROXY_LISTEN` | Listen address, e.g. `0.0.0.0:8080` |
|
||||
| `PASEO_SERVICE_PROXY_PUBLIC_BASE_URL` | Public base URL |
|
||||
| Variable | Description |
|
||||
| ------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| `PASEO_SERVICE_PROXY_ENABLED` | Compatibility shim; `false` suppresses optional public/listen layers only |
|
||||
| `PASEO_SERVICE_PROXY_LISTEN` | Starts the optional service-only listener, e.g. `0.0.0.0:8080` |
|
||||
| `PASEO_SERVICE_PROXY_PUBLIC_BASE_URL` | Adds public service aliases and links |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
|
||||
|
||||
export function derivePendingPermissionKey(
|
||||
agentId: string,
|
||||
@@ -26,11 +26,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
||||
? new Date(snapshot.attentionTimestamp)
|
||||
: null;
|
||||
const archivedAt = snapshot.archivedAt ? new Date(snapshot.archivedAt) : null;
|
||||
const parentAgentLabel = snapshot.labels?.[PARENT_AGENT_ID_LABEL];
|
||||
const parentAgentId =
|
||||
typeof parentAgentLabel === "string" && parentAgentLabel.trim().length > 0
|
||||
? parentAgentLabel.trim()
|
||||
: null;
|
||||
const parentAgentId = getParentAgentIdFromLabels(snapshot.labels);
|
||||
|
||||
return {
|
||||
serverId,
|
||||
|
||||
@@ -6,9 +6,11 @@ import { resolveWorkspaceScriptLink } from "./workspace-script-links";
|
||||
const runningService: WorkspaceScriptPayload = {
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "web.feature.paseo.localhost",
|
||||
hostname: "web--feature--paseo.localhost",
|
||||
port: 3000,
|
||||
proxyUrl: "http://web.feature.paseo.localhost:6767",
|
||||
localProxyUrl: "http://web--feature--paseo.localhost:6767",
|
||||
publicProxyUrl: null,
|
||||
proxyUrl: "http://web--feature--paseo.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
@@ -27,8 +29,8 @@ describe("resolveWorkspaceScriptLink", () => {
|
||||
expect(
|
||||
resolveLink({ type: "directTcp", endpoint: "localhost:6767", display: "localhost:6767" }),
|
||||
).toEqual({
|
||||
openUrl: "http://web.feature.paseo.localhost:6767",
|
||||
labelUrl: "http://web.feature.paseo.localhost:6767",
|
||||
openUrl: "http://web--feature--paseo.localhost:6767",
|
||||
labelUrl: "http://web--feature--paseo.localhost:6767",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,8 +38,8 @@ describe("resolveWorkspaceScriptLink", () => {
|
||||
expect(
|
||||
resolveLink({ type: "directSocket", endpoint: "/tmp/paseo.sock", display: "socket" }),
|
||||
).toEqual({
|
||||
openUrl: "http://web.feature.paseo.localhost:6767",
|
||||
labelUrl: "http://web.feature.paseo.localhost:6767",
|
||||
openUrl: "http://web--feature--paseo.localhost:6767",
|
||||
labelUrl: "http://web--feature--paseo.localhost:6767",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,7 +61,114 @@ describe("resolveWorkspaceScriptLink", () => {
|
||||
resolveLink({ type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" }),
|
||||
).toEqual({
|
||||
openUrl: null,
|
||||
labelUrl: "http://web.feature.paseo.localhost:6767",
|
||||
labelUrl: "http://web--feature--paseo.localhost:6767",
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the public URL over relay when one is provided", () => {
|
||||
expect(
|
||||
resolveWorkspaceScriptLink({
|
||||
script: {
|
||||
...runningService,
|
||||
publicProxyUrl: "https://web--feature--paseo.services.example.com",
|
||||
proxyUrl: "https://web--feature--paseo.services.example.com",
|
||||
},
|
||||
activeConnection: { type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" },
|
||||
}),
|
||||
).toEqual({
|
||||
openUrl: "https://web--feature--paseo.services.example.com",
|
||||
labelUrl: "https://web--feature--paseo.services.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses local URL for direct loopback even when public URL exists", () => {
|
||||
expect(
|
||||
resolveWorkspaceScriptLink({
|
||||
script: {
|
||||
...runningService,
|
||||
publicProxyUrl: "https://web--feature--paseo.services.example.com",
|
||||
proxyUrl: "https://web--feature--paseo.services.example.com",
|
||||
},
|
||||
activeConnection: { type: "directTcp", endpoint: "127.0.0.1:6767", display: "localhost" },
|
||||
}),
|
||||
).toEqual({
|
||||
openUrl: "http://web--feature--paseo.localhost:6767",
|
||||
labelUrl: "http://web--feature--paseo.localhost:6767",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses local URL for direct socket and pipe even when public URL exists", () => {
|
||||
expect(
|
||||
resolveWorkspaceScriptLink({
|
||||
script: {
|
||||
...runningService,
|
||||
publicProxyUrl: "https://web--feature--paseo.services.example.com",
|
||||
proxyUrl: "https://web--feature--paseo.services.example.com",
|
||||
},
|
||||
activeConnection: { type: "directPipe", endpoint: "paseo", display: "pipe" },
|
||||
}),
|
||||
).toEqual({
|
||||
openUrl: "http://web--feature--paseo.localhost:6767",
|
||||
labelUrl: "http://web--feature--paseo.localhost:6767",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses public URL for direct remote TCP when split URLs exist", () => {
|
||||
expect(
|
||||
resolveWorkspaceScriptLink({
|
||||
script: {
|
||||
...runningService,
|
||||
publicProxyUrl: "https://web--feature--paseo.services.example.com",
|
||||
proxyUrl: "https://web--feature--paseo.services.example.com",
|
||||
},
|
||||
activeConnection: {
|
||||
type: "directTcp",
|
||||
endpoint: "mac-mini.tail123.ts.net:6767",
|
||||
display: "remote",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
openUrl: "https://web--feature--paseo.services.example.com",
|
||||
labelUrl: "https://web--feature--paseo.services.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps old daemon local-only proxyUrl payloads working", () => {
|
||||
const {
|
||||
localProxyUrl: _localProxyUrl,
|
||||
publicProxyUrl: _publicProxyUrl,
|
||||
...oldPayload
|
||||
} = runningService;
|
||||
|
||||
expect(
|
||||
resolveWorkspaceScriptLink({
|
||||
script: oldPayload,
|
||||
activeConnection: { type: "directTcp", endpoint: "localhost:6767", display: "localhost" },
|
||||
}),
|
||||
).toEqual({
|
||||
openUrl: "http://web--feature--paseo.localhost:6767",
|
||||
labelUrl: "http://web--feature--paseo.localhost:6767",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps old daemon public proxyUrl payloads working over relay", () => {
|
||||
const {
|
||||
localProxyUrl: _localProxyUrl,
|
||||
publicProxyUrl: _publicProxyUrl,
|
||||
...oldPayload
|
||||
} = {
|
||||
...runningService,
|
||||
proxyUrl: "https://web--feature--paseo.services.example.com",
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveWorkspaceScriptLink({
|
||||
script: oldPayload,
|
||||
activeConnection: { type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" },
|
||||
}),
|
||||
).toEqual({
|
||||
openUrl: "https://web--feature--paseo.services.example.com",
|
||||
labelUrl: "https://web--feature--paseo.services.example.com",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,27 +50,33 @@ export function resolveWorkspaceScriptLink(input: {
|
||||
return { openUrl: null, labelUrl: script.proxyUrl };
|
||||
}
|
||||
|
||||
const proxyUrlIsLocalOnly = isLocalOnlyUrl(script.proxyUrl);
|
||||
|
||||
if (!proxyUrlIsLocalOnly) {
|
||||
return { openUrl: script.proxyUrl, labelUrl: script.proxyUrl };
|
||||
}
|
||||
const localProxyUrl = script.localProxyUrl ?? script.proxyUrl;
|
||||
const publicProxyUrl =
|
||||
script.publicProxyUrl ?? (!isLocalOnlyUrl(script.proxyUrl) ? script.proxyUrl : null);
|
||||
const preferredProxyUrl = publicProxyUrl ?? localProxyUrl ?? script.proxyUrl;
|
||||
|
||||
if (activeConnection.type === "relay") {
|
||||
return { openUrl: null, labelUrl: script.proxyUrl };
|
||||
return {
|
||||
openUrl: publicProxyUrl,
|
||||
labelUrl: publicProxyUrl ?? localProxyUrl ?? script.proxyUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (activeConnection.type === "directSocket" || activeConnection.type === "directPipe") {
|
||||
return { openUrl: script.proxyUrl, labelUrl: script.proxyUrl };
|
||||
return { openUrl: localProxyUrl, labelUrl: localProxyUrl };
|
||||
}
|
||||
|
||||
try {
|
||||
const { host } = parseHostPort(activeConnection.endpoint);
|
||||
if (isLoopbackHost(host)) {
|
||||
return { openUrl: script.proxyUrl, labelUrl: script.proxyUrl };
|
||||
return { openUrl: localProxyUrl, labelUrl: localProxyUrl };
|
||||
}
|
||||
} catch {
|
||||
return { openUrl: null, labelUrl: script.proxyUrl };
|
||||
return { openUrl: null, labelUrl: preferredProxyUrl };
|
||||
}
|
||||
|
||||
if (publicProxyUrl) {
|
||||
return { openUrl: publicProxyUrl, labelUrl: publicProxyUrl };
|
||||
}
|
||||
|
||||
if (script.port === null) {
|
||||
@@ -80,6 +86,6 @@ export function resolveWorkspaceScriptLink(input: {
|
||||
const directUrl = buildDirectServiceUrl(activeConnection.endpoint, script.port);
|
||||
return {
|
||||
openUrl: directUrl,
|
||||
labelUrl: directUrl ?? script.proxyUrl,
|
||||
labelUrl: directUrl ?? preferredProxyUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
getParentAgentIdFromLabels,
|
||||
isDelegatedAgent,
|
||||
PARENT_AGENT_ID_LABEL,
|
||||
} from "./agent-labels.js";
|
||||
|
||||
describe("agent label policy", () => {
|
||||
test("treats a non-empty parent agent label as delegation", () => {
|
||||
const labels = { [PARENT_AGENT_ID_LABEL]: " parent-agent \n" };
|
||||
|
||||
expect(getParentAgentIdFromLabels(labels)).toBe("parent-agent");
|
||||
expect(isDelegatedAgent({ labels })).toBe(true);
|
||||
});
|
||||
|
||||
test("ignores missing, empty, and non-string parent agent labels", () => {
|
||||
expect(isDelegatedAgent({ labels: {} })).toBe(false);
|
||||
expect(isDelegatedAgent({ labels: { [PARENT_AGENT_ID_LABEL]: " " } })).toBe(false);
|
||||
expect(isDelegatedAgent({ labels: { [PARENT_AGENT_ID_LABEL]: 42 } })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1 +1,16 @@
|
||||
export const PARENT_AGENT_ID_LABEL = "paseo.parent-agent-id";
|
||||
|
||||
export interface AgentLabelSource {
|
||||
labels?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export function getParentAgentIdFromLabels(labels: Record<string, unknown> | null | undefined) {
|
||||
const parentAgentId = labels?.[PARENT_AGENT_ID_LABEL];
|
||||
return typeof parentAgentId === "string" && parentAgentId.trim().length > 0
|
||||
? parentAgentId.trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
export function isDelegatedAgent(agent: AgentLabelSource): boolean {
|
||||
return getParentAgentIdFromLabels(agent.labels) !== null;
|
||||
}
|
||||
|
||||
@@ -2325,6 +2325,8 @@ export const WorkspaceScriptPayloadSchema = z.object({
|
||||
type: z.enum(["script", "service"]).optional().default("service"),
|
||||
hostname: z.string(),
|
||||
port: z.number().int().positive().nullable(),
|
||||
localProxyUrl: z.string().nullable().optional(),
|
||||
publicProxyUrl: z.string().nullable().optional(),
|
||||
proxyUrl: z.string().nullable().optional().default(null),
|
||||
lifecycle: WorkspaceScriptLifecycleSchema,
|
||||
health: WorkspaceScriptHealthSchema.nullable(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
SessionInboundMessageSchema,
|
||||
SessionOutboundMessageSchema,
|
||||
WorkspaceDescriptorPayloadSchema,
|
||||
WorkspaceScriptPayloadSchema,
|
||||
} from "./messages.js";
|
||||
|
||||
describe("workspace message schemas", () => {
|
||||
@@ -485,6 +486,53 @@ describe("workspace message schemas", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("parses workspace service payloads from old daemons without split proxy URLs", () => {
|
||||
const parsed = WorkspaceScriptPayloadSchema.parse({
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "web--repo.localhost",
|
||||
port: 3000,
|
||||
proxyUrl: "http://web--repo.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
});
|
||||
|
||||
expect(parsed.localProxyUrl).toBeUndefined();
|
||||
expect(parsed.publicProxyUrl).toBeUndefined();
|
||||
expect(parsed.proxyUrl).toBe("http://web--repo.localhost:6767");
|
||||
});
|
||||
|
||||
test("parses workspace service payloads with split local and public proxy URLs", () => {
|
||||
const parsed = WorkspaceScriptPayloadSchema.parse({
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "web--repo.localhost",
|
||||
port: 3000,
|
||||
localProxyUrl: "http://web--repo.localhost:6767",
|
||||
publicProxyUrl: "https://web--repo.services.example.com",
|
||||
proxyUrl: "https://web--repo.services.example.com",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
});
|
||||
|
||||
expect(parsed.localProxyUrl).toBe("http://web--repo.localhost:6767");
|
||||
expect(parsed.publicProxyUrl).toBe("https://web--repo.services.example.com");
|
||||
expect(parsed.proxyUrl).toBe("https://web--repo.services.example.com");
|
||||
});
|
||||
|
||||
test("defaults omitted workspace script proxyUrl to null", () => {
|
||||
const parsed = WorkspaceScriptPayloadSchema.parse({
|
||||
scriptName: "typecheck",
|
||||
type: "script",
|
||||
hostname: "typecheck",
|
||||
port: null,
|
||||
lifecycle: "stopped",
|
||||
health: null,
|
||||
});
|
||||
|
||||
expect(parsed.proxyUrl).toBeNull();
|
||||
});
|
||||
|
||||
test("parses workspace_setup_progress payload", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "workspace_setup_progress",
|
||||
|
||||
@@ -3892,6 +3892,39 @@ test("onAgentAttention is not called for internal agents", async () => {
|
||||
expect(attentionCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("onAgentAttention is not called for delegated child agents", async () => {
|
||||
const childAgentId = "00000000-0000-4000-8000-000000000112";
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const attentionCalls: string[] = [];
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => childAgentId,
|
||||
onAgentAttention: ({ agentId }) => {
|
||||
attentionCalls.push(agentId);
|
||||
},
|
||||
});
|
||||
|
||||
const agent = await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: "Delegated Child Agent",
|
||||
},
|
||||
undefined,
|
||||
{ labels: { [PARENT_AGENT_ID_LABEL]: "parent-agent" } },
|
||||
);
|
||||
|
||||
await manager.runAgent(agent.id, "hello");
|
||||
|
||||
expect(attentionCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test("clearAgentAttention on errored agent stays cleared until a new error transition", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-attention-error-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
AGENT_LIFECYCLE_STATUSES,
|
||||
type AgentLifecycleStatus,
|
||||
} from "@getpaseo/protocol/agent-lifecycle";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { isDelegatedAgent, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
@@ -3368,6 +3368,10 @@ export class AgentManager {
|
||||
agent: ManagedAgent,
|
||||
reason: "finished" | "error" | "permission",
|
||||
): void {
|
||||
if (isDelegatedAgent(agent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.onAgentAttention?.({
|
||||
agentId: agent.id,
|
||||
provider: agent.provider,
|
||||
|
||||
@@ -97,6 +97,18 @@ class SessionEvents {
|
||||
.map((event) => event.item);
|
||||
}
|
||||
|
||||
timelineAndCompletionEvents() {
|
||||
return this.events.flatMap((event) => {
|
||||
if (event.type === "timeline") {
|
||||
return [{ type: "timeline" as const, item: event.item }];
|
||||
}
|
||||
if (event.type === "turn_completed") {
|
||||
return [{ type: "turn_completed" as const }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
nextTurnCompletion(): Promise<Extract<AgentStreamEvent, { type: "turn_completed" }>> {
|
||||
return this.nextEvent(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "turn_completed" }> =>
|
||||
@@ -459,6 +471,28 @@ describe("PiRpcAgentSession", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("surfaces Pi extension command messages and completes when no agent turn starts", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
|
||||
await session.startTurn("/show-status");
|
||||
fakeSession.emit({
|
||||
type: "message_end",
|
||||
message: {
|
||||
role: "custom",
|
||||
content: [{ type: "text", text: "Extension command output" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(events.timelineAndCompletionEvents()).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
item: { type: "assistant_message", text: "Extension command output" },
|
||||
},
|
||||
{ type: "turn_completed" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("adds Pi assistant context to generic provider finish errors", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
|
||||
|
||||
@@ -1499,6 +1499,20 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
event: Extract<PiAgentSessionEvent, { type: "message_end" }>,
|
||||
turnId: string | undefined,
|
||||
): void {
|
||||
if (event.message.role === "custom") {
|
||||
const text = getUserMessageText(event.message.content);
|
||||
if (text) {
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: PI_PROVIDER,
|
||||
turnId,
|
||||
item: { type: "assistant_message", text },
|
||||
});
|
||||
}
|
||||
this.completeTurn(turnId, []);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.message.role !== "user") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ export type PiAgentMessage =
|
||||
role: "user";
|
||||
content: string | Array<PiTextContent | PiImageContent>;
|
||||
}
|
||||
| {
|
||||
role: "custom";
|
||||
content: string | Array<PiTextContent | PiImageContent>;
|
||||
}
|
||||
| {
|
||||
role: "assistant";
|
||||
content: PiAssistantContent[];
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import os from "node:os";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import pino from "pino";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
|
||||
import { hashDaemonPassword } from "./auth.js";
|
||||
import { generateLocalPairingOffer } from "./pairing-offer.js";
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { findFreePort } from "./service-proxy.js";
|
||||
|
||||
describe("paseo daemon bootstrap", () => {
|
||||
afterEach(() => {
|
||||
@@ -41,6 +45,253 @@ describe("paseo daemon bootstrap", () => {
|
||||
}
|
||||
});
|
||||
|
||||
function httpGetWithHost(port: number, host: string, requestPath: string): Promise<Response> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.get(
|
||||
{ hostname: "127.0.0.1", port, path: requestPath, headers: { host } },
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", () => {
|
||||
resolve(
|
||||
new Response(Buffer.concat(chunks), {
|
||||
status: res.statusCode ?? 0,
|
||||
headers: res.headers as HeadersInit,
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
test("proxies registered service hosts before daemon auth while daemon APIs stay protected", async () => {
|
||||
const upstream = http.createServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "text/plain" });
|
||||
res.end("service-ok");
|
||||
});
|
||||
await new Promise<void>((resolve) => upstream.listen(0, "127.0.0.1", resolve));
|
||||
const address = upstream.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected upstream TCP address");
|
||||
}
|
||||
|
||||
const daemonHandle = await createTestPaseoDaemon({
|
||||
auth: { password: hashDaemonPassword("secret") },
|
||||
});
|
||||
try {
|
||||
daemonHandle.daemon.serviceProxy.registerWorkspaceService({
|
||||
workspaceId: "workspace-service-auth",
|
||||
projectSlug: "repo",
|
||||
branchName: "main",
|
||||
scriptName: "web",
|
||||
port: address.port,
|
||||
});
|
||||
|
||||
const serviceResponse = await httpGetWithHost(
|
||||
daemonHandle.port,
|
||||
`web--repo.localhost:${daemonHandle.port}`,
|
||||
"/",
|
||||
);
|
||||
expect(serviceResponse.status).toBe(200);
|
||||
expect(await serviceResponse.text()).toBe("service-ok");
|
||||
|
||||
const daemonResponse = await httpGetWithHost(
|
||||
daemonHandle.port,
|
||||
`daemon.localhost:${daemonHandle.port}`,
|
||||
"/api/status",
|
||||
);
|
||||
expect(daemonResponse.status).toBe(401);
|
||||
} finally {
|
||||
await daemonHandle.close();
|
||||
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test("configured public service namespace misses never reach daemon APIs", async () => {
|
||||
const daemonHandle = await createTestPaseoDaemon({
|
||||
serviceProxy: {
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
standaloneListen: null,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await httpGetWithHost(
|
||||
daemonHandle.port,
|
||||
`missing.services.example.com:${daemonHandle.port}`,
|
||||
"/api/status",
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.text()).toBe("404 Not Found");
|
||||
} finally {
|
||||
await daemonHandle.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("rolls back daemon listener when standalone service proxy startup fails", async () => {
|
||||
const occupiedServer = http.createServer((_req, res) => {
|
||||
res.end("occupied");
|
||||
});
|
||||
await new Promise<void>((resolve) => occupiedServer.listen(0, "127.0.0.1", resolve));
|
||||
const address = occupiedServer.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected occupied TCP address");
|
||||
}
|
||||
|
||||
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-standalone-rollback-"));
|
||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
await mkdir(paseoHome, { recursive: true });
|
||||
const config: PaseoDaemonConfig = {
|
||||
listen: "127.0.0.1:0",
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
hostnames: true,
|
||||
mcpEnabled: false,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
agentClients: createTestAgentClients(),
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
relayEnabled: false,
|
||||
appBaseUrl: "https://app.paseo.sh",
|
||||
openai: undefined,
|
||||
speech: undefined,
|
||||
serviceProxy: {
|
||||
standaloneListen: `127.0.0.1:${address.port}`,
|
||||
},
|
||||
};
|
||||
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
|
||||
|
||||
try {
|
||||
await expect(daemon.start()).rejects.toThrow();
|
||||
await expect(fetch(`http://127.0.0.1:${daemon.port}/api/health`)).rejects.toThrow();
|
||||
} finally {
|
||||
await daemon.stop().catch(() => undefined);
|
||||
await new Promise<void>((resolve) => occupiedServer.close(() => resolve()));
|
||||
await rm(paseoHomeRoot, { recursive: true, force: true });
|
||||
await rm(staticDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("local service namespace misses never reach daemon APIs", async () => {
|
||||
const daemonHandle = await createTestPaseoDaemon({
|
||||
auth: { password: hashDaemonPassword("secret") },
|
||||
});
|
||||
try {
|
||||
const response = await httpGetWithHost(
|
||||
daemonHandle.port,
|
||||
`missing--repo.localhost:${daemonHandle.port}`,
|
||||
"/api/status",
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.text()).toBe("404 Not Found");
|
||||
} finally {
|
||||
await daemonHandle.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("daemon websocket still upgrades when service proxy upgrade handler is mounted", async () => {
|
||||
const daemonHandle = await createTestPaseoDaemon();
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${daemonHandle.port}/ws`);
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.once("open", resolve);
|
||||
ws.once("error", reject);
|
||||
});
|
||||
expect(ws.readyState).toBe(WebSocket.OPEN);
|
||||
} finally {
|
||||
ws.close();
|
||||
await daemonHandle.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("standalone listener exposes services only", async () => {
|
||||
const standalonePort = await findFreePort();
|
||||
const upstream = http.createServer((_req, res) => {
|
||||
res.end("service-ok");
|
||||
});
|
||||
await new Promise<void>((resolve) => upstream.listen(0, "127.0.0.1", resolve));
|
||||
const upstreamAddress = upstream.address();
|
||||
if (!upstreamAddress || typeof upstreamAddress === "string") {
|
||||
throw new Error("Expected upstream TCP address");
|
||||
}
|
||||
|
||||
const daemonHandle = await createTestPaseoDaemon({
|
||||
serviceProxy: { standaloneListen: `127.0.0.1:${standalonePort}` },
|
||||
});
|
||||
try {
|
||||
daemonHandle.daemon.serviceProxy.registerWorkspaceService({
|
||||
workspaceId: "workspace-standalone",
|
||||
projectSlug: "repo",
|
||||
branchName: "main",
|
||||
scriptName: "web",
|
||||
port: upstreamAddress.port,
|
||||
});
|
||||
|
||||
const serviceResponse = await httpGetWithHost(
|
||||
standalonePort,
|
||||
`web--repo.localhost:${standalonePort}`,
|
||||
"/",
|
||||
);
|
||||
expect(serviceResponse.status).toBe(200);
|
||||
expect(await serviceResponse.text()).toBe("service-ok");
|
||||
|
||||
for (const requestPath of ["/api/health", "/ws", "/mcp/agents", "/index.html", "/files/x"]) {
|
||||
const response = await httpGetWithHost(
|
||||
standalonePort,
|
||||
`daemon.localhost:${standalonePort}`,
|
||||
requestPath,
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
}
|
||||
} finally {
|
||||
await daemonHandle.close();
|
||||
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test("rolls back already-open standalone listener when main daemon listen fails", async () => {
|
||||
const mainPort = await findFreePort();
|
||||
const standalonePort = await findFreePort();
|
||||
const occupiedMain = http.createServer((_req, res) => {
|
||||
res.end("occupied-main");
|
||||
});
|
||||
await new Promise<void>((resolve) => occupiedMain.listen(mainPort, "127.0.0.1", resolve));
|
||||
|
||||
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-main-rollback-"));
|
||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
await mkdir(paseoHome, { recursive: true });
|
||||
const config: PaseoDaemonConfig = {
|
||||
listen: `127.0.0.1:${mainPort}`,
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
hostnames: true,
|
||||
mcpEnabled: false,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
agentClients: createTestAgentClients(),
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
relayEnabled: false,
|
||||
appBaseUrl: "https://app.paseo.sh",
|
||||
openai: undefined,
|
||||
speech: undefined,
|
||||
serviceProxy: { standaloneListen: `127.0.0.1:${standalonePort}` },
|
||||
};
|
||||
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
|
||||
|
||||
try {
|
||||
await expect(daemon.start()).rejects.toThrow();
|
||||
await expect(fetch(`http://127.0.0.1:${standalonePort}/api/health`)).rejects.toThrow();
|
||||
} finally {
|
||||
await daemon.stop().catch(() => undefined);
|
||||
await new Promise<void>((resolve) => occupiedMain.close(() => resolve()));
|
||||
await rm(paseoHomeRoot, { recursive: true, force: true });
|
||||
await rm(staticDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("redacts Agent MCP debug request credentials and bodies", async () => {
|
||||
const logLines: string[] = [];
|
||||
const logger = pino(
|
||||
|
||||
@@ -126,11 +126,7 @@ import type {
|
||||
ProviderOverride,
|
||||
} from "./agent/provider-launch-config.js";
|
||||
import type { PersistedConfig } from "./persisted-config.js";
|
||||
import {
|
||||
ScriptRouteStore,
|
||||
createScriptProxyMiddleware,
|
||||
createScriptProxyUpgradeHandler,
|
||||
} from "./script-proxy.js";
|
||||
import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js";
|
||||
import { ScriptHealthMonitor } from "./script-health-monitor.js";
|
||||
import { createScriptStatusEmitter } from "./script-status-projection.js";
|
||||
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||
@@ -250,9 +246,8 @@ export interface PaseoDaemonConfig {
|
||||
relayUseTls?: boolean;
|
||||
relayPublicUseTls?: boolean;
|
||||
serviceProxy?: {
|
||||
enabled: boolean;
|
||||
listen: string;
|
||||
publicBaseUrl: string | null;
|
||||
standaloneListen: string | null;
|
||||
};
|
||||
appBaseUrl?: string;
|
||||
auth?: DaemonAuthConfig;
|
||||
@@ -282,7 +277,7 @@ export interface PaseoDaemon {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
terminalManager: TerminalManager;
|
||||
scriptRouteStore: ScriptRouteStore;
|
||||
serviceProxy: ServiceProxySubsystem;
|
||||
scriptRuntimeStore: WorkspaceScriptRuntimeStore;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
@@ -336,24 +331,25 @@ export async function createPaseoDaemon(
|
||||
let boundListenTarget: ListenTarget | null = null;
|
||||
let workspaceRegistry: FileBackedWorkspaceRegistry | null = null;
|
||||
|
||||
const scriptRouteStore = new ScriptRouteStore();
|
||||
const serviceProxyPublicBaseUrl = config.serviceProxy?.publicBaseUrl
|
||||
? config.serviceProxy.publicBaseUrl
|
||||
: null;
|
||||
const serviceProxy = createServiceProxySubsystem({
|
||||
logger,
|
||||
publicBaseUrl: serviceProxyPublicBaseUrl,
|
||||
});
|
||||
const scriptRuntimeStore = new WorkspaceScriptRuntimeStore();
|
||||
const configuredHostnames = config.hostnames ?? config.allowedHosts;
|
||||
const serviceProxyPublicBaseUrl =
|
||||
config.serviceProxy?.enabled && config.serviceProxy.publicBaseUrl
|
||||
? config.serviceProxy.publicBaseUrl
|
||||
: null;
|
||||
let wsServer: VoiceAssistantWebSocketServer | null = null;
|
||||
let serviceProxyHttpServer: ReturnType<typeof createHTTPServer> | null = null;
|
||||
let serviceProxyListenTarget: ListenTarget | null = null;
|
||||
const scriptHealthMonitor = new ScriptHealthMonitor({
|
||||
routeStore: scriptRouteStore,
|
||||
serviceProxy,
|
||||
onChange: createScriptStatusEmitter({
|
||||
sessions: () =>
|
||||
wsServer?.listActiveSessions().map((session) => ({
|
||||
emit: (message) => session.emitServerMessage(message),
|
||||
})) ?? [],
|
||||
routeStore: scriptRouteStore,
|
||||
serviceProxy,
|
||||
runtimeStore: scriptRuntimeStore,
|
||||
daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
||||
resolveWorkspaceDirectory: async (workspaceId) =>
|
||||
@@ -363,13 +359,18 @@ export async function createPaseoDaemon(
|
||||
}),
|
||||
});
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
routeStore: scriptRouteStore,
|
||||
serviceProxy,
|
||||
onRoutesChanged: (workspaceId) => {
|
||||
scriptHealthMonitor.invalidateWorkspace(workspaceId);
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
// Service proxy classifies service hosts before daemon auth/route fallthrough.
|
||||
// Registered service hosts proxy directly; known service namespaces without a
|
||||
// route return 404 and never reach daemon APIs.
|
||||
app.use(serviceProxy.middleware());
|
||||
|
||||
// Host allowlist / DNS rebinding protection (vite-like semantics).
|
||||
// For non-TCP (unix sockets), skip host validation.
|
||||
if (listenTarget.type === "tcp") {
|
||||
@@ -419,11 +420,6 @@ export async function createPaseoDaemon(
|
||||
}),
|
||||
);
|
||||
|
||||
// Script proxy — intercepts requests for registered *.localhost hostnames
|
||||
// and forwards them to the corresponding local script port. Placed after
|
||||
// host/CORS/auth checks but before the rest of the routes.
|
||||
app.use(createScriptProxyMiddleware({ routeStore: scriptRouteStore, logger }));
|
||||
|
||||
// Serve static files from public directory
|
||||
app.use("/public", express.static(staticDir));
|
||||
|
||||
@@ -507,34 +503,10 @@ export async function createPaseoDaemon(
|
||||
// VoiceAssistantWebSocketServer attaches its own "upgrade" listener so that
|
||||
// script-bound upgrades are forwarded first. The handler is a no-op for
|
||||
// requests that don't match a registered script route.
|
||||
const scriptProxyUpgradeHandler = createScriptProxyUpgradeHandler({
|
||||
routeStore: scriptRouteStore,
|
||||
logger,
|
||||
});
|
||||
httpServer.on("upgrade", scriptProxyUpgradeHandler);
|
||||
httpServer.on("upgrade", serviceProxy.upgradeHandler({ passthroughUnknown: true }));
|
||||
|
||||
if (config.serviceProxy?.enabled) {
|
||||
const serviceProxyTarget = parseListenString(config.serviceProxy.listen);
|
||||
const serviceProxyApp = express();
|
||||
serviceProxyApp.set("trust proxy", true);
|
||||
serviceProxyApp.use(createScriptProxyMiddleware({ routeStore: scriptRouteStore, logger }));
|
||||
serviceProxyApp.use((_req, res) => {
|
||||
res.status(404).send("404 Not Found");
|
||||
});
|
||||
serviceProxyHttpServer = createHTTPServer(serviceProxyApp);
|
||||
const serviceProxyUpgradeHandler = createScriptProxyUpgradeHandler({
|
||||
routeStore: scriptRouteStore,
|
||||
logger,
|
||||
});
|
||||
serviceProxyHttpServer.on("upgrade", (req, socket, head) => {
|
||||
const hostHeader = req.headers.host;
|
||||
if (!hostHeader || !scriptRouteStore.findRoute(hostHeader)) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
serviceProxyUpgradeHandler(req, socket as import("node:net").Socket, head);
|
||||
});
|
||||
serviceProxyListenTarget = serviceProxyTarget;
|
||||
if (config.serviceProxy?.standaloneListen) {
|
||||
serviceProxyListenTarget = parseListenString(config.serviceProxy.standaloneListen);
|
||||
}
|
||||
|
||||
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
|
||||
@@ -767,7 +739,7 @@ export async function createPaseoDaemon(
|
||||
sessionLogger: logger,
|
||||
terminalManager,
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
scriptRouteStore,
|
||||
serviceProxy,
|
||||
scriptRuntimeStore,
|
||||
getDaemonTcpPort: () =>
|
||||
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
|
||||
@@ -914,194 +886,185 @@ export async function createPaseoDaemon(
|
||||
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
|
||||
|
||||
const start = async () => {
|
||||
// Start main HTTP server
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: Error) => {
|
||||
httpServer.off("listening", onListening);
|
||||
reject(err);
|
||||
};
|
||||
const onListening = () => {
|
||||
httpServer.off("error", onError);
|
||||
const logAndResolve = async () => {
|
||||
boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
|
||||
const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null;
|
||||
agentMcpBaseUrl = config.mcpInjectIntoAgents === false ? null : mcpBaseUrl;
|
||||
agentManager.setMcpBaseUrl(agentMcpBaseUrl);
|
||||
daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
|
||||
agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null);
|
||||
});
|
||||
daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => {
|
||||
agentManager.setAppendSystemPrompt(typeof value === "string" ? value : "");
|
||||
});
|
||||
const relayEnabled = config.relayEnabled ?? true;
|
||||
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
|
||||
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
|
||||
const relayUseTls = config.relayUseTls ?? relayEndpoint === "relay.paseo.sh:443";
|
||||
const relayPublicUseTls = config.relayPublicUseTls ?? relayUseTls;
|
||||
const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
|
||||
|
||||
if (boundListenTarget.type === "tcp") {
|
||||
logger.info(
|
||||
{
|
||||
host: boundListenTarget.host,
|
||||
port: boundListenTarget.port,
|
||||
authRequired: !!config.auth?.password,
|
||||
elapsed: elapsed(),
|
||||
},
|
||||
`Server listening on http://${boundListenTarget.host}:${boundListenTarget.port}`,
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
{
|
||||
path: boundListenTarget.path,
|
||||
authRequired: !!config.auth?.password,
|
||||
elapsed: elapsed(),
|
||||
},
|
||||
`Server listening on ${boundListenTarget.path}`,
|
||||
);
|
||||
}
|
||||
if (config.auth?.password) {
|
||||
logger.info("Daemon password authentication enabled");
|
||||
}
|
||||
|
||||
wsServer = new VoiceAssistantWebSocketServer(
|
||||
httpServer,
|
||||
logger,
|
||||
serverId,
|
||||
agentManager,
|
||||
agentStorage,
|
||||
downloadTokenStore,
|
||||
config.paseoHome,
|
||||
daemonConfigStore,
|
||||
mcpBaseUrl,
|
||||
{ allowedOrigins, hostnames: configuredHostnames },
|
||||
config.auth,
|
||||
speechService,
|
||||
terminalManager,
|
||||
{
|
||||
finalTimeoutMs: config.dictationFinalTimeoutMs,
|
||||
},
|
||||
daemonVersion,
|
||||
(intent) => {
|
||||
try {
|
||||
config.onLifecycleIntent?.(intent);
|
||||
} catch (error) {
|
||||
logger.error({ err: error, intent }, "Failed to handle daemon lifecycle intent");
|
||||
}
|
||||
},
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
chatService,
|
||||
loopService,
|
||||
scheduleService,
|
||||
checkoutDiffManager,
|
||||
scriptRouteStore,
|
||||
scriptRuntimeStore,
|
||||
handleBranchChange,
|
||||
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
||||
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null),
|
||||
(hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
|
||||
workspaceGitService,
|
||||
github,
|
||||
config.pushNotificationSender,
|
||||
providerSnapshotManager,
|
||||
{
|
||||
listen: formatListenTarget(boundListenTarget ?? listenTarget),
|
||||
worktreesRoot: config.worktreesRoot,
|
||||
relay: {
|
||||
enabled: relayEnabled,
|
||||
endpoint: relayEndpoint,
|
||||
publicEndpoint: relayPublicEndpoint,
|
||||
useTls: relayUseTls,
|
||||
publicUseTls: relayPublicUseTls,
|
||||
},
|
||||
},
|
||||
serviceProxyPublicBaseUrl,
|
||||
);
|
||||
|
||||
if (relayEnabled) {
|
||||
const offer = await createConnectionOfferV2({
|
||||
serverId,
|
||||
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
|
||||
relay: {
|
||||
endpoint: relayPublicEndpoint,
|
||||
useTls: relayPublicUseTls,
|
||||
},
|
||||
});
|
||||
|
||||
encodeOfferToFragmentUrl({ offer, appBaseUrl });
|
||||
|
||||
relayTransport?.stop().catch(() => undefined);
|
||||
relayTransport = startRelayTransport({
|
||||
logger,
|
||||
attachSocket: (ws, metadata) => {
|
||||
if (!wsServer) {
|
||||
throw new Error("WebSocket server not initialized");
|
||||
}
|
||||
return wsServer.attachExternalSocket(ws, metadata);
|
||||
},
|
||||
relayEndpoint,
|
||||
relayUseTls,
|
||||
serverId,
|
||||
daemonKeyPair: daemonKeyPair.keyPair,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
logAndResolve().then(resolve, reject);
|
||||
};
|
||||
httpServer.once("error", onError);
|
||||
httpServer.once("listening", onListening);
|
||||
|
||||
if (listenTarget.type === "tcp") {
|
||||
httpServer.listen(listenTarget.port, listenTarget.host);
|
||||
} else {
|
||||
if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
|
||||
unlinkSync(listenTarget.path);
|
||||
}
|
||||
httpServer.listen(listenTarget.path);
|
||||
let mainStarted = false;
|
||||
try {
|
||||
if (serviceProxyListenTarget) {
|
||||
const boundServiceProxyTarget = await serviceProxy.startStandalone({
|
||||
listenTarget: serviceProxyListenTarget,
|
||||
});
|
||||
serviceProxyListenTarget = boundServiceProxyTarget;
|
||||
logger.info(
|
||||
{
|
||||
listen: formatListenTarget(serviceProxyListenTarget),
|
||||
publicBaseUrl: serviceProxyPublicBaseUrl,
|
||||
elapsed: elapsed(),
|
||||
},
|
||||
"Service proxy listening",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (serviceProxyHttpServer && serviceProxyListenTarget) {
|
||||
const proxyServer = serviceProxyHttpServer;
|
||||
const proxyTarget = serviceProxyListenTarget;
|
||||
// Start main HTTP server
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: Error) => {
|
||||
proxyServer.off("listening", onListening);
|
||||
httpServer.off("listening", onListening);
|
||||
reject(err);
|
||||
};
|
||||
const onListening = () => {
|
||||
proxyServer.off("error", onError);
|
||||
serviceProxyListenTarget = resolveBoundListenTarget(proxyTarget, proxyServer);
|
||||
logger.info(
|
||||
{
|
||||
listen: formatListenTarget(serviceProxyListenTarget),
|
||||
publicBaseUrl: serviceProxyPublicBaseUrl,
|
||||
elapsed: elapsed(),
|
||||
},
|
||||
"Service proxy listening",
|
||||
);
|
||||
resolve();
|
||||
};
|
||||
proxyServer.once("error", onError);
|
||||
proxyServer.once("listening", onListening);
|
||||
httpServer.off("error", onError);
|
||||
mainStarted = true;
|
||||
const logAndResolve = async () => {
|
||||
boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
|
||||
const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null;
|
||||
agentMcpBaseUrl = config.mcpInjectIntoAgents === false ? null : mcpBaseUrl;
|
||||
agentManager.setMcpBaseUrl(agentMcpBaseUrl);
|
||||
daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
|
||||
agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null);
|
||||
});
|
||||
daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => {
|
||||
agentManager.setAppendSystemPrompt(typeof value === "string" ? value : "");
|
||||
});
|
||||
const relayEnabled = config.relayEnabled ?? true;
|
||||
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
|
||||
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
|
||||
const relayUseTls = config.relayUseTls ?? relayEndpoint === "relay.paseo.sh:443";
|
||||
const relayPublicUseTls = config.relayPublicUseTls ?? relayUseTls;
|
||||
const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
|
||||
|
||||
if (proxyTarget.type === "tcp") {
|
||||
proxyServer.listen(proxyTarget.port, proxyTarget.host);
|
||||
if (boundListenTarget.type === "tcp") {
|
||||
logger.info(
|
||||
{
|
||||
host: boundListenTarget.host,
|
||||
port: boundListenTarget.port,
|
||||
authRequired: !!config.auth?.password,
|
||||
elapsed: elapsed(),
|
||||
},
|
||||
`Server listening on http://${boundListenTarget.host}:${boundListenTarget.port}`,
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
{
|
||||
path: boundListenTarget.path,
|
||||
authRequired: !!config.auth?.password,
|
||||
elapsed: elapsed(),
|
||||
},
|
||||
`Server listening on ${boundListenTarget.path}`,
|
||||
);
|
||||
}
|
||||
if (config.auth?.password) {
|
||||
logger.info("Daemon password authentication enabled");
|
||||
}
|
||||
|
||||
wsServer = new VoiceAssistantWebSocketServer(
|
||||
httpServer,
|
||||
logger,
|
||||
serverId,
|
||||
agentManager,
|
||||
agentStorage,
|
||||
downloadTokenStore,
|
||||
config.paseoHome,
|
||||
daemonConfigStore,
|
||||
mcpBaseUrl,
|
||||
{ allowedOrigins, hostnames: configuredHostnames },
|
||||
config.auth,
|
||||
speechService,
|
||||
terminalManager,
|
||||
{
|
||||
finalTimeoutMs: config.dictationFinalTimeoutMs,
|
||||
},
|
||||
daemonVersion,
|
||||
(intent) => {
|
||||
try {
|
||||
config.onLifecycleIntent?.(intent);
|
||||
} catch (error) {
|
||||
logger.error({ err: error, intent }, "Failed to handle daemon lifecycle intent");
|
||||
}
|
||||
},
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
chatService,
|
||||
loopService,
|
||||
scheduleService,
|
||||
checkoutDiffManager,
|
||||
serviceProxy,
|
||||
scriptRuntimeStore,
|
||||
handleBranchChange,
|
||||
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
||||
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null),
|
||||
(hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
|
||||
workspaceGitService,
|
||||
github,
|
||||
config.pushNotificationSender,
|
||||
providerSnapshotManager,
|
||||
{
|
||||
listen: formatListenTarget(boundListenTarget ?? listenTarget),
|
||||
worktreesRoot: config.worktreesRoot,
|
||||
relay: {
|
||||
enabled: relayEnabled,
|
||||
endpoint: relayEndpoint,
|
||||
publicEndpoint: relayPublicEndpoint,
|
||||
useTls: relayUseTls,
|
||||
publicUseTls: relayPublicUseTls,
|
||||
},
|
||||
},
|
||||
serviceProxyPublicBaseUrl,
|
||||
);
|
||||
|
||||
if (relayEnabled) {
|
||||
const offer = await createConnectionOfferV2({
|
||||
serverId,
|
||||
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
|
||||
relay: {
|
||||
endpoint: relayPublicEndpoint,
|
||||
useTls: relayPublicUseTls,
|
||||
},
|
||||
});
|
||||
|
||||
encodeOfferToFragmentUrl({ offer, appBaseUrl });
|
||||
|
||||
relayTransport?.stop().catch(() => undefined);
|
||||
relayTransport = startRelayTransport({
|
||||
logger,
|
||||
attachSocket: (ws, metadata) => {
|
||||
if (!wsServer) {
|
||||
throw new Error("WebSocket server not initialized");
|
||||
}
|
||||
return wsServer.attachExternalSocket(ws, metadata);
|
||||
},
|
||||
relayEndpoint,
|
||||
relayUseTls,
|
||||
serverId,
|
||||
daemonKeyPair: daemonKeyPair.keyPair,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
logAndResolve().then(resolve, reject);
|
||||
};
|
||||
httpServer.once("error", onError);
|
||||
httpServer.once("listening", onListening);
|
||||
|
||||
if (listenTarget.type === "tcp") {
|
||||
httpServer.listen(listenTarget.port, listenTarget.host);
|
||||
} else {
|
||||
if (proxyTarget.type === "socket" && existsSync(proxyTarget.path)) {
|
||||
unlinkSync(proxyTarget.path);
|
||||
if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
|
||||
unlinkSync(listenTarget.path);
|
||||
}
|
||||
proxyServer.listen(proxyTarget.path);
|
||||
httpServer.listen(listenTarget.path);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Start speech service after listening so synchronous Sherpa native
|
||||
// model loading doesn't block the server from accepting connections.
|
||||
speechService.start();
|
||||
scriptHealthMonitor.start();
|
||||
// Start speech service after listening so synchronous Sherpa native
|
||||
// model loading doesn't block the server from accepting connections.
|
||||
speechService.start();
|
||||
scriptHealthMonitor.start();
|
||||
} catch (error) {
|
||||
await serviceProxy.stopStandalone().catch(() => undefined);
|
||||
if (mainStarted) {
|
||||
httpServer.closeAllConnections();
|
||||
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
@@ -1118,18 +1081,7 @@ export async function createPaseoDaemon(
|
||||
if (wsServer) {
|
||||
await wsServer.close();
|
||||
}
|
||||
if (serviceProxyHttpServer) {
|
||||
serviceProxyHttpServer.closeAllConnections();
|
||||
await new Promise<void>((resolve) => {
|
||||
serviceProxyHttpServer?.close(() => resolve());
|
||||
});
|
||||
if (
|
||||
serviceProxyListenTarget?.type === "socket" &&
|
||||
existsSync(serviceProxyListenTarget.path)
|
||||
) {
|
||||
unlinkSync(serviceProxyListenTarget.path);
|
||||
}
|
||||
}
|
||||
await serviceProxy.stopStandalone();
|
||||
// Force-drop remaining sockets so httpServer.close() resolves promptly.
|
||||
// We've already closed wsServer (which sent ws-layer close frames) and
|
||||
// stopped every other service, so anything still attached is a TCP
|
||||
@@ -1152,7 +1104,7 @@ export async function createPaseoDaemon(
|
||||
agentManager,
|
||||
agentStorage,
|
||||
terminalManager,
|
||||
scriptRouteStore,
|
||||
serviceProxy,
|
||||
scriptRuntimeStore,
|
||||
start,
|
||||
stop,
|
||||
|
||||
@@ -104,9 +104,38 @@ describe("daemon service proxy config", () => {
|
||||
});
|
||||
|
||||
expect(config.serviceProxy).toEqual({
|
||||
enabled: true,
|
||||
listen: "127.0.0.1:6868",
|
||||
publicBaseUrl: "https://env.example.com",
|
||||
standaloneListen: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("does not synthesize a standalone service listener from enabled true", async () => {
|
||||
const home = await createPaseoHome({
|
||||
version: 1,
|
||||
daemon: { serviceProxy: { enabled: true } },
|
||||
});
|
||||
|
||||
expect(loadConfig(home, { env: {} }).serviceProxy).toEqual({
|
||||
publicBaseUrl: null,
|
||||
standaloneListen: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("enabled false suppresses optional service proxy layers only", async () => {
|
||||
const home = await createPaseoHome({
|
||||
version: 1,
|
||||
daemon: {
|
||||
serviceProxy: {
|
||||
enabled: false,
|
||||
listen: "127.0.0.1:9999",
|
||||
publicBaseUrl: "https://persisted.example.com",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(loadConfig(home, { env: {} }).serviceProxy).toEqual({
|
||||
publicBaseUrl: null,
|
||||
standaloneListen: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostn
|
||||
const DEFAULT_PORT = 6767;
|
||||
const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
|
||||
const DEFAULT_APP_BASE_URL = "https://app.paseo.sh";
|
||||
const DEFAULT_SERVICE_PROXY_LISTEN = "127.0.0.1:6868";
|
||||
|
||||
function parseBooleanEnv(value: string | undefined): boolean | undefined {
|
||||
if (value === undefined) {
|
||||
@@ -154,9 +153,8 @@ interface ResolvedRelay {
|
||||
}
|
||||
|
||||
interface ResolvedServiceProxy {
|
||||
enabled: boolean;
|
||||
listen: string;
|
||||
publicBaseUrl: string | null;
|
||||
standaloneListen: string | null;
|
||||
}
|
||||
|
||||
function resolveTlsFromEnv(
|
||||
@@ -220,21 +218,24 @@ function resolveServiceProxyConfig(
|
||||
env: NodeJS.ProcessEnv,
|
||||
persisted: ReturnType<typeof loadPersistedConfig>,
|
||||
): ResolvedServiceProxy {
|
||||
const publicBaseUrl = resolveServiceProxyPublicBaseUrl(
|
||||
env.PASEO_SERVICE_PROXY_PUBLIC_BASE_URL ??
|
||||
persisted.daemon?.serviceProxy?.publicBaseUrl ??
|
||||
null,
|
||||
);
|
||||
const enabled =
|
||||
parseBooleanEnv(env.PASEO_SERVICE_PROXY_ENABLED) ??
|
||||
persisted.daemon?.serviceProxy?.enabled ??
|
||||
publicBaseUrl !== null;
|
||||
const listen =
|
||||
env.PASEO_SERVICE_PROXY_LISTEN ??
|
||||
persisted.daemon?.serviceProxy?.listen ??
|
||||
DEFAULT_SERVICE_PROXY_LISTEN;
|
||||
const enabledShim =
|
||||
parseBooleanEnv(env.PASEO_SERVICE_PROXY_ENABLED) ?? persisted.daemon?.serviceProxy?.enabled;
|
||||
// COMPAT(serviceProxyEnabled): added 2026-06-02, remove after 2026-12-02.
|
||||
// `enabled=false` used to disable the separate service proxy listener. Localhost
|
||||
// service proxying is now always enabled; this only suppresses optional layers.
|
||||
const optionalLayersEnabled = enabledShim !== false;
|
||||
const publicBaseUrl = optionalLayersEnabled
|
||||
? resolveServiceProxyPublicBaseUrl(
|
||||
env.PASEO_SERVICE_PROXY_PUBLIC_BASE_URL ??
|
||||
persisted.daemon?.serviceProxy?.publicBaseUrl ??
|
||||
null,
|
||||
)
|
||||
: null;
|
||||
const standaloneListen = optionalLayersEnabled
|
||||
? (env.PASEO_SERVICE_PROXY_LISTEN ?? persisted.daemon?.serviceProxy?.listen ?? null)
|
||||
: null;
|
||||
|
||||
return { enabled, listen, publicBaseUrl };
|
||||
return { publicBaseUrl, standaloneListen };
|
||||
}
|
||||
|
||||
function resolveVoiceLlmConfig(
|
||||
|
||||
@@ -239,6 +239,9 @@ export const PersistedConfigSchema = z
|
||||
.optional(),
|
||||
serviceProxy: z
|
||||
.object({
|
||||
// COMPAT(serviceProxyEnabled): added 2026-06-02, remove after 2026-12-02.
|
||||
// Parsed only to suppress optional public/listen layers for old configs;
|
||||
// localhost service proxying remains always enabled.
|
||||
enabled: z.boolean().optional(),
|
||||
listen: z.string().optional(),
|
||||
publicBaseUrl: z.string().url().optional(),
|
||||
|
||||
@@ -162,7 +162,7 @@ describe("ScriptHealthMonitor", () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
|
||||
const monitor = new ScriptHealthMonitor({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
@@ -214,7 +214,7 @@ describe("ScriptHealthMonitor", () => {
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
|
||||
const monitor = new ScriptHealthMonitor({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
@@ -261,7 +261,7 @@ describe("ScriptHealthMonitor", () => {
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
|
||||
const monitor = new ScriptHealthMonitor({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
@@ -292,7 +292,7 @@ describe("ScriptHealthMonitor", () => {
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
|
||||
const monitor = new ScriptHealthMonitor({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
@@ -343,7 +343,7 @@ describe("ScriptHealthMonitor", () => {
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
|
||||
const monitor = new ScriptHealthMonitor({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
@@ -393,7 +393,7 @@ describe("ScriptHealthMonitor", () => {
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
|
||||
const monitor = new ScriptHealthMonitor({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
@@ -450,7 +450,7 @@ describe("ScriptHealthMonitor", () => {
|
||||
branchName: null,
|
||||
scriptName,
|
||||
daemonPort: null,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager: createStubTerminalManager(
|
||||
createTerminalCalls,
|
||||
@@ -462,7 +462,7 @@ describe("ScriptHealthMonitor", () => {
|
||||
expect(createTerminalCalls).toHaveLength(2);
|
||||
expect(routeStore.listRoutes()).toEqual([
|
||||
{
|
||||
hostname: "api.repo.localhost",
|
||||
hostname: "api--repo.localhost",
|
||||
port: service.port,
|
||||
workspaceId: workspace.repoDir,
|
||||
projectSlug: "repo",
|
||||
@@ -472,7 +472,7 @@ describe("ScriptHealthMonitor", () => {
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
|
||||
const monitor = new ScriptHealthMonitor({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
@@ -487,7 +487,7 @@ describe("ScriptHealthMonitor", () => {
|
||||
expect(onChange).toHaveBeenCalledWith(workspace.repoDir, [
|
||||
{
|
||||
scriptName: "api",
|
||||
hostname: "api.repo.localhost",
|
||||
hostname: "api--repo.localhost",
|
||||
port: service.port,
|
||||
health: "healthy",
|
||||
},
|
||||
@@ -524,7 +524,7 @@ describe("ScriptHealthMonitor", () => {
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
|
||||
const monitor = new ScriptHealthMonitor({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import net from "node:net";
|
||||
import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js";
|
||||
import type { ServiceProxyHealthTarget, ServiceProxySubsystem } from "./service-proxy.js";
|
||||
|
||||
export type ScriptHealthState = "pending" | "healthy" | "unhealthy";
|
||||
|
||||
@@ -18,7 +18,7 @@ interface RouteHealthState {
|
||||
}
|
||||
|
||||
export class ScriptHealthMonitor {
|
||||
private readonly routeStore: ScriptRouteStore;
|
||||
private readonly serviceProxy: ServiceProxySubsystem;
|
||||
private readonly onChange: (workspaceId: string, scripts: ScriptHealthEntry[]) => void;
|
||||
private readonly pollIntervalMs: number;
|
||||
private readonly probeTimeoutMs: number;
|
||||
@@ -31,21 +31,21 @@ export class ScriptHealthMonitor {
|
||||
private pollInFlight = false;
|
||||
|
||||
constructor({
|
||||
routeStore,
|
||||
serviceProxy,
|
||||
onChange,
|
||||
pollIntervalMs = 3_000,
|
||||
probeTimeoutMs = 500,
|
||||
graceMs = 5_000,
|
||||
failuresBeforeStopped = 2,
|
||||
}: {
|
||||
routeStore: ScriptRouteStore;
|
||||
serviceProxy: ServiceProxySubsystem;
|
||||
onChange: (workspaceId: string, scripts: ScriptHealthEntry[]) => void;
|
||||
pollIntervalMs?: number;
|
||||
probeTimeoutMs?: number;
|
||||
graceMs?: number;
|
||||
failuresBeforeStopped?: number;
|
||||
}) {
|
||||
this.routeStore = routeStore;
|
||||
this.serviceProxy = serviceProxy;
|
||||
this.onChange = onChange;
|
||||
this.pollIntervalMs = pollIntervalMs;
|
||||
this.probeTimeoutMs = probeTimeoutMs;
|
||||
@@ -59,7 +59,7 @@ export class ScriptHealthMonitor {
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
for (const route of this.routeStore.listRoutes()) {
|
||||
for (const route of this.serviceProxy.getHealthCheckTargets()) {
|
||||
this.getOrCreateState(route, now);
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ export class ScriptHealthMonitor {
|
||||
|
||||
this.pollInFlight = true;
|
||||
try {
|
||||
const routes = this.routeStore.listRoutes();
|
||||
const routes = this.serviceProxy.getHealthCheckTargets();
|
||||
const activeHostnames = new Set(routes.map((route) => route.hostname));
|
||||
const changedWorkspaceIds = new Set<string>();
|
||||
const now = Date.now();
|
||||
@@ -142,7 +142,7 @@ export class ScriptHealthMonitor {
|
||||
}
|
||||
|
||||
private getOrCreateState(
|
||||
route: Pick<ScriptRouteEntry, "hostname" | "workspaceId">,
|
||||
route: Pick<ServiceProxyHealthTarget, "hostname" | "workspaceId">,
|
||||
registeredAt: number,
|
||||
): RouteHealthState {
|
||||
const existing = this.routeStates.get(route.hostname);
|
||||
@@ -171,7 +171,7 @@ export class ScriptHealthMonitor {
|
||||
}
|
||||
|
||||
private buildWorkspaceScriptList(workspaceId: string): ScriptHealthEntry[] {
|
||||
return this.routeStore.listRoutesForWorkspace(workspaceId).flatMap((route) => {
|
||||
return this.serviceProxy.getWorkspaceHealthTargets(workspaceId).flatMap((route) => {
|
||||
const state = this.routeStates.get(route.hostname);
|
||||
if (!state) {
|
||||
return [];
|
||||
@@ -186,7 +186,7 @@ export class ScriptHealthMonitor {
|
||||
return state.health;
|
||||
}
|
||||
|
||||
const route = this.routeStore.getRouteEntry(hostname);
|
||||
const route = this.serviceProxy.getHealthTargetForHostname(hostname);
|
||||
if (!route) {
|
||||
return null;
|
||||
}
|
||||
@@ -195,7 +195,7 @@ export class ScriptHealthMonitor {
|
||||
}
|
||||
|
||||
private toScriptHealthEntry(
|
||||
route: ScriptRouteEntry,
|
||||
route: ServiceProxyHealthTarget,
|
||||
health: ScriptHealthEntry["health"],
|
||||
): ScriptHealthEntry {
|
||||
return {
|
||||
|
||||
@@ -1,484 +1,16 @@
|
||||
import { it, expect, afterEach } from "vitest";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import express from "express";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import pino from "pino";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ScriptRouteStore,
|
||||
createScriptProxyMiddleware,
|
||||
createScriptProxyUpgradeHandler,
|
||||
findFreePort,
|
||||
ScriptRouteStore,
|
||||
} from "./script-proxy.js";
|
||||
|
||||
const logger = pino({ level: "silent" });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers for cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function closeServer(server: http.Server): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ScriptRouteStore
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("registerRoute and findRoute with exact match", () => {
|
||||
const store = new ScriptRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "route-a.example.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "editor",
|
||||
});
|
||||
|
||||
const route = store.findRoute("route-a.example.localhost");
|
||||
expect(route).toEqual({ hostname: "route-a.example.localhost", port: 3000 });
|
||||
});
|
||||
|
||||
it("findRoute strips port from host header", () => {
|
||||
const store = new ScriptRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "route-a.example.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "editor",
|
||||
});
|
||||
|
||||
const route = store.findRoute("route-a.example.localhost:6767");
|
||||
expect(route).toEqual({ hostname: "route-a.example.localhost", port: 3000 });
|
||||
});
|
||||
|
||||
it("findRoute subdomain match", () => {
|
||||
const store = new ScriptRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "editor.example.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "editor",
|
||||
});
|
||||
|
||||
const route = store.findRoute("tenant.editor.example.localhost");
|
||||
expect(route).toEqual({ hostname: "editor.example.localhost", port: 3000 });
|
||||
});
|
||||
|
||||
it("listRoutes returns enriched entries", () => {
|
||||
const store = new ScriptRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "a.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "web",
|
||||
});
|
||||
store.registerRoute({
|
||||
hostname: "b.localhost",
|
||||
port: 4000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-b",
|
||||
projectSlug: "repo",
|
||||
scriptName: "docs",
|
||||
});
|
||||
|
||||
const routes = store.listRoutes();
|
||||
expect(routes).toHaveLength(2);
|
||||
expect(routes).toContainEqual({
|
||||
hostname: "a.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "web",
|
||||
});
|
||||
expect(routes).toContainEqual({
|
||||
hostname: "b.localhost",
|
||||
port: 4000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-b",
|
||||
projectSlug: "repo",
|
||||
scriptName: "docs",
|
||||
describe("script-proxy compatibility re-exports", () => {
|
||||
it("keeps the legacy imports available", () => {
|
||||
expect(createScriptProxyMiddleware).toEqual(expect.any(Function));
|
||||
expect(createScriptProxyUpgradeHandler).toEqual(expect.any(Function));
|
||||
expect(findFreePort).toEqual(expect.any(Function));
|
||||
expect(new ScriptRouteStore()).toEqual(expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it("listRoutesForWorkspace returns only routes for that workspace", () => {
|
||||
const store = new ScriptRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "a.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "web",
|
||||
});
|
||||
store.registerRoute({
|
||||
hostname: "b.localhost",
|
||||
port: 4000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-b",
|
||||
projectSlug: "repo",
|
||||
scriptName: "docs",
|
||||
});
|
||||
store.registerRoute({
|
||||
hostname: "c.localhost",
|
||||
port: 5000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "api",
|
||||
});
|
||||
|
||||
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([
|
||||
{
|
||||
hostname: "a.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "web",
|
||||
},
|
||||
{
|
||||
hostname: "c.localhost",
|
||||
port: 5000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "api",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("removeRoute works", () => {
|
||||
const store = new ScriptRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "route-a.example.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "editor",
|
||||
});
|
||||
store.removeRoute("route-a.example.localhost");
|
||||
|
||||
expect(store.findRoute("route-a.example.localhost")).toBeNull();
|
||||
});
|
||||
|
||||
it("removeRoute cleans up workspace index", () => {
|
||||
const store = new ScriptRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "route-a.example.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "editor",
|
||||
});
|
||||
|
||||
store.removeRoute("route-a.example.localhost");
|
||||
|
||||
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]);
|
||||
});
|
||||
|
||||
it("removeRoutesForPort works", () => {
|
||||
const store = new ScriptRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "a.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "web",
|
||||
});
|
||||
store.registerRoute({
|
||||
hostname: "b.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "api",
|
||||
});
|
||||
store.registerRoute({
|
||||
hostname: "c.localhost",
|
||||
port: 4000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-b",
|
||||
projectSlug: "repo",
|
||||
scriptName: "docs",
|
||||
});
|
||||
|
||||
store.removeRoutesForPort(3000);
|
||||
|
||||
expect(store.findRoute("a.localhost")).toBeNull();
|
||||
expect(store.findRoute("b.localhost")).toBeNull();
|
||||
expect(store.findRoute("c.localhost")).toEqual({
|
||||
hostname: "c.localhost",
|
||||
port: 4000,
|
||||
});
|
||||
});
|
||||
|
||||
it("removeRoutesForPort cleans up workspace index", () => {
|
||||
const store = new ScriptRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "a.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "web",
|
||||
});
|
||||
store.registerRoute({
|
||||
hostname: "b.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "api",
|
||||
});
|
||||
|
||||
store.removeRoutesForPort(3000);
|
||||
|
||||
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]);
|
||||
});
|
||||
|
||||
it("findRoute returns null for unknown hosts", () => {
|
||||
const store = new ScriptRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "route-a.example.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "editor",
|
||||
});
|
||||
|
||||
expect(store.findRoute("unknown.example.com")).toBeNull();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP proxy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const servers: http.Server[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.map(closeServer));
|
||||
servers.length = 0;
|
||||
});
|
||||
|
||||
/** Start a real HTTP server that echoes back a known body and records received headers. */
|
||||
async function startUpstream(): Promise<{
|
||||
port: number;
|
||||
server: http.Server;
|
||||
receivedHeaders: () => http.IncomingHttpHeaders;
|
||||
}> {
|
||||
const port = await findFreePort();
|
||||
let lastHeaders: http.IncomingHttpHeaders = {};
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
lastHeaders = req.headers;
|
||||
res.writeHead(200, { "content-type": "text/plain" });
|
||||
res.end("upstream-ok");
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
|
||||
servers.push(server);
|
||||
|
||||
return {
|
||||
port,
|
||||
server,
|
||||
receivedHeaders: () => lastHeaders,
|
||||
};
|
||||
}
|
||||
|
||||
/** Start an Express app with the service proxy middleware and an optional fallback. */
|
||||
async function startProxy(
|
||||
routeStore: ScriptRouteStore,
|
||||
opts?: { fallback?: boolean },
|
||||
): Promise<{ port: number; server: http.Server }> {
|
||||
const port = await findFreePort();
|
||||
const app = express();
|
||||
app.use(createScriptProxyMiddleware({ routeStore, logger }));
|
||||
|
||||
if (opts?.fallback) {
|
||||
app.use((_req, res) => {
|
||||
res.status(404).send("no route");
|
||||
});
|
||||
}
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
|
||||
servers.push(server);
|
||||
|
||||
return { port, server };
|
||||
}
|
||||
|
||||
/** Simple HTTP GET helper that returns status code and body. */
|
||||
function httpGet(
|
||||
port: number,
|
||||
host: string,
|
||||
path = "/",
|
||||
): Promise<{ status: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.get({ hostname: "127.0.0.1", port, path, headers: { host } }, (res) => {
|
||||
let body = "";
|
||||
res.on("data", (chunk: Buffer) => (body += chunk.toString()));
|
||||
res.on("end", () => resolve({ status: res.statusCode ?? 0, body }));
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
it("proxies requests to the correct upstream based on Host header", async () => {
|
||||
const upstream = await startUpstream();
|
||||
const routeStore = new ScriptRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "test-service.localhost",
|
||||
port: upstream.port,
|
||||
workspaceId: "workspace-test",
|
||||
projectSlug: "test",
|
||||
scriptName: "service",
|
||||
});
|
||||
|
||||
const proxy = await startProxy(routeStore);
|
||||
const res = await httpGet(proxy.port, `test-service.localhost:${proxy.port}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toBe("upstream-ok");
|
||||
|
||||
const headers = upstream.receivedHeaders();
|
||||
expect(headers["x-forwarded-for"]).toBeDefined();
|
||||
expect(headers["x-forwarded-host"]).toBe("test-service.localhost");
|
||||
});
|
||||
|
||||
it("falls through when no route matches", async () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
const proxy = await startProxy(routeStore, { fallback: true });
|
||||
|
||||
const res = await httpGet(proxy.port, `unknown.localhost:${proxy.port}`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toBe("no route");
|
||||
});
|
||||
|
||||
it("returns 502 when upstream is down", async () => {
|
||||
// Get a port that nothing is listening on
|
||||
const deadPort = await findFreePort();
|
||||
|
||||
const routeStore = new ScriptRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "dead-service.localhost",
|
||||
port: deadPort,
|
||||
workspaceId: "workspace-dead",
|
||||
projectSlug: "dead",
|
||||
scriptName: "service",
|
||||
});
|
||||
|
||||
const proxy = await startProxy(routeStore);
|
||||
const res = await httpGet(proxy.port, `dead-service.localhost:${proxy.port}`);
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.body).toBe("502 Bad Gateway");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket proxy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const httpServers: http.Server[] = [];
|
||||
const wsServers: WebSocketServer[] = [];
|
||||
const wsClients: WebSocket[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const ws of wsClients) {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close();
|
||||
}
|
||||
wsClients.length = 0;
|
||||
|
||||
for (const wss of wsServers) {
|
||||
wss.close();
|
||||
}
|
||||
wsServers.length = 0;
|
||||
|
||||
await Promise.all(httpServers.map(closeServer));
|
||||
httpServers.length = 0;
|
||||
});
|
||||
|
||||
it("proxies WebSocket connections to the correct upstream", async () => {
|
||||
// 1. Start a real WebSocket echo server
|
||||
const upstreamPort = await findFreePort();
|
||||
const upstreamServer = http.createServer();
|
||||
const wss = new WebSocketServer({ server: upstreamServer });
|
||||
wsServers.push(wss);
|
||||
|
||||
wss.on("connection", (ws) => {
|
||||
ws.on("message", (data) => {
|
||||
ws.send(`echo: ${data.toString()}`);
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => upstreamServer.listen(upstreamPort, "127.0.0.1", resolve));
|
||||
httpServers.push(upstreamServer);
|
||||
|
||||
// 2. Create the proxy server with the upgrade handler
|
||||
const routeStore = new ScriptRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "ws-service.localhost",
|
||||
port: upstreamPort,
|
||||
workspaceId: "workspace-ws",
|
||||
projectSlug: "ws",
|
||||
scriptName: "service",
|
||||
});
|
||||
|
||||
const proxyPort = await findFreePort();
|
||||
const proxyServer = http.createServer((_req, res) => {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
const upgradeHandler = createScriptProxyUpgradeHandler({
|
||||
routeStore,
|
||||
logger,
|
||||
});
|
||||
proxyServer.on("upgrade", upgradeHandler);
|
||||
|
||||
await new Promise<void>((resolve) => proxyServer.listen(proxyPort, "127.0.0.1", resolve));
|
||||
httpServers.push(proxyServer);
|
||||
|
||||
// 3. Connect a WebSocket client through the proxy
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}`, {
|
||||
headers: { host: `ws-service.localhost:${proxyPort}` },
|
||||
});
|
||||
wsClients.push(ws);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.on("open", resolve);
|
||||
ws.on("error", reject);
|
||||
});
|
||||
|
||||
// 4. Send a message and verify echo
|
||||
const reply = await new Promise<string>((resolve, reject) => {
|
||||
ws.on("message", (data) => resolve(data.toString()));
|
||||
ws.on("error", reject);
|
||||
ws.send("hello proxy");
|
||||
});
|
||||
|
||||
expect(reply).toBe("echo: hello proxy");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findFreePort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("returns a number", async () => {
|
||||
const port = await findFreePort();
|
||||
expect(typeof port).toBe("number");
|
||||
expect(port).toBeGreaterThan(0);
|
||||
expect(port).toBeLessThan(65536);
|
||||
});
|
||||
|
||||
it("returns a port that is actually available", async () => {
|
||||
const port = await findFreePort();
|
||||
|
||||
// Verify we can bind a server to it
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.listen(port, "127.0.0.1", () => resolve());
|
||||
server.on("error", reject);
|
||||
});
|
||||
|
||||
const addr = server.address();
|
||||
expect(addr).not.toBeNull();
|
||||
expect(typeof addr === "object" && addr !== null ? addr.port : -1).toBe(port);
|
||||
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
@@ -1,353 +1,7 @@
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import type { Logger } from "pino";
|
||||
import type { RequestHandler } from "express";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hop-by-hop headers that must not be forwarded
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const HOP_BY_HOP_HEADERS = new Set([
|
||||
"connection",
|
||||
"transfer-encoding",
|
||||
"keep-alive",
|
||||
"upgrade",
|
||||
"proxy-connection",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ScriptRouteStore
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ScriptRoute {
|
||||
hostname: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface ScriptRouteEntry extends ScriptRoute {
|
||||
workspaceId: string;
|
||||
projectSlug: string;
|
||||
scriptName: string;
|
||||
publicHostname?: string | null;
|
||||
publicBaseUrl?: string | null;
|
||||
}
|
||||
|
||||
export class ScriptRouteStore {
|
||||
private routes = new Map<string, ScriptRouteEntry>();
|
||||
private hostnameAliases = new Map<string, string>();
|
||||
private workspaceHostnames = new Map<string, Set<string>>();
|
||||
|
||||
registerRoute(entry: ScriptRouteEntry): void {
|
||||
const previous = this.routes.get(entry.hostname);
|
||||
if (previous) {
|
||||
this.removeRoute(previous.hostname);
|
||||
}
|
||||
|
||||
const { publicHostname, publicBaseUrl, ...requiredEntry } = entry;
|
||||
const storedEntry: ScriptRouteEntry = {
|
||||
...requiredEntry,
|
||||
...(publicHostname ? { publicHostname } : {}),
|
||||
...(publicBaseUrl ? { publicBaseUrl } : {}),
|
||||
};
|
||||
this.routes.set(storedEntry.hostname, storedEntry);
|
||||
for (const alias of this.getRouteHostnames(storedEntry)) {
|
||||
const aliasedCanonicalHostname = this.hostnameAliases.get(alias);
|
||||
if (aliasedCanonicalHostname && aliasedCanonicalHostname !== storedEntry.hostname) {
|
||||
this.removeRoute(aliasedCanonicalHostname);
|
||||
}
|
||||
this.hostnameAliases.set(alias, storedEntry.hostname);
|
||||
}
|
||||
this.addHostnameToWorkspaceIndex(storedEntry.workspaceId, storedEntry.hostname);
|
||||
}
|
||||
|
||||
removeRoute(hostname: string): void {
|
||||
const canonicalHostname = this.hostnameAliases.get(hostname) ?? hostname;
|
||||
const entry = this.routes.get(canonicalHostname);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
this.routes.delete(canonicalHostname);
|
||||
for (const alias of this.getRouteHostnames(entry)) {
|
||||
this.hostnameAliases.delete(alias);
|
||||
}
|
||||
this.removeHostnameFromWorkspaceIndex(entry.workspaceId, canonicalHostname);
|
||||
}
|
||||
|
||||
removeRouteForWorkspaceScript(params: { workspaceId: string; scriptName: string }): void {
|
||||
const routes = this.listRoutesForWorkspace(params.workspaceId);
|
||||
const route = routes.find((entry) => entry.scriptName === params.scriptName);
|
||||
if (!route) {
|
||||
return;
|
||||
}
|
||||
this.removeRoute(route.hostname);
|
||||
}
|
||||
|
||||
removeRoutesForPort(port: number): void {
|
||||
for (const [hostname, entry] of this.routes) {
|
||||
if (entry.port === port) {
|
||||
this.routes.delete(hostname);
|
||||
for (const alias of this.getRouteHostnames(entry)) {
|
||||
this.hostnameAliases.delete(alias);
|
||||
}
|
||||
this.removeHostnameFromWorkspaceIndex(entry.workspaceId, hostname);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findRoute(host: string): ScriptRoute | null {
|
||||
// Strip port suffix from the Host header value
|
||||
const hostname = host.replace(/:\d+$/, "");
|
||||
|
||||
// 1. Exact match
|
||||
const exactRoute = this.getRouteByHostname(hostname);
|
||||
if (exactRoute !== undefined) {
|
||||
return { hostname: exactRoute.hostname, port: exactRoute.port };
|
||||
}
|
||||
|
||||
// 2. Subdomain match — walk up the labels looking for a registered parent
|
||||
const parts = hostname.split(".");
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const candidate = parts.slice(i).join(".");
|
||||
const candidateRoute = this.getRouteByHostname(candidate);
|
||||
if (candidateRoute !== undefined) {
|
||||
return { hostname: candidateRoute.hostname, port: candidateRoute.port };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getRouteEntry(hostname: string): ScriptRouteEntry | null {
|
||||
const entry = this.getRouteByHostname(hostname);
|
||||
return entry ? { ...entry } : null;
|
||||
}
|
||||
|
||||
listRoutes(): ScriptRouteEntry[] {
|
||||
return Array.from(this.routes.values()).map((entry) => Object.assign({}, entry));
|
||||
}
|
||||
|
||||
listRoutesForWorkspace(workspaceId: string): ScriptRouteEntry[] {
|
||||
const hostnames = this.workspaceHostnames.get(workspaceId);
|
||||
if (!hostnames) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const routes: ScriptRouteEntry[] = [];
|
||||
for (const hostname of hostnames) {
|
||||
const entry = this.routes.get(hostname);
|
||||
if (entry) {
|
||||
routes.push({ ...entry });
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
private getRouteByHostname(hostname: string): ScriptRouteEntry | undefined {
|
||||
const canonicalHostname = this.hostnameAliases.get(hostname) ?? hostname;
|
||||
return this.routes.get(canonicalHostname);
|
||||
}
|
||||
|
||||
private getRouteHostnames(
|
||||
entry: Pick<ScriptRouteEntry, "hostname" | "publicHostname">,
|
||||
): string[] {
|
||||
return [entry.hostname, ...(entry.publicHostname ? [entry.publicHostname] : [])];
|
||||
}
|
||||
|
||||
private addHostnameToWorkspaceIndex(workspaceId: string, hostname: string): void {
|
||||
const hostnames = this.workspaceHostnames.get(workspaceId) ?? new Set<string>();
|
||||
hostnames.add(hostname);
|
||||
this.workspaceHostnames.set(workspaceId, hostnames);
|
||||
}
|
||||
|
||||
private removeHostnameFromWorkspaceIndex(workspaceId: string, hostname: string): void {
|
||||
const hostnames = this.workspaceHostnames.get(workspaceId);
|
||||
if (!hostnames) {
|
||||
return;
|
||||
}
|
||||
|
||||
hostnames.delete(hostname);
|
||||
if (hostnames.size === 0) {
|
||||
this.workspaceHostnames.delete(workspaceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function stripHopByHopHeaders(
|
||||
rawHeaders: http.IncomingHttpHeaders,
|
||||
): Record<string, string | string[]> {
|
||||
const out: Record<string, string | string[]> = {};
|
||||
for (const [key, value] of Object.entries(rawHeaders)) {
|
||||
if (value === undefined) continue;
|
||||
if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue;
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createScriptProxyMiddleware
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createScriptProxyMiddleware({
|
||||
routeStore,
|
||||
logger,
|
||||
}: {
|
||||
routeStore: ScriptRouteStore;
|
||||
logger: Logger;
|
||||
}): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
const hostHeader = req.headers.host;
|
||||
if (!hostHeader) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const route = routeStore.findRoute(hostHeader);
|
||||
if (!route) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const forwardedHeaders = stripHopByHopHeaders(req.headers);
|
||||
forwardedHeaders["x-forwarded-for"] = req.socket.remoteAddress ?? "127.0.0.1";
|
||||
forwardedHeaders["x-forwarded-host"] = hostHeader.replace(/:\d+$/, "");
|
||||
forwardedHeaders["x-forwarded-proto"] = req.protocol;
|
||||
|
||||
const proxyReq = http.request(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port: route.port,
|
||||
path: req.originalUrl,
|
||||
method: req.method,
|
||||
headers: forwardedHeaders,
|
||||
},
|
||||
(proxyRes) => {
|
||||
const responseHeaders = stripHopByHopHeaders(proxyRes.headers);
|
||||
res.writeHead(proxyRes.statusCode ?? 502, responseHeaders);
|
||||
proxyRes.pipe(res, { end: true });
|
||||
},
|
||||
);
|
||||
|
||||
proxyReq.on("error", (err) => {
|
||||
logger.warn(
|
||||
{ err, hostname: route.hostname, port: route.port },
|
||||
"Script proxy: upstream unreachable",
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502, { "content-type": "text/plain" });
|
||||
res.end("502 Bad Gateway");
|
||||
}
|
||||
});
|
||||
|
||||
req.pipe(proxyReq, { end: true });
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createScriptProxyUpgradeHandler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createScriptProxyUpgradeHandler({
|
||||
routeStore,
|
||||
logger,
|
||||
}: {
|
||||
routeStore: ScriptRouteStore;
|
||||
logger: Logger;
|
||||
}): (req: IncomingMessage, socket: net.Socket, head: Buffer) => void {
|
||||
return (req, socket, head) => {
|
||||
const hostHeader = req.headers.host;
|
||||
if (!hostHeader) {
|
||||
return;
|
||||
}
|
||||
|
||||
const route = routeStore.findRoute(hostHeader);
|
||||
if (!route) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetSocket = net.connect({ host: "127.0.0.1", port: route.port }, () => {
|
||||
// Reconstruct the raw HTTP upgrade request to send to the target
|
||||
const forwardedHeaders = stripHopByHopHeaders(req.headers);
|
||||
forwardedHeaders["x-forwarded-for"] = req.socket.remoteAddress ?? "127.0.0.1";
|
||||
forwardedHeaders["x-forwarded-host"] = hostHeader.replace(/:\d+$/, "");
|
||||
forwardedHeaders["x-forwarded-proto"] = "http";
|
||||
|
||||
// Re-include upgrade and connection headers — they are required for
|
||||
// WebSocket handshake even though they are hop-by-hop.
|
||||
forwardedHeaders["connection"] = "Upgrade";
|
||||
forwardedHeaders["upgrade"] = req.headers.upgrade ?? "websocket";
|
||||
|
||||
const headerLines: string[] = [];
|
||||
headerLines.push(`${req.method ?? "GET"} ${req.url ?? "/"} HTTP/${req.httpVersion}`);
|
||||
for (const [key, value] of Object.entries(forwardedHeaders)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const v of value) {
|
||||
headerLines.push(`${key}: ${v}`);
|
||||
}
|
||||
} else {
|
||||
headerLines.push(`${key}: ${value}`);
|
||||
}
|
||||
}
|
||||
headerLines.push("\r\n");
|
||||
|
||||
targetSocket.write(headerLines.join("\r\n"));
|
||||
|
||||
if (head.length > 0) {
|
||||
targetSocket.write(head);
|
||||
}
|
||||
|
||||
// Pipe in both directions
|
||||
targetSocket.pipe(socket);
|
||||
socket.pipe(targetSocket);
|
||||
});
|
||||
|
||||
targetSocket.on("error", (err) => {
|
||||
logger.warn(
|
||||
{ err, hostname: route.hostname, port: route.port },
|
||||
"Script proxy: WebSocket upstream unreachable",
|
||||
);
|
||||
socket.end();
|
||||
});
|
||||
|
||||
socket.on("error", () => {
|
||||
targetSocket.destroy();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findFreePort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function findFreePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close();
|
||||
reject(new Error("Failed to get assigned port"));
|
||||
return;
|
||||
}
|
||||
const { port } = address;
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(port);
|
||||
}
|
||||
});
|
||||
});
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
export {
|
||||
createScriptProxyMiddleware,
|
||||
createScriptProxyUpgradeHandler,
|
||||
findFreePort,
|
||||
ScriptRouteStore,
|
||||
} from "./service-proxy.js";
|
||||
export type { ScriptRoute, ScriptRouteEntry } from "./service-proxy.js";
|
||||
|
||||
@@ -76,22 +76,22 @@ describe("script-route-branch-handler", () => {
|
||||
it("updates routes on branch rename by removing old hostnames and registering new ones", () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
registerRoute(routeStore, {
|
||||
hostname: "api.feature-auth.paseo.localhost",
|
||||
hostname: "api--feature-auth--paseo.localhost",
|
||||
port: 3001,
|
||||
scriptName: "api",
|
||||
});
|
||||
|
||||
const onRoutesChanged = vi.fn();
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onRoutesChanged,
|
||||
});
|
||||
|
||||
handleBranchChange("workspace-a", "feature/auth", "feature/billing");
|
||||
|
||||
expect(routeStore.findRoute("api.feature-auth.paseo.localhost")).toBeNull();
|
||||
expect(routeStore.findRoute("api.feature-billing.paseo.localhost")).toEqual({
|
||||
hostname: "api.feature-billing.paseo.localhost",
|
||||
expect(routeStore.findRoute("api--feature-auth--paseo.localhost")).toBeNull();
|
||||
expect(routeStore.findRoute("api--feature-billing--paseo.localhost")).toEqual({
|
||||
hostname: "api--feature-billing--paseo.localhost",
|
||||
port: 3001,
|
||||
});
|
||||
});
|
||||
@@ -100,7 +100,7 @@ describe("script-route-branch-handler", () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
const onRoutesChanged = vi.fn();
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onRoutesChanged,
|
||||
});
|
||||
|
||||
@@ -113,14 +113,14 @@ describe("script-route-branch-handler", () => {
|
||||
it("is a no-op when the resolved hostnames do not change", () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
registerRoute(routeStore, {
|
||||
hostname: "api.paseo.localhost",
|
||||
hostname: "api--paseo.localhost",
|
||||
port: 3001,
|
||||
scriptName: "api",
|
||||
});
|
||||
|
||||
const onRoutesChanged = vi.fn();
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onRoutesChanged,
|
||||
});
|
||||
|
||||
@@ -128,7 +128,7 @@ describe("script-route-branch-handler", () => {
|
||||
|
||||
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
|
||||
{
|
||||
hostname: "api.paseo.localhost",
|
||||
hostname: "api--paseo.localhost",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "paseo",
|
||||
@@ -141,14 +141,14 @@ describe("script-route-branch-handler", () => {
|
||||
it("triggers shared reprojection after a route change", () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
registerRoute(routeStore, {
|
||||
hostname: "api.feature-auth.paseo.localhost",
|
||||
hostname: "api--feature-auth--paseo.localhost",
|
||||
port: 3001,
|
||||
scriptName: "api",
|
||||
});
|
||||
|
||||
const onRoutesChanged = vi.fn();
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onRoutesChanged,
|
||||
});
|
||||
|
||||
@@ -160,7 +160,7 @@ describe("script-route-branch-handler", () => {
|
||||
it("updates public route aliases from the stored public base URL", () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
registerRoute(routeStore, {
|
||||
hostname: "api.feature-auth.paseo.localhost",
|
||||
hostname: "api--feature-auth--paseo.localhost",
|
||||
publicHostname: "api--feature-auth--paseo.services.example.com",
|
||||
publicBaseUrl: "https://services.example.com:8443",
|
||||
port: 3001,
|
||||
@@ -169,7 +169,7 @@ describe("script-route-branch-handler", () => {
|
||||
|
||||
const onRoutesChanged = vi.fn();
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onRoutesChanged,
|
||||
});
|
||||
|
||||
@@ -177,12 +177,12 @@ describe("script-route-branch-handler", () => {
|
||||
|
||||
expect(routeStore.findRoute("api--feature-auth--paseo.services.example.com")).toBeNull();
|
||||
expect(routeStore.findRoute("api--feature-billing--paseo.services.example.com")).toEqual({
|
||||
hostname: "api.feature-billing.paseo.localhost",
|
||||
hostname: "api--feature-billing--paseo.localhost",
|
||||
port: 3001,
|
||||
});
|
||||
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
|
||||
{
|
||||
hostname: "api.feature-billing.paseo.localhost",
|
||||
hostname: "api--feature-billing--paseo.localhost",
|
||||
publicHostname: "api--feature-billing--paseo.services.example.com",
|
||||
publicBaseUrl: "https://services.example.com:8443",
|
||||
port: 3001,
|
||||
@@ -196,17 +196,17 @@ describe("script-route-branch-handler", () => {
|
||||
it("updates all services for a workspace when multiple routes are registered", () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
registerRoute(routeStore, {
|
||||
hostname: "api.feature-auth.paseo.localhost",
|
||||
hostname: "api--feature-auth--paseo.localhost",
|
||||
port: 3001,
|
||||
scriptName: "api",
|
||||
});
|
||||
registerRoute(routeStore, {
|
||||
hostname: "web.feature-auth.paseo.localhost",
|
||||
hostname: "web--feature-auth--paseo.localhost",
|
||||
port: 3002,
|
||||
scriptName: "web",
|
||||
});
|
||||
registerRoute(routeStore, {
|
||||
hostname: "docs.docs-app.localhost",
|
||||
hostname: "docs--docs-app.localhost",
|
||||
port: 3003,
|
||||
workspaceId: "workspace-b",
|
||||
projectSlug: "docs-app",
|
||||
@@ -215,7 +215,7 @@ describe("script-route-branch-handler", () => {
|
||||
|
||||
const onRoutesChanged = vi.fn();
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onRoutesChanged,
|
||||
});
|
||||
|
||||
@@ -223,14 +223,14 @@ describe("script-route-branch-handler", () => {
|
||||
|
||||
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
|
||||
{
|
||||
hostname: "api.feature-billing.paseo.localhost",
|
||||
hostname: "api--feature-billing--paseo.localhost",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "paseo",
|
||||
scriptName: "api",
|
||||
},
|
||||
{
|
||||
hostname: "web.feature-billing.paseo.localhost",
|
||||
hostname: "web--feature-billing--paseo.localhost",
|
||||
port: 3002,
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "paseo",
|
||||
@@ -239,7 +239,7 @@ describe("script-route-branch-handler", () => {
|
||||
]);
|
||||
expect(routeStore.listRoutesForWorkspace("workspace-b")).toEqual([
|
||||
{
|
||||
hostname: "docs.docs-app.localhost",
|
||||
hostname: "docs--docs-app.localhost",
|
||||
port: 3003,
|
||||
workspaceId: "workspace-b",
|
||||
projectSlug: "docs-app",
|
||||
@@ -251,14 +251,14 @@ describe("script-route-branch-handler", () => {
|
||||
it("does not emit a status update when no changes are needed", () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
registerRoute(routeStore, {
|
||||
hostname: "web.paseo.localhost",
|
||||
hostname: "web--paseo.localhost",
|
||||
port: 3002,
|
||||
scriptName: "web",
|
||||
});
|
||||
|
||||
const onRoutesChanged = vi.fn();
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onRoutesChanged,
|
||||
});
|
||||
|
||||
@@ -279,7 +279,7 @@ describe("script-route-branch-handler", () => {
|
||||
});
|
||||
const routeStore = new ScriptRouteStore();
|
||||
registerRoute(routeStore, {
|
||||
hostname: "api.feature-auth.repo.localhost",
|
||||
hostname: "api--feature-auth--repo.localhost",
|
||||
port: 3001,
|
||||
workspaceId: workspace.repoDir,
|
||||
projectSlug: "repo",
|
||||
@@ -288,7 +288,7 @@ describe("script-route-branch-handler", () => {
|
||||
|
||||
const onRoutesChanged = vi.fn();
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onRoutesChanged,
|
||||
});
|
||||
|
||||
@@ -297,7 +297,7 @@ describe("script-route-branch-handler", () => {
|
||||
|
||||
expect(routeStore.listRoutesForWorkspace(workspace.repoDir)).toEqual([
|
||||
{
|
||||
hostname: "api.feature-billing.repo.localhost",
|
||||
hostname: "api--feature-billing--repo.localhost",
|
||||
port: 3001,
|
||||
workspaceId: workspace.repoDir,
|
||||
projectSlug: "repo",
|
||||
@@ -309,4 +309,99 @@ describe("script-route-branch-handler", () => {
|
||||
workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves existing local and public routes intact when a branch rename collides", () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
registerRoute(routeStore, {
|
||||
hostname: "api--feature-auth--repo.localhost",
|
||||
publicHostname: "api--feature-auth--repo.services.example.com",
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "api",
|
||||
});
|
||||
registerRoute(routeStore, {
|
||||
hostname: "api--feature-billing--repo.localhost",
|
||||
publicHostname: "api--feature-billing--repo.services.example.com",
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
port: 4001,
|
||||
workspaceId: "workspace-b",
|
||||
projectSlug: "repo",
|
||||
scriptName: "api",
|
||||
});
|
||||
|
||||
const onRoutesChanged = vi.fn();
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
serviceProxy: routeStore,
|
||||
onRoutesChanged,
|
||||
});
|
||||
|
||||
expect(() => handleBranchChange("workspace-a", "feature/auth", "feature/billing")).toThrow(
|
||||
"Service proxy hostname collision",
|
||||
);
|
||||
|
||||
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
|
||||
{
|
||||
hostname: "api--feature-auth--repo.localhost",
|
||||
publicHostname: "api--feature-auth--repo.services.example.com",
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "api",
|
||||
},
|
||||
]);
|
||||
expect(routeStore.getRouteEntry("api--feature-auth--repo.services.example.com")).toMatchObject({
|
||||
workspaceId: "workspace-a",
|
||||
port: 3001,
|
||||
});
|
||||
expect(
|
||||
routeStore.getRouteEntry("api--feature-billing--repo.services.example.com"),
|
||||
).toMatchObject({
|
||||
workspaceId: "workspace-b",
|
||||
port: 4001,
|
||||
});
|
||||
expect(onRoutesChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves old routes intact when branch rename creates an internal incoming collision", () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api--feature-one--repo.localhost",
|
||||
publicHostname: "api--feature-one--repo.services.example.com",
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "api",
|
||||
});
|
||||
routeStore.registerRoute({
|
||||
hostname: "api--feature-two--repo.localhost",
|
||||
publicHostname: "api--feature-two--repo.services.example.com",
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
port: 3002,
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "api",
|
||||
});
|
||||
|
||||
const onRoutesChanged = vi.fn();
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
serviceProxy: routeStore,
|
||||
onRoutesChanged,
|
||||
});
|
||||
|
||||
expect(() => handleBranchChange("workspace-a", "feature/one", "feature/collide")).toThrow(
|
||||
"Service proxy hostname collision",
|
||||
);
|
||||
expect(routeStore.getRouteEntry("api--feature-one--repo.localhost")).toMatchObject({
|
||||
port: 3001,
|
||||
});
|
||||
expect(routeStore.getRouteEntry("api--feature-two--repo.localhost")).toMatchObject({
|
||||
port: 3002,
|
||||
});
|
||||
expect(routeStore.getRouteEntry("api--feature-collide--repo.localhost")).toBeNull();
|
||||
expect(onRoutesChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,80 +1,24 @@
|
||||
import type { Logger } from "pino";
|
||||
import { buildPublicScriptHostname, buildScriptHostname } from "../utils/script-hostname.js";
|
||||
import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js";
|
||||
import type { ServiceProxySubsystem } from "./service-proxy.js";
|
||||
|
||||
interface BranchChangeRouteHandlerOptions {
|
||||
routeStore: ScriptRouteStore;
|
||||
serviceProxy: ServiceProxySubsystem;
|
||||
onRoutesChanged: (workspaceId: string) => void;
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
interface RouteHostnameUpdate {
|
||||
oldHostname: string;
|
||||
newHostname: string;
|
||||
newPublicHostname: string | null;
|
||||
route: ScriptRouteEntry;
|
||||
}
|
||||
|
||||
export function createBranchChangeRouteHandler(
|
||||
options: BranchChangeRouteHandlerOptions,
|
||||
): (workspaceId: string, oldBranch: string | null, newBranch: string | null) => void {
|
||||
return (workspaceId, _oldBranch, newBranch) => {
|
||||
// Only service scripts register routes, so branch renames only touch services.
|
||||
const routes = options.routeStore.listRoutesForWorkspace(workspaceId);
|
||||
if (routes.length === 0) {
|
||||
const changed = options.serviceProxy.replaceWorkspaceBranchRoutes({ workspaceId, newBranch });
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: RouteHostnameUpdate[] = [];
|
||||
for (const route of routes) {
|
||||
const newHostname = buildScriptHostname({
|
||||
projectSlug: route.projectSlug,
|
||||
branchName: newBranch,
|
||||
scriptName: route.scriptName,
|
||||
});
|
||||
const newPublicHostname = route.publicBaseUrl
|
||||
? buildPublicScriptHostname({
|
||||
projectSlug: route.projectSlug,
|
||||
branchName: newBranch,
|
||||
scriptName: route.scriptName,
|
||||
publicBaseUrl: route.publicBaseUrl,
|
||||
})
|
||||
: null;
|
||||
if (newHostname !== route.hostname || newPublicHostname !== (route.publicHostname ?? null)) {
|
||||
updates.push({
|
||||
oldHostname: route.hostname,
|
||||
newHostname,
|
||||
newPublicHostname,
|
||||
route,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const { oldHostname, newHostname, newPublicHostname, route } of updates) {
|
||||
options.routeStore.removeRoute(oldHostname);
|
||||
options.routeStore.registerRoute({
|
||||
hostname: newHostname,
|
||||
publicHostname: newPublicHostname,
|
||||
publicBaseUrl: route.publicBaseUrl ?? null,
|
||||
port: route.port,
|
||||
workspaceId: route.workspaceId,
|
||||
projectSlug: route.projectSlug,
|
||||
scriptName: route.scriptName,
|
||||
});
|
||||
options.logger?.info(
|
||||
{
|
||||
oldHostname,
|
||||
newHostname,
|
||||
scriptName: route.scriptName,
|
||||
},
|
||||
"Updated script route for branch rename",
|
||||
);
|
||||
}
|
||||
|
||||
options.logger?.info(
|
||||
{ workspaceId, newBranch },
|
||||
"Updated service proxy routes for branch rename",
|
||||
);
|
||||
options.onRoutesChanged(workspaceId);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,15 +54,22 @@ function buildPayloads(input: {
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string;
|
||||
paseoConfig?: PaseoConfig | null;
|
||||
routeStore: ScriptRouteStore;
|
||||
routeStore?: ScriptRouteStore;
|
||||
serviceProxy?: ScriptRouteStore;
|
||||
runtimeStore: WorkspaceScriptRuntimeStore;
|
||||
daemonPort: number | null;
|
||||
serviceProxyPublicBaseUrl?: string | null;
|
||||
gitMetadata?: { projectSlug: string; currentBranch: string | null };
|
||||
resolveHealth?: (hostname: string) => ScriptHealthState | null;
|
||||
}) {
|
||||
const paseoConfig =
|
||||
input.paseoConfig !== undefined ? input.paseoConfig : loadConfig(input.workspaceDirectory);
|
||||
return buildWorkspaceScriptPayloads({ ...input, paseoConfig });
|
||||
const { routeStore, serviceProxy, ...rest } = input;
|
||||
return buildWorkspaceScriptPayloads({
|
||||
...rest,
|
||||
serviceProxy: serviceProxy ?? routeStore ?? new ScriptRouteStore(),
|
||||
paseoConfig,
|
||||
});
|
||||
}
|
||||
|
||||
function loadConfig(repoRoot: string): PaseoConfig | null {
|
||||
@@ -131,9 +138,11 @@ describe("script-status-projection", () => {
|
||||
{
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "web.repo.localhost",
|
||||
hostname: "web--repo.localhost",
|
||||
port: 3000,
|
||||
proxyUrl: "http://web.repo.localhost:6767",
|
||||
localProxyUrl: "http://web--repo.localhost:6767",
|
||||
publicProxyUrl: null,
|
||||
proxyUrl: "http://web--repo.localhost:6767",
|
||||
lifecycle: "stopped",
|
||||
health: null,
|
||||
exitCode: null,
|
||||
@@ -162,7 +171,7 @@ describe("script-status-projection", () => {
|
||||
const payloads = buildPayloads({
|
||||
workspaceId,
|
||||
workspaceDirectory: workspace.repoDir,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
daemonPort: 6767,
|
||||
gitMetadata: {
|
||||
@@ -175,9 +184,54 @@ describe("script-status-projection", () => {
|
||||
{
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "web.feature-from-service.service-provided.localhost",
|
||||
hostname: "web--feature-from-service--service-provided.localhost",
|
||||
port: 3000,
|
||||
proxyUrl: "http://web.feature-from-service.service-provided.localhost:6767",
|
||||
localProxyUrl: "http://web--feature-from-service--service-provided.localhost:6767",
|
||||
publicProxyUrl: null,
|
||||
proxyUrl: "http://web--feature-from-service--service-provided.localhost:6767",
|
||||
lifecycle: "stopped",
|
||||
health: null,
|
||||
exitCode: null,
|
||||
terminalId: null,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("projects local and public service URLs while keeping proxyUrl public-first", () => {
|
||||
const workspaceId = "workspace-public-service";
|
||||
const workspace = createWorkspaceRepo({
|
||||
paseoConfig: {
|
||||
scripts: {
|
||||
web: { type: "service", command: "npm run web", port: 3000 },
|
||||
},
|
||||
},
|
||||
});
|
||||
const routeStore = new ScriptRouteStore();
|
||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||
|
||||
try {
|
||||
expect(
|
||||
buildPayloads({
|
||||
workspaceId,
|
||||
workspaceDirectory: workspace.repoDir,
|
||||
routeStore,
|
||||
runtimeStore,
|
||||
daemonPort: 6767,
|
||||
serviceProxyPublicBaseUrl: "https://services.example.com",
|
||||
gitMetadata: { projectSlug: "repo", currentBranch: "feature/card" },
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "web--feature-card--repo.localhost",
|
||||
port: 3000,
|
||||
localProxyUrl: "http://web--feature-card--repo.localhost:6767",
|
||||
publicProxyUrl: "https://web--feature-card--repo.services.example.com",
|
||||
proxyUrl: "https://web--feature-card--repo.services.example.com",
|
||||
lifecycle: "stopped",
|
||||
health: null,
|
||||
exitCode: null,
|
||||
@@ -201,7 +255,7 @@ describe("script-status-projection", () => {
|
||||
});
|
||||
const routeStore = new ScriptRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "web.feature-card.repo.localhost",
|
||||
hostname: "web--feature-card--repo.localhost",
|
||||
port: 4321,
|
||||
workspaceId,
|
||||
projectSlug: "repo",
|
||||
@@ -231,9 +285,11 @@ describe("script-status-projection", () => {
|
||||
{
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "web.feature-card.repo.localhost",
|
||||
hostname: "web--feature-card--repo.localhost",
|
||||
port: 4321,
|
||||
proxyUrl: "http://web.feature-card.repo.localhost:6767",
|
||||
localProxyUrl: "http://web--feature-card--repo.localhost:6767",
|
||||
publicProxyUrl: null,
|
||||
proxyUrl: "http://web--feature-card--repo.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
@@ -256,7 +312,7 @@ describe("script-status-projection", () => {
|
||||
});
|
||||
const routeStore = new ScriptRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "web.repo.localhost",
|
||||
hostname: "web--repo.localhost",
|
||||
port: 4321,
|
||||
workspaceId,
|
||||
projectSlug: "repo",
|
||||
@@ -286,9 +342,11 @@ describe("script-status-projection", () => {
|
||||
{
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "web.repo.localhost",
|
||||
hostname: "web--repo.localhost",
|
||||
port: 4321,
|
||||
proxyUrl: "http://web.repo.localhost:6767",
|
||||
localProxyUrl: "http://web--repo.localhost:6767",
|
||||
publicProxyUrl: null,
|
||||
proxyUrl: "http://web--repo.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: null,
|
||||
exitCode: null,
|
||||
@@ -305,7 +363,7 @@ describe("script-status-projection", () => {
|
||||
const workspace = createWorkspaceRepo();
|
||||
const routeStore = new ScriptRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "docs.repo.localhost",
|
||||
hostname: "docs--repo.localhost",
|
||||
port: 3002,
|
||||
workspaceId,
|
||||
projectSlug: "repo",
|
||||
@@ -334,9 +392,11 @@ describe("script-status-projection", () => {
|
||||
{
|
||||
scriptName: "docs",
|
||||
type: "service",
|
||||
hostname: "docs.repo.localhost",
|
||||
hostname: "docs--repo.localhost",
|
||||
port: 3002,
|
||||
proxyUrl: "http://docs.repo.localhost:6767",
|
||||
localProxyUrl: "http://docs--repo.localhost:6767",
|
||||
publicProxyUrl: null,
|
||||
proxyUrl: "http://docs--repo.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: null,
|
||||
exitCode: null,
|
||||
@@ -462,7 +522,7 @@ describe("script-status-projection", () => {
|
||||
});
|
||||
const routeStore = new ScriptRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.repo.localhost",
|
||||
hostname: "api--repo.localhost",
|
||||
port: 3001,
|
||||
workspaceId,
|
||||
projectSlug: "repo",
|
||||
@@ -481,7 +541,7 @@ describe("script-status-projection", () => {
|
||||
const session = { emit: vi.fn() };
|
||||
const emitUpdate = createScriptStatusEmitter({
|
||||
sessions: () => [session],
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
daemonPort: 6767,
|
||||
resolveWorkspaceDirectory: async (requestedWorkspaceId) =>
|
||||
@@ -493,7 +553,7 @@ describe("script-status-projection", () => {
|
||||
emitUpdate(workspaceId, [
|
||||
{
|
||||
scriptName: "api",
|
||||
hostname: "api.repo.localhost",
|
||||
hostname: "api--repo.localhost",
|
||||
port: 3001,
|
||||
health: "healthy",
|
||||
},
|
||||
@@ -508,9 +568,11 @@ describe("script-status-projection", () => {
|
||||
{
|
||||
scriptName: "api",
|
||||
type: "service",
|
||||
hostname: "api.repo.localhost",
|
||||
hostname: "api--repo.localhost",
|
||||
port: 3001,
|
||||
proxyUrl: "http://api.repo.localhost:6767",
|
||||
localProxyUrl: "http://api--repo.localhost:6767",
|
||||
publicProxyUrl: null,
|
||||
proxyUrl: "http://api--repo.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
|
||||
@@ -5,11 +5,13 @@ import type {
|
||||
WorkspaceScriptPayload,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
|
||||
import { buildPublicScriptProxyUrl, buildScriptHostname } from "../utils/script-hostname.js";
|
||||
import { getScriptConfigs, isServiceScript, readPaseoConfig } from "../utils/worktree.js";
|
||||
import { deriveProjectSlug } from "./workspace-git-metadata.js";
|
||||
import type { ScriptHealthEntry, ScriptHealthState } from "./script-health-monitor.js";
|
||||
import type { ScriptRouteStore } from "./script-proxy.js";
|
||||
import type {
|
||||
ServiceProxySubsystem,
|
||||
ServiceProxyWorkspaceScriptProjection,
|
||||
} from "./service-proxy.js";
|
||||
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||
|
||||
interface SessionEmitter {
|
||||
@@ -20,7 +22,7 @@ interface BuildWorkspaceScriptPayloadsOptions {
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string;
|
||||
paseoConfig: PaseoConfig | null;
|
||||
routeStore: ScriptRouteStore;
|
||||
serviceProxy: ServiceProxySubsystem;
|
||||
runtimeStore: WorkspaceScriptRuntimeStore;
|
||||
daemonPort: number | null;
|
||||
serviceProxyPublicBaseUrl?: string | null;
|
||||
@@ -53,28 +55,6 @@ function resolveDaemonPort(daemonPort: number | null | (() => number | null)): n
|
||||
return daemonPort;
|
||||
}
|
||||
|
||||
function toServiceProxyUrl(params: {
|
||||
hostname: string;
|
||||
daemonPort: number | null;
|
||||
serviceProxyPublicBaseUrl?: string | null;
|
||||
projectSlug: string;
|
||||
branchName: string | null;
|
||||
scriptName: string;
|
||||
}): string | null {
|
||||
if (params.serviceProxyPublicBaseUrl) {
|
||||
return buildPublicScriptProxyUrl({
|
||||
projectSlug: params.projectSlug,
|
||||
branchName: params.branchName,
|
||||
scriptName: params.scriptName,
|
||||
publicBaseUrl: params.serviceProxyPublicBaseUrl,
|
||||
});
|
||||
}
|
||||
if (params.daemonPort === null) {
|
||||
return null;
|
||||
}
|
||||
return `http://${params.hostname}:${params.daemonPort}`;
|
||||
}
|
||||
|
||||
function toWireHealth(health: ScriptHealthState | null): WorkspaceScriptPayload["health"] {
|
||||
if (health === "pending" || health === null) {
|
||||
return null;
|
||||
@@ -92,54 +72,92 @@ function sortPayloads(payloads: WorkspaceScriptPayload[]): WorkspaceScriptPayloa
|
||||
}
|
||||
|
||||
type RuntimeEntry = ReturnType<WorkspaceScriptRuntimeStore["listForWorkspace"]>[number];
|
||||
type RouteEntry = ReturnType<ScriptRouteStore["listRoutesForWorkspace"]>[number];
|
||||
|
||||
interface BuildPayloadContext {
|
||||
projectSlug: string;
|
||||
branchName: string | null;
|
||||
daemonPort: number | null;
|
||||
serviceProxyPublicBaseUrl?: string | null;
|
||||
serviceProxy: ServiceProxySubsystem;
|
||||
resolveHealth?: (hostname: string) => ScriptHealthState | null;
|
||||
}
|
||||
|
||||
function projectWorkspaceServiceState(params: {
|
||||
workspaceId: string;
|
||||
scriptName: string;
|
||||
ctx: BuildPayloadContext;
|
||||
}): ServiceProxyWorkspaceScriptProjection {
|
||||
return params.ctx.serviceProxy.projectWorkspaceServiceState({
|
||||
workspaceId: params.workspaceId,
|
||||
projectSlug: params.ctx.projectSlug,
|
||||
branchName: params.ctx.branchName,
|
||||
scriptName: params.scriptName,
|
||||
daemonPort: params.ctx.daemonPort,
|
||||
publicBaseUrl: params.ctx.serviceProxyPublicBaseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
function buildConfiguredPlainScriptPayload(
|
||||
scriptName: string,
|
||||
runtimeEntry: RuntimeEntry | null,
|
||||
): WorkspaceScriptPayload {
|
||||
return {
|
||||
scriptName,
|
||||
type: "script",
|
||||
hostname: scriptName,
|
||||
port: null,
|
||||
proxyUrl: null,
|
||||
lifecycle: runtimeEntry?.lifecycle ?? "stopped",
|
||||
health: null,
|
||||
exitCode: runtimeEntry?.exitCode ?? null,
|
||||
terminalId: runtimeEntry?.terminalId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildConfiguredScriptPayload(
|
||||
scriptName: string,
|
||||
config: ReturnType<typeof getScriptConfigs> extends Map<string, infer V> ? V : never,
|
||||
runtimeEntry: RuntimeEntry | null,
|
||||
routeEntry: RouteEntry | null,
|
||||
serviceState: ServiceProxyWorkspaceScriptProjection | null,
|
||||
ctx: BuildPayloadContext,
|
||||
): WorkspaceScriptPayload {
|
||||
const configIsService = isServiceScript(config);
|
||||
const type = configIsService ? "service" : "script";
|
||||
const configuredPort = configIsService ? (config.port ?? null) : null;
|
||||
const hostname =
|
||||
type === "service"
|
||||
? (routeEntry?.hostname ??
|
||||
buildScriptHostname({
|
||||
projectSlug: ctx.projectSlug,
|
||||
branchName: ctx.branchName,
|
||||
scriptName,
|
||||
}))
|
||||
: scriptName;
|
||||
if (!configIsService) {
|
||||
return buildConfiguredPlainScriptPayload(scriptName, runtimeEntry);
|
||||
}
|
||||
|
||||
const type = "service";
|
||||
const configuredPort = config.port ?? null;
|
||||
const hostname = (
|
||||
serviceState ??
|
||||
ctx.serviceProxy.projectWorkspaceService({
|
||||
projectSlug: ctx.projectSlug,
|
||||
branchName: ctx.branchName,
|
||||
scriptName,
|
||||
daemonPort: ctx.daemonPort,
|
||||
publicBaseUrl: ctx.serviceProxyPublicBaseUrl,
|
||||
})
|
||||
).hostname;
|
||||
|
||||
const urls =
|
||||
serviceState ??
|
||||
ctx.serviceProxy.projectUrls({
|
||||
projectSlug: ctx.projectSlug,
|
||||
branchName: ctx.branchName,
|
||||
scriptName,
|
||||
daemonPort: ctx.daemonPort,
|
||||
publicBaseUrl: ctx.serviceProxyPublicBaseUrl,
|
||||
});
|
||||
|
||||
return {
|
||||
scriptName,
|
||||
type,
|
||||
hostname,
|
||||
port: type === "service" ? (routeEntry?.port ?? configuredPort) : null,
|
||||
proxyUrl:
|
||||
type === "service"
|
||||
? toServiceProxyUrl({
|
||||
hostname,
|
||||
daemonPort: ctx.daemonPort,
|
||||
serviceProxyPublicBaseUrl: ctx.serviceProxyPublicBaseUrl,
|
||||
projectSlug: ctx.projectSlug,
|
||||
branchName: ctx.branchName,
|
||||
scriptName,
|
||||
})
|
||||
: null,
|
||||
port: serviceState?.port ?? configuredPort,
|
||||
localProxyUrl: urls.localProxyUrl,
|
||||
publicProxyUrl: urls.publicProxyUrl,
|
||||
proxyUrl: urls.proxyUrl,
|
||||
lifecycle: runtimeEntry?.lifecycle ?? "stopped",
|
||||
health: type === "service" ? toWireHealth(ctx.resolveHealth?.(hostname) ?? null) : null,
|
||||
health: toWireHealth(ctx.resolveHealth?.(hostname) ?? null),
|
||||
exitCode: runtimeEntry?.exitCode ?? null,
|
||||
terminalId: runtimeEntry?.terminalId ?? null,
|
||||
};
|
||||
@@ -147,38 +165,47 @@ function buildConfiguredScriptPayload(
|
||||
|
||||
function buildOrphanRuntimePayload(
|
||||
runtimeEntry: RuntimeEntry,
|
||||
routeEntry: RouteEntry | null,
|
||||
serviceState: ServiceProxyWorkspaceScriptProjection | null,
|
||||
ctx: BuildPayloadContext,
|
||||
): WorkspaceScriptPayload {
|
||||
const type = runtimeEntry.type;
|
||||
const hostname =
|
||||
type === "service"
|
||||
? (routeEntry?.hostname ??
|
||||
buildScriptHostname({
|
||||
projectSlug: ctx.projectSlug,
|
||||
branchName: ctx.branchName,
|
||||
scriptName: runtimeEntry.scriptName,
|
||||
}))
|
||||
? (
|
||||
serviceState ??
|
||||
ctx.serviceProxy.projectWorkspaceService({
|
||||
projectSlug: ctx.projectSlug,
|
||||
branchName: ctx.branchName,
|
||||
scriptName: runtimeEntry.scriptName,
|
||||
daemonPort: ctx.daemonPort,
|
||||
publicBaseUrl: ctx.serviceProxyPublicBaseUrl,
|
||||
})
|
||||
).hostname
|
||||
: runtimeEntry.scriptName;
|
||||
const urls =
|
||||
serviceState ??
|
||||
ctx.serviceProxy.projectUrls({
|
||||
projectSlug: ctx.projectSlug,
|
||||
branchName: ctx.branchName,
|
||||
scriptName: runtimeEntry.scriptName,
|
||||
daemonPort: ctx.daemonPort,
|
||||
publicBaseUrl: ctx.serviceProxyPublicBaseUrl,
|
||||
});
|
||||
|
||||
return {
|
||||
scriptName: runtimeEntry.scriptName,
|
||||
type,
|
||||
hostname,
|
||||
port: type === "service" ? (routeEntry?.port ?? null) : null,
|
||||
proxyUrl:
|
||||
type === "service"
|
||||
? toServiceProxyUrl({
|
||||
hostname,
|
||||
daemonPort: ctx.daemonPort,
|
||||
serviceProxyPublicBaseUrl: ctx.serviceProxyPublicBaseUrl,
|
||||
projectSlug: ctx.projectSlug,
|
||||
branchName: ctx.branchName,
|
||||
scriptName: runtimeEntry.scriptName,
|
||||
})
|
||||
: null,
|
||||
port: type === "service" ? (serviceState?.port ?? null) : null,
|
||||
...(type === "service"
|
||||
? { localProxyUrl: urls.localProxyUrl, publicProxyUrl: urls.publicProxyUrl }
|
||||
: {}),
|
||||
proxyUrl: type === "service" ? urls.proxyUrl : null,
|
||||
lifecycle: runtimeEntry.lifecycle,
|
||||
health:
|
||||
type === "service" && routeEntry ? toWireHealth(ctx.resolveHealth?.(hostname) ?? null) : null,
|
||||
type === "service" && serviceState?.port !== null
|
||||
? toWireHealth(ctx.resolveHealth?.(hostname) ?? null)
|
||||
: null,
|
||||
exitCode: runtimeEntry.exitCode,
|
||||
terminalId: runtimeEntry.terminalId,
|
||||
};
|
||||
@@ -197,17 +224,12 @@ export function buildWorkspaceScriptPayloads(
|
||||
.listForWorkspace(workspaceId)
|
||||
.map((entry) => [entry.scriptName, entry] as const),
|
||||
);
|
||||
const routesByScriptName = new Map(
|
||||
options.routeStore
|
||||
.listRoutesForWorkspace(workspaceId)
|
||||
.map((entry) => [entry.scriptName, entry] as const),
|
||||
);
|
||||
|
||||
const ctx: BuildPayloadContext = {
|
||||
projectSlug,
|
||||
branchName,
|
||||
daemonPort: options.daemonPort,
|
||||
serviceProxyPublicBaseUrl: options.serviceProxyPublicBaseUrl,
|
||||
serviceProxy: options.serviceProxy,
|
||||
resolveHealth: options.resolveHealth,
|
||||
};
|
||||
|
||||
@@ -215,16 +237,23 @@ export function buildWorkspaceScriptPayloads(
|
||||
|
||||
for (const [scriptName, config] of scriptConfigs.entries()) {
|
||||
const runtimeEntry = runtimeEntries.get(scriptName) ?? null;
|
||||
const routeEntry = routesByScriptName.get(scriptName) ?? null;
|
||||
payloads.push(buildConfiguredScriptPayload(scriptName, config, runtimeEntry, routeEntry, ctx));
|
||||
const serviceState = isServiceScript(config)
|
||||
? projectWorkspaceServiceState({ workspaceId, scriptName, ctx })
|
||||
: null;
|
||||
payloads.push(
|
||||
buildConfiguredScriptPayload(scriptName, config, runtimeEntry, serviceState, ctx),
|
||||
);
|
||||
}
|
||||
|
||||
for (const runtimeEntry of runtimeEntries.values()) {
|
||||
if (scriptConfigs.has(runtimeEntry.scriptName) || runtimeEntry.lifecycle !== "running") {
|
||||
continue;
|
||||
}
|
||||
const routeEntry = routesByScriptName.get(runtimeEntry.scriptName) ?? null;
|
||||
payloads.push(buildOrphanRuntimePayload(runtimeEntry, routeEntry, ctx));
|
||||
const serviceState =
|
||||
runtimeEntry.type === "service"
|
||||
? projectWorkspaceServiceState({ workspaceId, scriptName: runtimeEntry.scriptName, ctx })
|
||||
: null;
|
||||
payloads.push(buildOrphanRuntimePayload(runtimeEntry, serviceState, ctx));
|
||||
}
|
||||
|
||||
return sortPayloads(payloads);
|
||||
@@ -245,7 +274,7 @@ function buildScriptStatusUpdateMessage(params: {
|
||||
|
||||
export function createScriptStatusEmitter({
|
||||
sessions,
|
||||
routeStore,
|
||||
serviceProxy,
|
||||
runtimeStore,
|
||||
daemonPort,
|
||||
serviceProxyPublicBaseUrl,
|
||||
@@ -253,7 +282,7 @@ export function createScriptStatusEmitter({
|
||||
logger,
|
||||
}: {
|
||||
sessions: () => SessionEmitter[];
|
||||
routeStore: ScriptRouteStore;
|
||||
serviceProxy: ServiceProxySubsystem;
|
||||
runtimeStore: WorkspaceScriptRuntimeStore;
|
||||
daemonPort: number | null | (() => number | null);
|
||||
serviceProxyPublicBaseUrl?: string | null;
|
||||
@@ -276,7 +305,7 @@ export function createScriptStatusEmitter({
|
||||
workspaceId,
|
||||
workspaceDirectory,
|
||||
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger),
|
||||
routeStore,
|
||||
serviceProxy,
|
||||
runtimeStore,
|
||||
daemonPort: resolvedDaemonPort,
|
||||
serviceProxyPublicBaseUrl,
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import http from "node:http";
|
||||
import express from "express";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import pino from "pino";
|
||||
import {
|
||||
buildLocalServiceHostname,
|
||||
buildPublicServiceHostname,
|
||||
buildServiceProxyLabel,
|
||||
createServiceProxySubsystem,
|
||||
findFreePort,
|
||||
ServiceProxyRouteRegistry,
|
||||
} from "./service-proxy.js";
|
||||
|
||||
const logger = pino({ level: "silent" });
|
||||
|
||||
function readServerSourceFiles(dir = path.resolve(import.meta.dirname)): string[] {
|
||||
const entries: string[] = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const fullPath = path.join(dir, name);
|
||||
const stat = statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
entries.push(...readServerSourceFiles(fullPath));
|
||||
} else if (fullPath.endsWith(".ts") && !fullPath.endsWith(".test.ts")) {
|
||||
entries.push(fullPath);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function httpGet(port: number, host: string, requestPath = "/api/health") {
|
||||
return new Promise<{ status: number; body: string }>((resolve, reject) => {
|
||||
const req = http.get(
|
||||
{ hostname: "127.0.0.1", port, path: requestPath, headers: { host } },
|
||||
(res) => {
|
||||
let body = "";
|
||||
res.on("data", (chunk: Buffer) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
res.on("end", () => resolve({ status: res.statusCode ?? 0, body }));
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe("service proxy subsystem shape", () => {
|
||||
it("keeps production imports behind the service-proxy entrypoint", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const filePath of readServerSourceFiles()) {
|
||||
if (filePath.endsWith("service-proxy.ts") || filePath.endsWith("script-proxy.ts")) {
|
||||
continue;
|
||||
}
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
for (const needle of ["./script-proxy.js", "../utils/script-hostname.js"]) {
|
||||
if (source.includes(needle)) {
|
||||
offenders.push(`${path.relative(import.meta.dirname, filePath)} imports ${needle}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("classifies the configured public namespace before any route exists", async () => {
|
||||
const serviceProxy = createServiceProxySubsystem({
|
||||
logger,
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
});
|
||||
const port = await findFreePort();
|
||||
const app = express();
|
||||
app.use(serviceProxy.middleware());
|
||||
app.use((_req, res) => {
|
||||
res.status(200).send("daemon-api");
|
||||
});
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
|
||||
try {
|
||||
await expect(httpGet(port, `missing.services.example.com:${port}`)).resolves.toEqual({
|
||||
status: 404,
|
||||
body: "404 Not Found",
|
||||
});
|
||||
await expect(httpGet(port, `daemon.localhost:${port}`)).resolves.toEqual({
|
||||
status: 200,
|
||||
body: "daemon-api",
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps configured public namespace classified after the last public route is removed", async () => {
|
||||
const serviceProxy = createServiceProxySubsystem({
|
||||
logger,
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
});
|
||||
serviceProxy.registerWorkspaceService({
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo",
|
||||
branchName: "main",
|
||||
scriptName: "api",
|
||||
port: 3000,
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
});
|
||||
serviceProxy.removeWorkspaceService({ workspaceId: "workspace-a", scriptName: "api" });
|
||||
|
||||
const port = await findFreePort();
|
||||
const app = express();
|
||||
app.use(serviceProxy.middleware());
|
||||
app.use((_req, res) => {
|
||||
res.status(200).send("daemon-api");
|
||||
});
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
|
||||
try {
|
||||
await expect(httpGet(port, `missing.services.example.com:${port}`)).resolves.toEqual({
|
||||
status: 404,
|
||||
body: "404 Not Found",
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the same hash-truncated service label for local and public hostnames", () => {
|
||||
const input = {
|
||||
projectSlug: "project-".repeat(10),
|
||||
branchName: "branch-".repeat(10),
|
||||
scriptName: "script-".repeat(10),
|
||||
};
|
||||
const label = buildServiceProxyLabel(input);
|
||||
|
||||
expect(label.length).toBeLessThanOrEqual(63);
|
||||
expect(label.endsWith("-")).toBe(false);
|
||||
expect(buildLocalServiceHostname(input)).toBe(`${label}.localhost`);
|
||||
expect(
|
||||
buildPublicServiceHostname({ ...input, publicBaseUrl: "https://services.example.com" }),
|
||||
).toBe(`${label}.services.example.com`);
|
||||
expect(buildServiceProxyLabel(input)).toBe(label);
|
||||
expect(
|
||||
buildServiceProxyLabel({ ...input, scriptName: `different-${input.scriptName}` }),
|
||||
).not.toBe(label);
|
||||
});
|
||||
|
||||
it("gives long labels with the same prefix different hash suffixes", () => {
|
||||
const sharedPrefix = "service-".repeat(12);
|
||||
const first = buildServiceProxyLabel({
|
||||
projectSlug: "repo",
|
||||
branchName: "feature/shared-prefix",
|
||||
scriptName: `${sharedPrefix}alpha`,
|
||||
});
|
||||
const second = buildServiceProxyLabel({
|
||||
projectSlug: "repo",
|
||||
branchName: "feature/shared-prefix",
|
||||
scriptName: `${sharedPrefix}beta`,
|
||||
});
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
expect(first.slice(0, -10)).toBe(second.slice(0, -10));
|
||||
expect(first.split("--").at(-1)).not.toBe(second.split("--").at(-1));
|
||||
});
|
||||
|
||||
it("rejects cross-service collisions without deleting the existing route", () => {
|
||||
const serviceProxy = createServiceProxySubsystem({ logger });
|
||||
serviceProxy.registerWorkspaceService({
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo",
|
||||
branchName: "main",
|
||||
scriptName: "api",
|
||||
port: 3000,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
serviceProxy.registerWorkspaceService({
|
||||
workspaceId: "workspace-b",
|
||||
projectSlug: "repo",
|
||||
branchName: "main",
|
||||
scriptName: "api",
|
||||
port: 4000,
|
||||
}),
|
||||
).toThrow("Service proxy hostname collision");
|
||||
|
||||
expect(serviceProxy.getHealthTargetForHostname("api--repo.localhost")).toMatchObject({
|
||||
workspaceId: "workspace-a",
|
||||
port: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects public alias collisions without deleting the existing route", () => {
|
||||
const serviceProxy = createServiceProxySubsystem({ logger });
|
||||
serviceProxy.registerWorkspaceService({
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo",
|
||||
branchName: "main",
|
||||
scriptName: "api",
|
||||
port: 3000,
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
serviceProxy.registerWorkspaceService({
|
||||
workspaceId: "workspace-b",
|
||||
projectSlug: "repo",
|
||||
branchName: "main",
|
||||
scriptName: "api",
|
||||
port: 4000,
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
}),
|
||||
).toThrow("Service proxy hostname collision");
|
||||
|
||||
expect(serviceProxy.getHealthTargetForHostname("api--repo.services.example.com")).toMatchObject(
|
||||
{
|
||||
workspaceId: "workspace-a",
|
||||
port: 3000,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects public alias collisions even when canonical hostnames differ", () => {
|
||||
const serviceProxy = new ServiceProxyRouteRegistry();
|
||||
serviceProxy.registerRoute({
|
||||
hostname: "api--repo-a.localhost",
|
||||
publicHostname: "api.services.example.com",
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
port: 3000,
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo-a",
|
||||
scriptName: "api",
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
serviceProxy.registerRoute({
|
||||
hostname: "api--repo-b.localhost",
|
||||
publicHostname: "api.services.example.com",
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
port: 4000,
|
||||
workspaceId: "workspace-b",
|
||||
projectSlug: "repo-b",
|
||||
scriptName: "api",
|
||||
}),
|
||||
).toThrow("Service proxy hostname collision");
|
||||
|
||||
expect(serviceProxy.getRouteEntry("api--repo-a.localhost")).toMatchObject({ port: 3000 });
|
||||
expect(serviceProxy.getRouteEntry("api--repo-b.localhost")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects canonical-to-public-alias collisions", () => {
|
||||
const serviceProxy = new ServiceProxyRouteRegistry();
|
||||
serviceProxy.registerRoute({
|
||||
hostname: "api--repo.localhost",
|
||||
publicHostname: "api.services.example.com",
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
port: 3000,
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo",
|
||||
scriptName: "api",
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
serviceProxy.registerRoute({
|
||||
hostname: "api.services.example.com",
|
||||
port: 4000,
|
||||
workspaceId: "workspace-b",
|
||||
projectSlug: "other",
|
||||
scriptName: "api",
|
||||
}),
|
||||
).toThrow("Service proxy hostname collision");
|
||||
|
||||
expect(serviceProxy.getRouteEntry("api--repo.localhost")).toMatchObject({ port: 3000 });
|
||||
expect(serviceProxy.getRouteEntry("api.services.example.com")).toMatchObject({ port: 3000 });
|
||||
});
|
||||
|
||||
it("allows same workspace/script replacement", () => {
|
||||
const serviceProxy = createServiceProxySubsystem({ logger });
|
||||
serviceProxy.registerWorkspaceService({
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo",
|
||||
branchName: "main",
|
||||
scriptName: "api",
|
||||
port: 3000,
|
||||
});
|
||||
serviceProxy.registerWorkspaceService({
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo",
|
||||
branchName: "main",
|
||||
scriptName: "api",
|
||||
port: 4000,
|
||||
});
|
||||
|
||||
expect(serviceProxy.getHealthTargetForHostname("api--repo.localhost")).toMatchObject({
|
||||
port: 4000,
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -229,7 +229,7 @@ interface SessionForTestOptions {
|
||||
workspaceRegistry?: { get: ReturnType<typeof vi.fn> };
|
||||
projectRegistry?: Partial<SessionOptions["projectRegistry"]>;
|
||||
terminalManager?: SessionOptions["terminalManager"];
|
||||
scriptRouteStore?: SessionOptions["scriptRouteStore"];
|
||||
serviceProxy?: SessionOptions["serviceProxy"];
|
||||
scriptRuntimeStore?: SessionOptions["scriptRuntimeStore"];
|
||||
getDaemonTcpPort?: () => number | null;
|
||||
getDaemonTcpHost?: () => string | null;
|
||||
@@ -311,7 +311,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
terminalManager: options.terminalManager ?? null,
|
||||
providerSnapshotManager:
|
||||
options.providerSnapshotManager ?? createProviderSnapshotManagerStub().manager,
|
||||
scriptRouteStore: options.scriptRouteStore,
|
||||
serviceProxy: options.serviceProxy,
|
||||
scriptRuntimeStore: options.scriptRuntimeStore,
|
||||
getDaemonTcpPort: options.getDaemonTcpPort,
|
||||
getDaemonTcpHost: options.getDaemonTcpHost,
|
||||
@@ -3549,7 +3549,7 @@ describe("session workspace script handling", () => {
|
||||
workspaceGitService,
|
||||
workspaceRegistry,
|
||||
terminalManager: { subscribeTerminalsChanged: vi.fn(() => () => {}) },
|
||||
scriptRouteStore: { listRoutesForWorkspace: vi.fn(() => []) },
|
||||
serviceProxy: { listRoutesForWorkspace: vi.fn(() => []) },
|
||||
scriptRuntimeStore: { listForWorkspace: vi.fn(() => []) },
|
||||
getDaemonTcpPort: () => 6767,
|
||||
getDaemonTcpHost: () => "127.0.0.1",
|
||||
|
||||
@@ -192,7 +192,7 @@ import {
|
||||
import { buildMetadataPrompt } from "../utils/build-metadata-prompt.js";
|
||||
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
|
||||
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
||||
import type { ScriptRouteStore } from "./script-proxy.js";
|
||||
import type { ServiceProxySubsystem } from "./service-proxy.js";
|
||||
import {
|
||||
checkoutResolvedBranch,
|
||||
type CheckoutExistingBranchResult,
|
||||
@@ -588,7 +588,7 @@ export interface SessionOptions {
|
||||
tts: Resolvable<TextToSpeechProvider | null>;
|
||||
terminalManager: TerminalManager | null;
|
||||
providerSnapshotManager: ProviderSnapshotManager;
|
||||
scriptRouteStore?: ScriptRouteStore;
|
||||
serviceProxy?: ServiceProxySubsystem;
|
||||
scriptRuntimeStore?: WorkspaceScriptRuntimeStore;
|
||||
workspaceSetupSnapshots?: Map<string, WorkspaceSetupSnapshot>;
|
||||
onBranchChanged?: (
|
||||
@@ -803,7 +803,7 @@ export class Session {
|
||||
private readonly terminalManager: TerminalManager | null;
|
||||
private readonly providerSnapshotManager: ProviderSnapshotManager;
|
||||
private unsubscribeProviderSnapshotEvents: (() => void) | null = null;
|
||||
private readonly scriptRouteStore: ScriptRouteStore | null;
|
||||
private readonly serviceProxy: ServiceProxySubsystem | null;
|
||||
private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
|
||||
private readonly onBranchChanged?: (
|
||||
workspaceId: string,
|
||||
@@ -881,7 +881,7 @@ export class Session {
|
||||
tts,
|
||||
terminalManager,
|
||||
providerSnapshotManager,
|
||||
scriptRouteStore,
|
||||
serviceProxy,
|
||||
scriptRuntimeStore,
|
||||
workspaceSetupSnapshots,
|
||||
onBranchChanged,
|
||||
@@ -966,7 +966,7 @@ export class Session {
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
this.providerSnapshotManager = providerSnapshotManager;
|
||||
this.scriptRouteStore = scriptRouteStore ?? null;
|
||||
this.serviceProxy = serviceProxy ?? null;
|
||||
this.scriptRuntimeStore = scriptRuntimeStore ?? null;
|
||||
this.workspaceSetupSnapshots = workspaceSetupSnapshots ?? new Map();
|
||||
this.onBranchChanged = onBranchChanged;
|
||||
@@ -6295,12 +6295,12 @@ export class Session {
|
||||
activityAt: null,
|
||||
diffStat,
|
||||
scripts:
|
||||
this.scriptRouteStore && this.scriptRuntimeStore
|
||||
this.serviceProxy && this.scriptRuntimeStore
|
||||
? buildWorkspaceScriptPayloads({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.cwd,
|
||||
paseoConfig: readPaseoConfigForProjection(workspace.cwd, this.sessionLogger),
|
||||
routeStore: this.scriptRouteStore,
|
||||
serviceProxy: this.serviceProxy,
|
||||
runtimeStore: this.scriptRuntimeStore,
|
||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
||||
@@ -7124,14 +7124,14 @@ export class Session {
|
||||
workspaceId: string,
|
||||
workspaceDirectory: string,
|
||||
): WorkspaceDescriptorPayload["scripts"] {
|
||||
if (!this.scriptRouteStore || !this.scriptRuntimeStore) {
|
||||
if (!this.serviceProxy || !this.scriptRuntimeStore) {
|
||||
return [];
|
||||
}
|
||||
return buildWorkspaceScriptPayloads({
|
||||
workspaceId,
|
||||
workspaceDirectory,
|
||||
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, this.sessionLogger),
|
||||
routeStore: this.scriptRouteStore,
|
||||
serviceProxy: this.serviceProxy,
|
||||
runtimeStore: this.scriptRuntimeStore,
|
||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
||||
@@ -7182,7 +7182,7 @@ export class Session {
|
||||
request: StartWorkspaceScriptRequest,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!this.terminalManager || !this.scriptRouteStore || !this.scriptRuntimeStore) {
|
||||
if (!this.terminalManager || !this.serviceProxy || !this.scriptRuntimeStore) {
|
||||
throw new Error("Workspace scripts are not available on this daemon");
|
||||
}
|
||||
|
||||
@@ -7201,7 +7201,7 @@ export class Session {
|
||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||
daemonListenHost: this.getDaemonTcpHost?.() ?? null,
|
||||
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
||||
routeStore: this.scriptRouteStore,
|
||||
serviceProxy: this.serviceProxy,
|
||||
runtimeStore: this.scriptRuntimeStore,
|
||||
terminalManager: this.terminalManager,
|
||||
logger: this.sessionLogger,
|
||||
@@ -7348,7 +7348,7 @@ export class Session {
|
||||
sessionLogger: this.sessionLogger,
|
||||
terminalManager: this.terminalManager,
|
||||
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
||||
scriptRouteStore: this.scriptRouteStore,
|
||||
serviceProxy: this.serviceProxy,
|
||||
scriptRuntimeStore: this.scriptRuntimeStore,
|
||||
getDaemonTcpPort: this.getDaemonTcpPort,
|
||||
getDaemonTcpHost: this.getDaemonTcpHost,
|
||||
|
||||
@@ -2,10 +2,11 @@ import { describe, expect, test, vi } from "vitest";
|
||||
import path from "node:path";
|
||||
import type pino from "pino";
|
||||
import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js";
|
||||
import { ScriptRouteStore } from "./script-proxy.js";
|
||||
import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js";
|
||||
import { Session, type SessionOptions } from "./session.js";
|
||||
import { asInternals, createStub } from "./test-utils/class-mocks.js";
|
||||
import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js";
|
||||
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||
import type {
|
||||
WorkspaceGitListener,
|
||||
@@ -102,7 +103,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
|
||||
oldBranch: string | null,
|
||||
newBranch: string | null,
|
||||
) => void;
|
||||
scriptRouteStore?: ScriptRouteStore;
|
||||
serviceProxy?: ServiceProxySubsystem;
|
||||
scriptRuntimeStore?: WorkspaceScriptRuntimeStore;
|
||||
}): {
|
||||
session: Session;
|
||||
@@ -234,7 +235,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
|
||||
tts: null,
|
||||
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
|
||||
terminalManager: null,
|
||||
scriptRouteStore: options?.scriptRouteStore,
|
||||
serviceProxy: options?.serviceProxy,
|
||||
scriptRuntimeStore: options?.scriptRuntimeStore,
|
||||
onBranchChanged: options?.onBranchChanged,
|
||||
getDaemonTcpPort: () => 6767,
|
||||
@@ -425,12 +426,12 @@ describe("workspace git watch targets", () => {
|
||||
});
|
||||
|
||||
test("updates running service script URLs when the git branch changes", async () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "app.old-branch.paseo.localhost",
|
||||
const serviceProxy = createServiceProxySubsystem({ logger: createTestLogger() });
|
||||
serviceProxy.registerWorkspaceService({
|
||||
port: 4321,
|
||||
workspaceId: "ws-10",
|
||||
projectSlug: "paseo",
|
||||
branchName: "old-branch",
|
||||
scriptName: "app",
|
||||
});
|
||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||
@@ -444,12 +445,12 @@ describe("workspace git watch targets", () => {
|
||||
});
|
||||
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
routeStore,
|
||||
serviceProxy,
|
||||
onRoutesChanged: vi.fn(),
|
||||
});
|
||||
const { session, projects, workspaces, subscriptions } = createSessionForWorkspaceGitWatchTests(
|
||||
{
|
||||
scriptRouteStore: routeStore,
|
||||
serviceProxy,
|
||||
scriptRuntimeStore: runtimeStore,
|
||||
onBranchChanged: handleBranchChange,
|
||||
},
|
||||
@@ -474,18 +475,19 @@ describe("workspace git watch targets", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(routeStore.listRoutesForWorkspace("ws-10")).toEqual([
|
||||
expect(serviceProxy.getWorkspaceHealthTargets("ws-10")).toEqual([
|
||||
expect.objectContaining({
|
||||
hostname: "app.new-branch.paseo.localhost",
|
||||
projectSlug: "paseo",
|
||||
hostname: "app--new-branch--paseo.localhost",
|
||||
scriptName: "app",
|
||||
}),
|
||||
]);
|
||||
expect(sessionAny.buildWorkspaceScriptPayloadSnapshot("ws-10", "/tmp/repo")).toEqual([
|
||||
expect.objectContaining({
|
||||
scriptName: "app",
|
||||
hostname: "app.new-branch.paseo.localhost",
|
||||
proxyUrl: "http://app.new-branch.paseo.localhost:6767",
|
||||
hostname: "app--new-branch--paseo.localhost",
|
||||
localProxyUrl: "http://app--new-branch--paseo.localhost:6767",
|
||||
publicProxyUrl: null,
|
||||
proxyUrl: "http://app--new-branch--paseo.localhost:6767",
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ interface TestPaseoDaemonOptions {
|
||||
dictationFinalTimeoutMs?: number;
|
||||
auth?: PaseoDaemonConfig["auth"];
|
||||
pushNotificationSender?: PushNotificationSender;
|
||||
serviceProxy?: PaseoDaemonConfig["serviceProxy"];
|
||||
}
|
||||
|
||||
export interface TestPaseoDaemon {
|
||||
@@ -161,6 +162,7 @@ async function prepareTestDaemonConfig(
|
||||
appBaseUrl: "https://app.paseo.sh",
|
||||
auth: options.auth,
|
||||
pushNotificationSender: options.pushNotificationSender,
|
||||
serviceProxy: options.serviceProxy,
|
||||
openai: options.openai,
|
||||
speech: options.speech,
|
||||
voiceLlmProvider: options.voiceLlmProvider ?? null,
|
||||
|
||||
@@ -92,10 +92,10 @@ export function asWorkspaceGitService(stub: {
|
||||
return createStub<SessionOptions["workspaceGitService"]>(stub);
|
||||
}
|
||||
|
||||
export function asScriptRouteStore(stub: {
|
||||
[K in keyof SessionOptions["scriptRouteStore"]]?: unknown;
|
||||
}): SessionOptions["scriptRouteStore"] {
|
||||
return createStub<SessionOptions["scriptRouteStore"]>(stub);
|
||||
export function asServiceProxy(stub: {
|
||||
[K in keyof SessionOptions["serviceProxy"]]?: unknown;
|
||||
}): SessionOptions["serviceProxy"] {
|
||||
return createStub<SessionOptions["serviceProxy"]>(stub);
|
||||
}
|
||||
|
||||
export function asWorkspaceScriptRuntimeStore(stub: {
|
||||
|
||||
@@ -35,7 +35,7 @@ import { buildWorkspaceGitMetadataFromSnapshot } from "./workspace-git-metadata.
|
||||
import { PushTokenStore } from "./push/token-store.js";
|
||||
import { createPushNotificationSender, type PushNotificationSender } from "./push/notifications.js";
|
||||
import type { ScriptHealthState } from "./script-health-monitor.js";
|
||||
import type { ScriptRouteStore } from "./script-proxy.js";
|
||||
import type { ServiceProxySubsystem } from "./service-proxy.js";
|
||||
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||
import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js";
|
||||
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
|
||||
@@ -357,7 +357,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly mcpBaseUrl: string | null;
|
||||
private speech!: SpeechService | null;
|
||||
private terminalManager!: TerminalManager | null;
|
||||
private scriptRouteStore!: ScriptRouteStore | null;
|
||||
private serviceProxy!: ServiceProxySubsystem | null;
|
||||
private scriptRuntimeStore!: WorkspaceScriptRuntimeStore | null;
|
||||
private getDaemonTcpPort!: (() => number | null) | null;
|
||||
private getDaemonTcpHost!: (() => string | null) | null;
|
||||
@@ -405,7 +405,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
loopService?: LoopService,
|
||||
scheduleService?: ScheduleService,
|
||||
checkoutDiffManager?: CheckoutDiffManager,
|
||||
scriptRouteStore?: ScriptRouteStore | null,
|
||||
serviceProxy?: ServiceProxySubsystem | null,
|
||||
scriptRuntimeStore?: WorkspaceScriptRuntimeStore | null,
|
||||
onBranchChanged?: (
|
||||
workspaceId: string,
|
||||
@@ -465,7 +465,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
terminalManager,
|
||||
dictation,
|
||||
onLifecycleIntent,
|
||||
scriptRouteStore,
|
||||
serviceProxy,
|
||||
scriptRuntimeStore,
|
||||
onBranchChanged,
|
||||
getDaemonTcpPort,
|
||||
@@ -514,7 +514,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
terminalManager: TerminalManager | null | undefined;
|
||||
dictation: { finalTimeoutMs?: number } | undefined;
|
||||
onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | undefined;
|
||||
scriptRouteStore: ScriptRouteStore | null | undefined;
|
||||
serviceProxy: ServiceProxySubsystem | null | undefined;
|
||||
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null | undefined;
|
||||
onBranchChanged:
|
||||
| ((workspaceId: string, oldBranch: string | null, newBranch: string | null) => void)
|
||||
@@ -528,7 +528,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.terminalManager = params.terminalManager ?? null;
|
||||
this.dictation = params.dictation ?? null;
|
||||
this.onLifecycleIntent = params.onLifecycleIntent ?? null;
|
||||
this.scriptRouteStore = params.scriptRouteStore ?? null;
|
||||
this.serviceProxy = params.serviceProxy ?? null;
|
||||
this.scriptRuntimeStore = params.scriptRuntimeStore ?? null;
|
||||
this.onBranchChanged = params.onBranchChanged ?? null;
|
||||
this.getDaemonTcpPort = params.getDaemonTcpPort ?? null;
|
||||
@@ -884,7 +884,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
tts: () => this.speech?.resolveTts() ?? null,
|
||||
terminalManager: this.terminalManager,
|
||||
providerSnapshotManager: this.providerSnapshotManager,
|
||||
scriptRouteStore: this.scriptRouteStore ?? undefined,
|
||||
serviceProxy: this.serviceProxy ?? undefined,
|
||||
scriptRuntimeStore: this.scriptRuntimeStore ?? undefined,
|
||||
workspaceSetupSnapshots: this.workspaceSetupSnapshots,
|
||||
onBranchChanged: this.onBranchChanged ?? undefined,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||
import type { AgentSnapshotPayload, WorkspaceDescriptorPayload } from "./messages.js";
|
||||
import { WorkspaceDirectory } from "./workspace-directory.js";
|
||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
|
||||
const NOW = "2026-03-01T12:00:00.000Z";
|
||||
|
||||
class WorkspaceStatus {
|
||||
private readonly project: PersistedProjectRecord = {
|
||||
projectId: "project-1",
|
||||
rootPath: "/workspace/project",
|
||||
kind: "git",
|
||||
displayName: "project",
|
||||
customName: null,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
archivedAt: null,
|
||||
};
|
||||
|
||||
private readonly workspace: PersistedWorkspaceRecord = {
|
||||
workspaceId: "workspace-1",
|
||||
projectId: this.project.projectId,
|
||||
cwd: this.project.rootPath,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
archivedAt: null,
|
||||
};
|
||||
|
||||
private readonly agents: AgentSnapshotPayload[] = [];
|
||||
private readonly directory = new WorkspaceDirectory({
|
||||
logger: createTestLogger(),
|
||||
projectRegistry: { list: async () => [this.project] },
|
||||
workspaceRegistry: { list: async () => [this.workspace] },
|
||||
listAgentPayloads: async () => this.agents,
|
||||
isProviderVisibleToClient: () => true,
|
||||
buildWorkspaceDescriptor: async ({ workspace }) => ({
|
||||
id: workspace.workspaceId,
|
||||
projectId: workspace.projectId,
|
||||
projectDisplayName: "project",
|
||||
projectCustomName: null,
|
||||
projectRootPath: this.project.rootPath,
|
||||
workspaceDirectory: workspace.cwd,
|
||||
projectKind: "git",
|
||||
workspaceKind: workspace.kind,
|
||||
name: workspace.displayName,
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
gitRuntime: null,
|
||||
githubRuntime: null,
|
||||
}),
|
||||
});
|
||||
|
||||
hasRootAgent(input: AgentState): void {
|
||||
this.agents.push(createAgent({ ...input, cwd: this.workspace.cwd }));
|
||||
}
|
||||
|
||||
hasDelegatedAgent(input: AgentState): void {
|
||||
this.agents.push(
|
||||
createAgent({
|
||||
...input,
|
||||
cwd: this.workspace.cwd,
|
||||
labels: { [PARENT_AGENT_ID_LABEL]: "parent-agent" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async workspaceStatus(): Promise<WorkspaceDescriptorPayload["status"]> {
|
||||
const entries = await this.directory.listFetchEntries({
|
||||
type: "fetch_workspaces_request",
|
||||
requestId: "workspace-status",
|
||||
});
|
||||
return entries.entries[0]?.status ?? "done";
|
||||
}
|
||||
}
|
||||
|
||||
interface AgentState {
|
||||
id: string;
|
||||
status: AgentSnapshotPayload["status"];
|
||||
pendingPermissionCount?: number;
|
||||
requiresAttention?: boolean;
|
||||
attentionReason?: AgentSnapshotPayload["attentionReason"];
|
||||
}
|
||||
|
||||
function createAgent(input: AgentState & { cwd: string; labels?: Record<string, string> }) {
|
||||
const pendingPermissionCount = input.pendingPermissionCount ?? 0;
|
||||
return {
|
||||
id: input.id,
|
||||
provider: "codex",
|
||||
cwd: input.cwd,
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
effectiveThinkingOptionId: null,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
lastUserMessageAt: null,
|
||||
status: input.status,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: Array.from({ length: pendingPermissionCount }, (_, index) => ({
|
||||
id: `permission-${input.id}-${index}`,
|
||||
provider: "codex",
|
||||
name: "tool",
|
||||
kind: "tool" as const,
|
||||
})),
|
||||
persistence: null,
|
||||
runtimeInfo: {
|
||||
provider: "codex",
|
||||
sessionId: null,
|
||||
},
|
||||
title: null,
|
||||
labels: input.labels ?? {},
|
||||
requiresAttention: input.requiresAttention ?? false,
|
||||
attentionReason: input.attentionReason ?? null,
|
||||
attentionTimestamp: null,
|
||||
archivedAt: null,
|
||||
} satisfies AgentSnapshotPayload;
|
||||
}
|
||||
|
||||
describe("WorkspaceDirectory", () => {
|
||||
test("uses root agent activity, not delegated child activity, for workspace status", async () => {
|
||||
const workspace = new WorkspaceStatus();
|
||||
|
||||
workspace.hasRootAgent({ id: "root-agent", status: "running" });
|
||||
workspace.hasDelegatedAgent({
|
||||
id: "child-needs-input",
|
||||
status: "idle",
|
||||
pendingPermissionCount: 1,
|
||||
});
|
||||
workspace.hasDelegatedAgent({
|
||||
id: "child-error",
|
||||
status: "error",
|
||||
requiresAttention: true,
|
||||
attentionReason: "error",
|
||||
});
|
||||
|
||||
await expect(workspace.workspaceStatus()).resolves.toBe("running");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
deriveAgentStateBucket,
|
||||
getWorkspaceStateBucketPriority,
|
||||
} from "@getpaseo/protocol/agent-state-bucket";
|
||||
import { isDelegatedAgent } from "@getpaseo/protocol/agent-labels";
|
||||
import { SortablePager } from "./pagination/sortable-pager.js";
|
||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
|
||||
@@ -187,6 +188,9 @@ export class WorkspaceDirectory {
|
||||
if (!this.deps.isProviderVisibleToClient(agent.provider)) {
|
||||
continue;
|
||||
}
|
||||
if (isDelegatedAgent(agent)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const workspaceId = workspaceIdsByDirectory.get(normalizeWorkspaceId(agent.cwd));
|
||||
if (workspaceId === undefined) {
|
||||
|
||||
@@ -65,9 +65,9 @@ describe("buildWorkspaceServiceEnv", () => {
|
||||
).toEqual({
|
||||
HOST: "127.0.0.1",
|
||||
PASEO_PORT: "5173",
|
||||
PASEO_URL: "http://daemon.paseo.localhost:6767",
|
||||
PASEO_URL: "http://daemon--paseo.localhost:6767",
|
||||
PASEO_SERVICE_DAEMON_PORT: "5173",
|
||||
PASEO_SERVICE_DAEMON_URL: "http://daemon.paseo.localhost:6767",
|
||||
PASEO_SERVICE_DAEMON_URL: "http://daemon--paseo.localhost:6767",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,9 +84,9 @@ describe("buildWorkspaceServiceEnv", () => {
|
||||
).toEqual({
|
||||
HOST: "127.0.0.1",
|
||||
PASEO_PORT: "5173",
|
||||
PASEO_URL: "http://daemon.feature-x.paseo.localhost:6767",
|
||||
PASEO_URL: "http://daemon--feature-x--paseo.localhost:6767",
|
||||
PASEO_SERVICE_DAEMON_PORT: "5173",
|
||||
PASEO_SERVICE_DAEMON_URL: "http://daemon.feature-x.paseo.localhost:6767",
|
||||
PASEO_SERVICE_DAEMON_URL: "http://daemon--feature-x--paseo.localhost:6767",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,11 +137,32 @@ describe("buildWorkspaceServiceEnv", () => {
|
||||
).toEqual({
|
||||
HOST: "127.0.0.1",
|
||||
PASEO_PORT: "5173",
|
||||
PASEO_URL: "http://web.feature-x.paseo.localhost:6767",
|
||||
PASEO_URL: "http://web--feature-x--paseo.localhost:6767",
|
||||
PASEO_SERVICE_API_PORT: "4000",
|
||||
PASEO_SERVICE_API_URL: "http://api.feature-x.paseo.localhost:6767",
|
||||
PASEO_SERVICE_API_URL: "http://api--feature-x--paseo.localhost:6767",
|
||||
PASEO_SERVICE_WEB_PORT: "5173",
|
||||
PASEO_SERVICE_WEB_URL: "http://web.feature-x.paseo.localhost:6767",
|
||||
PASEO_SERVICE_WEB_URL: "http://web--feature-x--paseo.localhost:6767",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses public service URLs when a public base URL is configured", () => {
|
||||
expect(
|
||||
buildWorkspaceServiceEnv({
|
||||
scriptName: "web",
|
||||
projectSlug: "paseo",
|
||||
branchName: "feature-x",
|
||||
daemonPort: 6767,
|
||||
daemonListenHost: null,
|
||||
serviceProxyPublicBaseUrl: "https://services.example.com",
|
||||
peers: [
|
||||
{ scriptName: "api", port: 4000 },
|
||||
{ scriptName: "web", port: 5173 },
|
||||
],
|
||||
}),
|
||||
).toMatchObject({
|
||||
PASEO_URL: "https://web--feature-x--paseo.services.example.com",
|
||||
PASEO_SERVICE_API_URL: "https://api--feature-x--paseo.services.example.com",
|
||||
PASEO_SERVICE_WEB_URL: "https://web--feature-x--paseo.services.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildPublicScriptProxyUrl, buildScriptHostname } from "../utils/script-hostname.js";
|
||||
import { projectServiceProxyUrls } from "./service-proxy.js";
|
||||
|
||||
export interface WorkspaceServicePeer {
|
||||
scriptName: string;
|
||||
@@ -81,25 +81,13 @@ interface BuildServiceProxyUrlOptions {
|
||||
}
|
||||
|
||||
function buildServiceProxyUrl(options: BuildServiceProxyUrlOptions): string | null {
|
||||
if (options.serviceProxyPublicBaseUrl) {
|
||||
return buildPublicScriptProxyUrl({
|
||||
projectSlug: options.projectSlug,
|
||||
branchName: options.branchName,
|
||||
scriptName: options.scriptName,
|
||||
publicBaseUrl: options.serviceProxyPublicBaseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.daemonPort === null || options.daemonPort === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostname = buildScriptHostname({
|
||||
return projectServiceProxyUrls({
|
||||
projectSlug: options.projectSlug,
|
||||
branchName: options.branchName,
|
||||
scriptName: options.scriptName,
|
||||
});
|
||||
return `http://${hostname}:${options.daemonPort}`;
|
||||
daemonPort: options.daemonPort,
|
||||
publicBaseUrl: options.serviceProxyPublicBaseUrl,
|
||||
}).proxyUrl;
|
||||
}
|
||||
|
||||
function isLoopbackListenHost(host: string | null | undefined): boolean {
|
||||
|
||||
@@ -450,7 +450,7 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
|
||||
branchName: "feature-peer-env",
|
||||
scriptName,
|
||||
daemonPort: 6767,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
}),
|
||||
@@ -465,16 +465,24 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
|
||||
const apiEnv = readEnvFile(apiEnvPath);
|
||||
const webEnv = readEnvFile(webEnvPath);
|
||||
|
||||
expect(apiEnv.PASEO_SERVICE_API_URL).toBe("http://api.feature-peer-env.repo.localhost:6767");
|
||||
expect(apiEnv.PASEO_SERVICE_WEB_URL).toBe("http://web.feature-peer-env.repo.localhost:6767");
|
||||
expect(apiEnv.PASEO_SERVICE_API_URL).toBe(
|
||||
"http://api--feature-peer-env--repo.localhost:6767",
|
||||
);
|
||||
expect(apiEnv.PASEO_SERVICE_WEB_URL).toBe(
|
||||
"http://web--feature-peer-env--repo.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://api.feature-peer-env.repo.localhost:6767");
|
||||
expect(webEnv.PASEO_SERVICE_WEB_URL).toBe("http://web.feature-peer-env.repo.localhost:6767");
|
||||
expect(webEnv.PASEO_SERVICE_API_URL).toBe(
|
||||
"http://api--feature-peer-env--repo.localhost:6767",
|
||||
);
|
||||
expect(webEnv.PASEO_SERVICE_WEB_URL).toBe(
|
||||
"http://web--feature-peer-env--repo.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);
|
||||
@@ -487,14 +495,14 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
|
||||
expect(Number.isInteger(webPort)).toBe(true);
|
||||
expect(routeStore.listRoutes()).toEqual([
|
||||
{
|
||||
hostname: "api.feature-peer-env.repo.localhost",
|
||||
hostname: "api--feature-peer-env--repo.localhost",
|
||||
port: apiPort,
|
||||
workspaceId: repoDir,
|
||||
projectSlug: "repo",
|
||||
scriptName: "api",
|
||||
},
|
||||
{
|
||||
hostname: "web.feature-peer-env.repo.localhost",
|
||||
hostname: "web--feature-peer-env--repo.localhost",
|
||||
port: webPort,
|
||||
workspaceId: repoDir,
|
||||
projectSlug: "repo",
|
||||
|
||||
@@ -420,13 +420,13 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
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_URL).toBe(
|
||||
"http://api.feature-socket-service.repo.localhost:6767",
|
||||
"http://api--feature-socket-service--repo.localhost:6767",
|
||||
);
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_PORT).toBe(
|
||||
createTerminalCalls[0]?.env?.PASEO_PORT,
|
||||
);
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_URL).toBe(
|
||||
"http://api.feature-socket-service.repo.localhost:6767",
|
||||
"http://api--feature-socket-service--repo.localhost:6767",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -450,7 +450,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
String(plannedAppServerPort),
|
||||
);
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_APP_SERVER_URL).toBe(
|
||||
"http://app-server.feature-socket-service.repo.localhost:6767",
|
||||
"http://app-server--feature-socket-service--repo.localhost:6767",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -497,7 +497,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-socket-service",
|
||||
scriptName: "web",
|
||||
daemonPort: null,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
|
||||
});
|
||||
@@ -541,7 +541,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-script-exit",
|
||||
scriptName: "typecheck",
|
||||
daemonPort: null,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
});
|
||||
@@ -580,7 +580,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-script-rerun",
|
||||
scriptName: "typecheck",
|
||||
daemonPort: null,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
});
|
||||
@@ -600,7 +600,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-script-rerun",
|
||||
scriptName: "typecheck",
|
||||
daemonPort: null,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
});
|
||||
@@ -659,7 +659,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-script-existing-terminal",
|
||||
scriptName: "typecheck",
|
||||
daemonPort: null,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
});
|
||||
@@ -701,7 +701,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-script-terminal-exit",
|
||||
scriptName: "typecheck",
|
||||
daemonPort: null,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
});
|
||||
@@ -738,7 +738,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-script-duplicate",
|
||||
scriptName: "typecheck",
|
||||
daemonPort: null,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
});
|
||||
@@ -751,7 +751,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-script-duplicate",
|
||||
scriptName: "typecheck",
|
||||
daemonPort: null,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
}),
|
||||
@@ -786,7 +786,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-socket-service",
|
||||
scriptName: "api",
|
||||
daemonPort: 6767,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
|
||||
});
|
||||
@@ -794,7 +794,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
expect(result.scriptName).toBe("api");
|
||||
expect(routeStore.listRoutes()).toEqual([
|
||||
{
|
||||
hostname: "api.feature-socket-service.repo.localhost",
|
||||
hostname: "api--feature-socket-service--repo.localhost",
|
||||
port: expect.any(Number),
|
||||
workspaceId: repoDir,
|
||||
projectSlug: "repo",
|
||||
@@ -813,6 +813,60 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("spawns services with public aliases and public service URLs", async () => {
|
||||
commitPaseoScripts(
|
||||
{
|
||||
api: {
|
||||
type: "service",
|
||||
command: "npm run api",
|
||||
},
|
||||
"app-server": {
|
||||
type: "service",
|
||||
command: "npm run app",
|
||||
},
|
||||
},
|
||||
"add public service script config",
|
||||
);
|
||||
|
||||
const routeStore = new ScriptRouteStore();
|
||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||
const createTerminalCalls: CreateTerminalCall[] = [];
|
||||
const terminalRecords: StubTerminalRecord[] = [];
|
||||
|
||||
const result = await spawnWorkspaceScript({
|
||||
repoRoot: repoDir,
|
||||
workspaceId: repoDir,
|
||||
projectSlug: "repo",
|
||||
branchName: "feature-public-service",
|
||||
scriptName: "api",
|
||||
daemonPort: 6767,
|
||||
serviceProxyPublicBaseUrl: "https://services.example.com",
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
|
||||
});
|
||||
|
||||
expect(result.hostname).toBe("api--feature-public-service--repo.localhost");
|
||||
expect(
|
||||
routeStore.getRouteEntry("api--feature-public-service--repo.services.example.com"),
|
||||
).toMatchObject({
|
||||
hostname: "api--feature-public-service--repo.localhost",
|
||||
publicHostname: "api--feature-public-service--repo.services.example.com",
|
||||
publicBaseUrl: "https://services.example.com",
|
||||
workspaceId: repoDir,
|
||||
scriptName: "api",
|
||||
});
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe(
|
||||
"https://api--feature-public-service--repo.services.example.com",
|
||||
);
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_URL).toBe(
|
||||
"https://api--feature-public-service--repo.services.example.com",
|
||||
);
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_APP_SERVER_URL).toBe(
|
||||
"https://app-server--feature-public-service--repo.services.example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes a stopped service port on respawn and updates the route", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
@@ -852,7 +906,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-respawn-service",
|
||||
scriptName: "api",
|
||||
daemonPort: 6767,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
});
|
||||
@@ -864,7 +918,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-respawn-service",
|
||||
scriptName: "worker",
|
||||
daemonPort: 6767,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
});
|
||||
@@ -889,7 +943,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
lifecycle: "stopped",
|
||||
exitCode: 0,
|
||||
});
|
||||
expect(routeStore.getRouteEntry("api.feature-respawn-service.repo.localhost")).toBeNull();
|
||||
expect(routeStore.getRouteEntry("api--feature-respawn-service--repo.localhost")).toBeNull();
|
||||
|
||||
const secondResult = await spawnWorkspaceScript({
|
||||
repoRoot: repoDir,
|
||||
@@ -898,7 +952,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-respawn-service",
|
||||
scriptName: "api",
|
||||
daemonPort: 6767,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
});
|
||||
@@ -911,8 +965,8 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
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("api.feature-respawn-service.repo.localhost")).toMatchObject({
|
||||
hostname: "api.feature-respawn-service.repo.localhost",
|
||||
expect(routeStore.getRouteEntry("api--feature-respawn-service--repo.localhost")).toMatchObject({
|
||||
hostname: "api--feature-respawn-service--repo.localhost",
|
||||
port: secondPort,
|
||||
workspaceId: repoDir,
|
||||
projectSlug: "repo",
|
||||
@@ -955,20 +1009,20 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-before-rename",
|
||||
scriptName: "api",
|
||||
daemonPort: 6767,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
});
|
||||
|
||||
const updateRoutesForBranchChange = createBranchChangeRouteHandler({
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
onRoutesChanged: () => {},
|
||||
});
|
||||
updateRoutesForBranchChange(repoDir, "feature-before-rename", "feature-after-rename");
|
||||
|
||||
expect(routeStore.listRoutesForWorkspace(repoDir)).toEqual([
|
||||
expect.objectContaining({
|
||||
hostname: "api.feature-after-rename.repo.localhost",
|
||||
hostname: "api--feature-after-rename--repo.localhost",
|
||||
scriptName: "api",
|
||||
}),
|
||||
]);
|
||||
@@ -1020,7 +1074,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-collision-service",
|
||||
scriptName: "app-server",
|
||||
daemonPort: 6767,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager: createStubTerminalManager(createTerminalCalls),
|
||||
}),
|
||||
@@ -1029,7 +1083,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
expect(createTerminalCalls).toHaveLength(0);
|
||||
expect(routeStore.listRoutes()).toEqual([]);
|
||||
expect(
|
||||
routeStore.getRouteEntry("app-server.feature-collision-service.repo.localhost"),
|
||||
routeStore.getRouteEntry("app-server--feature-collision-service--repo.localhost"),
|
||||
).toBeNull();
|
||||
|
||||
writeFileSync(
|
||||
@@ -1055,7 +1109,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
branchName: "feature-collision-service",
|
||||
scriptName: "app-server",
|
||||
daemonPort: 6767,
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager: createStubTerminalManager(createTerminalCalls),
|
||||
});
|
||||
@@ -1108,7 +1162,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
scriptName: "web",
|
||||
daemonPort: 6767,
|
||||
daemonListenHost: "100.64.0.20",
|
||||
routeStore,
|
||||
serviceProxy: routeStore,
|
||||
runtimeStore,
|
||||
terminalManager: createStubTerminalManager(createTerminalCalls),
|
||||
});
|
||||
@@ -1116,7 +1170,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
expect(createTerminalCalls).toHaveLength(1);
|
||||
expect(createTerminalCalls[0]?.env?.HOST).toBe("0.0.0.0");
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe(
|
||||
"http://web.feature-remote-service.repo.localhost:6767",
|
||||
"http://web--feature-remote-service--repo.localhost:6767",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import type { Logger } from "pino";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type { TerminalSession } from "../terminal/terminal.js";
|
||||
import { buildPublicScriptHostname, buildScriptHostname } from "../utils/script-hostname.js";
|
||||
import {
|
||||
getScriptConfigs,
|
||||
getWorktreeTerminalSpecs,
|
||||
@@ -17,7 +16,7 @@ import {
|
||||
type WorktreeSetupCommandResult,
|
||||
type WorktreeRuntimeEnv,
|
||||
} from "../utils/worktree.js";
|
||||
import { findFreePort, type ScriptRouteStore } from "./script-proxy.js";
|
||||
import { findFreePort, type ServiceProxySubsystem } from "./service-proxy.js";
|
||||
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||
import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js";
|
||||
import {
|
||||
@@ -700,7 +699,7 @@ interface SpawnWorkspaceScriptOptions {
|
||||
daemonPort?: number | null;
|
||||
daemonListenHost?: string | null;
|
||||
serviceProxyPublicBaseUrl?: string | null;
|
||||
routeStore: ScriptRouteStore;
|
||||
serviceProxy: ServiceProxySubsystem;
|
||||
runtimeStore: WorkspaceScriptRuntimeStore;
|
||||
terminalManager: TerminalManager;
|
||||
logger?: Logger;
|
||||
@@ -724,7 +723,7 @@ async function setupServiceScriptRoute(params: {
|
||||
daemonListenHost: string | null | undefined;
|
||||
serviceProxyPublicBaseUrl: string | null | undefined;
|
||||
existingRuntimeEntry: ReturnType<WorkspaceScriptRuntimeStore["get"]>;
|
||||
routeStore: ScriptRouteStore;
|
||||
serviceProxy: ServiceProxySubsystem;
|
||||
}): Promise<ServiceScriptSetupResult> {
|
||||
const {
|
||||
scriptConfigs,
|
||||
@@ -737,17 +736,8 @@ async function setupServiceScriptRoute(params: {
|
||||
daemonListenHost,
|
||||
serviceProxyPublicBaseUrl,
|
||||
existingRuntimeEntry,
|
||||
routeStore,
|
||||
serviceProxy,
|
||||
} = params;
|
||||
const hostname = buildScriptHostname({ projectSlug, branchName, scriptName });
|
||||
const publicHostname = serviceProxyPublicBaseUrl
|
||||
? buildPublicScriptHostname({
|
||||
projectSlug,
|
||||
branchName,
|
||||
scriptName,
|
||||
publicBaseUrl: serviceProxyPublicBaseUrl,
|
||||
})
|
||||
: null;
|
||||
|
||||
const serviceDeclarations: Array<{ scriptName: string; port?: number }> = [];
|
||||
for (const [configuredScriptName, scriptConfig] of scriptConfigs) {
|
||||
@@ -794,16 +784,15 @@ async function setupServiceScriptRoute(params: {
|
||||
peers,
|
||||
});
|
||||
|
||||
routeStore.registerRoute({
|
||||
hostname,
|
||||
publicHostname,
|
||||
publicBaseUrl: serviceProxyPublicBaseUrl ?? null,
|
||||
const registeredRoute = serviceProxy.registerWorkspaceService({
|
||||
port,
|
||||
workspaceId,
|
||||
projectSlug,
|
||||
branchName,
|
||||
scriptName,
|
||||
publicBaseUrl: serviceProxyPublicBaseUrl ?? null,
|
||||
});
|
||||
return { hostname, port, env };
|
||||
return { hostname: registeredRoute.hostname, port, env };
|
||||
}
|
||||
|
||||
async function acquireWorkspaceScriptTerminal(params: {
|
||||
@@ -843,7 +832,7 @@ export async function spawnWorkspaceScript(
|
||||
daemonPort,
|
||||
daemonListenHost,
|
||||
serviceProxyPublicBaseUrl,
|
||||
routeStore,
|
||||
serviceProxy,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
logger,
|
||||
@@ -886,7 +875,7 @@ export async function spawnWorkspaceScript(
|
||||
daemonListenHost,
|
||||
serviceProxyPublicBaseUrl,
|
||||
existingRuntimeEntry,
|
||||
routeStore,
|
||||
serviceProxy,
|
||||
});
|
||||
hostname = serviceSetup.hostname;
|
||||
port = serviceSetup.port;
|
||||
@@ -923,7 +912,7 @@ export async function spawnWorkspaceScript(
|
||||
disposeLifecycleListeners = null;
|
||||
|
||||
if (input.removeRoute && hostname) {
|
||||
routeStore.removeRouteForWorkspaceScript({ workspaceId, scriptName });
|
||||
serviceProxy.removeWorkspaceService({ workspaceId, scriptName });
|
||||
}
|
||||
runtimeStore.set({
|
||||
workspaceId,
|
||||
@@ -991,7 +980,7 @@ export async function spawnWorkspaceScript(
|
||||
} catch (error) {
|
||||
disposeLifecycleListeners?.();
|
||||
if (routeRegistered && hostname) {
|
||||
routeStore.removeRoute(hostname);
|
||||
serviceProxy.removeServiceRoutesByHostnames([hostname]);
|
||||
}
|
||||
if (runtimeRegistered) {
|
||||
runtimeStore.remove({ workspaceId, scriptName });
|
||||
@@ -1014,12 +1003,12 @@ export async function spawnWorkspaceScript(
|
||||
|
||||
export function teardownWorktreeScripts(options: {
|
||||
hostnames: string[];
|
||||
routeStore: ScriptRouteStore;
|
||||
serviceProxy: Pick<ServiceProxySubsystem, "removeServiceRoutesByHostnames">;
|
||||
logger: Logger;
|
||||
}): void {
|
||||
const { hostnames, routeStore, logger } = options;
|
||||
const { hostnames, serviceProxy, logger } = options;
|
||||
serviceProxy.removeServiceRoutesByHostnames(hostnames);
|
||||
for (const hostname of hostnames) {
|
||||
routeStore.removeRoute(hostname);
|
||||
logger.info({ hostname }, "Removed script proxy route");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ function createWorkflowForRequestTest(options: {
|
||||
sessionLogger: createLogger(),
|
||||
terminalManager: null,
|
||||
archiveWorkspaceRecord: async () => {},
|
||||
scriptRouteStore: null,
|
||||
serviceProxy: null,
|
||||
scriptRuntimeStore: null,
|
||||
getDaemonTcpPort: null,
|
||||
getDaemonTcpHost: null,
|
||||
@@ -416,7 +416,7 @@ describe("create-agent worktree setup boundary", () => {
|
||||
sessionLogger: createLogger(),
|
||||
terminalManager: null,
|
||||
archiveWorkspaceRecord: async () => {},
|
||||
scriptRouteStore: null,
|
||||
serviceProxy: null,
|
||||
scriptRuntimeStore: null,
|
||||
getDaemonTcpPort: null,
|
||||
getDaemonTcpHost: null,
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
getWorktreeSetupProgressResults,
|
||||
} from "./worktree-bootstrap.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type { ScriptRouteStore } from "./script-proxy.js";
|
||||
import type { ServiceProxySubsystem } from "./service-proxy.js";
|
||||
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||
import type { GitHubService } from "../services/github-service.js";
|
||||
import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js";
|
||||
@@ -103,7 +103,7 @@ interface CreatePaseoWorktreeInBackgroundDependencies {
|
||||
sessionLogger: Logger;
|
||||
terminalManager: TerminalManager | null;
|
||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||
scriptRouteStore: ScriptRouteStore | null;
|
||||
serviceProxy: ServiceProxySubsystem | null;
|
||||
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
|
||||
getDaemonTcpPort: (() => number | null) | null;
|
||||
getDaemonTcpHost: (() => string | null) | null;
|
||||
|
||||
@@ -13,7 +13,7 @@ describe("buildScriptHostname", () => {
|
||||
branchName: null,
|
||||
scriptName: "web",
|
||||
}),
|
||||
).toBe("web.paseo.localhost");
|
||||
).toBe("web--paseo.localhost");
|
||||
});
|
||||
|
||||
it("omits the branch label for main and master", () => {
|
||||
@@ -23,14 +23,14 @@ describe("buildScriptHostname", () => {
|
||||
branchName: "main",
|
||||
scriptName: "web",
|
||||
}),
|
||||
).toBe("web.paseo.localhost");
|
||||
).toBe("web--paseo.localhost");
|
||||
expect(
|
||||
buildScriptHostname({
|
||||
projectSlug: "paseo",
|
||||
branchName: "master",
|
||||
scriptName: "web",
|
||||
}),
|
||||
).toBe("web.paseo.localhost");
|
||||
).toBe("web--paseo.localhost");
|
||||
});
|
||||
|
||||
it("builds non-default branch hostnames with script, branch, and project labels", () => {
|
||||
@@ -40,7 +40,7 @@ describe("buildScriptHostname", () => {
|
||||
branchName: "feature-auth",
|
||||
scriptName: "web",
|
||||
}),
|
||||
).toBe("web.feature-auth.paseo.localhost");
|
||||
).toBe("web--feature-auth--paseo.localhost");
|
||||
});
|
||||
|
||||
it("slugifies script, default branch project, and non-default branch labels", () => {
|
||||
@@ -50,7 +50,7 @@ describe("buildScriptHostname", () => {
|
||||
branchName: "Feature/Auth Flow",
|
||||
scriptName: "Web/API @ Dev",
|
||||
}),
|
||||
).toBe("web-api-dev.feature-auth-flow.paseo-app.localhost");
|
||||
).toBe("web-api-dev--feature-auth-flow--paseo-app.localhost");
|
||||
});
|
||||
|
||||
it("accepts already slugified labels because slugify is idempotent", () => {
|
||||
@@ -60,7 +60,7 @@ describe("buildScriptHostname", () => {
|
||||
branchName: "feature-auth-flow",
|
||||
scriptName: "web-api-dev",
|
||||
}),
|
||||
).toBe("web-api-dev.feature-auth-flow.paseo-app.localhost");
|
||||
).toBe("web-api-dev--feature-auth-flow--paseo-app.localhost");
|
||||
});
|
||||
|
||||
it("uses untitled as the hostname-label fallback when labels collapse to empty", () => {
|
||||
@@ -70,7 +70,7 @@ describe("buildScriptHostname", () => {
|
||||
branchName: "***",
|
||||
scriptName: "---",
|
||||
}),
|
||||
).toBe("untitled.untitled.untitled.localhost");
|
||||
).toBe("untitled--untitled--untitled.localhost");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { slugify } from "./worktree.js";
|
||||
import {
|
||||
buildLocalServiceHostname,
|
||||
buildPublicServiceHostname,
|
||||
buildPublicServiceProxyUrl,
|
||||
} from "../server/service-proxy.js";
|
||||
|
||||
const MAX_DNS_LABEL_LENGTH = 63;
|
||||
// Compatibility boundary for older tests/imports; new service proxy code owns hostname rules.
|
||||
|
||||
interface BuildScriptHostnameOptions {
|
||||
projectSlug: string;
|
||||
@@ -12,64 +16,21 @@ interface BuildPublicScriptHostnameOptions extends BuildScriptHostnameOptions {
|
||||
publicBaseUrl: string;
|
||||
}
|
||||
|
||||
function toHostnameLabel(value: string): string {
|
||||
return slugify(value) || "untitled";
|
||||
}
|
||||
|
||||
function capDnsLabel(label: string): string {
|
||||
if (label.length <= MAX_DNS_LABEL_LENGTH) {
|
||||
return label;
|
||||
}
|
||||
return label.slice(0, MAX_DNS_LABEL_LENGTH).replace(/-+$/g, "") || "untitled";
|
||||
}
|
||||
|
||||
function toPublicServiceLabel({
|
||||
projectSlug,
|
||||
branchName,
|
||||
scriptName,
|
||||
}: BuildScriptHostnameOptions): string {
|
||||
const labels = [toHostnameLabel(scriptName)];
|
||||
const isDefaultBranch = branchName === null || branchName === "main" || branchName === "master";
|
||||
if (!isDefaultBranch) {
|
||||
labels.push(toHostnameLabel(branchName));
|
||||
}
|
||||
labels.push(toHostnameLabel(projectSlug));
|
||||
|
||||
// Public URLs must keep script/branch/project in one label. The local
|
||||
// script.branch.project.localhost shape would require multi-level wildcard
|
||||
// DNS/certificates for arbitrary branch names, while a single label works with
|
||||
// normal `*.base-domain` DNS and wildcard TLS. Cap it to the DNS 63-octet
|
||||
// label limit so long branch/project/script names still produce resolvable URLs.
|
||||
return capDnsLabel(labels.join("--"));
|
||||
}
|
||||
|
||||
export function buildScriptHostname({
|
||||
projectSlug,
|
||||
branchName,
|
||||
scriptName,
|
||||
}: BuildScriptHostnameOptions): string {
|
||||
const serviceHostnameLabel = toHostnameLabel(scriptName);
|
||||
const projectHostnameLabel = toHostnameLabel(projectSlug);
|
||||
const isDefaultBranch = branchName === null || branchName === "main" || branchName === "master";
|
||||
|
||||
if (isDefaultBranch) {
|
||||
return `${serviceHostnameLabel}.${projectHostnameLabel}.localhost`;
|
||||
}
|
||||
|
||||
return `${serviceHostnameLabel}.${toHostnameLabel(branchName)}.${projectHostnameLabel}.localhost`;
|
||||
return buildLocalServiceHostname({ projectSlug, branchName, scriptName });
|
||||
}
|
||||
|
||||
export function buildPublicScriptHostname({
|
||||
publicBaseUrl,
|
||||
...script
|
||||
}: BuildPublicScriptHostnameOptions): string {
|
||||
const base = new URL(publicBaseUrl);
|
||||
return `${toPublicServiceLabel(script)}.${base.hostname}`;
|
||||
return buildPublicServiceHostname({ publicBaseUrl, ...script });
|
||||
}
|
||||
|
||||
export function buildPublicScriptProxyUrl(options: BuildPublicScriptHostnameOptions): string {
|
||||
const base = new URL(options.publicBaseUrl);
|
||||
const hostname = buildPublicScriptHostname(options);
|
||||
const port = base.port ? `:${base.port}` : "";
|
||||
return `${base.protocol}//${hostname}${port}`;
|
||||
return buildPublicServiceProxyUrl(options);
|
||||
}
|
||||
|
||||
@@ -97,12 +97,15 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "Compatibility shim. false suppresses optional service proxy listen/public layers only; localhost proxying remains enabled.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"listen": {
|
||||
"description": "Optional service-only listener address. Presence enables the standalone service proxy listener.",
|
||||
"type": "string"
|
||||
},
|
||||
"publicBaseUrl": {
|
||||
"description": "Optional public base URL for service host aliases.",
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
}
|
||||
|
||||
@@ -110,10 +110,10 @@ Omit `port` to let Paseo auto-assign one. Bind your process to `$PASEO_PORT` rat
|
||||
Every service is reachable through the daemon at a deterministic hostname:
|
||||
|
||||
```
|
||||
http://<script>.<branch>.<project>.localhost:<daemon-port>
|
||||
http://<script>--<branch>--<project>.localhost:<daemon-port>
|
||||
|
||||
# on the default branch, the branch label is dropped:
|
||||
http://<script>.<project>.localhost:<daemon-port>
|
||||
http://<script>--<project>.localhost:<daemon-port>
|
||||
```
|
||||
|
||||
`*.localhost` resolves to `127.0.0.1` on modern systems, so these URLs work out of the box. The proxy supports WebSocket upgrades.
|
||||
@@ -124,11 +124,11 @@ Services launched from the same workspace see each other's ports and proxy URLs.
|
||||
|
||||
```
|
||||
PASEO_PORT=3000 # this service's port
|
||||
PASEO_URL=http://web.my-app.localhost:6767 # this service's proxy URL
|
||||
PASEO_URL=http://web--my-app.localhost:6767 # this service's proxy URL
|
||||
PASEO_SERVICE_API_PORT=51732
|
||||
PASEO_SERVICE_API_URL=http://api.my-app.localhost:6767
|
||||
PASEO_SERVICE_API_URL=http://api--my-app.localhost:6767
|
||||
PASEO_SERVICE_WEB_PORT=3000
|
||||
PASEO_SERVICE_WEB_URL=http://web.my-app.localhost:6767
|
||||
PASEO_SERVICE_WEB_URL=http://web--my-app.localhost:6767
|
||||
```
|
||||
|
||||
Script names are upper-cased and non-alphanumerics become `_`. Point your frontend at `$PASEO_SERVICE_API_URL` instead of hard-coding a port.
|
||||
|
||||
Reference in New Issue
Block a user