From 266d04252817f3959e0d5eb59a62f04befa205c2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 30 Mar 2026 21:41:03 +0700 Subject: [PATCH 1/4] =?UTF-8?q?feat(server):=20built-in=20service=20proxy?= =?UTF-8?q?=20=E2=80=94=20absorb=20portless=20into=20daemon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Services defined in paseo.json get reverse-proxied through the daemon via hostname-based routing on *.localhost. Each service receives $PORT, $HOST, and $PASEO_SERVICE_URL env vars, and is accessible at {service}.localhost:6767 (main) or {branch}.{service}.localhost:6767 (worktrees). --- .../src/server/agent/agent-management-mcp.ts | 5 + .../server/src/server/agent/mcp-server.ts | 5 + packages/server/src/server/bootstrap.ts | 38 ++ .../server/src/server/service-proxy.test.ts | 347 ++++++++++++++++++ packages/server/src/server/service-proxy.ts | 262 +++++++++++++ packages/server/src/server/session.ts | 11 + .../server/src/server/websocket-server.ts | 9 + .../server/src/server/worktree-bootstrap.ts | 112 ++++++ packages/server/src/utils/worktree.ts | 40 ++ 9 files changed, 829 insertions(+) create mode 100644 packages/server/src/server/service-proxy.test.ts create mode 100644 packages/server/src/server/service-proxy.ts diff --git a/packages/server/src/server/agent/agent-management-mcp.ts b/packages/server/src/server/agent/agent-management-mcp.ts index 14086d973..36ccf9ab6 100644 --- a/packages/server/src/server/agent/agent-management-mcp.ts +++ b/packages/server/src/server/agent/agent-management-mcp.ts @@ -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: AgentStorage; terminalManager?: TerminalManager | null; + serviceRouteStore?: ServiceRouteStore; + getDaemonTcpPort?: () => number | null; paseoHome?: string; logger: Logger; } @@ -339,6 +342,8 @@ export async function createAgentManagementMcpServer( agentId: snapshot.id, item, }), + serviceRouteStore: options.serviceRouteStore, + daemonPort: options.getDaemonTcpPort?.() ?? null, logger: childLogger, }); } diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 60f20cf5a..b1fb60104 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -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: AgentStorage; terminalManager?: TerminalManager | null; + serviceRouteStore?: ServiceRouteStore; + getDaemonTcpPort?: () => number | null; paseoHome?: string; /** * ID of the agent that is connecting to this MCP server. @@ -510,6 +513,8 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom agentId: snapshot.id, item, }), + serviceRouteStore: options.serviceRouteStore, + daemonPort: options.getDaemonTcpPort?.() ?? null, logger: childLogger, }); } diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index e73fe74a6..42db80269 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -113,6 +113,11 @@ 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 { createVoiceMcpSocketBridgeManager, type VoiceMcpSocketBridgeManager, @@ -189,6 +194,7 @@ export interface PaseoDaemon { agentManager: AgentManager; agentStorage: AgentStorage; terminalManager: TerminalManager; + serviceRouteStore: ServiceRouteStore; start(): Promise; stop(): Promise; getListenTarget(): ListenTarget | null; @@ -218,6 +224,8 @@ export async function createPaseoDaemon( const app = express(); let boundListenTarget: ListenTarget | null = null; + const serviceRouteStore = new ServiceRouteStore(); + // Host allowlist / DNS rebinding protection (vite-like semantics). // For non-TCP (unix sockets), skip host validation. if (listenTarget.type === "tcp") { @@ -231,6 +239,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, @@ -352,6 +368,16 @@ export async function createPaseoDaemon( const httpServer = createHTTPServer(app); + // 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 AgentStorage(config.agentStoragePath, logger); const projectRegistry = new FileBackedProjectRegistry( path.join(config.paseoHome, "projects", "projects.json"), @@ -433,6 +459,9 @@ export async function createPaseoDaemon( agentManager, agentStorage, terminalManager, + serviceRouteStore, + getDaemonTcpPort: () => + boundListenTarget?.type === "tcp" ? boundListenTarget.port : null, paseoHome: config.paseoHome, enableVoiceTools: false, resolveSpeakHandler: (callerAgentId) => @@ -459,6 +488,9 @@ export async function createPaseoDaemon( agentManager, agentStorage, terminalManager, + serviceRouteStore, + getDaemonTcpPort: () => + boundListenTarget?.type === "tcp" ? boundListenTarget.port : null, paseoHome: config.paseoHome, callerAgentId, enableVoiceTools: false, @@ -579,6 +611,9 @@ export async function createPaseoDaemon( agentManager, agentStorage, terminalManager, + serviceRouteStore, + getDaemonTcpPort: () => + boundListenTarget?.type === "tcp" ? boundListenTarget.port : null, paseoHome: config.paseoHome, callerAgentId, voiceOnly: true, @@ -639,6 +674,8 @@ export async function createPaseoDaemon( loopService, scheduleService, checkoutDiffManager, + serviceRouteStore, + () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), ); logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening"); @@ -763,6 +800,7 @@ export async function createPaseoDaemon( agentManager, agentStorage, terminalManager, + serviceRouteStore, start, stop, getListenTarget: () => boundListenTarget, diff --git a/packages/server/src/server/service-proxy.test.ts b/packages/server/src/server/service-proxy.test.ts new file mode 100644 index 000000000..a39c04eb8 --- /dev/null +++ b/packages/server/src/server/service-proxy.test.ts @@ -0,0 +1,347 @@ +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 { + return new Promise((resolve) => { + server.close(() => resolve()); + }); +} + +// --------------------------------------------------------------------------- +// ServiceRouteStore +// --------------------------------------------------------------------------- + +describe("ServiceRouteStore", () => { + it("addRoute and findRoute with exact match", () => { + const store = new ServiceRouteStore(); + store.addRoute("editor.localhost", 3000); + + 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.addRoute("editor.localhost", 3000); + + const route = store.findRoute("editor.localhost:6767"); + expect(route).toEqual({ hostname: "editor.localhost", port: 3000 }); + }); + + it("findRoute subdomain match", () => { + const store = new ServiceRouteStore(); + store.addRoute("editor.localhost", 3000); + + const route = store.findRoute("fix-auth.editor.localhost"); + expect(route).toEqual({ hostname: "editor.localhost", port: 3000 }); + }); + + it("removeRoute works", () => { + const store = new ServiceRouteStore(); + store.addRoute("editor.localhost", 3000); + store.removeRoute("editor.localhost"); + + expect(store.findRoute("editor.localhost")).toBeNull(); + }); + + it("removeRoutesForPort works", () => { + const store = new ServiceRouteStore(); + store.addRoute("a.localhost", 3000); + store.addRoute("b.localhost", 3000); + store.addRoute("c.localhost", 4000); + + 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("findRoute returns null for unknown hosts", () => { + const store = new ServiceRouteStore(); + store.addRoute("editor.localhost", 3000); + + expect(store.findRoute("unknown.example.com")).toBeNull(); + }); + + it("listRoutes returns all routes", () => { + const store = new ServiceRouteStore(); + store.addRoute("a.localhost", 3000); + store.addRoute("b.localhost", 4000); + + const routes = store.listRoutes(); + expect(routes).toHaveLength(2); + expect(routes).toContainEqual({ hostname: "a.localhost", port: 3000 }); + expect(routes).toContainEqual({ hostname: "b.localhost", port: 4000 }); + }); +}); + +// --------------------------------------------------------------------------- +// 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((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((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((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((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((resolve, reject) => { + ws.on("open", resolve); + ws.on("error", reject); + }); + + // 4. Send a message and verify echo + const reply = await new Promise((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((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((resolve) => server.close(() => resolve())); + }); +}); diff --git a/packages/server/src/server/service-proxy.ts b/packages/server/src/server/service-proxy.ts new file mode 100644 index 000000000..8839fadba --- /dev/null +++ b/packages/server/src/server/service-proxy.ts @@ -0,0 +1,262 @@ +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 class ServiceRouteStore { + private routes = new Map(); + + addRoute(hostname: string, port: number): void { + this.routes.set(hostname, port); + } + + removeRoute(hostname: string): void { + this.routes.delete(hostname); + } + + removeRoutesForPort(port: number): void { + for (const [hostname, p] of this.routes) { + if (p === port) { + this.routes.delete(hostname); + } + } + } + + findRoute(host: string): ServiceRoute | null { + // Strip port suffix from the Host header value + const hostname = host.replace(/:\d+$/, ""); + + // 1. Exact match + const exactPort = this.routes.get(hostname); + if (exactPort !== undefined) { + return { hostname, port: exactPort }; + } + + // 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 candidatePort = this.routes.get(candidate); + if (candidatePort !== undefined) { + return { hostname: candidate, port: candidatePort }; + } + } + + return null; + } + + listRoutes(): ServiceRoute[] { + return Array.from(this.routes.entries()).map(([hostname, port]) => ({ + hostname, + port, + })); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function stripHopByHopHeaders( + rawHeaders: http.IncomingHttpHeaders, +): Record { + const out: Record = {}; + 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 { + 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); + }); +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 45bb25437..05a22c5a6 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -139,6 +139,7 @@ import { type WorktreeConfig, } from "../utils/worktree.js"; import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js"; +import type { ServiceRouteStore } from "./service-proxy.js"; import { getCheckoutDiff, getCheckoutShortstat, @@ -372,6 +373,8 @@ export type SessionOptions = { stt: Resolvable; tts: Resolvable; terminalManager: TerminalManager | null; + serviceRouteStore?: ServiceRouteStore; + getDaemonTcpPort?: () => number | null; voice?: { voiceAgentMcpStdio?: VoiceMcpStdioConfig | null; turnDetection?: Resolvable; @@ -567,6 +570,8 @@ 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 subscribedTerminalDirectories = new Set(); private unsubscribeTerminalsChanged: (() => void) | null = null; private terminalExitSubscriptions: Map void> = new Map(); @@ -618,6 +623,8 @@ export class Session { stt, tts, terminalManager, + serviceRouteStore, + getDaemonTcpPort, voice, voiceBridge, dictation, @@ -642,6 +649,8 @@ export class Session { this.checkoutDiffManager = checkoutDiffManager; this.createAgentMcpTransport = createAgentMcpTransport; this.terminalManager = terminalManager; + this.serviceRouteStore = serviceRouteStore ?? null; + this.getDaemonTcpPort = getDaemonTcpPort ?? null; if (this.terminalManager) { this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => this.handleTerminalsChanged(event), @@ -2732,6 +2741,8 @@ export class Session { agentId: snapshot.id, item, }), + serviceRouteStore: this.serviceRouteStore ?? undefined, + daemonPort: this.getDaemonTcpPort?.() ?? null, logger: this.sessionLogger, }); } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index ab9d0f787..00a88d219 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -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 { @@ -241,6 +242,8 @@ 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 dictation: { finalTimeoutMs?: number; } | null; @@ -307,6 +310,8 @@ export class VoiceAssistantWebSocketServer { loopService?: LoopService, scheduleService?: ScheduleService, checkoutDiffManager?: CheckoutDiffManager, + serviceRouteStore?: ServiceRouteStore | null, + getDaemonTcpPort?: () => number | null, ) { this.logger = logger.child({ module: "websocket-server" }); this.serverId = serverId; @@ -343,6 +348,8 @@ export class VoiceAssistantWebSocketServer { this.dictation = dictation ?? null; this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; this.onLifecycleIntent = onLifecycleIntent ?? null; + this.serviceRouteStore = serviceRouteStore ?? null; + this.getDaemonTcpPort = getDaemonTcpPort ?? null; this.serverCapabilities = buildServerCapabilities({ readiness: this.speech?.getReadiness() ?? null, }); @@ -643,6 +650,8 @@ export class VoiceAssistantWebSocketServer { stt: () => this.speech?.resolveStt() ?? null, tts: () => this.speech?.resolveTts() ?? null, terminalManager: this.terminalManager, + serviceRouteStore: this.serviceRouteStore ?? undefined, + getDaemonTcpPort: this.getDaemonTcpPort ?? undefined, voice: { ...(this.voice ?? {}), turnDetection: () => this.speech?.resolveTurnDetection() ?? null, diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index afeec0c29..849007779 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -7,15 +7,18 @@ import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalSession } from "../terminal/terminal.js"; import { createWorktree, + getServiceConfigs, getWorktreeTerminalSpecs, listPaseoWorktrees, resolveWorktreeRuntimeEnv, runWorktreeSetupCommands, + slugify, WorktreeSetupError, type WorktreeConfig, type WorktreeSetupCommandResult, type WorktreeRuntimeEnv, } from "../utils/worktree.js"; +import { findFreePort, type ServiceRouteStore } from "./service-proxy.js"; import type { AgentTimelineItem } from "./agent/agent-sdk-types.js"; export interface WorktreeBootstrapTerminalResult { @@ -30,6 +33,8 @@ export interface RunAsyncWorktreeBootstrapOptions { agentId: string; worktree: WorktreeConfig; terminalManager: TerminalManager | null; + serviceRouteStore?: ServiceRouteStore; + daemonPort?: number | null; appendTimelineItem: (item: AgentTimelineItem) => Promise; emitLiveTimelineItem?: (item: AgentTimelineItem) => Promise; logger?: Logger; @@ -653,4 +658,111 @@ export async function runAsyncWorktreeBootstrap( } await runWorktreeTerminalBootstrap(options, runtimeEnv); + + if ( + !options.terminalManager || + !options.serviceRouteStore || + options.daemonPort === null || + options.daemonPort === undefined + ) { + return; + } + + try { + await spawnWorktreeServices({ + repoRoot: 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; + branchName: string | null; + daemonPort: number; + routeStore: ServiceRouteStore; + terminalManager: TerminalManager; + logger?: Logger; +}): Promise { + const { repoRoot, branchName, daemonPort, routeStore, terminalManager, logger } = options; + const serviceConfigs = getServiceConfigs(repoRoot); + if (serviceConfigs.size === 0) { + return []; + } + + const results: WorktreeServiceResult[] = []; + + for (const [serviceName, config] of serviceConfigs) { + const port = config.port ?? (await findFreePort()); + const branchHostnameLabel = branchName ? slugify(branchName) : null; + + const isDefaultBranch = + branchName === null || branchName === "main" || branchName === "master"; + const hostname = isDefaultBranch + ? `${serviceName}.localhost` + : `${branchHostnameLabel}.${serviceName}.localhost`; + + routeStore.addRoute(hostname, port); + + const env: Record = { + PORT: String(port), + HOST: "127.0.0.1", + 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, + }); + } + + 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"); + } } diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 193901553..3dcb38811 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -20,6 +20,7 @@ interface PaseoConfig { teardown?: string[]; terminals?: WorktreeTerminalConfig[]; }; + services?: Record; } const execAsync = promisify(exec); @@ -84,6 +85,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 +200,40 @@ export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConf return specs; } +export function getServiceConfigs(repoRoot: string): Map { + const config = readPaseoConfig(repoRoot); + const services = config?.services; + if (!services || typeof services !== "object") { + return new Map(); + } + + const result = new Map(); + 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; +} + async function execSetupCommand( command: string, options: { cwd: string; env: NodeJS.ProcessEnv }, From 1f6ffa9e0e056661b9745c6c60a203187e399e5d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 31 Mar 2026 09:09:38 +0700 Subject: [PATCH 2/4] Add workspace setup streaming and setup tab --- packages/app/e2e/helpers/workspace-setup.ts | 156 +++++++ packages/app/e2e/helpers/workspace.ts | 23 +- .../app/e2e/workspace-setup-streaming.spec.ts | 132 ++++++ .../workspace/[workspaceId]/_layout.tsx | 3 + .../src/components/sidebar-workspace-list.tsx | 2 +- packages/app/src/contexts/session-context.tsx | 27 +- packages/app/src/panels/register-panels.ts | 2 + packages/app/src/panels/setup-panel.tsx | 309 +++++++++++++ .../workspace/workspace-desktop-tabs-row.tsx | 3 + .../screens/workspace/workspace-screen.tsx | 6 + .../screens/workspace/workspace-tab-menu.ts | 3 + .../app/src/stores/workspace-setup-store.ts | 72 +++ .../src/stores/workspace-tabs-store.test.ts | 15 + .../app/src/stores/workspace-tabs-store.ts | 13 +- packages/app/src/utils/host-routes.test.ts | 4 + packages/app/src/utils/host-routes.ts | 10 +- .../app/src/utils/workspace-tab-identity.ts | 10 + .../server/src/client/daemon-client.test.ts | 74 ++++ packages/server/src/client/daemon-client.ts | 11 + .../src/server/agent/agent-management-mcp.ts | 17 +- .../server/src/server/agent/mcp-server.ts | 9 +- packages/server/src/server/session.ts | 16 +- .../src/server/worktree-bootstrap.test.ts | 29 +- .../server/src/server/worktree-bootstrap.ts | 181 +++++--- .../src/server/worktree-session.test.ts | 415 ++++++++++++++++++ .../server/src/server/worktree-session.ts | 191 +++++--- packages/server/src/shared/messages.ts | 48 +- .../src/shared/messages.workspaces.test.ts | 29 ++ 28 files changed, 1645 insertions(+), 165 deletions(-) create mode 100644 packages/app/e2e/helpers/workspace-setup.ts create mode 100644 packages/app/e2e/workspace-setup-streaming.spec.ts create mode 100644 packages/app/src/panels/setup-panel.tsx create mode 100644 packages/app/src/stores/workspace-setup-store.ts create mode 100644 packages/server/src/server/worktree-session.test.ts diff --git a/packages/app/e2e/helpers/workspace-setup.ts b/packages/app/e2e/helpers/workspace-setup.ts new file mode 100644 index 000000000..844e40e0b --- /dev/null +++ b/packages/app/e2e/helpers/workspace-setup.ts @@ -0,0 +1,156 @@ +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { pathToFileURL } from "node:url"; +import { expect, type Page } from "@playwright/test"; +import { gotoAppShell } from "./app"; +import type { SessionOutboundMessage } from "@server/shared/messages"; + +type WorkspaceSetupDaemonClient = { + connect(): Promise; + close(): Promise; + openProject( + cwd: string, + ): Promise<{ workspace: { id: string; name: string } | null; error: string | null }>; + createPaseoWorktree( + input: { cwd: string; worktreeSlug?: string }, + ): Promise<{ workspace: { id: string; name: string } | null; error: string | null }>; + subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void; +}; + +export type WorkspaceSetupProgressPayload = Extract< + SessionOutboundMessage, + { type: "workspace_setup_progress" } +>["payload"]; + +function getDaemonWsUrl(): string { + const daemonPort = process.env.E2E_DAEMON_PORT; + if (!daemonPort) { + throw new Error("E2E_DAEMON_PORT is not set."); + } + return `ws://127.0.0.1:${daemonPort}/ws`; +} + +async function loadDaemonClientConstructor(): Promise< + new (config: { url: string; clientId: string; clientType: "cli" }) => WorkspaceSetupDaemonClient +> { + const repoRoot = path.resolve(process.cwd(), "../.."); + const moduleUrl = pathToFileURL( + path.join(repoRoot, "packages/server/dist/server/server/exports.js"), + ).href; + const mod = (await import(moduleUrl)) as { + DaemonClient: new (config: { + url: string; + clientId: string; + clientType: "cli"; + }) => WorkspaceSetupDaemonClient; + }; + return mod.DaemonClient; +} + +export async function connectWorkspaceSetupClient(): Promise { + const DaemonClient = await loadDaemonClientConstructor(); + const client = new DaemonClient({ + url: getDaemonWsUrl(), + clientId: `workspace-setup-${randomUUID()}`, + clientType: "cli", + }); + await client.connect(); + return client; +} + +export async function seedProjectForWorkspaceSetup( + client: WorkspaceSetupDaemonClient, + repoPath: string, +): Promise { + const result = await client.openProject(repoPath); + if (!result.workspace || result.error) { + throw new Error(result.error ?? `Failed to open project ${repoPath}`); + } +} + +export function projectNameFromPath(repoPath: string): string { + return repoPath.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? repoPath; +} + +export async function openHomeWithProject(page: Page, repoPath: string): Promise { + await gotoAppShell(page); + await expect(createWorkspaceButton(page, repoPath)).toBeVisible({ timeout: 30_000 }); +} + +function createWorkspaceButton(page: Page, repoPath: string) { + return page.getByRole("button", { + name: `Create a new workspace for ${projectNameFromPath(repoPath)}`, + }); +} + +async function revealWorkspaceButton(page: Page, repoPath: string): Promise { + await page.getByTestId(`sidebar-project-row-${repoPath}`).hover(); +} + +export async function createWorkspaceFromSidebar(page: Page, repoPath: string): Promise { + await revealWorkspaceButton(page, repoPath); + await expect(createWorkspaceButton(page, repoPath)).toBeEnabled({ timeout: 30_000 }); + await createWorkspaceButton(page, repoPath).click(); + await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }); +} + +export async function expectSetupPanel(page: Page): Promise { + await expect(page.getByText("Workspace setup", { exact: true })).toBeVisible({ timeout: 30_000 }); +} + +export async function expectSetupStatus( + page: Page, + status: "Running" | "Completed" | "Failed", +): Promise { + await expect(page.getByTestId("workspace-setup-status")).toContainText(status, { + timeout: 30_000, + }); +} + +export async function expectSetupLogContains(page: Page, text: string): Promise { + await expect(page.getByTestId("workspace-setup-log")).toContainText(text, { + timeout: 30_000, + }); +} + +export async function expectNoSetupMessage(page: Page): Promise { + await expect(page.getByText("No setup commands ran for this workspace.", { exact: true })).toBeVisible({ + timeout: 30_000, + }); +} + +export async function createWorkspaceThroughDaemon( + client: WorkspaceSetupDaemonClient, + input: { cwd: string; worktreeSlug: string }, +): Promise<{ id: string; name: string }> { + const result = await client.createPaseoWorktree(input); + if (!result.workspace || result.error) { + throw new Error(result.error ?? `Failed to create workspace for ${input.cwd}`); + } + return result.workspace; +} + +export async function waitForWorkspaceSetupProgress( + client: WorkspaceSetupDaemonClient, + predicate: (payload: WorkspaceSetupProgressPayload) => boolean, + timeoutMs = 30_000, +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + unsubscribe(); + reject(new Error(`Timed out waiting for workspace_setup_progress after ${timeoutMs}ms`)); + }, timeoutMs); + + const unsubscribe = client.subscribeRawMessages((message) => { + if (message.type !== "workspace_setup_progress") { + return; + } + if (!predicate(message.payload)) { + return; + } + clearTimeout(timeout); + unsubscribe(); + resolve(message.payload); + }); + }); +} diff --git a/packages/app/e2e/helpers/workspace.ts b/packages/app/e2e/helpers/workspace.ts index d49d67a87..e5305c414 100644 --- a/packages/app/e2e/helpers/workspace.ts +++ b/packages/app/e2e/helpers/workspace.ts @@ -10,7 +10,11 @@ type TempRepo = { export const createTempGitRepo = async ( prefix = "paseo-e2e-", - options?: { withRemote?: boolean }, + options?: { + withRemote?: boolean; + paseoConfig?: Record; + files?: Array<{ path: string; content: string }>; + }, ): Promise => { // Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping. const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp"; @@ -22,7 +26,24 @@ export const createTempGitRepo = async ( execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" }); execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" }); await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n"); + if (options?.paseoConfig) { + await writeFile( + path.join(repoPath, "paseo.json"), + JSON.stringify(options.paseoConfig, null, 2), + ); + } + for (const file of options?.files ?? []) { + const filePath = path.join(repoPath, file.path); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, file.content); + } execSync("git add README.md", { cwd: repoPath, stdio: "ignore" }); + if (options?.paseoConfig) { + execSync("git add paseo.json", { cwd: repoPath, stdio: "ignore" }); + } + for (const file of options?.files ?? []) { + execSync(`git add ${JSON.stringify(file.path)}`, { cwd: repoPath, stdio: "ignore" }); + } execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" }); if (withRemote) { diff --git a/packages/app/e2e/workspace-setup-streaming.spec.ts b/packages/app/e2e/workspace-setup-streaming.spec.ts new file mode 100644 index 000000000..edd6d3979 --- /dev/null +++ b/packages/app/e2e/workspace-setup-streaming.spec.ts @@ -0,0 +1,132 @@ +import { test, expect } from "./fixtures"; +import { createTempGitRepo } from "./helpers/workspace"; +import { + connectWorkspaceSetupClient, + createWorkspaceFromSidebar, + createWorkspaceThroughDaemon, + expectSetupPanel, + openHomeWithProject, + seedProjectForWorkspaceSetup, + waitForWorkspaceSetupProgress, +} from "./helpers/workspace-setup"; + +test.describe("Workspace setup streaming", () => { + test("opens the setup tab when a workspace is created from the sidebar", async ({ page }) => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("setup-open-", { + paseoConfig: { + worktree: { + setup: ["sh -c 'echo starting setup; sleep 2; echo setup complete'"], + }, + }, + }); + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + await openHomeWithProject(page, repo.path); + await createWorkspaceFromSidebar(page, repo.path); + + await expectSetupPanel(page); + await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + + test("streams running and completed setup snapshots for a successful setup", async () => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("setup-success-", { + paseoConfig: { + worktree: { + setup: ["sh -c 'echo starting setup; sleep 2; echo setup complete'"], + }, + }, + }); + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + const running = waitForWorkspaceSetupProgress(client, (payload) => payload.status === "running"); + const completed = waitForWorkspaceSetupProgress( + client, + (payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"), + ); + + await createWorkspaceThroughDaemon(client, { + cwd: repo.path, + worktreeSlug: "workspace-setup-success", + }); + + const runningPayload = await running; + const completedPayload = await completed; + + expect(runningPayload.detail.log).toContain("starting setup"); + expect(completedPayload.detail.log).toContain("setup complete"); + expect(completedPayload.error).toBeNull(); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + + test("streams a failed setup snapshot when setup fails", async () => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("setup-failure-", { + paseoConfig: { + worktree: { + setup: ["sh -c 'echo starting setup; sleep 2; echo setup failed 1>&2; exit 1'"], + }, + }, + }); + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + const failed = waitForWorkspaceSetupProgress( + client, + (payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"), + ); + + await createWorkspaceThroughDaemon(client, { + cwd: repo.path, + worktreeSlug: "workspace-setup-failure", + }); + + const failedPayload = await failed; + expect(failedPayload.detail.log).toContain("starting setup"); + expect(failedPayload.detail.log).toContain("setup failed"); + expect(failedPayload.error).toMatch(/failed/i); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + + test("emits a completed empty snapshot when no setup commands exist", async () => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("setup-none-"); + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + const completed = waitForWorkspaceSetupProgress( + client, + (payload) => + payload.status === "completed" && + payload.detail.commands.length === 0 && + payload.detail.log === "", + ); + + await createWorkspaceThroughDaemon(client, { + cwd: repo.path, + worktreeSlug: "workspace-setup-none", + }); + + const completedPayload = await completed; + expect(completedPayload.error).toBeNull(); + expect(completedPayload.detail.commands).toEqual([]); + expect(completedPayload.detail.log).toBe(""); + } finally { + await client.close(); + await repo.cleanup(); + } + }); +}); diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx index 2afc57223..49cec89c3 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx @@ -31,6 +31,9 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge if (openIntent.kind === "file") { return { kind: "file", path: openIntent.path }; } + if (openIntent.kind === "setup") { + return { kind: "setup", workspaceId: openIntent.workspaceId }; + } return { kind: "draft", draftId: openIntent.draftId }; } diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index aced08534..c8995c8d8 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -695,7 +695,7 @@ function ProjectHeaderRow({ prepareWorkspaceTab({ serverId: serverId!, workspaceId: workspace.id, - target: { kind: "draft", draftId: "new" }, + target: { kind: "setup", workspaceId: workspace.id }, }) as any, ); }, diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 96f2993fc..b6b4e547b 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -35,6 +35,7 @@ import { normalizeWorkspaceDescriptor, } from "@/stores/session-store"; import { useDraftStore } from "@/stores/draft-store"; +import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import type { AgentDirectoryEntry } from "@/types/agent-directory"; import { sendOsNotification } from "@/utils/os-notifications"; import { getIsAppActivelyVisible } from "@/utils/app-visibility"; @@ -159,6 +160,10 @@ type WorkspaceUpdatePayload = Extract< SessionOutboundMessage, { type: "workspace_update" } >["payload"]; +type WorkspaceSetupProgressPayload = Extract< + SessionOutboundMessage, + { type: "workspace_setup_progress" } +>["payload"]; const getAgentIdFromUpdate = (update: AgentUpdatePayload): string => update.kind === "remove" ? update.agentId : update.agent.id; @@ -264,6 +269,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); const updateSessionClient = useSessionStore((state) => state.updateSessionClient); const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo); + const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress); + const removeWorkspaceSetup = useWorkspaceSetupStore((state) => state.removeWorkspace); + const clearWorkspaceSetupServer = useWorkspaceSetupStore((state) => state.clearServer); // Track focused agent for heartbeat const focusedAgentId = useSessionStore( @@ -748,6 +756,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider ], ); + const applyWorkspaceSetupProgress = useCallback( + (payload: WorkspaceSetupProgressPayload) => { + upsertWorkspaceSetupProgress({ serverId, payload }); + }, + [serverId, upsertWorkspaceSetupProgress], + ); + const requestCanonicalCatchUp = useCallback( (agentId: string, cursor: { epoch: string; endSeq: number }) => { void client @@ -1090,12 +1105,18 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const unsubWorkspaceUpdate = client.on("workspace_update", (message) => { if (message.type !== "workspace_update") return; if (message.payload.kind === "remove") { + removeWorkspaceSetup({ serverId, workspaceId: message.payload.id }); removeWorkspace(serverId, message.payload.id); return; } mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]); }); + const unsubWorkspaceSetupProgress = client.on("workspace_setup_progress", (message) => { + if (message.type !== "workspace_setup_progress") return; + applyWorkspaceSetupProgress(message.payload); + }); + const unsubStatus = client.on("status", (message) => { if (message.type !== "status") return; const serverInfo = parseServerInfoStatusPayload(message.payload); @@ -1444,6 +1465,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider unsubAgentStream(); unsubAgentTimeline(); unsubWorkspaceUpdate(); + unsubWorkspaceSetupProgress(); unsubStatus(); unsubPermissionRequest(); unsubPermissionResolved(); @@ -1471,6 +1493,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setAgents, mergeWorkspaces, removeWorkspace, + removeWorkspaceSetup, setAgentLastActivity, setPendingPermissions, setHasHydratedAgents, @@ -1478,6 +1501,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider notifyAgentAttention, requestCanonicalCatchUp, applyAgentUpdatePayload, + applyWorkspaceSetupProgress, applyTimelineResponse, voiceRuntime, voiceAudioEngine, @@ -1681,9 +1705,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider // Cleanup on unmount useEffect(() => { return () => { + clearWorkspaceSetupServer(serverId); clearSession(serverId); }; - }, [clearSession, serverId]); + }, [clearSession, clearWorkspaceSetupServer, serverId]); return children; } diff --git a/packages/app/src/panels/register-panels.ts b/packages/app/src/panels/register-panels.ts index 760671b65..dfc14f7fe 100644 --- a/packages/app/src/panels/register-panels.ts +++ b/packages/app/src/panels/register-panels.ts @@ -2,6 +2,7 @@ import { agentPanelRegistration } from "@/panels/agent-panel"; import { draftPanelRegistration } from "@/panels/draft-panel"; import { filePanelRegistration } from "@/panels/file-panel"; import { registerPanel } from "@/panels/panel-registry"; +import { setupPanelRegistration } from "@/panels/setup-panel"; import { terminalPanelRegistration } from "@/panels/terminal-panel"; let panelsRegistered = false; @@ -12,6 +13,7 @@ export function ensurePanelsRegistered(): void { } registerPanel(draftPanelRegistration); registerPanel(agentPanelRegistration); + registerPanel(setupPanelRegistration); registerPanel(terminalPanelRegistration); registerPanel(filePanelRegistration); panelsRegistered = true; diff --git a/packages/app/src/panels/setup-panel.tsx b/packages/app/src/panels/setup-panel.tsx new file mode 100644 index 000000000..3407e0727 --- /dev/null +++ b/packages/app/src/panels/setup-panel.tsx @@ -0,0 +1,309 @@ +import { CheckCircle2, CircleAlert, SquareTerminal } from "lucide-react-native"; +import { ScrollView, Text, View } from "react-native"; +import invariant from "tiny-invariant"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { Fonts } from "@/constants/theme"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; +import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; +import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; + +function useSetupPanelDescriptor( + target: { kind: "setup"; workspaceId: string }, + context: { serverId: string; workspaceId: string }, +): PanelDescriptor { + const key = buildWorkspaceTabPersistenceKey({ + serverId: context.serverId, + workspaceId: target.workspaceId, + }); + const snapshot = useWorkspaceSetupStore((state) => (key ? state.snapshots[key] ?? null : null)); + + if (snapshot?.status === "completed") { + return { + label: "Setup", + subtitle: "Setup completed", + titleState: "ready", + icon: CheckCircle2, + statusBucket: null, + }; + } + + if (snapshot?.status === "failed") { + return { + label: "Setup", + subtitle: "Setup failed", + titleState: "ready", + icon: CircleAlert, + statusBucket: null, + }; + } + + return { + label: "Setup", + subtitle: "Workspace setup", + titleState: "ready", + icon: SquareTerminal, + statusBucket: snapshot?.status === "running" ? "running" : null, + }; +} + +function formatCommandStatus(status: "running" | "completed" | "failed"): string { + if (status === "running") { + return "Running"; + } + if (status === "completed") { + return "Completed"; + } + return "Failed"; +} + +function formatSetupStatus(status: "running" | "completed" | "failed" | null): string { + if (status === "running") { + return "Running"; + } + if (status === "completed") { + return "Completed"; + } + if (status === "failed") { + return "Failed"; + } + return "Waiting for setup output"; +} + +function SetupPanel() { + const { theme } = useUnistyles(); + const { serverId, target } = usePaneContext(); + invariant(target.kind === "setup", "SetupPanel requires setup target"); + + const key = buildWorkspaceTabPersistenceKey({ + serverId, + workspaceId: target.workspaceId, + }); + const snapshot = useWorkspaceSetupStore((state) => (key ? state.snapshots[key] ?? null : null)); + + const commands = snapshot?.detail.commands ?? []; + const log = snapshot?.detail.log ?? ""; + const statusLabel = formatSetupStatus(snapshot?.status ?? null); + const hasNoSetupCommands = + snapshot?.status === "completed" && commands.length === 0 && log.trim().length === 0; + + return ( + + + Workspace setup + + + {statusLabel} + + + + + {snapshot?.error ? ( + + Setup error + + {snapshot.error} + + + ) : null} + + {commands.length > 0 ? ( + + Commands + + {commands.map((command) => ( + + {command.index}. + + + {command.command} + + + {formatCommandStatus(command.status)} + {typeof command.exitCode === "number" ? ` · exit ${command.exitCode}` : ""} + + + + ))} + + + ) : null} + + + Log + {hasNoSetupCommands ? ( + + + No setup commands ran for this workspace. + + + ) : ( + + + {log.trim().length > 0 ? log : "Waiting for setup output..."} + + + )} + + + ); +} + +export const setupPanelRegistration: PanelRegistration<"setup"> = { + kind: "setup", + component: SetupPanel, + useDescriptor: useSetupPanelDescriptor, +}; + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + minHeight: 0, + padding: theme.spacing[4], + gap: theme.spacing[4], + backgroundColor: theme.colors.surface0, + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[3], + }, + title: { + fontSize: theme.fontSize.lg, + fontWeight: "600", + color: theme.colors.foreground, + }, + statusBadge: { + borderRadius: theme.borderRadius.full, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[1], + backgroundColor: theme.colors.surface2, + }, + statusBadgeText: { + fontSize: theme.fontSize.sm, + fontWeight: "600", + color: theme.colors.foregroundMuted, + }, + errorCard: { + borderRadius: theme.borderRadius.lg, + borderWidth: 1, + borderColor: theme.colors.palette.red[200], + backgroundColor: theme.colors.palette.red[100], + padding: theme.spacing[3], + gap: theme.spacing[2], + }, + errorTitle: { + fontSize: theme.fontSize.sm, + fontWeight: "600", + color: theme.colors.palette.red[800], + }, + errorBody: { + fontSize: theme.fontSize.sm, + color: theme.colors.palette.red[800], + }, + section: { + gap: theme.spacing[2], + }, + sectionFill: { + flex: 1, + minHeight: 0, + gap: theme.spacing[2], + }, + sectionTitle: { + fontSize: theme.fontSize.sm, + fontWeight: "600", + color: theme.colors.foregroundMuted, + textTransform: "uppercase", + letterSpacing: 0.5, + }, + commandList: { + gap: theme.spacing[2], + }, + commandRow: { + flexDirection: "row", + alignItems: "flex-start", + gap: theme.spacing[2], + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface1, + padding: theme.spacing[3], + }, + commandIndex: { + width: 18, + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + commandTextColumn: { + flex: 1, + gap: theme.spacing[1], + }, + commandText: { + fontFamily: Fonts.mono, + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + }, + commandMeta: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + logContainer: { + flex: 1, + minHeight: 0, + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.surface1, + }, + logContent: { + padding: theme.spacing[3], + }, + logText: { + fontFamily: Fonts.mono, + fontSize: theme.fontSize.sm, + lineHeight: 20, + color: theme.colors.foreground, + }, + emptyCard: { + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.surface1, + padding: theme.spacing[3], + }, + emptyText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 851aab132..4a6816c11 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -79,6 +79,9 @@ function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "draft") { return "New Agent"; } + if (tab.target.kind === "setup") { + return "Setup"; + } if (tab.target.kind === "terminal") { return "Terminal"; } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 913aff9f1..523cedec1 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -139,6 +139,9 @@ function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "draft") { return "New Agent"; } + if (tab.target.kind === "setup") { + return "Setup"; + } if (tab.target.kind === "terminal") { return "Terminal"; } @@ -152,6 +155,9 @@ function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "draft") { return "New Agent"; } + if (tab.target.kind === "setup") { + return "Workspace setup"; + } if (tab.target.kind === "agent") { return "Agent"; } diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index bb4039712..b6bcec9d6 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -76,6 +76,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "draft") { return `workspace-draft-close-${tab.target.draftId}`; } + if (tab.target.kind === "setup") { + return `workspace-setup-close-${encodeFilePathForPathSegment(tab.target.workspaceId)}`; + } return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`; } diff --git a/packages/app/src/stores/workspace-setup-store.ts b/packages/app/src/stores/workspace-setup-store.ts new file mode 100644 index 000000000..ab0bb6198 --- /dev/null +++ b/packages/app/src/stores/workspace-setup-store.ts @@ -0,0 +1,72 @@ +import type { SessionOutboundMessage } from "@server/shared/messages"; +import { create } from "zustand"; +import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; + +export type WorkspaceSetupProgressPayload = Extract< + SessionOutboundMessage, + { type: "workspace_setup_progress" } +>["payload"]; + +export interface WorkspaceSetupSnapshot extends WorkspaceSetupProgressPayload { + updatedAt: number; +} + +interface WorkspaceSetupStoreState { + snapshots: Record; + upsertProgress: (input: { serverId: string; payload: WorkspaceSetupProgressPayload }) => void; + removeWorkspace: (input: { serverId: string; workspaceId: string }) => void; + clearServer: (serverId: string) => void; +} + +function buildWorkspaceSetupKey(input: { + serverId: string; + workspaceId: string; +}): string | null { + return buildWorkspaceTabPersistenceKey(input); +} + +export const useWorkspaceSetupStore = create()((set) => ({ + snapshots: {}, + upsertProgress: ({ serverId, payload }) => { + const key = buildWorkspaceSetupKey({ serverId, workspaceId: payload.workspaceId }); + if (!key) { + return; + } + + set((state) => ({ + snapshots: { + ...state.snapshots, + [key]: { + ...payload, + updatedAt: Date.now(), + }, + }, + })); + }, + removeWorkspace: ({ serverId, workspaceId }) => { + const key = buildWorkspaceSetupKey({ serverId, workspaceId }); + if (!key) { + return; + } + + set((state) => { + if (!(key in state.snapshots)) { + return state; + } + const next = { ...state.snapshots }; + delete next[key]; + return { snapshots: next }; + }); + }, + clearServer: (serverId) => { + set((state) => { + const nextEntries = Object.entries(state.snapshots).filter( + ([key]) => !key.startsWith(`${serverId}:`), + ); + if (nextEntries.length === Object.keys(state.snapshots).length) { + return state; + } + return { snapshots: Object.fromEntries(nextEntries) }; + }); + }, +})); diff --git a/packages/app/src/stores/workspace-tabs-store.test.ts b/packages/app/src/stores/workspace-tabs-store.test.ts index 1b22b31d1..98a3ad4ad 100644 --- a/packages/app/src/stores/workspace-tabs-store.test.ts +++ b/packages/app/src/stores/workspace-tabs-store.test.ts @@ -200,4 +200,19 @@ describe("workspace-tabs-store retargetTab", () => { expect(reopenedFileTabId).toBe(fileTabId); expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(fileTabId); }); + + it("builds a deterministic setup tab keyed by workspace id", () => { + const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); + expect(key).toBeTruthy(); + const workspaceKey = key as string; + + const tabId = useWorkspaceTabsStore.getState().openOrFocusTab({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "setup", workspaceId: WORKSPACE_ID }, + }); + + expect(tabId).toBe(`setup_${WORKSPACE_ID}`); + expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(tabId); + }); }); diff --git a/packages/app/src/stores/workspace-tabs-store.ts b/packages/app/src/stores/workspace-tabs-store.ts index 23ed460e0..b403f7e8e 100644 --- a/packages/app/src/stores/workspace-tabs-store.ts +++ b/packages/app/src/stores/workspace-tabs-store.ts @@ -6,7 +6,8 @@ export type WorkspaceTabTarget = | { kind: "draft"; draftId: string } | { kind: "agent"; agentId: string } | { kind: "terminal"; terminalId: string } - | { kind: "file"; path: string }; + | { kind: "file"; path: string } + | { kind: "setup"; workspaceId: string }; export type WorkspaceTab = { tabId: string; @@ -60,6 +61,10 @@ function normalizeTabTarget( const path = trimNonEmpty(value.path); return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null; } + if (value.kind === "setup") { + const workspaceId = trimNonEmpty(value.workspaceId); + return workspaceId ? { kind: "setup", workspaceId: workspaceId.replace(/\\/g, "/") } : null; + } return null; } @@ -79,6 +84,9 @@ function tabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): b if (left.kind === "file" && right.kind === "file") { return left.path === right.path; } + if (left.kind === "setup" && right.kind === "setup") { + return left.workspaceId === right.workspaceId; + } return false; } @@ -92,6 +100,9 @@ function buildDeterministicTabId(target: WorkspaceTabTarget): string { if (target.kind === "terminal") { return `terminal_${target.terminalId}`; } + if (target.kind === "setup") { + return `setup_${target.workspaceId}`; + } return `file_${target.path}`; } diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 5b34e4e95..f7328f183 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -82,6 +82,10 @@ describe("workspace route parsing", () => { kind: "file", path: "src/index.ts", }); + expect(parseWorkspaceOpenIntent("setup:L3RtcC9yZXBv")).toEqual({ + kind: "setup", + workspaceId: "/tmp/repo", + }); }); it("uses the plain workspace route when workspace context is provided", () => { diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 5afe9f059..9ea63bba8 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -99,7 +99,8 @@ export type WorkspaceOpenIntent = | { kind: "agent"; agentId: string } | { kind: "terminal"; terminalId: string } | { kind: "file"; path: string } - | { kind: "draft"; draftId: string }; + | { kind: "draft"; draftId: string } + | { kind: "setup"; workspaceId: string }; export function parseWorkspaceOpenIntent( value: string | null | undefined, @@ -136,6 +137,13 @@ export function parseWorkspaceOpenIntent( } return { kind: "file", path: decodedPath }; } + if (kind === "setup") { + const workspaceId = decodeWorkspaceIdFromPathSegment(payload); + if (!workspaceId) { + return null; + } + return { kind: "setup", workspaceId }; + } return null; } diff --git a/packages/app/src/utils/workspace-tab-identity.ts b/packages/app/src/utils/workspace-tab-identity.ts index 9ea9ffd7f..5de7dcff2 100644 --- a/packages/app/src/utils/workspace-tab-identity.ts +++ b/packages/app/src/utils/workspace-tab-identity.ts @@ -22,6 +22,10 @@ export function normalizeWorkspaceTabTarget( const path = trimNonEmpty(value.path); return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null; } + if (value.kind === "setup") { + const workspaceId = trimNonEmpty(value.workspaceId); + return workspaceId ? { kind: "setup", workspaceId: workspaceId.replace(/\\/g, "/") } : null; + } return null; } @@ -44,6 +48,9 @@ export function workspaceTabTargetsEqual( if (left.kind === "file" && right.kind === "file") { return left.path === right.path; } + if (left.kind === "setup" && right.kind === "setup") { + return left.workspaceId === right.workspaceId; + } return false; } @@ -57,6 +64,9 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st if (target.kind === "terminal") { return `terminal_${target.terminalId}`; } + if (target.kind === "setup") { + return `setup_${target.workspaceId}`; + } return `file_${target.path}`; } diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index eed79a7e0..8a01c3d83 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -213,6 +213,80 @@ 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[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", + 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", + status: "running", + exitCode: null, + }, + ], + }, + error: null, + }, + }); + }); + test("sends explicit shutdown_server_request via shutdownServer", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 13d35ab3e..b5bd5514f 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -123,6 +123,11 @@ export type DaemonEvent = workspaceId: string; payload: Extract["payload"]; } + | { + type: "workspace_setup_progress"; + workspaceId: string; + payload: Extract["payload"]; + } | { type: "agent_stream"; agentId: string; @@ -3523,6 +3528,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", diff --git a/packages/server/src/server/agent/agent-management-mcp.ts b/packages/server/src/server/agent/agent-management-mcp.ts index 36ccf9ab6..06f016692 100644 --- a/packages/server/src/server/agent/agent-management-mcp.ts +++ b/packages/server/src/server/agent/agent-management-mcp.ts @@ -299,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"; @@ -325,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({ diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index b1fb60104..56f163088 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -431,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); @@ -467,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; @@ -500,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({ diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 05a22c5a6..1ff4574bc 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -2664,7 +2664,7 @@ export class Session { ...(provisionalTitle ? { title: provisionalTitle } : {}), }; - const { sessionConfig, worktreeConfig } = await this.buildAgentSessionConfig( + const { sessionConfig, worktreeBootstrap } = await this.buildAgentSessionConfig( resolvedConfig, git, worktreeName, @@ -2724,10 +2724,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({ @@ -2909,7 +2910,10 @@ export class Session { gitOptions?: GitSetupOptions, legacyWorktreeName?: string, _labels?: Record, - ): Promise<{ sessionConfig: AgentSessionConfig; worktreeConfig?: WorktreeConfig }> { + ): Promise<{ + sessionConfig: AgentSessionConfig; + worktreeBootstrap?: { worktree: WorktreeConfig; shouldBootstrap: boolean }; + }> { return buildWorktreeAgentSessionConfig( { paseoHome: this.paseoHome, @@ -5562,8 +5566,12 @@ export class Session { paseoHome: this.paseoHome, emitWorkspaceUpdateForCwd: (cwd, emitOptions) => this.emitWorkspaceUpdateForCwd(cwd, emitOptions), + emit: (message) => this.emit(message), sessionLogger: this.sessionLogger, terminalManager: this.terminalManager, + archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), + serviceRouteStore: this.serviceRouteStore, + daemonPort: this.getDaemonTcpPort?.() ?? null, }, options, ); diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index e52fef018..bb72bd6a1 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -56,7 +56,7 @@ describe("runAsyncWorktreeBootstrap", () => { stdio: "pipe", }); - const worktree = await createAgentWorktree({ + const worktreeBootstrap = await createAgentWorktree({ cwd: repoDir, branchName: "feature-streaming-setup", baseBranch: "main", @@ -69,7 +69,8 @@ describe("runAsyncWorktreeBootstrap", () => { await runAsyncWorktreeBootstrap({ agentId: "agent-test", - worktree, + worktree: worktreeBootstrap.worktree, + shouldBootstrap: worktreeBootstrap.shouldBootstrap, terminalManager: null, appendTimelineItem: async (item) => { persisted.push(item); @@ -160,7 +161,7 @@ describe("runAsyncWorktreeBootstrap", () => { stdio: "pipe", }); - const worktree = await createAgentWorktree({ + const worktreeBootstrap = await createAgentWorktree({ cwd: repoDir, branchName: "feature-live-failure", baseBranch: "main", @@ -172,7 +173,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 +212,7 @@ describe("runAsyncWorktreeBootstrap", () => { stdio: "pipe", }); - const worktree = await createAgentWorktree({ + const worktreeBootstrap = await createAgentWorktree({ cwd: repoDir, branchName: "feature-large-output", baseBranch: "main", @@ -221,7 +223,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); @@ -266,7 +269,7 @@ describe("runAsyncWorktreeBootstrap", () => { stdio: "pipe", }); - const worktree = await createAgentWorktree({ + const worktreeBootstrap = await createAgentWorktree({ cwd: repoDir, branchName: "feature-terminal-readiness", baseBranch: "main", @@ -280,7 +283,8 @@ describe("runAsyncWorktreeBootstrap", () => { await runAsyncWorktreeBootstrap({ agentId: "agent-terminal-readiness", - worktree, + worktree: worktreeBootstrap.worktree, + shouldBootstrap: worktreeBootstrap.shouldBootstrap, terminalManager: { async getTerminals() { return []; @@ -357,7 +361,7 @@ describe("runAsyncWorktreeBootstrap", () => { stdio: "pipe", }); - const worktree = await createAgentWorktree({ + const worktreeBootstrap = await createAgentWorktree({ cwd: repoDir, branchName: "feature-shared-runtime-port", baseBranch: "main", @@ -370,7 +374,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 +421,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); diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 849007779..3cb8430fe 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -19,7 +19,7 @@ import { type WorktreeRuntimeEnv, } from "../utils/worktree.js"; import { findFreePort, type ServiceRouteStore } from "./service-proxy.js"; -import type { AgentTimelineItem } from "./agent/agent-sdk-types.js"; +import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js"; export interface WorktreeBootstrapTerminalResult { name: string | null; @@ -32,6 +32,7 @@ export interface WorktreeBootstrapTerminalResult { export interface RunAsyncWorktreeBootstrapOptions { agentId: string; worktree: WorktreeConfig; + shouldBootstrap?: boolean; terminalManager: TerminalManager | null; serviceRouteStore?: ServiceRouteStore; daemonPort?: number | null; @@ -48,6 +49,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......\n"; const WORKTREE_BOOTSTRAP_TERMINAL_READY_TIMEOUT_MS = 1_500; @@ -56,8 +62,6 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = { GIT_OPTIONAL_LOCKS: "0", }; const execAsync = promisify(exec); -const worktreeSetupEligibility = new WeakMap(); - type MiddleTruncationAccumulator = { totalBytes: number; head: string; @@ -65,6 +69,12 @@ type MiddleTruncationAccumulator = { truncated: boolean; }; +export type WorktreeSetupOutputAccumulator = MiddleTruncationAccumulator; +export type WorktreeSetupProgressAccumulator = { + resultsByIndex: Map; + outputAccumulatorsByIndex: Map; +}; + function byteLength(text: string): number { return Buffer.byteLength(text, "utf8"); } @@ -91,7 +101,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: "", @@ -108,8 +118,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) { @@ -166,16 +176,17 @@ function renderMiddleTruncationAccumulator(accumulator: MiddleTruncationAccumula export async function createAgentWorktree( options: CreateAgentWorktreeOptions, -): Promise { +): Promise { 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({ @@ -186,8 +197,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) { @@ -226,7 +239,7 @@ function commandStatusFromResult( function buildWorktreeSetupLog(input: { results: WorktreeSetupCommandResult[]; - outputAccumulatorsByIndex?: Map; + outputAccumulatorsByIndex?: Map; }): { log: string; truncated: boolean } { const { results, outputAccumulatorsByIndex } = input; if (results.length === 0) { @@ -266,14 +279,68 @@ function buildWorktreeSetupLog(input: { }; } -function buildSetupTimelineItem(input: { - callId: string; - status: "running" | "completed" | "failed"; +export function createWorktreeSetupProgressAccumulator(): WorktreeSetupProgressAccumulator { + return { + resultsByIndex: new Map(), + outputAccumulatorsByIndex: new Map(), + }; +} + +export function applyWorktreeSetupProgressEvent( + accumulator: WorktreeSetupProgressAccumulator, + event: Parameters[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; - errorMessage: string | null; -}): AgentTimelineItem { + outputAccumulatorsByIndex?: Map; +}): Extract { const commands = input.results.map((result, index) => ({ index: index + 1, command: result.command, @@ -286,14 +353,30 @@ function buildSetupTimelineItem(input: { results: input.results, outputAccumulatorsByIndex: input.outputAccumulatorsByIndex, }); - const detail = { - type: "worktree_setup" as const, + + return { + type: "worktree_setup", worktreePath: input.worktree.worktreePath, branchName: input.worktree.branchName, log: renderedLog.log, commands, ...(renderedLog.truncated ? { truncated: true } : {}), }; +} + +function buildSetupTimelineItem(input: { + callId: string; + status: "running" | "completed" | "failed"; + worktree: WorktreeConfig; + results: WorktreeSetupCommandResult[]; + outputAccumulatorsByIndex?: Map; + errorMessage: string | null; +}): AgentTimelineItem { + const detail = buildWorktreeSetupDetail({ + worktree: input.worktree, + results: input.results, + outputAccumulatorsByIndex: input.outputAccumulatorsByIndex, + }); if (input.status === "running") { return { @@ -528,7 +611,7 @@ async function runWorktreeTerminalBootstrap( export async function runAsyncWorktreeBootstrap( options: RunAsyncWorktreeBootstrapOptions, ): Promise { - if (worktreeSetupEligibility.get(options.worktree) === false) { + if (options.shouldBootstrap === false) { return; } @@ -536,17 +619,14 @@ export async function runAsyncWorktreeBootstrap( let setupResults: WorktreeSetupCommandResult[] = []; let runtimeEnv: WorktreeRuntimeEnv | null = null; const emitLiveTimelineItem = options.emitLiveTimelineItem; - const runningResultsByIndex = new Map(); - const outputAccumulatorsByIndex = new Map(); + 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( @@ -555,7 +635,7 @@ export async function runAsyncWorktreeBootstrap( status: "running", worktree: options.worktree, results: runningResults, - outputAccumulatorsByIndex, + outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex, errorMessage: null, }), ); @@ -584,42 +664,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(); }, }); @@ -631,7 +676,7 @@ export async function runAsyncWorktreeBootstrap( status: "completed", worktree: options.worktree, results: setupResults, - outputAccumulatorsByIndex, + outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex, errorMessage: null, }), ); @@ -650,7 +695,7 @@ export async function runAsyncWorktreeBootstrap( status: "failed", worktree: options.worktree, results: setupResults, - outputAccumulatorsByIndex, + outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex, errorMessage: message, }), ); diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts new file mode 100644 index 000000000..e99fd36fa --- /dev/null +++ b/packages/server/src/server/worktree-session.test.ts @@ -0,0 +1,415 @@ +import { execSync } from "node:child_process"; +import { 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 } from "./worktree-session.js"; +import { computeWorktreePath, 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; + }) => Promise; +}) { + const terminals: Array<{ + id: string; + cwd: string; + name: string | undefined; + env: Record | undefined; + sent: string[]; + }> = []; + + return { + terminals, + manager: { + registerCwdEnv: vi.fn(), + createTerminal: vi.fn(async (input: { + cwd: string; + name?: string; + env?: Record; + }) => { + 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 }) { + 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 a single completed snapshot 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 worktreePath = await computeWorktreePath(repoDir, "feature-no-setup", paseoHome); + const emitted: SessionOutboundMessage[] = []; + const routeStore = new ServiceRouteStore(); + const logger = createLogger(); + const terminalManager = createTerminalManagerStub(); + const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); + const archiveWorkspaceRecord = vi.fn(async () => {}); + + await createPaseoWorktreeInBackground( + { + paseoHome, + emitWorkspaceUpdateForCwd, + emit: (message) => emitted.push(message), + sessionLogger: logger, + terminalManager: terminalManager.manager, + archiveWorkspaceRecord, + serviceRouteStore: routeStore, + daemonPort: 6767, + }, + { + requestCwd: repoDir, + repoRoot: repoDir, + baseBranch: "main", + slug: "feature-no-setup", + worktreePath, + }, + ); + + const progressMessages = emitted.filter( + (message): message is Extract => + message.type === "workspace_setup_progress", + ); + expect(progressMessages).toHaveLength(1); + expect(progressMessages[0]?.payload).toMatchObject({ + workspaceId: worktreePath, + 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) }, + ]); + expect(terminalManager.terminals).toHaveLength(1); + expect(terminalManager.terminals[0]?.cwd).toBe(worktreePath); + 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); + + const paseoHome = path.join(tempDir, ".paseo"); + const worktreePath = await computeWorktreePath(repoDir, "broken-feature", paseoHome); + const emitted: SessionOutboundMessage[] = []; + const logger = createLogger(); + const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); + const archiveWorkspaceRecord = vi.fn(async () => {}); + + await createPaseoWorktreeInBackground( + { + paseoHome, + emitWorkspaceUpdateForCwd, + emit: (message) => emitted.push(message), + sessionLogger: logger, + terminalManager: null, + archiveWorkspaceRecord, + serviceRouteStore: null, + daemonPort: null, + }, + { + requestCwd: repoDir, + repoRoot: repoDir, + baseBranch: "does-not-exist", + slug: "broken-feature", + worktreePath, + }, + ); + + const progressMessages = emitted.filter( + (message): message is Extract => + message.type === "workspace_setup_progress", + ); + expect(progressMessages).toHaveLength(1); + expect(progressMessages[0]?.payload.status).toBe("failed"); + expect(progressMessages[0]?.payload.error).toContain("does-not-exist"); + expect(progressMessages[0]?.payload.detail.commands).toEqual([]); + 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 worktreePath = await computeWorktreePath(repoDir, "feature-running-setup", paseoHome); + const emitted: SessionOutboundMessage[] = []; + const logger = createLogger(); + const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); + const archiveWorkspaceRecord = vi.fn(async () => {}); + + await createPaseoWorktreeInBackground( + { + paseoHome, + emitWorkspaceUpdateForCwd, + emit: (message) => emitted.push(message), + sessionLogger: logger, + terminalManager: null, + archiveWorkspaceRecord, + serviceRouteStore: null, + daemonPort: null, + }, + { + requestCwd: repoDir, + repoRoot: repoDir, + baseBranch: "main", + slug: "feature-running-setup", + worktreePath, + }, + ); + + const progressMessages = emitted.filter( + (message): message is Extract => + message.type === "workspace_setup_progress", + ); + expect(progressMessages.length).toBeGreaterThan(1); + 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"), + ); + + expect(runningMessages[0]?.payload.detail.log).toContain("phase-one"); + expect(runningMessages[0]?.payload.detail.commands[0]).toMatchObject({ + index: 1, + command: 'sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"', + 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\'"', + status: "completed", + exitCode: 0, + }); + }); + + 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 worktreePath = await computeWorktreePath(repoDir, "feature-service-failure", paseoHome); + const emitted: SessionOutboundMessage[] = []; + 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, + emit: (message) => emitted.push(message), + sessionLogger: logger, + terminalManager: terminalManager.manager, + archiveWorkspaceRecord, + serviceRouteStore: routeStore, + daemonPort: 6767, + }, + { + requestCwd: repoDir, + repoRoot: repoDir, + baseBranch: "main", + slug: "feature-service-failure", + worktreePath, + }, + ); + + const progressMessages = emitted.filter( + (message): message is Extract => + message.type === "workspace_setup_progress", + ); + expect(progressMessages).toHaveLength(1); + expect(progressMessages[0]?.payload.status).toBe("completed"); + expect(progressMessages[0]?.payload.error).toBeNull(); + expect(emitted.some((message) => message.type === "workspace_setup_progress" && message.payload.status === "failed")).toBe(false); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + err: expect.any(Error), + worktreePath, + }), + "Failed to spawn worktree services after workspace setup completed", + ); + expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); + expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath); + }); + + test("reused existing worktrees do not rerun setup or spawn services", 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 routeStore = new ServiceRouteStore(); + const logger = createLogger(); + const terminalManager = createTerminalManagerStub(); + const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); + const archiveWorkspaceRecord = vi.fn(async () => {}); + + await createPaseoWorktreeInBackground( + { + paseoHome, + emitWorkspaceUpdateForCwd, + emit: (message) => emitted.push(message), + sessionLogger: logger, + terminalManager: terminalManager.manager, + archiveWorkspaceRecord, + serviceRouteStore: routeStore, + daemonPort: 6767, + }, + { + requestCwd: repoDir, + repoRoot: repoDir, + baseBranch: "main", + slug: "reused-worktree", + worktreePath: existingWorktree.worktreePath, + }, + ); + + expect( + emitted.some((message) => message.type === "workspace_setup_progress"), + ).toBe(false); + expect(routeStore.listRoutes()).toEqual([]); + expect(terminalManager.terminals).toHaveLength(0); + expect( + readFileSync(path.join(existingWorktree.worktreePath, "README.md"), "utf8"), + ).toContain("hello"); + expect(() => readFileSync(path.join(existingWorktree.worktreePath, "setup-ran.txt"), "utf8")).toThrow(); + expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); + expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(existingWorktree.worktreePath); + }); +}); diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 2076c53e9..2204a42c4 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -20,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, @@ -35,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"; @@ -105,8 +116,12 @@ type CreatePaseoWorktreeInBackgroundDependencies = { cwd: string, options?: { dedupeGitState?: boolean }, ) => Promise; + emit: EmitSessionMessage; sessionLogger: Logger; terminalManager: TerminalManager | null; + archiveWorkspaceRecord: (workspaceId: string) => Promise; + serviceRouteStore: ServiceRouteStore | null; + daemonPort: number | null; }; type HandleCreatePaseoWorktreeRequestDependencies = { @@ -143,10 +158,13 @@ export async function buildAgentSessionConfig( gitOptions?: GitSetupOptions, legacyWorktreeName?: string, _labels?: Record, -): 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 { @@ -188,8 +206,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)); @@ -207,7 +225,7 @@ export async function buildAgentSessionConfig( ...config, cwd, }, - worktreeConfig, + worktreeBootstrap, }; } @@ -631,54 +649,127 @@ export async function createPaseoWorktreeInBackground( worktreePath: string; }, ): Promise { - let setupTerminalId: string | null = null; + let worktree: WorktreeConfig = { + branchName: options.slug, + worktreePath: options.worktreePath, + }; + let setupResults: WorktreeSetupCommandResult[] = []; + let setupStarted = false; + const progressAccumulator = createWorktreeSetupProgressAccumulator(); + + const emitSetupProgress = (status: "running" | "completed" | "failed", error: string | null) => { + dependencies.emit({ + type: "workspace_setup_progress", + payload: { + workspaceId: normalizePersistedWorkspaceId(worktree.worktreePath), + status, + detail: buildWorktreeSetupDetail({ + worktree, + results: + status === "running" ? getWorktreeSetupProgressResults(progressAccumulator) : setupResults, + outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex, + }), + error, + }, + }); + }; try { - await createAgentWorktree({ - cwd: options.repoRoot, - branchName: options.slug, - baseBranch: options.baseBranch, - worktreeSlug: options.slug, - paseoHome: dependencies.paseoHome, - }); - - const setupCommands = getWorktreeSetupCommands(options.worktreePath); - if (setupCommands.length > 0 && dependencies.terminalManager) { - const runtimeEnv = await resolveWorktreeRuntimeEnv({ - worktreePath: options.worktreePath, + try { + const createdWorktree = await createAgentWorktree({ + cwd: options.repoRoot, 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; - - for (const command of setupCommands) { - terminal.send({ - type: "input", - data: `${command}\r`, - }); - } - } - } catch (error) { - dependencies.sessionLogger.error( - { - err: error, - cwd: options.requestCwd, - repoRoot: options.repoRoot, + baseBranch: options.baseBranch, worktreeSlug: options.slug, - worktreePath: options.worktreePath, - setupTerminalId, - }, - "Background worktree creation failed", - ); + paseoHome: dependencies.paseoHome, + }); + worktree = createdWorktree.worktree; + + if (!createdWorktree.shouldBootstrap) { + return; + } + + 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(options.worktreePath)); + worktree = { + ...worktree, + worktreePath: options.worktreePath, + }; + } + + dependencies.sessionLogger.error( + { + err: error, + cwd: options.requestCwd, + repoRoot: options.repoRoot, + worktreeSlug: options.slug, + worktreePath: options.worktreePath, + setupStarted, + }, + "Background worktree creation failed", + ); + return; + } + + if ( + !dependencies.terminalManager || + !dependencies.serviceRouteStore || + dependencies.daemonPort === null || + dependencies.daemonPort === undefined + ) { + return; + } + + try { + await spawnWorktreeServices({ + repoRoot: worktree.worktreePath, + branchName: worktree.branchName, + daemonPort: dependencies.daemonPort, + routeStore: dependencies.serviceRouteStore, + terminalManager: dependencies.terminalManager, + logger: dependencies.sessionLogger, + }); + } catch (error) { + dependencies.sessionLogger.warn( + { err: error, worktreePath: worktree.worktreePath }, + "Failed to spawn worktree services after workspace setup completed", + ); + } } finally { await dependencies.emitWorkspaceUpdateForCwd(options.worktreePath); } diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 84f771030..64c4b2b11 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -195,7 +195,26 @@ const NonNullUnknownSchema = z.union([ z.object({}).passthrough(), ]); +const WorktreeSetupCommandSnapshotSchema = 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(), +}); + +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 = z.discriminatedUnion("type", [ + WorktreeSetupDetailPayloadSchema, z.object({ type: z.literal("shell"), command: z.string(), @@ -254,23 +273,6 @@ const ToolCallDetailPayloadSchema: z.ZodType = 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(), @@ -1680,6 +1682,16 @@ export const WorkspaceUpdateMessageSchema = z.object({ ]), }); +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 OpenProjectResponseMessageSchema = z.object({ type: z.literal("open_project_response"), payload: z.object({ @@ -2251,6 +2263,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ArtifactMessageSchema, AgentUpdateMessageSchema, WorkspaceUpdateMessageSchema, + WorkspaceSetupProgressMessageSchema, AgentStreamMessageSchema, AgentStatusMessageSchema, FetchAgentsResponseMessageSchema, @@ -2334,6 +2347,7 @@ export type ServerInfoStatusPayload = z.infer; export type ArtifactMessage = z.infer; export type AgentUpdateMessage = z.infer; +export type WorkspaceSetupProgressMessage = z.infer; export type AgentStreamMessage = z.infer; export type AgentStatusMessage = z.infer; export type ProjectCheckoutLitePayload = z.infer; diff --git a/packages/server/src/shared/messages.workspaces.test.ts b/packages/server/src/shared/messages.workspaces.test.ts index 3f62d3a60..57741db55 100644 --- a/packages/server/src/shared/messages.workspaces.test.ts +++ b/packages/server/src/shared/messages.workspaces.test.ts @@ -50,4 +50,33 @@ describe("workspace message schemas", () => { expect(result.success).toBe(false); }); + + 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", + status: "completed", + exitCode: 0, + durationMs: 100, + }, + ], + }, + error: null, + }, + }); + + expect(parsed.type).toBe("workspace_setup_progress"); + }); }); From 806079c7add8a9421574d49e756a667fc80d5521 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 31 Mar 2026 16:22:51 +0700 Subject: [PATCH 3/4] Emit running state when workspace setup begins --- .../app/e2e/workspace-setup-streaming.spec.ts | 69 +++++- packages/server/src/server/session.ts | 5 +- .../src/server/worktree-session.test.ts | 203 +++++++++++------- .../server/src/server/worktree-session.ts | 56 +++-- 4 files changed, 220 insertions(+), 113 deletions(-) diff --git a/packages/app/e2e/workspace-setup-streaming.spec.ts b/packages/app/e2e/workspace-setup-streaming.spec.ts index edd6d3979..6e789634c 100644 --- a/packages/app/e2e/workspace-setup-streaming.spec.ts +++ b/packages/app/e2e/workspace-setup-streaming.spec.ts @@ -1,10 +1,14 @@ +import { rm, writeFile } from "node:fs/promises"; import { test, expect } from "./fixtures"; import { createTempGitRepo } from "./helpers/workspace"; +import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; import { connectWorkspaceSetupClient, createWorkspaceFromSidebar, createWorkspaceThroughDaemon, + expectSetupLogContains, expectSetupPanel, + expectSetupStatus, openHomeWithProject, seedProjectForWorkspaceSetup, waitForWorkspaceSetupProgress, @@ -34,6 +38,58 @@ test.describe("Workspace setup streaming", () => { } }); + test("runs setup through the sidebar and leaves the workspace usable", async ({ page }) => { + const setupTriggerPath = `/tmp/setup-trigger-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("setup-ui-flow-", { + paseoConfig: { + worktree: { + setup: [ + `sh -c 'while [ ! -f "${setupTriggerPath}" ]; do sleep 0.2; done; echo starting setup; sleep 1; echo loading dependencies; sleep 1; echo setup complete'`, + ], + }, + }, + files: [{ path: "src/index.ts", content: "export const ready = true;\n" }], + }); + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + await openHomeWithProject(page, repo.path); + await createWorkspaceFromSidebar(page, repo.path); + + await expectSetupPanel(page); + await expectSetupStatus(page, "Running"); + await writeFile(setupTriggerPath, "start\n"); + await expectSetupLogContains(page, "starting setup"); + await expectSetupLogContains(page, "loading dependencies"); + await expectSetupStatus(page, "Completed"); + await expectSetupLogContains(page, "setup complete"); + + await waitForWorkspaceTabsVisible(page); + await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({ + timeout: 30_000, + }); + + const explorerToggle = page.getByTestId("workspace-explorer-toggle").first(); + if ((await explorerToggle.getAttribute("aria-expanded")) !== "true") { + await explorerToggle.click(); + } + await expect(explorerToggle).toHaveAttribute("aria-expanded", "true", { timeout: 30_000 }); + await page.getByTestId("explorer-tab-files").click(); + await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText("README.md", { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByText("src", { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); + } finally { + await rm(setupTriggerPath, { force: true }); + await client.close(); + await repo.cleanup(); + } + }); + test("streams running and completed setup snapshots for a successful setup", async () => { const client = await connectWorkspaceSetupClient(); const repo = await createTempGitRepo("setup-success-", { @@ -46,7 +102,14 @@ test.describe("Workspace setup streaming", () => { try { await seedProjectForWorkspaceSetup(client, repo.path); - const running = waitForWorkspaceSetupProgress(client, (payload) => payload.status === "running"); + const initialRunning = waitForWorkspaceSetupProgress( + client, + (payload) => payload.status === "running" && payload.detail.log === "", + ); + const runningWithOutput = waitForWorkspaceSetupProgress( + client, + (payload) => payload.status === "running" && payload.detail.log.includes("starting setup"), + ); const completed = waitForWorkspaceSetupProgress( client, (payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"), @@ -57,9 +120,11 @@ test.describe("Workspace setup streaming", () => { worktreeSlug: "workspace-setup-success", }); - const runningPayload = await running; + const initialPayload = await initialRunning; + const runningPayload = await runningWithOutput; const completedPayload = await completed; + expect(initialPayload.detail.log).toBe(""); expect(runningPayload.detail.log).toContain("starting setup"); expect(completedPayload.detail.log).toContain("setup complete"); expect(completedPayload.error).toBeNull(); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 1ff4574bc..091ecfc62 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -5557,9 +5557,8 @@ 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 { return createWorktreeInBackgroundSession( { diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index e99fd36fa..e3064f93d 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -96,7 +96,7 @@ describe("createPaseoWorktreeInBackground", () => { } }); - test("emits a single completed snapshot for no-setup workspaces and then launches services", async () => { + test("emits running then completed snapshots for no-setup workspaces and then launches services", async () => { const { tempDir, repoDir } = createGitRepo({ paseoConfig: { services: { @@ -141,8 +141,20 @@ describe("createPaseoWorktreeInBackground", () => { (message): message is Extract => message.type === "workspace_setup_progress", ); - expect(progressMessages).toHaveLength(1); + 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, @@ -200,10 +212,12 @@ describe("createPaseoWorktreeInBackground", () => { (message): message is Extract => message.type === "workspace_setup_progress", ); - expect(progressMessages).toHaveLength(1); - expect(progressMessages[0]?.payload.status).toBe("failed"); - expect(progressMessages[0]?.payload.error).toContain("does-not-exist"); - expect(progressMessages[0]?.payload.detail.commands).toEqual([]); + 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("does-not-exist"); + expect(progressMessages[1]?.payload.detail.commands).toEqual([]); expect(archiveWorkspaceRecord).toHaveBeenCalledWith(worktreePath); expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath); }); @@ -250,6 +264,18 @@ describe("createPaseoWorktreeInBackground", () => { 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"); @@ -258,8 +284,11 @@ describe("createPaseoWorktreeInBackground", () => { progressMessages.findIndex((message) => message.payload.status === "completed"), ); - expect(runningMessages[0]?.payload.detail.log).toContain("phase-one"); - expect(runningMessages[0]?.payload.detail.commands[0]).toMatchObject({ + const setupOutputMessage = runningMessages.find((message) => + message.payload.detail.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\'"', status: "running", @@ -284,71 +313,7 @@ describe("createPaseoWorktreeInBackground", () => { }); }); - 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 worktreePath = await computeWorktreePath(repoDir, "feature-service-failure", paseoHome); - const emitted: SessionOutboundMessage[] = []; - 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, - emit: (message) => emitted.push(message), - sessionLogger: logger, - terminalManager: terminalManager.manager, - archiveWorkspaceRecord, - serviceRouteStore: routeStore, - daemonPort: 6767, - }, - { - requestCwd: repoDir, - repoRoot: repoDir, - baseBranch: "main", - slug: "feature-service-failure", - worktreePath, - }, - ); - - const progressMessages = emitted.filter( - (message): message is Extract => - message.type === "workspace_setup_progress", - ); - expect(progressMessages).toHaveLength(1); - expect(progressMessages[0]?.payload.status).toBe("completed"); - expect(progressMessages[0]?.payload.error).toBeNull(); - expect(emitted.some((message) => message.type === "workspace_setup_progress" && message.payload.status === "failed")).toBe(false); - expect(logger.warn).toHaveBeenCalledWith( - expect.objectContaining({ - err: expect.any(Error), - worktreePath, - }), - "Failed to spawn worktree services after workspace setup completed", - ); - expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); - expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath); - }); - - test("reused existing worktrees do not rerun setup or spawn services", async () => { + test("emits completed when reusing an existing worktree without bootstrapping", async () => { const { tempDir, repoDir } = createGitRepo({ paseoConfig: { worktree: { @@ -400,9 +365,28 @@ describe("createPaseoWorktreeInBackground", () => { }, ); - expect( - emitted.some((message) => message.type === "workspace_setup_progress"), - ).toBe(false); + const progressMessages = emitted.filter( + (message): message is Extract => + 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([]); expect(terminalManager.terminals).toHaveLength(0); expect( @@ -412,4 +396,71 @@ describe("createPaseoWorktreeInBackground", () => { 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 worktreePath = await computeWorktreePath(repoDir, "feature-service-failure", paseoHome); + const emitted: SessionOutboundMessage[] = []; + 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, + emit: (message) => emitted.push(message), + sessionLogger: logger, + terminalManager: terminalManager.manager, + archiveWorkspaceRecord, + serviceRouteStore: routeStore, + daemonPort: 6767, + }, + { + requestCwd: repoDir, + repoRoot: repoDir, + baseBranch: "main", + slug: "feature-service-failure", + worktreePath, + }, + ); + + const progressMessages = emitted.filter( + (message): message is Extract => + 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.warn).toHaveBeenCalledWith( + expect.objectContaining({ + err: expect.any(Error), + worktreePath, + }), + "Failed to spawn worktree services after workspace setup completed", + ); + expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); + expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath); + }); + }); diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 2204a42c4..221a42091 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -27,6 +27,7 @@ import { createWorktreeSetupProgressAccumulator, getWorktreeSetupProgressResults, spawnWorktreeServices, + type CreateAgentWorktreeResult, } from "./worktree-bootstrap.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { ServiceRouteStore } from "./service-proxy.js"; @@ -139,9 +140,8 @@ type HandleCreatePaseoWorktreeRequestDependencies = { createPaseoWorktreeInBackground: (options: { requestCwd: string; repoRoot: string; - baseBranch: string; - slug: string; - worktreePath: string; + worktree: WorktreeConfig; + shouldBootstrap: boolean; }) => Promise; }; @@ -598,10 +598,17 @@ export async function handleCreatePaseoWorktreeRequest( } const worktreePath = 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, }); const descriptor = await dependencies.describeWorkspaceRecord(workspace); dependencies.emit({ @@ -617,9 +624,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"; @@ -644,15 +650,11 @@ export async function createPaseoWorktreeInBackground( options: { requestCwd: string; repoRoot: string; - baseBranch: string; - slug: string; - worktreePath: string; + worktree: WorktreeConfig; + shouldBootstrap: boolean; }, ): Promise { - let worktree: WorktreeConfig = { - branchName: options.slug, - worktreePath: options.worktreePath, - }; + let worktree: WorktreeConfig = options.worktree; let setupResults: WorktreeSetupCommandResult[] = []; let setupStarted = false; const progressAccumulator = createWorktreeSetupProgressAccumulator(); @@ -676,16 +678,10 @@ export async function createPaseoWorktreeInBackground( try { try { - const createdWorktree = await createAgentWorktree({ - cwd: options.repoRoot, - branchName: options.slug, - baseBranch: options.baseBranch, - worktreeSlug: options.slug, - paseoHome: dependencies.paseoHome, - }); - worktree = createdWorktree.worktree; + emitSetupProgress("running", null); - if (!createdWorktree.shouldBootstrap) { + if (!options.shouldBootstrap) { + emitSetupProgress("completed", null); return; } @@ -725,11 +721,7 @@ export async function createPaseoWorktreeInBackground( emitSetupProgress("failed", message); if (!setupStarted) { - await dependencies.archiveWorkspaceRecord(normalizePersistedWorkspaceId(options.worktreePath)); - worktree = { - ...worktree, - worktreePath: options.worktreePath, - }; + await dependencies.archiveWorkspaceRecord(normalizePersistedWorkspaceId(worktree.worktreePath)); } dependencies.sessionLogger.error( @@ -737,8 +729,8 @@ export async function createPaseoWorktreeInBackground( err: error, cwd: options.requestCwd, repoRoot: options.repoRoot, - worktreeSlug: options.slug, - worktreePath: options.worktreePath, + worktreeSlug: worktree.branchName, + worktreePath: worktree.worktreePath, setupStarted, }, "Background worktree creation failed", @@ -771,7 +763,7 @@ export async function createPaseoWorktreeInBackground( ); } } finally { - await dependencies.emitWorkspaceUpdateForCwd(options.worktreePath); + await dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath); } } From 5b3ec572cb0035b6f1f19e1e2c526c07d72c7a9e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 1 Apr 2026 10:31:15 +0700 Subject: [PATCH 4/4] feat: service proxy, health monitor, hover card, and homepage section Built-in service proxy with branch-based URLs, service health monitoring, workspace hover card with service status, and "Forget about ports" homepage section. --- packages/app/e2e/helpers/workspace-setup.ts | 5 +- packages/app/e2e/workspace-hover-card.spec.ts | 134 +++++ .../app/e2e/workspace-setup-streaming.spec.ts | 99 +++- .../src/components/sidebar-workspace-list.tsx | 234 +++++---- .../src/components/workspace-hover-card.tsx | 490 ++++++++++++++++++ .../session-context.service-status.test.ts | 64 +++ packages/app/src/contexts/session-context.tsx | 20 + .../contexts/session-workspace-services.ts | 19 + .../hooks/use-sidebar-workspaces-list.test.ts | 41 +- .../src/hooks/use-sidebar-workspaces-list.ts | 11 +- packages/app/src/panels/setup-panel.tsx | 469 +++++++++++------ .../workspace-source-of-truth.test.ts | 1 + packages/app/src/stores/session-store.test.ts | 124 +++++ packages/app/src/stores/session-store.ts | 2 + .../utils/sidebar-project-row-model.test.ts | 18 +- .../app/src/utils/sidebar-shortcuts.test.ts | 8 +- .../app/src/utils/tool-call-display.test.ts | 1 + .../workspace-archive-navigation.test.ts | 1 + .../server/src/client/daemon-client.test.ts | 2 + packages/server/src/client/daemon-client.ts | 17 + .../src/server/agent/agent-sdk-types.ts | 1 + packages/server/src/server/bootstrap.ts | 18 +- .../src/server/service-health-monitor.test.ts | 455 ++++++++++++++++ .../src/server/service-health-monitor.ts | 203 ++++++++ .../server/src/server/service-proxy.test.ts | 167 +++++- packages/server/src/server/service-proxy.ts | 89 +++- .../server/service-status-projection.test.ts | 269 ++++++++++ .../src/server/service-status-projection.ts | 86 +++ packages/server/src/server/session.ts | 39 ++ .../server/src/server/websocket-server.ts | 14 + .../src/server/worktree-bootstrap.test.ts | 149 +++++- .../server/src/server/worktree-bootstrap.ts | 170 ++++-- .../src/server/worktree-session.test.ts | 385 +++++++++++++- .../server/src/server/worktree-session.ts | 125 +++-- packages/server/src/shared/messages.ts | 49 ++ .../src/shared/messages.workspaces.test.ts | 99 ++++ .../src/shared/tool-call-display.test.ts | 1 + packages/server/src/utils/worktree.ts | 148 ++++-- .../website/src/components/landing-page.tsx | 47 ++ 39 files changed, 3776 insertions(+), 498 deletions(-) create mode 100644 packages/app/e2e/workspace-hover-card.spec.ts create mode 100644 packages/app/src/components/workspace-hover-card.tsx create mode 100644 packages/app/src/contexts/session-context.service-status.test.ts create mode 100644 packages/app/src/contexts/session-workspace-services.ts create mode 100644 packages/app/src/stores/session-store.test.ts create mode 100644 packages/server/src/server/service-health-monitor.test.ts create mode 100644 packages/server/src/server/service-health-monitor.ts create mode 100644 packages/server/src/server/service-status-projection.test.ts create mode 100644 packages/server/src/server/service-status-projection.ts diff --git a/packages/app/e2e/helpers/workspace-setup.ts b/packages/app/e2e/helpers/workspace-setup.ts index 844e40e0b..e4e854e0d 100644 --- a/packages/app/e2e/helpers/workspace-setup.ts +++ b/packages/app/e2e/helpers/workspace-setup.ts @@ -14,6 +14,9 @@ type WorkspaceSetupDaemonClient = { createPaseoWorktree( input: { cwd: string; worktreeSlug?: string }, ): Promise<{ workspace: { id: string; name: string } | null; error: string | null }>; + listTerminals( + cwd?: string, + ): Promise<{ cwd?: string; terminals: Array<{ id: string; name: string }>; requestId: string }>; subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void; }; @@ -95,7 +98,7 @@ export async function createWorkspaceFromSidebar(page: Page, repoPath: string): } export async function expectSetupPanel(page: Page): Promise { - await expect(page.getByText("Workspace setup", { exact: true })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("workspace-setup-panel")).toBeVisible({ timeout: 30_000 }); } export async function expectSetupStatus( diff --git a/packages/app/e2e/workspace-hover-card.spec.ts b/packages/app/e2e/workspace-hover-card.spec.ts new file mode 100644 index 000000000..553b4efc8 --- /dev/null +++ b/packages/app/e2e/workspace-hover-card.spec.ts @@ -0,0 +1,134 @@ +import { test, expect } from "./fixtures"; +import { createTempGitRepo } from "./helpers/workspace"; +import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; +import { + connectWorkspaceSetupClient, + createWorkspaceFromSidebar, + expectSetupPanel, + expectSetupStatus, + openHomeWithProject, + seedProjectForWorkspaceSetup, +} from "./helpers/workspace-setup"; +import type { Page } from "@playwright/test"; + +// --------------------------------------------------------------------------- +// Composable helpers +// --------------------------------------------------------------------------- + +/** Waits for the globe icon to appear on a workspace row (proves services are running). */ +async function expectGlobeIcon(page: Page): Promise { + await expect(page.getByTestId("workspace-globe-icon")).toBeVisible({ timeout: 30_000 }); +} + +/** Hovers the workspace row (by visible name) and waits for the hover card to appear. */ +async function expectHoverCard(page: Page, workspaceName: string): Promise { + const row = page.getByRole("button", { name: workspaceName }).first(); + await row.hover(); + await expect(page.getByTestId("workspace-hover-card")).toBeVisible({ timeout: 10_000 }); +} + +/** Asserts that a service row with the given name exists in the hover card. */ +async function expectServiceInCard(page: Page, serviceName: string): Promise { + const card = page.getByTestId("workspace-hover-card"); + await expect(card.getByTestId(`hover-card-service-${serviceName}`)).toBeVisible({ + timeout: 10_000, + }); +} + +/** Asserts the service status dot indicates "running". */ +async function expectServiceRunning(page: Page, serviceName: string): Promise { + const card = page.getByTestId("workspace-hover-card"); + await expect( + card.getByTestId(`hover-card-service-status-${serviceName}`), + ).toHaveAttribute("aria-label", "Running", { timeout: 10_000 }); +} + +/** Asserts the hover card contains the workspace name. */ +async function expectWorkspaceNameInCard(page: Page, name: string): Promise { + const card = page.getByTestId("workspace-hover-card"); + await expect(card.getByTestId("hover-card-workspace-name")).toContainText(name, { + timeout: 10_000, + }); +} + +/** Moves the mouse away from the sidebar and asserts the hover card disappears. */ +async function expectHoverCardDismissed(page: Page): Promise { + // Move mouse to the center of the viewport (away from sidebar) + const viewport = page.viewportSize(); + await page.mouse.move((viewport?.width ?? 1280) / 2, (viewport?.height ?? 720) / 2); + await expect(page.getByTestId("workspace-hover-card")).not.toBeVisible({ timeout: 10_000 }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test.describe("Workspace hover card", () => { + test("shows hover card with services when hovering a workspace with running services", async ({ + page, + }) => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("hovercard-svc-", { + paseoConfig: { + worktree: { + setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"], + }, + services: { + web: { + command: + "node -e \"const http = require('http'); const s = http.createServer((q,r) => r.end('ok')); s.listen(process.env.PORT || 3000, () => console.log('listening on ' + s.address().port))\"", + }, + }, + }, + }); + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + await openHomeWithProject(page, repo.path); + await createWorkspaceFromSidebar(page, repo.path); + + // Wait for setup to complete and workspace to be usable + await expectSetupPanel(page); + await expectSetupStatus(page, "Completed"); + await waitForWorkspaceTabsVisible(page); + + // Wait for the globe icon — proves services are running and client has the data + await expectGlobeIcon(page); + + // Read the workspace name from the page header (the mnemonic name, e.g. "upbeat-crab") + const workspaceHeader = page.getByTestId("workspace-tabs-row"); + await expect(workspaceHeader).toBeVisible({ timeout: 10_000 }); + // The workspace name is the second workspace row button in the sidebar under the worktree project + // We can find it by looking for the workspace row that has the globe icon next to it + const globeIcon = page.getByTestId("workspace-globe-icon"); + const workspaceRow = page.locator('[data-testid^="sidebar-workspace-row-"]', { + has: globeIcon, + }); + const workspaceName = + (await workspaceRow.locator("button").first().innerText()).trim() || "workspace"; + + // Hover the workspace row — hover card should appear + await expectHoverCard(page, workspaceName); + + // Assert the card shows the workspace name + await expectWorkspaceNameInCard(page, workspaceName); + + // Assert the "web" service entry exists in the card + await expectServiceInCard(page, "web"); + + // Assert the status dot shows "running" + await expectServiceRunning(page, "web"); + + // Assert the service row is a link (has role="link") + const card = page.getByTestId("workspace-hover-card"); + const serviceLink = card.getByRole("link", { name: "web service" }); + await expect(serviceLink).toBeVisible({ timeout: 10_000 }); + + // Move mouse away — card should dismiss + await expectHoverCardDismissed(page); + } finally { + await client.close(); + await repo.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/workspace-setup-streaming.spec.ts b/packages/app/e2e/workspace-setup-streaming.spec.ts index 6e789634c..9ea4522bd 100644 --- a/packages/app/e2e/workspace-setup-streaming.spec.ts +++ b/packages/app/e2e/workspace-setup-streaming.spec.ts @@ -66,15 +66,18 @@ test.describe("Workspace setup streaming", () => { await expectSetupLogContains(page, "setup complete"); await waitForWorkspaceTabsVisible(page); + await page.getByTestId("workspace-new-agent-tab").first().click(); await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({ timeout: 30_000, }); const explorerToggle = page.getByTestId("workspace-explorer-toggle").first(); - if ((await explorerToggle.getAttribute("aria-expanded")) !== "true") { + if ((await explorerToggle.getAttribute("aria-label")) === "Open explorer") { await explorerToggle.click(); } - await expect(explorerToggle).toHaveAttribute("aria-expanded", "true", { timeout: 30_000 }); + await expect(explorerToggle).toHaveAttribute("aria-label", "Close explorer", { + timeout: 30_000, + }); await page.getByTestId("explorer-tab-files").click(); await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 }); await expect(page.getByText("README.md", { exact: true }).first()).toBeVisible({ @@ -194,4 +197,96 @@ test.describe("Workspace setup streaming", () => { await repo.cleanup(); } }); + + test("launches service terminals after setup completes", async ({ page }) => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("setup-svc-ui-", { + paseoConfig: { + worktree: { + setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"], + }, + services: { + web: { + command: + "node -e \"const http = require('http'); const s = http.createServer((q,r) => r.end('ok')); s.listen(process.env.PORT || 3000, () => console.log('listening on ' + s.address().port))\"", + }, + }, + }, + }); + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + await openHomeWithProject(page, repo.path); + await createWorkspaceFromSidebar(page, repo.path); + + await expectSetupPanel(page); + await expectSetupStatus(page, "Completed"); + + await waitForWorkspaceTabsVisible(page); + + // Wait for the service terminal tab to appear in the tabs bar + const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]', { + hasText: "web", + }); + await expect(terminalTab).toBeVisible({ timeout: 30_000 }); + + // Click the service terminal tab + await terminalTab.click(); + + // Verify the terminal surface rendered + await expect(page.getByTestId("terminal-surface").first()).toBeVisible({ timeout: 10_000 }); + + // Verify the terminal output contains "listening on" (xterm renders text in .xterm-rows) + await expect(page.locator(".xterm-rows").first()).toContainText("listening on", { + timeout: 30_000, + }); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + + test("launches workspace services after setup completes", async () => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("setup-services-", { + paseoConfig: { + worktree: { + setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"], + }, + services: { + editor: { + command: "npm run dev", + }, + }, + }, + }); + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + const completed = waitForWorkspaceSetupProgress( + client, + (payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"), + ); + + const workspace = await createWorkspaceThroughDaemon(client, { + cwd: repo.path, + worktreeSlug: "workspace-setup-services", + }); + + await completed; + + await expect + .poll(async () => { + const terminals = await client.listTerminals(workspace.id); + return terminals.terminals.find((terminal) => terminal.name === "editor") ?? null; + }) + .toMatchObject({ + id: expect.any(String), + name: "editor", + }); + } finally { + await client.close(); + await repo.cleanup(); + } + }); }); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index c8995c8d8..cf204af08 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -34,6 +34,7 @@ import { FolderPlus, FolderGit2, GitPullRequest, + Globe, Monitor, MoreVertical, Plus, @@ -87,6 +88,7 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session- import { createNameId } from "mnemonic-id"; import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; import { openExternalUrl } from "@/utils/open-external-url"; +import { WorkspaceHoverCard } from "@/components/workspace-hover-card"; function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { if (!icon) { @@ -855,122 +857,132 @@ function WorkspaceRowInner({ onPress(); }, [interaction.didLongPressRef, onPress]); + const isDesktop = !isMobile; + const showGlobe = isDesktop && workspace.hasRunningServices; + return ( - setIsHovered(true)} - onPointerLeave={() => setIsHovered(false)} - > - [ - styles.workspaceRow, - isDragging && styles.workspaceRowDragging, - selected && styles.sidebarRowSelected, - isHovered && styles.workspaceRowHovered, - pressed && styles.workspaceRowPressed, - ]} - onPressIn={interaction.handlePressIn} - onTouchMove={interaction.handleTouchMove} - onPressOut={interaction.handlePressOut} - onPress={handlePress} - testID={`sidebar-workspace-row-${workspace.workspaceKey}`} + + setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)} > - - - - [ + styles.workspaceRow, + isDragging && styles.workspaceRowDragging, + selected && styles.sidebarRowSelected, + isHovered && styles.workspaceRowHovered, + pressed && styles.workspaceRowPressed, + ]} + onPressIn={interaction.handlePressIn} + onTouchMove={interaction.handleTouchMove} + onPressOut={interaction.handlePressOut} + onPress={handlePress} + testID={`sidebar-workspace-row-${workspace.workspaceKey}`} + > + + - {workspace.name} - - - - {isCreating ? Creating... : null} - {onArchive && (isHovered || isMobile) ? ( - - [ - styles.kebabButton, - hovered && styles.kebabButtonHovered, - ]} - accessibilityRole="button" - accessibilityLabel="Workspace actions" - testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`} - > - {({ hovered }) => ( - - )} - - - {onCopyPath ? ( - } - onSelect={onCopyPath} - > - Copy path - - ) : null} - {onCopyBranchName ? ( - } - onSelect={onCopyBranchName} - > - Copy branch name - - ) : null} - } - trailing={archiveShortcutKeys ? : null} - status={archiveStatus} - pendingLabel={archivePendingLabel} - onSelect={onArchive} + + + {workspace.name} + + + + {showGlobe ? ( + + + + ) : null} + {isCreating ? Creating... : null} + {onArchive && (isHovered || isMobile) ? ( + + [ + styles.kebabButton, + hovered && styles.kebabButtonHovered, + ]} + accessibilityRole="button" + accessibilityLabel="Workspace actions" + testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`} > - {archiveLabel ?? "Archive"} - - - - ) : workspace.diffStat ? ( - - +{workspace.diffStat.additions} - -{workspace.diffStat.deletions} - - ) : null} - {showShortcutBadge && shortcutNumber !== null ? ( - - {shortcutNumber} - - ) : null} + {({ hovered }) => ( + + )} + + + {onCopyPath ? ( + } + onSelect={onCopyPath} + > + Copy path + + ) : null} + {onCopyBranchName ? ( + } + onSelect={onCopyBranchName} + > + Copy branch name + + ) : null} + } + trailing={archiveShortcutKeys ? : null} + status={archiveStatus} + pendingLabel={archivePendingLabel} + onSelect={onArchive} + > + {archiveLabel ?? "Archive"} + + + + ) : workspace.diffStat ? ( + + +{workspace.diffStat.additions} + -{workspace.diffStat.deletions} + + ) : null} + {showShortcutBadge && shortcutNumber !== null ? ( + + {shortcutNumber} + + ) : null} + - - {prHint ? ( - - - - ) : null} - - + {prHint ? ( + + + + ) : null} + + + ); } diff --git a/packages/app/src/components/workspace-hover-card.tsx b/packages/app/src/components/workspace-hover-card.tsx new file mode 100644 index 000000000..f66bc75c0 --- /dev/null +++ b/packages/app/src/components/workspace-hover-card.tsx @@ -0,0 +1,490 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type PropsWithChildren, + type ReactElement, +} from "react"; +import { Dimensions, Platform, Text, View } from "react-native"; +import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { ExternalLink, FolderGit2, GitPullRequest, Monitor } from "lucide-react-native"; +import { Pressable } from "react-native"; +import { Portal } from "@gorhom/portal"; +import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; +import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; +import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query"; +import { openExternalUrl } from "@/utils/open-external-url"; +import { getStatusDotColor } from "@/utils/status-dot-color"; +import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; +import { SyncedLoader } from "@/components/synced-loader"; + +interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +function measureElement(element: View): Promise { + return new Promise((resolve) => { + element.measureInWindow((x, y, width, height) => { + resolve({ x, y, width, height }); + }); + }); +} + +function computeHoverCardPosition({ + triggerRect, + contentSize, + displayArea, + offset, +}: { + triggerRect: Rect; + contentSize: { width: number; height: number }; + displayArea: Rect; + offset: number; +}): { x: number; y: number } { + let x = triggerRect.x + triggerRect.width + offset; + let y = triggerRect.y; + + // If it overflows right, try left + if (x + contentSize.width > displayArea.width - 8) { + x = triggerRect.x - contentSize.width - offset; + } + + // Constrain to screen + const padding = 8; + x = Math.max(padding, Math.min(displayArea.width - contentSize.width - padding, x)); + y = Math.max( + displayArea.y + padding, + Math.min(displayArea.y + displayArea.height - contentSize.height - padding, y), + ); + + return { x, y }; +} + +const HOVER_GRACE_MS = 100; +const HOVER_CARD_WIDTH = 260; + +interface WorkspaceHoverCardProps { + workspace: SidebarWorkspaceEntry; + isDragging: boolean; +} + +export function WorkspaceHoverCard({ + workspace, + isDragging, + children, +}: PropsWithChildren): ReactElement { + // Desktop-only: skip on non-web platforms + if (Platform.OS !== "web") { + return <>{children}; + } + + return ( + + {children} + + ); +} + +function WorkspaceHoverCardDesktop({ + workspace, + isDragging, + children, +}: PropsWithChildren): ReactElement { + const triggerRef = useRef(null); + const [open, setOpen] = useState(false); + const graceTimerRef = useRef | null>(null); + const triggerHoveredRef = useRef(false); + const contentHoveredRef = useRef(false); + + const hasServices = workspace.services.length > 0; + + const clearGraceTimer = useCallback(() => { + if (graceTimerRef.current) { + clearTimeout(graceTimerRef.current); + graceTimerRef.current = null; + } + }, []); + + const scheduleClose = useCallback(() => { + clearGraceTimer(); + graceTimerRef.current = setTimeout(() => { + if (!triggerHoveredRef.current && !contentHoveredRef.current) { + setOpen(false); + } + graceTimerRef.current = null; + }, HOVER_GRACE_MS); + }, [clearGraceTimer]); + + const handleTriggerEnter = useCallback(() => { + triggerHoveredRef.current = true; + clearGraceTimer(); + if (!isDragging && hasServices) { + setOpen(true); + } + }, [clearGraceTimer, isDragging, hasServices]); + + const handleTriggerLeave = useCallback(() => { + triggerHoveredRef.current = false; + scheduleClose(); + }, [scheduleClose]); + + const handleContentEnter = useCallback(() => { + contentHoveredRef.current = true; + clearGraceTimer(); + }, [clearGraceTimer]); + + const handleContentLeave = useCallback(() => { + contentHoveredRef.current = false; + scheduleClose(); + }, [scheduleClose]); + + // Close when drag starts + useEffect(() => { + if (isDragging) { + clearGraceTimer(); + setOpen(false); + } + }, [isDragging, clearGraceTimer]); + + // When hasServices becomes true while trigger is already hovered, open the card. + useEffect(() => { + if (!hasServices || isDragging) return; + if (triggerHoveredRef.current) { + setOpen(true); + } + }, [hasServices, isDragging]); + + // Cleanup on unmount + useEffect(() => { + return () => { + clearGraceTimer(); + }; + }, [clearGraceTimer]); + + return ( + + {children} + {open && hasServices ? ( + + ) : null} + + ); +} + +const GITHUB_PR_STATE_LABELS: Record = { + open: "Open", + merged: "Merged", + closed: "Closed", +}; + +function HoverCardStatusIndicator({ + workspace, +}: { + workspace: SidebarWorkspaceEntry; +}): ReactElement | null { + const { theme } = useUnistyles(); + const showSyncedLoader = shouldRenderSyncedStatusLoader({ bucket: workspace.statusBucket }); + + if (showSyncedLoader) { + return ; + } + + const KindIcon = + workspace.workspaceKind === "local_checkout" + ? Monitor + : workspace.workspaceKind === "worktree" + ? FolderGit2 + : null; + if (!KindIcon) return null; + + const dotColor = getStatusDotColor({ theme, bucket: workspace.statusBucket, showDoneAsInactive: false }); + + return ( + + + {dotColor ? ( + + ) : null} + + ); +} + +function WorkspaceHoverCardContent({ + workspace, + triggerRef, + onContentEnter, + onContentLeave, +}: { + workspace: SidebarWorkspaceEntry; + triggerRef: React.RefObject; + onContentEnter: () => void; + onContentLeave: () => void; +}): ReactElement | null { + const { theme } = useUnistyles(); + const bottomSheetInternal = useBottomSheetModalInternal(true); + const [triggerRect, setTriggerRect] = useState(null); + const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); + const [position, setPosition] = useState<{ x: number; y: number } | null>(null); + const prHint = useWorkspacePrHint({ + serverId: workspace.serverId, + cwd: workspace.workspaceId, + enabled: workspace.workspaceKind !== "directory", + }); + + // Measure trigger — same pattern as tooltip.tsx + useEffect(() => { + if (!triggerRef.current) return; + + let cancelled = false; + measureElement(triggerRef.current).then((rect) => { + if (cancelled) return; + setTriggerRect(rect); + }); + + return () => { + cancelled = true; + }; + }, [triggerRef]); + + // Compute position when both measurements are available + useEffect(() => { + if (!triggerRect || !contentSize) return; + const { width: screenWidth, height: screenHeight } = Dimensions.get("window"); + const displayArea = { x: 0, y: 0, width: screenWidth, height: screenHeight }; + const result = computeHoverCardPosition({ + triggerRect, + contentSize, + displayArea, + offset: 4, + }); + setPosition(result); + }, [triggerRect, contentSize]); + + const handleLayout = useCallback( + (event: { nativeEvent: { layout: { width: number; height: number } } }) => { + const { width, height } = event.nativeEvent.layout; + setContentSize({ width, height }); + }, + [], + ); + + return ( + + + + + + + {workspace.name} + + + {workspace.diffStat ? ( + + +{workspace.diffStat.additions} + -{workspace.diffStat.deletions} + + ) : null} + {prHint ? ( + void openExternalUrl(prHint.url)} + > + + + #{prHint.number} · {GITHUB_PR_STATE_LABELS[prHint.state]} + + + ) : null} + + + {workspace.services.map((service) => ( + [ + styles.serviceRow, + hovered && styles.serviceRowHovered, + ]} + onPress={() => { + if (service.url) { + void openExternalUrl(service.url); + } + }} + disabled={!service.url} + > + + + {service.serviceName} + + {service.url ? ( + + ) : null} + + ))} + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + portalOverlay: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + zIndex: 1000, + }, + card: { + backgroundColor: theme.colors.surface1, + borderWidth: 1, + borderColor: theme.colors.borderAccent, + borderRadius: theme.borderRadius.lg, + paddingVertical: theme.spacing[2], + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 8, + elevation: 8, + zIndex: 1000, + }, + cardHeader: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingBottom: theme.spacing[2], + }, + cardTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + flex: 1, + minWidth: 0, + }, + cardMetaRow: { + flexDirection: "row", + alignItems: "center", + gap: 6, + paddingHorizontal: theme.spacing[3], + paddingBottom: theme.spacing[2], + }, + diffStatAdditions: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.palette.green[400], + }, + diffStatDeletions: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.palette.red[500], + }, + prBadgeText: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + hoverStatusIcon: { + width: 14, + height: 14, + alignItems: "center", + justifyContent: "center", + position: "relative", + }, + hoverStatusDotOverlay: { + position: "absolute", + bottom: -1, + right: -1, + width: 6, + height: 6, + borderRadius: 3, + borderWidth: 1, + }, + separator: { + height: 1, + backgroundColor: theme.colors.border, + }, + serviceList: { + paddingTop: theme.spacing[1], + }, + serviceRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + minHeight: 32, + }, + serviceRowHovered: { + backgroundColor: theme.colors.surface2, + }, + statusDot: { + width: 8, + height: 8, + borderRadius: 4, + flexShrink: 0, + }, + serviceName: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + flex: 1, + minWidth: 0, + }, +})); diff --git a/packages/app/src/contexts/session-context.service-status.test.ts b/packages/app/src/contexts/session-context.service-status.test.ts new file mode 100644 index 000000000..617a0f024 --- /dev/null +++ b/packages/app/src/contexts/session-context.service-status.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import type { WorkspaceServicePayload } from "@server/shared/messages"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { patchWorkspaceServices } from "./session-workspace-services"; + +function workspace(input: { + id: string; + services?: WorkspaceDescriptor["services"]; +}): WorkspaceDescriptor { + return { + id: input.id, + projectId: "project-1", + projectDisplayName: "Project 1", + projectRootPath: "/repo", + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "running", + activityAt: null, + diffStat: null, + services: input.services ?? [], + }; +} + +const runningService: WorkspaceServicePayload = { + serviceName: "web", + hostname: "main.web.localhost", + port: 3000, + url: "http://main.web.localhost:6767", + status: "running", +}; + +describe("patchWorkspaceServices", () => { + it("patches only the matching workspace services", () => { + const other = workspace({ id: "/repo/other", services: [] }); + const current = new Map([ + ["/repo/main", workspace({ id: "/repo/main", services: [] })], + [other.id, other], + ]); + + const next = patchWorkspaceServices(current, { + workspaceId: "/repo/main", + services: [runningService], + }); + + expect(next).not.toBe(current); + expect(next.get("/repo/main")?.services).toEqual([runningService]); + expect(next.get("/repo/other")).toBe(other); + }); + + it("ignores updates for unknown workspaces", () => { + const current = new Map([ + ["/repo/main", workspace({ id: "/repo/main", services: [] })], + ]); + + const next = patchWorkspaceServices(current, { + workspaceId: "/repo/missing", + services: [runningService], + }); + + expect(next).toBe(current); + expect(next.get("/repo/main")?.services).toEqual([]); + }); +}); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index b6b4e547b..faa31895e 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -51,6 +51,7 @@ import { resolveProjectPlacement } from "@/utils/project-placement"; import { buildDraftStoreKey } from "@/stores/draft-keys"; import type { AttachmentMetadata } from "@/attachments/types"; import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking"; +import { patchWorkspaceServices } from "@/contexts/session-workspace-services"; // Re-export types from session-store and draft-store for backward compatibility export type { DraftInput } from "@/stores/draft-store"; @@ -1112,11 +1113,27 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]); }); + const unsubServiceStatusUpdate = client.on("service_status_update", (message) => { + if (message.type !== "service_status_update") return; + setWorkspaces(serverId, (prev) => patchWorkspaceServices(prev, message.payload)); + }); + const unsubWorkspaceSetupProgress = client.on("workspace_setup_progress", (message) => { if (message.type !== "workspace_setup_progress") return; applyWorkspaceSetupProgress(message.payload); }); + const unsubWorkspaceSetupStatusResponse = client.on( + "workspace_setup_status_response", + (message) => { + if (message.type !== "workspace_setup_status_response") return; + const { workspaceId, snapshot } = message.payload; + if (snapshot) { + applyWorkspaceSetupProgress({ workspaceId, ...snapshot }); + } + }, + ); + const unsubStatus = client.on("status", (message) => { if (message.type !== "status") return; const serverInfo = parseServerInfoStatusPayload(message.payload); @@ -1465,7 +1482,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider unsubAgentStream(); unsubAgentTimeline(); unsubWorkspaceUpdate(); + unsubServiceStatusUpdate(); unsubWorkspaceSetupProgress(); + unsubWorkspaceSetupStatusResponse(); unsubStatus(); unsubPermissionRequest(); unsubPermissionResolved(); @@ -1491,6 +1510,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setAgentTimelineCursor, setInitializingAgents, setAgents, + setWorkspaces, mergeWorkspaces, removeWorkspace, removeWorkspaceSetup, diff --git a/packages/app/src/contexts/session-workspace-services.ts b/packages/app/src/contexts/session-workspace-services.ts new file mode 100644 index 000000000..3ba1885f5 --- /dev/null +++ b/packages/app/src/contexts/session-workspace-services.ts @@ -0,0 +1,19 @@ +import type { ServiceStatusUpdateMessage } from "@server/shared/messages"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; + +export function patchWorkspaceServices( + workspaces: Map, + update: ServiceStatusUpdateMessage["payload"], +): Map { + const existing = workspaces.get(update.workspaceId); + if (!existing) { + return workspaces; + } + + const next = new Map(workspaces); + next.set(update.workspaceId, { + ...existing, + services: update.services.map((s) => ({ ...s })), + }); + return next; +} diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts index 88f4925c6..652de0683 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import type { WorkspaceServicePayload } from "@server/shared/messages"; import { appendMissingOrderKeys, applyStoredOrdering, @@ -19,7 +20,7 @@ function workspace( Partial< Pick< WorkspaceDescriptor, - "projectDisplayName" | "projectRootPath" | "projectKind" | "workspaceKind" + "projectDisplayName" | "projectRootPath" | "projectKind" | "workspaceKind" | "services" > >, ): WorkspaceDescriptor { @@ -34,9 +35,26 @@ function workspace( status: input.status, activityAt: input.activityAt, diffStat: null, + services: input.services ?? [], }; } +const runningService: WorkspaceServicePayload = { + serviceName: "web", + hostname: "main.web.localhost", + port: 3000, + url: "http://main.web.localhost:6767", + status: "running", +}; + +const stoppedService: WorkspaceServicePayload = { + serviceName: "api", + hostname: "main.api.localhost", + port: 3001, + url: "http://main.api.localhost:6767", + status: "stopped", +}; + describe("applyStoredOrdering", () => { it("keeps unknown items on the baseline while applying stored order", () => { const result = applyStoredOrdering({ @@ -117,6 +135,27 @@ describe("buildSidebarProjectsFromWorkspaces", () => { expect(projects[0]?.workspaces[0]?.statusBucket).toBe("failed"); }); + it("threads services into workspace rows and derives hasRunningServices", () => { + const projects = buildSidebarProjectsFromWorkspaces({ + serverId: "srv", + workspaces: [ + workspace({ + id: "/repo/main", + projectId: "project-1", + name: "main", + status: "running", + activityAt: new Date("2026-01-01T00:00:00.000Z"), + services: [runningService, stoppedService], + }), + ], + projectOrder: [], + workspaceOrderByScope: {}, + }); + + expect(projects[0]?.workspaces[0]?.services).toEqual([runningService, stoppedService]); + expect(projects[0]?.workspaces[0]?.hasRunningServices).toBe(true); + }); + it("preserves stored project order even when activity changes", () => { const initialWorkspaces: WorkspaceDescriptor[] = [ workspace({ diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 205deb6a4..7b5cccd20 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; +import type { WorkspaceDescriptorPayload } from "@server/shared/messages"; import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; @@ -20,6 +21,8 @@ export interface SidebarWorkspaceEntry { activityAt: Date | null; statusBucket: SidebarStateBucket; diffStat: { additions: number; deletions: number } | null; + services: WorkspaceDescriptor["services"]; + hasRunningServices: boolean; } export interface SidebarProjectEntry { @@ -135,6 +138,8 @@ export function buildSidebarProjectsFromWorkspaces(input: { activityAt: workspace.activityAt, statusBucket: workspace.status, diffStat: workspace.diffStat, + services: workspace.services, + hasRunningServices: workspace.services.some((service) => service.status === "running"), }; project.workspaces.push(row); @@ -257,8 +262,12 @@ function toWorkspaceDescriptor(payload: { name: string; status: WorkspaceDescriptor["status"]; activityAt: string | null; + services?: WorkspaceDescriptorPayload["services"]; }): WorkspaceDescriptor { - return normalizeWorkspaceDescriptor(payload); + return normalizeWorkspaceDescriptor({ + ...payload, + services: payload.services ?? [], + }); } export function useSidebarWorkspacesList(options?: { diff --git a/packages/app/src/panels/setup-panel.tsx b/packages/app/src/panels/setup-panel.tsx index 3407e0727..41ed6b143 100644 --- a/packages/app/src/panels/setup-panel.tsx +++ b/packages/app/src/panels/setup-panel.tsx @@ -1,5 +1,6 @@ -import { CheckCircle2, CircleAlert, SquareTerminal } from "lucide-react-native"; -import { ScrollView, Text, View } from "react-native"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native"; +import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; import invariant from "tiny-invariant"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; @@ -7,6 +8,7 @@ import { usePaneContext } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; function useSetupPanelDescriptor( target: { kind: "setup"; workspaceId: string }, @@ -47,27 +49,43 @@ function useSetupPanelDescriptor( }; } -function formatCommandStatus(status: "running" | "completed" | "failed"): string { +type CommandStatus = "running" | "completed" | "failed"; + +function CommandStatusIcon({ status }: { status: CommandStatus }) { + const { theme } = useUnistyles(); + if (status === "running") { - return "Running"; + return ; } if (status === "completed") { - return "Completed"; + return ; } - return "Failed"; + return ; } -function formatSetupStatus(status: "running" | "completed" | "failed" | null): string { - if (status === "running") { - return "Running"; - } - if (status === "completed") { - return "Completed"; - } - if (status === "failed") { - return "Failed"; - } - return "Waiting for setup output"; +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; +} + +/** + * Process carriage returns in log text so progress-bar output renders cleanly. + * Splits on \r, keeps only the last segment per CR-delimited group (unless followed by \n). + */ +function processCarriageReturns(text: string): string { + if (!text.includes("\r")) return text; + return text + .split("\n") + .map((line) => { + if (!line.includes("\r")) return line; + const segments = line.split("\r"); + return segments[segments.length - 1]; + }) + .join("\n"); } function SetupPanel() { @@ -75,113 +93,233 @@ function SetupPanel() { const { serverId, target } = usePaneContext(); invariant(target.kind === "setup", "SetupPanel requires setup target"); + const client = useHostRuntimeClient(serverId); const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId: target.workspaceId, }); const snapshot = useWorkspaceSetupStore((state) => (key ? state.snapshots[key] ?? null : null)); + const upsertProgress = useWorkspaceSetupStore((state) => state.upsertProgress); + + // On mount, if no snapshot in the store, request cached status from server + const requestedRef = useRef(false); + useEffect(() => { + if (snapshot || requestedRef.current || !client) return; + requestedRef.current = true; + client + .fetchWorkspaceSetupStatus(target.workspaceId) + .then((response) => { + if (response.snapshot) { + upsertProgress({ + serverId, + payload: { workspaceId: response.workspaceId, ...response.snapshot }, + }); + } + }) + .catch(() => { + // Server may not support this yet — ignore + }); + }, [client, snapshot, serverId, target.workspaceId, upsertProgress]); const commands = snapshot?.detail.commands ?? []; const log = snapshot?.detail.log ?? ""; - const statusLabel = formatSetupStatus(snapshot?.status ?? null); const hasNoSetupCommands = snapshot?.status === "completed" && commands.length === 0 && log.trim().length === 0; + const isWaiting = !snapshot || (snapshot.status === "running" && commands.length === 0); + + const [expandedIndices, setExpandedIndices] = useState>(new Set()); + const [manuallyCollapsed, setManuallyCollapsed] = useState>(new Set()); + + const toggleExpanded = useCallback((index: number, isAutoExpanded: boolean) => { + setExpandedIndices((prev) => { + const next = new Set(prev); + if (next.has(index) || isAutoExpanded) { + next.delete(index); + // If this was auto-expanded, record that the user manually collapsed it + if (isAutoExpanded) { + setManuallyCollapsed((mc) => new Set(mc).add(index)); + } + } else { + next.add(index); + // If the user re-expands, remove from manually collapsed + setManuallyCollapsed((mc) => { + const next = new Set(mc); + next.delete(index); + return next; + }); + } + return next; + }); + }, []); + + // Determine which command should auto-expand (running or last completed). + const autoExpandIndex = (() => { + const running = commands.find((c) => c.status === "running"); + if (running) return running.index; + if (commands.length > 0) return commands[commands.length - 1].index; + return null; + })(); + + const statusLabel = snapshot?.status === "running" + ? "Running" + : snapshot?.status === "completed" + ? "Completed" + : snapshot?.status === "failed" + ? "Failed" + : "Waiting for setup output"; return ( - - + {/* Hidden element for status — preserves testID for E2E */} + - Workspace setup - + >{statusLabel} + + {isWaiting ? ( + + + Setting up workspace... + + ) : hasNoSetupCommands ? ( + - {statusLabel} + No setup commands ran for this workspace. - + ) : ( + + {commands.map((command) => { + const isExpanded = expandedIndices.has(command.index); + const hasError = command.status === "failed" && snapshot?.error; - {snapshot?.error ? ( - - Setup error - - {snapshot.error} - - - ) : null} + // Per-command log: use command.log if available, fall back to detail.log for the auto-expand target + const commandLog = (() => { + if ("log" in command && typeof command.log === "string") { + return command.log; + } + // Fallback: show detail.log on the auto-expand target command + if (command.index === autoExpandIndex) return log; + return ""; + })(); + const hasLog = commandLog.trim().length > 0; - {commands.length > 0 ? ( - - Commands - - {commands.map((command) => ( - - {command.index}. - - + // All non-running commands are expandable (completed/failed) + const isExpandable = command.status !== "running" || hasLog || !!hasError; + + // Auto-expand the active command unless the user manually collapsed it + const isAutoExpanded = + command.index === autoExpandIndex && !manuallyCollapsed.has(command.index); + const showDetail = isExpanded || isAutoExpanded; + + const processedLog = hasLog ? processCarriageReturns(commandLog) : ""; + + return ( + + toggleExpanded(command.index, isAutoExpanded)} + style={({ pressed }) => [ + styles.commandRow, + showDetail && styles.commandRowExpanded, + pressed && styles.commandRowPressed, + ]} + accessibilityRole="button" + accessibilityState={{ expanded: showDetail }} + > + + + + {command.command} - - {formatCommandStatus(command.status)} - {typeof command.exitCode === "number" ? ` · exit ${command.exitCode}` : ""} - - + {command.durationMs != null ? ( + + {formatDuration(command.durationMs)} + + ) : null} + + + {showDetail ? ( + + {hasLog ? ( + + + {processedLog} + + + ) : ( + + No output + + )} + {hasError ? ( + + + {snapshot.error} + + + ) : null} + + ) : null} - ))} - - - ) : null} + ); + })} - - Log - {hasNoSetupCommands ? ( - - 0 ? ( + - No setup commands ran for this workspace. - - - ) : ( - - - {log.trim().length > 0 ? log : "Waiting for setup output..."} - - - )} - - + + {log} + + + ) : null} + + {/* Show error at top level if no commands failed but there's a setup error */} + {snapshot?.error && !commands.some((c) => c.status === "failed") ? ( + + + {snapshot.error} + + + ) : null} + + )} + ); } @@ -195,100 +333,93 @@ const styles = StyleSheet.create((theme) => ({ container: { flex: 1, minHeight: 0, - padding: theme.spacing[4], - gap: theme.spacing[4], backgroundColor: theme.colors.surface0, }, - header: { - flexDirection: "row", + contentContainer: { + padding: theme.spacing[4], + flexGrow: 1, + }, + hiddenStatus: { + position: "absolute", + width: 1, + height: 1, + overflow: "hidden", + opacity: 0, + }, + waitingContainer: { + flex: 1, alignItems: "center", - justifyContent: "space-between", + justifyContent: "center", gap: theme.spacing[3], }, - title: { - fontSize: theme.fontSize.lg, - fontWeight: "600", - color: theme.colors.foreground, - }, - statusBadge: { - borderRadius: theme.borderRadius.full, - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[1], - backgroundColor: theme.colors.surface2, - }, - statusBadgeText: { + waitingText: { fontSize: theme.fontSize.sm, - fontWeight: "600", color: theme.colors.foregroundMuted, }, - errorCard: { - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: theme.colors.palette.red[200], - backgroundColor: theme.colors.palette.red[100], - padding: theme.spacing[3], - gap: theme.spacing[2], - }, - errorTitle: { - fontSize: theme.fontSize.sm, - fontWeight: "600", - color: theme.colors.palette.red[800], - }, - errorBody: { - fontSize: theme.fontSize.sm, - color: theme.colors.palette.red[800], - }, - section: { - gap: theme.spacing[2], - }, - sectionFill: { + emptyContainer: { flex: 1, - minHeight: 0, - gap: theme.spacing[2], + alignItems: "center", + justifyContent: "center", }, - sectionTitle: { + emptyText: { fontSize: theme.fontSize.sm, - fontWeight: "600", color: theme.colors.foregroundMuted, - textTransform: "uppercase", - letterSpacing: 0.5, }, commandList: { gap: theme.spacing[2], }, + commandItem: { + borderRadius: theme.borderRadius.lg, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + overflow: "hidden", + }, commandRow: { flexDirection: "row", - alignItems: "flex-start", + alignItems: "center", gap: theme.spacing[2], - borderRadius: theme.borderRadius.md, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], backgroundColor: theme.colors.surface1, - padding: theme.spacing[3], }, - commandIndex: { + commandRowExpanded: { + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: theme.colors.border, + }, + commandRowPressed: { + opacity: 0.8, + }, + commandStatusIcon: { width: 18, - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - }, - commandTextColumn: { - flex: 1, - gap: theme.spacing[1], + height: 18, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, }, commandText: { + flex: 1, fontFamily: Fonts.mono, fontSize: theme.fontSize.sm, color: theme.colors.foreground, }, - commandMeta: { + commandDuration: { fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, + flexShrink: 0, }, - logContainer: { - flex: 1, - minHeight: 0, - borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.surface1, + chevron: { + flexShrink: 0, }, - logContent: { + chevronExpanded: { + transform: [{ rotate: "90deg" }], + }, + commandDetail: { + backgroundColor: theme.colors.surface0, + }, + logScroll: { + maxHeight: 400, + }, + logScrollContent: { padding: theme.spacing[3], }, logText: { @@ -297,13 +428,17 @@ const styles = StyleSheet.create((theme) => ({ lineHeight: 20, color: theme.colors.foreground, }, - emptyCard: { - borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.surface1, - padding: theme.spacing[3], - }, - emptyText: { + emptyLogText: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, + fontStyle: "italic", + }, + errorCard: { + padding: theme.spacing[3], + backgroundColor: theme.colors.palette.red[100], + }, + errorText: { + fontSize: theme.fontSize.sm, + color: theme.colors.palette.red[800], }, })); diff --git a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts index 84562c96d..9dc09bfbc 100644 --- a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts +++ b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts @@ -19,6 +19,7 @@ describe("workspace source of truth consumption", () => { status: "running", activityAt: new Date("2026-03-01T00:00:00.000Z"), diffStat: null, + services: [], }; const header = resolveWorkspaceHeader({ workspace }); diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts new file mode 100644 index 000000000..3a6ae4955 --- /dev/null +++ b/packages/app/src/stores/session-store.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { DaemonClient } from "@server/client/daemon-client"; +import type { WorkspaceDescriptorPayload } from "@server/shared/messages"; +import { + normalizeWorkspaceDescriptor, + useSessionStore, + type WorkspaceDescriptor, +} from "./session-store"; + +function workspace( + input: Partial & Pick, +): WorkspaceDescriptor { + return { + id: input.id, + projectId: input.projectId ?? "project-1", + projectDisplayName: input.projectDisplayName ?? "Project 1", + projectRootPath: input.projectRootPath ?? "/repo", + projectKind: input.projectKind ?? "git", + workspaceKind: input.workspaceKind ?? "local_checkout", + name: input.name ?? "main", + status: input.status ?? "done", + activityAt: input.activityAt ?? null, + diffStat: input.diffStat ?? null, + services: input.services ?? [], + }; +} + +afterEach(() => { + useSessionStore.getState().clearSession("test-server"); +}); + +describe("normalizeWorkspaceDescriptor", () => { + it("normalizes workspace services and invalid activity timestamps", () => { + const services = [ + { + serviceName: "web", + hostname: "main.web.localhost", + port: 3000, + url: "http://main.web.localhost:6767", + status: "running" as const, + }, + ]; + const workspace = normalizeWorkspaceDescriptor({ + id: "/repo/main", + projectId: "project-1", + projectDisplayName: "Project 1", + projectRootPath: "/repo", + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "running", + activityAt: "not-a-date", + diffStat: null, + services, + }); + + expect(workspace.activityAt).toBeNull(); + expect(workspace.services).toEqual([ + { + serviceName: "web", + hostname: "main.web.localhost", + port: 3000, + url: "http://main.web.localhost:6767", + status: "running", + }, + ]); + expect(workspace.services).not.toBe(services); + }); + + it("defaults missing services to an empty array", () => { + const payload = { + id: "/repo/main", + projectId: "project-1", + projectDisplayName: "Project 1", + projectRootPath: "/repo", + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "done", + activityAt: null, + diffStat: null, + } as WorkspaceDescriptorPayload; + + const workspace = normalizeWorkspaceDescriptor(payload); + + expect(workspace.services).toEqual([]); + }); +}); + +describe("mergeWorkspaces", () => { + it("preserves services on merged workspace entries", () => { + const store = useSessionStore.getState(); + store.initializeSession("test-server", null as unknown as DaemonClient); + store.setWorkspaces( + "test-server", + new Map([["/repo/main", workspace({ id: "/repo/main", services: [] })]]), + ); + + store.mergeWorkspaces("test-server", [ + workspace({ + id: "/repo/main", + services: [ + { + serviceName: "web", + hostname: "main.web.localhost", + port: 3000, + url: "http://main.web.localhost:6767", + status: "running", + }, + ], + }), + ]); + + expect(store.getSession("test-server")?.workspaces.get("/repo/main")?.services).toEqual([ + { + serviceName: "web", + hostname: "main.web.localhost", + port: 3000, + url: "http://main.web.localhost:6767", + status: "running", + }, + ]); + }); +}); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 0646658cb..a08277688 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -119,6 +119,7 @@ export interface WorkspaceDescriptor { status: WorkspaceDescriptorPayload["status"]; activityAt: Date | null; diffStat: { additions: number; deletions: number } | null; + services: WorkspaceDescriptorPayload["services"]; } export function normalizeWorkspaceDescriptor( @@ -136,6 +137,7 @@ export function normalizeWorkspaceDescriptor( status: payload.status, activityAt: activityAt && !Number.isNaN(activityAt.getTime()) ? activityAt : null, diffStat: payload.diffStat ?? null, + services: (payload.services ?? []).map((s) => ({ ...s })), }; } diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index 04c8a3f10..9b0871c40 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -18,6 +18,8 @@ function workspace(overrides: Partial = {}): SidebarWorks activityAt: null, statusBucket: "done", diffStat: null, + services: [], + hasRunningServices: false, ...overrides, }; } @@ -87,8 +89,8 @@ describe("buildSidebarProjectRowModel", () => { }); }); - it("flattens git projects with a single workspace and keeps the new worktree action", () => { - const flattenedWorkspace = workspace({ + it("keeps single-workspace git projects as sections with a new worktree action", () => { + const workspaceEntry = workspace({ workspaceId: "/repo/main", workspaceKind: "local_checkout", }); @@ -96,16 +98,14 @@ describe("buildSidebarProjectRowModel", () => { const result = buildSidebarProjectRowModel({ project: project({ projectKind: "git", - workspaces: [flattenedWorkspace], + workspaces: [workspaceEntry], }), collapsed: true, }); expect(result).toEqual({ - kind: "workspace_link", - workspace: flattenedWorkspace, - selected: false, - chevron: null, + kind: "project_section", + chevron: "expand", trailingAction: "new_worktree", }); }); @@ -131,10 +131,10 @@ describe("buildSidebarProjectRowModel", () => { }); describe("isSidebarProjectFlattened", () => { - it("returns true for single-workspace projects regardless of kind", () => { + it("returns true only for single-workspace non-git projects", () => { expect( isSidebarProjectFlattened(project({ projectKind: "git", workspaces: [workspace()] })), - ).toBe(true); + ).toBe(false); expect( isSidebarProjectFlattened(project({ projectKind: "non_git", workspaces: [workspace()] })), ).toBe(true); diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index 113927177..db1041773 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -16,6 +16,8 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry { activityAt: null, statusBucket: "done", diffStat: null, + services: [], + hasRunningServices: false, }; } @@ -76,7 +78,7 @@ describe("buildSidebarShortcutModel", () => { expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "/repo/w9" }); }); - it("ignores collapsed state for flattened single-workspace projects", () => { + it("still hides collapsed single-workspace git projects because they are not flattened", () => { const projects = [project("p1", [workspace("s1", "/repo/main")])]; const model = buildSidebarShortcutModel({ @@ -84,7 +86,7 @@ describe("buildSidebarShortcutModel", () => { collapsedProjectKeys: new Set(["p1"]), }); - expect(model.visibleTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]); - expect(model.shortcutTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]); + expect(model.visibleTargets).toEqual([]); + expect(model.shortcutTargets).toEqual([]); }); }); diff --git a/packages/app/src/utils/tool-call-display.test.ts b/packages/app/src/utils/tool-call-display.test.ts index 3854da586..572cc75f9 100644 --- a/packages/app/src/utils/tool-call-display.test.ts +++ b/packages/app/src/utils/tool-call-display.test.ts @@ -96,6 +96,7 @@ describe("tool-call-display", () => { index: 1, command: "npm install", cwd: "/tmp/repo/.paseo/worktrees/repo/branch", + log: "", status: "running", exitCode: null, }, diff --git a/packages/app/src/utils/workspace-archive-navigation.test.ts b/packages/app/src/utils/workspace-archive-navigation.test.ts index eeaba5bca..d5aecc092 100644 --- a/packages/app/src/utils/workspace-archive-navigation.test.ts +++ b/packages/app/src/utils/workspace-archive-navigation.test.ts @@ -19,6 +19,7 @@ function workspace( status: input.status ?? "done", activityAt: input.activityAt ?? null, diffStat: input.diffStat ?? null, + services: input.services ?? [], }; } diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index 8a01c3d83..777c11a14 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -251,6 +251,7 @@ describe("DaemonClient", () => { index: 1, command: "npm install", cwd: "/tmp/project/.paseo/worktrees/feature-a", + log: "phase-one\n", status: "running", exitCode: null, }, @@ -277,6 +278,7 @@ describe("DaemonClient", () => { index: 1, command: "npm install", cwd: "/tmp/project/.paseo/worktrees/feature-a", + log: "phase-one\n", status: "running", exitCode: null, }, diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index b5bd5514f..240c11381 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -36,6 +36,7 @@ import type { ProjectIconResponse, OpenProjectResponseMessage, ArchiveWorkspaceResponseMessage, + WorkspaceSetupStatusResponseMessage, ListCommandsResponse, ListProviderModelsResponseMessage, ListAvailableProvidersResponse, @@ -462,6 +463,7 @@ export type InspectScheduleOptions = { }; type OpenProjectPayload = OpenProjectResponseMessage["payload"]; type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"]; +type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"]; export type FetchAgentResult = { agent: AgentSnapshotPayload; @@ -1318,6 +1320,21 @@ export class DaemonClient { }); } + async fetchWorkspaceSetupStatus( + workspaceId: string, + requestId?: string, + ): Promise { + 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 { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index a4440aa81..891ecc814 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -178,6 +178,7 @@ export type ToolCallDetail = index: number; command: string; cwd: string; + log: string; status: "running" | "completed" | "failed"; exitCode: number | null; durationMs?: number; diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 42db80269..af593f8a8 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -118,6 +118,8 @@ import { createServiceProxyMiddleware, createServiceProxyUpgradeHandler, } from "./service-proxy.js"; +import { ServiceHealthMonitor } from "./service-health-monitor.js"; +import { createServiceStatusEmitter } from "./service-status-projection.js"; import { createVoiceMcpSocketBridgeManager, type VoiceMcpSocketBridgeManager, @@ -225,6 +227,18 @@ export async function createPaseoDaemon( 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. @@ -450,7 +464,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) @@ -676,6 +689,7 @@ export async function createPaseoDaemon( checkoutDiffManager, serviceRouteStore, () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), + (hostname) => serviceHealthMonitor.getStatusForHostname(hostname), ); logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening"); @@ -766,9 +780,11 @@ 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 () => { + serviceHealthMonitor.stop(); await closeAllAgents(logger, agentManager); await agentManager.flush().catch(() => undefined); detachAgentStoragePersistence(); diff --git a/packages/server/src/server/service-health-monitor.test.ts b/packages/server/src/server/service-health-monitor.test.ts new file mode 100644 index 000000000..5c2579b43 --- /dev/null +++ b/packages/server/src/server/service-health-monitor.test.ts @@ -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 { + const server = net.createServer((socket) => { + socket.end(); + }); + + await new Promise((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 { + if (!server.listening) { + return; + } + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +async function advancePoll(ms: number): Promise { + await vi.advanceTimersByTimeAsync(ms); + for (let i = 0; i < 5; i += 1) { + await scheduler.yield(); + } +} + +describe("ServiceHealthMonitor", () => { + const servers = new Set(); + + 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", + }, + ]); + }); +}); diff --git a/packages/server/src/server/service-health-monitor.ts b/packages/server/src/server/service-health-monitor.ts new file mode 100644 index 000000000..376d014ca --- /dev/null +++ b/packages/server/src/server/service-health-monitor.ts @@ -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(); + private readonly lastEmittedSnapshots = new Map(); + + 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 { + 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(); + 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): 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 { + 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)); + }); + } +} diff --git a/packages/server/src/server/service-proxy.test.ts b/packages/server/src/server/service-proxy.test.ts index a39c04eb8..3a10ca745 100644 --- a/packages/server/src/server/service-proxy.test.ts +++ b/packages/server/src/server/service-proxy.test.ts @@ -28,9 +28,14 @@ function closeServer(server: http.Server): Promise { // --------------------------------------------------------------------------- describe("ServiceRouteStore", () => { - it("addRoute and findRoute with exact match", () => { + it("registerRoute and findRoute with exact match", () => { const store = new ServiceRouteStore(); - store.addRoute("editor.localhost", 3000); + 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 }); @@ -38,7 +43,12 @@ describe("ServiceRouteStore", () => { it("findRoute strips port from host header", () => { const store = new ServiceRouteStore(); - store.addRoute("editor.localhost", 3000); + 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 }); @@ -46,25 +56,132 @@ describe("ServiceRouteStore", () => { it("findRoute subdomain match", () => { const store = new ServiceRouteStore(); - store.addRoute("editor.localhost", 3000); + 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.addRoute("editor.localhost", 3000); + 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.addRoute("a.localhost", 3000); - store.addRoute("b.localhost", 3000); - store.addRoute("c.localhost", 4000); + 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); @@ -76,22 +193,36 @@ describe("ServiceRouteStore", () => { }); }); - it("findRoute returns null for unknown hosts", () => { + it("removeRoutesForPort cleans up workspace index", () => { const store = new ServiceRouteStore(); - store.addRoute("editor.localhost", 3000); + 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", + }); - expect(store.findRoute("unknown.example.com")).toBeNull(); + store.removeRoutesForPort(3000); + + expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]); }); - it("listRoutes returns all routes", () => { + it("findRoute returns null for unknown hosts", () => { const store = new ServiceRouteStore(); - store.addRoute("a.localhost", 3000); - store.addRoute("b.localhost", 4000); + store.registerRoute({ + hostname: "editor.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + serviceName: "editor", + }); - const routes = store.listRoutes(); - expect(routes).toHaveLength(2); - expect(routes).toContainEqual({ hostname: "a.localhost", port: 3000 }); - expect(routes).toContainEqual({ hostname: "b.localhost", port: 4000 }); + expect(store.findRoute("unknown.example.com")).toBeNull(); }); }); diff --git a/packages/server/src/server/service-proxy.ts b/packages/server/src/server/service-proxy.ts index 8839fadba..fc7a23d0c 100644 --- a/packages/server/src/server/service-proxy.ts +++ b/packages/server/src/server/service-proxy.ts @@ -29,21 +29,49 @@ export interface ServiceRoute { port: number; } +export interface ServiceRouteEntry extends ServiceRoute { + workspaceId: string; + serviceName: string; +} + export class ServiceRouteStore { - private routes = new Map(); + private routes = new Map(); + private workspaceHostnames = new Map>(); addRoute(hostname: string, port: number): void { - this.routes.set(hostname, port); + 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, p] of this.routes) { - if (p === port) { + for (const [hostname, entry] of this.routes) { + if (entry.port === port) { this.routes.delete(hostname); + this.removeHostnameFromWorkspaceIndex(entry.workspaceId, hostname); } } } @@ -53,29 +81,60 @@ export class ServiceRouteStore { const hostname = host.replace(/:\d+$/, ""); // 1. Exact match - const exactPort = this.routes.get(hostname); - if (exactPort !== undefined) { - return { hostname, port: exactPort }; + 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 candidatePort = this.routes.get(candidate); - if (candidatePort !== undefined) { - return { hostname: candidate, port: candidatePort }; + const candidateRoute = this.routes.get(candidate); + if (candidateRoute !== undefined) { + return { hostname: candidateRoute.hostname, port: candidateRoute.port }; } } return null; } - listRoutes(): ServiceRoute[] { - return Array.from(this.routes.entries()).map(([hostname, port]) => ({ - hostname, - port, - })); + 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(); + 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); + } } } diff --git a/packages/server/src/server/service-status-projection.test.ts b/packages/server/src/server/service-status-projection.test.ts new file mode 100644 index 000000000..2074d0e13 --- /dev/null +++ b/packages/server/src/server/service-status-projection.test.ts @@ -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([ + ["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", + }, + ], + }, + }); + }); + +}); diff --git a/packages/server/src/server/service-status-projection.ts b/packages/server/src/server/service-status-projection.ts new file mode 100644 index 000000000..892ffa5ff --- /dev/null +++ b/packages/server/src/server/service-status-projection.ts @@ -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); + } + }; +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 091ecfc62..d60f48fc9 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -28,6 +28,7 @@ import { type UnsubscribeCheckoutDiffRequest, type DirectorySuggestionsRequest, type ProjectPlacementPayload, + type WorkspaceSetupSnapshot, type WorkspaceDescriptorPayload, type WorkspaceStateBucket, } from "./messages.js"; @@ -62,6 +63,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; import { buildProviderRegistry } from "./agent/provider-registry.js"; @@ -180,6 +182,7 @@ import { handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest, handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest, handlePaseoWorktreeListRequest as handleWorktreeListRequest, + handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage, killTerminalsUnderPath as killWorktreeTerminalsUnderPath, registerPendingWorktreeWorkspace as registerPendingWorktreeWorkspaceSession, } from "./worktree-session.js"; @@ -375,6 +378,7 @@ export type SessionOptions = { terminalManager: TerminalManager | null; serviceRouteStore?: ServiceRouteStore; getDaemonTcpPort?: () => number | null; + resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null; voice?: { voiceAgentMcpStdio?: VoiceMcpStdioConfig | null; turnDetection?: Resolvable; @@ -572,6 +576,7 @@ export class Session { 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(); private unsubscribeTerminalsChanged: (() => void) | null = null; private terminalExitSubscriptions: Map void> = new Map(); @@ -582,6 +587,7 @@ export class Session { private peakInflightRequests = 0; private readonly checkoutDiffSubscriptions = new Map void>(); private readonly workspaceGitWatchTargets = new Map(); + private readonly workspaceSetupSnapshots = new Map(); private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null; private readonly registerVoiceSpeakHandler?: ( agentId: string, @@ -625,6 +631,7 @@ export class Session { terminalManager, serviceRouteStore, getDaemonTcpPort, + resolveServiceStatus, voice, voiceBridge, dictation, @@ -651,6 +658,7 @@ export class Session { 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), @@ -716,6 +724,10 @@ export class Session { }; } + public emitServerMessage(message: SessionOutboundMessage): void { + this.emit(message); + } + /** * Send initial state to client after connection */ @@ -1617,6 +1629,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; @@ -4918,6 +4934,14 @@ export class Session { status: "done", activityAt: null, diffStat, + services: this.serviceRouteStore + ? buildWorkspaceServicePayloads( + this.serviceRouteStore, + workspace.workspaceId, + this.getDaemonTcpPort?.() ?? null, + this.resolveServiceStatus ?? undefined, + ) + : [], }; } @@ -5565,6 +5589,9 @@ export class Session { 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, @@ -5576,6 +5603,18 @@ export class Session { ); } + private async handleWorkspaceSetupStatusRequest( + request: Extract, + ): Promise { + return handleWorkspaceSetupStatusRequestMessage( + { + emit: (message) => this.emit(message), + workspaceSetupSnapshots: this.workspaceSetupSnapshots, + }, + request, + ); + } + private async handleArchiveWorkspaceRequest( request: Extract, ): Promise { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 00a88d219..49fdc3a6e 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -244,6 +244,7 @@ export class VoiceAssistantWebSocketServer { 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; @@ -312,6 +313,7 @@ export class VoiceAssistantWebSocketServer { checkoutDiffManager?: CheckoutDiffManager, serviceRouteStore?: ServiceRouteStore | null, getDaemonTcpPort?: () => number | null, + resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null, ) { this.logger = logger.child({ module: "websocket-server" }); this.serverId = serverId; @@ -350,6 +352,7 @@ export class VoiceAssistantWebSocketServer { this.onLifecycleIntent = onLifecycleIntent ?? null; this.serviceRouteStore = serviceRouteStore ?? null; this.getDaemonTcpPort = getDaemonTcpPort ?? null; + this.resolveServiceStatus = resolveServiceStatus ?? null; this.serverCapabilities = buildServerCapabilities({ readiness: this.speech?.getReadiness() ?? null, }); @@ -420,6 +423,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 })); } @@ -652,6 +665,7 @@ export class VoiceAssistantWebSocketServer { terminalManager: this.terminalManager, serviceRouteStore: this.serviceRouteStore ?? undefined, getDaemonTcpPort: this.getDaemonTcpPort ?? undefined, + resolveServiceStatus: this.resolveServiceStatus ?? undefined, voice: { ...(this.voice ?? {}), turnDetection: () => this.speech?.resolveTurnDetection() ?? null, diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index bb72bd6a1..e6b20a5f9 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -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; @@ -114,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, }); @@ -247,6 +255,64 @@ describe("runAsyncWorktreeBootstrap", () => { expect(persistedSetupItem.detail.log).toContain("prefix-"); expect(persistedSetupItem.detail.log).toContain("-suffix"); expect(persistedSetupItem.detail.log).toContain("......"); + expect(persistedSetupItem.detail.commands[0]?.log).toContain("prefix-"); + expect(persistedSetupItem.detail.commands[0]?.log).toContain("-suffix"); + expect(persistedSetupItem.detail.commands[0]?.log).toContain( + "......", + ); + }); + + 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 => + 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 () => { @@ -440,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 }> = []; + + 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(); + }); }); diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 3cb8430fe..aeebbff03 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -10,6 +10,7 @@ import { getServiceConfigs, getWorktreeTerminalSpecs, listPaseoWorktrees, + processCarriageReturns, resolveWorktreeRuntimeEnv, runWorktreeSetupCommands, slugify, @@ -254,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; @@ -279,6 +278,26 @@ function buildWorktreeSetupLog(input: { }; } +function buildWorktreeSetupCommandLog(input: { + index: number; + result: WorktreeSetupCommandResult; + outputAccumulatorsByIndex?: Map; +}): { 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(), @@ -341,14 +360,26 @@ export function buildWorktreeSetupDetail(input: { results: WorktreeSetupCommandResult[]; outputAccumulatorsByIndex?: Map; }): Extract { - 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 } : {}), - })); + 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, @@ -360,7 +391,7 @@ export function buildWorktreeSetupDetail(input: { branchName: input.worktree.branchName, log: renderedLog.log, commands, - ...(renderedLog.truncated ? { truncated: true } : {}), + ...(renderedLog.truncated || anyCommandTruncated ? { truncated: true } : {}), }; } @@ -704,18 +735,14 @@ export async function runAsyncWorktreeBootstrap( await runWorktreeTerminalBootstrap(options, runtimeEnv); - if ( - !options.terminalManager || - !options.serviceRouteStore || - options.daemonPort === null || - options.daemonPort === undefined - ) { + 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, @@ -743,13 +770,15 @@ export interface WorktreeServiceResult { export async function spawnWorktreeServices(options: { repoRoot: string; + workspaceId: string; branchName: string | null; - daemonPort: number; + daemonPort?: number | null; routeStore: ServiceRouteStore; terminalManager: TerminalManager; logger?: Logger; }): Promise { - const { repoRoot, branchName, daemonPort, routeStore, terminalManager, logger } = options; + const { repoRoot, workspaceId, branchName, daemonPort, routeStore, terminalManager, logger } = + options; const serviceConfigs = getServiceConfigs(repoRoot); if (serviceConfigs.size === 0) { return []; @@ -758,43 +787,72 @@ export async function spawnWorktreeServices(options: { const results: WorktreeServiceResult[] = []; for (const [serviceName, config] of serviceConfigs) { - const port = config.port ?? (await findFreePort()); - const branchHostnameLabel = branchName ? slugify(branchName) : null; + let hostname: string | null = null; + let port: number | null = null; - const isDefaultBranch = - branchName === null || branchName === "main" || branchName === "master"; - const hostname = isDefaultBranch - ? `${serviceName}.localhost` - : `${branchHostnameLabel}.${serviceName}.localhost`; + try { + port = config.port ?? (await findFreePort()); + const branchHostnameLabel = branchName ? slugify(branchName) : null; - routeStore.addRoute(hostname, port); + const isDefaultBranch = + branchName === null || branchName === "main" || branchName === "master"; + hostname = isDefaultBranch + ? `${serviceName}.localhost` + : `${branchHostnameLabel}.${serviceName}.localhost`; - const env: Record = { - PORT: String(port), - HOST: "127.0.0.1", - PASEO_SERVICE_URL: `http://${hostname}:${daemonPort}`, - }; + routeStore.registerRoute({ + hostname, + port, + workspaceId, + serviceName, + }); - const terminal = await terminalManager.createTerminal({ - cwd: repoRoot, - name: serviceName, - env, - }); + const env: Record = { + PORT: String(port), + HOST: "127.0.0.1", + }; + if (daemonPort !== null && daemonPort !== undefined) { + env.PASEO_SERVICE_URL = `http://${hostname}:${daemonPort}`; + } - await waitForTerminalBootstrapReadiness(terminal); - terminal.send({ type: "input", data: `${config.command}\r` }); + const terminal = await terminalManager.createTerminal({ + cwd: repoRoot, + name: serviceName, + env, + }); - logger?.info( - { serviceName, hostname, port, terminalId: terminal.id }, - `Registered service proxy: ${hostname} -> 127.0.0.1:${port}`, - ); + await waitForTerminalBootstrapReadiness(terminal); + terminal.send({ type: "input", data: `${config.command}\r` }); - results.push({ - serviceName, - hostname, - port, - terminalId: terminal.id, - }); + 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; diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index e3064f93d..54a7d695c 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -1,13 +1,17 @@ import { execSync } from "node:child_process"; -import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +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 } from "./worktree-session.js"; -import { computeWorktreePath, createWorktree } from "../utils/worktree.js"; +import { + createPaseoWorktreeInBackground, + handleCreatePaseoWorktreeRequest, + handleWorkspaceSetupStatusRequest, +} from "./worktree-session.js"; +import { createWorktree } from "../utils/worktree.js"; function createLogger() { return { @@ -109,8 +113,17 @@ describe("createPaseoWorktreeInBackground", () => { cleanupPaths.push(tempDir); const paseoHome = path.join(tempDir, ".paseo"); - const worktreePath = await computeWorktreePath(repoDir, "feature-no-setup", paseoHome); + 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(); const routeStore = new ServiceRouteStore(); const logger = createLogger(); const terminalManager = createTerminalManagerStub(); @@ -121,6 +134,7 @@ describe("createPaseoWorktreeInBackground", () => { { paseoHome, emitWorkspaceUpdateForCwd, + cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: terminalManager.manager, @@ -131,9 +145,11 @@ describe("createPaseoWorktreeInBackground", () => { { requestCwd: repoDir, repoRoot: repoDir, - baseBranch: "main", - slug: "feature-no-setup", - worktreePath, + worktree: { + branchName: "feature-no-setup", + worktreePath, + }, + shouldBootstrap: true, }, ); @@ -166,12 +182,30 @@ describe("createPaseoWorktreeInBackground", () => { 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) }, + { + 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); @@ -181,9 +215,25 @@ describe("createPaseoWorktreeInBackground", () => { 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 worktreePath = await computeWorktreePath(repoDir, "broken-feature", paseoHome); + 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(); const logger = createLogger(); const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); const archiveWorkspaceRecord = vi.fn(async () => {}); @@ -192,6 +242,7 @@ describe("createPaseoWorktreeInBackground", () => { { paseoHome, emitWorkspaceUpdateForCwd, + cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: null, @@ -202,9 +253,11 @@ describe("createPaseoWorktreeInBackground", () => { { requestCwd: repoDir, repoRoot: repoDir, - baseBranch: "does-not-exist", - slug: "broken-feature", - worktreePath, + worktree: { + branchName: "broken-feature", + worktreePath, + }, + shouldBootstrap: true, }, ); @@ -216,8 +269,12 @@ describe("createPaseoWorktreeInBackground", () => { 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("does-not-exist"); + 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); }); @@ -233,8 +290,17 @@ describe("createPaseoWorktreeInBackground", () => { cleanupPaths.push(tempDir); const paseoHome = path.join(tempDir, ".paseo"); - const worktreePath = await computeWorktreePath(repoDir, "feature-running-setup", paseoHome); + 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(); const logger = createLogger(); const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); const archiveWorkspaceRecord = vi.fn(async () => {}); @@ -243,6 +309,7 @@ describe("createPaseoWorktreeInBackground", () => { { paseoHome, emitWorkspaceUpdateForCwd, + cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: null, @@ -253,9 +320,11 @@ describe("createPaseoWorktreeInBackground", () => { { requestCwd: repoDir, repoRoot: repoDir, - baseBranch: "main", - slug: "feature-running-setup", - worktreePath, + worktree: { + branchName: "feature-running-setup", + worktreePath, + }, + shouldBootstrap: true, }, ); @@ -285,12 +354,13 @@ describe("createPaseoWorktreeInBackground", () => { ); const setupOutputMessage = runningMessages.find((message) => - message.payload.detail.log.includes("phase-one"), + 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", }); @@ -308,9 +378,14 @@ describe("createPaseoWorktreeInBackground", () => { 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 () => { @@ -339,6 +414,7 @@ describe("createPaseoWorktreeInBackground", () => { }); const emitted: SessionOutboundMessage[] = []; + const snapshots = new Map(); const routeStore = new ServiceRouteStore(); const logger = createLogger(); const terminalManager = createTerminalManagerStub(); @@ -349,6 +425,7 @@ describe("createPaseoWorktreeInBackground", () => { { paseoHome, emitWorkspaceUpdateForCwd, + cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: terminalManager.manager, @@ -359,9 +436,11 @@ describe("createPaseoWorktreeInBackground", () => { { requestCwd: repoDir, repoRoot: repoDir, - baseBranch: "main", - slug: "reused-worktree", - worktreePath: existingWorktree.worktreePath, + worktree: { + branchName: "reused-worktree", + worktreePath: existingWorktree.worktreePath, + }, + shouldBootstrap: false, }, ); @@ -387,12 +466,28 @@ describe("createPaseoWorktreeInBackground", () => { commands: [], }, }); - expect(routeStore.listRoutes()).toEqual([]); - expect(terminalManager.terminals).toHaveLength(0); + 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); }); @@ -410,8 +505,17 @@ describe("createPaseoWorktreeInBackground", () => { cleanupPaths.push(tempDir); const paseoHome = path.join(tempDir, ".paseo"); - const worktreePath = await computeWorktreePath(repoDir, "feature-service-failure", paseoHome); + 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(); const routeStore = new ServiceRouteStore(); const logger = createLogger(); const terminalManager = createTerminalManagerStub({ @@ -426,6 +530,7 @@ describe("createPaseoWorktreeInBackground", () => { { paseoHome, emitWorkspaceUpdateForCwd, + cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot), emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: terminalManager.manager, @@ -436,9 +541,11 @@ describe("createPaseoWorktreeInBackground", () => { { requestCwd: repoDir, repoRoot: repoDir, - baseBranch: "main", - slug: "feature-service-failure", - worktreePath, + worktree: { + branchName: "feature-service-failure", + worktreePath, + }, + shouldBootstrap: true, }, ); @@ -452,15 +559,237 @@ describe("createPaseoWorktreeInBackground", () => { 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.warn).toHaveBeenCalledWith( + 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(); + 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 => + 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 }); + } + }); }); diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 221a42091..8ecae9311 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -11,6 +11,7 @@ import { type ProjectPlacementPayload, type SessionInboundMessage, type SessionOutboundMessage, + type WorkspaceSetupSnapshot, type WorkspaceDescriptorPayload, } from "./messages.js"; import type { @@ -27,7 +28,6 @@ import { createWorktreeSetupProgressAccumulator, getWorktreeSetupProgressResults, spawnWorktreeServices, - type CreateAgentWorktreeResult, } from "./worktree-bootstrap.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { ServiceRouteStore } from "./service-proxy.js"; @@ -117,12 +117,18 @@ type CreatePaseoWorktreeInBackgroundDependencies = { cwd: string, options?: { dedupeGitState?: boolean }, ) => Promise; + cacheWorkspaceSetupSnapshot: (workspaceId: string, snapshot: WorkspaceSetupSnapshot) => void; emit: EmitSessionMessage; sessionLogger: Logger; terminalManager: TerminalManager | null; archiveWorkspaceRecord: (workspaceId: string) => Promise; serviceRouteStore: ServiceRouteStore | null; - daemonPort: number | null; + daemonPort?: number | null; +}; + +type HandleWorkspaceSetupStatusRequestDependencies = { + emit: EmitSessionMessage; + workspaceSetupSnapshots: ReadonlyMap; }; type HandleCreatePaseoWorktreeRequestDependencies = { @@ -597,7 +603,7 @@ 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, @@ -645,6 +651,21 @@ export async function handleCreatePaseoWorktreeRequest( } } +export async function handleWorkspaceSetupStatusRequest( + dependencies: HandleWorkspaceSetupStatusRequestDependencies, + request: Extract, +): Promise { + 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: { @@ -658,20 +679,25 @@ export async function createPaseoWorktreeInBackground( 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: normalizePersistedWorkspaceId(worktree.worktreePath), - status, - detail: buildWorktreeSetupDetail({ - worktree, - results: - status === "running" ? getWorktreeSetupProgressResults(progressAccumulator) : setupResults, - outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex, - }), - error, + workspaceId, + ...snapshot, }, }); }; @@ -682,36 +708,35 @@ export async function createPaseoWorktreeInBackground( if (!options.shouldBootstrap) { emitSetupProgress("completed", null); - return; - } - - 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); + 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) { @@ -738,18 +763,14 @@ export async function createPaseoWorktreeInBackground( return; } - if ( - !dependencies.terminalManager || - !dependencies.serviceRouteStore || - dependencies.daemonPort === null || - dependencies.daemonPort === undefined - ) { + if (!dependencies.terminalManager || !dependencies.serviceRouteStore) { return; } try { await spawnWorktreeServices({ repoRoot: worktree.worktreePath, + workspaceId: worktree.worktreePath, branchName: worktree.branchName, daemonPort: dependencies.daemonPort, routeStore: dependencies.serviceRouteStore, @@ -757,8 +778,14 @@ export async function createPaseoWorktreeInBackground( logger: dependencies.sessionLogger, }); } catch (error) { - dependencies.sessionLogger.warn( - { err: error, worktreePath: worktree.worktreePath }, + 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", ); } diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 64c4b2b11..0dcc09fe8 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -199,6 +199,7 @@ 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(), @@ -985,6 +986,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(), @@ -1228,6 +1235,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ PaseoWorktreeListRequestSchema, PaseoWorktreeArchiveRequestSchema, CreatePaseoWorktreeRequestSchema, + WorkspaceSetupStatusRequestSchema, OpenProjectRequestSchema, ArchiveWorkspaceRequestSchema, FileExplorerRequestSchema, @@ -1573,6 +1581,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.string(), projectId: z.string(), @@ -1590,6 +1606,7 @@ export const WorkspaceDescriptorPayloadSchema = z.object({ }) .nullable() .optional(), + services: z.array(WorkspaceServicePayloadSchema).default([]), }); export const AgentUpdateMessageSchema = z.object({ @@ -1682,6 +1699,14 @@ 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({ @@ -1692,6 +1717,21 @@ export const WorkspaceSetupProgressMessageSchema = z.object({ }), }); +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({ @@ -2263,7 +2303,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ArtifactMessageSchema, AgentUpdateMessageSchema, WorkspaceUpdateMessageSchema, + ServiceStatusUpdateMessageSchema, WorkspaceSetupProgressMessageSchema, + WorkspaceSetupStatusResponseMessageSchema, AgentStreamMessageSchema, AgentStatusMessageSchema, FetchAgentsResponseMessageSchema, @@ -2348,14 +2390,20 @@ export type RpcErrorMessage = z.infer; export type ArtifactMessage = z.infer; export type AgentUpdateMessage = z.infer; export type WorkspaceSetupProgressMessage = z.infer; +export type WorkspaceSetupSnapshot = z.infer; +export type WorkspaceSetupStatusResponseMessage = z.infer< + typeof WorkspaceSetupStatusResponseMessageSchema +>; export type AgentStreamMessage = z.infer; export type AgentStatusMessage = z.infer; export type ProjectCheckoutLitePayload = z.infer; export type ProjectPlacementPayload = z.infer; export type WorkspaceStateBucket = z.infer; export type WorkspaceDescriptorPayload = z.infer; +export type WorkspaceServicePayload = z.infer; export type FetchAgentsResponseMessage = z.infer; export type FetchWorkspacesResponseMessage = z.infer; +export type ServiceStatusUpdateMessage = z.infer; export type OpenProjectResponseMessage = z.infer; export type ArchiveWorkspaceResponseMessage = z.infer; export type FetchAgentResponseMessage = z.infer; @@ -2468,6 +2516,7 @@ export type PaseoWorktreeListRequest = z.infer; export type PaseoWorktreeArchiveRequest = z.infer; export type PaseoWorktreeArchiveResponse = z.infer; +export type WorkspaceSetupStatusRequest = z.infer; export type OpenProjectRequest = z.infer; export type ArchiveWorkspaceRequest = z.infer; export type FileExplorerRequest = z.infer; diff --git a/packages/server/src/shared/messages.workspaces.test.ts b/packages/server/src/shared/messages.workspaces.test.ts index 57741db55..68b78b90a 100644 --- a/packages/server/src/shared/messages.workspaces.test.ts +++ b/packages/server/src/shared/messages.workspaces.test.ts @@ -44,6 +44,7 @@ describe("workspace message schemas", () => { name: "", status: "not-a-bucket", activityAt: null, + services: [], }, }, }); @@ -51,6 +52,70 @@ describe("workspace message schemas", () => { 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", @@ -67,6 +132,7 @@ describe("workspace message schemas", () => { index: 1, command: "npm install", cwd: "/repo/.paseo/worktrees/feature-a", + log: "done", status: "completed", exitCode: 0, durationMs: 100, @@ -79,4 +145,37 @@ describe("workspace message schemas", () => { 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"); + }); }); diff --git a/packages/server/src/shared/tool-call-display.test.ts b/packages/server/src/shared/tool-call-display.test.ts index 136d9e851..7ecb7ba21 100644 --- a/packages/server/src/shared/tool-call-display.test.ts +++ b/packages/server/src/shared/tool-call-display.test.ts @@ -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, }, diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 3dcb38811..62572476f 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -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?: { @@ -234,6 +237,56 @@ export function getServiceConfigs(repoRoot: string): Map 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 }, @@ -279,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; @@ -314,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(); + } }); } diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index 9cea3e24c..a15059a45 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -59,6 +59,7 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
+ @@ -475,6 +476,52 @@ function SelfHostedSection() { } +function ServiceProxySection() { + const workspaces = [ + { name: "fix-auth", url: "fix-auth.my-app.localhost" }, + { name: "add-search", url: "add-search.my-app.localhost" }, + { name: "upgrade-deps", url: "upgrade-deps.my-app.localhost" }, + ]; + + return ( + +
+
+ {/* Project */} +
+ + + + my-app +
+ + {/* Workspaces indented */} +
+ {workspaces.map((ws) => ( +
+
+
+ {ws.name} + npm run dev +
+ + {ws.url} + +
+ ))} +
+
+
+ + ); +} + function ShortcutsSection() { const shortcuts = [ { keys: ["⌘", "1-9"], action: "Switch panels" },