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"> <View style={styles.sectionList} testID="hover-card-script-list">
{workspace.scripts.map((script) => { {workspace.scripts.map((script) => {
const isRunning = script.lifecycle === "running"; 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 ( return (
<Pressable <Pressable
key={script.hostname} key={script.hostname}
accessibilityRole={isLinkable ? "link" : undefined} accessibilityRole={isLinkable ? "link" : undefined}
accessibilityLabel={`${script.scriptName} script — ${isRunning ? getScriptHealthLabel(script.health) : "Stopped"}`} accessibilityLabel={`${script.scriptName} script — ${accessibilityStatus}`}
testID={`hover-card-script-${script.scriptName}`} testID={`hover-card-script-${script.scriptName}`}
style={({ hovered }) => [ style={({ hovered }) => [
styles.listRow, styles.listRow,
@@ -375,11 +406,7 @@ function WorkspaceHoverCardContent({
testID={`hover-card-script-health-${script.scriptName}`} testID={`hover-card-script-health-${script.scriptName}`}
style={[ style={[
styles.statusDot, styles.statusDot,
{ { backgroundColor: dotColor },
backgroundColor: isRunning
? getScriptHealthColor({ health: script.health, theme })
: theme.colors.foregroundMuted,
},
]} ]}
/> />
<Text <Text
@@ -395,15 +422,19 @@ function WorkspaceHoverCardContent({
> >
{script.scriptName} {script.scriptName}
</Text> </Text>
{isRunning && script.url ? ( {isService && isRunning && script.url ? (
<Text style={styles.listRowSecondary} numberOfLines={1}> <Text style={styles.listRowSecondary} numberOfLines={1}>
{script.url.replace(/^https?:\/\//, "")} {script.url.replace(/^https?:\/\//, "")}
</Text> </Text>
) : !isService && !isRunning && exitCode !== null && exitCode !== 0 ? (
<Text style={styles.listRowSecondary} numberOfLines={1}>
exit {exitCode}
</Text>
) : ( ) : (
<View style={styles.listRowSpacer} /> <View style={styles.listRowSpacer} />
)} )}
{isRunning ? ( {isRunning ? (
script.url && hovered ? ( isLinkable && hovered ? (
<View <View
style={[ style={[
styles.externalLinkOverlay, styles.externalLinkOverlay,
@@ -25,11 +25,13 @@ function workspace(input: {
const runningScript: WorkspaceScriptPayload = { const runningScript: WorkspaceScriptPayload = {
scriptName: "web", scriptName: "web",
type: "service",
hostname: "main.web.localhost", hostname: "main.web.localhost",
port: 3000, port: 3000,
url: "http://main.web.localhost:6767", url: "http://main.web.localhost:6767",
lifecycle: "running", lifecycle: "running",
health: "healthy", health: "healthy",
exitCode: null,
}; };
describe("patchWorkspaceScripts", () => { describe("patchWorkspaceScripts", () => {
@@ -47,20 +47,24 @@ function workspace(
const runningScript: WorkspaceScriptPayload = { const runningScript: WorkspaceScriptPayload = {
scriptName: "web", scriptName: "web",
type: "service",
hostname: "main.web.localhost", hostname: "main.web.localhost",
port: 3000, port: 3000,
url: "http://main.web.localhost:6767", url: "http://main.web.localhost:6767",
lifecycle: "running", lifecycle: "running",
health: "healthy", health: "healthy",
exitCode: null,
}; };
const stoppedScript: WorkspaceScriptPayload = { const stoppedScript: WorkspaceScriptPayload = {
scriptName: "api", scriptName: "api",
type: "service",
hostname: "main.api.localhost", hostname: "main.api.localhost",
port: 3001, port: 3001,
url: "http://main.api.localhost:6767", url: "http://main.api.localhost:6767",
lifecycle: "stopped", lifecycle: "stopped",
health: null, health: null,
exitCode: null,
}; };
describe("applyStoredOrdering", () => { describe("applyStoredOrdering", () => {
@@ -104,7 +104,24 @@ export function WorkspaceScriptsButton({
<View style={styles.scriptList}> <View style={styles.scriptList}>
{scripts.map((script) => { {scripts.map((script) => {
const isRunning = script.lifecycle === "running"; 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 ( return (
<Pressable <Pressable
@@ -124,11 +141,7 @@ export function WorkspaceScriptsButton({
<View <View
style={[ style={[
styles.statusDot, styles.statusDot,
{ { backgroundColor: dotColor },
backgroundColor: isRunning
? getScriptHealthColor(script.health, theme)
: theme.colors.foregroundMuted,
},
]} ]}
/> />
<Text <Text
@@ -144,15 +157,19 @@ export function WorkspaceScriptsButton({
> >
{script.scriptName} {script.scriptName}
</Text> </Text>
{isRunning && script.url ? ( {isService && isRunning && script.url ? (
<Text style={styles.scriptUrl} numberOfLines={1}> <Text style={styles.scriptUrl} numberOfLines={1}>
{script.url.replace(/^https?:\/\//, "")} {script.url.replace(/^https?:\/\//, "")}
</Text> </Text>
) : !isService && !isRunning && exitCode !== null && exitCode !== 0 ? (
<Text style={styles.scriptUrl} numberOfLines={1}>
exit {exitCode}
</Text>
) : ( ) : (
<View style={styles.spacer} /> <View style={styles.spacer} />
)} )}
{isRunning ? ( {isRunning ? (
script.url && hovered ? ( isLinkable && hovered ? (
<View <View
style={[ style={[
styles.externalLinkOverlay, styles.externalLinkOverlay,
@@ -38,11 +38,13 @@ describe("normalizeWorkspaceDescriptor", () => {
const scripts = [ const scripts = [
{ {
scriptName: "web", scriptName: "web",
type: "service" as const,
hostname: "main.web.localhost", hostname: "main.web.localhost",
port: 3000, port: 3000,
url: "http://main.web.localhost:6767", url: "http://main.web.localhost:6767",
lifecycle: "running" as const, lifecycle: "running" as const,
health: "healthy" as const, health: "healthy" as const,
exitCode: null,
}, },
]; ];
const workspace = normalizeWorkspaceDescriptor({ const workspace = normalizeWorkspaceDescriptor({
@@ -64,11 +66,13 @@ describe("normalizeWorkspaceDescriptor", () => {
expect(workspace.scripts).toEqual([ expect(workspace.scripts).toEqual([
{ {
scriptName: "web", scriptName: "web",
type: "service",
hostname: "main.web.localhost", hostname: "main.web.localhost",
port: 3000, port: 3000,
url: "http://main.web.localhost:6767", url: "http://main.web.localhost:6767",
lifecycle: "running", lifecycle: "running",
health: "healthy", health: "healthy",
exitCode: null,
}, },
]); ]);
expect(workspace.scripts).not.toBe(scripts); expect(workspace.scripts).not.toBe(scripts);
@@ -111,11 +115,13 @@ describe("mergeWorkspaces", () => {
scripts: [ scripts: [
{ {
scriptName: "web", scriptName: "web",
type: "service",
hostname: "main.web.localhost", hostname: "main.web.localhost",
port: 3000, port: 3000,
url: "http://main.web.localhost:6767", url: "http://main.web.localhost:6767",
lifecycle: "running", lifecycle: "running",
health: "healthy", health: "healthy",
exitCode: null,
}, },
], ],
}), }),
@@ -124,11 +130,13 @@ describe("mergeWorkspaces", () => {
expect(store.getSession("test-server")?.workspaces.get("/repo/main")?.scripts).toEqual([ expect(store.getSession("test-server")?.workspaces.get("/repo/main")?.scripts).toEqual([
{ {
scriptName: "web", scriptName: "web",
type: "service",
hostname: "main.web.localhost", hostname: "main.web.localhost",
port: 3000, port: 3000,
url: "http://main.web.localhost:6767", url: "http://main.web.localhost:6767",
lifecycle: "running", lifecycle: "running",
health: "healthy", health: "healthy",
exitCode: null,
}, },
]); ]);
}); });
@@ -48,13 +48,11 @@ import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import { expandUserPath } from "../path-utils.js"; import { expandUserPath } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js"; import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
import type { ScriptRouteStore } from "../script-proxy.js";
export interface AgentManagementMcpOptions { export interface AgentManagementMcpOptions {
agentManager: AgentManager; agentManager: AgentManager;
agentStorage: AgentSnapshotStore; agentStorage: AgentSnapshotStore;
terminalManager?: TerminalManager | null; terminalManager?: TerminalManager | null;
scriptRouteStore?: ScriptRouteStore;
getDaemonTcpPort?: () => number | null; getDaemonTcpPort?: () => number | null;
paseoHome?: string; paseoHome?: string;
logger: Logger; logger: Logger;
@@ -350,8 +348,6 @@ export async function createAgentManagementMcpServer(
agentId: snapshot.id, agentId: snapshot.id,
item, item,
}), }),
scriptRouteStore: options.scriptRouteStore,
daemonPort: options.getDaemonTcpPort?.() ?? null,
logger: childLogger, logger: childLogger,
}); });
} }
@@ -28,13 +28,11 @@ import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
import { expandUserPath, resolvePathFromBase } from "../path-utils.js"; import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js"; import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
import type { ScriptRouteStore } from "../script-proxy.js";
export interface AgentMcpServerOptions { export interface AgentMcpServerOptions {
agentManager: AgentManager; agentManager: AgentManager;
agentStorage: AgentSnapshotStore; agentStorage: AgentSnapshotStore;
terminalManager?: TerminalManager | null; terminalManager?: TerminalManager | null;
scriptRouteStore?: ScriptRouteStore;
getDaemonTcpPort?: () => number | null; getDaemonTcpPort?: () => number | null;
paseoHome?: string; paseoHome?: string;
/** /**
@@ -519,8 +517,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
agentId: snapshot.id, agentId: snapshot.id,
item, item,
}), }),
scriptRouteStore: options.scriptRouteStore,
daemonPort: options.getDaemonTcpPort?.() ?? null,
logger: childLogger, logger: childLogger,
}); });
} }
+8 -12
View File
@@ -125,6 +125,7 @@ import {
} from "./script-proxy.js"; } from "./script-proxy.js";
import { ScriptHealthMonitor } from "./script-health-monitor.js"; import { ScriptHealthMonitor } from "./script-health-monitor.js";
import { createScriptStatusEmitter } from "./script-status-projection.js"; import { createScriptStatusEmitter } from "./script-status-projection.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import { import {
createVoiceMcpSocketBridgeManager, createVoiceMcpSocketBridgeManager,
type VoiceMcpSocketBridgeManager, type VoiceMcpSocketBridgeManager,
@@ -209,6 +210,7 @@ export interface PaseoDaemon {
agentStorage: AgentSnapshotStore; agentStorage: AgentSnapshotStore;
terminalManager: TerminalManager; terminalManager: TerminalManager;
scriptRouteStore: ScriptRouteStore; scriptRouteStore: ScriptRouteStore;
scriptRuntimeStore: WorkspaceScriptRuntimeStore;
start(): Promise<void>; start(): Promise<void>;
stop(): Promise<void>; stop(): Promise<void>;
getListenTarget(): ListenTarget | null; getListenTarget(): ListenTarget | null;
@@ -240,6 +242,7 @@ export async function createPaseoDaemon(
let boundListenTarget: ListenTarget | null = null; let boundListenTarget: ListenTarget | null = null;
const scriptRouteStore = new ScriptRouteStore(); const scriptRouteStore = new ScriptRouteStore();
const scriptRuntimeStore = new WorkspaceScriptRuntimeStore();
let wsServer: VoiceAssistantWebSocketServer | null = null; let wsServer: VoiceAssistantWebSocketServer | null = null;
const scriptHealthMonitor = new ScriptHealthMonitor({ const scriptHealthMonitor = new ScriptHealthMonitor({
routeStore: scriptRouteStore, routeStore: scriptRouteStore,
@@ -249,20 +252,14 @@ export async function createPaseoDaemon(
emit: (message) => session.emitServerMessage(message), emit: (message) => session.emitServerMessage(message),
})) ?? [], })) ?? [],
routeStore: scriptRouteStore, routeStore: scriptRouteStore,
runtimeStore: scriptRuntimeStore,
daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
}), }),
}); });
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore: scriptRouteStore, routeStore: scriptRouteStore,
emitScriptStatusUpdate: (workspaceId, scripts) => { onRoutesChanged: (workspaceId) => {
const message = { scriptHealthMonitor.invalidateWorkspace(workspaceId);
type: "script_status_update" as const,
payload: { workspaceId, scripts },
};
const activeSessions = wsServer?.listActiveSessions() ?? [];
for (const session of activeSessions) {
session.emitServerMessage(message);
}
}, },
logger, logger,
}); });
@@ -492,7 +489,6 @@ export async function createPaseoDaemon(
agentManager, agentManager,
agentStorage, agentStorage,
terminalManager, terminalManager,
scriptRouteStore,
getDaemonTcpPort: () => getDaemonTcpPort: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null, boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
paseoHome: config.paseoHome, paseoHome: config.paseoHome,
@@ -521,7 +517,6 @@ export async function createPaseoDaemon(
agentManager, agentManager,
agentStorage, agentStorage,
terminalManager, terminalManager,
scriptRouteStore,
getDaemonTcpPort: () => getDaemonTcpPort: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null, boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
paseoHome: config.paseoHome, paseoHome: config.paseoHome,
@@ -644,7 +639,6 @@ export async function createPaseoDaemon(
agentManager, agentManager,
agentStorage, agentStorage,
terminalManager, terminalManager,
scriptRouteStore,
getDaemonTcpPort: () => getDaemonTcpPort: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null, boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
paseoHome: config.paseoHome, paseoHome: config.paseoHome,
@@ -711,6 +705,7 @@ export async function createPaseoDaemon(
scheduleService, scheduleService,
checkoutDiffManager, checkoutDiffManager,
scriptRouteStore, scriptRouteStore,
scriptRuntimeStore,
handleBranchChange, handleBranchChange,
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
(hostname) => scriptHealthMonitor.getHealthForHostname(hostname), (hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
@@ -841,6 +836,7 @@ export async function createPaseoDaemon(
agentStorage, agentStorage,
terminalManager, terminalManager,
scriptRouteStore, scriptRouteStore,
scriptRuntimeStore,
start, start,
stop, stop,
getListenTarget: () => boundListenTarget, 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 net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { scheduler } from "node:timers/promises"; import { scheduler } from "node:timers/promises";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { findFreePort, ScriptRouteStore } from "./script-proxy.js"; import { findFreePort, ScriptRouteStore } from "./script-proxy.js";
@@ -6,12 +10,89 @@ import {
ScriptHealthMonitor, ScriptHealthMonitor,
type ScriptHealthEntry, type ScriptHealthEntry,
} from "./script-health-monitor.js"; } from "./script-health-monitor.js";
import { spawnWorktreeScripts } from "./worktree-bootstrap.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
type TcpServerHandle = { type TcpServerHandle = {
port: number; port: number;
server: net.Server; 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> { async function startTcpServer(): Promise<TcpServerHandle> {
const server = net.createServer((socket) => { const server = net.createServer((socket) => {
socket.end(); socket.end();
@@ -68,13 +149,23 @@ describe("ScriptHealthMonitor", () => {
servers.clear(); 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(); vi.useFakeTimers();
const healthy = await startTcpServer(); const healthy = await startTcpServer();
servers.add(healthy.server); servers.add(healthy.server);
const routeStore = new ScriptRouteStore(); 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({ routeStore.registerRoute({
hostname: "api.localhost", hostname: "api.localhost",
port: healthy.port, port: healthy.port,
@@ -82,19 +173,16 @@ describe("ScriptHealthMonitor", () => {
scriptName: "api", scriptName: "api",
}); });
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); expect(monitor.getHealthForHostname("api.localhost")).toBe("pending");
const monitor = new ScriptHealthMonitor({
routeStore, await advancePoll(4_000);
onChange, expect(monitor.getHealthForHostname("api.localhost")).toBe("pending");
pollIntervalMs: 1_000, expect(onChange).not.toHaveBeenCalled();
probeTimeoutMs: 100,
graceMs: 0,
});
monitor.start();
await advancePoll(1_000); await advancePoll(1_000);
monitor.stop(); monitor.stop();
expect(monitor.getHealthForHostname("api.localhost")).toBe("healthy");
expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("workspace-a", [ 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(); vi.useFakeTimers();
const deadPort = await findFreePort(); const deadPort = await findFreePort();
@@ -124,17 +212,21 @@ describe("ScriptHealthMonitor", () => {
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
probeTimeoutMs: 100, probeTimeoutMs: 100,
graceMs: 0, graceMs: 2_000,
failuresBeforeStopped: 2, failuresBeforeStopped: 2,
}); });
expect(monitor.getHealthForHostname("api.localhost")).toBe("pending");
monitor.start(); monitor.start();
await advancePoll(1_000); await advancePoll(2_000);
expect(monitor.getHealthForHostname("api.localhost")).toBe("pending");
expect(onChange).not.toHaveBeenCalled(); expect(onChange).not.toHaveBeenCalled();
await advancePoll(1_000); await advancePoll(1_000);
monitor.stop(); monitor.stop();
expect(monitor.getHealthForHostname("api.localhost")).toBe("unhealthy");
expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("workspace-a", [ expect(onChange).toHaveBeenCalledWith("workspace-a", [
{ {
@@ -176,48 +268,7 @@ describe("ScriptHealthMonitor", () => {
expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledTimes(1);
}); });
it("respects startup grace period — does not probe newly registered routes for 5 seconds", async () => { it("requires 2 consecutive failures before marking a previously healthy service unhealthy", 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 () => {
vi.useFakeTimers(); vi.useFakeTimers();
const healthy = await startTcpServer(); const healthy = await startTcpServer();
@@ -249,11 +300,13 @@ describe("ScriptHealthMonitor", () => {
servers.delete(healthy.server); servers.delete(healthy.server);
await advancePoll(1_000); await advancePoll(1_000);
expect(monitor.getHealthForHostname("api.localhost")).toBe("healthy");
expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledTimes(1);
await advancePoll(1_000); await advancePoll(1_000);
monitor.stop(); monitor.stop();
expect(monitor.getHealthForHostname("api.localhost")).toBe("unhealthy");
expect(onChange).toHaveBeenCalledTimes(2); expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenLastCalledWith("workspace-a", [ 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(); vi.useFakeTimers();
const healthy = await startTcpServer(); const healthy = await startTcpServer();
@@ -291,6 +344,7 @@ describe("ScriptHealthMonitor", () => {
monitor.start(); monitor.start();
await advancePoll(1_000); await advancePoll(1_000);
expect(monitor.getHealthForHostname("api.localhost")).toBe("healthy");
expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledTimes(1);
routeStore.removeRoute("api.localhost"); routeStore.removeRoute("api.localhost");
@@ -300,10 +354,11 @@ describe("ScriptHealthMonitor", () => {
await advancePoll(3_000); await advancePoll(3_000);
monitor.stop(); monitor.stop();
expect(monitor.getHealthForHostname("api.localhost")).toBeNull();
expect(onChange).toHaveBeenCalledTimes(1); 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(); vi.useFakeTimers();
const api = await startTcpServer(); 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(); vi.useFakeTimers();
const healthy = await startTcpServer(); const service = await startTcpServer();
servers.add(healthy.server); servers.add(service.server);
const routeStore = new ScriptRouteStore(); const workspace = createWorkspaceRepo({
routeStore.registerRoute({ paseoConfig: {
hostname: "api.localhost", scripts: {
port: healthy.port, typecheck: { command: "npm run typecheck" },
workspaceId: "workspace-a", api: { type: "service", command: "npm run api", port: service.port },
scriptName: "api", },
},
}); });
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 onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({ const monitor = new ScriptHealthMonitor({
@@ -378,17 +459,26 @@ describe("ScriptHealthMonitor", () => {
graceMs: 0, graceMs: 0,
}); });
expect(monitor.getHealthForHostname("api.localhost")).toBeNull();
monitor.start(); monitor.start();
await advancePoll(1_000); await advancePoll(1_000);
monitor.stop(); monitor.stop();
expect(monitor.getHealthForHostname("api.localhost")).toBe("healthy"); expect(onChange).toHaveBeenCalledTimes(1);
expect(monitor.getHealthForHostname("unknown.localhost")).toBeNull(); 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(); vi.useFakeTimers();
const api = await startTcpServer(); const api = await startTcpServer();
@@ -1,15 +1,18 @@
import net from "node:net"; import net from "node:net";
import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js"; import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js";
export type ScriptHealthState = "pending" | "healthy" | "unhealthy";
export interface ScriptHealthEntry { export interface ScriptHealthEntry {
scriptName: string; scriptName: string;
hostname: string; hostname: string;
port: number; port: number;
health: "healthy" | "unhealthy"; health: ScriptHealthState;
} }
type RouteHealthState = { type RouteHealthState = {
health: ScriptHealthEntry["health"] | null; workspaceId: string;
health: ScriptHealthState;
consecutiveFailures: number; consecutiveFailures: number;
registeredAt: number; registeredAt: number;
}; };
@@ -60,7 +63,7 @@ export class ScriptHealthMonitor {
const now = Date.now(); const now = Date.now();
for (const route of this.routeStore.listRoutes()) { for (const route of this.routeStore.listRoutes()) {
this.getOrCreateState(route.hostname, now); this.getOrCreateState(route, now);
} }
this.intervalHandle = setInterval(() => { 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> { private async poll(): Promise<void> {
if (this.pollInFlight) { if (this.pollInFlight) {
return; return;
@@ -88,7 +102,7 @@ export class ScriptHealthMonitor {
const now = Date.now(); const now = Date.now();
for (const route of routes) { for (const route of routes) {
const state = this.getOrCreateState(route.hostname, now); const state = this.getOrCreateState(route, now);
if (now - state.registeredAt < this.graceMs) { if (now - state.registeredAt < this.graceMs) {
continue; continue;
} }
@@ -106,7 +120,7 @@ export class ScriptHealthMonitor {
} }
} }
if (state.health !== null && state.health !== previousHealth) { if (state.health !== previousHealth) {
changedWorkspaceIds.add(route.workspaceId); changedWorkspaceIds.add(route.workspaceId);
} }
} }
@@ -128,27 +142,32 @@ export class ScriptHealthMonitor {
} }
} }
private getOrCreateState(hostname: string, registeredAt: number): RouteHealthState { private getOrCreateState(
const existing = this.routeStates.get(hostname); route: Pick<ScriptRouteEntry, "hostname" | "workspaceId">,
registeredAt: number,
): RouteHealthState {
const existing = this.routeStates.get(route.hostname);
if (existing) { if (existing) {
return existing; return existing;
} }
const state: RouteHealthState = { const state: RouteHealthState = {
health: null, workspaceId: route.workspaceId,
health: "pending",
consecutiveFailures: 0, consecutiveFailures: 0,
registeredAt, registeredAt,
}; };
this.routeStates.set(hostname, state); this.routeStates.set(route.hostname, state);
return state; return state;
} }
private pruneRemovedRoutes(activeHostnames: Set<string>): void { 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)) { if (activeHostnames.has(hostname)) {
continue; continue;
} }
this.routeStates.delete(hostname); this.routeStates.delete(hostname);
this.lastEmittedSnapshots.delete(state.workspaceId);
} }
} }
@@ -157,15 +176,25 @@ export class ScriptHealthMonitor {
.listRoutesForWorkspace(workspaceId) .listRoutesForWorkspace(workspaceId)
.flatMap((route) => { .flatMap((route) => {
const state = this.routeStates.get(route.hostname); const state = this.routeStates.get(route.hostname);
if (!state?.health) { if (!state) {
return []; return [];
} }
return [this.toScriptHealthEntry(route, state.health)]; return [this.toScriptHealthEntry(route, state.health)];
}); });
} }
getHealthForHostname(hostname: string): ScriptHealthEntry["health"] | null { getHealthForHostname(hostname: string): ScriptHealthState | null {
return this.routeStates.get(hostname)?.health ?? 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( 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 { describe, expect, it, vi } from "vitest";
import { ScriptRouteStore } from "./script-proxy.js"; import { ScriptRouteStore } from "./script-proxy.js";
import { createBranchChangeRouteHandler } from "./script-route-branch-handler.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( function registerRoute(
routeStore: ScriptRouteStore, routeStore: ScriptRouteStore,
{ {
@@ -33,10 +63,10 @@ describe("script-route-branch-handler", () => {
scriptName: "api", scriptName: "api",
}); });
const emitScriptStatusUpdate = vi.fn(); const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, routeStore,
emitScriptStatusUpdate, onRoutesChanged,
}); });
handleBranchChange("workspace-a", "feature/auth", "feature/billing"); 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", () => { it("is a no-op when the workspace has no routes", () => {
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
const emitScriptStatusUpdate = vi.fn(); const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, routeStore,
emitScriptStatusUpdate, onRoutesChanged,
}); });
handleBranchChange("workspace-a", "feature/auth", "feature/billing"); handleBranchChange("workspace-a", "feature/auth", "feature/billing");
expect(routeStore.listRoutes()).toEqual([]); expect(routeStore.listRoutes()).toEqual([]);
expect(emitScriptStatusUpdate).not.toHaveBeenCalled(); expect(onRoutesChanged).not.toHaveBeenCalled();
}); });
it("is a no-op when the resolved hostnames do not change", () => { it("is a no-op when the resolved hostnames do not change", () => {
@@ -70,10 +100,10 @@ describe("script-route-branch-handler", () => {
scriptName: "api", scriptName: "api",
}); });
const emitScriptStatusUpdate = vi.fn(); const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, routeStore,
emitScriptStatusUpdate, onRoutesChanged,
}); });
handleBranchChange("workspace-a", "main", "master"); handleBranchChange("workspace-a", "main", "master");
@@ -86,10 +116,10 @@ describe("script-route-branch-handler", () => {
scriptName: "api", 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(); const routeStore = new ScriptRouteStore();
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "feature-auth.api.localhost", hostname: "feature-auth.api.localhost",
@@ -97,24 +127,15 @@ describe("script-route-branch-handler", () => {
scriptName: "api", scriptName: "api",
}); });
const emitScriptStatusUpdate = vi.fn(); const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, routeStore,
emitScriptStatusUpdate, onRoutesChanged,
}); });
handleBranchChange("workspace-a", "feature/auth", "feature/billing"); handleBranchChange("workspace-a", "feature/auth", "feature/billing");
expect(emitScriptStatusUpdate).toHaveBeenCalledWith("workspace-a", [ expect(onRoutesChanged).toHaveBeenCalledWith("workspace-a");
{
scriptName: "api",
hostname: "feature-billing.api.localhost",
port: 3001,
url: null,
lifecycle: "running",
health: null,
},
]);
}); });
it("updates all services for a workspace when multiple routes are registered", () => { it("updates all services for a workspace when multiple routes are registered", () => {
@@ -136,10 +157,10 @@ describe("script-route-branch-handler", () => {
scriptName: "docs", scriptName: "docs",
}); });
const emitScriptStatusUpdate = vi.fn(); const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, routeStore,
emitScriptStatusUpdate, onRoutesChanged,
}); });
handleBranchChange("workspace-a", "feature/auth", "feature/billing"); handleBranchChange("workspace-a", "feature/auth", "feature/billing");
@@ -176,14 +197,55 @@ describe("script-route-branch-handler", () => {
scriptName: "web", scriptName: "web",
}); });
const emitScriptStatusUpdate = vi.fn(); const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, routeStore,
emitScriptStatusUpdate, onRoutesChanged,
}); });
handleBranchChange("workspace-a", null, "main"); 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 { Logger } from "pino";
import type { WorkspaceScriptPayload } from "../shared/messages.js";
import { buildScriptHostname } from "../utils/script-hostname.js"; import { buildScriptHostname } from "../utils/script-hostname.js";
import { buildWorkspaceScriptPayloads } from "./script-status-projection.js";
import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js"; import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js";
interface BranchChangeRouteHandlerOptions { interface BranchChangeRouteHandlerOptions {
routeStore: ScriptRouteStore; routeStore: ScriptRouteStore;
emitScriptStatusUpdate: ( onRoutesChanged: (workspaceId: string) => void;
workspaceId: string,
scripts: WorkspaceScriptPayload[],
) => void;
logger?: Logger; logger?: Logger;
} }
@@ -23,6 +18,7 @@ export function createBranchChangeRouteHandler(
options: BranchChangeRouteHandlerOptions, options: BranchChangeRouteHandlerOptions,
): (workspaceId: string, oldBranch: string | null, newBranch: string | null) => void { ): (workspaceId: string, oldBranch: string | null, newBranch: string | null) => void {
return (workspaceId, _oldBranch, newBranch) => { return (workspaceId, _oldBranch, newBranch) => {
// Only service scripts register routes, so branch renames only touch services.
const routes = options.routeStore.listRoutesForWorkspace(workspaceId); const routes = options.routeStore.listRoutesForWorkspace(workspaceId);
if (routes.length === 0) { if (routes.length === 0) {
return; return;
@@ -62,9 +58,6 @@ export function createBranchChangeRouteHandler(
); );
} }
options.emitScriptStatusUpdate( options.onRoutesChanged(workspaceId);
workspaceId,
buildWorkspaceScriptPayloads(options.routeStore, workspaceId, null),
);
}; };
} }
@@ -8,6 +8,8 @@ import {
buildWorkspaceScriptPayloads, buildWorkspaceScriptPayloads,
createScriptStatusEmitter, createScriptStatusEmitter,
} from "./script-status-projection.js"; } from "./script-status-projection.js";
import type { ScriptHealthState } from "./script-health-monitor.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
function createWorkspaceRepo(options?: { function createWorkspaceRepo(options?: {
branchName?: string; 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", () => { describe("script-status-projection", () => {
it("shows configured scripts even before they have routes", () => { it("projects plain scripts and services differently", () => {
const workspace = createWorkspaceRepo({ const workspace = createWorkspaceRepo({
paseoConfig: { paseoConfig: {
scripts: { scripts: {
api: { command: "npm run api" }, typecheck: { command: "npm run typecheck" },
web: { command: "npm run web", port: 3000 }, web: { type: "service", command: "npm run web", port: 3000 },
}, },
}, },
}); });
const routeStore = new ScriptRouteStore(); 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 { try {
expect(buildWorkspaceScriptPayloads(routeStore, workspace.repoDir, 6767)).toEqual([ expect(
buildPayloads({
workspaceDirectory: workspace.repoDir,
routeStore,
runtimeStore,
daemonPort: 6767,
}),
).toEqual([
{ {
scriptName: "api", scriptName: "typecheck",
hostname: "api.localhost", type: "script",
hostname: "typecheck",
port: null, port: null,
url: "http://api.localhost:6767", url: null,
lifecycle: "stopped", lifecycle: "stopped",
health: null, health: null,
exitCode: 0,
}, },
{ {
scriptName: "web", scriptName: "web",
type: "service",
hostname: "web.localhost", hostname: "web.localhost",
port: 3000, port: 3000,
url: "http://web.localhost:6767", url: "http://web.localhost:6767",
lifecycle: "stopped", lifecycle: "stopped",
health: null, health: null,
exitCode: null,
}, },
]); ]);
} finally { } 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({ const workspace = createWorkspaceRepo({
branchName: "feature/card", branchName: "feature/card",
paseoConfig: { paseoConfig: {
scripts: { scripts: {
web: { command: "npm run web" }, web: { type: "service", command: "npm run web" },
}, },
}, },
}); });
@@ -87,16 +119,35 @@ describe("script-status-projection", () => {
workspaceId: workspace.repoDir, workspaceId: workspace.repoDir,
scriptName: "web", scriptName: "web",
}); });
const runtimeStore = new WorkspaceScriptRuntimeStore();
runtimeStore.set({
workspaceId: workspace.repoDir,
scriptName: "web",
type: "service",
lifecycle: "running",
terminalId: "term-web",
exitCode: null,
});
try { try {
expect(buildWorkspaceScriptPayloads(routeStore, workspace.repoDir, 6767)).toEqual([ expect(
buildPayloads({
workspaceDirectory: workspace.repoDir,
routeStore,
runtimeStore,
daemonPort: 6767,
resolveHealth: () => "healthy",
}),
).toEqual([
{ {
scriptName: "web", scriptName: "web",
type: "service",
hostname: "feature-card.web.localhost", hostname: "feature-card.web.localhost",
port: 4321, port: 4321,
url: "http://feature-card.web.localhost:6767", url: "http://feature-card.web.localhost:6767",
lifecycle: "running", lifecycle: "running",
health: null, health: "healthy",
exitCode: null,
}, },
]); ]);
} finally { } 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 workspace = createWorkspaceRepo();
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
routeStore.registerRoute({ routeStore.registerRoute({
@@ -113,16 +215,34 @@ describe("script-status-projection", () => {
workspaceId: workspace.repoDir, workspaceId: workspace.repoDir,
scriptName: "docs", scriptName: "docs",
}); });
const runtimeStore = new WorkspaceScriptRuntimeStore();
runtimeStore.set({
workspaceId: workspace.repoDir,
scriptName: "docs",
type: "service",
lifecycle: "running",
terminalId: "term-docs",
exitCode: null,
});
try { try {
expect(buildWorkspaceScriptPayloads(routeStore, workspace.repoDir, 6767)).toEqual([ expect(
buildPayloads({
workspaceDirectory: workspace.repoDir,
routeStore,
runtimeStore,
daemonPort: 6767,
}),
).toEqual([
{ {
scriptName: "docs", scriptName: "docs",
type: "service",
hostname: "docs.localhost", hostname: "docs.localhost",
port: 3002, port: 3002,
url: "http://docs.localhost:6767", url: "http://docs.localhost:6767",
lifecycle: "running", lifecycle: "running",
health: null, health: null,
exitCode: null,
}, },
]); ]);
} finally { } 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({ const workspace = createWorkspaceRepo({
paseoConfig: { paseoConfig: {
scripts: { scripts: {
api: { command: "npm run api" }, api: { type: "service", command: "npm run api" },
web: { command: "npm run web" }, typecheck: { command: "npm run typecheck" },
}, },
}, },
}); });
@@ -146,11 +304,21 @@ describe("script-status-projection", () => {
workspaceId: workspace.repoDir, workspaceId: workspace.repoDir,
scriptName: "api", 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 session = { emit: vi.fn() };
const emitUpdate = createScriptStatusEmitter({ const emitUpdate = createScriptStatusEmitter({
sessions: () => [session], sessions: () => [session],
routeStore, routeStore,
runtimeStore,
daemonPort: 6767, daemonPort: 6767,
}); });
@@ -171,19 +339,23 @@ describe("script-status-projection", () => {
scripts: [ scripts: [
{ {
scriptName: "api", scriptName: "api",
type: "service",
hostname: "api.localhost", hostname: "api.localhost",
port: 3001, port: 3001,
url: "http://api.localhost:6767", url: "http://api.localhost:6767",
lifecycle: "running", lifecycle: "running",
health: "healthy", health: "healthy",
exitCode: null,
}, },
{ {
scriptName: "web", scriptName: "typecheck",
hostname: "web.localhost", type: "script",
hostname: "typecheck",
port: null, port: null,
url: "http://web.localhost:6767", url: null,
lifecycle: "stopped", lifecycle: "stopped",
health: null, health: null,
exitCode: null,
}, },
], ],
}, },
@@ -192,41 +364,4 @@ describe("script-status-projection", () => {
workspace.cleanup(); 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, WorkspaceScriptPayload,
} from "../shared/messages.js"; } from "../shared/messages.js";
import { buildScriptHostname } from "../utils/script-hostname.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 { readGitCommand } from "./workspace-git-metadata.js";
import type { ScriptHealthEntry } from "./script-health-monitor.js"; import type { ScriptHealthEntry, ScriptHealthState } from "./script-health-monitor.js";
import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js"; import type { ScriptRouteStore } from "./script-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
type SessionEmitter = { type SessionEmitter = {
emit(message: SessionOutboundMessage): void; 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 { function resolveDaemonPort(daemonPort: number | null | (() => number | null)): number | null {
if (typeof daemonPort === "function") { if (typeof daemonPort === "function") {
return daemonPort(); return daemonPort();
@@ -20,6 +29,10 @@ function resolveDaemonPort(daemonPort: number | null | (() => number | null)): n
return daemonPort; 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 { function toServiceUrl(hostname: string, daemonPort: number | null): string | null {
if (daemonPort === null) { if (daemonPort === null) {
return null; return null;
@@ -27,41 +40,39 @@ function toServiceUrl(hostname: string, daemonPort: number | null): string | nul
return `http://${hostname}:${daemonPort}`; 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; scriptName: string;
hostname: string; type: WorkspaceScriptPayload["type"];
port: number | null; 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 {
return readGitCommand(workspaceDirectory, "git symbolic-ref --short HEAD"); 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[] { function sortPayloads(payloads: WorkspaceScriptPayload[]): WorkspaceScriptPayload[] {
const branchName = resolveWorkspaceBranchName(workspaceDirectory); return payloads.sort((left, right) =>
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) =>
left.scriptName.localeCompare(right.scriptName, undefined, { left.scriptName.localeCompare(right.scriptName, undefined, {
numeric: true, numeric: true,
sensitivity: "base", sensitivity: "base",
@@ -70,22 +81,82 @@ function mergeWorkspaceScriptDefinitions(
} }
export function buildWorkspaceScriptPayloads( export function buildWorkspaceScriptPayloads(
routeStore: ScriptRouteStore, options: BuildWorkspaceScriptPayloadsOptions,
workspaceDirectory: string,
daemonPort: number | null,
resolveHealth?: (hostname: string) => "healthy" | "unhealthy" | null,
): WorkspaceScriptPayload[] { ): WorkspaceScriptPayload[] {
return mergeWorkspaceScriptDefinitions(workspaceDirectory, routeStore).map((script) => { const workspaceDirectory = options.workspaceDirectory;
const route = routeStore.getRouteEntry(script.hostname); const branchName = resolveWorkspaceBranchName(workspaceDirectory);
return { const scriptConfigs = getScriptConfigs(workspaceDirectory);
scriptName: script.scriptName, const runtimeEntries = new Map(
hostname: script.hostname, options.runtimeStore
port: route?.port ?? script.port, .listForWorkspace(workspaceDirectory)
url: toServiceUrl(script.hostname, daemonPort), .map((entry) => [entry.scriptName, entry] as const),
lifecycle: route ? "running" : "stopped", );
health: resolveHealth?.(script.hostname) ?? null, 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: { function buildScriptStatusUpdateMessage(params: {
@@ -104,10 +175,12 @@ function buildScriptStatusUpdateMessage(params: {
export function createScriptStatusEmitter({ export function createScriptStatusEmitter({
sessions, sessions,
routeStore, routeStore,
runtimeStore,
daemonPort, daemonPort,
}: { }: {
sessions: () => SessionEmitter[]; sessions: () => SessionEmitter[];
routeStore: ScriptRouteStore; routeStore: ScriptRouteStore;
runtimeStore: WorkspaceScriptRuntimeStore;
daemonPort: number | null | (() => number | null); daemonPort: number | null | (() => number | null);
}): (workspaceId: string, scripts: ScriptHealthEntry[]) => void { }): (workspaceId: string, scripts: ScriptHealthEntry[]) => void {
return (workspaceId, scripts) => { return (workspaceId, scripts) => {
@@ -116,12 +189,13 @@ export function createScriptStatusEmitter({
scripts.map((script) => [script.hostname, script.health] as const), scripts.map((script) => [script.hostname, script.health] as const),
); );
const projected = buildWorkspaceScriptPayloads( const projected = buildWorkspaceScriptPayloads({
workspaceDirectory: workspaceId,
routeStore, routeStore,
workspaceId, runtimeStore,
resolvedDaemonPort, daemonPort: resolvedDaemonPort,
(hostname) => scriptHealthByHostname.get(hostname) ?? null, resolveHealth: (hostname) => scriptHealthByHostname.get(hostname) ?? null,
); });
const message = buildScriptStatusUpdateMessage({ const message = buildScriptStatusUpdateMessage({
workspaceId, 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 { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js"; import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js";
import { buildWorkspaceScriptPayloads } from "./script-status-projection.js"; import { buildWorkspaceScriptPayloads } from "./script-status-projection.js";
import type { ScriptHealthState } from "./script-health-monitor.js";
import { spawnWorkspaceScript } from "./worktree-bootstrap.js"; import { spawnWorkspaceScript } from "./worktree-bootstrap.js";
import { readGitCommand } from "./workspace-git-metadata.js"; import { readGitCommand } from "./workspace-git-metadata.js";
import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js"; import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
export type AgentMcpTransportFactory = () => Promise<Transport>; export type AgentMcpTransportFactory = () => Promise<Transport>;
import { buildProviderRegistry } from "./agent/provider-registry.js"; import { buildProviderRegistry } from "./agent/provider-registry.js";
@@ -430,13 +432,14 @@ export type SessionOptions = {
terminalManager: TerminalManager | null; terminalManager: TerminalManager | null;
providerSnapshotManager?: ProviderSnapshotManager; providerSnapshotManager?: ProviderSnapshotManager;
scriptRouteStore?: ScriptRouteStore; scriptRouteStore?: ScriptRouteStore;
scriptRuntimeStore?: WorkspaceScriptRuntimeStore;
onBranchChanged?: ( onBranchChanged?: (
workspaceId: string, workspaceId: string,
oldBranch: string | null, oldBranch: string | null,
newBranch: string | null, newBranch: string | null,
) => void; ) => void;
getDaemonTcpPort?: () => number | null; getDaemonTcpPort?: () => number | null;
resolveScriptHealth?: (hostname: string) => "healthy" | "unhealthy" | null; resolveScriptHealth?: (hostname: string) => ScriptHealthState | null;
voice?: { voice?: {
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null; voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
turnDetection?: Resolvable<TurnDetectionProvider | null>; turnDetection?: Resolvable<TurnDetectionProvider | null>;
@@ -614,6 +617,7 @@ export class Session {
private readonly providerSnapshotManager: ProviderSnapshotManager | null; private readonly providerSnapshotManager: ProviderSnapshotManager | null;
private unsubscribeProviderSnapshotEvents: (() => void) | null = null; private unsubscribeProviderSnapshotEvents: (() => void) | null = null;
private readonly scriptRouteStore: ScriptRouteStore | null; private readonly scriptRouteStore: ScriptRouteStore | null;
private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
private readonly onBranchChanged?: ( private readonly onBranchChanged?: (
workspaceId: string, workspaceId: string,
oldBranch: string | null, oldBranch: string | null,
@@ -621,7 +625,7 @@ export class Session {
) => void; ) => void;
private readonly getDaemonTcpPort: (() => number | null) | null; private readonly getDaemonTcpPort: (() => number | null) | null;
private readonly resolveScriptHealth: private readonly resolveScriptHealth:
| ((hostname: string) => "healthy" | "unhealthy" | null) | ((hostname: string) => ScriptHealthState | null)
| null; | null;
private readonly subscribedTerminalDirectories = new Set<string>(); private readonly subscribedTerminalDirectories = new Set<string>();
private unsubscribeTerminalsChanged: (() => void) | null = null; private unsubscribeTerminalsChanged: (() => void) | null = null;
@@ -681,6 +685,7 @@ export class Session {
terminalManager, terminalManager,
providerSnapshotManager, providerSnapshotManager,
scriptRouteStore, scriptRouteStore,
scriptRuntimeStore,
onBranchChanged, onBranchChanged,
getDaemonTcpPort, getDaemonTcpPort,
resolveScriptHealth, resolveScriptHealth,
@@ -725,6 +730,7 @@ export class Session {
this.terminalManager = terminalManager; this.terminalManager = terminalManager;
this.providerSnapshotManager = providerSnapshotManager ?? null; this.providerSnapshotManager = providerSnapshotManager ?? null;
this.scriptRouteStore = scriptRouteStore ?? null; this.scriptRouteStore = scriptRouteStore ?? null;
this.scriptRuntimeStore = scriptRuntimeStore ?? null;
this.onBranchChanged = onBranchChanged; this.onBranchChanged = onBranchChanged;
this.getDaemonTcpPort = getDaemonTcpPort ?? null; this.getDaemonTcpPort = getDaemonTcpPort ?? null;
this.resolveScriptHealth = resolveScriptHealth ?? null; this.resolveScriptHealth = resolveScriptHealth ?? null;
@@ -2930,8 +2936,6 @@ export class Session {
agentId: snapshot.id, agentId: snapshot.id,
item, item,
}), }),
scriptRouteStore: this.scriptRouteStore ?? undefined,
daemonPort: this.getDaemonTcpPort?.() ?? null,
logger: this.sessionLogger, logger: this.sessionLogger,
}); });
} }
@@ -5363,13 +5367,14 @@ export class Session {
status: "done", status: "done",
activityAt: null, activityAt: null,
diffStat, diffStat,
scripts: this.scriptRouteStore scripts: this.scriptRouteStore && this.scriptRuntimeStore
? buildWorkspaceScriptPayloads( ? buildWorkspaceScriptPayloads({
this.scriptRouteStore, workspaceDirectory: workspace.directory,
workspace.directory, routeStore: this.scriptRouteStore,
this.getDaemonTcpPort?.() ?? null, runtimeStore: this.scriptRuntimeStore,
this.resolveScriptHealth ?? undefined, daemonPort: this.getDaemonTcpPort?.() ?? null,
) resolveHealth: this.resolveScriptHealth ?? undefined,
})
: [], : [],
}; };
} }
@@ -5901,6 +5906,7 @@ export class Session {
const nextArchivedAt = archivedAt ?? new Date().toISOString(); const nextArchivedAt = archivedAt ?? new Date().toISOString();
await this.workspaceRegistry.archive(workspaceId, nextArchivedAt); await this.workspaceRegistry.archive(workspaceId, nextArchivedAt);
await this.removeWorkspaceGitWatchTarget(existingWorkspace.directory); await this.removeWorkspaceGitWatchTarget(existingWorkspace.directory);
this.scriptRuntimeStore?.removeForWorkspace(existingWorkspace.directory);
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter( const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt, (workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt,
@@ -6195,15 +6201,16 @@ export class Session {
private buildWorkspaceScriptPayloadSnapshot( private buildWorkspaceScriptPayloadSnapshot(
workspaceDirectory: string, workspaceDirectory: string,
): WorkspaceDescriptorPayload["scripts"] { ): WorkspaceDescriptorPayload["scripts"] {
if (!this.scriptRouteStore) { if (!this.scriptRouteStore || !this.scriptRuntimeStore) {
return []; return [];
} }
return buildWorkspaceScriptPayloads( return buildWorkspaceScriptPayloads({
this.scriptRouteStore,
workspaceDirectory, workspaceDirectory,
this.getDaemonTcpPort?.() ?? null, routeStore: this.scriptRouteStore,
this.resolveScriptHealth ?? undefined, runtimeStore: this.scriptRuntimeStore,
); daemonPort: this.getDaemonTcpPort?.() ?? null,
resolveHealth: this.resolveScriptHealth ?? undefined,
});
} }
private emitWorkspaceScriptStatusUpdate(workspaceDirectory: string): void { private emitWorkspaceScriptStatusUpdate(workspaceDirectory: string): void {
@@ -6228,7 +6235,7 @@ export class Session {
request: StartWorkspaceScriptRequest, request: StartWorkspaceScriptRequest,
): Promise<void> { ): Promise<void> {
try { try {
if (!this.terminalManager || !this.scriptRouteStore) { if (!this.terminalManager || !this.scriptRouteStore || !this.scriptRuntimeStore) {
throw new Error("Workspace scripts are not available on this daemon"); throw new Error("Workspace scripts are not available on this daemon");
} }
@@ -6244,6 +6251,7 @@ export class Session {
scriptName: request.scriptName, scriptName: request.scriptName,
daemonPort: this.getDaemonTcpPort?.() ?? null, daemonPort: this.getDaemonTcpPort?.() ?? null,
routeStore: this.scriptRouteStore, routeStore: this.scriptRouteStore,
runtimeStore: this.scriptRuntimeStore,
terminalManager: this.terminalManager, terminalManager: this.terminalManager,
logger: this.sessionLogger, logger: this.sessionLogger,
onLifecycleChanged: () => { onLifecycleChanged: () => {
@@ -6384,8 +6392,6 @@ export class Session {
sessionLogger: this.sessionLogger, sessionLogger: this.sessionLogger,
terminalManager: this.terminalManager, terminalManager: this.terminalManager,
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
scriptRouteStore: this.scriptRouteStore,
daemonPort: this.getDaemonTcpPort?.() ?? null,
}, },
options, options,
); );
@@ -36,7 +36,9 @@ import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import { buildProviderRegistry } from "./agent/provider-registry.js"; import { buildProviderRegistry } from "./agent/provider-registry.js";
import { PushTokenStore } from "./push/token-store.js"; import { PushTokenStore } from "./push/token-store.js";
import { PushService } from "./push/push-service.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 { 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 { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js";
import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js"; import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js";
import { import {
@@ -249,9 +251,10 @@ export class VoiceAssistantWebSocketServer {
private readonly speech: SpeechService | null; private readonly speech: SpeechService | null;
private readonly terminalManager: TerminalManager | null; private readonly terminalManager: TerminalManager | null;
private readonly scriptRouteStore: ScriptRouteStore | null; private readonly scriptRouteStore: ScriptRouteStore | null;
private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
private readonly getDaemonTcpPort: (() => number | null) | null; private readonly getDaemonTcpPort: (() => number | null) | null;
private readonly resolveScriptHealth: private readonly resolveScriptHealth:
| ((hostname: string) => "healthy" | "unhealthy" | null) | ((hostname: string) => ScriptHealthState | null)
| null; | null;
private readonly dictation: { private readonly dictation: {
finalTimeoutMs?: number; finalTimeoutMs?: number;
@@ -324,13 +327,14 @@ export class VoiceAssistantWebSocketServer {
scheduleService?: ScheduleService, scheduleService?: ScheduleService,
checkoutDiffManager?: CheckoutDiffManager, checkoutDiffManager?: CheckoutDiffManager,
scriptRouteStore?: ScriptRouteStore | null, scriptRouteStore?: ScriptRouteStore | null,
scriptRuntimeStore?: WorkspaceScriptRuntimeStore | null,
onBranchChanged?: ( onBranchChanged?: (
workspaceId: string, workspaceId: string,
oldBranch: string | null, oldBranch: string | null,
newBranch: string | null, newBranch: string | null,
) => void, ) => void,
getDaemonTcpPort?: () => number | null, getDaemonTcpPort?: () => number | null,
resolveScriptHealth?: (hostname: string) => "healthy" | "unhealthy" | null, resolveScriptHealth?: (hostname: string) => ScriptHealthState | null,
) { ) {
this.logger = logger.child({ module: "websocket-server" }); this.logger = logger.child({ module: "websocket-server" });
this.serverId = serverId; this.serverId = serverId;
@@ -378,6 +382,7 @@ export class VoiceAssistantWebSocketServer {
); );
this.onLifecycleIntent = onLifecycleIntent ?? null; this.onLifecycleIntent = onLifecycleIntent ?? null;
this.scriptRouteStore = scriptRouteStore ?? null; this.scriptRouteStore = scriptRouteStore ?? null;
this.scriptRuntimeStore = scriptRuntimeStore ?? null;
this.onBranchChanged = onBranchChanged ?? null; this.onBranchChanged = onBranchChanged ?? null;
this.getDaemonTcpPort = getDaemonTcpPort ?? null; this.getDaemonTcpPort = getDaemonTcpPort ?? null;
this.resolveScriptHealth = resolveScriptHealth ?? null; this.resolveScriptHealth = resolveScriptHealth ?? null;
@@ -700,6 +705,7 @@ export class VoiceAssistantWebSocketServer {
terminalManager: this.terminalManager, terminalManager: this.terminalManager,
providerSnapshotManager: this.providerSnapshotManager, providerSnapshotManager: this.providerSnapshotManager,
scriptRouteStore: this.scriptRouteStore ?? undefined, scriptRouteStore: this.scriptRouteStore ?? undefined,
scriptRuntimeStore: this.scriptRuntimeStore ?? undefined,
onBranchChanged: this.onBranchChanged ?? undefined, onBranchChanged: this.onBranchChanged ?? undefined,
getDaemonTcpPort: this.getDaemonTcpPort ?? undefined, getDaemonTcpPort: this.getDaemonTcpPort ?? undefined,
resolveScriptHealth: this.resolveScriptHealth ?? 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, spawnWorktreeScripts,
} from "./worktree-bootstrap.js"; } from "./worktree-bootstrap.js";
import { ScriptRouteStore } from "./script-proxy.js"; import { ScriptRouteStore } from "./script-proxy.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
describe("runAsyncWorktreeBootstrap", () => { describe("runAsyncWorktreeBootstrap", () => {
let tempDir: string; let tempDir: string;
@@ -507,7 +508,56 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(terminalToolCall?.status).toBe("completed"); 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( writeFileSync(
join(repoDir, "paseo.json"), join(repoDir, "paseo.json"),
JSON.stringify({ JSON.stringify({
@@ -525,6 +575,7 @@ describe("runAsyncWorktreeBootstrap", () => {
}); });
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore();
const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record<string, string> }> = []; const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record<string, string> }> = [];
const results = await spawnWorktreeScripts({ const results = await spawnWorktreeScripts({
@@ -533,42 +584,54 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-socket-service", branchName: "feature-socket-service",
daemonPort: null, daemonPort: null,
routeStore, routeStore,
terminalManager: { runtimeStore,
async getTerminals() { terminalManager: createStubTerminalManager(createTerminalCalls) as any,
return []; });
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: () => {}, );
}; execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
}, execSync("git -c commit.gpgsign=false commit -m 'add service script config'", {
registerCwdEnv() {}, cwd: repoDir,
getTerminal() { stdio: "pipe",
return undefined; });
},
killTerminal() {}, const routeStore = new ScriptRouteStore();
listDirectories() { const runtimeStore = new WorkspaceScriptRuntimeStore();
return []; const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record<string, string> }> = [];
},
killAll() {}, const results = await spawnWorktreeScripts({
subscribeTerminalsChanged() { repoRoot: repoDir,
return () => {}; workspaceId: repoDir,
}, branchName: "feature-socket-service",
}, daemonPort: 6767,
routeStore,
runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls) as any,
}); });
expect(results).toHaveLength(1); expect(results).toHaveLength(1);
@@ -585,6 +648,13 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(createTerminalCalls[0]?.name).toBe("web"); expect(createTerminalCalls[0]?.name).toBe("web");
expect(createTerminalCalls[0]?.env?.PORT).toEqual(expect.any(String)); expect(createTerminalCalls[0]?.env?.PORT).toEqual(expect.any(String));
expect(createTerminalCalls[0]?.env?.HOST).toBe("127.0.0.1"); 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, createWorktree,
getScriptConfigs, getScriptConfigs,
getWorktreeTerminalSpecs, getWorktreeTerminalSpecs,
isServiceScript,
listPaseoWorktrees, listPaseoWorktrees,
processCarriageReturns, processCarriageReturns,
resolveWorktreeRuntimeEnv, resolveWorktreeRuntimeEnv,
@@ -20,6 +21,7 @@ import {
type WorktreeRuntimeEnv, type WorktreeRuntimeEnv,
} from "../utils/worktree.js"; } from "../utils/worktree.js";
import { findFreePort, type ScriptRouteStore } from "./script-proxy.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"; import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js";
export interface WorktreeBootstrapTerminalResult { export interface WorktreeBootstrapTerminalResult {
@@ -35,8 +37,6 @@ export interface RunAsyncWorktreeBootstrapOptions {
worktree: WorktreeConfig; worktree: WorktreeConfig;
shouldBootstrap?: boolean; shouldBootstrap?: boolean;
terminalManager: TerminalManager | null; terminalManager: TerminalManager | null;
scriptRouteStore?: ScriptRouteStore;
daemonPort?: number | null;
appendTimelineItem: (item: AgentTimelineItem) => Promise<boolean>; appendTimelineItem: (item: AgentTimelineItem) => Promise<boolean>;
emitLiveTimelineItem?: (item: AgentTimelineItem) => Promise<boolean>; emitLiveTimelineItem?: (item: AgentTimelineItem) => Promise<boolean>;
logger?: Logger; logger?: Logger;
@@ -734,27 +734,6 @@ export async function runAsyncWorktreeBootstrap(
} }
await runWorktreeTerminalBootstrap(options, runtimeEnv); 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 { export interface WorktreeScriptResult {
scriptName: string; scriptName: string;
hostname: string; hostname: string | null;
port: number; port: number | null;
terminalId: string; terminalId: string;
} }
@@ -775,6 +754,7 @@ type SpawnWorkspaceScriptOptions = {
scriptName: string; scriptName: string;
daemonPort?: number | null; daemonPort?: number | null;
routeStore: ScriptRouteStore; routeStore: ScriptRouteStore;
runtimeStore: WorkspaceScriptRuntimeStore;
terminalManager: TerminalManager; terminalManager: TerminalManager;
logger?: Logger; logger?: Logger;
onLifecycleChanged?: () => void; onLifecycleChanged?: () => void;
@@ -790,6 +770,7 @@ export async function spawnWorkspaceScript(
scriptName, scriptName,
daemonPort, daemonPort,
routeStore, routeStore,
runtimeStore,
terminalManager, terminalManager,
logger, logger,
onLifecycleChanged, onLifecycleChanged,
@@ -800,44 +781,74 @@ export async function spawnWorkspaceScript(
throw new Error(`Script '${scriptName}' is not configured in paseo.json`); 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 port: number | null = null;
let terminal: TerminalSession | null = null;
let runtimeRegistered = false;
try { try {
hostname = buildScriptHostname(branchName, scriptName); if (runtimeStore.isRunning({ workspaceId, scriptName })) {
const resolvedHostname = hostname;
if (routeStore.getRouteEntry(resolvedHostname)) {
throw new Error(`Script '${scriptName}' is already running`); throw new Error(`Script '${scriptName}' is already running`);
} }
if (serviceScript) {
port = config.port ?? (await findFreePort()); port = config.port ?? (await findFreePort());
routeStore.registerRoute({ routeStore.registerRoute({
hostname: resolvedHostname, hostname: hostname!,
port, port,
workspaceId, workspaceId,
scriptName, 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, cwd: repoRoot,
name: scriptName, name: scriptName,
env, env,
}); });
runtimeStore.set({
workspaceId,
scriptName,
type: serviceScript ? "service" : "script",
lifecycle: "running",
terminalId: terminal.id,
exitCode: null,
});
runtimeRegistered = true;
terminal.onExit(() => { terminal.onExit((info) => {
routeStore.removeRoute(resolvedHostname); if (hostname) {
routeStore.removeRoute(hostname);
}
runtimeStore.set({
workspaceId,
scriptName,
type: serviceScript ? "service" : "script",
lifecycle: "stopped",
terminalId: terminal!.id,
exitCode: info.exitCode,
});
onLifecycleChanged?.(); onLifecycleChanged?.();
logger?.info( logger?.info(
{ scriptName, hostname: resolvedHostname, terminalId: terminal.id }, {
scriptName,
hostname,
exitCode: info.exitCode,
terminalId: terminal!.id,
},
"Stopped worktree script", "Stopped worktree script",
); );
}); });
@@ -846,14 +857,16 @@ export async function spawnWorkspaceScript(
terminal.send({ type: "input", data: `${config.command}\r` }); terminal.send({ type: "input", data: `${config.command}\r` });
logger?.info( logger?.info(
{ scriptName, hostname: resolvedHostname, port, terminalId: terminal.id }, { scriptName, hostname, port, terminalId: terminal.id, type: serviceScript ? "service" : "script" },
`Registered script proxy: ${resolvedHostname} -> 127.0.0.1:${port}`, serviceScript
? `Registered script proxy: ${hostname} -> 127.0.0.1:${port}`
: "Started workspace script",
); );
onLifecycleChanged?.(); onLifecycleChanged?.();
return { return {
scriptName, scriptName,
hostname: resolvedHostname, hostname,
port, port,
terminalId: terminal.id, terminalId: terminal.id,
}; };
@@ -861,6 +874,9 @@ export async function spawnWorkspaceScript(
if (hostname && port !== null) { if (hostname && port !== null) {
routeStore.removeRoute(hostname); routeStore.removeRoute(hostname);
} }
if (runtimeRegistered) {
runtimeStore.remove({ workspaceId, scriptName });
}
logger?.error( logger?.error(
{ {
err: error, err: error,
@@ -883,6 +899,7 @@ export async function spawnWorktreeScripts(options: {
branchName: string | null; branchName: string | null;
daemonPort?: number | null; daemonPort?: number | null;
routeStore: ScriptRouteStore; routeStore: ScriptRouteStore;
runtimeStore: WorkspaceScriptRuntimeStore;
terminalManager: TerminalManager; terminalManager: TerminalManager;
logger?: Logger; logger?: Logger;
onLifecycleChanged?: () => void; 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({ const { tempDir, repoDir } = createGitRepo({
paseoConfig: { paseoConfig: {
scripts: { scripts: {
@@ -126,7 +126,6 @@ describe("createPaseoWorktreeInBackground", () => {
const worktreePath = createdWorktree.worktreePath; const worktreePath = createdWorktree.worktreePath;
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const snapshots = new Map<string, unknown>(); const snapshots = new Map<string, unknown>();
const routeStore = new ScriptRouteStore();
const logger = createLogger(); const logger = createLogger();
const terminalManager = createTerminalManagerStub(); const terminalManager = createTerminalManagerStub();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
@@ -141,8 +140,6 @@ describe("createPaseoWorktreeInBackground", () => {
sessionLogger: logger, sessionLogger: logger,
terminalManager: terminalManager.manager, terminalManager: terminalManager.manager,
archiveWorkspaceRecord, archiveWorkspaceRecord,
scriptRouteStore: routeStore,
daemonPort: 6767,
}, },
{ {
requestCwd: repoDir, requestCwd: repoDir,
@@ -197,19 +194,7 @@ describe("createPaseoWorktreeInBackground", () => {
}, },
}); });
expect(routeStore.listRoutes()).toEqual([ expect(terminalManager.terminals).toHaveLength(0);
{
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(archiveWorkspaceRecord).not.toHaveBeenCalled(); expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath); expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
}); });
@@ -251,8 +236,6 @@ describe("createPaseoWorktreeInBackground", () => {
sessionLogger: logger, sessionLogger: logger,
terminalManager: null, terminalManager: null,
archiveWorkspaceRecord, archiveWorkspaceRecord,
scriptRouteStore: null,
daemonPort: null,
}, },
{ {
requestCwd: repoDir, requestCwd: repoDir,
@@ -319,8 +302,6 @@ describe("createPaseoWorktreeInBackground", () => {
sessionLogger: logger, sessionLogger: logger,
terminalManager: null, terminalManager: null,
archiveWorkspaceRecord, archiveWorkspaceRecord,
scriptRouteStore: null,
daemonPort: null,
}, },
{ {
requestCwd: repoDir, 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({ const { tempDir, repoDir } = createGitRepo({
paseoConfig: { paseoConfig: {
worktree: { worktree: {
@@ -421,7 +402,6 @@ describe("createPaseoWorktreeInBackground", () => {
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const snapshots = new Map<string, unknown>(); const snapshots = new Map<string, unknown>();
const routeStore = new ScriptRouteStore();
const logger = createLogger(); const logger = createLogger();
const terminalManager = createTerminalManagerStub(); const terminalManager = createTerminalManagerStub();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
@@ -436,8 +416,6 @@ describe("createPaseoWorktreeInBackground", () => {
sessionLogger: logger, sessionLogger: logger,
terminalManager: terminalManager.manager, terminalManager: terminalManager.manager,
archiveWorkspaceRecord, archiveWorkspaceRecord,
scriptRouteStore: routeStore,
daemonPort: 6767,
}, },
{ {
requestCwd: repoDir, requestCwd: repoDir,
@@ -473,20 +451,7 @@ describe("createPaseoWorktreeInBackground", () => {
commands: [], commands: [],
}, },
}); });
expect(routeStore.listRoutes()).toEqual([ expect(terminalManager.terminals).toHaveLength(0);
{
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( expect(
readFileSync(path.join(existingWorktree.worktreePath, "README.md"), "utf8"), readFileSync(path.join(existingWorktree.worktreePath, "README.md"), "utf8"),
).toContain("hello"); ).toContain("hello");
@@ -499,7 +464,7 @@ describe("createPaseoWorktreeInBackground", () => {
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(existingWorktree.worktreePath); 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({ const { tempDir, repoDir } = createGitRepo({
paseoConfig: { paseoConfig: {
scripts: { scripts: {
@@ -523,7 +488,6 @@ describe("createPaseoWorktreeInBackground", () => {
const worktreePath = createdWorktree.worktreePath; const worktreePath = createdWorktree.worktreePath;
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const snapshots = new Map<string, unknown>(); const snapshots = new Map<string, unknown>();
const routeStore = new ScriptRouteStore();
const logger = createLogger(); const logger = createLogger();
const terminalManager = createTerminalManagerStub({ const terminalManager = createTerminalManagerStub({
createTerminal: async () => { createTerminal: async () => {
@@ -542,8 +506,6 @@ describe("createPaseoWorktreeInBackground", () => {
sessionLogger: logger, sessionLogger: logger,
terminalManager: terminalManager.manager, terminalManager: terminalManager.manager,
archiveWorkspaceRecord, archiveWorkspaceRecord,
scriptRouteStore: routeStore,
daemonPort: 6767,
}, },
{ {
requestCwd: repoDir, requestCwd: repoDir,
@@ -566,17 +528,17 @@ describe("createPaseoWorktreeInBackground", () => {
expect(progressMessages[0]?.payload.error).toBeNull(); expect(progressMessages[0]?.payload.error).toBeNull();
expect(progressMessages[1]?.payload.status).toBe("completed"); expect(progressMessages[1]?.payload.status).toBe("completed");
expect(progressMessages[1]?.payload.error).toBeNull(); expect(progressMessages[1]?.payload.error).toBeNull();
expect(emitted.some((message) => message.type === "workspace_setup_progress" && message.payload.status === "failed")).toBe(false); expect(
expect(logger.error).toHaveBeenCalledWith( emitted.some(
expect.objectContaining({ (message) =>
err: expect.any(Error), message.type === "workspace_setup_progress" && message.payload.status === "failed",
cwd: repoDir, ),
repoRoot: repoDir, ).toBe(false);
worktreeSlug: "feature-service-failure", expect(logger.error).not.toHaveBeenCalledWith(
worktreePath, expect.anything(),
}),
"Failed to spawn worktree scripts after workspace setup completed", "Failed to spawn worktree scripts after workspace setup completed",
); );
expect(terminalManager.terminals).toHaveLength(0);
expect(snapshots.get("45")).toMatchObject({ expect(snapshots.get("45")).toMatchObject({
status: "completed", status: "completed",
error: null, error: null,
@@ -585,7 +547,7 @@ describe("createPaseoWorktreeInBackground", () => {
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath); 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({ const { tempDir, repoDir } = createGitRepo({
paseoConfig: { paseoConfig: {
scripts: { scripts: {
@@ -609,7 +571,6 @@ describe("createPaseoWorktreeInBackground", () => {
const worktreePath = createdWorktree.worktreePath; const worktreePath = createdWorktree.worktreePath;
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const snapshots = new Map<string, unknown>(); const snapshots = new Map<string, unknown>();
const routeStore = new ScriptRouteStore();
const logger = createLogger(); const logger = createLogger();
const terminalManager = createTerminalManagerStub(); const terminalManager = createTerminalManagerStub();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
@@ -624,8 +585,6 @@ describe("createPaseoWorktreeInBackground", () => {
sessionLogger: logger, sessionLogger: logger,
terminalManager: terminalManager.manager, terminalManager: terminalManager.manager,
archiveWorkspaceRecord, archiveWorkspaceRecord,
scriptRouteStore: routeStore,
daemonPort: null,
}, },
{ {
requestCwd: repoDir, requestCwd: repoDir,
@@ -639,19 +598,7 @@ describe("createPaseoWorktreeInBackground", () => {
}, },
); );
expect(routeStore.listRoutes()).toEqual([ expect(terminalManager.terminals).toHaveLength(0);
{
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(snapshots.get("46")).toMatchObject({ expect(snapshots.get("46")).toMatchObject({
status: "completed", status: "completed",
error: null, error: null,
@@ -26,10 +26,8 @@ import {
createAgentWorktree, createAgentWorktree,
createWorktreeSetupProgressAccumulator, createWorktreeSetupProgressAccumulator,
getWorktreeSetupProgressResults, getWorktreeSetupProgressResults,
spawnWorktreeScripts,
} from "./worktree-bootstrap.js"; } from "./worktree-bootstrap.js";
import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { ScriptRouteStore } from "./script-proxy.js";
import { import {
getCheckoutStatusLite, getCheckoutStatusLite,
resolveRepositoryDefaultBranch, resolveRepositoryDefaultBranch,
@@ -110,8 +108,6 @@ type CreatePaseoWorktreeInBackgroundDependencies = {
sessionLogger: Logger; sessionLogger: Logger;
terminalManager: TerminalManager | null; terminalManager: TerminalManager | null;
archiveWorkspaceRecord: (workspaceId: number) => Promise<void>; archiveWorkspaceRecord: (workspaceId: number) => Promise<void>;
scriptRouteStore: ScriptRouteStore | null;
daemonPort?: number | null;
}; };
type HandleWorkspaceSetupStatusRequestDependencies = { type HandleWorkspaceSetupStatusRequestDependencies = {
@@ -765,35 +761,6 @@ export async function createPaseoWorktreeInBackground(
return; 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 { } finally {
await dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath); await dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath);
} }
+2
View File
@@ -1820,11 +1820,13 @@ export const WorkspaceScriptHealthSchema = z.enum(["healthy", "unhealthy"]);
export const WorkspaceScriptPayloadSchema = z.object({ export const WorkspaceScriptPayloadSchema = z.object({
scriptName: z.string(), scriptName: z.string(),
type: z.enum(["script", "service"]).optional().default("service"),
hostname: z.string(), hostname: z.string(),
port: z.number().int().positive().nullable(), port: z.number().int().positive().nullable(),
url: z.string().nullable(), url: z.string().nullable(),
lifecycle: WorkspaceScriptLifecycleSchema, lifecycle: WorkspaceScriptLifecycleSchema,
health: WorkspaceScriptHealthSchema.nullable(), health: WorkspaceScriptHealthSchema.nullable(),
exitCode: z.number().nullable().optional().default(null),
}); });
export const WorkspaceDescriptorPayloadSchema = z.object({ export const WorkspaceDescriptorPayloadSchema = z.object({
@@ -155,11 +155,13 @@ describe("workspace message schemas", () => {
expect(parsed.payload.workspace.scripts).toEqual([ expect(parsed.payload.workspace.scripts).toEqual([
{ {
scriptName: "web", scriptName: "web",
type: "service",
hostname: "web.localhost", hostname: "web.localhost",
port: 3000, port: 3000,
url: "http://web.localhost:6767", url: "http://web.localhost:6767",
lifecycle: "running", lifecycle: "running",
health: "healthy", health: "healthy",
exitCode: null,
}, },
]); ]);
}); });
@@ -213,6 +215,10 @@ describe("workspace message schemas", () => {
expect(parsed.type).toBe("script_status_update"); expect(parsed.type).toBe("script_status_update");
expect(parsed.payload.workspaceId).toBe("/repo"); expect(parsed.payload.workspaceId).toBe("/repo");
expect(parsed.payload.scripts[0]).toMatchObject({
type: "service",
exitCode: null,
});
}); });
test("parses workspace_setup_progress payload", () => { test("parses workspace_setup_progress payload", () => {
@@ -3,7 +3,9 @@ import {
createWorktree, createWorktree,
deriveWorktreeProjectHash, deriveWorktreeProjectHash,
deletePaseoWorktree, deletePaseoWorktree,
getScriptConfigs,
getWorktreeTerminalSpecs, getWorktreeTerminalSpecs,
isServiceScript,
isPaseoOwnedWorktreeCwd, isPaseoOwnedWorktreeCwd,
listPaseoWorktrees, listPaseoWorktrees,
resolveWorktreeRuntimeEnv, resolveWorktreeRuntimeEnv,
@@ -483,6 +485,91 @@ describe("createWorktree", () => {
{ command: "npm run test" }, { 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", () => { describe("paseo worktree manager", () => {
+30 -4
View File
@@ -24,7 +24,14 @@ interface PaseoConfig {
teardown?: string[]; teardown?: string[];
terminals?: WorktreeTerminalConfig[]; terminals?: WorktreeTerminalConfig[];
}; };
scripts?: Record<string, ScriptConfig>; scripts?: Record<
string,
{
type?: unknown;
command?: unknown;
port?: unknown;
}
>;
} }
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -90,11 +97,24 @@ export interface WorktreeTerminalConfig {
command: string; command: string;
} }
export interface ScriptConfig { export interface PlainScriptConfig {
type?: undefined;
command: string;
port?: undefined;
}
export interface ServiceScriptConfig {
type: "service";
command: string; command: string;
port?: number; // explicit port override, otherwise auto-assigned 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 { export class WorktreeSetupError extends Error {
readonly results: WorktreeSetupCommandResult[]; readonly results: WorktreeSetupCommandResult[];
@@ -227,9 +247,15 @@ export function getScriptConfigs(repoRoot: string): Map<string, ScriptConfig> {
continue; 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; scriptConfig.port = entry.port;
} }
+1
View File
@@ -8,6 +8,7 @@
}, },
"scripts": { "scripts": {
"server": { "server": {
"type": "service",
"command": "npm run dev" "command": "npm run dev"
}, },
"typecheck": { "typecheck": {