Distinguish services from scripts with type system and runtime tracking

This commit is contained in:
Mohamed Boudra
2026-04-09 23:45:00 +07:00
parent c7399084a4
commit b1b85833bb
27 changed files with 1265 additions and 493 deletions
@@ -355,12 +355,43 @@ function WorkspaceHoverCardContent({
<View style={styles.sectionList} testID="hover-card-script-list">
{workspace.scripts.map((script) => {
const isRunning = script.lifecycle === "running";
const isLinkable = isRunning && !!script.url;
const isService = (script.type ?? "service") === "service";
const isLinkable = isService && isRunning && !!script.url;
const exitCode = script.exitCode ?? null;
let dotColor: string;
if (isService) {
dotColor = isRunning
? getScriptHealthColor({ health: script.health, theme })
: theme.colors.foregroundMuted;
} else if (isRunning) {
dotColor = theme.colors.palette.green[500];
} else if (exitCode === 0) {
dotColor = theme.colors.palette.green[500];
} else if (exitCode !== null) {
dotColor = theme.colors.palette.red[500];
} else {
dotColor = theme.colors.foregroundMuted;
}
let accessibilityStatus: string;
if (isService) {
accessibilityStatus = isRunning ? getScriptHealthLabel(script.health) : "Stopped";
} else if (isRunning) {
accessibilityStatus = "Running";
} else if (exitCode === 0) {
accessibilityStatus = "Completed";
} else if (exitCode !== null) {
accessibilityStatus = `Failed (exit ${exitCode})`;
} else {
accessibilityStatus = "Stopped";
}
return (
<Pressable
key={script.hostname}
accessibilityRole={isLinkable ? "link" : undefined}
accessibilityLabel={`${script.scriptName} script — ${isRunning ? getScriptHealthLabel(script.health) : "Stopped"}`}
accessibilityLabel={`${script.scriptName} script — ${accessibilityStatus}`}
testID={`hover-card-script-${script.scriptName}`}
style={({ hovered }) => [
styles.listRow,
@@ -375,11 +406,7 @@ function WorkspaceHoverCardContent({
testID={`hover-card-script-health-${script.scriptName}`}
style={[
styles.statusDot,
{
backgroundColor: isRunning
? getScriptHealthColor({ health: script.health, theme })
: theme.colors.foregroundMuted,
},
{ backgroundColor: dotColor },
]}
/>
<Text
@@ -395,15 +422,19 @@ function WorkspaceHoverCardContent({
>
{script.scriptName}
</Text>
{isRunning && script.url ? (
{isService && isRunning && script.url ? (
<Text style={styles.listRowSecondary} numberOfLines={1}>
{script.url.replace(/^https?:\/\//, "")}
</Text>
) : !isService && !isRunning && exitCode !== null && exitCode !== 0 ? (
<Text style={styles.listRowSecondary} numberOfLines={1}>
exit {exitCode}
</Text>
) : (
<View style={styles.listRowSpacer} />
)}
{isRunning ? (
script.url && hovered ? (
isLinkable && hovered ? (
<View
style={[
styles.externalLinkOverlay,
@@ -25,11 +25,13 @@ function workspace(input: {
const runningScript: WorkspaceScriptPayload = {
scriptName: "web",
type: "service",
hostname: "main.web.localhost",
port: 3000,
url: "http://main.web.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
};
describe("patchWorkspaceScripts", () => {
@@ -47,20 +47,24 @@ function workspace(
const runningScript: WorkspaceScriptPayload = {
scriptName: "web",
type: "service",
hostname: "main.web.localhost",
port: 3000,
url: "http://main.web.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
};
const stoppedScript: WorkspaceScriptPayload = {
scriptName: "api",
type: "service",
hostname: "main.api.localhost",
port: 3001,
url: "http://main.api.localhost:6767",
lifecycle: "stopped",
health: null,
exitCode: null,
};
describe("applyStoredOrdering", () => {
@@ -104,7 +104,24 @@ export function WorkspaceScriptsButton({
<View style={styles.scriptList}>
{scripts.map((script) => {
const isRunning = script.lifecycle === "running";
const isLinkable = isRunning && !!script.url;
const isService = (script.type ?? "service") === "service";
const isLinkable = isService && isRunning && !!script.url;
const exitCode = script.exitCode ?? null;
let dotColor: string;
if (isService) {
dotColor = isRunning
? getScriptHealthColor(script.health, theme)
: theme.colors.foregroundMuted;
} else if (isRunning) {
dotColor = theme.colors.palette.green[500];
} else if (exitCode === 0) {
dotColor = theme.colors.palette.green[500];
} else if (exitCode !== null) {
dotColor = theme.colors.palette.red[500];
} else {
dotColor = theme.colors.foregroundMuted;
}
return (
<Pressable
@@ -124,11 +141,7 @@ export function WorkspaceScriptsButton({
<View
style={[
styles.statusDot,
{
backgroundColor: isRunning
? getScriptHealthColor(script.health, theme)
: theme.colors.foregroundMuted,
},
{ backgroundColor: dotColor },
]}
/>
<Text
@@ -144,15 +157,19 @@ export function WorkspaceScriptsButton({
>
{script.scriptName}
</Text>
{isRunning && script.url ? (
{isService && isRunning && script.url ? (
<Text style={styles.scriptUrl} numberOfLines={1}>
{script.url.replace(/^https?:\/\//, "")}
</Text>
) : !isService && !isRunning && exitCode !== null && exitCode !== 0 ? (
<Text style={styles.scriptUrl} numberOfLines={1}>
exit {exitCode}
</Text>
) : (
<View style={styles.spacer} />
)}
{isRunning ? (
script.url && hovered ? (
isLinkable && hovered ? (
<View
style={[
styles.externalLinkOverlay,
@@ -38,11 +38,13 @@ describe("normalizeWorkspaceDescriptor", () => {
const scripts = [
{
scriptName: "web",
type: "service" as const,
hostname: "main.web.localhost",
port: 3000,
url: "http://main.web.localhost:6767",
lifecycle: "running" as const,
health: "healthy" as const,
exitCode: null,
},
];
const workspace = normalizeWorkspaceDescriptor({
@@ -64,11 +66,13 @@ describe("normalizeWorkspaceDescriptor", () => {
expect(workspace.scripts).toEqual([
{
scriptName: "web",
type: "service",
hostname: "main.web.localhost",
port: 3000,
url: "http://main.web.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
},
]);
expect(workspace.scripts).not.toBe(scripts);
@@ -111,11 +115,13 @@ describe("mergeWorkspaces", () => {
scripts: [
{
scriptName: "web",
type: "service",
hostname: "main.web.localhost",
port: 3000,
url: "http://main.web.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
},
],
}),
@@ -124,11 +130,13 @@ describe("mergeWorkspaces", () => {
expect(store.getSession("test-server")?.workspaces.get("/repo/main")?.scripts).toEqual([
{
scriptName: "web",
type: "service",
hostname: "main.web.localhost",
port: 3000,
url: "http://main.web.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
},
]);
});
@@ -48,13 +48,11 @@ import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import { expandUserPath } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
import type { ScriptRouteStore } from "../script-proxy.js";
export interface AgentManagementMcpOptions {
agentManager: AgentManager;
agentStorage: AgentSnapshotStore;
terminalManager?: TerminalManager | null;
scriptRouteStore?: ScriptRouteStore;
getDaemonTcpPort?: () => number | null;
paseoHome?: string;
logger: Logger;
@@ -350,8 +348,6 @@ export async function createAgentManagementMcpServer(
agentId: snapshot.id,
item,
}),
scriptRouteStore: options.scriptRouteStore,
daemonPort: options.getDaemonTcpPort?.() ?? null,
logger: childLogger,
});
}
@@ -28,13 +28,11 @@ import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
import type { ScriptRouteStore } from "../script-proxy.js";
export interface AgentMcpServerOptions {
agentManager: AgentManager;
agentStorage: AgentSnapshotStore;
terminalManager?: TerminalManager | null;
scriptRouteStore?: ScriptRouteStore;
getDaemonTcpPort?: () => number | null;
paseoHome?: string;
/**
@@ -519,8 +517,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
agentId: snapshot.id,
item,
}),
scriptRouteStore: options.scriptRouteStore,
daemonPort: options.getDaemonTcpPort?.() ?? null,
logger: childLogger,
});
}
+8 -12
View File
@@ -125,6 +125,7 @@ import {
} from "./script-proxy.js";
import { ScriptHealthMonitor } from "./script-health-monitor.js";
import { createScriptStatusEmitter } from "./script-status-projection.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import {
createVoiceMcpSocketBridgeManager,
type VoiceMcpSocketBridgeManager,
@@ -209,6 +210,7 @@ export interface PaseoDaemon {
agentStorage: AgentSnapshotStore;
terminalManager: TerminalManager;
scriptRouteStore: ScriptRouteStore;
scriptRuntimeStore: WorkspaceScriptRuntimeStore;
start(): Promise<void>;
stop(): Promise<void>;
getListenTarget(): ListenTarget | null;
@@ -240,6 +242,7 @@ export async function createPaseoDaemon(
let boundListenTarget: ListenTarget | null = null;
const scriptRouteStore = new ScriptRouteStore();
const scriptRuntimeStore = new WorkspaceScriptRuntimeStore();
let wsServer: VoiceAssistantWebSocketServer | null = null;
const scriptHealthMonitor = new ScriptHealthMonitor({
routeStore: scriptRouteStore,
@@ -249,20 +252,14 @@ export async function createPaseoDaemon(
emit: (message) => session.emitServerMessage(message),
})) ?? [],
routeStore: scriptRouteStore,
runtimeStore: scriptRuntimeStore,
daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
}),
});
const handleBranchChange = createBranchChangeRouteHandler({
routeStore: scriptRouteStore,
emitScriptStatusUpdate: (workspaceId, scripts) => {
const message = {
type: "script_status_update" as const,
payload: { workspaceId, scripts },
};
const activeSessions = wsServer?.listActiveSessions() ?? [];
for (const session of activeSessions) {
session.emitServerMessage(message);
}
onRoutesChanged: (workspaceId) => {
scriptHealthMonitor.invalidateWorkspace(workspaceId);
},
logger,
});
@@ -492,7 +489,6 @@ export async function createPaseoDaemon(
agentManager,
agentStorage,
terminalManager,
scriptRouteStore,
getDaemonTcpPort: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
paseoHome: config.paseoHome,
@@ -521,7 +517,6 @@ export async function createPaseoDaemon(
agentManager,
agentStorage,
terminalManager,
scriptRouteStore,
getDaemonTcpPort: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
paseoHome: config.paseoHome,
@@ -644,7 +639,6 @@ export async function createPaseoDaemon(
agentManager,
agentStorage,
terminalManager,
scriptRouteStore,
getDaemonTcpPort: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
paseoHome: config.paseoHome,
@@ -711,6 +705,7 @@ export async function createPaseoDaemon(
scheduleService,
checkoutDiffManager,
scriptRouteStore,
scriptRuntimeStore,
handleBranchChange,
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
(hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
@@ -841,6 +836,7 @@ export async function createPaseoDaemon(
agentStorage,
terminalManager,
scriptRouteStore,
scriptRuntimeStore,
start,
stop,
getListenTarget: () => boundListenTarget,
@@ -1,4 +1,8 @@
import { execSync } from "node:child_process";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { scheduler } from "node:timers/promises";
import { afterEach, describe, expect, it, vi } from "vitest";
import { findFreePort, ScriptRouteStore } from "./script-proxy.js";
@@ -6,12 +10,89 @@ import {
ScriptHealthMonitor,
type ScriptHealthEntry,
} from "./script-health-monitor.js";
import { spawnWorktreeScripts } from "./worktree-bootstrap.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
type TcpServerHandle = {
port: number;
server: net.Server;
};
function createWorkspaceRepo(options?: {
branchName?: string;
paseoConfig?: Record<string, unknown>;
}): { tempDir: string; repoDir: string; cleanup: () => void } {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-health-monitor-")));
const repoDir = path.join(tempDir, "repo");
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
if (options?.paseoConfig) {
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
}
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
return {
tempDir,
repoDir,
cleanup: () => {
rmSync(tempDir, { recursive: true, force: true });
},
};
}
function createStubTerminalManager(
createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record<string, string> }>,
) {
return {
async getTerminals() {
return [];
},
async createTerminal(options: {
cwd: string;
name?: string;
env?: Record<string, string>;
}) {
createTerminalCalls.push(options);
return {
id: `term-${options.name ?? "terminal"}`,
name: options.name ?? "Terminal",
cwd: options.cwd,
send: () => {},
subscribe: () => () => {},
onExit: () => () => {},
getState: () => ({
rows: 1,
cols: 1,
grid: [[{ char: "$" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
}),
kill: () => {},
onTitleChange: () => () => {},
getSize: () => ({ rows: 1, cols: 1 }),
getTitle: () => undefined,
getExitInfo: () => null,
};
},
registerCwdEnv() {},
getTerminal() {
return undefined;
},
killTerminal() {},
listDirectories() {
return [];
},
killAll() {},
subscribeTerminalsChanged() {
return () => {};
},
};
}
async function startTcpServer(): Promise<TcpServerHandle> {
const server = net.createServer((socket) => {
socket.end();
@@ -68,13 +149,23 @@ describe("ScriptHealthMonitor", () => {
servers.clear();
});
it("marks a healthy port as running after successful TCP connect", async () => {
it("starts new service routes in pending and transitions to healthy after the grace period", async () => {
vi.useFakeTimers();
const healthy = await startTcpServer();
servers.add(healthy.server);
const routeStore = new ScriptRouteStore();
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({
routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
graceMs: 5_000,
});
monitor.start();
routeStore.registerRoute({
hostname: "api.localhost",
port: healthy.port,
@@ -82,19 +173,16 @@ describe("ScriptHealthMonitor", () => {
scriptName: "api",
});
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({
routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
graceMs: 0,
});
expect(monitor.getHealthForHostname("api.localhost")).toBe("pending");
await advancePoll(4_000);
expect(monitor.getHealthForHostname("api.localhost")).toBe("pending");
expect(onChange).not.toHaveBeenCalled();
monitor.start();
await advancePoll(1_000);
monitor.stop();
expect(monitor.getHealthForHostname("api.localhost")).toBe("healthy");
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("workspace-a", [
{
@@ -106,7 +194,7 @@ describe("ScriptHealthMonitor", () => {
]);
});
it("marks an unreachable port as stopped after consecutive failures", async () => {
it("transitions pending services to unhealthy after the grace period and required failures", async () => {
vi.useFakeTimers();
const deadPort = await findFreePort();
@@ -124,17 +212,21 @@ describe("ScriptHealthMonitor", () => {
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
graceMs: 0,
graceMs: 2_000,
failuresBeforeStopped: 2,
});
expect(monitor.getHealthForHostname("api.localhost")).toBe("pending");
monitor.start();
await advancePoll(1_000);
await advancePoll(2_000);
expect(monitor.getHealthForHostname("api.localhost")).toBe("pending");
expect(onChange).not.toHaveBeenCalled();
await advancePoll(1_000);
monitor.stop();
expect(monitor.getHealthForHostname("api.localhost")).toBe("unhealthy");
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("workspace-a", [
{
@@ -176,48 +268,7 @@ describe("ScriptHealthMonitor", () => {
expect(onChange).toHaveBeenCalledTimes(1);
});
it("respects startup grace period — does not probe newly registered routes for 5 seconds", async () => {
vi.useFakeTimers();
const healthy = await startTcpServer();
servers.add(healthy.server);
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "api.localhost",
port: healthy.port,
workspaceId: "workspace-a",
scriptName: "api",
});
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({
routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
graceMs: 5_000,
});
monitor.start();
await advancePoll(4_000);
expect(onChange).not.toHaveBeenCalled();
await advancePoll(1_000);
monitor.stop();
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("workspace-a", [
{
scriptName: "api",
hostname: "api.localhost",
port: healthy.port,
health: "healthy",
},
]);
});
it("requires 2 consecutive failures before marking stopped (debounce)", async () => {
it("requires 2 consecutive failures before marking a previously healthy service unhealthy", async () => {
vi.useFakeTimers();
const healthy = await startTcpServer();
@@ -249,11 +300,13 @@ describe("ScriptHealthMonitor", () => {
servers.delete(healthy.server);
await advancePoll(1_000);
expect(monitor.getHealthForHostname("api.localhost")).toBe("healthy");
expect(onChange).toHaveBeenCalledTimes(1);
await advancePoll(1_000);
monitor.stop();
expect(monitor.getHealthForHostname("api.localhost")).toBe("unhealthy");
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenLastCalledWith("workspace-a", [
{
@@ -265,7 +318,7 @@ describe("ScriptHealthMonitor", () => {
]);
});
it("stops probing routes that are removed from the store", async () => {
it("stops polling removed service routes and clears their health state", async () => {
vi.useFakeTimers();
const healthy = await startTcpServer();
@@ -291,6 +344,7 @@ describe("ScriptHealthMonitor", () => {
monitor.start();
await advancePoll(1_000);
expect(monitor.getHealthForHostname("api.localhost")).toBe("healthy");
expect(onChange).toHaveBeenCalledTimes(1);
routeStore.removeRoute("api.localhost");
@@ -300,10 +354,11 @@ describe("ScriptHealthMonitor", () => {
await advancePoll(3_000);
monitor.stop();
expect(monitor.getHealthForHostname("api.localhost")).toBeNull();
expect(onChange).toHaveBeenCalledTimes(1);
});
it("calls onChange with workspaceId and full service list when status transitions", async () => {
it("calls onChange with the full service list when multiple services change in one workspace", async () => {
vi.useFakeTimers();
const api = await startTcpServer();
@@ -355,19 +410,45 @@ describe("ScriptHealthMonitor", () => {
]);
});
it("getHealthForHostname returns current health after probe", async () => {
it("only probes service routes because plain scripts never register routes", async () => {
vi.useFakeTimers();
const healthy = await startTcpServer();
servers.add(healthy.server);
const service = await startTcpServer();
servers.add(service.server);
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "api.localhost",
port: healthy.port,
workspaceId: "workspace-a",
scriptName: "api",
const workspace = createWorkspaceRepo({
paseoConfig: {
scripts: {
typecheck: { command: "npm run typecheck" },
api: { type: "service", command: "npm run api", port: service.port },
},
},
});
const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore();
const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record<string, string> }> =
[];
try {
await spawnWorktreeScripts({
repoRoot: workspace.repoDir,
workspaceId: workspace.repoDir,
branchName: null,
daemonPort: null,
routeStore,
runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls) as any,
});
expect(createTerminalCalls).toHaveLength(2);
expect(routeStore.listRoutes()).toEqual([
{
hostname: "api.localhost",
port: service.port,
workspaceId: workspace.repoDir,
scriptName: "api",
},
]);
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({
@@ -378,17 +459,26 @@ describe("ScriptHealthMonitor", () => {
graceMs: 0,
});
expect(monitor.getHealthForHostname("api.localhost")).toBeNull();
monitor.start();
await advancePoll(1_000);
monitor.stop();
expect(monitor.getHealthForHostname("api.localhost")).toBe("healthy");
expect(monitor.getHealthForHostname("unknown.localhost")).toBeNull();
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(workspace.repoDir, [
{
scriptName: "api",
hostname: "api.localhost",
port: service.port,
health: "healthy",
},
]);
expect(monitor.getHealthForHostname("typecheck")).toBeNull();
} finally {
workspace.cleanup();
}
});
it("coalesces multiple service changes in same workspace into one onChange call per poll cycle", async () => {
it("coalesces multiple service changes in the same workspace into one onChange call per poll cycle", async () => {
vi.useFakeTimers();
const api = await startTcpServer();
@@ -1,15 +1,18 @@
import net from "node:net";
import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js";
export type ScriptHealthState = "pending" | "healthy" | "unhealthy";
export interface ScriptHealthEntry {
scriptName: string;
hostname: string;
port: number;
health: "healthy" | "unhealthy";
health: ScriptHealthState;
}
type RouteHealthState = {
health: ScriptHealthEntry["health"] | null;
workspaceId: string;
health: ScriptHealthState;
consecutiveFailures: number;
registeredAt: number;
};
@@ -60,7 +63,7 @@ export class ScriptHealthMonitor {
const now = Date.now();
for (const route of this.routeStore.listRoutes()) {
this.getOrCreateState(route.hostname, now);
this.getOrCreateState(route, now);
}
this.intervalHandle = setInterval(() => {
@@ -75,6 +78,17 @@ export class ScriptHealthMonitor {
}
}
invalidateWorkspace(workspaceId: string): void {
const scripts = this.buildWorkspaceScriptList(workspaceId);
const snapshot = JSON.stringify(scripts);
if (snapshot === this.lastEmittedSnapshots.get(workspaceId)) {
return;
}
this.lastEmittedSnapshots.set(workspaceId, snapshot);
this.onChange(workspaceId, scripts);
}
private async poll(): Promise<void> {
if (this.pollInFlight) {
return;
@@ -88,7 +102,7 @@ export class ScriptHealthMonitor {
const now = Date.now();
for (const route of routes) {
const state = this.getOrCreateState(route.hostname, now);
const state = this.getOrCreateState(route, now);
if (now - state.registeredAt < this.graceMs) {
continue;
}
@@ -106,7 +120,7 @@ export class ScriptHealthMonitor {
}
}
if (state.health !== null && state.health !== previousHealth) {
if (state.health !== previousHealth) {
changedWorkspaceIds.add(route.workspaceId);
}
}
@@ -128,27 +142,32 @@ export class ScriptHealthMonitor {
}
}
private getOrCreateState(hostname: string, registeredAt: number): RouteHealthState {
const existing = this.routeStates.get(hostname);
private getOrCreateState(
route: Pick<ScriptRouteEntry, "hostname" | "workspaceId">,
registeredAt: number,
): RouteHealthState {
const existing = this.routeStates.get(route.hostname);
if (existing) {
return existing;
}
const state: RouteHealthState = {
health: null,
workspaceId: route.workspaceId,
health: "pending",
consecutiveFailures: 0,
registeredAt,
};
this.routeStates.set(hostname, state);
this.routeStates.set(route.hostname, state);
return state;
}
private pruneRemovedRoutes(activeHostnames: Set<string>): void {
for (const hostname of this.routeStates.keys()) {
for (const [hostname, state] of this.routeStates.entries()) {
if (activeHostnames.has(hostname)) {
continue;
}
this.routeStates.delete(hostname);
this.lastEmittedSnapshots.delete(state.workspaceId);
}
}
@@ -157,15 +176,25 @@ export class ScriptHealthMonitor {
.listRoutesForWorkspace(workspaceId)
.flatMap((route) => {
const state = this.routeStates.get(route.hostname);
if (!state?.health) {
if (!state) {
return [];
}
return [this.toScriptHealthEntry(route, state.health)];
});
}
getHealthForHostname(hostname: string): ScriptHealthEntry["health"] | null {
return this.routeStates.get(hostname)?.health ?? null;
getHealthForHostname(hostname: string): ScriptHealthState | null {
const state = this.routeStates.get(hostname);
if (state) {
return state.health;
}
const route = this.routeStore.getRouteEntry(hostname);
if (!route) {
return null;
}
return this.getOrCreateState(route, Date.now()).health;
}
private toScriptHealthEntry(
@@ -1,7 +1,37 @@
import { execSync } from "node:child_process";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { ScriptRouteStore } from "./script-proxy.js";
import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js";
function createWorkspaceRepo(options?: {
branchName?: string;
paseoConfig?: Record<string, unknown>;
}): { tempDir: string; repoDir: string; cleanup: () => void } {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-branch-handler-")));
const repoDir = path.join(tempDir, "repo");
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
if (options?.paseoConfig) {
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
}
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
return {
tempDir,
repoDir,
cleanup: () => {
rmSync(tempDir, { recursive: true, force: true });
},
};
}
function registerRoute(
routeStore: ScriptRouteStore,
{
@@ -33,10 +63,10 @@ describe("script-route-branch-handler", () => {
scriptName: "api",
});
const emitScriptStatusUpdate = vi.fn();
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
emitScriptStatusUpdate,
onRoutesChanged,
});
handleBranchChange("workspace-a", "feature/auth", "feature/billing");
@@ -50,16 +80,16 @@ describe("script-route-branch-handler", () => {
it("is a no-op when the workspace has no routes", () => {
const routeStore = new ScriptRouteStore();
const emitScriptStatusUpdate = vi.fn();
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
emitScriptStatusUpdate,
onRoutesChanged,
});
handleBranchChange("workspace-a", "feature/auth", "feature/billing");
expect(routeStore.listRoutes()).toEqual([]);
expect(emitScriptStatusUpdate).not.toHaveBeenCalled();
expect(onRoutesChanged).not.toHaveBeenCalled();
});
it("is a no-op when the resolved hostnames do not change", () => {
@@ -70,10 +100,10 @@ describe("script-route-branch-handler", () => {
scriptName: "api",
});
const emitScriptStatusUpdate = vi.fn();
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
emitScriptStatusUpdate,
onRoutesChanged,
});
handleBranchChange("workspace-a", "main", "master");
@@ -86,10 +116,10 @@ describe("script-route-branch-handler", () => {
scriptName: "api",
},
]);
expect(emitScriptStatusUpdate).not.toHaveBeenCalled();
expect(onRoutesChanged).not.toHaveBeenCalled();
});
it("emits a status update with the refreshed route payload after a route change", () => {
it("triggers shared reprojection after a route change", () => {
const routeStore = new ScriptRouteStore();
registerRoute(routeStore, {
hostname: "feature-auth.api.localhost",
@@ -97,24 +127,15 @@ describe("script-route-branch-handler", () => {
scriptName: "api",
});
const emitScriptStatusUpdate = vi.fn();
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
emitScriptStatusUpdate,
onRoutesChanged,
});
handleBranchChange("workspace-a", "feature/auth", "feature/billing");
expect(emitScriptStatusUpdate).toHaveBeenCalledWith("workspace-a", [
{
scriptName: "api",
hostname: "feature-billing.api.localhost",
port: 3001,
url: null,
lifecycle: "running",
health: null,
},
]);
expect(onRoutesChanged).toHaveBeenCalledWith("workspace-a");
});
it("updates all services for a workspace when multiple routes are registered", () => {
@@ -136,10 +157,10 @@ describe("script-route-branch-handler", () => {
scriptName: "docs",
});
const emitScriptStatusUpdate = vi.fn();
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
emitScriptStatusUpdate,
onRoutesChanged,
});
handleBranchChange("workspace-a", "feature/auth", "feature/billing");
@@ -176,14 +197,55 @@ describe("script-route-branch-handler", () => {
scriptName: "web",
});
const emitScriptStatusUpdate = vi.fn();
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
emitScriptStatusUpdate,
onRoutesChanged,
});
handleBranchChange("workspace-a", null, "main");
expect(emitScriptStatusUpdate).not.toHaveBeenCalled();
expect(onRoutesChanged).not.toHaveBeenCalled();
});
it("renames only service routes and leaves plain scripts unaffected", () => {
const workspace = createWorkspaceRepo({
branchName: "feature/auth",
paseoConfig: {
scripts: {
api: { type: "service", command: "npm run api" },
typecheck: { command: "npm run typecheck" },
},
},
});
const routeStore = new ScriptRouteStore();
registerRoute(routeStore, {
hostname: "feature-auth.api.localhost",
port: 3001,
workspaceId: workspace.repoDir,
scriptName: "api",
});
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
onRoutesChanged,
});
try {
handleBranchChange(workspace.repoDir, "feature/auth", "feature/billing");
expect(routeStore.listRoutesForWorkspace(workspace.repoDir)).toEqual([
{
hostname: "feature-billing.api.localhost",
port: 3001,
workspaceId: workspace.repoDir,
scriptName: "api",
},
]);
expect(onRoutesChanged).toHaveBeenCalledWith(workspace.repoDir);
} finally {
workspace.cleanup();
}
});
});
@@ -1,15 +1,10 @@
import type { Logger } from "pino";
import type { WorkspaceScriptPayload } from "../shared/messages.js";
import { buildScriptHostname } from "../utils/script-hostname.js";
import { buildWorkspaceScriptPayloads } from "./script-status-projection.js";
import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js";
interface BranchChangeRouteHandlerOptions {
routeStore: ScriptRouteStore;
emitScriptStatusUpdate: (
workspaceId: string,
scripts: WorkspaceScriptPayload[],
) => void;
onRoutesChanged: (workspaceId: string) => void;
logger?: Logger;
}
@@ -23,6 +18,7 @@ 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) {
return;
@@ -62,9 +58,6 @@ export function createBranchChangeRouteHandler(
);
}
options.emitScriptStatusUpdate(
workspaceId,
buildWorkspaceScriptPayloads(options.routeStore, workspaceId, null),
);
options.onRoutesChanged(workspaceId);
};
}
@@ -8,6 +8,8 @@ import {
buildWorkspaceScriptPayloads,
createScriptStatusEmitter,
} from "./script-status-projection.js";
import type { ScriptHealthState } from "./script-health-monitor.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
function createWorkspaceRepo(options?: {
branchName?: string;
@@ -35,35 +37,65 @@ function createWorkspaceRepo(options?: {
};
}
function buildPayloads(input: {
workspaceDirectory: string;
routeStore: ScriptRouteStore;
runtimeStore: WorkspaceScriptRuntimeStore;
daemonPort: number | null;
resolveHealth?: (hostname: string) => ScriptHealthState | null;
}) {
return buildWorkspaceScriptPayloads(input);
}
describe("script-status-projection", () => {
it("shows configured scripts even before they have routes", () => {
it("projects plain scripts and services differently", () => {
const workspace = createWorkspaceRepo({
paseoConfig: {
scripts: {
api: { command: "npm run api" },
web: { command: "npm run web", port: 3000 },
typecheck: { command: "npm run typecheck" },
web: { type: "service", command: "npm run web", port: 3000 },
},
},
});
const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore();
runtimeStore.set({
workspaceId: workspace.repoDir,
scriptName: "typecheck",
type: "script",
lifecycle: "stopped",
terminalId: "term-script",
exitCode: 0,
});
try {
expect(buildWorkspaceScriptPayloads(routeStore, workspace.repoDir, 6767)).toEqual([
expect(
buildPayloads({
workspaceDirectory: workspace.repoDir,
routeStore,
runtimeStore,
daemonPort: 6767,
}),
).toEqual([
{
scriptName: "api",
hostname: "api.localhost",
scriptName: "typecheck",
type: "script",
hostname: "typecheck",
port: null,
url: "http://api.localhost:6767",
url: null,
lifecycle: "stopped",
health: null,
exitCode: 0,
},
{
scriptName: "web",
type: "service",
hostname: "web.localhost",
port: 3000,
url: "http://web.localhost:6767",
lifecycle: "stopped",
health: null,
exitCode: null,
},
]);
} finally {
@@ -71,12 +103,12 @@ describe("script-status-projection", () => {
}
});
it("uses the active route port and branch-aware hostname for running scripts", () => {
it("overlays runtime, route, and health state for running services", () => {
const workspace = createWorkspaceRepo({
branchName: "feature/card",
paseoConfig: {
scripts: {
web: { command: "npm run web" },
web: { type: "service", command: "npm run web" },
},
},
});
@@ -87,16 +119,35 @@ describe("script-status-projection", () => {
workspaceId: workspace.repoDir,
scriptName: "web",
});
const runtimeStore = new WorkspaceScriptRuntimeStore();
runtimeStore.set({
workspaceId: workspace.repoDir,
scriptName: "web",
type: "service",
lifecycle: "running",
terminalId: "term-web",
exitCode: null,
});
try {
expect(buildWorkspaceScriptPayloads(routeStore, workspace.repoDir, 6767)).toEqual([
expect(
buildPayloads({
workspaceDirectory: workspace.repoDir,
routeStore,
runtimeStore,
daemonPort: 6767,
resolveHealth: () => "healthy",
}),
).toEqual([
{
scriptName: "web",
type: "service",
hostname: "feature-card.web.localhost",
port: 4321,
url: "http://feature-card.web.localhost:6767",
lifecycle: "running",
health: null,
health: "healthy",
exitCode: null,
},
]);
} finally {
@@ -104,7 +155,58 @@ describe("script-status-projection", () => {
}
});
it("includes orphaned active routes even if the current config no longer declares them", () => {
it("maps internal pending health to null on the wire", () => {
const workspace = createWorkspaceRepo({
paseoConfig: {
scripts: {
web: { type: "service", command: "npm run web" },
},
},
});
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "web.localhost",
port: 4321,
workspaceId: workspace.repoDir,
scriptName: "web",
});
const runtimeStore = new WorkspaceScriptRuntimeStore();
runtimeStore.set({
workspaceId: workspace.repoDir,
scriptName: "web",
type: "service",
lifecycle: "running",
terminalId: "term-web",
exitCode: null,
});
try {
expect(
buildPayloads({
workspaceDirectory: workspace.repoDir,
routeStore,
runtimeStore,
daemonPort: 6767,
resolveHealth: () => "pending",
}),
).toEqual([
{
scriptName: "web",
type: "service",
hostname: "web.localhost",
port: 4321,
url: "http://web.localhost:6767",
lifecycle: "running",
health: null,
exitCode: null,
},
]);
} finally {
workspace.cleanup();
}
});
it("includes orphaned running runtime entries even after config removal", () => {
const workspace = createWorkspaceRepo();
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
@@ -113,16 +215,34 @@ describe("script-status-projection", () => {
workspaceId: workspace.repoDir,
scriptName: "docs",
});
const runtimeStore = new WorkspaceScriptRuntimeStore();
runtimeStore.set({
workspaceId: workspace.repoDir,
scriptName: "docs",
type: "service",
lifecycle: "running",
terminalId: "term-docs",
exitCode: null,
});
try {
expect(buildWorkspaceScriptPayloads(routeStore, workspace.repoDir, 6767)).toEqual([
expect(
buildPayloads({
workspaceDirectory: workspace.repoDir,
routeStore,
runtimeStore,
daemonPort: 6767,
}),
).toEqual([
{
scriptName: "docs",
type: "service",
hostname: "docs.localhost",
port: 3002,
url: "http://docs.localhost:6767",
lifecycle: "running",
health: null,
exitCode: null,
},
]);
} finally {
@@ -130,12 +250,50 @@ describe("script-status-projection", () => {
}
});
it("createScriptStatusEmitter overlays health onto the full workspace script list", () => {
it("projects orphaned plain scripts as scripts instead of services", () => {
const workspace = createWorkspaceRepo();
const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore();
runtimeStore.set({
workspaceId: workspace.repoDir,
scriptName: "typecheck",
type: "script",
lifecycle: "running",
terminalId: "term-typecheck",
exitCode: null,
});
try {
expect(
buildPayloads({
workspaceDirectory: workspace.repoDir,
routeStore,
runtimeStore,
daemonPort: 6767,
}),
).toEqual([
{
scriptName: "typecheck",
type: "script",
hostname: "typecheck",
port: null,
url: null,
lifecycle: "running",
health: null,
exitCode: null,
},
]);
} finally {
workspace.cleanup();
}
});
it("createScriptStatusEmitter overlays health onto the projected workspace script list", () => {
const workspace = createWorkspaceRepo({
paseoConfig: {
scripts: {
api: { command: "npm run api" },
web: { command: "npm run web" },
api: { type: "service", command: "npm run api" },
typecheck: { command: "npm run typecheck" },
},
},
});
@@ -146,11 +304,21 @@ describe("script-status-projection", () => {
workspaceId: workspace.repoDir,
scriptName: "api",
});
const runtimeStore = new WorkspaceScriptRuntimeStore();
runtimeStore.set({
workspaceId: workspace.repoDir,
scriptName: "api",
type: "service",
lifecycle: "running",
terminalId: "term-api",
exitCode: null,
});
const session = { emit: vi.fn() };
const emitUpdate = createScriptStatusEmitter({
sessions: () => [session],
routeStore,
runtimeStore,
daemonPort: 6767,
});
@@ -171,19 +339,23 @@ describe("script-status-projection", () => {
scripts: [
{
scriptName: "api",
type: "service",
hostname: "api.localhost",
port: 3001,
url: "http://api.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
},
{
scriptName: "web",
hostname: "web.localhost",
scriptName: "typecheck",
type: "script",
hostname: "typecheck",
port: null,
url: "http://web.localhost:6767",
url: null,
lifecycle: "stopped",
health: null,
exitCode: null,
},
],
},
@@ -192,41 +364,4 @@ describe("script-status-projection", () => {
workspace.cleanup();
}
});
it("computes URLs with and without a daemon port", () => {
const workspace = createWorkspaceRepo({
paseoConfig: {
scripts: {
api: { command: "npm run api" },
},
},
});
const routeStore = new ScriptRouteStore();
try {
expect(buildWorkspaceScriptPayloads(routeStore, workspace.repoDir, 6767)).toEqual([
{
scriptName: "api",
hostname: "api.localhost",
port: null,
url: "http://api.localhost:6767",
lifecycle: "stopped",
health: null,
},
]);
expect(buildWorkspaceScriptPayloads(routeStore, workspace.repoDir, null)).toEqual([
{
scriptName: "api",
hostname: "api.localhost",
port: null,
url: null,
lifecycle: "stopped",
health: null,
},
]);
} finally {
workspace.cleanup();
}
});
});
@@ -4,15 +4,24 @@ import type {
WorkspaceScriptPayload,
} from "../shared/messages.js";
import { buildScriptHostname } from "../utils/script-hostname.js";
import { getScriptConfigs } from "../utils/worktree.js";
import { getScriptConfigs, isServiceScript } from "../utils/worktree.js";
import { readGitCommand } from "./workspace-git-metadata.js";
import type { ScriptHealthEntry } from "./script-health-monitor.js";
import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js";
import type { ScriptHealthEntry, ScriptHealthState } from "./script-health-monitor.js";
import type { ScriptRouteStore } from "./script-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
type SessionEmitter = {
emit(message: SessionOutboundMessage): void;
};
type BuildWorkspaceScriptPayloadsOptions = {
workspaceDirectory: string;
routeStore: ScriptRouteStore;
runtimeStore: WorkspaceScriptRuntimeStore;
daemonPort: number | null;
resolveHealth?: (hostname: string) => ScriptHealthState | null;
};
function resolveDaemonPort(daemonPort: number | null | (() => number | null)): number | null {
if (typeof daemonPort === "function") {
return daemonPort();
@@ -20,6 +29,10 @@ function resolveDaemonPort(daemonPort: number | null | (() => number | null)): n
return daemonPort;
}
function resolveWorkspaceBranchName(workspaceDirectory: string): string | null {
return readGitCommand(workspaceDirectory, "git symbolic-ref --short HEAD");
}
function toServiceUrl(hostname: string, daemonPort: number | null): string | null {
if (daemonPort === null) {
return null;
@@ -27,41 +40,39 @@ function toServiceUrl(hostname: string, daemonPort: number | null): string | nul
return `http://${hostname}:${daemonPort}`;
}
type ConfiguredWorkspaceScript = {
function toWireHealth(health: ScriptHealthState | null): WorkspaceScriptPayload["health"] {
if (health === "pending" || health === null) {
return null;
}
return health;
}
function createConfiguredPayload(input: {
scriptName: string;
hostname: string;
port: number | null;
};
type: WorkspaceScriptPayload["type"];
branchName: string | null;
daemonPort: number | null;
configuredPort: number | null;
}): WorkspaceScriptPayload {
const hostname =
input.type === "service"
? buildScriptHostname(input.branchName, input.scriptName)
: input.scriptName;
function resolveWorkspaceBranchName(workspaceDirectory: string): string | null {
return readGitCommand(workspaceDirectory, "git symbolic-ref --short HEAD");
return {
scriptName: input.scriptName,
type: input.type,
hostname,
port: input.type === "service" ? input.configuredPort : null,
url: input.type === "service" ? toServiceUrl(hostname, input.daemonPort) : null,
lifecycle: "stopped",
health: null,
exitCode: null,
};
}
function listConfiguredWorkspaceScripts(workspaceDirectory: string): ConfiguredWorkspaceScript[] {
const branchName = resolveWorkspaceBranchName(workspaceDirectory);
const scriptConfigs = getScriptConfigs(workspaceDirectory);
return Array.from(scriptConfigs.entries()).map(([scriptName, config]) => ({
scriptName,
hostname: buildScriptHostname(branchName, scriptName),
port: config.port ?? null,
}));
}
function mergeWorkspaceScriptDefinitions(
workspaceDirectory: string,
routeStore: ScriptRouteStore,
): Array<ConfiguredWorkspaceScript | ScriptRouteEntry> {
const merged = new Map<string, ConfiguredWorkspaceScript | ScriptRouteEntry>();
for (const script of listConfiguredWorkspaceScripts(workspaceDirectory)) {
merged.set(script.hostname, script);
}
for (const route of routeStore.listRoutesForWorkspace(workspaceDirectory)) {
merged.set(route.hostname, route);
}
return Array.from(merged.values()).sort((left, right) =>
function sortPayloads(payloads: WorkspaceScriptPayload[]): WorkspaceScriptPayload[] {
return payloads.sort((left, right) =>
left.scriptName.localeCompare(right.scriptName, undefined, {
numeric: true,
sensitivity: "base",
@@ -70,22 +81,82 @@ function mergeWorkspaceScriptDefinitions(
}
export function buildWorkspaceScriptPayloads(
routeStore: ScriptRouteStore,
workspaceDirectory: string,
daemonPort: number | null,
resolveHealth?: (hostname: string) => "healthy" | "unhealthy" | null,
options: BuildWorkspaceScriptPayloadsOptions,
): WorkspaceScriptPayload[] {
return mergeWorkspaceScriptDefinitions(workspaceDirectory, routeStore).map((script) => {
const route = routeStore.getRouteEntry(script.hostname);
return {
scriptName: script.scriptName,
hostname: script.hostname,
port: route?.port ?? script.port,
url: toServiceUrl(script.hostname, daemonPort),
lifecycle: route ? "running" : "stopped",
health: resolveHealth?.(script.hostname) ?? null,
};
const workspaceDirectory = options.workspaceDirectory;
const branchName = resolveWorkspaceBranchName(workspaceDirectory);
const scriptConfigs = getScriptConfigs(workspaceDirectory);
const runtimeEntries = new Map(
options.runtimeStore
.listForWorkspace(workspaceDirectory)
.map((entry) => [entry.scriptName, entry] as const),
);
const routesByScriptName = new Map(
options.routeStore
.listRoutesForWorkspace(workspaceDirectory)
.map((entry) => [entry.scriptName, entry] as const),
);
const payloads: WorkspaceScriptPayload[] = [];
for (const [scriptName, config] of scriptConfigs.entries()) {
const type = isServiceScript(config) ? "service" : "script";
const runtimeEntry = runtimeEntries.get(scriptName) ?? null;
const routeEntry = routesByScriptName.get(scriptName) ?? null;
const payload = createConfiguredPayload({
scriptName,
type,
branchName,
daemonPort: options.daemonPort,
configuredPort: isServiceScript(config) ? config.port ?? null : null,
});
payloads.push({
...payload,
hostname:
type === "service" ? (routeEntry?.hostname ?? payload.hostname) : payload.scriptName,
port: type === "service" ? (routeEntry?.port ?? payload.port) : null,
url:
type === "service"
? toServiceUrl(routeEntry?.hostname ?? payload.hostname, options.daemonPort)
: null,
lifecycle: runtimeEntry?.lifecycle ?? payload.lifecycle,
health:
type === "service"
? toWireHealth(options.resolveHealth?.(routeEntry?.hostname ?? payload.hostname) ?? null)
: null,
exitCode: runtimeEntry?.exitCode ?? null,
});
}
for (const runtimeEntry of runtimeEntries.values()) {
if (scriptConfigs.has(runtimeEntry.scriptName) || runtimeEntry.lifecycle !== "running") {
continue;
}
const routeEntry = routesByScriptName.get(runtimeEntry.scriptName) ?? null;
const type = runtimeEntry.type;
const hostname =
type === "service"
? (routeEntry?.hostname ?? buildScriptHostname(branchName, runtimeEntry.scriptName))
: runtimeEntry.scriptName;
payloads.push({
scriptName: runtimeEntry.scriptName,
type,
hostname,
port: type === "service" ? (routeEntry?.port ?? null) : null,
url: type === "service" ? toServiceUrl(hostname, options.daemonPort) : null,
lifecycle: runtimeEntry.lifecycle,
health:
type === "service" && routeEntry
? toWireHealth(options.resolveHealth?.(hostname) ?? null)
: null,
exitCode: runtimeEntry.exitCode,
});
}
return sortPayloads(payloads);
}
function buildScriptStatusUpdateMessage(params: {
@@ -104,10 +175,12 @@ function buildScriptStatusUpdateMessage(params: {
export function createScriptStatusEmitter({
sessions,
routeStore,
runtimeStore,
daemonPort,
}: {
sessions: () => SessionEmitter[];
routeStore: ScriptRouteStore;
runtimeStore: WorkspaceScriptRuntimeStore;
daemonPort: number | null | (() => number | null);
}): (workspaceId: string, scripts: ScriptHealthEntry[]) => void {
return (workspaceId, scripts) => {
@@ -116,12 +189,13 @@ export function createScriptStatusEmitter({
scripts.map((script) => [script.hostname, script.health] as const),
);
const projected = buildWorkspaceScriptPayloads(
const projected = buildWorkspaceScriptPayloads({
workspaceDirectory: workspaceId,
routeStore,
workspaceId,
resolvedDaemonPort,
(hostname) => scriptHealthByHostname.get(hostname) ?? null,
);
runtimeStore,
daemonPort: resolvedDaemonPort,
resolveHealth: (hostname) => scriptHealthByHostname.get(hostname) ?? null,
});
const message = buildScriptStatusUpdateMessage({
workspaceId,
+26 -20
View File
@@ -68,9 +68,11 @@ import { experimental_createMCPClient } from "ai";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js";
import { buildWorkspaceScriptPayloads } from "./script-status-projection.js";
import type { ScriptHealthState } from "./script-health-monitor.js";
import { spawnWorkspaceScript } from "./worktree-bootstrap.js";
import { readGitCommand } from "./workspace-git-metadata.js";
import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
export type AgentMcpTransportFactory = () => Promise<Transport>;
import { buildProviderRegistry } from "./agent/provider-registry.js";
@@ -430,13 +432,14 @@ export type SessionOptions = {
terminalManager: TerminalManager | null;
providerSnapshotManager?: ProviderSnapshotManager;
scriptRouteStore?: ScriptRouteStore;
scriptRuntimeStore?: WorkspaceScriptRuntimeStore;
onBranchChanged?: (
workspaceId: string,
oldBranch: string | null,
newBranch: string | null,
) => void;
getDaemonTcpPort?: () => number | null;
resolveScriptHealth?: (hostname: string) => "healthy" | "unhealthy" | null;
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null;
voice?: {
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
turnDetection?: Resolvable<TurnDetectionProvider | null>;
@@ -614,6 +617,7 @@ export class Session {
private readonly providerSnapshotManager: ProviderSnapshotManager | null;
private unsubscribeProviderSnapshotEvents: (() => void) | null = null;
private readonly scriptRouteStore: ScriptRouteStore | null;
private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
private readonly onBranchChanged?: (
workspaceId: string,
oldBranch: string | null,
@@ -621,7 +625,7 @@ export class Session {
) => void;
private readonly getDaemonTcpPort: (() => number | null) | null;
private readonly resolveScriptHealth:
| ((hostname: string) => "healthy" | "unhealthy" | null)
| ((hostname: string) => ScriptHealthState | null)
| null;
private readonly subscribedTerminalDirectories = new Set<string>();
private unsubscribeTerminalsChanged: (() => void) | null = null;
@@ -681,6 +685,7 @@ export class Session {
terminalManager,
providerSnapshotManager,
scriptRouteStore,
scriptRuntimeStore,
onBranchChanged,
getDaemonTcpPort,
resolveScriptHealth,
@@ -725,6 +730,7 @@ export class Session {
this.terminalManager = terminalManager;
this.providerSnapshotManager = providerSnapshotManager ?? null;
this.scriptRouteStore = scriptRouteStore ?? null;
this.scriptRuntimeStore = scriptRuntimeStore ?? null;
this.onBranchChanged = onBranchChanged;
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
this.resolveScriptHealth = resolveScriptHealth ?? null;
@@ -2930,8 +2936,6 @@ export class Session {
agentId: snapshot.id,
item,
}),
scriptRouteStore: this.scriptRouteStore ?? undefined,
daemonPort: this.getDaemonTcpPort?.() ?? null,
logger: this.sessionLogger,
});
}
@@ -5363,13 +5367,14 @@ export class Session {
status: "done",
activityAt: null,
diffStat,
scripts: this.scriptRouteStore
? buildWorkspaceScriptPayloads(
this.scriptRouteStore,
workspace.directory,
this.getDaemonTcpPort?.() ?? null,
this.resolveScriptHealth ?? undefined,
)
scripts: this.scriptRouteStore && this.scriptRuntimeStore
? buildWorkspaceScriptPayloads({
workspaceDirectory: workspace.directory,
routeStore: this.scriptRouteStore,
runtimeStore: this.scriptRuntimeStore,
daemonPort: this.getDaemonTcpPort?.() ?? null,
resolveHealth: this.resolveScriptHealth ?? undefined,
})
: [],
};
}
@@ -5901,6 +5906,7 @@ export class Session {
const nextArchivedAt = archivedAt ?? new Date().toISOString();
await this.workspaceRegistry.archive(workspaceId, nextArchivedAt);
await this.removeWorkspaceGitWatchTarget(existingWorkspace.directory);
this.scriptRuntimeStore?.removeForWorkspace(existingWorkspace.directory);
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt,
@@ -6195,15 +6201,16 @@ export class Session {
private buildWorkspaceScriptPayloadSnapshot(
workspaceDirectory: string,
): WorkspaceDescriptorPayload["scripts"] {
if (!this.scriptRouteStore) {
if (!this.scriptRouteStore || !this.scriptRuntimeStore) {
return [];
}
return buildWorkspaceScriptPayloads(
this.scriptRouteStore,
return buildWorkspaceScriptPayloads({
workspaceDirectory,
this.getDaemonTcpPort?.() ?? null,
this.resolveScriptHealth ?? undefined,
);
routeStore: this.scriptRouteStore,
runtimeStore: this.scriptRuntimeStore,
daemonPort: this.getDaemonTcpPort?.() ?? null,
resolveHealth: this.resolveScriptHealth ?? undefined,
});
}
private emitWorkspaceScriptStatusUpdate(workspaceDirectory: string): void {
@@ -6228,7 +6235,7 @@ export class Session {
request: StartWorkspaceScriptRequest,
): Promise<void> {
try {
if (!this.terminalManager || !this.scriptRouteStore) {
if (!this.terminalManager || !this.scriptRouteStore || !this.scriptRuntimeStore) {
throw new Error("Workspace scripts are not available on this daemon");
}
@@ -6244,6 +6251,7 @@ export class Session {
scriptName: request.scriptName,
daemonPort: this.getDaemonTcpPort?.() ?? null,
routeStore: this.scriptRouteStore,
runtimeStore: this.scriptRuntimeStore,
terminalManager: this.terminalManager,
logger: this.sessionLogger,
onLifecycleChanged: () => {
@@ -6384,8 +6392,6 @@ export class Session {
sessionLogger: this.sessionLogger,
terminalManager: this.terminalManager,
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
scriptRouteStore: this.scriptRouteStore,
daemonPort: this.getDaemonTcpPort?.() ?? null,
},
options,
);
@@ -36,7 +36,9 @@ import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import { buildProviderRegistry } from "./agent/provider-registry.js";
import { PushTokenStore } from "./push/token-store.js";
import { PushService } from "./push/push-service.js";
import type { ScriptHealthState } from "./script-health-monitor.js";
import type { ScriptRouteStore } from "./script-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js";
import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js";
import {
@@ -249,9 +251,10 @@ export class VoiceAssistantWebSocketServer {
private readonly speech: SpeechService | null;
private readonly terminalManager: TerminalManager | null;
private readonly scriptRouteStore: ScriptRouteStore | null;
private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
private readonly getDaemonTcpPort: (() => number | null) | null;
private readonly resolveScriptHealth:
| ((hostname: string) => "healthy" | "unhealthy" | null)
| ((hostname: string) => ScriptHealthState | null)
| null;
private readonly dictation: {
finalTimeoutMs?: number;
@@ -324,13 +327,14 @@ export class VoiceAssistantWebSocketServer {
scheduleService?: ScheduleService,
checkoutDiffManager?: CheckoutDiffManager,
scriptRouteStore?: ScriptRouteStore | null,
scriptRuntimeStore?: WorkspaceScriptRuntimeStore | null,
onBranchChanged?: (
workspaceId: string,
oldBranch: string | null,
newBranch: string | null,
) => void,
getDaemonTcpPort?: () => number | null,
resolveScriptHealth?: (hostname: string) => "healthy" | "unhealthy" | null,
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null,
) {
this.logger = logger.child({ module: "websocket-server" });
this.serverId = serverId;
@@ -378,6 +382,7 @@ export class VoiceAssistantWebSocketServer {
);
this.onLifecycleIntent = onLifecycleIntent ?? null;
this.scriptRouteStore = scriptRouteStore ?? null;
this.scriptRuntimeStore = scriptRuntimeStore ?? null;
this.onBranchChanged = onBranchChanged ?? null;
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
this.resolveScriptHealth = resolveScriptHealth ?? null;
@@ -700,6 +705,7 @@ export class VoiceAssistantWebSocketServer {
terminalManager: this.terminalManager,
providerSnapshotManager: this.providerSnapshotManager,
scriptRouteStore: this.scriptRouteStore ?? undefined,
scriptRuntimeStore: this.scriptRuntimeStore ?? undefined,
onBranchChanged: this.onBranchChanged ?? undefined,
getDaemonTcpPort: this.getDaemonTcpPort ?? undefined,
resolveScriptHealth: this.resolveScriptHealth ?? undefined,
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import {
WorkspaceScriptRuntimeStore,
type ScriptRuntimeEntry,
} from "./workspace-script-runtime-store.js";
function createEntry(overrides: Partial<ScriptRuntimeEntry> = {}): ScriptRuntimeEntry {
return {
workspaceId: 101,
scriptName: "web",
type: "service",
lifecycle: "running",
terminalId: "terminal-1",
exitCode: null,
...overrides,
};
}
describe("WorkspaceScriptRuntimeStore", () => {
it("stores and returns entries by workspace and script name", () => {
const store = new WorkspaceScriptRuntimeStore();
const entry = createEntry();
store.set(entry);
expect(store.get({ workspaceId: 101, scriptName: "web" })).toEqual(entry);
expect(store.listForWorkspace(101)).toEqual([entry]);
});
it("preserves whether the runtime entry is a plain script or service", () => {
const store = new WorkspaceScriptRuntimeStore();
const entry = createEntry({
scriptName: "typecheck",
type: "script",
});
store.set(entry);
expect(store.get({ workspaceId: 101, scriptName: "typecheck" })).toEqual(entry);
});
it("reports whether a script is currently running", () => {
const store = new WorkspaceScriptRuntimeStore();
store.set(createEntry());
store.set(
createEntry({
workspaceId: 101,
scriptName: "typecheck",
lifecycle: "stopped",
terminalId: "terminal-2",
exitCode: 0,
}),
);
expect(store.isRunning({ workspaceId: 101, scriptName: "web" })).toBe(true);
expect(store.isRunning({ workspaceId: 101, scriptName: "typecheck" })).toBe(false);
expect(store.isRunning({ workspaceId: 101, scriptName: "missing" })).toBe(false);
});
it("removes individual entries", () => {
const store = new WorkspaceScriptRuntimeStore();
store.set(createEntry());
store.remove({ workspaceId: 101, scriptName: "web" });
expect(store.get({ workspaceId: 101, scriptName: "web" })).toBeNull();
expect(store.listForWorkspace(101)).toEqual([]);
});
it("removes all entries for a workspace without touching others", () => {
const store = new WorkspaceScriptRuntimeStore();
store.set(createEntry());
store.set(
createEntry({
workspaceId: 101,
scriptName: "api",
terminalId: "terminal-2",
}),
);
store.set(
createEntry({
workspaceId: 202,
scriptName: "docs",
terminalId: "terminal-3",
}),
);
store.removeForWorkspace(101);
expect(store.listForWorkspace(101)).toEqual([]);
expect(store.listForWorkspace(202)).toEqual([
createEntry({
workspaceId: 202,
scriptName: "docs",
terminalId: "terminal-3",
}),
]);
});
});
@@ -0,0 +1,105 @@
export interface ScriptRuntimeEntry {
workspaceId: number | string;
scriptName: string;
type: "script" | "service";
lifecycle: "running" | "stopped";
terminalId: string;
exitCode: number | null;
}
type RuntimeEntryKey = {
workspaceId: number | string;
scriptName: string;
};
export class WorkspaceScriptRuntimeStore {
private readonly entries = new Map<string, ScriptRuntimeEntry>();
private readonly scriptsByWorkspace = new Map<string, Set<string>>();
get(key: RuntimeEntryKey): ScriptRuntimeEntry | null {
const entry = this.entries.get(this.toEntryKey(key));
return entry ? { ...entry } : null;
}
set(entry: ScriptRuntimeEntry): void {
const workspaceKey = this.toWorkspaceKey(entry.workspaceId);
const entryKey = this.toEntryKey(entry);
const previous = this.entries.get(entryKey);
if (previous) {
this.removeScriptFromWorkspaceIndex(previous.workspaceId, previous.scriptName);
}
this.entries.set(entryKey, { ...entry });
this.addScriptToWorkspaceIndex(workspaceKey, entry.scriptName);
}
remove(key: RuntimeEntryKey): void {
const entryKey = this.toEntryKey(key);
const existing = this.entries.get(entryKey);
if (!existing) {
return;
}
this.entries.delete(entryKey);
this.removeScriptFromWorkspaceIndex(existing.workspaceId, existing.scriptName);
}
listForWorkspace(workspaceId: number | string): ScriptRuntimeEntry[] {
const scriptNames = this.scriptsByWorkspace.get(this.toWorkspaceKey(workspaceId));
if (!scriptNames) {
return [];
}
const entries: ScriptRuntimeEntry[] = [];
for (const scriptName of scriptNames) {
const entry = this.entries.get(
this.toEntryKey({
workspaceId,
scriptName,
}),
);
if (entry) {
entries.push({ ...entry });
}
}
return entries;
}
removeForWorkspace(workspaceId: number | string): void {
for (const entry of this.listForWorkspace(workspaceId)) {
this.entries.delete(this.toEntryKey(entry));
}
this.scriptsByWorkspace.delete(this.toWorkspaceKey(workspaceId));
}
isRunning(key: RuntimeEntryKey): boolean {
return this.get(key)?.lifecycle === "running";
}
private addScriptToWorkspaceIndex(workspaceKey: string, scriptName: string): void {
const scripts = this.scriptsByWorkspace.get(workspaceKey) ?? new Set<string>();
scripts.add(scriptName);
this.scriptsByWorkspace.set(workspaceKey, scripts);
}
private removeScriptFromWorkspaceIndex(workspaceId: number | string, scriptName: string): void {
const workspaceKey = this.toWorkspaceKey(workspaceId);
const scripts = this.scriptsByWorkspace.get(workspaceKey);
if (!scripts) {
return;
}
scripts.delete(scriptName);
if (scripts.size === 0) {
this.scriptsByWorkspace.delete(workspaceKey);
}
}
private toEntryKey(key: RuntimeEntryKey): string {
return `${this.toWorkspaceKey(key.workspaceId)}::${key.scriptName}`;
}
private toWorkspaceKey(workspaceId: number | string): string {
return String(workspaceId);
}
}
@@ -11,6 +11,7 @@ import {
spawnWorktreeScripts,
} from "./worktree-bootstrap.js";
import { ScriptRouteStore } from "./script-proxy.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
describe("runAsyncWorktreeBootstrap", () => {
let tempDir: string;
@@ -507,7 +508,56 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(terminalToolCall?.status).toBe("completed");
});
it("spawns scripts without PASEO_SCRIPT_URL when the daemon has no TCP port", async () => {
function createStubTerminalManager(
createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record<string, string> }>,
) {
return {
async getTerminals() {
return [];
},
async createTerminal(options: {
cwd: string;
name?: string;
env?: Record<string, string>;
}) {
createTerminalCalls.push(options);
return {
id: "term-service",
name: options.name ?? "Terminal",
cwd: options.cwd,
send: () => {},
subscribe: () => () => {},
onExit: () => () => {},
getState: () => ({
rows: 1,
cols: 1,
grid: [[{ char: "$" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
}),
kill: () => {},
onTitleChange: () => () => {},
getSize: () => ({ rows: 1, cols: 1 }),
getTitle: () => undefined,
getExitInfo: () => null,
};
},
registerCwdEnv() {},
getTerminal() {
return undefined;
},
killTerminal() {},
listDirectories() {
return [];
},
killAll() {},
subscribeTerminalsChanged() {
return () => {};
},
};
}
it("spawns plain scripts without env injection or routes", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
@@ -525,6 +575,7 @@ describe("runAsyncWorktreeBootstrap", () => {
});
const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore();
const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record<string, string> }> = [];
const results = await spawnWorktreeScripts({
@@ -533,42 +584,54 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-socket-service",
daemonPort: null,
routeStore,
terminalManager: {
async getTerminals() {
return [];
runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls) as any,
});
expect(results).toHaveLength(1);
expect(routeStore.listRoutes()).toEqual([]);
expect(createTerminalCalls).toHaveLength(1);
expect(createTerminalCalls[0]?.cwd).toBe(repoDir);
expect(createTerminalCalls[0]?.name).toBe("web");
expect(createTerminalCalls[0]?.env).toBeUndefined();
expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "web" })).toMatchObject({
type: "script",
lifecycle: "running",
exitCode: null,
terminalId: "term-service",
});
});
it("spawns services with route registration and injected service env vars", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
scripts: {
web: {
type: "service",
command: "npm run dev",
},
},
async createTerminal(options) {
createTerminalCalls.push(options);
return {
id: "term-service",
name: options.name ?? "Terminal",
cwd: options.cwd,
send: () => {},
subscribe: () => () => {},
onExit: () => () => {},
getState: () => ({
rows: 1,
cols: 1,
grid: [[{ char: "$" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
}),
kill: () => {},
};
},
registerCwdEnv() {},
getTerminal() {
return undefined;
},
killTerminal() {},
listDirectories() {
return [];
},
killAll() {},
subscribeTerminalsChanged() {
return () => {};
},
},
);
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add service script config'", {
cwd: repoDir,
stdio: "pipe",
});
const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore();
const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record<string, string> }> = [];
const results = await spawnWorktreeScripts({
repoRoot: repoDir,
workspaceId: repoDir,
branchName: "feature-socket-service",
daemonPort: 6767,
routeStore,
runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls) as any,
});
expect(results).toHaveLength(1);
@@ -585,6 +648,13 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(createTerminalCalls[0]?.name).toBe("web");
expect(createTerminalCalls[0]?.env?.PORT).toEqual(expect.any(String));
expect(createTerminalCalls[0]?.env?.HOST).toBe("127.0.0.1");
expect(createTerminalCalls[0]?.env?.PASEO_SCRIPT_URL).toBeUndefined();
expect(createTerminalCalls[0]?.env?.PASEO_SCRIPT_URL).toBe(
"http://feature-socket-service.web.localhost:6767",
);
expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "web" })).toMatchObject({
type: "service",
lifecycle: "running",
exitCode: null,
});
});
});
@@ -10,6 +10,7 @@ import {
createWorktree,
getScriptConfigs,
getWorktreeTerminalSpecs,
isServiceScript,
listPaseoWorktrees,
processCarriageReturns,
resolveWorktreeRuntimeEnv,
@@ -20,6 +21,7 @@ import {
type WorktreeRuntimeEnv,
} from "../utils/worktree.js";
import { findFreePort, type ScriptRouteStore } from "./script-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js";
export interface WorktreeBootstrapTerminalResult {
@@ -35,8 +37,6 @@ export interface RunAsyncWorktreeBootstrapOptions {
worktree: WorktreeConfig;
shouldBootstrap?: boolean;
terminalManager: TerminalManager | null;
scriptRouteStore?: ScriptRouteStore;
daemonPort?: number | null;
appendTimelineItem: (item: AgentTimelineItem) => Promise<boolean>;
emitLiveTimelineItem?: (item: AgentTimelineItem) => Promise<boolean>;
logger?: Logger;
@@ -734,27 +734,6 @@ export async function runAsyncWorktreeBootstrap(
}
await runWorktreeTerminalBootstrap(options, runtimeEnv);
if (!options.terminalManager || !options.scriptRouteStore) {
return;
}
try {
await spawnWorktreeScripts({
repoRoot: options.worktree.worktreePath,
workspaceId: options.worktree.worktreePath,
branchName: options.worktree.branchName,
daemonPort: options.daemonPort,
routeStore: options.scriptRouteStore,
terminalManager: options.terminalManager,
logger: options.logger,
});
} catch (error) {
options.logger?.warn(
{ err: error, agentId: options.agentId, worktreePath: options.worktree.worktreePath },
"Failed to spawn worktree scripts",
);
}
}
// ---------------------------------------------------------------------------
@@ -763,8 +742,8 @@ export async function runAsyncWorktreeBootstrap(
export interface WorktreeScriptResult {
scriptName: string;
hostname: string;
port: number;
hostname: string | null;
port: number | null;
terminalId: string;
}
@@ -775,6 +754,7 @@ type SpawnWorkspaceScriptOptions = {
scriptName: string;
daemonPort?: number | null;
routeStore: ScriptRouteStore;
runtimeStore: WorkspaceScriptRuntimeStore;
terminalManager: TerminalManager;
logger?: Logger;
onLifecycleChanged?: () => void;
@@ -790,6 +770,7 @@ export async function spawnWorkspaceScript(
scriptName,
daemonPort,
routeStore,
runtimeStore,
terminalManager,
logger,
onLifecycleChanged,
@@ -800,44 +781,74 @@ export async function spawnWorkspaceScript(
throw new Error(`Script '${scriptName}' is not configured in paseo.json`);
}
let hostname: string | null = null;
const serviceScript = isServiceScript(config);
const hostname = serviceScript ? buildScriptHostname(branchName, scriptName) : null;
let port: number | null = null;
let terminal: TerminalSession | null = null;
let runtimeRegistered = false;
try {
hostname = buildScriptHostname(branchName, scriptName);
const resolvedHostname = hostname;
if (routeStore.getRouteEntry(resolvedHostname)) {
if (runtimeStore.isRunning({ workspaceId, scriptName })) {
throw new Error(`Script '${scriptName}' is already running`);
}
if (serviceScript) {
port = config.port ?? (await findFreePort());
routeStore.registerRoute({
hostname: resolvedHostname,
hostname: hostname!,
port,
workspaceId,
scriptName,
});
const env: Record<string, string> = {
PORT: String(port),
HOST: "127.0.0.1",
};
if (daemonPort !== null && daemonPort !== undefined) {
env.PASEO_SCRIPT_URL = `http://${resolvedHostname}:${daemonPort}`;
}
const terminal = await terminalManager.createTerminal({
const env = serviceScript
? {
PORT: String(port),
HOST: "127.0.0.1",
...(daemonPort !== null && daemonPort !== undefined
? {
PASEO_SCRIPT_URL: `http://${hostname!}:${daemonPort}`,
}
: {}),
}
: undefined;
terminal = await terminalManager.createTerminal({
cwd: repoRoot,
name: scriptName,
env,
});
runtimeStore.set({
workspaceId,
scriptName,
type: serviceScript ? "service" : "script",
lifecycle: "running",
terminalId: terminal.id,
exitCode: null,
});
runtimeRegistered = true;
terminal.onExit(() => {
routeStore.removeRoute(resolvedHostname);
terminal.onExit((info) => {
if (hostname) {
routeStore.removeRoute(hostname);
}
runtimeStore.set({
workspaceId,
scriptName,
type: serviceScript ? "service" : "script",
lifecycle: "stopped",
terminalId: terminal!.id,
exitCode: info.exitCode,
});
onLifecycleChanged?.();
logger?.info(
{ scriptName, hostname: resolvedHostname, terminalId: terminal.id },
{
scriptName,
hostname,
exitCode: info.exitCode,
terminalId: terminal!.id,
},
"Stopped worktree script",
);
});
@@ -846,14 +857,16 @@ export async function spawnWorkspaceScript(
terminal.send({ type: "input", data: `${config.command}\r` });
logger?.info(
{ scriptName, hostname: resolvedHostname, port, terminalId: terminal.id },
`Registered script proxy: ${resolvedHostname} -> 127.0.0.1:${port}`,
{ scriptName, hostname, port, terminalId: terminal.id, type: serviceScript ? "service" : "script" },
serviceScript
? `Registered script proxy: ${hostname} -> 127.0.0.1:${port}`
: "Started workspace script",
);
onLifecycleChanged?.();
return {
scriptName,
hostname: resolvedHostname,
hostname,
port,
terminalId: terminal.id,
};
@@ -861,6 +874,9 @@ export async function spawnWorkspaceScript(
if (hostname && port !== null) {
routeStore.removeRoute(hostname);
}
if (runtimeRegistered) {
runtimeStore.remove({ workspaceId, scriptName });
}
logger?.error(
{
err: error,
@@ -883,6 +899,7 @@ export async function spawnWorktreeScripts(options: {
branchName: string | null;
daemonPort?: number | null;
routeStore: ScriptRouteStore;
runtimeStore: WorkspaceScriptRuntimeStore;
terminalManager: TerminalManager;
logger?: Logger;
onLifecycleChanged?: () => void;
@@ -102,7 +102,7 @@ describe("createPaseoWorktreeInBackground", () => {
}
});
test("emits running then completed snapshots for no-setup workspaces and then launches scripts", async () => {
test("emits running then completed snapshots for no-setup workspaces without auto-starting scripts", async () => {
const { tempDir, repoDir } = createGitRepo({
paseoConfig: {
scripts: {
@@ -126,7 +126,6 @@ describe("createPaseoWorktreeInBackground", () => {
const worktreePath = createdWorktree.worktreePath;
const emitted: SessionOutboundMessage[] = [];
const snapshots = new Map<string, unknown>();
const routeStore = new ScriptRouteStore();
const logger = createLogger();
const terminalManager = createTerminalManagerStub();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
@@ -141,8 +140,6 @@ describe("createPaseoWorktreeInBackground", () => {
sessionLogger: logger,
terminalManager: terminalManager.manager,
archiveWorkspaceRecord,
scriptRouteStore: routeStore,
daemonPort: 6767,
},
{
requestCwd: repoDir,
@@ -197,19 +194,7 @@ describe("createPaseoWorktreeInBackground", () => {
},
});
expect(routeStore.listRoutes()).toEqual([
{
hostname: "feature-no-setup.web.localhost",
port: expect.any(Number),
workspaceId: worktreePath,
scriptName: "web",
},
]);
expect(terminalManager.terminals).toHaveLength(1);
expect(terminalManager.terminals[0]?.cwd).toBe(worktreePath);
expect(terminalManager.terminals[0]?.env?.PORT).toEqual(expect.any(String));
expect(terminalManager.terminals[0]?.env?.PASEO_SCRIPT_URL).toBeDefined();
expect(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]);
expect(terminalManager.terminals).toHaveLength(0);
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
});
@@ -251,8 +236,6 @@ describe("createPaseoWorktreeInBackground", () => {
sessionLogger: logger,
terminalManager: null,
archiveWorkspaceRecord,
scriptRouteStore: null,
daemonPort: null,
},
{
requestCwd: repoDir,
@@ -319,8 +302,6 @@ describe("createPaseoWorktreeInBackground", () => {
sessionLogger: logger,
terminalManager: null,
archiveWorkspaceRecord,
scriptRouteStore: null,
daemonPort: null,
},
{
requestCwd: repoDir,
@@ -394,7 +375,7 @@ describe("createPaseoWorktreeInBackground", () => {
});
});
test("emits completed when reusing an existing worktree without bootstrapping", async () => {
test("emits completed when reusing an existing worktree without bootstrapping or auto-starting scripts", async () => {
const { tempDir, repoDir } = createGitRepo({
paseoConfig: {
worktree: {
@@ -421,7 +402,6 @@ describe("createPaseoWorktreeInBackground", () => {
const emitted: SessionOutboundMessage[] = [];
const snapshots = new Map<string, unknown>();
const routeStore = new ScriptRouteStore();
const logger = createLogger();
const terminalManager = createTerminalManagerStub();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
@@ -436,8 +416,6 @@ describe("createPaseoWorktreeInBackground", () => {
sessionLogger: logger,
terminalManager: terminalManager.manager,
archiveWorkspaceRecord,
scriptRouteStore: routeStore,
daemonPort: 6767,
},
{
requestCwd: repoDir,
@@ -473,20 +451,7 @@ describe("createPaseoWorktreeInBackground", () => {
commands: [],
},
});
expect(routeStore.listRoutes()).toEqual([
{
hostname: "reused-worktree.web.localhost",
port: expect.any(Number),
workspaceId: existingWorktree.worktreePath,
scriptName: "web",
},
]);
expect(terminalManager.terminals).toHaveLength(1);
expect(terminalManager.terminals[0]?.cwd).toBe(existingWorktree.worktreePath);
expect(terminalManager.terminals[0]?.name).toBe("web");
expect(terminalManager.terminals[0]?.env?.PORT).toEqual(expect.any(String));
expect(terminalManager.terminals[0]?.env?.PASEO_SCRIPT_URL).toBeDefined();
expect(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]);
expect(terminalManager.terminals).toHaveLength(0);
expect(
readFileSync(path.join(existingWorktree.worktreePath, "README.md"), "utf8"),
).toContain("hello");
@@ -499,7 +464,7 @@ describe("createPaseoWorktreeInBackground", () => {
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(existingWorktree.worktreePath);
});
test("keeps setup completed when service launch fails afterward", async () => {
test("keeps setup completed without attempting script launch afterward", async () => {
const { tempDir, repoDir } = createGitRepo({
paseoConfig: {
scripts: {
@@ -523,7 +488,6 @@ describe("createPaseoWorktreeInBackground", () => {
const worktreePath = createdWorktree.worktreePath;
const emitted: SessionOutboundMessage[] = [];
const snapshots = new Map<string, unknown>();
const routeStore = new ScriptRouteStore();
const logger = createLogger();
const terminalManager = createTerminalManagerStub({
createTerminal: async () => {
@@ -542,8 +506,6 @@ describe("createPaseoWorktreeInBackground", () => {
sessionLogger: logger,
terminalManager: terminalManager.manager,
archiveWorkspaceRecord,
scriptRouteStore: routeStore,
daemonPort: 6767,
},
{
requestCwd: repoDir,
@@ -566,17 +528,17 @@ describe("createPaseoWorktreeInBackground", () => {
expect(progressMessages[0]?.payload.error).toBeNull();
expect(progressMessages[1]?.payload.status).toBe("completed");
expect(progressMessages[1]?.payload.error).toBeNull();
expect(emitted.some((message) => message.type === "workspace_setup_progress" && message.payload.status === "failed")).toBe(false);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
cwd: repoDir,
repoRoot: repoDir,
worktreeSlug: "feature-service-failure",
worktreePath,
}),
expect(
emitted.some(
(message) =>
message.type === "workspace_setup_progress" && message.payload.status === "failed",
),
).toBe(false);
expect(logger.error).not.toHaveBeenCalledWith(
expect.anything(),
"Failed to spawn worktree scripts after workspace setup completed",
);
expect(terminalManager.terminals).toHaveLength(0);
expect(snapshots.get("45")).toMatchObject({
status: "completed",
error: null,
@@ -585,7 +547,7 @@ describe("createPaseoWorktreeInBackground", () => {
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
});
test("launches scripts in socket mode without requiring a daemon TCP port", async () => {
test("does not auto-start scripts in socket mode", async () => {
const { tempDir, repoDir } = createGitRepo({
paseoConfig: {
scripts: {
@@ -609,7 +571,6 @@ describe("createPaseoWorktreeInBackground", () => {
const worktreePath = createdWorktree.worktreePath;
const emitted: SessionOutboundMessage[] = [];
const snapshots = new Map<string, unknown>();
const routeStore = new ScriptRouteStore();
const logger = createLogger();
const terminalManager = createTerminalManagerStub();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
@@ -624,8 +585,6 @@ describe("createPaseoWorktreeInBackground", () => {
sessionLogger: logger,
terminalManager: terminalManager.manager,
archiveWorkspaceRecord,
scriptRouteStore: routeStore,
daemonPort: null,
},
{
requestCwd: repoDir,
@@ -639,19 +598,7 @@ describe("createPaseoWorktreeInBackground", () => {
},
);
expect(routeStore.listRoutes()).toEqual([
{
hostname: "feature-socket-mode.web.localhost",
port: expect.any(Number),
workspaceId: worktreePath,
scriptName: "web",
},
]);
expect(terminalManager.terminals).toHaveLength(1);
expect(terminalManager.terminals[0]?.cwd).toBe(worktreePath);
expect(terminalManager.terminals[0]?.env?.PORT).toEqual(expect.any(String));
expect(terminalManager.terminals[0]?.env?.PASEO_SCRIPT_URL).toBeUndefined();
expect(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]);
expect(terminalManager.terminals).toHaveLength(0);
expect(snapshots.get("46")).toMatchObject({
status: "completed",
error: null,
@@ -26,10 +26,8 @@ import {
createAgentWorktree,
createWorktreeSetupProgressAccumulator,
getWorktreeSetupProgressResults,
spawnWorktreeScripts,
} from "./worktree-bootstrap.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { ScriptRouteStore } from "./script-proxy.js";
import {
getCheckoutStatusLite,
resolveRepositoryDefaultBranch,
@@ -110,8 +108,6 @@ type CreatePaseoWorktreeInBackgroundDependencies = {
sessionLogger: Logger;
terminalManager: TerminalManager | null;
archiveWorkspaceRecord: (workspaceId: number) => Promise<void>;
scriptRouteStore: ScriptRouteStore | null;
daemonPort?: number | null;
};
type HandleWorkspaceSetupStatusRequestDependencies = {
@@ -765,35 +761,6 @@ export async function createPaseoWorktreeInBackground(
return;
}
if (!dependencies.terminalManager || !dependencies.scriptRouteStore) {
return;
}
try {
await spawnWorktreeScripts({
repoRoot: worktree.worktreePath,
workspaceId: worktree.worktreePath,
branchName: worktree.branchName,
daemonPort: dependencies.daemonPort,
routeStore: dependencies.scriptRouteStore,
terminalManager: dependencies.terminalManager,
logger: dependencies.sessionLogger,
onLifecycleChanged: () => {
void dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath);
},
});
} catch (error) {
dependencies.sessionLogger.error(
{
err: error,
cwd: options.requestCwd,
repoRoot: options.repoRoot,
worktreeSlug: worktree.branchName,
worktreePath: worktree.worktreePath,
},
"Failed to spawn worktree scripts after workspace setup completed",
);
}
} finally {
await dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath);
}
+2
View File
@@ -1820,11 +1820,13 @@ export const WorkspaceScriptHealthSchema = z.enum(["healthy", "unhealthy"]);
export const WorkspaceScriptPayloadSchema = z.object({
scriptName: z.string(),
type: z.enum(["script", "service"]).optional().default("service"),
hostname: z.string(),
port: z.number().int().positive().nullable(),
url: z.string().nullable(),
lifecycle: WorkspaceScriptLifecycleSchema,
health: WorkspaceScriptHealthSchema.nullable(),
exitCode: z.number().nullable().optional().default(null),
});
export const WorkspaceDescriptorPayloadSchema = z.object({
@@ -155,11 +155,13 @@ describe("workspace message schemas", () => {
expect(parsed.payload.workspace.scripts).toEqual([
{
scriptName: "web",
type: "service",
hostname: "web.localhost",
port: 3000,
url: "http://web.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
},
]);
});
@@ -213,6 +215,10 @@ describe("workspace message schemas", () => {
expect(parsed.type).toBe("script_status_update");
expect(parsed.payload.workspaceId).toBe("/repo");
expect(parsed.payload.scripts[0]).toMatchObject({
type: "service",
exitCode: null,
});
});
test("parses workspace_setup_progress payload", () => {
@@ -3,7 +3,9 @@ import {
createWorktree,
deriveWorktreeProjectHash,
deletePaseoWorktree,
getScriptConfigs,
getWorktreeTerminalSpecs,
isServiceScript,
isPaseoOwnedWorktreeCwd,
listPaseoWorktrees,
resolveWorktreeRuntimeEnv,
@@ -483,6 +485,91 @@ describe("createWorktree", () => {
{ command: "npm run test" },
]);
});
it("parses omitted script type as a plain script", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
scripts: {
typecheck: {
command: " npm run typecheck ",
},
},
}),
);
const scriptConfigs = getScriptConfigs(repoDir);
const typecheck = scriptConfigs.get("typecheck");
expect(typecheck).toEqual({
command: "npm run typecheck",
});
expect(typecheck).toBeDefined();
expect(isServiceScript(typecheck!)).toBe(false);
});
it("parses service scripts and preserves optional port", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
scripts: {
server: {
type: "service",
command: "npm run dev",
port: 4321,
},
},
}),
);
const scriptConfigs = getScriptConfigs(repoDir);
const server = scriptConfigs.get("server");
expect(server).toEqual({
type: "service",
command: "npm run dev",
port: 4321,
});
expect(server).toBeDefined();
expect(isServiceScript(server!)).toBe(true);
});
it("ignores invalid script entries gracefully", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
scripts: {
valid: {
command: "npm run valid",
},
invalidType: {
type: "worker",
command: "npm run worker",
},
missingCommand: {
type: "service",
},
blankCommand: {
command: " ",
},
nonObject: "npm run nope",
invalidPort: {
type: "service",
command: "npm run dev",
port: "3000",
},
},
}),
);
expect(getScriptConfigs(repoDir)).toEqual(
new Map([
["valid", { command: "npm run valid" }],
["invalidType", { command: "npm run worker" }],
["invalidPort", { type: "service", command: "npm run dev" }],
]),
);
});
});
describe("paseo worktree manager", () => {
+30 -4
View File
@@ -24,7 +24,14 @@ interface PaseoConfig {
teardown?: string[];
terminals?: WorktreeTerminalConfig[];
};
scripts?: Record<string, ScriptConfig>;
scripts?: Record<
string,
{
type?: unknown;
command?: unknown;
port?: unknown;
}
>;
}
const execAsync = promisify(exec);
@@ -90,11 +97,24 @@ export interface WorktreeTerminalConfig {
command: string;
}
export interface ScriptConfig {
export interface PlainScriptConfig {
type?: undefined;
command: string;
port?: undefined;
}
export interface ServiceScriptConfig {
type: "service";
command: string;
port?: number; // explicit port override, otherwise auto-assigned
}
export type ScriptConfig = PlainScriptConfig | ServiceScriptConfig;
export function isServiceScript(config: ScriptConfig): config is ServiceScriptConfig {
return "type" in config && config.type === "service";
}
export class WorktreeSetupError extends Error {
readonly results: WorktreeSetupCommandResult[];
@@ -227,9 +247,15 @@ export function getScriptConfigs(repoRoot: string): Map<string, ScriptConfig> {
continue;
}
const scriptConfig: ScriptConfig = { command };
const scriptConfig: ScriptConfig =
entry.type === "service"
? {
type: "service",
command,
}
: { command };
if (typeof entry.port === "number" && Number.isFinite(entry.port)) {
if (isServiceScript(scriptConfig) && typeof entry.port === "number" && Number.isFinite(entry.port)) {
scriptConfig.port = entry.port;
}
+1
View File
@@ -8,6 +8,7 @@
},
"scripts": {
"server": {
"type": "service",
"command": "npm run dev"
},
"typecheck": {