Merge branch 'investigate/portless' into dev
# Conflicts: # packages/app/e2e/helpers/workspace-setup.ts # packages/app/src/components/sidebar-workspace-list.tsx # packages/app/src/contexts/session-context.tsx # packages/app/src/hooks/use-sidebar-workspaces-list.test.ts # packages/app/src/screens/workspace/workspace-tab-menu.ts # packages/app/src/stores/workspace-setup-store.ts # packages/app/src/stores/workspace-tabs-store.ts # packages/app/src/utils/sidebar-project-row-model.test.ts # packages/app/src/utils/sidebar-shortcuts.test.ts # packages/app/src/utils/workspace-tab-identity.ts # packages/server/src/server/bootstrap.ts # packages/server/src/server/worktree-session.ts
This commit is contained in:
@@ -213,6 +213,82 @@ describe("DaemonClient", () => {
|
||||
expect(client.getConnectionState().status).toBe("disposed");
|
||||
});
|
||||
|
||||
test("normalizes workspace_setup_progress into a workspace-scoped daemon event", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const events: Array<Parameters<Parameters<typeof client.subscribe>[0]>[0]> = [];
|
||||
client.subscribe((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "workspace_setup_progress",
|
||||
payload: {
|
||||
workspaceId: "/tmp/project/.paseo/worktrees/feature-a",
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/tmp/project/.paseo/worktrees/feature-a",
|
||||
branchName: "feature-a",
|
||||
log: "phase-one\n",
|
||||
commands: [
|
||||
{
|
||||
index: 1,
|
||||
command: "npm install",
|
||||
cwd: "/tmp/project/.paseo/worktrees/feature-a",
|
||||
log: "phase-one\n",
|
||||
status: "running",
|
||||
exitCode: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: "workspace_setup_progress",
|
||||
workspaceId: "/tmp/project/.paseo/worktrees/feature-a",
|
||||
payload: {
|
||||
workspaceId: "/tmp/project/.paseo/worktrees/feature-a",
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/tmp/project/.paseo/worktrees/feature-a",
|
||||
branchName: "feature-a",
|
||||
log: "phase-one\n",
|
||||
commands: [
|
||||
{
|
||||
index: 1,
|
||||
command: "npm install",
|
||||
cwd: "/tmp/project/.paseo/worktrees/feature-a",
|
||||
log: "phase-one\n",
|
||||
status: "running",
|
||||
exitCode: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("sends explicit shutdown_server_request via shutdownServer", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
ProjectIconResponse,
|
||||
OpenProjectResponseMessage,
|
||||
ArchiveWorkspaceResponseMessage,
|
||||
WorkspaceSetupStatusResponseMessage,
|
||||
ListCommandsResponse,
|
||||
ListProviderModelsResponseMessage,
|
||||
ListAvailableProvidersResponse,
|
||||
@@ -124,6 +125,11 @@ export type DaemonEvent =
|
||||
workspaceId: number;
|
||||
payload: Extract<SessionOutboundMessage, { type: "workspace_update" }>["payload"];
|
||||
}
|
||||
| {
|
||||
type: "workspace_setup_progress";
|
||||
workspaceId: string;
|
||||
payload: Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }>["payload"];
|
||||
}
|
||||
| {
|
||||
type: "agent_stream";
|
||||
agentId: string;
|
||||
@@ -459,6 +465,7 @@ export type InspectScheduleOptions = {
|
||||
};
|
||||
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
|
||||
type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"];
|
||||
type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"];
|
||||
|
||||
export type FetchAgentResult = {
|
||||
agent: AgentSnapshotPayload;
|
||||
@@ -1315,6 +1322,21 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async fetchWorkspaceSetupStatus(
|
||||
workspaceId: string,
|
||||
requestId?: string,
|
||||
): Promise<WorkspaceSetupStatusPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "workspace_setup_status_request",
|
||||
workspaceId,
|
||||
},
|
||||
responseType: "workspace_setup_status_response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async fetchAgent(agentId: string, requestId?: string): Promise<FetchAgentResult | null> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
@@ -3549,6 +3571,12 @@ export class DaemonClient {
|
||||
workspaceId: msg.payload.kind === "upsert" ? msg.payload.workspace.id : msg.payload.id,
|
||||
payload: msg.payload,
|
||||
};
|
||||
case "workspace_setup_progress":
|
||||
return {
|
||||
type: "workspace_setup_progress",
|
||||
workspaceId: msg.payload.workspaceId,
|
||||
payload: msg.payload,
|
||||
};
|
||||
case "agent_stream":
|
||||
return {
|
||||
type: "agent_stream",
|
||||
|
||||
@@ -48,11 +48,14 @@ import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||
import { expandUserPath } from "../path-utils.js";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
|
||||
import type { ServiceRouteStore } from "../service-proxy.js";
|
||||
|
||||
export interface AgentManagementMcpOptions {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentSnapshotStore;
|
||||
terminalManager?: TerminalManager | null;
|
||||
serviceRouteStore?: ServiceRouteStore;
|
||||
getDaemonTcpPort?: () => number | null;
|
||||
paseoHome?: string;
|
||||
logger: Logger;
|
||||
}
|
||||
@@ -296,21 +299,25 @@ export async function createAgentManagementMcpServer(
|
||||
};
|
||||
|
||||
let resolvedCwd = expandUserPath(cwd);
|
||||
let worktreeConfig: WorktreeConfig | undefined;
|
||||
let worktreeBootstrap:
|
||||
| {
|
||||
worktree: WorktreeConfig;
|
||||
shouldBootstrap: boolean;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (worktreeName) {
|
||||
if (!baseBranch) {
|
||||
throw new Error("baseBranch is required when creating a worktree");
|
||||
}
|
||||
const worktree = await createAgentWorktree({
|
||||
worktreeBootstrap = await createAgentWorktree({
|
||||
branchName: worktreeName,
|
||||
cwd: resolvedCwd,
|
||||
baseBranch,
|
||||
worktreeSlug: worktreeName,
|
||||
paseoHome: options.paseoHome,
|
||||
});
|
||||
resolvedCwd = worktree.worktreePath;
|
||||
worktreeConfig = worktree;
|
||||
resolvedCwd = worktreeBootstrap.worktree.worktreePath;
|
||||
}
|
||||
|
||||
const provider: AgentProvider = agentType ?? "claude";
|
||||
@@ -322,10 +329,11 @@ export async function createAgentManagementMcpServer(
|
||||
title: normalizedTitle ?? undefined,
|
||||
});
|
||||
|
||||
if (worktreeConfig) {
|
||||
if (worktreeBootstrap) {
|
||||
void runAsyncWorktreeBootstrap({
|
||||
agentId: snapshot.id,
|
||||
worktree: worktreeConfig,
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: options.terminalManager ?? null,
|
||||
appendTimelineItem: (item) =>
|
||||
appendTimelineItemIfAgentKnown({
|
||||
@@ -339,6 +347,8 @@ export async function createAgentManagementMcpServer(
|
||||
agentId: snapshot.id,
|
||||
item,
|
||||
}),
|
||||
serviceRouteStore: options.serviceRouteStore,
|
||||
daemonPort: options.getDaemonTcpPort?.() ?? null,
|
||||
logger: childLogger,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -179,6 +179,7 @@ export type ToolCallDetail =
|
||||
index: number;
|
||||
command: string;
|
||||
cwd: string;
|
||||
log: string;
|
||||
status: "running" | "completed" | "failed";
|
||||
exitCode: number | null;
|
||||
durationMs?: number;
|
||||
|
||||
@@ -28,11 +28,14 @@ import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
|
||||
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
|
||||
import type { ServiceRouteStore } from "../service-proxy.js";
|
||||
|
||||
export interface AgentMcpServerOptions {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentSnapshotStore;
|
||||
terminalManager?: TerminalManager | null;
|
||||
serviceRouteStore?: ServiceRouteStore;
|
||||
getDaemonTcpPort?: () => number | null;
|
||||
paseoHome?: string;
|
||||
/**
|
||||
* ID of the agent that is connecting to this MCP server.
|
||||
@@ -428,6 +431,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
let resolvedCwd: string;
|
||||
let resolvedMode: string | undefined;
|
||||
let worktreeConfig: WorktreeConfig | undefined;
|
||||
let shouldBootstrapWorktree: boolean | undefined;
|
||||
|
||||
if (callerAgentId) {
|
||||
const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args);
|
||||
@@ -464,15 +468,16 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
if (!baseBranch) {
|
||||
throw new Error("baseBranch is required when creating a worktree");
|
||||
}
|
||||
const worktree = await createAgentWorktree({
|
||||
const worktreeBootstrap = await createAgentWorktree({
|
||||
branchName: worktreeName,
|
||||
cwd: resolvedCwd,
|
||||
baseBranch,
|
||||
worktreeSlug: worktreeName,
|
||||
paseoHome: options.paseoHome,
|
||||
});
|
||||
resolvedCwd = worktree.worktreePath;
|
||||
worktreeConfig = worktree;
|
||||
resolvedCwd = worktreeBootstrap.worktree.worktreePath;
|
||||
worktreeConfig = worktreeBootstrap.worktree;
|
||||
shouldBootstrapWorktree = worktreeBootstrap.shouldBootstrap;
|
||||
}
|
||||
|
||||
resolvedMode = initialMode;
|
||||
@@ -497,6 +502,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
void runAsyncWorktreeBootstrap({
|
||||
agentId: snapshot.id,
|
||||
worktree: worktreeConfig,
|
||||
shouldBootstrap: shouldBootstrapWorktree,
|
||||
terminalManager: terminalManager ?? null,
|
||||
appendTimelineItem: (item) =>
|
||||
appendTimelineItemIfAgentKnown({
|
||||
@@ -510,6 +516,8 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
agentId: snapshot.id,
|
||||
item,
|
||||
}),
|
||||
serviceRouteStore: options.serviceRouteStore,
|
||||
daemonPort: options.getDaemonTcpPort?.() ?? null,
|
||||
logger: childLogger,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -118,6 +118,13 @@ import { resolveDaemonVersion } from "./daemon-version.js";
|
||||
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
|
||||
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
||||
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
|
||||
import {
|
||||
ServiceRouteStore,
|
||||
createServiceProxyMiddleware,
|
||||
createServiceProxyUpgradeHandler,
|
||||
} from "./service-proxy.js";
|
||||
import { ServiceHealthMonitor } from "./service-health-monitor.js";
|
||||
import { createServiceStatusEmitter } from "./service-status-projection.js";
|
||||
import {
|
||||
createVoiceMcpSocketBridgeManager,
|
||||
type VoiceMcpSocketBridgeManager,
|
||||
@@ -194,6 +201,7 @@ export interface PaseoDaemon {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentSnapshotStore;
|
||||
terminalManager: TerminalManager;
|
||||
serviceRouteStore: ServiceRouteStore;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
getListenTarget(): ListenTarget | null;
|
||||
@@ -224,6 +232,20 @@ export async function createPaseoDaemon(
|
||||
const app = express();
|
||||
let boundListenTarget: ListenTarget | null = null;
|
||||
|
||||
const serviceRouteStore = new ServiceRouteStore();
|
||||
let wsServer: VoiceAssistantWebSocketServer | null = null;
|
||||
const serviceHealthMonitor = new ServiceHealthMonitor({
|
||||
routeStore: serviceRouteStore,
|
||||
onChange: createServiceStatusEmitter({
|
||||
sessions: () =>
|
||||
wsServer?.listActiveSessions().map((session) => ({
|
||||
emit: (message) => session.emitServerMessage(message),
|
||||
})) ?? [],
|
||||
routeStore: serviceRouteStore,
|
||||
daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
||||
}),
|
||||
});
|
||||
|
||||
// Host allowlist / DNS rebinding protection (vite-like semantics).
|
||||
// For non-TCP (unix sockets), skip host validation.
|
||||
if (listenTarget.type === "tcp") {
|
||||
@@ -237,6 +259,14 @@ export async function createPaseoDaemon(
|
||||
});
|
||||
}
|
||||
|
||||
// Service proxy — intercepts requests for registered *.localhost hostnames
|
||||
// and forwards them to the corresponding local service port. Placed after
|
||||
// the host allowlist (*.localhost is already allowed) but before CORS and
|
||||
// the rest of the routes so proxied requests skip unnecessary middleware.
|
||||
app.use(
|
||||
createServiceProxyMiddleware({ routeStore: serviceRouteStore, logger }),
|
||||
);
|
||||
|
||||
// CORS - allow same-origin + configured origins
|
||||
const allowedOrigins = new Set([
|
||||
...config.corsAllowedOrigins,
|
||||
@@ -361,6 +391,16 @@ export async function createPaseoDaemon(
|
||||
database = await openPaseoDatabase(path.join(config.paseoHome, "db"));
|
||||
logger.info({ elapsed: elapsed() }, "Paseo database opened");
|
||||
|
||||
// Service proxy WebSocket upgrade handler — must be registered before the
|
||||
// VoiceAssistantWebSocketServer attaches its own "upgrade" listener so that
|
||||
// service-bound upgrades are forwarded first. The handler is a no-op for
|
||||
// requests that don't match a registered service route.
|
||||
const serviceProxyUpgradeHandler = createServiceProxyUpgradeHandler({
|
||||
routeStore: serviceRouteStore,
|
||||
logger,
|
||||
});
|
||||
httpServer.on("upgrade", serviceProxyUpgradeHandler);
|
||||
|
||||
const agentStorage = new DbAgentSnapshotStore(database.db);
|
||||
const chatService = new FileBackedChatService({
|
||||
paseoHome: config.paseoHome,
|
||||
@@ -451,7 +491,6 @@ export async function createPaseoDaemon(
|
||||
"Voice mode configured for agent-scoped resume flow (no dedicated voice assistant provider)",
|
||||
);
|
||||
logger.info({ elapsed: elapsed() }, "Preparing voice and MCP runtime");
|
||||
let wsServer: VoiceAssistantWebSocketServer | null = null;
|
||||
let voiceMcpBridgeManager: VoiceMcpSocketBridgeManager | null = null;
|
||||
|
||||
// Create in-memory transport for Session's Agent MCP client (voice assistant tools)
|
||||
@@ -460,6 +499,9 @@ export async function createPaseoDaemon(
|
||||
agentManager,
|
||||
agentStorage,
|
||||
terminalManager,
|
||||
serviceRouteStore,
|
||||
getDaemonTcpPort: () =>
|
||||
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
|
||||
paseoHome: config.paseoHome,
|
||||
enableVoiceTools: false,
|
||||
resolveSpeakHandler: (callerAgentId) =>
|
||||
@@ -486,6 +528,9 @@ export async function createPaseoDaemon(
|
||||
agentManager,
|
||||
agentStorage,
|
||||
terminalManager,
|
||||
serviceRouteStore,
|
||||
getDaemonTcpPort: () =>
|
||||
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
|
||||
paseoHome: config.paseoHome,
|
||||
callerAgentId,
|
||||
enableVoiceTools: false,
|
||||
@@ -606,6 +651,9 @@ export async function createPaseoDaemon(
|
||||
agentManager,
|
||||
agentStorage,
|
||||
terminalManager,
|
||||
serviceRouteStore,
|
||||
getDaemonTcpPort: () =>
|
||||
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
|
||||
paseoHome: config.paseoHome,
|
||||
callerAgentId,
|
||||
voiceOnly: true,
|
||||
@@ -666,6 +714,9 @@ export async function createPaseoDaemon(
|
||||
loopService,
|
||||
scheduleService,
|
||||
checkoutDiffManager,
|
||||
serviceRouteStore,
|
||||
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
||||
(hostname) => serviceHealthMonitor.getStatusForHostname(hostname),
|
||||
);
|
||||
|
||||
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
|
||||
@@ -756,10 +807,12 @@ export async function createPaseoDaemon(
|
||||
// Start speech service after listening so synchronous Sherpa native
|
||||
// model loading doesn't block the server from accepting connections.
|
||||
speechService.start();
|
||||
serviceHealthMonitor.start();
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
reconciliationService.stop();
|
||||
serviceHealthMonitor.stop();
|
||||
await closeAllAgents(logger, agentManager);
|
||||
await agentManager.flush().catch(() => undefined);
|
||||
await shutdownProviders(logger, {
|
||||
@@ -790,6 +843,7 @@ export async function createPaseoDaemon(
|
||||
agentManager,
|
||||
agentStorage,
|
||||
terminalManager,
|
||||
serviceRouteStore,
|
||||
start,
|
||||
stop,
|
||||
getListenTarget: () => boundListenTarget,
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
import net from "node:net";
|
||||
import { scheduler } from "node:timers/promises";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { findFreePort, ServiceRouteStore } from "./service-proxy.js";
|
||||
import {
|
||||
ServiceHealthMonitor,
|
||||
type ServiceStatusEntry,
|
||||
} from "./service-health-monitor.js";
|
||||
|
||||
type TcpServerHandle = {
|
||||
port: number;
|
||||
server: net.Server;
|
||||
};
|
||||
|
||||
async function startTcpServer(): Promise<TcpServerHandle> {
|
||||
const server = net.createServer((socket) => {
|
||||
socket.end();
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Failed to resolve TCP server address");
|
||||
}
|
||||
|
||||
return { port: address.port, server };
|
||||
}
|
||||
|
||||
async function closeServer(server: net.Server): Promise<void> {
|
||||
if (!server.listening) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function advancePoll(ms: number): Promise<void> {
|
||||
await vi.advanceTimersByTimeAsync(ms);
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await scheduler.yield();
|
||||
}
|
||||
}
|
||||
|
||||
describe("ServiceHealthMonitor", () => {
|
||||
const servers = new Set<net.Server>();
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
for (const server of servers) {
|
||||
await closeServer(server);
|
||||
}
|
||||
servers.clear();
|
||||
});
|
||||
|
||||
it("marks a healthy port as running after successful TCP connect", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const healthy = await startTcpServer();
|
||||
servers.add(healthy.server);
|
||||
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: healthy.port,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
graceMs: 0,
|
||||
});
|
||||
|
||||
monitor.start();
|
||||
await advancePoll(1_000);
|
||||
monitor.stop();
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith("workspace-a", [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: healthy.port,
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks an unreachable port as stopped after consecutive failures", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const deadPort = await findFreePort();
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: deadPort,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
graceMs: 0,
|
||||
failuresBeforeStopped: 2,
|
||||
});
|
||||
|
||||
monitor.start();
|
||||
await advancePoll(1_000);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
await advancePoll(1_000);
|
||||
monitor.stop();
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith("workspace-a", [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: deadPort,
|
||||
status: "stopped",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not emit when status has not changed", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const healthy = await startTcpServer();
|
||||
servers.add(healthy.server);
|
||||
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: healthy.port,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
graceMs: 0,
|
||||
});
|
||||
|
||||
monitor.start();
|
||||
await advancePoll(3_000);
|
||||
monitor.stop();
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("respects startup grace period — does not probe newly registered routes for 5 seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const healthy = await startTcpServer();
|
||||
servers.add(healthy.server);
|
||||
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: healthy.port,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
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", [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: healthy.port,
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires 2 consecutive failures before marking stopped (debounce)", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const healthy = await startTcpServer();
|
||||
servers.add(healthy.server);
|
||||
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: healthy.port,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
graceMs: 0,
|
||||
failuresBeforeStopped: 2,
|
||||
});
|
||||
|
||||
monitor.start();
|
||||
await advancePoll(1_000);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
await closeServer(healthy.server);
|
||||
servers.delete(healthy.server);
|
||||
|
||||
await advancePoll(1_000);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
await advancePoll(1_000);
|
||||
monitor.stop();
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(2);
|
||||
expect(onChange).toHaveBeenLastCalledWith("workspace-a", [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: healthy.port,
|
||||
status: "stopped",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("stops probing routes that are removed from the store", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const healthy = await startTcpServer();
|
||||
servers.add(healthy.server);
|
||||
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: healthy.port,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
graceMs: 0,
|
||||
failuresBeforeStopped: 2,
|
||||
});
|
||||
|
||||
monitor.start();
|
||||
await advancePoll(1_000);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
routeStore.removeRoute("api.localhost");
|
||||
await closeServer(healthy.server);
|
||||
servers.delete(healthy.server);
|
||||
|
||||
await advancePoll(3_000);
|
||||
monitor.stop();
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onChange with workspaceId and full service list when status transitions", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const api = await startTcpServer();
|
||||
const web = await startTcpServer();
|
||||
servers.add(api.server);
|
||||
servers.add(web.server);
|
||||
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: api.port,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
routeStore.registerRoute({
|
||||
hostname: "web.localhost",
|
||||
port: web.port,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "web",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
graceMs: 0,
|
||||
});
|
||||
|
||||
monitor.start();
|
||||
await advancePoll(1_000);
|
||||
monitor.stop();
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith("workspace-a", [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: api.port,
|
||||
status: "running",
|
||||
},
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: web.port,
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("getStatusForHostname returns current status after probe", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const healthy = await startTcpServer();
|
||||
servers.add(healthy.server);
|
||||
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: healthy.port,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
graceMs: 0,
|
||||
});
|
||||
|
||||
expect(monitor.getStatusForHostname("api.localhost")).toBeNull();
|
||||
|
||||
monitor.start();
|
||||
await advancePoll(1_000);
|
||||
monitor.stop();
|
||||
|
||||
expect(monitor.getStatusForHostname("api.localhost")).toBe("running");
|
||||
expect(monitor.getStatusForHostname("unknown.localhost")).toBeNull();
|
||||
});
|
||||
|
||||
it("coalesces multiple service changes in same workspace into one onChange call per poll cycle", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const api = await startTcpServer();
|
||||
const web = await startTcpServer();
|
||||
servers.add(api.server);
|
||||
servers.add(web.server);
|
||||
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: api.port,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
routeStore.registerRoute({
|
||||
hostname: "web.localhost",
|
||||
port: web.port,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "web",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
pollIntervalMs: 1_000,
|
||||
probeTimeoutMs: 100,
|
||||
graceMs: 0,
|
||||
failuresBeforeStopped: 2,
|
||||
});
|
||||
|
||||
monitor.start();
|
||||
await advancePoll(1_000);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
onChange.mockClear();
|
||||
await closeServer(api.server);
|
||||
await closeServer(web.server);
|
||||
servers.delete(api.server);
|
||||
servers.delete(web.server);
|
||||
|
||||
await advancePoll(1_000);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
await advancePoll(1_000);
|
||||
monitor.stop();
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith("workspace-a", [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: api.port,
|
||||
status: "stopped",
|
||||
},
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: web.port,
|
||||
status: "stopped",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import net from "node:net";
|
||||
import type { ServiceRouteEntry, ServiceRouteStore } from "./service-proxy.js";
|
||||
|
||||
export interface ServiceStatusEntry {
|
||||
serviceName: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
status: "running" | "stopped";
|
||||
}
|
||||
|
||||
type RouteHealthState = {
|
||||
status: ServiceStatusEntry["status"] | null;
|
||||
consecutiveFailures: number;
|
||||
registeredAt: number;
|
||||
};
|
||||
|
||||
export class ServiceHealthMonitor {
|
||||
private readonly routeStore: ServiceRouteStore;
|
||||
private readonly onChange: (
|
||||
workspaceId: string,
|
||||
services: ServiceStatusEntry[],
|
||||
) => void;
|
||||
private readonly pollIntervalMs: number;
|
||||
private readonly probeTimeoutMs: number;
|
||||
private readonly graceMs: number;
|
||||
private readonly failuresBeforeStopped: number;
|
||||
private readonly routeStates = new Map<string, RouteHealthState>();
|
||||
private readonly lastEmittedSnapshots = new Map<string, string>();
|
||||
|
||||
private intervalHandle: NodeJS.Timeout | null = null;
|
||||
private pollInFlight = false;
|
||||
|
||||
constructor({
|
||||
routeStore,
|
||||
onChange,
|
||||
pollIntervalMs = 3_000,
|
||||
probeTimeoutMs = 500,
|
||||
graceMs = 5_000,
|
||||
failuresBeforeStopped = 2,
|
||||
}: {
|
||||
routeStore: ServiceRouteStore;
|
||||
onChange: (workspaceId: string, services: ServiceStatusEntry[]) => void;
|
||||
pollIntervalMs?: number;
|
||||
probeTimeoutMs?: number;
|
||||
graceMs?: number;
|
||||
failuresBeforeStopped?: number;
|
||||
}) {
|
||||
this.routeStore = routeStore;
|
||||
this.onChange = onChange;
|
||||
this.pollIntervalMs = pollIntervalMs;
|
||||
this.probeTimeoutMs = probeTimeoutMs;
|
||||
this.graceMs = graceMs;
|
||||
this.failuresBeforeStopped = failuresBeforeStopped;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.intervalHandle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
for (const route of this.routeStore.listRoutes()) {
|
||||
this.getOrCreateState(route.hostname, now);
|
||||
}
|
||||
|
||||
this.intervalHandle = setInterval(() => {
|
||||
void this.poll();
|
||||
}, this.pollIntervalMs);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.intervalHandle) {
|
||||
clearInterval(this.intervalHandle);
|
||||
this.intervalHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async poll(): Promise<void> {
|
||||
if (this.pollInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pollInFlight = true;
|
||||
try {
|
||||
const routes = this.routeStore.listRoutes();
|
||||
const activeHostnames = new Set(routes.map((route) => route.hostname));
|
||||
const changedWorkspaceIds = new Set<string>();
|
||||
const now = Date.now();
|
||||
|
||||
for (const route of routes) {
|
||||
const state = this.getOrCreateState(route.hostname, now);
|
||||
if (now - state.registeredAt < this.graceMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isHealthy = await this.probeRoute(route.port);
|
||||
const previousStatus = state.status;
|
||||
|
||||
if (isHealthy) {
|
||||
state.consecutiveFailures = 0;
|
||||
state.status = "running";
|
||||
} else {
|
||||
state.consecutiveFailures += 1;
|
||||
if (state.consecutiveFailures >= this.failuresBeforeStopped) {
|
||||
state.status = "stopped";
|
||||
}
|
||||
}
|
||||
|
||||
if (state.status !== null && state.status !== previousStatus) {
|
||||
changedWorkspaceIds.add(route.workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
this.pruneRemovedRoutes(activeHostnames);
|
||||
|
||||
for (const workspaceId of changedWorkspaceIds) {
|
||||
const services = this.buildWorkspaceServiceList(workspaceId);
|
||||
const snapshot = JSON.stringify(services);
|
||||
if (snapshot === this.lastEmittedSnapshots.get(workspaceId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.lastEmittedSnapshots.set(workspaceId, snapshot);
|
||||
this.onChange(workspaceId, services);
|
||||
}
|
||||
} finally {
|
||||
this.pollInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
private getOrCreateState(hostname: string, registeredAt: number): RouteHealthState {
|
||||
const existing = this.routeStates.get(hostname);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const state: RouteHealthState = {
|
||||
status: null,
|
||||
consecutiveFailures: 0,
|
||||
registeredAt,
|
||||
};
|
||||
this.routeStates.set(hostname, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
private pruneRemovedRoutes(activeHostnames: Set<string>): void {
|
||||
for (const hostname of this.routeStates.keys()) {
|
||||
if (activeHostnames.has(hostname)) {
|
||||
continue;
|
||||
}
|
||||
this.routeStates.delete(hostname);
|
||||
}
|
||||
}
|
||||
|
||||
private buildWorkspaceServiceList(workspaceId: string): ServiceStatusEntry[] {
|
||||
return this.routeStore
|
||||
.listRoutesForWorkspace(workspaceId)
|
||||
.flatMap((route) => {
|
||||
const state = this.routeStates.get(route.hostname);
|
||||
if (!state?.status) {
|
||||
return [];
|
||||
}
|
||||
return [this.toServiceStatusEntry(route, state.status)];
|
||||
});
|
||||
}
|
||||
|
||||
getStatusForHostname(hostname: string): ServiceStatusEntry["status"] | null {
|
||||
return this.routeStates.get(hostname)?.status ?? null;
|
||||
}
|
||||
|
||||
private toServiceStatusEntry(
|
||||
route: ServiceRouteEntry,
|
||||
status: ServiceStatusEntry["status"],
|
||||
): ServiceStatusEntry {
|
||||
return {
|
||||
serviceName: route.serviceName,
|
||||
hostname: route.hostname,
|
||||
port: route.port,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
private probeRoute(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect({ host: "127.0.0.1", port });
|
||||
let settled = false;
|
||||
|
||||
const finish = (healthy: boolean) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
resolve(healthy);
|
||||
};
|
||||
|
||||
socket.setTimeout(this.probeTimeoutMs);
|
||||
socket.once("connect", () => finish(true));
|
||||
socket.once("timeout", () => finish(false));
|
||||
socket.once("error", () => finish(false));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import express from "express";
|
||||
import WebSocket, { WebSocketServer } from "ws";
|
||||
import pino from "pino";
|
||||
import {
|
||||
ServiceRouteStore,
|
||||
createServiceProxyMiddleware,
|
||||
createServiceProxyUpgradeHandler,
|
||||
findFreePort,
|
||||
} from "./service-proxy.js";
|
||||
|
||||
const logger = pino({ level: "silent" });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers for cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function closeServer(server: http.Server): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ServiceRouteStore
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ServiceRouteStore", () => {
|
||||
it("registerRoute and findRoute with exact match", () => {
|
||||
const store = new ServiceRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "editor.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "editor",
|
||||
});
|
||||
|
||||
const route = store.findRoute("editor.localhost");
|
||||
expect(route).toEqual({ hostname: "editor.localhost", port: 3000 });
|
||||
});
|
||||
|
||||
it("findRoute strips port from host header", () => {
|
||||
const store = new ServiceRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "editor.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "editor",
|
||||
});
|
||||
|
||||
const route = store.findRoute("editor.localhost:6767");
|
||||
expect(route).toEqual({ hostname: "editor.localhost", port: 3000 });
|
||||
});
|
||||
|
||||
it("findRoute subdomain match", () => {
|
||||
const store = new ServiceRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "editor.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "editor",
|
||||
});
|
||||
|
||||
const route = store.findRoute("fix-auth.editor.localhost");
|
||||
expect(route).toEqual({ hostname: "editor.localhost", port: 3000 });
|
||||
});
|
||||
|
||||
it("listRoutes returns enriched entries", () => {
|
||||
const store = new ServiceRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "a.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "web",
|
||||
});
|
||||
store.registerRoute({
|
||||
hostname: "b.localhost",
|
||||
port: 4000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-b",
|
||||
serviceName: "docs",
|
||||
});
|
||||
|
||||
const routes = store.listRoutes();
|
||||
expect(routes).toHaveLength(2);
|
||||
expect(routes).toContainEqual({
|
||||
hostname: "a.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "web",
|
||||
});
|
||||
expect(routes).toContainEqual({
|
||||
hostname: "b.localhost",
|
||||
port: 4000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-b",
|
||||
serviceName: "docs",
|
||||
});
|
||||
});
|
||||
|
||||
it("listRoutesForWorkspace returns only routes for that workspace", () => {
|
||||
const store = new ServiceRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "a.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "web",
|
||||
});
|
||||
store.registerRoute({
|
||||
hostname: "b.localhost",
|
||||
port: 4000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-b",
|
||||
serviceName: "docs",
|
||||
});
|
||||
store.registerRoute({
|
||||
hostname: "c.localhost",
|
||||
port: 5000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([
|
||||
{
|
||||
hostname: "a.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "web",
|
||||
},
|
||||
{
|
||||
hostname: "c.localhost",
|
||||
port: 5000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "api",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("removeRoute works", () => {
|
||||
const store = new ServiceRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "editor.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "editor",
|
||||
});
|
||||
store.removeRoute("editor.localhost");
|
||||
|
||||
expect(store.findRoute("editor.localhost")).toBeNull();
|
||||
});
|
||||
|
||||
it("removeRoute cleans up workspace index", () => {
|
||||
const store = new ServiceRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "editor.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "editor",
|
||||
});
|
||||
|
||||
store.removeRoute("editor.localhost");
|
||||
|
||||
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]);
|
||||
});
|
||||
|
||||
it("removeRoutesForPort works", () => {
|
||||
const store = new ServiceRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "a.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "web",
|
||||
});
|
||||
store.registerRoute({
|
||||
hostname: "b.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
store.registerRoute({
|
||||
hostname: "c.localhost",
|
||||
port: 4000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-b",
|
||||
serviceName: "docs",
|
||||
});
|
||||
|
||||
store.removeRoutesForPort(3000);
|
||||
|
||||
expect(store.findRoute("a.localhost")).toBeNull();
|
||||
expect(store.findRoute("b.localhost")).toBeNull();
|
||||
expect(store.findRoute("c.localhost")).toEqual({
|
||||
hostname: "c.localhost",
|
||||
port: 4000,
|
||||
});
|
||||
});
|
||||
|
||||
it("removeRoutesForPort cleans up workspace index", () => {
|
||||
const store = new ServiceRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "a.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "web",
|
||||
});
|
||||
store.registerRoute({
|
||||
hostname: "b.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
store.removeRoutesForPort(3000);
|
||||
|
||||
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]);
|
||||
});
|
||||
|
||||
it("findRoute returns null for unknown hosts", () => {
|
||||
const store = new ServiceRouteStore();
|
||||
store.registerRoute({
|
||||
hostname: "editor.localhost",
|
||||
port: 3000,
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
serviceName: "editor",
|
||||
});
|
||||
|
||||
expect(store.findRoute("unknown.example.com")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP proxy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("HTTP proxy", () => {
|
||||
const servers: http.Server[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.map(closeServer));
|
||||
servers.length = 0;
|
||||
});
|
||||
|
||||
/** Start a real HTTP server that echoes back a known body and records received headers. */
|
||||
async function startUpstream(): Promise<{
|
||||
port: number;
|
||||
server: http.Server;
|
||||
receivedHeaders: () => http.IncomingHttpHeaders;
|
||||
}> {
|
||||
const port = await findFreePort();
|
||||
let lastHeaders: http.IncomingHttpHeaders = {};
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
lastHeaders = req.headers;
|
||||
res.writeHead(200, { "content-type": "text/plain" });
|
||||
res.end("upstream-ok");
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(port, "127.0.0.1", resolve),
|
||||
);
|
||||
servers.push(server);
|
||||
|
||||
return {
|
||||
port,
|
||||
server,
|
||||
receivedHeaders: () => lastHeaders,
|
||||
};
|
||||
}
|
||||
|
||||
/** Start an Express app with the service proxy middleware and an optional fallback. */
|
||||
async function startProxy(
|
||||
routeStore: ServiceRouteStore,
|
||||
opts?: { fallback?: boolean },
|
||||
): Promise<{ port: number; server: http.Server }> {
|
||||
const port = await findFreePort();
|
||||
const app = express();
|
||||
app.use(createServiceProxyMiddleware({ routeStore, logger }));
|
||||
|
||||
if (opts?.fallback) {
|
||||
app.use((_req, res) => {
|
||||
res.status(404).send("no route");
|
||||
});
|
||||
}
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(port, "127.0.0.1", resolve),
|
||||
);
|
||||
servers.push(server);
|
||||
|
||||
return { port, server };
|
||||
}
|
||||
|
||||
/** Simple HTTP GET helper that returns status code and body. */
|
||||
function httpGet(
|
||||
port: number,
|
||||
host: string,
|
||||
path = "/",
|
||||
): Promise<{ status: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.get(
|
||||
{ hostname: "127.0.0.1", port, path, headers: { host } },
|
||||
(res) => {
|
||||
let body = "";
|
||||
res.on("data", (chunk: Buffer) => (body += chunk.toString()));
|
||||
res.on("end", () =>
|
||||
resolve({ status: res.statusCode ?? 0, body }),
|
||||
);
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
it("proxies requests to the correct upstream based on Host header", async () => {
|
||||
const upstream = await startUpstream();
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.addRoute("test-service.localhost", upstream.port);
|
||||
|
||||
const proxy = await startProxy(routeStore);
|
||||
const res = await httpGet(
|
||||
proxy.port,
|
||||
`test-service.localhost:${proxy.port}`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toBe("upstream-ok");
|
||||
|
||||
const headers = upstream.receivedHeaders();
|
||||
expect(headers["x-forwarded-for"]).toBeDefined();
|
||||
expect(headers["x-forwarded-host"]).toBe("test-service.localhost");
|
||||
});
|
||||
|
||||
it("falls through when no route matches", async () => {
|
||||
const routeStore = new ServiceRouteStore();
|
||||
const proxy = await startProxy(routeStore, { fallback: true });
|
||||
|
||||
const res = await httpGet(
|
||||
proxy.port,
|
||||
`unknown.localhost:${proxy.port}`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toBe("no route");
|
||||
});
|
||||
|
||||
it("returns 502 when upstream is down", async () => {
|
||||
// Get a port that nothing is listening on
|
||||
const deadPort = await findFreePort();
|
||||
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.addRoute("dead-service.localhost", deadPort);
|
||||
|
||||
const proxy = await startProxy(routeStore);
|
||||
const res = await httpGet(
|
||||
proxy.port,
|
||||
`dead-service.localhost:${proxy.port}`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.body).toBe("502 Bad Gateway");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket proxy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("WebSocket proxy", () => {
|
||||
const httpServers: http.Server[] = [];
|
||||
const wsServers: WebSocketServer[] = [];
|
||||
const wsClients: WebSocket[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const ws of wsClients) {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close();
|
||||
}
|
||||
wsClients.length = 0;
|
||||
|
||||
for (const wss of wsServers) {
|
||||
wss.close();
|
||||
}
|
||||
wsServers.length = 0;
|
||||
|
||||
await Promise.all(httpServers.map(closeServer));
|
||||
httpServers.length = 0;
|
||||
});
|
||||
|
||||
it("proxies WebSocket connections to the correct upstream", async () => {
|
||||
// 1. Start a real WebSocket echo server
|
||||
const upstreamPort = await findFreePort();
|
||||
const upstreamServer = http.createServer();
|
||||
const wss = new WebSocketServer({ server: upstreamServer });
|
||||
wsServers.push(wss);
|
||||
|
||||
wss.on("connection", (ws) => {
|
||||
ws.on("message", (data) => {
|
||||
ws.send(`echo: ${data.toString()}`);
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) =>
|
||||
upstreamServer.listen(upstreamPort, "127.0.0.1", resolve),
|
||||
);
|
||||
httpServers.push(upstreamServer);
|
||||
|
||||
// 2. Create the proxy server with the upgrade handler
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.addRoute("ws-service.localhost", upstreamPort);
|
||||
|
||||
const proxyPort = await findFreePort();
|
||||
const proxyServer = http.createServer((_req, res) => {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
const upgradeHandler = createServiceProxyUpgradeHandler({
|
||||
routeStore,
|
||||
logger,
|
||||
});
|
||||
proxyServer.on("upgrade", upgradeHandler);
|
||||
|
||||
await new Promise<void>((resolve) =>
|
||||
proxyServer.listen(proxyPort, "127.0.0.1", resolve),
|
||||
);
|
||||
httpServers.push(proxyServer);
|
||||
|
||||
// 3. Connect a WebSocket client through the proxy
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}`, {
|
||||
headers: { host: `ws-service.localhost:${proxyPort}` },
|
||||
});
|
||||
wsClients.push(ws);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.on("open", resolve);
|
||||
ws.on("error", reject);
|
||||
});
|
||||
|
||||
// 4. Send a message and verify echo
|
||||
const reply = await new Promise<string>((resolve, reject) => {
|
||||
ws.on("message", (data) => resolve(data.toString()));
|
||||
ws.on("error", reject);
|
||||
ws.send("hello proxy");
|
||||
});
|
||||
|
||||
expect(reply).toBe("echo: hello proxy");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findFreePort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("findFreePort", () => {
|
||||
it("returns a number", async () => {
|
||||
const port = await findFreePort();
|
||||
expect(typeof port).toBe("number");
|
||||
expect(port).toBeGreaterThan(0);
|
||||
expect(port).toBeLessThan(65536);
|
||||
});
|
||||
|
||||
it("returns a port that is actually available", async () => {
|
||||
const port = await findFreePort();
|
||||
|
||||
// Verify we can bind a server to it
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.listen(port, "127.0.0.1", () => resolve());
|
||||
server.on("error", reject);
|
||||
});
|
||||
|
||||
const addr = server.address();
|
||||
expect(addr).not.toBeNull();
|
||||
expect(typeof addr === "object" && addr !== null ? addr.port : -1).toBe(
|
||||
port,
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import type { Logger } from "pino";
|
||||
import type { RequestHandler } from "express";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hop-by-hop headers that must not be forwarded
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const HOP_BY_HOP_HEADERS = new Set([
|
||||
"connection",
|
||||
"transfer-encoding",
|
||||
"keep-alive",
|
||||
"upgrade",
|
||||
"proxy-connection",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ServiceRouteStore
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ServiceRoute {
|
||||
hostname: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface ServiceRouteEntry extends ServiceRoute {
|
||||
workspaceId: string;
|
||||
serviceName: string;
|
||||
}
|
||||
|
||||
export class ServiceRouteStore {
|
||||
private routes = new Map<string, ServiceRouteEntry>();
|
||||
private workspaceHostnames = new Map<string, Set<string>>();
|
||||
|
||||
addRoute(hostname: string, port: number): void {
|
||||
this.registerRoute({
|
||||
hostname,
|
||||
port,
|
||||
workspaceId: "",
|
||||
serviceName: hostname,
|
||||
});
|
||||
}
|
||||
|
||||
registerRoute(entry: ServiceRouteEntry): void {
|
||||
const previous = this.routes.get(entry.hostname);
|
||||
if (previous) {
|
||||
this.removeHostnameFromWorkspaceIndex(previous.workspaceId, previous.hostname);
|
||||
}
|
||||
|
||||
const storedEntry = { ...entry };
|
||||
this.routes.set(storedEntry.hostname, storedEntry);
|
||||
this.addHostnameToWorkspaceIndex(storedEntry.workspaceId, storedEntry.hostname);
|
||||
}
|
||||
|
||||
removeRoute(hostname: string): void {
|
||||
const entry = this.routes.get(hostname);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
this.routes.delete(hostname);
|
||||
this.removeHostnameFromWorkspaceIndex(entry.workspaceId, hostname);
|
||||
}
|
||||
|
||||
removeRoutesForPort(port: number): void {
|
||||
for (const [hostname, entry] of this.routes) {
|
||||
if (entry.port === port) {
|
||||
this.routes.delete(hostname);
|
||||
this.removeHostnameFromWorkspaceIndex(entry.workspaceId, hostname);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findRoute(host: string): ServiceRoute | null {
|
||||
// Strip port suffix from the Host header value
|
||||
const hostname = host.replace(/:\d+$/, "");
|
||||
|
||||
// 1. Exact match
|
||||
const exactRoute = this.routes.get(hostname);
|
||||
if (exactRoute !== undefined) {
|
||||
return { hostname: exactRoute.hostname, port: exactRoute.port };
|
||||
}
|
||||
|
||||
// 2. Subdomain match — walk up the labels looking for a registered parent
|
||||
const parts = hostname.split(".");
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const candidate = parts.slice(i).join(".");
|
||||
const candidateRoute = this.routes.get(candidate);
|
||||
if (candidateRoute !== undefined) {
|
||||
return { hostname: candidateRoute.hostname, port: candidateRoute.port };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
listRoutes(): ServiceRouteEntry[] {
|
||||
return Array.from(this.routes.values()).map((entry) => ({ ...entry }));
|
||||
}
|
||||
|
||||
listRoutesForWorkspace(workspaceId: string): ServiceRouteEntry[] {
|
||||
const hostnames = this.workspaceHostnames.get(workspaceId);
|
||||
if (!hostnames) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const routes: ServiceRouteEntry[] = [];
|
||||
for (const hostname of hostnames) {
|
||||
const entry = this.routes.get(hostname);
|
||||
if (entry) {
|
||||
routes.push({ ...entry });
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
private addHostnameToWorkspaceIndex(workspaceId: string, hostname: string): void {
|
||||
const hostnames = this.workspaceHostnames.get(workspaceId) ?? new Set<string>();
|
||||
hostnames.add(hostname);
|
||||
this.workspaceHostnames.set(workspaceId, hostnames);
|
||||
}
|
||||
|
||||
private removeHostnameFromWorkspaceIndex(workspaceId: string, hostname: string): void {
|
||||
const hostnames = this.workspaceHostnames.get(workspaceId);
|
||||
if (!hostnames) {
|
||||
return;
|
||||
}
|
||||
|
||||
hostnames.delete(hostname);
|
||||
if (hostnames.size === 0) {
|
||||
this.workspaceHostnames.delete(workspaceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function stripHopByHopHeaders(
|
||||
rawHeaders: http.IncomingHttpHeaders,
|
||||
): Record<string, string | string[]> {
|
||||
const out: Record<string, string | string[]> = {};
|
||||
for (const [key, value] of Object.entries(rawHeaders)) {
|
||||
if (value === undefined) continue;
|
||||
if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue;
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createServiceProxyMiddleware
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createServiceProxyMiddleware({
|
||||
routeStore,
|
||||
logger,
|
||||
}: {
|
||||
routeStore: ServiceRouteStore;
|
||||
logger: Logger;
|
||||
}): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
const hostHeader = req.headers.host;
|
||||
if (!hostHeader) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const route = routeStore.findRoute(hostHeader);
|
||||
if (!route) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const forwardedHeaders = stripHopByHopHeaders(req.headers);
|
||||
forwardedHeaders["x-forwarded-for"] =
|
||||
req.socket.remoteAddress ?? "127.0.0.1";
|
||||
forwardedHeaders["x-forwarded-host"] = hostHeader.replace(/:\d+$/, "");
|
||||
forwardedHeaders["x-forwarded-proto"] = req.protocol;
|
||||
|
||||
const proxyReq = http.request(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port: route.port,
|
||||
path: req.originalUrl,
|
||||
method: req.method,
|
||||
headers: forwardedHeaders,
|
||||
},
|
||||
(proxyRes) => {
|
||||
const responseHeaders = stripHopByHopHeaders(proxyRes.headers);
|
||||
res.writeHead(proxyRes.statusCode ?? 502, responseHeaders);
|
||||
proxyRes.pipe(res, { end: true });
|
||||
},
|
||||
);
|
||||
|
||||
proxyReq.on("error", (err) => {
|
||||
logger.warn(
|
||||
{ err, hostname: route.hostname, port: route.port },
|
||||
"Service proxy: upstream unreachable",
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502, { "content-type": "text/plain" });
|
||||
res.end("502 Bad Gateway");
|
||||
}
|
||||
});
|
||||
|
||||
req.pipe(proxyReq, { end: true });
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createServiceProxyUpgradeHandler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createServiceProxyUpgradeHandler({
|
||||
routeStore,
|
||||
logger,
|
||||
}: {
|
||||
routeStore: ServiceRouteStore;
|
||||
logger: Logger;
|
||||
}): (req: IncomingMessage, socket: net.Socket, head: Buffer) => void {
|
||||
return (req, socket, head) => {
|
||||
const hostHeader = req.headers.host;
|
||||
if (!hostHeader) {
|
||||
return;
|
||||
}
|
||||
|
||||
const route = routeStore.findRoute(hostHeader);
|
||||
if (!route) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetSocket = net.connect(
|
||||
{ host: "127.0.0.1", port: route.port },
|
||||
() => {
|
||||
// Reconstruct the raw HTTP upgrade request to send to the target
|
||||
const forwardedHeaders = stripHopByHopHeaders(req.headers);
|
||||
forwardedHeaders["x-forwarded-for"] =
|
||||
req.socket.remoteAddress ?? "127.0.0.1";
|
||||
forwardedHeaders["x-forwarded-host"] = hostHeader.replace(/:\d+$/, "");
|
||||
forwardedHeaders["x-forwarded-proto"] = "http";
|
||||
|
||||
// Re-include upgrade and connection headers — they are required for
|
||||
// WebSocket handshake even though they are hop-by-hop.
|
||||
forwardedHeaders["connection"] = "Upgrade";
|
||||
forwardedHeaders["upgrade"] = req.headers.upgrade ?? "websocket";
|
||||
|
||||
const headerLines: string[] = [];
|
||||
headerLines.push(
|
||||
`${req.method ?? "GET"} ${req.url ?? "/"} HTTP/${req.httpVersion}`,
|
||||
);
|
||||
for (const [key, value] of Object.entries(forwardedHeaders)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const v of value) {
|
||||
headerLines.push(`${key}: ${v}`);
|
||||
}
|
||||
} else {
|
||||
headerLines.push(`${key}: ${value}`);
|
||||
}
|
||||
}
|
||||
headerLines.push("\r\n");
|
||||
|
||||
targetSocket.write(headerLines.join("\r\n"));
|
||||
|
||||
if (head.length > 0) {
|
||||
targetSocket.write(head);
|
||||
}
|
||||
|
||||
// Pipe in both directions
|
||||
targetSocket.pipe(socket);
|
||||
socket.pipe(targetSocket);
|
||||
},
|
||||
);
|
||||
|
||||
targetSocket.on("error", (err) => {
|
||||
logger.warn(
|
||||
{ err, hostname: route.hostname, port: route.port },
|
||||
"Service proxy: WebSocket upstream unreachable",
|
||||
);
|
||||
socket.end();
|
||||
});
|
||||
|
||||
socket.on("error", () => {
|
||||
targetSocket.destroy();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findFreePort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function findFreePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close();
|
||||
reject(new Error("Failed to get assigned port"));
|
||||
return;
|
||||
}
|
||||
const { port } = address;
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(port);
|
||||
}
|
||||
});
|
||||
});
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ServiceRouteStore } from "./service-proxy.js";
|
||||
import {
|
||||
buildWorkspaceServicePayloads,
|
||||
createServiceStatusEmitter,
|
||||
} from "./service-status-projection.js";
|
||||
|
||||
describe("service-status-projection", () => {
|
||||
it("buildWorkspaceServicePayloads returns service payloads from workspace routes", () => {
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
routeStore.registerRoute({
|
||||
hostname: "docs.localhost",
|
||||
port: 3002,
|
||||
workspaceId: "workspace-b",
|
||||
serviceName: "docs",
|
||||
});
|
||||
routeStore.registerRoute({
|
||||
hostname: "web.localhost",
|
||||
port: 3003,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "web",
|
||||
});
|
||||
|
||||
expect(buildWorkspaceServicePayloads(routeStore, "workspace-a", 6767)).toEqual([
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
url: "http://api.localhost:6767",
|
||||
status: "stopped",
|
||||
},
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: 3003,
|
||||
url: "http://web.localhost:6767",
|
||||
status: "stopped",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("computes URLs with and without a daemon port", () => {
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
expect(buildWorkspaceServicePayloads(routeStore, "workspace-a", 6767)).toEqual([
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
url: "http://api.localhost:6767",
|
||||
status: "stopped",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(buildWorkspaceServicePayloads(routeStore, "workspace-a", null)).toEqual([
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
url: null,
|
||||
status: "stopped",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("createServiceStatusEmitter emits updates to all active sessions", () => {
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
const sessionA = { emit: vi.fn() };
|
||||
const sessionB = { emit: vi.fn() };
|
||||
|
||||
const emitUpdate = createServiceStatusEmitter({
|
||||
sessions: () => [sessionA, sessionB],
|
||||
routeStore,
|
||||
daemonPort: 6767,
|
||||
});
|
||||
|
||||
emitUpdate("workspace-a", [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(sessionA.emit).toHaveBeenCalledWith({
|
||||
type: "service_status_update",
|
||||
payload: {
|
||||
workspaceId: "workspace-a",
|
||||
services: [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
url: "http://api.localhost:6767",
|
||||
status: "running",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(sessionB.emit).toHaveBeenCalledWith({
|
||||
type: "service_status_update",
|
||||
payload: {
|
||||
workspaceId: "workspace-a",
|
||||
services: [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
url: "http://api.localhost:6767",
|
||||
status: "running",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses resolveStatus to set initial service status when provided", () => {
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
routeStore.registerRoute({
|
||||
hostname: "web.localhost",
|
||||
port: 3003,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "web",
|
||||
});
|
||||
|
||||
const statuses = new Map<string, "running" | "stopped">([
|
||||
["api.localhost", "running"],
|
||||
]);
|
||||
|
||||
expect(
|
||||
buildWorkspaceServicePayloads(routeStore, "workspace-a", 6767, (hostname) =>
|
||||
statuses.get(hostname) ?? null,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
url: "http://api.localhost:6767",
|
||||
status: "running",
|
||||
},
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: 3003,
|
||||
url: "http://web.localhost:6767",
|
||||
status: "stopped",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits workspace-specific batches", () => {
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
routeStore.registerRoute({
|
||||
hostname: "web.localhost",
|
||||
port: 3002,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "web",
|
||||
});
|
||||
routeStore.registerRoute({
|
||||
hostname: "docs.localhost",
|
||||
port: 3003,
|
||||
workspaceId: "workspace-b",
|
||||
serviceName: "docs",
|
||||
});
|
||||
|
||||
const session = { emit: vi.fn() };
|
||||
const emitUpdate = createServiceStatusEmitter({
|
||||
sessions: () => [session],
|
||||
routeStore,
|
||||
daemonPort: null,
|
||||
});
|
||||
|
||||
emitUpdate("workspace-a", [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
status: "running",
|
||||
},
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: 3002,
|
||||
status: "stopped",
|
||||
},
|
||||
]);
|
||||
|
||||
emitUpdate("workspace-b", [
|
||||
{
|
||||
serviceName: "docs",
|
||||
hostname: "docs.localhost",
|
||||
port: 3003,
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(session.emit).toHaveBeenNthCalledWith(1, {
|
||||
type: "service_status_update",
|
||||
payload: {
|
||||
workspaceId: "workspace-a",
|
||||
services: [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
url: null,
|
||||
status: "running",
|
||||
},
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: 3002,
|
||||
url: null,
|
||||
status: "stopped",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(session.emit).toHaveBeenNthCalledWith(2, {
|
||||
type: "service_status_update",
|
||||
payload: {
|
||||
workspaceId: "workspace-b",
|
||||
services: [
|
||||
{
|
||||
serviceName: "docs",
|
||||
hostname: "docs.localhost",
|
||||
port: 3003,
|
||||
url: null,
|
||||
status: "running",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import type {
|
||||
ServiceStatusUpdateMessage,
|
||||
SessionOutboundMessage,
|
||||
WorkspaceServicePayload,
|
||||
} from "../shared/messages.js";
|
||||
import type { ServiceStatusEntry } from "./service-health-monitor.js";
|
||||
import type { ServiceRouteStore } from "./service-proxy.js";
|
||||
|
||||
type SessionEmitter = {
|
||||
emit(message: SessionOutboundMessage): void;
|
||||
};
|
||||
|
||||
function resolveDaemonPort(daemonPort: number | null | (() => number | null)): number | null {
|
||||
if (typeof daemonPort === "function") {
|
||||
return daemonPort();
|
||||
}
|
||||
return daemonPort;
|
||||
}
|
||||
|
||||
function toServiceUrl(hostname: string, daemonPort: number | null): string | null {
|
||||
if (daemonPort === null) {
|
||||
return null;
|
||||
}
|
||||
return `http://${hostname}:${daemonPort}`;
|
||||
}
|
||||
|
||||
export function buildWorkspaceServicePayloads(
|
||||
routeStore: ServiceRouteStore,
|
||||
workspaceId: string,
|
||||
daemonPort: number | null,
|
||||
resolveStatus?: (hostname: string) => "running" | "stopped" | null,
|
||||
): WorkspaceServicePayload[] {
|
||||
return routeStore.listRoutesForWorkspace(workspaceId).map((route) => ({
|
||||
serviceName: route.serviceName,
|
||||
hostname: route.hostname,
|
||||
port: route.port,
|
||||
url: toServiceUrl(route.hostname, daemonPort),
|
||||
status: resolveStatus?.(route.hostname) ?? "stopped",
|
||||
}));
|
||||
}
|
||||
|
||||
function buildServiceStatusUpdateMessage(params: {
|
||||
workspaceId: string;
|
||||
services: WorkspaceServicePayload[];
|
||||
}): ServiceStatusUpdateMessage {
|
||||
return {
|
||||
type: "service_status_update",
|
||||
payload: {
|
||||
workspaceId: params.workspaceId,
|
||||
services: params.services,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createServiceStatusEmitter({
|
||||
sessions,
|
||||
routeStore,
|
||||
daemonPort,
|
||||
}: {
|
||||
sessions: () => SessionEmitter[];
|
||||
routeStore: ServiceRouteStore;
|
||||
daemonPort: number | null | (() => number | null);
|
||||
}): (workspaceId: string, services: ServiceStatusEntry[]) => void {
|
||||
return (workspaceId, services) => {
|
||||
const resolvedDaemonPort = resolveDaemonPort(daemonPort);
|
||||
const serviceStatusByHostname = new Map(
|
||||
services.map((service) => [service.hostname, service.status] as const),
|
||||
);
|
||||
|
||||
const projected = buildWorkspaceServicePayloads(routeStore, workspaceId, resolvedDaemonPort).map(
|
||||
(service) => ({
|
||||
...service,
|
||||
status: serviceStatusByHostname.get(service.hostname) ?? service.status,
|
||||
}),
|
||||
);
|
||||
|
||||
const message = buildServiceStatusUpdateMessage({
|
||||
workspaceId,
|
||||
services: projected,
|
||||
});
|
||||
|
||||
for (const session of sessions()) {
|
||||
session.emit(message);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type UnsubscribeCheckoutDiffRequest,
|
||||
type DirectorySuggestionsRequest,
|
||||
type ProjectPlacementPayload,
|
||||
type WorkspaceSetupSnapshot,
|
||||
type WorkspaceDescriptorPayload,
|
||||
type WorkspaceStateBucket,
|
||||
} from "./messages.js";
|
||||
@@ -63,6 +64,7 @@ import {
|
||||
import { experimental_createMCPClient } from "ai";
|
||||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js";
|
||||
import { buildWorkspaceServicePayloads } from "./service-status-projection.js";
|
||||
|
||||
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
||||
import { buildProviderRegistry } from "./agent/provider-registry.js";
|
||||
@@ -125,6 +127,7 @@ import {
|
||||
type WorktreeConfig,
|
||||
} from "../utils/worktree.js";
|
||||
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
|
||||
import type { ServiceRouteStore } from "./service-proxy.js";
|
||||
import {
|
||||
getCheckoutDiff,
|
||||
getCheckoutShortstat,
|
||||
@@ -166,6 +169,7 @@ import {
|
||||
handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest,
|
||||
handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest,
|
||||
handlePaseoWorktreeListRequest as handleWorktreeListRequest,
|
||||
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
|
||||
killTerminalsUnderPath as killWorktreeTerminalsUnderPath,
|
||||
} from "./worktree-session.js";
|
||||
|
||||
@@ -371,6 +375,9 @@ export type SessionOptions = {
|
||||
stt: Resolvable<SpeechToTextProvider | null>;
|
||||
tts: Resolvable<TextToSpeechProvider | null>;
|
||||
terminalManager: TerminalManager | null;
|
||||
serviceRouteStore?: ServiceRouteStore;
|
||||
getDaemonTcpPort?: () => number | null;
|
||||
resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null;
|
||||
voice?: {
|
||||
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
|
||||
turnDetection?: Resolvable<TurnDetectionProvider | null>;
|
||||
@@ -543,6 +550,9 @@ export class Session {
|
||||
} | null = null;
|
||||
private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000;
|
||||
private readonly terminalManager: TerminalManager | null;
|
||||
private readonly serviceRouteStore: ServiceRouteStore | null;
|
||||
private readonly getDaemonTcpPort: (() => number | null) | null;
|
||||
private readonly resolveServiceStatus: ((hostname: string) => "running" | "stopped" | null) | null;
|
||||
private readonly subscribedTerminalDirectories = new Set<string>();
|
||||
private unsubscribeTerminalsChanged: (() => void) | null = null;
|
||||
private terminalExitSubscriptions: Map<string, () => void> = new Map();
|
||||
@@ -553,6 +563,7 @@ export class Session {
|
||||
private peakInflightRequests = 0;
|
||||
private readonly checkoutDiffSubscriptions = new Map<string, () => void>();
|
||||
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
|
||||
private readonly workspaceSetupSnapshots = new Map<string, WorkspaceSetupSnapshot>();
|
||||
private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null;
|
||||
private readonly registerVoiceSpeakHandler?: (
|
||||
agentId: string,
|
||||
@@ -595,6 +606,9 @@ export class Session {
|
||||
stt,
|
||||
tts,
|
||||
terminalManager,
|
||||
serviceRouteStore,
|
||||
getDaemonTcpPort,
|
||||
resolveServiceStatus,
|
||||
voice,
|
||||
voiceBridge,
|
||||
dictation,
|
||||
@@ -631,6 +645,9 @@ export class Session {
|
||||
});
|
||||
this.createAgentMcpTransport = createAgentMcpTransport;
|
||||
this.terminalManager = terminalManager;
|
||||
this.serviceRouteStore = serviceRouteStore ?? null;
|
||||
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
|
||||
this.resolveServiceStatus = resolveServiceStatus ?? null;
|
||||
if (this.terminalManager) {
|
||||
this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) =>
|
||||
this.handleTerminalsChanged(event),
|
||||
@@ -691,6 +708,10 @@ export class Session {
|
||||
};
|
||||
}
|
||||
|
||||
public emitServerMessage(message: SessionOutboundMessage): void {
|
||||
this.emit(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send initial state to client after connection
|
||||
*/
|
||||
@@ -1542,6 +1563,10 @@ export class Session {
|
||||
await this.handleCreatePaseoWorktreeRequest(msg);
|
||||
break;
|
||||
|
||||
case "workspace_setup_status_request":
|
||||
await this.handleWorkspaceSetupStatusRequest(msg);
|
||||
break;
|
||||
|
||||
case "open_project_request":
|
||||
await this.handleOpenProjectRequest(msg);
|
||||
break;
|
||||
@@ -2566,7 +2591,7 @@ export class Session {
|
||||
...(provisionalTitle ? { title: provisionalTitle } : {}),
|
||||
};
|
||||
|
||||
const { sessionConfig, worktreeConfig } = await this.buildAgentSessionConfig(
|
||||
const { sessionConfig, worktreeBootstrap } = await this.buildAgentSessionConfig(
|
||||
resolvedConfig,
|
||||
git,
|
||||
worktreeName,
|
||||
@@ -2649,10 +2674,11 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
if (worktreeConfig) {
|
||||
if (worktreeBootstrap) {
|
||||
void runAsyncWorktreeBootstrap({
|
||||
agentId: snapshot.id,
|
||||
worktree: worktreeConfig,
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: this.terminalManager,
|
||||
appendTimelineItem: (item) =>
|
||||
appendTimelineItemIfAgentKnown({
|
||||
@@ -2666,6 +2692,8 @@ export class Session {
|
||||
agentId: snapshot.id,
|
||||
item,
|
||||
}),
|
||||
serviceRouteStore: this.serviceRouteStore ?? undefined,
|
||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
}
|
||||
@@ -2812,7 +2840,10 @@ export class Session {
|
||||
gitOptions?: GitSetupOptions,
|
||||
legacyWorktreeName?: string,
|
||||
_labels?: Record<string, string>,
|
||||
): Promise<{ sessionConfig: AgentSessionConfig; worktreeConfig?: WorktreeConfig }> {
|
||||
): Promise<{
|
||||
sessionConfig: AgentSessionConfig;
|
||||
worktreeBootstrap?: { worktree: WorktreeConfig; shouldBootstrap: boolean };
|
||||
}> {
|
||||
return buildWorktreeAgentSessionConfig(
|
||||
{
|
||||
paseoHome: this.paseoHome,
|
||||
@@ -4823,6 +4854,14 @@ export class Session {
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat,
|
||||
services: this.serviceRouteStore
|
||||
? buildWorkspaceServicePayloads(
|
||||
this.serviceRouteStore,
|
||||
workspace.workspaceId,
|
||||
this.getDaemonTcpPort?.() ?? null,
|
||||
this.resolveServiceStatus ?? undefined,
|
||||
)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5535,22 +5574,40 @@ export class Session {
|
||||
private async createPaseoWorktreeInBackground(options: {
|
||||
requestCwd: string;
|
||||
repoRoot: string;
|
||||
baseBranch: string;
|
||||
slug: string;
|
||||
worktreePath: string;
|
||||
worktree: { branchName: string; worktreePath: string };
|
||||
shouldBootstrap: boolean;
|
||||
}): Promise<void> {
|
||||
return createWorktreeInBackgroundSession(
|
||||
{
|
||||
paseoHome: this.paseoHome,
|
||||
emitWorkspaceUpdateForCwd: (cwd, emitOptions) =>
|
||||
this.emitWorkspaceUpdateForCwd(cwd, emitOptions),
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => {
|
||||
this.workspaceSetupSnapshots.set(workspaceId, snapshot);
|
||||
},
|
||||
emit: (message) => this.emit(message),
|
||||
sessionLogger: this.sessionLogger,
|
||||
terminalManager: this.terminalManager,
|
||||
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
||||
serviceRouteStore: this.serviceRouteStore,
|
||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
private async handleWorkspaceSetupStatusRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "workspace_setup_status_request" }>,
|
||||
): Promise<void> {
|
||||
return handleWorkspaceSetupStatusRequestMessage(
|
||||
{
|
||||
emit: (message) => this.emit(message),
|
||||
workspaceSetupSnapshots: this.workspaceSetupSnapshots,
|
||||
},
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
private async handleArchiveWorkspaceRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "archive_workspace_request" }>,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -33,6 +33,7 @@ import type { AgentProvider } from "./agent/agent-sdk-types.js";
|
||||
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
||||
import { PushTokenStore } from "./push/token-store.js";
|
||||
import { PushService } from "./push/push-service.js";
|
||||
import type { ServiceRouteStore } from "./service-proxy.js";
|
||||
import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js";
|
||||
import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js";
|
||||
import {
|
||||
@@ -242,6 +243,9 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly createAgentMcpTransport: AgentMcpTransportFactory;
|
||||
private readonly speech: SpeechService | null;
|
||||
private readonly terminalManager: TerminalManager | null;
|
||||
private readonly serviceRouteStore: ServiceRouteStore | null;
|
||||
private readonly getDaemonTcpPort: (() => number | null) | null;
|
||||
private readonly resolveServiceStatus: ((hostname: string) => "running" | "stopped" | null) | null;
|
||||
private readonly dictation: {
|
||||
finalTimeoutMs?: number;
|
||||
} | null;
|
||||
@@ -308,6 +312,9 @@ export class VoiceAssistantWebSocketServer {
|
||||
loopService?: LoopService,
|
||||
scheduleService?: ScheduleService,
|
||||
checkoutDiffManager?: CheckoutDiffManager,
|
||||
serviceRouteStore?: ServiceRouteStore | null,
|
||||
getDaemonTcpPort?: () => number | null,
|
||||
resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null,
|
||||
) {
|
||||
this.logger = logger.child({ module: "websocket-server" });
|
||||
this.serverId = serverId;
|
||||
@@ -344,6 +351,9 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.dictation = dictation ?? null;
|
||||
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
|
||||
this.onLifecycleIntent = onLifecycleIntent ?? null;
|
||||
this.serviceRouteStore = serviceRouteStore ?? null;
|
||||
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
|
||||
this.resolveServiceStatus = resolveServiceStatus ?? null;
|
||||
this.serverCapabilities = buildServerCapabilities({
|
||||
readiness: this.speech?.getReadiness() ?? null,
|
||||
});
|
||||
@@ -416,6 +426,16 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
}
|
||||
|
||||
public listActiveSessions(): Session[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
[...this.sessions.values(), ...this.externalSessionsByKey.values()].map(
|
||||
(connection) => connection.session,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public publishSpeechReadiness(readiness: SpeechReadinessSnapshot | null): void {
|
||||
this.updateServerCapabilities(buildServerCapabilities({ readiness }));
|
||||
}
|
||||
@@ -646,6 +666,9 @@ export class VoiceAssistantWebSocketServer {
|
||||
stt: () => this.speech?.resolveStt() ?? null,
|
||||
tts: () => this.speech?.resolveTts() ?? null,
|
||||
terminalManager: this.terminalManager,
|
||||
serviceRouteStore: this.serviceRouteStore ?? undefined,
|
||||
getDaemonTcpPort: this.getDaemonTcpPort ?? undefined,
|
||||
resolveServiceStatus: this.resolveServiceStatus ?? undefined,
|
||||
voice: {
|
||||
...(this.voice ?? {}),
|
||||
turnDetection: () => this.speech?.resolveTurnDetection() ?? null,
|
||||
|
||||
@@ -5,7 +5,12 @@ import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
|
||||
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
|
||||
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
|
||||
import {
|
||||
createAgentWorktree,
|
||||
runAsyncWorktreeBootstrap,
|
||||
spawnWorktreeServices,
|
||||
} from "./worktree-bootstrap.js";
|
||||
import { ServiceRouteStore } from "./service-proxy.js";
|
||||
|
||||
describe("runAsyncWorktreeBootstrap", () => {
|
||||
let tempDir: string;
|
||||
@@ -56,7 +61,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const worktree = await createAgentWorktree({
|
||||
const worktreeBootstrap = await createAgentWorktree({
|
||||
cwd: repoDir,
|
||||
branchName: "feature-streaming-setup",
|
||||
baseBranch: "main",
|
||||
@@ -69,7 +74,8 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-test",
|
||||
worktree,
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: null,
|
||||
appendTimelineItem: async (item) => {
|
||||
persisted.push(item);
|
||||
@@ -113,12 +119,15 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
expect(persistedSetupItems[0].detail.commands[0]).toMatchObject({
|
||||
index: 1,
|
||||
command: 'echo "line-one"; echo "line-two" 1>&2',
|
||||
log: expect.stringContaining("line-one"),
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
});
|
||||
expect(persistedSetupItems[0].detail.commands[0]?.log).toContain("line-two");
|
||||
expect(persistedSetupItems[0].detail.commands[1]).toMatchObject({
|
||||
index: 2,
|
||||
command: 'echo "line-three"',
|
||||
log: "line-three\n",
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
});
|
||||
@@ -160,7 +169,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const worktree = await createAgentWorktree({
|
||||
const worktreeBootstrap = await createAgentWorktree({
|
||||
cwd: repoDir,
|
||||
branchName: "feature-live-failure",
|
||||
baseBranch: "main",
|
||||
@@ -172,7 +181,8 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
await expect(
|
||||
runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-live-failure",
|
||||
worktree,
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: null,
|
||||
appendTimelineItem: async (item) => {
|
||||
persisted.push(item);
|
||||
@@ -210,7 +220,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const worktree = await createAgentWorktree({
|
||||
const worktreeBootstrap = await createAgentWorktree({
|
||||
cwd: repoDir,
|
||||
branchName: "feature-large-output",
|
||||
baseBranch: "main",
|
||||
@@ -221,7 +231,8 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
const persisted: AgentTimelineItem[] = [];
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-large-output",
|
||||
worktree,
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: null,
|
||||
appendTimelineItem: async (item) => {
|
||||
persisted.push(item);
|
||||
@@ -244,6 +255,64 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
expect(persistedSetupItem.detail.log).toContain("prefix-");
|
||||
expect(persistedSetupItem.detail.log).toContain("-suffix");
|
||||
expect(persistedSetupItem.detail.log).toContain("...<output truncated in the middle>...");
|
||||
expect(persistedSetupItem.detail.commands[0]?.log).toContain("prefix-");
|
||||
expect(persistedSetupItem.detail.commands[0]?.log).toContain("-suffix");
|
||||
expect(persistedSetupItem.detail.commands[0]?.log).toContain(
|
||||
"...<output truncated in the middle>...",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps only the final carriage-return-updated content in command logs", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: [
|
||||
`node -e "process.stdout.write('fetch 1/3\\\\rfetch 2/3\\\\rfetch 3/3\\\\nready\\\\n')"`,
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add carriage return setup'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const worktreeBootstrap = await createAgentWorktree({
|
||||
cwd: repoDir,
|
||||
branchName: "feature-carriage-return",
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-carriage-return",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const persisted: AgentTimelineItem[] = [];
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-carriage-return",
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: null,
|
||||
appendTimelineItem: async (item) => {
|
||||
persisted.push(item);
|
||||
return true;
|
||||
},
|
||||
emitLiveTimelineItem: async () => true,
|
||||
});
|
||||
|
||||
const persistedSetupItem = persisted.find(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
|
||||
item.type === "tool_call" && item.name === "paseo_worktree_setup",
|
||||
);
|
||||
expect(persistedSetupItem?.detail.type).toBe("worktree_setup");
|
||||
if (!persistedSetupItem || persistedSetupItem.detail.type !== "worktree_setup") {
|
||||
throw new Error("Expected worktree_setup tool detail");
|
||||
}
|
||||
|
||||
expect(persistedSetupItem.detail.log).toContain("\nfetch 3/3\nready\n");
|
||||
expect(persistedSetupItem.detail.log).not.toContain("\nfetch 1/3\n");
|
||||
expect(persistedSetupItem.detail.log).not.toContain("\nfetch 2/3\n");
|
||||
expect(persistedSetupItem.detail.commands[0]?.log).toBe("fetch 3/3\nready\n");
|
||||
});
|
||||
|
||||
it("waits for terminal output before sending bootstrap commands", async () => {
|
||||
@@ -266,7 +335,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const worktree = await createAgentWorktree({
|
||||
const worktreeBootstrap = await createAgentWorktree({
|
||||
cwd: repoDir,
|
||||
branchName: "feature-terminal-readiness",
|
||||
baseBranch: "main",
|
||||
@@ -280,7 +349,8 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-terminal-readiness",
|
||||
worktree,
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: {
|
||||
async getTerminals() {
|
||||
return [];
|
||||
@@ -357,7 +427,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const worktree = await createAgentWorktree({
|
||||
const worktreeBootstrap = await createAgentWorktree({
|
||||
cwd: repoDir,
|
||||
branchName: "feature-shared-runtime-port",
|
||||
baseBranch: "main",
|
||||
@@ -370,7 +440,8 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
const persisted: AgentTimelineItem[] = [];
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-shared-runtime-port",
|
||||
worktree,
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: {
|
||||
async getTerminals() {
|
||||
return [];
|
||||
@@ -416,13 +487,13 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
emitLiveTimelineItem: async () => true,
|
||||
});
|
||||
|
||||
const setupPortPath = join(worktree.worktreePath, "setup-port.txt");
|
||||
const setupPortPath = join(worktreeBootstrap.worktree.worktreePath, "setup-port.txt");
|
||||
await waitForPathExists(setupPortPath);
|
||||
|
||||
const setupPort = readFileSync(setupPortPath, "utf8").trim();
|
||||
expect(setupPort.length).toBeGreaterThan(0);
|
||||
expect(registeredEnvs).toHaveLength(1);
|
||||
expect(registeredEnvs[0]?.cwd).toBe(worktree.worktreePath);
|
||||
expect(registeredEnvs[0]?.cwd).toBe(worktreeBootstrap.worktree.worktreePath);
|
||||
expect(registeredEnvs[0]?.env.PASEO_WORKTREE_PORT).toBe(setupPort);
|
||||
expect(createTerminalEnvs.length).toBeGreaterThan(0);
|
||||
expect(createTerminalEnvs[0]?.PASEO_WORKTREE_PORT).toBe(setupPort);
|
||||
@@ -435,4 +506,85 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
);
|
||||
expect(terminalToolCall?.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("spawns services without PASEO_SERVICE_URL when the daemon has no TCP port", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
services: {
|
||||
web: {
|
||||
command: "npm run dev",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add service config'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const routeStore = new ServiceRouteStore();
|
||||
const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record<string, string> }> = [];
|
||||
|
||||
const results = await spawnWorktreeServices({
|
||||
repoRoot: repoDir,
|
||||
workspaceId: repoDir,
|
||||
branchName: "feature-socket-service",
|
||||
daemonPort: null,
|
||||
routeStore,
|
||||
terminalManager: {
|
||||
async getTerminals() {
|
||||
return [];
|
||||
},
|
||||
async createTerminal(options) {
|
||||
createTerminalCalls.push(options);
|
||||
return {
|
||||
id: "term-service",
|
||||
name: options.name ?? "Terminal",
|
||||
cwd: options.cwd,
|
||||
send: () => {},
|
||||
subscribe: () => () => {},
|
||||
onExit: () => () => {},
|
||||
getState: () => ({
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
grid: [[{ char: "$" }]],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 0 },
|
||||
}),
|
||||
kill: () => {},
|
||||
};
|
||||
},
|
||||
registerCwdEnv() {},
|
||||
getTerminal() {
|
||||
return undefined;
|
||||
},
|
||||
killTerminal() {},
|
||||
listDirectories() {
|
||||
return [];
|
||||
},
|
||||
killAll() {},
|
||||
subscribeTerminalsChanged() {
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(routeStore.listRoutes()).toEqual([
|
||||
{
|
||||
hostname: "feature-socket-service.web.localhost",
|
||||
port: expect.any(Number),
|
||||
workspaceId: repoDir,
|
||||
serviceName: "web",
|
||||
},
|
||||
]);
|
||||
expect(createTerminalCalls).toHaveLength(1);
|
||||
expect(createTerminalCalls[0]?.cwd).toBe(repoDir);
|
||||
expect(createTerminalCalls[0]?.name).toBe("web");
|
||||
expect(createTerminalCalls[0]?.env?.PORT).toEqual(expect.any(String));
|
||||
expect(createTerminalCalls[0]?.env?.HOST).toBe("127.0.0.1");
|
||||
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_URL).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,16 +7,20 @@ import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type { TerminalSession } from "../terminal/terminal.js";
|
||||
import {
|
||||
createWorktree,
|
||||
getServiceConfigs,
|
||||
getWorktreeTerminalSpecs,
|
||||
listPaseoWorktrees,
|
||||
processCarriageReturns,
|
||||
resolveWorktreeRuntimeEnv,
|
||||
runWorktreeSetupCommands,
|
||||
slugify,
|
||||
WorktreeSetupError,
|
||||
type WorktreeConfig,
|
||||
type WorktreeSetupCommandResult,
|
||||
type WorktreeRuntimeEnv,
|
||||
} from "../utils/worktree.js";
|
||||
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
|
||||
import { findFreePort, type ServiceRouteStore } from "./service-proxy.js";
|
||||
import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js";
|
||||
|
||||
export interface WorktreeBootstrapTerminalResult {
|
||||
name: string | null;
|
||||
@@ -29,7 +33,10 @@ export interface WorktreeBootstrapTerminalResult {
|
||||
export interface RunAsyncWorktreeBootstrapOptions {
|
||||
agentId: string;
|
||||
worktree: WorktreeConfig;
|
||||
shouldBootstrap?: boolean;
|
||||
terminalManager: TerminalManager | null;
|
||||
serviceRouteStore?: ServiceRouteStore;
|
||||
daemonPort?: number | null;
|
||||
appendTimelineItem: (item: AgentTimelineItem) => Promise<boolean>;
|
||||
emitLiveTimelineItem?: (item: AgentTimelineItem) => Promise<boolean>;
|
||||
logger?: Logger;
|
||||
@@ -43,6 +50,11 @@ export interface CreateAgentWorktreeOptions {
|
||||
paseoHome?: string;
|
||||
}
|
||||
|
||||
export interface CreateAgentWorktreeResult {
|
||||
worktree: WorktreeConfig;
|
||||
shouldBootstrap: boolean;
|
||||
}
|
||||
|
||||
const MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES = 64 * 1024;
|
||||
const WORKTREE_SETUP_TRUNCATION_MARKER = "\n...<output truncated in the middle>...\n";
|
||||
const WORKTREE_BOOTSTRAP_TERMINAL_READY_TIMEOUT_MS = 1_500;
|
||||
@@ -51,8 +63,6 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
||||
GIT_OPTIONAL_LOCKS: "0",
|
||||
};
|
||||
const execAsync = promisify(exec);
|
||||
const worktreeSetupEligibility = new WeakMap<WorktreeConfig, boolean>();
|
||||
|
||||
type MiddleTruncationAccumulator = {
|
||||
totalBytes: number;
|
||||
head: string;
|
||||
@@ -60,6 +70,12 @@ type MiddleTruncationAccumulator = {
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
export type WorktreeSetupOutputAccumulator = MiddleTruncationAccumulator;
|
||||
export type WorktreeSetupProgressAccumulator = {
|
||||
resultsByIndex: Map<number, WorktreeSetupCommandResult>;
|
||||
outputAccumulatorsByIndex: Map<number, WorktreeSetupOutputAccumulator>;
|
||||
};
|
||||
|
||||
function byteLength(text: string): number {
|
||||
return Buffer.byteLength(text, "utf8");
|
||||
}
|
||||
@@ -86,7 +102,7 @@ function sliceLastBytes(text: string, maxBytes: number): string {
|
||||
return bytes.subarray(bytes.length - maxBytes).toString("utf8");
|
||||
}
|
||||
|
||||
function createMiddleTruncationAccumulator(): MiddleTruncationAccumulator {
|
||||
export function createWorktreeSetupOutputAccumulator(): WorktreeSetupOutputAccumulator {
|
||||
return {
|
||||
totalBytes: 0,
|
||||
head: "",
|
||||
@@ -103,8 +119,8 @@ function getHeadTailBudgets(maxBytes: number): { headBytes: number; tailBytes: n
|
||||
return { headBytes, tailBytes };
|
||||
}
|
||||
|
||||
function appendToMiddleTruncationAccumulator(
|
||||
accumulator: MiddleTruncationAccumulator,
|
||||
export function appendWorktreeSetupOutputAccumulator(
|
||||
accumulator: WorktreeSetupOutputAccumulator,
|
||||
chunk: string,
|
||||
): void {
|
||||
if (!chunk) {
|
||||
@@ -161,16 +177,17 @@ function renderMiddleTruncationAccumulator(accumulator: MiddleTruncationAccumula
|
||||
|
||||
export async function createAgentWorktree(
|
||||
options: CreateAgentWorktreeOptions,
|
||||
): Promise<WorktreeConfig> {
|
||||
): Promise<CreateAgentWorktreeResult> {
|
||||
const existingWorktree = await findExistingPaseoWorktreeBySlug(options);
|
||||
if (existingWorktree) {
|
||||
const branchName = await resolveBranchNameForWorktreePath(existingWorktree.path);
|
||||
const reusedWorktree = {
|
||||
branchName,
|
||||
worktreePath: existingWorktree.path,
|
||||
return {
|
||||
worktree: {
|
||||
branchName,
|
||||
worktreePath: existingWorktree.path,
|
||||
},
|
||||
shouldBootstrap: false,
|
||||
};
|
||||
worktreeSetupEligibility.set(reusedWorktree, false);
|
||||
return reusedWorktree;
|
||||
}
|
||||
|
||||
const createdWorktree = await createWorktree({
|
||||
@@ -181,8 +198,10 @@ export async function createAgentWorktree(
|
||||
runSetup: false,
|
||||
paseoHome: options.paseoHome,
|
||||
});
|
||||
worktreeSetupEligibility.set(createdWorktree, true);
|
||||
return createdWorktree;
|
||||
return {
|
||||
worktree: createdWorktree,
|
||||
shouldBootstrap: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function findExistingPaseoWorktreeBySlug(options: CreateAgentWorktreeOptions) {
|
||||
@@ -221,7 +240,7 @@ function commandStatusFromResult(
|
||||
|
||||
function buildWorktreeSetupLog(input: {
|
||||
results: WorktreeSetupCommandResult[];
|
||||
outputAccumulatorsByIndex?: Map<number, MiddleTruncationAccumulator>;
|
||||
outputAccumulatorsByIndex?: Map<number, WorktreeSetupOutputAccumulator>;
|
||||
}): { log: string; truncated: boolean } {
|
||||
const { results, outputAccumulatorsByIndex } = input;
|
||||
if (results.length === 0) {
|
||||
@@ -236,15 +255,13 @@ function buildWorktreeSetupLog(input: {
|
||||
const total = results.length;
|
||||
for (const [index, result] of results.entries()) {
|
||||
lines.push(`==> [${index + 1}/${total}] Running: ${result.command}`);
|
||||
const accumulator = outputAccumulatorsByIndex?.get(index + 1);
|
||||
const output = accumulator
|
||||
? renderMiddleTruncationAccumulator(accumulator)
|
||||
: truncateTextInMiddle(
|
||||
`${result.stdout ?? ""}${result.stderr ?? ""}`,
|
||||
MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES,
|
||||
);
|
||||
if (output.text.length > 0) {
|
||||
lines.push(output.text.replace(/\n$/, ""));
|
||||
const output = buildWorktreeSetupCommandLog({
|
||||
index: index + 1,
|
||||
result,
|
||||
outputAccumulatorsByIndex,
|
||||
});
|
||||
if (output.log.length > 0) {
|
||||
lines.push(output.log.replace(/\n$/, ""));
|
||||
}
|
||||
if (output.truncated) {
|
||||
anyTruncated = true;
|
||||
@@ -261,34 +278,136 @@ function buildWorktreeSetupLog(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function buildWorktreeSetupCommandLog(input: {
|
||||
index: number;
|
||||
result: WorktreeSetupCommandResult;
|
||||
outputAccumulatorsByIndex?: Map<number, WorktreeSetupOutputAccumulator>;
|
||||
}): { log: string; truncated: boolean } {
|
||||
const { index, result, outputAccumulatorsByIndex } = input;
|
||||
const accumulator = outputAccumulatorsByIndex?.get(index);
|
||||
const rendered = accumulator
|
||||
? renderMiddleTruncationAccumulator(accumulator)
|
||||
: truncateTextInMiddle(
|
||||
`${result.stdout ?? ""}${result.stderr ?? ""}`,
|
||||
MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES,
|
||||
);
|
||||
|
||||
return {
|
||||
log: processCarriageReturns(rendered.text),
|
||||
truncated: rendered.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
export function createWorktreeSetupProgressAccumulator(): WorktreeSetupProgressAccumulator {
|
||||
return {
|
||||
resultsByIndex: new Map(),
|
||||
outputAccumulatorsByIndex: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyWorktreeSetupProgressEvent(
|
||||
accumulator: WorktreeSetupProgressAccumulator,
|
||||
event: Parameters<NonNullable<Parameters<typeof runWorktreeSetupCommands>[0]["onEvent"]>>[0],
|
||||
): void {
|
||||
const existing = accumulator.resultsByIndex.get(event.index);
|
||||
const baseResult: WorktreeSetupCommandResult = existing ?? {
|
||||
command: event.command,
|
||||
cwd: event.cwd,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: null,
|
||||
durationMs: 0,
|
||||
};
|
||||
|
||||
if (event.type === "output") {
|
||||
const outputAccumulator =
|
||||
accumulator.outputAccumulatorsByIndex.get(event.index) ??
|
||||
createWorktreeSetupOutputAccumulator();
|
||||
appendWorktreeSetupOutputAccumulator(outputAccumulator, event.chunk);
|
||||
accumulator.outputAccumulatorsByIndex.set(event.index, outputAccumulator);
|
||||
accumulator.resultsByIndex.set(event.index, {
|
||||
...baseResult,
|
||||
stdout: baseResult.stdout,
|
||||
stderr: baseResult.stderr,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "command_completed") {
|
||||
accumulator.resultsByIndex.set(event.index, {
|
||||
...baseResult,
|
||||
stdout: event.stdout,
|
||||
stderr: event.stderr,
|
||||
exitCode: event.exitCode,
|
||||
durationMs: event.durationMs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
accumulator.resultsByIndex.set(event.index, baseResult);
|
||||
}
|
||||
|
||||
export function getWorktreeSetupProgressResults(
|
||||
accumulator: WorktreeSetupProgressAccumulator,
|
||||
): WorktreeSetupCommandResult[] {
|
||||
return Array.from(accumulator.resultsByIndex.entries())
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([, result]) => result);
|
||||
}
|
||||
|
||||
export function buildWorktreeSetupDetail(input: {
|
||||
worktree: WorktreeConfig;
|
||||
results: WorktreeSetupCommandResult[];
|
||||
outputAccumulatorsByIndex?: Map<number, WorktreeSetupOutputAccumulator>;
|
||||
}): Extract<ToolCallDetail, { type: "worktree_setup" }> {
|
||||
let anyCommandTruncated = false;
|
||||
const commands = input.results.map((result, index) => {
|
||||
const renderedLog = buildWorktreeSetupCommandLog({
|
||||
index: index + 1,
|
||||
result,
|
||||
outputAccumulatorsByIndex: input.outputAccumulatorsByIndex,
|
||||
});
|
||||
if (renderedLog.truncated) {
|
||||
anyCommandTruncated = true;
|
||||
}
|
||||
return {
|
||||
index: index + 1,
|
||||
command: result.command,
|
||||
cwd: result.cwd,
|
||||
log: renderedLog.log,
|
||||
status: commandStatusFromResult(result),
|
||||
exitCode: result.exitCode,
|
||||
...(result.durationMs > 0 ? { durationMs: result.durationMs } : {}),
|
||||
};
|
||||
});
|
||||
const renderedLog = buildWorktreeSetupLog({
|
||||
results: input.results,
|
||||
outputAccumulatorsByIndex: input.outputAccumulatorsByIndex,
|
||||
});
|
||||
|
||||
return {
|
||||
type: "worktree_setup",
|
||||
worktreePath: input.worktree.worktreePath,
|
||||
branchName: input.worktree.branchName,
|
||||
log: renderedLog.log,
|
||||
commands,
|
||||
...(renderedLog.truncated || anyCommandTruncated ? { truncated: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSetupTimelineItem(input: {
|
||||
callId: string;
|
||||
status: "running" | "completed" | "failed";
|
||||
worktree: WorktreeConfig;
|
||||
results: WorktreeSetupCommandResult[];
|
||||
outputAccumulatorsByIndex?: Map<number, MiddleTruncationAccumulator>;
|
||||
outputAccumulatorsByIndex?: Map<number, WorktreeSetupOutputAccumulator>;
|
||||
errorMessage: string | null;
|
||||
}): AgentTimelineItem {
|
||||
const commands = input.results.map((result, index) => ({
|
||||
index: index + 1,
|
||||
command: result.command,
|
||||
cwd: result.cwd,
|
||||
status: commandStatusFromResult(result),
|
||||
exitCode: result.exitCode,
|
||||
...(result.durationMs > 0 ? { durationMs: result.durationMs } : {}),
|
||||
}));
|
||||
const renderedLog = buildWorktreeSetupLog({
|
||||
const detail = buildWorktreeSetupDetail({
|
||||
worktree: input.worktree,
|
||||
results: input.results,
|
||||
outputAccumulatorsByIndex: input.outputAccumulatorsByIndex,
|
||||
});
|
||||
const detail = {
|
||||
type: "worktree_setup" as const,
|
||||
worktreePath: input.worktree.worktreePath,
|
||||
branchName: input.worktree.branchName,
|
||||
log: renderedLog.log,
|
||||
commands,
|
||||
...(renderedLog.truncated ? { truncated: true } : {}),
|
||||
};
|
||||
|
||||
if (input.status === "running") {
|
||||
return {
|
||||
@@ -523,7 +642,7 @@ async function runWorktreeTerminalBootstrap(
|
||||
export async function runAsyncWorktreeBootstrap(
|
||||
options: RunAsyncWorktreeBootstrapOptions,
|
||||
): Promise<void> {
|
||||
if (worktreeSetupEligibility.get(options.worktree) === false) {
|
||||
if (options.shouldBootstrap === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -531,17 +650,14 @@ export async function runAsyncWorktreeBootstrap(
|
||||
let setupResults: WorktreeSetupCommandResult[] = [];
|
||||
let runtimeEnv: WorktreeRuntimeEnv | null = null;
|
||||
const emitLiveTimelineItem = options.emitLiveTimelineItem;
|
||||
const runningResultsByIndex = new Map<number, WorktreeSetupCommandResult>();
|
||||
const outputAccumulatorsByIndex = new Map<number, MiddleTruncationAccumulator>();
|
||||
const progressAccumulator = createWorktreeSetupProgressAccumulator();
|
||||
let liveEmitQueue = Promise.resolve();
|
||||
|
||||
const queueLiveRunningEmit = () => {
|
||||
if (!emitLiveTimelineItem) {
|
||||
return;
|
||||
}
|
||||
const runningResults = Array.from(runningResultsByIndex.entries())
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([, result]) => result);
|
||||
const runningResults = getWorktreeSetupProgressResults(progressAccumulator);
|
||||
liveEmitQueue = liveEmitQueue.then(async () => {
|
||||
try {
|
||||
await emitLiveTimelineItem(
|
||||
@@ -550,7 +666,7 @@ export async function runAsyncWorktreeBootstrap(
|
||||
status: "running",
|
||||
worktree: options.worktree,
|
||||
results: runningResults,
|
||||
outputAccumulatorsByIndex,
|
||||
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
|
||||
errorMessage: null,
|
||||
}),
|
||||
);
|
||||
@@ -579,42 +695,7 @@ export async function runAsyncWorktreeBootstrap(
|
||||
cleanupOnFailure: false,
|
||||
runtimeEnv,
|
||||
onEvent: (event) => {
|
||||
const existing = runningResultsByIndex.get(event.index);
|
||||
const baseResult: WorktreeSetupCommandResult = existing ?? {
|
||||
command: event.command,
|
||||
cwd: event.cwd,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: null,
|
||||
durationMs: 0,
|
||||
};
|
||||
if (event.type === "output") {
|
||||
const outputAccumulator =
|
||||
outputAccumulatorsByIndex.get(event.index) ?? createMiddleTruncationAccumulator();
|
||||
appendToMiddleTruncationAccumulator(outputAccumulator, event.chunk);
|
||||
outputAccumulatorsByIndex.set(event.index, outputAccumulator);
|
||||
runningResultsByIndex.set(event.index, {
|
||||
...baseResult,
|
||||
// Keep the timeline command model lightweight; output is carried in
|
||||
// outputAccumulatorsByIndex.
|
||||
stdout: baseResult.stdout,
|
||||
stderr: baseResult.stderr,
|
||||
});
|
||||
queueLiveRunningEmit();
|
||||
return;
|
||||
}
|
||||
if (event.type === "command_completed") {
|
||||
runningResultsByIndex.set(event.index, {
|
||||
...baseResult,
|
||||
stdout: event.stdout,
|
||||
stderr: event.stderr,
|
||||
exitCode: event.exitCode,
|
||||
durationMs: event.durationMs,
|
||||
});
|
||||
queueLiveRunningEmit();
|
||||
return;
|
||||
}
|
||||
runningResultsByIndex.set(event.index, baseResult);
|
||||
applyWorktreeSetupProgressEvent(progressAccumulator, event);
|
||||
queueLiveRunningEmit();
|
||||
},
|
||||
});
|
||||
@@ -626,7 +707,7 @@ export async function runAsyncWorktreeBootstrap(
|
||||
status: "completed",
|
||||
worktree: options.worktree,
|
||||
results: setupResults,
|
||||
outputAccumulatorsByIndex,
|
||||
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
|
||||
errorMessage: null,
|
||||
}),
|
||||
);
|
||||
@@ -645,7 +726,7 @@ export async function runAsyncWorktreeBootstrap(
|
||||
status: "failed",
|
||||
worktree: options.worktree,
|
||||
results: setupResults,
|
||||
outputAccumulatorsByIndex,
|
||||
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
|
||||
errorMessage: message,
|
||||
}),
|
||||
);
|
||||
@@ -653,4 +734,138 @@ export async function runAsyncWorktreeBootstrap(
|
||||
}
|
||||
|
||||
await runWorktreeTerminalBootstrap(options, runtimeEnv);
|
||||
|
||||
if (!options.terminalManager || !options.serviceRouteStore) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await spawnWorktreeServices({
|
||||
repoRoot: options.worktree.worktreePath,
|
||||
workspaceId: options.worktree.worktreePath,
|
||||
branchName: options.worktree.branchName,
|
||||
daemonPort: options.daemonPort,
|
||||
routeStore: options.serviceRouteStore,
|
||||
terminalManager: options.terminalManager,
|
||||
logger: options.logger,
|
||||
});
|
||||
} catch (error) {
|
||||
options.logger?.warn(
|
||||
{ err: error, agentId: options.agentId, worktreePath: options.worktree.worktreePath },
|
||||
"Failed to spawn worktree services",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service lifecycle helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WorktreeServiceResult {
|
||||
serviceName: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
terminalId: string;
|
||||
}
|
||||
|
||||
export async function spawnWorktreeServices(options: {
|
||||
repoRoot: string;
|
||||
workspaceId: string;
|
||||
branchName: string | null;
|
||||
daemonPort?: number | null;
|
||||
routeStore: ServiceRouteStore;
|
||||
terminalManager: TerminalManager;
|
||||
logger?: Logger;
|
||||
}): Promise<WorktreeServiceResult[]> {
|
||||
const { repoRoot, workspaceId, branchName, daemonPort, routeStore, terminalManager, logger } =
|
||||
options;
|
||||
const serviceConfigs = getServiceConfigs(repoRoot);
|
||||
if (serviceConfigs.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: WorktreeServiceResult[] = [];
|
||||
|
||||
for (const [serviceName, config] of serviceConfigs) {
|
||||
let hostname: string | null = null;
|
||||
let port: number | null = null;
|
||||
|
||||
try {
|
||||
port = config.port ?? (await findFreePort());
|
||||
const branchHostnameLabel = branchName ? slugify(branchName) : null;
|
||||
|
||||
const isDefaultBranch =
|
||||
branchName === null || branchName === "main" || branchName === "master";
|
||||
hostname = isDefaultBranch
|
||||
? `${serviceName}.localhost`
|
||||
: `${branchHostnameLabel}.${serviceName}.localhost`;
|
||||
|
||||
routeStore.registerRoute({
|
||||
hostname,
|
||||
port,
|
||||
workspaceId,
|
||||
serviceName,
|
||||
});
|
||||
|
||||
const env: Record<string, string> = {
|
||||
PORT: String(port),
|
||||
HOST: "127.0.0.1",
|
||||
};
|
||||
if (daemonPort !== null && daemonPort !== undefined) {
|
||||
env.PASEO_SERVICE_URL = `http://${hostname}:${daemonPort}`;
|
||||
}
|
||||
|
||||
const terminal = await terminalManager.createTerminal({
|
||||
cwd: repoRoot,
|
||||
name: serviceName,
|
||||
env,
|
||||
});
|
||||
|
||||
await waitForTerminalBootstrapReadiness(terminal);
|
||||
terminal.send({ type: "input", data: `${config.command}\r` });
|
||||
|
||||
logger?.info(
|
||||
{ serviceName, hostname, port, terminalId: terminal.id },
|
||||
`Registered service proxy: ${hostname} -> 127.0.0.1:${port}`,
|
||||
);
|
||||
|
||||
results.push({
|
||||
serviceName,
|
||||
hostname,
|
||||
port,
|
||||
terminalId: terminal.id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (hostname && port !== null) {
|
||||
routeStore.removeRoute(hostname);
|
||||
}
|
||||
logger?.error(
|
||||
{
|
||||
err: error,
|
||||
serviceName,
|
||||
repoRoot,
|
||||
branchName,
|
||||
hostname,
|
||||
port,
|
||||
command: config.command,
|
||||
},
|
||||
"Failed to spawn worktree service",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function teardownWorktreeServices(options: {
|
||||
hostnames: string[];
|
||||
routeStore: ServiceRouteStore;
|
||||
logger: Logger;
|
||||
}): void {
|
||||
const { hostnames, routeStore, logger } = options;
|
||||
for (const hostname of hostnames) {
|
||||
routeStore.removeRoute(hostname);
|
||||
logger.info({ hostname }, "Removed service proxy route");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,795 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { SessionOutboundMessage } from "./messages.js";
|
||||
import { ServiceRouteStore } from "./service-proxy.js";
|
||||
import {
|
||||
createPaseoWorktreeInBackground,
|
||||
handleCreatePaseoWorktreeRequest,
|
||||
handleWorkspaceSetupStatusRequest,
|
||||
} from "./worktree-session.js";
|
||||
import { createWorktree } from "../utils/worktree.js";
|
||||
|
||||
function createLogger() {
|
||||
return {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function createTerminalManagerStub(options?: {
|
||||
createTerminal?: (input: {
|
||||
cwd: string;
|
||||
name?: string;
|
||||
env?: Record<string, string>;
|
||||
}) => Promise<any>;
|
||||
}) {
|
||||
const terminals: Array<{
|
||||
id: string;
|
||||
cwd: string;
|
||||
name: string | undefined;
|
||||
env: Record<string, string> | undefined;
|
||||
sent: string[];
|
||||
}> = [];
|
||||
|
||||
return {
|
||||
terminals,
|
||||
manager: {
|
||||
registerCwdEnv: vi.fn(),
|
||||
createTerminal: vi.fn(async (input: {
|
||||
cwd: string;
|
||||
name?: string;
|
||||
env?: Record<string, string>;
|
||||
}) => {
|
||||
if (options?.createTerminal) {
|
||||
return options.createTerminal(input);
|
||||
}
|
||||
const sent: string[] = [];
|
||||
const terminal = {
|
||||
id: `terminal-${terminals.length + 1}`,
|
||||
getState: () => ({
|
||||
scrollback: [[{ char: "$" }]],
|
||||
grid: [],
|
||||
}),
|
||||
subscribe: () => () => {},
|
||||
send: (message: { type: string; data: string }) => {
|
||||
if (message.type === "input") {
|
||||
sent.push(message.data);
|
||||
}
|
||||
},
|
||||
};
|
||||
terminals.push({
|
||||
id: terminal.id,
|
||||
cwd: input.cwd,
|
||||
name: input.name,
|
||||
env: input.env,
|
||||
sent,
|
||||
});
|
||||
return terminal;
|
||||
}),
|
||||
} as any,
|
||||
};
|
||||
}
|
||||
|
||||
function createGitRepo(options?: { paseoConfig?: Record<string, unknown> }) {
|
||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "worktree-session-test-")));
|
||||
const repoDir = path.join(tempDir, "repo");
|
||||
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
|
||||
execSync("git init -b 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 };
|
||||
}
|
||||
|
||||
describe("createPaseoWorktreeInBackground", () => {
|
||||
const cleanupPaths: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const target of cleanupPaths.splice(0)) {
|
||||
rmSync(target, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("emits running then completed snapshots for no-setup workspaces and then launches services", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo({
|
||||
paseoConfig: {
|
||||
services: {
|
||||
web: {
|
||||
command: "npm run dev",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const createdWorktree = await createWorktree({
|
||||
branchName: "feature-no-setup",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-no-setup",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
const worktreePath = createdWorktree.worktreePath;
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const snapshots = new Map<string, unknown>();
|
||||
const routeStore = new ServiceRouteStore();
|
||||
const logger = createLogger();
|
||||
const terminalManager = createTerminalManagerStub();
|
||||
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
|
||||
await createPaseoWorktreeInBackground(
|
||||
{
|
||||
paseoHome,
|
||||
emitWorkspaceUpdateForCwd,
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
|
||||
emit: (message) => emitted.push(message),
|
||||
sessionLogger: logger,
|
||||
terminalManager: terminalManager.manager,
|
||||
archiveWorkspaceRecord,
|
||||
serviceRouteStore: routeStore,
|
||||
daemonPort: 6767,
|
||||
},
|
||||
{
|
||||
requestCwd: repoDir,
|
||||
repoRoot: repoDir,
|
||||
worktree: {
|
||||
branchName: "feature-no-setup",
|
||||
worktreePath,
|
||||
},
|
||||
shouldBootstrap: true,
|
||||
},
|
||||
);
|
||||
|
||||
const progressMessages = emitted.filter(
|
||||
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
|
||||
message.type === "workspace_setup_progress",
|
||||
);
|
||||
expect(progressMessages).toHaveLength(2);
|
||||
expect(progressMessages[0]?.payload).toMatchObject({
|
||||
workspaceId: worktreePath,
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath,
|
||||
branchName: "feature-no-setup",
|
||||
log: "",
|
||||
commands: [],
|
||||
},
|
||||
});
|
||||
expect(progressMessages[1]?.payload).toMatchObject({
|
||||
workspaceId: worktreePath,
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath,
|
||||
branchName: "feature-no-setup",
|
||||
log: "",
|
||||
commands: [],
|
||||
},
|
||||
});
|
||||
expect(snapshots.get(worktreePath)).toMatchObject({
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath,
|
||||
branchName: "feature-no-setup",
|
||||
log: "",
|
||||
commands: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(routeStore.listRoutes()).toEqual([
|
||||
{
|
||||
hostname: "feature-no-setup.web.localhost",
|
||||
port: expect.any(Number),
|
||||
workspaceId: worktreePath,
|
||||
serviceName: "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_SERVICE_URL).toBeDefined();
|
||||
expect(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]);
|
||||
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
|
||||
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
|
||||
});
|
||||
|
||||
test("archives the pending workspace and emits a failed snapshot when setup cannot start", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
writeFileSync(path.join(repoDir, "paseo.json"), "{ invalid json\n");
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'broken config'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const createdWorktree = await createWorktree({
|
||||
branchName: "broken-feature",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "broken-feature",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
const worktreePath = createdWorktree.worktreePath;
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const snapshots = new Map<string, unknown>();
|
||||
const logger = createLogger();
|
||||
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
|
||||
await createPaseoWorktreeInBackground(
|
||||
{
|
||||
paseoHome,
|
||||
emitWorkspaceUpdateForCwd,
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
|
||||
emit: (message) => emitted.push(message),
|
||||
sessionLogger: logger,
|
||||
terminalManager: null,
|
||||
archiveWorkspaceRecord,
|
||||
serviceRouteStore: null,
|
||||
daemonPort: null,
|
||||
},
|
||||
{
|
||||
requestCwd: repoDir,
|
||||
repoRoot: repoDir,
|
||||
worktree: {
|
||||
branchName: "broken-feature",
|
||||
worktreePath,
|
||||
},
|
||||
shouldBootstrap: true,
|
||||
},
|
||||
);
|
||||
|
||||
const progressMessages = emitted.filter(
|
||||
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
|
||||
message.type === "workspace_setup_progress",
|
||||
);
|
||||
expect(progressMessages).toHaveLength(2);
|
||||
expect(progressMessages[0]?.payload.status).toBe("running");
|
||||
expect(progressMessages[0]?.payload.error).toBeNull();
|
||||
expect(progressMessages[1]?.payload.status).toBe("failed");
|
||||
expect(progressMessages[1]?.payload.error).toContain("Failed to parse paseo.json");
|
||||
expect(progressMessages[1]?.payload.detail.commands).toEqual([]);
|
||||
expect(snapshots.get(worktreePath)).toMatchObject({
|
||||
status: "failed",
|
||||
error: expect.stringContaining("Failed to parse paseo.json"),
|
||||
});
|
||||
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(worktreePath);
|
||||
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
|
||||
});
|
||||
|
||||
test("emits running setup snapshots before completed for real setup commands", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo({
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ['sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"'],
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const createdWorktree = await createWorktree({
|
||||
branchName: "feature-running-setup",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-running-setup",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
const worktreePath = createdWorktree.worktreePath;
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const snapshots = new Map<string, unknown>();
|
||||
const logger = createLogger();
|
||||
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
|
||||
await createPaseoWorktreeInBackground(
|
||||
{
|
||||
paseoHome,
|
||||
emitWorkspaceUpdateForCwd,
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
|
||||
emit: (message) => emitted.push(message),
|
||||
sessionLogger: logger,
|
||||
terminalManager: null,
|
||||
archiveWorkspaceRecord,
|
||||
serviceRouteStore: null,
|
||||
daemonPort: null,
|
||||
},
|
||||
{
|
||||
requestCwd: repoDir,
|
||||
repoRoot: repoDir,
|
||||
worktree: {
|
||||
branchName: "feature-running-setup",
|
||||
worktreePath,
|
||||
},
|
||||
shouldBootstrap: true,
|
||||
},
|
||||
);
|
||||
|
||||
const progressMessages = emitted.filter(
|
||||
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
|
||||
message.type === "workspace_setup_progress",
|
||||
);
|
||||
expect(progressMessages.length).toBeGreaterThan(1);
|
||||
expect(progressMessages[0]?.payload).toMatchObject({
|
||||
workspaceId: worktreePath,
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath,
|
||||
branchName: "feature-running-setup",
|
||||
log: "",
|
||||
commands: [],
|
||||
},
|
||||
});
|
||||
expect(progressMessages.at(-1)?.payload.status).toBe("completed");
|
||||
|
||||
const runningMessages = progressMessages.filter((message) => message.payload.status === "running");
|
||||
expect(runningMessages.length).toBeGreaterThan(0);
|
||||
expect(progressMessages.findIndex((message) => message.payload.status === "running")).toBeLessThan(
|
||||
progressMessages.findIndex((message) => message.payload.status === "completed"),
|
||||
);
|
||||
|
||||
const setupOutputMessage = runningMessages.find((message) =>
|
||||
message.payload.detail.commands[0]?.log.includes("phase-one"),
|
||||
);
|
||||
expect(setupOutputMessage?.payload.detail.log).toContain("phase-one");
|
||||
expect(setupOutputMessage?.payload.detail.commands[0]).toMatchObject({
|
||||
index: 1,
|
||||
command: 'sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"',
|
||||
log: expect.stringContaining("phase-one"),
|
||||
status: "running",
|
||||
});
|
||||
|
||||
expect(progressMessages.at(-1)?.payload).toMatchObject({
|
||||
workspaceId: worktreePath,
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath,
|
||||
branchName: "feature-running-setup",
|
||||
},
|
||||
});
|
||||
expect(progressMessages.at(-1)?.payload.detail.log).toContain("phase-two");
|
||||
expect(progressMessages.at(-1)?.payload.detail.commands[0]).toMatchObject({
|
||||
index: 1,
|
||||
command: 'sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"',
|
||||
log: expect.stringContaining("phase-two"),
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
});
|
||||
expect(snapshots.get(worktreePath)).toMatchObject({
|
||||
status: "completed",
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("emits completed when reusing an existing worktree without bootstrapping", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo({
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ["printf 'ran' > setup-ran.txt"],
|
||||
},
|
||||
services: {
|
||||
web: {
|
||||
command: "npm run dev",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const existingWorktree = await createWorktree({
|
||||
branchName: "reused-worktree",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "reused-worktree",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const snapshots = new Map<string, unknown>();
|
||||
const routeStore = new ServiceRouteStore();
|
||||
const logger = createLogger();
|
||||
const terminalManager = createTerminalManagerStub();
|
||||
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
|
||||
await createPaseoWorktreeInBackground(
|
||||
{
|
||||
paseoHome,
|
||||
emitWorkspaceUpdateForCwd,
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
|
||||
emit: (message) => emitted.push(message),
|
||||
sessionLogger: logger,
|
||||
terminalManager: terminalManager.manager,
|
||||
archiveWorkspaceRecord,
|
||||
serviceRouteStore: routeStore,
|
||||
daemonPort: 6767,
|
||||
},
|
||||
{
|
||||
requestCwd: repoDir,
|
||||
repoRoot: repoDir,
|
||||
worktree: {
|
||||
branchName: "reused-worktree",
|
||||
worktreePath: existingWorktree.worktreePath,
|
||||
},
|
||||
shouldBootstrap: false,
|
||||
},
|
||||
);
|
||||
|
||||
const progressMessages = emitted.filter(
|
||||
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
|
||||
message.type === "workspace_setup_progress",
|
||||
);
|
||||
expect(progressMessages).toHaveLength(2);
|
||||
expect(progressMessages[0]?.payload).toMatchObject({
|
||||
workspaceId: existingWorktree.worktreePath,
|
||||
status: "running",
|
||||
error: null,
|
||||
});
|
||||
expect(progressMessages[1]?.payload).toMatchObject({
|
||||
workspaceId: existingWorktree.worktreePath,
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: existingWorktree.worktreePath,
|
||||
branchName: "reused-worktree",
|
||||
log: "",
|
||||
commands: [],
|
||||
},
|
||||
});
|
||||
expect(routeStore.listRoutes()).toEqual([
|
||||
{
|
||||
hostname: "reused-worktree.web.localhost",
|
||||
port: expect.any(Number),
|
||||
workspaceId: existingWorktree.worktreePath,
|
||||
serviceName: "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_SERVICE_URL).toBeDefined();
|
||||
expect(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]);
|
||||
expect(
|
||||
readFileSync(path.join(existingWorktree.worktreePath, "README.md"), "utf8"),
|
||||
).toContain("hello");
|
||||
expect(() => readFileSync(path.join(existingWorktree.worktreePath, "setup-ran.txt"), "utf8")).toThrow();
|
||||
expect(snapshots.get(existingWorktree.worktreePath)).toMatchObject({
|
||||
status: "completed",
|
||||
error: null,
|
||||
});
|
||||
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
|
||||
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(existingWorktree.worktreePath);
|
||||
});
|
||||
|
||||
test("keeps setup completed when service launch fails afterward", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo({
|
||||
paseoConfig: {
|
||||
services: {
|
||||
web: {
|
||||
command: "npm run dev",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const createdWorktree = await createWorktree({
|
||||
branchName: "feature-service-failure",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-service-failure",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
const worktreePath = createdWorktree.worktreePath;
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const snapshots = new Map<string, unknown>();
|
||||
const routeStore = new ServiceRouteStore();
|
||||
const logger = createLogger();
|
||||
const terminalManager = createTerminalManagerStub({
|
||||
createTerminal: async () => {
|
||||
throw new Error("terminal spawn failed");
|
||||
},
|
||||
});
|
||||
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
|
||||
await createPaseoWorktreeInBackground(
|
||||
{
|
||||
paseoHome,
|
||||
emitWorkspaceUpdateForCwd,
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
|
||||
emit: (message) => emitted.push(message),
|
||||
sessionLogger: logger,
|
||||
terminalManager: terminalManager.manager,
|
||||
archiveWorkspaceRecord,
|
||||
serviceRouteStore: routeStore,
|
||||
daemonPort: 6767,
|
||||
},
|
||||
{
|
||||
requestCwd: repoDir,
|
||||
repoRoot: repoDir,
|
||||
worktree: {
|
||||
branchName: "feature-service-failure",
|
||||
worktreePath,
|
||||
},
|
||||
shouldBootstrap: true,
|
||||
},
|
||||
);
|
||||
|
||||
const progressMessages = emitted.filter(
|
||||
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
|
||||
message.type === "workspace_setup_progress",
|
||||
);
|
||||
expect(progressMessages).toHaveLength(2);
|
||||
expect(progressMessages[0]?.payload.status).toBe("running");
|
||||
expect(progressMessages[0]?.payload.error).toBeNull();
|
||||
expect(progressMessages[1]?.payload.status).toBe("completed");
|
||||
expect(progressMessages[1]?.payload.error).toBeNull();
|
||||
expect(emitted.some((message) => message.type === "workspace_setup_progress" && message.payload.status === "failed")).toBe(false);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: expect.any(Error),
|
||||
cwd: repoDir,
|
||||
repoRoot: repoDir,
|
||||
worktreeSlug: "feature-service-failure",
|
||||
worktreePath,
|
||||
}),
|
||||
"Failed to spawn worktree services after workspace setup completed",
|
||||
);
|
||||
expect(snapshots.get(worktreePath)).toMatchObject({
|
||||
status: "completed",
|
||||
error: null,
|
||||
});
|
||||
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
|
||||
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
|
||||
});
|
||||
|
||||
test("launches services in socket mode without requiring a daemon TCP port", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo({
|
||||
paseoConfig: {
|
||||
services: {
|
||||
web: {
|
||||
command: "npm run dev",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const createdWorktree = await createWorktree({
|
||||
branchName: "feature-socket-mode",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-socket-mode",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
const worktreePath = createdWorktree.worktreePath;
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const snapshots = new Map<string, unknown>();
|
||||
const routeStore = new ServiceRouteStore();
|
||||
const logger = createLogger();
|
||||
const terminalManager = createTerminalManagerStub();
|
||||
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||
|
||||
await createPaseoWorktreeInBackground(
|
||||
{
|
||||
paseoHome,
|
||||
emitWorkspaceUpdateForCwd,
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
|
||||
emit: (message) => emitted.push(message),
|
||||
sessionLogger: logger,
|
||||
terminalManager: terminalManager.manager,
|
||||
archiveWorkspaceRecord,
|
||||
serviceRouteStore: routeStore,
|
||||
daemonPort: null,
|
||||
},
|
||||
{
|
||||
requestCwd: repoDir,
|
||||
repoRoot: repoDir,
|
||||
worktree: {
|
||||
branchName: "feature-socket-mode",
|
||||
worktreePath,
|
||||
},
|
||||
shouldBootstrap: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(routeStore.listRoutes()).toEqual([
|
||||
{
|
||||
hostname: "feature-socket-mode.web.localhost",
|
||||
port: expect.any(Number),
|
||||
workspaceId: worktreePath,
|
||||
serviceName: "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_SERVICE_URL).toBeUndefined();
|
||||
expect(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]);
|
||||
expect(snapshots.get(worktreePath)).toMatchObject({
|
||||
status: "completed",
|
||||
error: null,
|
||||
});
|
||||
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
|
||||
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
|
||||
});
|
||||
|
||||
test("returns the cached workspace setup snapshot for status requests", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const snapshots = new Map([
|
||||
[
|
||||
"/repo/.paseo/worktrees/feature-a",
|
||||
{
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/repo/.paseo/worktrees/feature-a",
|
||||
branchName: "feature-a",
|
||||
log: "done",
|
||||
commands: [],
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
await handleWorkspaceSetupStatusRequest(
|
||||
{
|
||||
emit: (message) => emitted.push(message),
|
||||
workspaceSetupSnapshots: snapshots,
|
||||
},
|
||||
{
|
||||
type: "workspace_setup_status_request",
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
requestId: "req-status",
|
||||
},
|
||||
);
|
||||
|
||||
expect(emitted).toContainEqual({
|
||||
type: "workspace_setup_status_response",
|
||||
payload: {
|
||||
requestId: "req-status",
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
snapshot: {
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/repo/.paseo/worktrees/feature-a",
|
||||
branchName: "feature-a",
|
||||
log: "done",
|
||||
commands: [],
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null when no cached workspace setup snapshot exists", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
|
||||
await handleWorkspaceSetupStatusRequest(
|
||||
{
|
||||
emit: (message) => emitted.push(message),
|
||||
workspaceSetupSnapshots: new Map(),
|
||||
},
|
||||
{
|
||||
type: "workspace_setup_status_request",
|
||||
workspaceId: "/repo/.paseo/worktrees/missing",
|
||||
requestId: "req-missing",
|
||||
},
|
||||
);
|
||||
|
||||
expect(emitted).toContainEqual({
|
||||
type: "workspace_setup_status_response",
|
||||
payload: {
|
||||
requestId: "req-missing",
|
||||
workspaceId: "/repo/.paseo/worktrees/missing",
|
||||
snapshot: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
test("creates the worktree before emitting the response", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const backgroundWork = vi.fn(async () => {});
|
||||
|
||||
try {
|
||||
await handleCreatePaseoWorktreeRequest(
|
||||
{
|
||||
paseoHome,
|
||||
sessionLogger: createLogger(),
|
||||
emit: (message) => emitted.push(message),
|
||||
registerPendingWorktreeWorkspace: vi.fn(async (options) => {
|
||||
expect(existsSync(options.worktreePath)).toBe(true);
|
||||
return {
|
||||
workspaceId: options.worktreePath,
|
||||
projectId: options.repoRoot,
|
||||
} as any;
|
||||
}),
|
||||
describeWorkspaceRecord: vi.fn(async (workspace) => ({
|
||||
id: workspace.workspaceId,
|
||||
projectId: workspace.projectId,
|
||||
projectDisplayName: path.basename(repoDir),
|
||||
projectRootPath: repoDir,
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: path.basename(workspace.workspaceId),
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
})),
|
||||
createPaseoWorktreeInBackground: backgroundWork,
|
||||
},
|
||||
{
|
||||
type: "create_paseo_worktree_request",
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "response-after-create",
|
||||
requestId: "req-1",
|
||||
},
|
||||
);
|
||||
|
||||
const response = emitted.find(
|
||||
(message): message is Extract<SessionOutboundMessage, { type: "create_paseo_worktree_response" }> =>
|
||||
message.type === "create_paseo_worktree_response",
|
||||
);
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(response?.payload.workspace?.id).toBeTruthy();
|
||||
expect(existsSync(response!.payload.workspace!.id)).toBe(true);
|
||||
expect(backgroundWork).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requestCwd: repoDir,
|
||||
repoRoot: repoDir,
|
||||
worktree: {
|
||||
branchName: "response-after-create",
|
||||
worktreePath: response!.payload.workspace!.id,
|
||||
},
|
||||
shouldBootstrap: true,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type ProjectPlacementPayload,
|
||||
type SessionInboundMessage,
|
||||
type SessionOutboundMessage,
|
||||
type WorkspaceSetupSnapshot,
|
||||
type WorkspaceDescriptorPayload,
|
||||
} from "./messages.js";
|
||||
import type {
|
||||
@@ -19,8 +20,16 @@ import type {
|
||||
WorkspaceRegistry,
|
||||
} from "./workspace-registry.js";
|
||||
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
|
||||
import { createAgentWorktree } from "./worktree-bootstrap.js";
|
||||
import {
|
||||
applyWorktreeSetupProgressEvent,
|
||||
buildWorktreeSetupDetail,
|
||||
createAgentWorktree,
|
||||
createWorktreeSetupProgressAccumulator,
|
||||
getWorktreeSetupProgressResults,
|
||||
spawnWorktreeServices,
|
||||
} from "./worktree-bootstrap.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type { ServiceRouteStore } from "./service-proxy.js";
|
||||
import {
|
||||
getCheckoutStatusLite,
|
||||
resolveRepositoryDefaultBranch,
|
||||
@@ -34,9 +43,12 @@ import {
|
||||
listPaseoWorktrees,
|
||||
resolvePaseoWorktreeRootForCwd,
|
||||
resolveWorktreeRuntimeEnv,
|
||||
runWorktreeSetupCommands,
|
||||
slugify,
|
||||
validateBranchSlug,
|
||||
type WorktreeConfig,
|
||||
type WorktreeSetupCommandResult,
|
||||
WorktreeSetupError,
|
||||
} from "../utils/worktree.js";
|
||||
import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js";
|
||||
|
||||
@@ -93,8 +105,18 @@ type CreatePaseoWorktreeInBackgroundDependencies = {
|
||||
cwd: string,
|
||||
options?: { dedupeGitState?: boolean },
|
||||
) => Promise<void>;
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId: string, snapshot: WorkspaceSetupSnapshot) => void;
|
||||
emit: EmitSessionMessage;
|
||||
sessionLogger: Logger;
|
||||
terminalManager: TerminalManager | null;
|
||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||
serviceRouteStore: ServiceRouteStore | null;
|
||||
daemonPort?: number | null;
|
||||
};
|
||||
|
||||
type HandleWorkspaceSetupStatusRequestDependencies = {
|
||||
emit: EmitSessionMessage;
|
||||
workspaceSetupSnapshots: ReadonlyMap<string, WorkspaceSetupSnapshot>;
|
||||
};
|
||||
|
||||
type HandleCreatePaseoWorktreeRequestDependencies = {
|
||||
@@ -112,9 +134,8 @@ type HandleCreatePaseoWorktreeRequestDependencies = {
|
||||
createPaseoWorktreeInBackground: (options: {
|
||||
requestCwd: string;
|
||||
repoRoot: string;
|
||||
baseBranch: string;
|
||||
slug: string;
|
||||
worktreePath: string;
|
||||
worktree: WorktreeConfig;
|
||||
shouldBootstrap: boolean;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -131,10 +152,13 @@ export async function buildAgentSessionConfig(
|
||||
gitOptions?: GitSetupOptions,
|
||||
legacyWorktreeName?: string,
|
||||
_labels?: Record<string, string>,
|
||||
): Promise<{ sessionConfig: AgentSessionConfig; worktreeConfig?: WorktreeConfig }> {
|
||||
): Promise<{
|
||||
sessionConfig: AgentSessionConfig;
|
||||
worktreeBootstrap?: { worktree: WorktreeConfig; shouldBootstrap: boolean };
|
||||
}> {
|
||||
let cwd = expandTilde(config.cwd);
|
||||
const normalized = normalizeGitOptions(gitOptions, legacyWorktreeName);
|
||||
let worktreeConfig: WorktreeConfig | undefined;
|
||||
let worktreeBootstrap: { worktree: WorktreeConfig; shouldBootstrap: boolean } | undefined;
|
||||
|
||||
if (!normalized) {
|
||||
return {
|
||||
@@ -176,8 +200,8 @@ export async function buildAgentSessionConfig(
|
||||
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
cwd = createdWorktree.worktreePath;
|
||||
worktreeConfig = createdWorktree;
|
||||
cwd = createdWorktree.worktree.worktreePath;
|
||||
worktreeBootstrap = createdWorktree;
|
||||
} else if (normalized.createNewBranch) {
|
||||
const baseBranch =
|
||||
normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
|
||||
@@ -195,7 +219,7 @@ export async function buildAgentSessionConfig(
|
||||
...config,
|
||||
cwd,
|
||||
},
|
||||
worktreeConfig,
|
||||
worktreeBootstrap,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -567,11 +591,18 @@ export async function handleCreatePaseoWorktreeRequest(
|
||||
throw new Error(`Invalid worktree name: ${validation.error}`);
|
||||
}
|
||||
|
||||
const worktreePath = await computeWorktreePath(repoRoot, normalizedSlug, dependencies.paseoHome);
|
||||
await computeWorktreePath(repoRoot, normalizedSlug, dependencies.paseoHome);
|
||||
const createdWorktree = await createAgentWorktree({
|
||||
cwd: repoRoot,
|
||||
branchName: normalizedSlug,
|
||||
baseBranch,
|
||||
worktreeSlug: normalizedSlug,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
const workspace = await dependencies.registerPendingWorktreeWorkspace({
|
||||
repoRoot,
|
||||
worktreePath,
|
||||
branchName: normalizedSlug,
|
||||
worktreePath: createdWorktree.worktree.worktreePath,
|
||||
branchName: createdWorktree.worktree.branchName,
|
||||
});
|
||||
await createAgentWorktree({
|
||||
cwd: repoRoot,
|
||||
@@ -594,9 +625,8 @@ export async function handleCreatePaseoWorktreeRequest(
|
||||
void dependencies.createPaseoWorktreeInBackground({
|
||||
requestCwd: request.cwd,
|
||||
repoRoot,
|
||||
baseBranch,
|
||||
slug: normalizedSlug,
|
||||
worktreePath,
|
||||
worktree: createdWorktree.worktree,
|
||||
shouldBootstrap: createdWorktree.shouldBootstrap,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to create worktree";
|
||||
@@ -616,58 +646,146 @@ export async function handleCreatePaseoWorktreeRequest(
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleWorkspaceSetupStatusRequest(
|
||||
dependencies: HandleWorkspaceSetupStatusRequestDependencies,
|
||||
request: Extract<SessionInboundMessage, { type: "workspace_setup_status_request" }>,
|
||||
): Promise<void> {
|
||||
const workspaceId = normalizePersistedWorkspaceId(request.workspaceId);
|
||||
dependencies.emit({
|
||||
type: "workspace_setup_status_response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspaceId,
|
||||
snapshot: dependencies.workspaceSetupSnapshots.get(workspaceId) ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createPaseoWorktreeInBackground(
|
||||
dependencies: CreatePaseoWorktreeInBackgroundDependencies,
|
||||
options: {
|
||||
requestCwd: string;
|
||||
repoRoot: string;
|
||||
baseBranch: string;
|
||||
slug: string;
|
||||
worktreePath: string;
|
||||
worktree: WorktreeConfig;
|
||||
shouldBootstrap: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
let setupTerminalId: string | null = null;
|
||||
let worktree: WorktreeConfig = options.worktree;
|
||||
let setupResults: WorktreeSetupCommandResult[] = [];
|
||||
let setupStarted = false;
|
||||
const progressAccumulator = createWorktreeSetupProgressAccumulator();
|
||||
const workspaceId = normalizePersistedWorkspaceId(worktree.worktreePath);
|
||||
|
||||
const emitSetupProgress = (status: "running" | "completed" | "failed", error: string | null) => {
|
||||
const snapshot: WorkspaceSetupSnapshot = {
|
||||
status,
|
||||
detail: buildWorktreeSetupDetail({
|
||||
worktree,
|
||||
results:
|
||||
status === "running" ? getWorktreeSetupProgressResults(progressAccumulator) : setupResults,
|
||||
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
|
||||
}),
|
||||
error,
|
||||
};
|
||||
dependencies.cacheWorkspaceSetupSnapshot(workspaceId, snapshot);
|
||||
dependencies.emit({
|
||||
type: "workspace_setup_progress",
|
||||
payload: {
|
||||
workspaceId,
|
||||
...snapshot,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const setupCommands = getWorktreeSetupCommands(options.worktreePath);
|
||||
if (setupCommands.length > 0 && dependencies.terminalManager) {
|
||||
const runtimeEnv = await resolveWorktreeRuntimeEnv({
|
||||
worktreePath: options.worktreePath,
|
||||
branchName: options.slug,
|
||||
repoRootPath: options.repoRoot,
|
||||
});
|
||||
dependencies.terminalManager.registerCwdEnv({
|
||||
cwd: options.worktreePath,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
const terminal = await dependencies.terminalManager.createTerminal({
|
||||
cwd: options.worktreePath,
|
||||
name: `setup-${options.slug}`,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
setupTerminalId = terminal.id;
|
||||
try {
|
||||
emitSetupProgress("running", null);
|
||||
|
||||
for (const command of setupCommands) {
|
||||
terminal.send({
|
||||
type: "input",
|
||||
data: `${command}\r`,
|
||||
});
|
||||
if (!options.shouldBootstrap) {
|
||||
emitSetupProgress("completed", null);
|
||||
} else {
|
||||
const setupCommands = getWorktreeSetupCommands(worktree.worktreePath);
|
||||
if (setupCommands.length === 0) {
|
||||
setupStarted = true;
|
||||
emitSetupProgress("completed", null);
|
||||
} else {
|
||||
const runtimeEnv = await resolveWorktreeRuntimeEnv({
|
||||
worktreePath: worktree.worktreePath,
|
||||
branchName: worktree.branchName,
|
||||
repoRootPath: options.repoRoot,
|
||||
});
|
||||
dependencies.terminalManager?.registerCwdEnv({
|
||||
cwd: worktree.worktreePath,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
setupStarted = true;
|
||||
setupResults = await runWorktreeSetupCommands({
|
||||
worktreePath: worktree.worktreePath,
|
||||
branchName: worktree.branchName,
|
||||
cleanupOnFailure: false,
|
||||
repoRootPath: options.repoRoot,
|
||||
runtimeEnv,
|
||||
onEvent: (event) => {
|
||||
applyWorktreeSetupProgressEvent(progressAccumulator, event);
|
||||
emitSetupProgress("running", null);
|
||||
},
|
||||
});
|
||||
emitSetupProgress("completed", null);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof WorktreeSetupError) {
|
||||
setupResults = error.results;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
emitSetupProgress("failed", message);
|
||||
|
||||
if (!setupStarted) {
|
||||
await dependencies.archiveWorkspaceRecord(normalizePersistedWorkspaceId(worktree.worktreePath));
|
||||
}
|
||||
|
||||
dependencies.sessionLogger.error(
|
||||
{
|
||||
err: error,
|
||||
cwd: options.requestCwd,
|
||||
repoRoot: options.repoRoot,
|
||||
worktreeSlug: worktree.branchName,
|
||||
worktreePath: worktree.worktreePath,
|
||||
setupStarted,
|
||||
},
|
||||
"Background worktree creation failed",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dependencies.terminalManager || !dependencies.serviceRouteStore) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await spawnWorktreeServices({
|
||||
repoRoot: worktree.worktreePath,
|
||||
workspaceId: worktree.worktreePath,
|
||||
branchName: worktree.branchName,
|
||||
daemonPort: dependencies.daemonPort,
|
||||
routeStore: dependencies.serviceRouteStore,
|
||||
terminalManager: dependencies.terminalManager,
|
||||
logger: dependencies.sessionLogger,
|
||||
});
|
||||
} catch (error) {
|
||||
dependencies.sessionLogger.error(
|
||||
{
|
||||
err: error,
|
||||
cwd: options.requestCwd,
|
||||
repoRoot: options.repoRoot,
|
||||
worktreeSlug: worktree.branchName,
|
||||
worktreePath: worktree.worktreePath,
|
||||
},
|
||||
"Failed to spawn worktree services after workspace setup completed",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
dependencies.sessionLogger.error(
|
||||
{
|
||||
err: error,
|
||||
cwd: options.requestCwd,
|
||||
repoRoot: options.repoRoot,
|
||||
worktreeSlug: options.slug,
|
||||
worktreePath: options.worktreePath,
|
||||
setupTerminalId,
|
||||
},
|
||||
"Background worktree creation failed",
|
||||
);
|
||||
} finally {
|
||||
await dependencies.emitWorkspaceUpdateForCwd(options.worktreePath);
|
||||
await dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -197,7 +197,27 @@ const NonNullUnknownSchema = z.union([
|
||||
z.object({}).passthrough(),
|
||||
]);
|
||||
|
||||
const WorktreeSetupCommandSnapshotSchema = z.object({
|
||||
index: z.number().int().positive(),
|
||||
command: z.string(),
|
||||
cwd: z.string(),
|
||||
log: z.string(),
|
||||
status: z.enum(["running", "completed", "failed"]),
|
||||
exitCode: z.number().nullable(),
|
||||
durationMs: z.number().nonnegative().optional(),
|
||||
});
|
||||
|
||||
const WorktreeSetupDetailPayloadSchema = z.object({
|
||||
type: z.literal("worktree_setup"),
|
||||
worktreePath: z.string(),
|
||||
branchName: z.string(),
|
||||
log: z.string(),
|
||||
commands: z.array(WorktreeSetupCommandSnapshotSchema),
|
||||
truncated: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUnion("type", [
|
||||
WorktreeSetupDetailPayloadSchema,
|
||||
z.object({
|
||||
type: z.literal("shell"),
|
||||
command: z.string(),
|
||||
@@ -256,23 +276,6 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUn
|
||||
bytes: z.number().optional(),
|
||||
durationMs: z.number().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("worktree_setup"),
|
||||
worktreePath: z.string(),
|
||||
branchName: z.string(),
|
||||
log: z.string(),
|
||||
commands: z.array(
|
||||
z.object({
|
||||
index: z.number().int().positive(),
|
||||
command: z.string(),
|
||||
cwd: z.string(),
|
||||
status: z.enum(["running", "completed", "failed"]),
|
||||
exitCode: z.number().nullable(),
|
||||
durationMs: z.number().nonnegative().optional(),
|
||||
}),
|
||||
),
|
||||
truncated: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("sub_agent"),
|
||||
subAgentType: z.string().optional(),
|
||||
@@ -1004,6 +1007,12 @@ export const CreatePaseoWorktreeRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const WorkspaceSetupStatusRequestSchema = z.object({
|
||||
type: z.literal("workspace_setup_status_request"),
|
||||
workspaceId: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const OpenProjectRequestSchema = z.object({
|
||||
type: z.literal("open_project_request"),
|
||||
cwd: z.string(),
|
||||
@@ -1251,6 +1260,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
PaseoWorktreeListRequestSchema,
|
||||
PaseoWorktreeArchiveRequestSchema,
|
||||
CreatePaseoWorktreeRequestSchema,
|
||||
WorkspaceSetupStatusRequestSchema,
|
||||
OpenProjectRequestSchema,
|
||||
ArchiveWorkspaceRequestSchema,
|
||||
FileExplorerRequestSchema,
|
||||
@@ -1596,6 +1606,14 @@ export const ProjectPlacementPayloadSchema = z.object({
|
||||
checkout: ProjectCheckoutLitePayloadSchema,
|
||||
});
|
||||
|
||||
export const WorkspaceServicePayloadSchema = z.object({
|
||||
serviceName: z.string(),
|
||||
hostname: z.string(),
|
||||
port: z.number().int().positive(),
|
||||
url: z.string().nullable(),
|
||||
status: z.enum(["running", "stopped"]),
|
||||
});
|
||||
|
||||
export const WorkspaceDescriptorPayloadSchema = z.object({
|
||||
id: z.number().int(),
|
||||
projectId: z.number().int(),
|
||||
@@ -1614,6 +1632,7 @@ export const WorkspaceDescriptorPayloadSchema = z.object({
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
services: z.array(WorkspaceServicePayloadSchema).default([]),
|
||||
});
|
||||
|
||||
export const AgentUpdateMessageSchema = z.object({
|
||||
@@ -1709,6 +1728,39 @@ export const WorkspaceUpdateMessageSchema = z.object({
|
||||
]),
|
||||
});
|
||||
|
||||
export const ServiceStatusUpdateMessageSchema = z.object({
|
||||
type: z.literal("service_status_update"),
|
||||
payload: z.object({
|
||||
workspaceId: z.string(),
|
||||
services: z.array(WorkspaceServicePayloadSchema),
|
||||
}),
|
||||
});
|
||||
|
||||
export const WorkspaceSetupProgressMessageSchema = z.object({
|
||||
type: z.literal("workspace_setup_progress"),
|
||||
payload: z.object({
|
||||
workspaceId: z.string(),
|
||||
status: z.enum(["running", "completed", "failed"]),
|
||||
detail: WorktreeSetupDetailPayloadSchema,
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const WorkspaceSetupSnapshotSchema = z.object({
|
||||
status: z.enum(["running", "completed", "failed"]),
|
||||
detail: WorktreeSetupDetailPayloadSchema,
|
||||
error: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const WorkspaceSetupStatusResponseMessageSchema = z.object({
|
||||
type: z.literal("workspace_setup_status_response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
workspaceId: z.string(),
|
||||
snapshot: WorkspaceSetupSnapshotSchema.nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const OpenProjectResponseMessageSchema = z.object({
|
||||
type: z.literal("open_project_response"),
|
||||
payload: z.object({
|
||||
@@ -2289,6 +2341,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ArtifactMessageSchema,
|
||||
AgentUpdateMessageSchema,
|
||||
WorkspaceUpdateMessageSchema,
|
||||
ServiceStatusUpdateMessageSchema,
|
||||
WorkspaceSetupProgressMessageSchema,
|
||||
WorkspaceSetupStatusResponseMessageSchema,
|
||||
AgentStreamMessageSchema,
|
||||
AgentStatusMessageSchema,
|
||||
FetchAgentsResponseMessageSchema,
|
||||
@@ -2373,14 +2428,21 @@ export type ServerInfoStatusPayload = z.infer<typeof ServerInfoStatusPayloadSche
|
||||
export type RpcErrorMessage = z.infer<typeof RpcErrorMessageSchema>;
|
||||
export type ArtifactMessage = z.infer<typeof ArtifactMessageSchema>;
|
||||
export type AgentUpdateMessage = z.infer<typeof AgentUpdateMessageSchema>;
|
||||
export type WorkspaceSetupProgressMessage = z.infer<typeof WorkspaceSetupProgressMessageSchema>;
|
||||
export type WorkspaceSetupSnapshot = z.infer<typeof WorkspaceSetupSnapshotSchema>;
|
||||
export type WorkspaceSetupStatusResponseMessage = z.infer<
|
||||
typeof WorkspaceSetupStatusResponseMessageSchema
|
||||
>;
|
||||
export type AgentStreamMessage = z.infer<typeof AgentStreamMessageSchema>;
|
||||
export type AgentStatusMessage = z.infer<typeof AgentStatusMessageSchema>;
|
||||
export type ProjectCheckoutLitePayload = z.infer<typeof ProjectCheckoutLitePayloadSchema>;
|
||||
export type ProjectPlacementPayload = z.infer<typeof ProjectPlacementPayloadSchema>;
|
||||
export type WorkspaceStateBucket = z.infer<typeof WorkspaceStateBucketSchema>;
|
||||
export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPayloadSchema>;
|
||||
export type WorkspaceServicePayload = z.infer<typeof WorkspaceServicePayloadSchema>;
|
||||
export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>;
|
||||
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
|
||||
export type ServiceStatusUpdateMessage = z.infer<typeof ServiceStatusUpdateMessageSchema>;
|
||||
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>;
|
||||
export type ArchiveWorkspaceResponseMessage = z.infer<typeof ArchiveWorkspaceResponseMessageSchema>;
|
||||
export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessageSchema>;
|
||||
@@ -2493,6 +2555,7 @@ export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSc
|
||||
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
|
||||
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
|
||||
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
|
||||
export type WorkspaceSetupStatusRequest = z.infer<typeof WorkspaceSetupStatusRequestSchema>;
|
||||
export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>;
|
||||
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
|
||||
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
||||
|
||||
@@ -44,10 +44,138 @@ describe("workspace message schemas", () => {
|
||||
name: "",
|
||||
status: "not-a-bucket",
|
||||
activityAt: null,
|
||||
services: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("parses workspace descriptors with services", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
workspace: {
|
||||
id: "/repo",
|
||||
projectId: "/repo",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/repo",
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
name: "repo",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
services: [
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: 3000,
|
||||
url: "http://web.localhost:6767",
|
||||
status: "running",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("workspace_update");
|
||||
if (parsed.type !== "workspace_update" || parsed.payload.kind !== "upsert") {
|
||||
throw new Error("Expected workspace_update upsert payload");
|
||||
}
|
||||
expect(parsed.payload.workspace.services).toEqual([
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: 3000,
|
||||
url: "http://web.localhost:6767",
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses service_status_update payload", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "service_status_update",
|
||||
payload: {
|
||||
workspaceId: "/repo",
|
||||
services: [
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: 3000,
|
||||
url: null,
|
||||
status: "stopped",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("service_status_update");
|
||||
expect(parsed.payload.workspaceId).toBe("/repo");
|
||||
});
|
||||
|
||||
test("parses workspace_setup_progress payload", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "workspace_setup_progress",
|
||||
payload: {
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/repo/.paseo/worktrees/feature-a",
|
||||
branchName: "feature-a",
|
||||
log: "done",
|
||||
commands: [
|
||||
{
|
||||
index: 1,
|
||||
command: "npm install",
|
||||
cwd: "/repo/.paseo/worktrees/feature-a",
|
||||
log: "done",
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
durationMs: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("workspace_setup_progress");
|
||||
});
|
||||
|
||||
test("parses workspace_setup_status_request", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "workspace_setup_status_request",
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
requestId: "req-status",
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("workspace_setup_status_request");
|
||||
});
|
||||
|
||||
test("parses workspace_setup_status_response payload", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "workspace_setup_status_response",
|
||||
payload: {
|
||||
requestId: "req-status",
|
||||
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||
snapshot: {
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/repo/.paseo/worktrees/feature-a",
|
||||
branchName: "feature-a",
|
||||
log: "done",
|
||||
commands: [],
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("workspace_setup_status_response");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,6 +79,7 @@ describe("shared tool-call display mapping", () => {
|
||||
index: 1,
|
||||
command: "npm install",
|
||||
cwd: "/tmp/repo/.paseo/worktrees/repo/branch",
|
||||
log: "==> [1/1] Running: npm install\n",
|
||||
status: "running",
|
||||
exitCode: null,
|
||||
},
|
||||
|
||||
@@ -4,7 +4,9 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } f
|
||||
import { join, basename, dirname, resolve, sep } from "path";
|
||||
import net from "node:net";
|
||||
import { createHash } from "node:crypto";
|
||||
import * as pty from "node-pty";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import {
|
||||
normalizeBaseRefName,
|
||||
readPaseoWorktreeMetadata,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
writePaseoWorktreeRuntimeMetadata,
|
||||
} from "./worktree-metadata.js";
|
||||
import { resolvePaseoHome } from "../server/paseo-home.js";
|
||||
import { ensureNodePtySpawnHelperExecutableForCurrentPlatform } from "../terminal/terminal.js";
|
||||
|
||||
interface PaseoConfig {
|
||||
worktree?: {
|
||||
@@ -20,6 +23,7 @@ interface PaseoConfig {
|
||||
teardown?: string[];
|
||||
terminals?: WorktreeTerminalConfig[];
|
||||
};
|
||||
services?: Record<string, ServiceConfig>;
|
||||
}
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -84,6 +88,11 @@ export interface WorktreeTerminalConfig {
|
||||
command: string;
|
||||
}
|
||||
|
||||
export interface ServiceConfig {
|
||||
command: string;
|
||||
port?: number; // explicit port override, otherwise auto-assigned
|
||||
}
|
||||
|
||||
export class WorktreeSetupError extends Error {
|
||||
readonly results: WorktreeSetupCommandResult[];
|
||||
|
||||
@@ -194,6 +203,90 @@ export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConf
|
||||
return specs;
|
||||
}
|
||||
|
||||
export function getServiceConfigs(repoRoot: string): Map<string, ServiceConfig> {
|
||||
const config = readPaseoConfig(repoRoot);
|
||||
const services = config?.services;
|
||||
if (!services || typeof services !== "object") {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const result = new Map<string, ServiceConfig>();
|
||||
for (const [name, entry] of Object.entries(services)) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawCommand = entry.command;
|
||||
if (typeof rawCommand !== "string") {
|
||||
continue;
|
||||
}
|
||||
const command = rawCommand.trim();
|
||||
if (!command) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const serviceConfig: ServiceConfig = { command };
|
||||
|
||||
if (typeof entry.port === "number" && Number.isFinite(entry.port)) {
|
||||
serviceConfig.port = entry.port;
|
||||
}
|
||||
|
||||
result.set(name, serviceConfig);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function processCarriageReturns(text: string): string {
|
||||
if (!text.includes("\r")) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const output: string[] = [];
|
||||
let line: string[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
const flushLine = () => {
|
||||
output.push(line.join(""));
|
||||
line = [];
|
||||
cursor = 0;
|
||||
};
|
||||
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const char = text[index];
|
||||
|
||||
if (char === "\r") {
|
||||
if (text[index + 1] === "\n") {
|
||||
flushLine();
|
||||
output.push("\n");
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
cursor = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "\n") {
|
||||
flushLine();
|
||||
output.push("\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cursor < line.length) {
|
||||
line[cursor] = char;
|
||||
} else {
|
||||
line.push(char);
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
if (line.length > 0) {
|
||||
output.push(line.join(""));
|
||||
}
|
||||
|
||||
return output.join("");
|
||||
}
|
||||
|
||||
async function execSetupCommand(
|
||||
command: string,
|
||||
options: { cwd: string; env: NodeJS.ProcessEnv },
|
||||
@@ -239,6 +332,27 @@ async function execSetupCommandStreamed(options: {
|
||||
const stderrChunks: string[] = [];
|
||||
let settled = false;
|
||||
|
||||
const emitOutput = (stream: "stdout" | "stderr", chunk: string) => {
|
||||
const text = stripAnsi(chunk);
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
if (stream === "stdout") {
|
||||
stdoutChunks.push(text);
|
||||
} else {
|
||||
stderrChunks.push(text);
|
||||
}
|
||||
options.onEvent?.({
|
||||
type: "output",
|
||||
index: options.index,
|
||||
total: options.total,
|
||||
command: options.command,
|
||||
cwd: options.cwd,
|
||||
stream,
|
||||
chunk: text,
|
||||
});
|
||||
};
|
||||
|
||||
const finish = (exitCode: number | null) => {
|
||||
if (settled) {
|
||||
return;
|
||||
@@ -274,48 +388,52 @@ async function execSetupCommandStreamed(options: {
|
||||
cwd: options.cwd,
|
||||
});
|
||||
|
||||
const child = spawn("/bin/bash", ["-lc", options.command], {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
const text = chunk.toString();
|
||||
stdoutChunks.push(text);
|
||||
options.onEvent?.({
|
||||
type: "output",
|
||||
index: options.index,
|
||||
total: options.total,
|
||||
command: options.command,
|
||||
const spawnWithPipes = () => {
|
||||
const child = spawn("/bin/bash", ["-lc", options.command], {
|
||||
cwd: options.cwd,
|
||||
stream: "stdout",
|
||||
chunk: text,
|
||||
env: options.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk: Buffer | string) => {
|
||||
const text = chunk.toString();
|
||||
stderrChunks.push(text);
|
||||
options.onEvent?.({
|
||||
type: "output",
|
||||
index: options.index,
|
||||
total: options.total,
|
||||
command: options.command,
|
||||
child.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
emitOutput("stdout", chunk.toString());
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk: Buffer | string) => {
|
||||
emitOutput("stderr", chunk.toString());
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
emitOutput("stderr", error instanceof Error ? error.message : String(error));
|
||||
finish(null);
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
finish(typeof code === "number" ? code : null);
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
ensureNodePtySpawnHelperExecutableForCurrentPlatform();
|
||||
const terminal = pty.spawn("/bin/bash", ["-lc", options.command], {
|
||||
cwd: options.cwd,
|
||||
stream: "stderr",
|
||||
chunk: text,
|
||||
env: options.env,
|
||||
name: "xterm-color",
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
});
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
stderrChunks.push(error instanceof Error ? error.message : String(error));
|
||||
finish(null);
|
||||
});
|
||||
terminal.onData((data) => {
|
||||
emitOutput("stdout", data);
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
finish(typeof code === "number" ? code : null);
|
||||
});
|
||||
terminal.onExit(({ exitCode }) => {
|
||||
finish(typeof exitCode === "number" ? exitCode : null);
|
||||
});
|
||||
} catch (error) {
|
||||
emitOutput("stderr", error instanceof Error ? error.message : String(error));
|
||||
spawnWithPipes();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user