Distinguish services from scripts with type system and runtime tracking
This commit is contained in:
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"server": {
|
"server": {
|
||||||
|
"type": "service",
|
||||||
"command": "npm run dev"
|
"command": "npm run dev"
|
||||||
},
|
},
|
||||||
"typecheck": {
|
"typecheck": {
|
||||||
|
|||||||
Reference in New Issue
Block a user