feat: add service start capability with health monitoring
This commit is contained in:
@@ -289,6 +289,61 @@ describe("DaemonClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("sends create_agent_request with string workspace ids", 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 connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const createPromise = client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project/.paseo/worktrees/feature-a",
|
||||
workspaceId: "/tmp/project/.paseo/worktrees/feature-a",
|
||||
title: "Compat agent",
|
||||
modeId: "default",
|
||||
});
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = JSON.parse(String(mock.sent[0])) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "create_agent_request";
|
||||
requestId: string;
|
||||
workspaceId: string;
|
||||
};
|
||||
};
|
||||
expect(request.message).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "create_agent_request",
|
||||
workspaceId: "/tmp/project/.paseo/worktrees/feature-a",
|
||||
}),
|
||||
);
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_create_failed",
|
||||
requestId: request.message.requestId,
|
||||
error: "compat test sentinel",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(createPromise).rejects.toThrow("compat test sentinel");
|
||||
});
|
||||
|
||||
test("sends explicit shutdown_server_request via shutdownServer", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -197,7 +197,7 @@ export type CreateAgentRequestOptions = {
|
||||
config?: AgentSessionConfig;
|
||||
provider?: AgentProvider;
|
||||
cwd?: string;
|
||||
workspaceId?: number;
|
||||
workspaceId?: string | number;
|
||||
initialPrompt?: string;
|
||||
clientMessageId?: string;
|
||||
outputSchema?: Record<string, unknown>;
|
||||
@@ -1322,6 +1322,23 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async startWorkspaceService(
|
||||
workspaceId: string,
|
||||
serviceName: string,
|
||||
requestId?: string,
|
||||
): Promise<Extract<SessionOutboundMessage, { type: "start_workspace_service_response" }>["payload"]> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "start_workspace_service_request",
|
||||
workspaceId,
|
||||
serviceName,
|
||||
},
|
||||
responseType: "start_workspace_service_response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async archiveWorkspace(
|
||||
workspaceId: number,
|
||||
requestId?: string,
|
||||
@@ -1423,7 +1440,7 @@ export class DaemonClient {
|
||||
type: "create_agent_request",
|
||||
requestId,
|
||||
config,
|
||||
...(typeof options.workspaceId === "number" ? { workspaceId: options.workspaceId } : {}),
|
||||
...(options.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}),
|
||||
...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}),
|
||||
...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}),
|
||||
...(options.outputSchema ? { outputSchema: options.outputSchema } : {}),
|
||||
|
||||
@@ -704,7 +704,7 @@ export async function createPaseoDaemon(
|
||||
serviceRouteStore,
|
||||
handleBranchChange,
|
||||
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
||||
(hostname) => serviceHealthMonitor.getStatusForHostname(hostname),
|
||||
(hostname) => serviceHealthMonitor.getHealthForHostname(hostname),
|
||||
);
|
||||
|
||||
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
|
||||
|
||||
@@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { findFreePort, ServiceRouteStore } from "./service-proxy.js";
|
||||
import {
|
||||
ServiceHealthMonitor,
|
||||
type ServiceStatusEntry,
|
||||
type ServiceHealthEntry,
|
||||
} from "./service-health-monitor.js";
|
||||
|
||||
type TcpServerHandle = {
|
||||
@@ -82,7 +82,7 @@ describe("ServiceHealthMonitor", () => {
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
@@ -101,7 +101,7 @@ describe("ServiceHealthMonitor", () => {
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: healthy.port,
|
||||
status: "running",
|
||||
health: "healthy",
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -118,7 +118,7 @@ describe("ServiceHealthMonitor", () => {
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
@@ -141,7 +141,7 @@ describe("ServiceHealthMonitor", () => {
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: deadPort,
|
||||
status: "stopped",
|
||||
health: "unhealthy",
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -160,7 +160,7 @@ describe("ServiceHealthMonitor", () => {
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
@@ -190,7 +190,7 @@ describe("ServiceHealthMonitor", () => {
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
@@ -212,7 +212,7 @@ describe("ServiceHealthMonitor", () => {
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: healthy.port,
|
||||
status: "running",
|
||||
health: "healthy",
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -231,7 +231,7 @@ describe("ServiceHealthMonitor", () => {
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
@@ -260,7 +260,7 @@ describe("ServiceHealthMonitor", () => {
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: healthy.port,
|
||||
status: "stopped",
|
||||
health: "unhealthy",
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -279,7 +279,7 @@ describe("ServiceHealthMonitor", () => {
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
@@ -325,7 +325,7 @@ describe("ServiceHealthMonitor", () => {
|
||||
serviceName: "web",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
@@ -344,18 +344,18 @@ describe("ServiceHealthMonitor", () => {
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: api.port,
|
||||
status: "running",
|
||||
health: "healthy",
|
||||
},
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: web.port,
|
||||
status: "running",
|
||||
health: "healthy",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("getStatusForHostname returns current status after probe", async () => {
|
||||
it("getHealthForHostname returns current health after probe", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const healthy = await startTcpServer();
|
||||
@@ -369,7 +369,7 @@ describe("ServiceHealthMonitor", () => {
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
@@ -378,14 +378,14 @@ describe("ServiceHealthMonitor", () => {
|
||||
graceMs: 0,
|
||||
});
|
||||
|
||||
expect(monitor.getStatusForHostname("api.localhost")).toBeNull();
|
||||
expect(monitor.getHealthForHostname("api.localhost")).toBeNull();
|
||||
|
||||
monitor.start();
|
||||
await advancePoll(1_000);
|
||||
monitor.stop();
|
||||
|
||||
expect(monitor.getStatusForHostname("api.localhost")).toBe("running");
|
||||
expect(monitor.getStatusForHostname("unknown.localhost")).toBeNull();
|
||||
expect(monitor.getHealthForHostname("api.localhost")).toBe("healthy");
|
||||
expect(monitor.getHealthForHostname("unknown.localhost")).toBeNull();
|
||||
});
|
||||
|
||||
it("coalesces multiple service changes in same workspace into one onChange call per poll cycle", async () => {
|
||||
@@ -410,7 +410,7 @@ describe("ServiceHealthMonitor", () => {
|
||||
serviceName: "web",
|
||||
});
|
||||
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
|
||||
const onChange = vi.fn<(workspaceId: string, services: ServiceHealthEntry[]) => void>();
|
||||
const monitor = new ServiceHealthMonitor({
|
||||
routeStore,
|
||||
onChange,
|
||||
@@ -442,13 +442,13 @@ describe("ServiceHealthMonitor", () => {
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: api.port,
|
||||
status: "stopped",
|
||||
health: "unhealthy",
|
||||
},
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: web.port,
|
||||
status: "stopped",
|
||||
health: "unhealthy",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import net from "node:net";
|
||||
import type { ServiceRouteEntry, ServiceRouteStore } from "./service-proxy.js";
|
||||
|
||||
export interface ServiceStatusEntry {
|
||||
export interface ServiceHealthEntry {
|
||||
serviceName: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
status: "running" | "stopped";
|
||||
health: "healthy" | "unhealthy";
|
||||
}
|
||||
|
||||
type RouteHealthState = {
|
||||
status: ServiceStatusEntry["status"] | null;
|
||||
health: ServiceHealthEntry["health"] | null;
|
||||
consecutiveFailures: number;
|
||||
registeredAt: number;
|
||||
};
|
||||
@@ -18,7 +18,7 @@ export class ServiceHealthMonitor {
|
||||
private readonly routeStore: ServiceRouteStore;
|
||||
private readonly onChange: (
|
||||
workspaceId: string,
|
||||
services: ServiceStatusEntry[],
|
||||
services: ServiceHealthEntry[],
|
||||
) => void;
|
||||
private readonly pollIntervalMs: number;
|
||||
private readonly probeTimeoutMs: number;
|
||||
@@ -39,7 +39,7 @@ export class ServiceHealthMonitor {
|
||||
failuresBeforeStopped = 2,
|
||||
}: {
|
||||
routeStore: ServiceRouteStore;
|
||||
onChange: (workspaceId: string, services: ServiceStatusEntry[]) => void;
|
||||
onChange: (workspaceId: string, services: ServiceHealthEntry[]) => void;
|
||||
pollIntervalMs?: number;
|
||||
probeTimeoutMs?: number;
|
||||
graceMs?: number;
|
||||
@@ -94,19 +94,19 @@ export class ServiceHealthMonitor {
|
||||
}
|
||||
|
||||
const isHealthy = await this.probeRoute(route.port);
|
||||
const previousStatus = state.status;
|
||||
const previousHealth = state.health;
|
||||
|
||||
if (isHealthy) {
|
||||
state.consecutiveFailures = 0;
|
||||
state.status = "running";
|
||||
state.health = "healthy";
|
||||
} else {
|
||||
state.consecutiveFailures += 1;
|
||||
if (state.consecutiveFailures >= this.failuresBeforeStopped) {
|
||||
state.status = "stopped";
|
||||
state.health = "unhealthy";
|
||||
}
|
||||
}
|
||||
|
||||
if (state.status !== null && state.status !== previousStatus) {
|
||||
if (state.health !== null && state.health !== previousHealth) {
|
||||
changedWorkspaceIds.add(route.workspaceId);
|
||||
}
|
||||
}
|
||||
@@ -135,7 +135,7 @@ export class ServiceHealthMonitor {
|
||||
}
|
||||
|
||||
const state: RouteHealthState = {
|
||||
status: null,
|
||||
health: null,
|
||||
consecutiveFailures: 0,
|
||||
registeredAt,
|
||||
};
|
||||
@@ -152,31 +152,31 @@ export class ServiceHealthMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
private buildWorkspaceServiceList(workspaceId: string): ServiceStatusEntry[] {
|
||||
private buildWorkspaceServiceList(workspaceId: string): ServiceHealthEntry[] {
|
||||
return this.routeStore
|
||||
.listRoutesForWorkspace(workspaceId)
|
||||
.flatMap((route) => {
|
||||
const state = this.routeStates.get(route.hostname);
|
||||
if (!state?.status) {
|
||||
if (!state?.health) {
|
||||
return [];
|
||||
}
|
||||
return [this.toServiceStatusEntry(route, state.status)];
|
||||
return [this.toServiceHealthEntry(route, state.health)];
|
||||
});
|
||||
}
|
||||
|
||||
getStatusForHostname(hostname: string): ServiceStatusEntry["status"] | null {
|
||||
return this.routeStates.get(hostname)?.status ?? null;
|
||||
getHealthForHostname(hostname: string): ServiceHealthEntry["health"] | null {
|
||||
return this.routeStates.get(hostname)?.health ?? null;
|
||||
}
|
||||
|
||||
private toServiceStatusEntry(
|
||||
private toServiceHealthEntry(
|
||||
route: ServiceRouteEntry,
|
||||
status: ServiceStatusEntry["status"],
|
||||
): ServiceStatusEntry {
|
||||
health: ServiceHealthEntry["health"],
|
||||
): ServiceHealthEntry {
|
||||
return {
|
||||
serviceName: route.serviceName,
|
||||
hostname: route.hostname,
|
||||
port: route.port,
|
||||
status,
|
||||
health,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,11 @@ export class ServiceRouteStore {
|
||||
return null;
|
||||
}
|
||||
|
||||
getRouteEntry(hostname: string): ServiceRouteEntry | null {
|
||||
const entry = this.routes.get(hostname);
|
||||
return entry ? { ...entry } : null;
|
||||
}
|
||||
|
||||
listRoutes(): ServiceRouteEntry[] {
|
||||
return Array.from(this.routes.values()).map((entry) => ({ ...entry }));
|
||||
}
|
||||
|
||||
@@ -111,7 +111,8 @@ describe("service-route-branch-handler", () => {
|
||||
hostname: "feature-billing.api.localhost",
|
||||
port: 3001,
|
||||
url: null,
|
||||
status: "stopped",
|
||||
lifecycle: "running",
|
||||
health: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,269 +1,232 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
import { ServiceRouteStore } from "./service-proxy.js";
|
||||
import {
|
||||
buildWorkspaceServicePayloads,
|
||||
createServiceStatusEmitter,
|
||||
} from "./service-status-projection.js";
|
||||
|
||||
function createWorkspaceRepo(options?: {
|
||||
branchName?: string;
|
||||
paseoConfig?: Record<string, unknown>;
|
||||
}): { tempDir: string; repoDir: string; cleanup: () => void } {
|
||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "service-projection-")));
|
||||
const repoDir = path.join(tempDir, "repo");
|
||||
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
|
||||
execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
|
||||
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
|
||||
if (options?.paseoConfig) {
|
||||
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
|
||||
}
|
||||
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
return {
|
||||
tempDir,
|
||||
repoDir,
|
||||
cleanup: () => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("service-status-projection", () => {
|
||||
it("buildWorkspaceServicePayloads returns service payloads from workspace routes", () => {
|
||||
it("shows configured services even before they have routes", () => {
|
||||
const workspace = createWorkspaceRepo({
|
||||
paseoConfig: {
|
||||
services: {
|
||||
api: { command: "npm run api" },
|
||||
web: { command: "npm run web", port: 3000 },
|
||||
},
|
||||
},
|
||||
});
|
||||
const routeStore = new ServiceRouteStore();
|
||||
|
||||
try {
|
||||
expect(buildWorkspaceServicePayloads(routeStore, workspace.repoDir, 6767)).toEqual([
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: null,
|
||||
url: "http://api.localhost:6767",
|
||||
lifecycle: "stopped",
|
||||
health: null,
|
||||
},
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: 3000,
|
||||
url: "http://web.localhost:6767",
|
||||
lifecycle: "stopped",
|
||||
health: null,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the active route port and branch-aware hostname for running services", () => {
|
||||
const workspace = createWorkspaceRepo({
|
||||
branchName: "feature/card",
|
||||
paseoConfig: {
|
||||
services: {
|
||||
web: { command: "npm run web" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
hostname: "feature-card.web.localhost",
|
||||
port: 4321,
|
||||
workspaceId: workspace.repoDir,
|
||||
serviceName: "web",
|
||||
});
|
||||
|
||||
try {
|
||||
expect(buildWorkspaceServicePayloads(routeStore, workspace.repoDir, 6767)).toEqual([
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "feature-card.web.localhost",
|
||||
port: 4321,
|
||||
url: "http://feature-card.web.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: null,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("includes orphaned active routes even if the current config no longer declares them", () => {
|
||||
const workspace = createWorkspaceRepo();
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "docs.localhost",
|
||||
port: 3002,
|
||||
workspaceId: "workspace-b",
|
||||
workspaceId: workspace.repoDir,
|
||||
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",
|
||||
},
|
||||
]);
|
||||
try {
|
||||
expect(buildWorkspaceServicePayloads(routeStore, workspace.repoDir, 6767)).toEqual([
|
||||
{
|
||||
serviceName: "docs",
|
||||
hostname: "docs.localhost",
|
||||
port: 3002,
|
||||
url: "http://docs.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: null,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("computes URLs with and without a daemon port", () => {
|
||||
it("createServiceStatusEmitter overlays health onto the full workspace service list", () => {
|
||||
const workspace = createWorkspaceRepo({
|
||||
paseoConfig: {
|
||||
services: {
|
||||
api: { command: "npm run api" },
|
||||
web: { command: "npm run web" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
workspaceId: workspace.repoDir,
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
expect(buildWorkspaceServicePayloads(routeStore, "workspace-a", 6767)).toEqual([
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
url: "http://api.localhost:6767",
|
||||
status: "stopped",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(buildWorkspaceServicePayloads(routeStore, "workspace-a", null)).toEqual([
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
url: null,
|
||||
status: "stopped",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("createServiceStatusEmitter emits updates to all active sessions", () => {
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
|
||||
const sessionA = { emit: vi.fn() };
|
||||
const sessionB = { emit: vi.fn() };
|
||||
|
||||
const emitUpdate = createServiceStatusEmitter({
|
||||
sessions: () => [sessionA, sessionB],
|
||||
routeStore,
|
||||
daemonPort: 6767,
|
||||
});
|
||||
|
||||
emitUpdate("workspace-a", [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(sessionA.emit).toHaveBeenCalledWith({
|
||||
type: "service_status_update",
|
||||
payload: {
|
||||
workspaceId: "workspace-a",
|
||||
services: [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
url: "http://api.localhost:6767",
|
||||
status: "running",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(sessionB.emit).toHaveBeenCalledWith({
|
||||
type: "service_status_update",
|
||||
payload: {
|
||||
workspaceId: "workspace-a",
|
||||
services: [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
url: "http://api.localhost:6767",
|
||||
status: "running",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses resolveStatus to set initial service status when provided", () => {
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
routeStore.registerRoute({
|
||||
hostname: "web.localhost",
|
||||
port: 3003,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "web",
|
||||
});
|
||||
|
||||
const statuses = new Map<string, "running" | "stopped">([
|
||||
["api.localhost", "running"],
|
||||
]);
|
||||
|
||||
expect(
|
||||
buildWorkspaceServicePayloads(routeStore, "workspace-a", 6767, (hostname) =>
|
||||
statuses.get(hostname) ?? null,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
url: "http://api.localhost:6767",
|
||||
status: "running",
|
||||
},
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: 3003,
|
||||
url: "http://web.localhost:6767",
|
||||
status: "stopped",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits workspace-specific batches", () => {
|
||||
const routeStore = new ServiceRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "api",
|
||||
});
|
||||
routeStore.registerRoute({
|
||||
hostname: "web.localhost",
|
||||
port: 3002,
|
||||
workspaceId: "workspace-a",
|
||||
serviceName: "web",
|
||||
});
|
||||
routeStore.registerRoute({
|
||||
hostname: "docs.localhost",
|
||||
port: 3003,
|
||||
workspaceId: "workspace-b",
|
||||
serviceName: "docs",
|
||||
});
|
||||
|
||||
const session = { emit: vi.fn() };
|
||||
const emitUpdate = createServiceStatusEmitter({
|
||||
sessions: () => [session],
|
||||
routeStore,
|
||||
daemonPort: null,
|
||||
daemonPort: 6767,
|
||||
});
|
||||
|
||||
emitUpdate("workspace-a", [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
status: "running",
|
||||
},
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: 3002,
|
||||
status: "stopped",
|
||||
},
|
||||
]);
|
||||
try {
|
||||
emitUpdate(workspace.repoDir, [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
health: "healthy",
|
||||
},
|
||||
]);
|
||||
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(session.emit).toHaveBeenCalledWith({
|
||||
type: "service_status_update",
|
||||
payload: {
|
||||
workspaceId: workspace.repoDir,
|
||||
services: [
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: 3001,
|
||||
url: "http://api.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
},
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: null,
|
||||
url: "http://web.localhost:6767",
|
||||
lifecycle: "stopped",
|
||||
health: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("computes URLs with and without a daemon port", () => {
|
||||
const workspace = createWorkspaceRepo({
|
||||
paseoConfig: {
|
||||
services: {
|
||||
api: { command: "npm run api" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const routeStore = new ServiceRouteStore();
|
||||
|
||||
try {
|
||||
expect(buildWorkspaceServicePayloads(routeStore, workspace.repoDir, 6767)).toEqual([
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: null,
|
||||
url: "http://api.localhost:6767",
|
||||
lifecycle: "stopped",
|
||||
health: null,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(buildWorkspaceServicePayloads(routeStore, workspace.repoDir, null)).toEqual([
|
||||
{
|
||||
serviceName: "api",
|
||||
hostname: "api.localhost",
|
||||
port: null,
|
||||
url: null,
|
||||
lifecycle: "stopped",
|
||||
health: null,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,11 @@ import type {
|
||||
SessionOutboundMessage,
|
||||
WorkspaceServicePayload,
|
||||
} from "../shared/messages.js";
|
||||
import type { ServiceStatusEntry } from "./service-health-monitor.js";
|
||||
import type { ServiceRouteStore } from "./service-proxy.js";
|
||||
import { buildServiceHostname } from "../utils/service-hostname.js";
|
||||
import { getServiceConfigs } from "../utils/worktree.js";
|
||||
import { readGitCommand } from "./workspace-git-metadata.js";
|
||||
import type { ServiceHealthEntry } from "./service-health-monitor.js";
|
||||
import type { ServiceRouteEntry, ServiceRouteStore } from "./service-proxy.js";
|
||||
|
||||
type SessionEmitter = {
|
||||
emit(message: SessionOutboundMessage): void;
|
||||
@@ -24,19 +27,65 @@ function toServiceUrl(hostname: string, daemonPort: number | null): string | nul
|
||||
return `http://${hostname}:${daemonPort}`;
|
||||
}
|
||||
|
||||
type ConfiguredWorkspaceService = {
|
||||
serviceName: string;
|
||||
hostname: string;
|
||||
port: number | null;
|
||||
};
|
||||
|
||||
function resolveWorkspaceBranchName(workspaceDirectory: string): string | null {
|
||||
return readGitCommand(workspaceDirectory, "git symbolic-ref --short HEAD");
|
||||
}
|
||||
|
||||
function listConfiguredWorkspaceServices(workspaceDirectory: string): ConfiguredWorkspaceService[] {
|
||||
const branchName = resolveWorkspaceBranchName(workspaceDirectory);
|
||||
const serviceConfigs = getServiceConfigs(workspaceDirectory);
|
||||
return Array.from(serviceConfigs.entries()).map(([serviceName, config]) => ({
|
||||
serviceName,
|
||||
hostname: buildServiceHostname(branchName, serviceName),
|
||||
port: config.port ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function mergeWorkspaceServiceDefinitions(
|
||||
workspaceDirectory: string,
|
||||
routeStore: ServiceRouteStore,
|
||||
): Array<ConfiguredWorkspaceService | ServiceRouteEntry> {
|
||||
const merged = new Map<string, ConfiguredWorkspaceService | ServiceRouteEntry>();
|
||||
|
||||
for (const service of listConfiguredWorkspaceServices(workspaceDirectory)) {
|
||||
merged.set(service.hostname, service);
|
||||
}
|
||||
|
||||
for (const route of routeStore.listRoutesForWorkspace(workspaceDirectory)) {
|
||||
merged.set(route.hostname, route);
|
||||
}
|
||||
|
||||
return Array.from(merged.values()).sort((left, right) =>
|
||||
left.serviceName.localeCompare(right.serviceName, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildWorkspaceServicePayloads(
|
||||
routeStore: ServiceRouteStore,
|
||||
workspaceId: string,
|
||||
workspaceDirectory: string,
|
||||
daemonPort: number | null,
|
||||
resolveStatus?: (hostname: string) => "running" | "stopped" | null,
|
||||
resolveHealth?: (hostname: string) => "healthy" | "unhealthy" | 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",
|
||||
}));
|
||||
return mergeWorkspaceServiceDefinitions(workspaceDirectory, routeStore).map((service) => {
|
||||
const route = routeStore.getRouteEntry(service.hostname);
|
||||
return {
|
||||
serviceName: service.serviceName,
|
||||
hostname: service.hostname,
|
||||
port: route?.port ?? service.port,
|
||||
url: toServiceUrl(service.hostname, daemonPort),
|
||||
lifecycle: route ? "running" : "stopped",
|
||||
health: resolveHealth?.(service.hostname) ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildServiceStatusUpdateMessage(params: {
|
||||
@@ -60,18 +109,18 @@ export function createServiceStatusEmitter({
|
||||
sessions: () => SessionEmitter[];
|
||||
routeStore: ServiceRouteStore;
|
||||
daemonPort: number | null | (() => number | null);
|
||||
}): (workspaceId: string, services: ServiceStatusEntry[]) => void {
|
||||
}): (workspaceId: string, services: ServiceHealthEntry[]) => void {
|
||||
return (workspaceId, services) => {
|
||||
const resolvedDaemonPort = resolveDaemonPort(daemonPort);
|
||||
const serviceStatusByHostname = new Map(
|
||||
services.map((service) => [service.hostname, service.status] as const),
|
||||
const serviceHealthByHostname = new Map(
|
||||
services.map((service) => [service.hostname, service.health] as const),
|
||||
);
|
||||
|
||||
const projected = buildWorkspaceServicePayloads(routeStore, workspaceId, resolvedDaemonPort).map(
|
||||
(service) => ({
|
||||
...service,
|
||||
status: serviceStatusByHostname.get(service.hostname) ?? service.status,
|
||||
}),
|
||||
const projected = buildWorkspaceServicePayloads(
|
||||
routeStore,
|
||||
workspaceId,
|
||||
resolvedDaemonPort,
|
||||
(hostname) => serviceHealthByHostname.get(hostname) ?? null,
|
||||
);
|
||||
|
||||
const message = buildServiceStatusUpdateMessage({
|
||||
|
||||
@@ -170,6 +170,65 @@ describe("provider history compatibility ownership", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("fetch_agent_timeline_request emits legacy compatibility fields for older clients", async () => {
|
||||
const ensureAgentLoaded = vi.fn(async () => createCompatibilitySnapshot());
|
||||
const { session, emitted } = createSessionForOwnershipTests({
|
||||
storedRecord: createStoredAgentRecord(),
|
||||
timelineRows: [
|
||||
{
|
||||
seq: 7,
|
||||
item: { type: "assistant_message", text: "compat row" },
|
||||
timestamp: new Date("2026-03-24T00:00:07.000Z"),
|
||||
},
|
||||
],
|
||||
agentLoadingService: {
|
||||
ensureAgentLoaded,
|
||||
},
|
||||
});
|
||||
|
||||
session.buildAgentPayload = vi.fn(async () => ({ id: "agent-1" }));
|
||||
|
||||
await session.handleMessage({
|
||||
type: "fetch_agent_timeline_request",
|
||||
requestId: "req-compat",
|
||||
agentId: "agent-1",
|
||||
direction: "tail",
|
||||
});
|
||||
|
||||
expect(emitted).toContainEqual({
|
||||
type: "fetch_agent_timeline_response",
|
||||
payload: expect.objectContaining({
|
||||
requestId: "req-compat",
|
||||
agentId: "agent-1",
|
||||
epoch: "compat:agent-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
projection: "projected",
|
||||
startCursor: { seq: 7, epoch: "compat:agent-1" },
|
||||
endCursor: { seq: 7, epoch: "compat:agent-1" },
|
||||
window: {
|
||||
minSeq: 7,
|
||||
maxSeq: 7,
|
||||
nextSeq: 8,
|
||||
startSeq: 7,
|
||||
endSeq: 7,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
},
|
||||
entries: [
|
||||
expect.objectContaining({
|
||||
seq: 7,
|
||||
seqStart: 7,
|
||||
seqEnd: 7,
|
||||
sourceSeqRanges: [{ startSeq: 7, endSeq: 7 }],
|
||||
collapsed: [],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("send_agent_message_request delegates unloaded bootstrap before recording and streaming", async () => {
|
||||
const ensureAgentLoaded = vi.fn(async () => createCompatibilitySnapshot());
|
||||
const { session, agentManager, emitted } = createSessionForOwnershipTests({
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type SubscribeTerminalsRequest,
|
||||
type UnsubscribeTerminalsRequest,
|
||||
type CreateTerminalRequest,
|
||||
type StartWorkspaceServiceRequest,
|
||||
type SubscribeTerminalRequest,
|
||||
type UnsubscribeTerminalRequest,
|
||||
type TerminalInput,
|
||||
@@ -63,6 +64,8 @@ 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";
|
||||
import { spawnWorkspaceService } from "./worktree-bootstrap.js";
|
||||
import { readGitCommand } from "./workspace-git-metadata.js";
|
||||
|
||||
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
||||
import { buildProviderRegistry } from "./agent/provider-registry.js";
|
||||
@@ -404,7 +407,7 @@ export type SessionOptions = {
|
||||
newBranch: string | null,
|
||||
) => void;
|
||||
getDaemonTcpPort?: () => number | null;
|
||||
resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null;
|
||||
resolveServiceHealth?: (hostname: string) => "healthy" | "unhealthy" | null;
|
||||
voice?: {
|
||||
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
|
||||
turnDetection?: Resolvable<TurnDetectionProvider | null>;
|
||||
@@ -587,7 +590,9 @@ export class Session {
|
||||
newBranch: string | null,
|
||||
) => void;
|
||||
private readonly getDaemonTcpPort: (() => number | null) | null;
|
||||
private readonly resolveServiceStatus: ((hostname: string) => "running" | "stopped" | null) | null;
|
||||
private readonly resolveServiceHealth:
|
||||
| ((hostname: string) => "healthy" | "unhealthy" | null)
|
||||
| null;
|
||||
private readonly subscribedTerminalDirectories = new Set<string>();
|
||||
private unsubscribeTerminalsChanged: (() => void) | null = null;
|
||||
private terminalExitSubscriptions: Map<string, () => void> = new Map();
|
||||
@@ -646,7 +651,7 @@ export class Session {
|
||||
serviceRouteStore,
|
||||
onBranchChanged,
|
||||
getDaemonTcpPort,
|
||||
resolveServiceStatus,
|
||||
resolveServiceHealth,
|
||||
voice,
|
||||
voiceBridge,
|
||||
dictation,
|
||||
@@ -688,7 +693,7 @@ export class Session {
|
||||
this.serviceRouteStore = serviceRouteStore ?? null;
|
||||
this.onBranchChanged = onBranchChanged;
|
||||
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
|
||||
this.resolveServiceStatus = resolveServiceStatus ?? null;
|
||||
this.resolveServiceHealth = resolveServiceHealth ?? null;
|
||||
if (this.terminalManager) {
|
||||
this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) =>
|
||||
this.handleTerminalsChanged(event),
|
||||
@@ -1739,6 +1744,10 @@ export class Session {
|
||||
await this.handleCreateTerminalRequest(msg);
|
||||
break;
|
||||
|
||||
case "start_workspace_service_request":
|
||||
await this.handleStartWorkspaceServiceRequest(msg);
|
||||
break;
|
||||
|
||||
case "subscribe_terminal_request":
|
||||
await this.handleSubscribeTerminalRequest(msg);
|
||||
break;
|
||||
@@ -5225,7 +5234,7 @@ export class Session {
|
||||
this.serviceRouteStore,
|
||||
workspace.directory,
|
||||
this.getDaemonTcpPort?.() ?? null,
|
||||
this.resolveServiceStatus ?? undefined,
|
||||
this.resolveServiceHealth ?? undefined,
|
||||
)
|
||||
: [],
|
||||
};
|
||||
@@ -5945,6 +5954,87 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private buildWorkspaceServicePayloadSnapshot(workspaceDirectory: string): WorkspaceDescriptorPayload["services"] {
|
||||
if (!this.serviceRouteStore) {
|
||||
return [];
|
||||
}
|
||||
return buildWorkspaceServicePayloads(
|
||||
this.serviceRouteStore,
|
||||
workspaceDirectory,
|
||||
this.getDaemonTcpPort?.() ?? null,
|
||||
this.resolveServiceHealth ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
private emitWorkspaceServiceStatusUpdate(workspaceDirectory: string): void {
|
||||
this.emit({
|
||||
type: "service_status_update",
|
||||
payload: {
|
||||
workspaceId: workspaceDirectory,
|
||||
services: this.buildWorkspaceServicePayloadSnapshot(workspaceDirectory),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async handleStartWorkspaceServiceRequest(
|
||||
request: StartWorkspaceServiceRequest,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!this.terminalManager || !this.serviceRouteStore) {
|
||||
throw new Error("Workspace services are not available on this daemon");
|
||||
}
|
||||
|
||||
const workspace = await this.resolveWorkspaceByIdOrDirectory(request.workspaceId);
|
||||
if (!workspace) {
|
||||
throw new Error(`Workspace not found: ${request.workspaceId}`);
|
||||
}
|
||||
|
||||
await spawnWorkspaceService({
|
||||
repoRoot: workspace.directory,
|
||||
workspaceId: workspace.directory,
|
||||
branchName: readGitCommand(workspace.directory, "git symbolic-ref --short HEAD"),
|
||||
serviceName: request.serviceName,
|
||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||
routeStore: this.serviceRouteStore,
|
||||
terminalManager: this.terminalManager,
|
||||
logger: this.sessionLogger,
|
||||
onLifecycleChanged: () => {
|
||||
this.emitWorkspaceServiceStatusUpdate(workspace.directory);
|
||||
},
|
||||
});
|
||||
|
||||
this.emitWorkspaceServiceStatusUpdate(workspace.directory);
|
||||
this.emit({
|
||||
type: "start_workspace_service_response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspaceId: request.workspaceId,
|
||||
serviceName: request.serviceName,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to start workspace service";
|
||||
this.sessionLogger.error(
|
||||
{
|
||||
err: error,
|
||||
workspaceId: request.workspaceId,
|
||||
serviceName: request.serviceName,
|
||||
},
|
||||
"Failed to start workspace service",
|
||||
);
|
||||
this.emit({
|
||||
type: "start_workspace_service_response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspaceId: request.workspaceId,
|
||||
serviceName: request.serviceName,
|
||||
error: message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCreatePaseoWorktreeRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
|
||||
): Promise<void> {
|
||||
@@ -6095,11 +6185,38 @@ export class Session {
|
||||
});
|
||||
const firstRow = timeline.rows[0];
|
||||
const lastRow = timeline.rows[timeline.rows.length - 1];
|
||||
const timelineWindow =
|
||||
timeline.window ??
|
||||
(() => {
|
||||
const minSeq = firstRow?.seq ?? 0;
|
||||
const maxSeq = lastRow?.seq ?? 0;
|
||||
return {
|
||||
minSeq,
|
||||
maxSeq,
|
||||
nextSeq: maxSeq > 0 ? maxSeq + 1 : 0,
|
||||
};
|
||||
})();
|
||||
const epoch = `compat:${msg.agentId}`;
|
||||
const startCursor = firstRow ? { seq: firstRow.seq, epoch } : null;
|
||||
const endCursor = lastRow ? { seq: lastRow.seq, epoch } : null;
|
||||
const window = {
|
||||
minSeq: timelineWindow.minSeq,
|
||||
maxSeq: timelineWindow.maxSeq,
|
||||
nextSeq: timelineWindow.nextSeq,
|
||||
startSeq: firstRow?.seq ?? null,
|
||||
endSeq: lastRow?.seq ?? null,
|
||||
hasOlder: timeline.hasOlder,
|
||||
hasNewer: timeline.hasNewer,
|
||||
};
|
||||
const entries = timeline.rows.map((row) => ({
|
||||
provider: snapshot.provider,
|
||||
item: row.item,
|
||||
timestamp: row.timestamp,
|
||||
seq: row.seq,
|
||||
seqStart: row.seq,
|
||||
seqEnd: row.seq,
|
||||
sourceSeqRanges: [{ startSeq: row.seq, endSeq: row.seq }],
|
||||
collapsed: [],
|
||||
}));
|
||||
|
||||
this.emit({
|
||||
@@ -6115,9 +6232,18 @@ export class Session {
|
||||
hasNewer: timeline.hasNewer,
|
||||
entries,
|
||||
error: null,
|
||||
epoch,
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window,
|
||||
startCursor,
|
||||
endCursor,
|
||||
projection: msg.projection ?? "projected",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const epoch = `compat:${msg.agentId}`;
|
||||
this.sessionLogger.error(
|
||||
{ err: error, agentId: msg.agentId },
|
||||
"Failed to handle fetch_agent_timeline_request",
|
||||
@@ -6135,6 +6261,22 @@ export class Session {
|
||||
hasNewer: false,
|
||||
entries: [],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
epoch,
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: {
|
||||
minSeq: 0,
|
||||
maxSeq: 0,
|
||||
nextSeq: 0,
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
},
|
||||
startCursor: null,
|
||||
endCursor: null,
|
||||
projection: msg.projection ?? "projected",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -248,7 +248,9 @@ 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 resolveServiceHealth:
|
||||
| ((hostname: string) => "healthy" | "unhealthy" | null)
|
||||
| null;
|
||||
private readonly dictation: {
|
||||
finalTimeoutMs?: number;
|
||||
} | null;
|
||||
@@ -326,7 +328,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
newBranch: string | null,
|
||||
) => void,
|
||||
getDaemonTcpPort?: () => number | null,
|
||||
resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null,
|
||||
resolveServiceHealth?: (hostname: string) => "healthy" | "unhealthy" | null,
|
||||
) {
|
||||
this.logger = logger.child({ module: "websocket-server" });
|
||||
this.serverId = serverId;
|
||||
@@ -373,7 +375,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.serviceRouteStore = serviceRouteStore ?? null;
|
||||
this.onBranchChanged = onBranchChanged ?? null;
|
||||
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
|
||||
this.resolveServiceStatus = resolveServiceStatus ?? null;
|
||||
this.resolveServiceHealth = resolveServiceHealth ?? null;
|
||||
this.serverCapabilities = buildServerCapabilities({
|
||||
readiness: this.speech?.getReadiness() ?? null,
|
||||
});
|
||||
@@ -693,7 +695,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
serviceRouteStore: this.serviceRouteStore ?? undefined,
|
||||
onBranchChanged: this.onBranchChanged ?? undefined,
|
||||
getDaemonTcpPort: this.getDaemonTcpPort ?? undefined,
|
||||
resolveServiceStatus: this.resolveServiceStatus ?? undefined,
|
||||
resolveServiceHealth: this.resolveServiceHealth ?? undefined,
|
||||
voice: {
|
||||
...(this.voice ?? {}),
|
||||
turnDetection: () => this.speech?.resolveTurnDetection() ?? null,
|
||||
|
||||
@@ -768,6 +768,115 @@ export interface WorktreeServiceResult {
|
||||
terminalId: string;
|
||||
}
|
||||
|
||||
type SpawnWorkspaceServiceOptions = {
|
||||
repoRoot: string;
|
||||
workspaceId: string;
|
||||
branchName: string | null;
|
||||
serviceName: string;
|
||||
daemonPort?: number | null;
|
||||
routeStore: ServiceRouteStore;
|
||||
terminalManager: TerminalManager;
|
||||
logger?: Logger;
|
||||
onLifecycleChanged?: () => void;
|
||||
};
|
||||
|
||||
export async function spawnWorkspaceService(
|
||||
options: SpawnWorkspaceServiceOptions,
|
||||
): Promise<WorktreeServiceResult> {
|
||||
const {
|
||||
repoRoot,
|
||||
workspaceId,
|
||||
branchName,
|
||||
serviceName,
|
||||
daemonPort,
|
||||
routeStore,
|
||||
terminalManager,
|
||||
logger,
|
||||
onLifecycleChanged,
|
||||
} = options;
|
||||
const serviceConfigs = getServiceConfigs(repoRoot);
|
||||
const config = serviceConfigs.get(serviceName);
|
||||
if (!config) {
|
||||
throw new Error(`Service '${serviceName}' is not configured in paseo.json`);
|
||||
}
|
||||
|
||||
let hostname: string | null = null;
|
||||
let port: number | null = null;
|
||||
|
||||
try {
|
||||
hostname = buildServiceHostname(branchName, serviceName);
|
||||
const resolvedHostname = hostname;
|
||||
if (routeStore.getRouteEntry(resolvedHostname)) {
|
||||
throw new Error(`Service '${serviceName}' is already running`);
|
||||
}
|
||||
|
||||
port = config.port ?? (await findFreePort());
|
||||
|
||||
routeStore.registerRoute({
|
||||
hostname: resolvedHostname,
|
||||
port,
|
||||
workspaceId,
|
||||
serviceName,
|
||||
});
|
||||
|
||||
const env: Record<string, string> = {
|
||||
PORT: String(port),
|
||||
HOST: "127.0.0.1",
|
||||
};
|
||||
if (daemonPort !== null && daemonPort !== undefined) {
|
||||
env.PASEO_SERVICE_URL = `http://${resolvedHostname}:${daemonPort}`;
|
||||
}
|
||||
|
||||
const terminal = await terminalManager.createTerminal({
|
||||
cwd: repoRoot,
|
||||
name: serviceName,
|
||||
env,
|
||||
});
|
||||
|
||||
terminal.onExit(() => {
|
||||
routeStore.removeRoute(resolvedHostname);
|
||||
onLifecycleChanged?.();
|
||||
logger?.info(
|
||||
{ serviceName, hostname: resolvedHostname, terminalId: terminal.id },
|
||||
"Stopped worktree service",
|
||||
);
|
||||
});
|
||||
|
||||
await waitForTerminalBootstrapReadiness(terminal);
|
||||
terminal.send({ type: "input", data: `${config.command}\r` });
|
||||
|
||||
logger?.info(
|
||||
{ serviceName, hostname: resolvedHostname, port, terminalId: terminal.id },
|
||||
`Registered service proxy: ${resolvedHostname} -> 127.0.0.1:${port}`,
|
||||
);
|
||||
|
||||
onLifecycleChanged?.();
|
||||
return {
|
||||
serviceName,
|
||||
hostname: resolvedHostname,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export async function spawnWorktreeServices(options: {
|
||||
repoRoot: string;
|
||||
workspaceId: string;
|
||||
@@ -776,77 +885,22 @@ export async function spawnWorktreeServices(options: {
|
||||
routeStore: ServiceRouteStore;
|
||||
terminalManager: TerminalManager;
|
||||
logger?: Logger;
|
||||
onLifecycleChanged?: () => void;
|
||||
}): Promise<WorktreeServiceResult[]> {
|
||||
const { repoRoot, workspaceId, branchName, daemonPort, routeStore, terminalManager, logger } =
|
||||
options;
|
||||
const { repoRoot } = options;
|
||||
const serviceConfigs = getServiceConfigs(repoRoot);
|
||||
if (serviceConfigs.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: WorktreeServiceResult[] = [];
|
||||
|
||||
for (const [serviceName, config] of serviceConfigs) {
|
||||
let hostname: string | null = null;
|
||||
let port: number | null = null;
|
||||
|
||||
try {
|
||||
port = config.port ?? (await findFreePort());
|
||||
hostname = buildServiceHostname(branchName, serviceName);
|
||||
|
||||
routeStore.registerRoute({
|
||||
hostname,
|
||||
port,
|
||||
workspaceId,
|
||||
for (const serviceName of serviceConfigs.keys()) {
|
||||
results.push(
|
||||
await spawnWorkspaceService({
|
||||
...options,
|
||||
serviceName,
|
||||
});
|
||||
|
||||
const env: Record<string, string> = {
|
||||
PORT: String(port),
|
||||
HOST: "127.0.0.1",
|
||||
};
|
||||
if (daemonPort !== null && daemonPort !== undefined) {
|
||||
env.PASEO_SERVICE_URL = `http://${hostname}:${daemonPort}`;
|
||||
}
|
||||
|
||||
const terminal = await terminalManager.createTerminal({
|
||||
cwd: repoRoot,
|
||||
name: serviceName,
|
||||
env,
|
||||
});
|
||||
|
||||
await waitForTerminalBootstrapReadiness(terminal);
|
||||
terminal.send({ type: "input", data: `${config.command}\r` });
|
||||
|
||||
logger?.info(
|
||||
{ serviceName, hostname, port, terminalId: terminal.id },
|
||||
`Registered service proxy: ${hostname} -> 127.0.0.1:${port}`,
|
||||
);
|
||||
|
||||
results.push({
|
||||
serviceName,
|
||||
hostname,
|
||||
port,
|
||||
terminalId: terminal.id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (hostname && port !== null) {
|
||||
routeStore.removeRoute(hostname);
|
||||
}
|
||||
logger?.error(
|
||||
{
|
||||
err: error,
|
||||
serviceName,
|
||||
repoRoot,
|
||||
branchName,
|
||||
hostname,
|
||||
port,
|
||||
command: config.command,
|
||||
},
|
||||
"Failed to spawn worktree service",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
@@ -57,6 +57,7 @@ function createTerminalManagerStub(options?: {
|
||||
grid: [],
|
||||
}),
|
||||
subscribe: () => () => {},
|
||||
onExit: () => () => {},
|
||||
send: (message: { type: string; data: string }) => {
|
||||
if (message.type === "input") {
|
||||
sent.push(message.data);
|
||||
|
||||
@@ -778,6 +778,9 @@ export async function createPaseoWorktreeInBackground(
|
||||
routeStore: dependencies.serviceRouteStore,
|
||||
terminalManager: dependencies.terminalManager,
|
||||
logger: dependencies.sessionLogger,
|
||||
onLifecycleChanged: () => {
|
||||
void dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
dependencies.sessionLogger.error(
|
||||
|
||||
@@ -1262,6 +1262,13 @@ export const CreateTerminalRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const StartWorkspaceServiceRequestSchema = z.object({
|
||||
type: z.literal("start_workspace_service_request"),
|
||||
workspaceId: z.string(),
|
||||
serviceName: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const SubscribeTerminalRequestSchema = z.object({
|
||||
type: z.literal("subscribe_terminal_request"),
|
||||
terminalId: z.string(),
|
||||
@@ -1373,6 +1380,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
SubscribeTerminalsRequestSchema,
|
||||
UnsubscribeTerminalsRequestSchema,
|
||||
CreateTerminalRequestSchema,
|
||||
StartWorkspaceServiceRequestSchema,
|
||||
SubscribeTerminalRequestSchema,
|
||||
UnsubscribeTerminalRequestSchema,
|
||||
TerminalInputSchema,
|
||||
@@ -1728,12 +1736,16 @@ export const ProjectPlacementPayloadSchema = z.object({
|
||||
checkout: ProjectCheckoutLitePayloadSchema,
|
||||
});
|
||||
|
||||
export const WorkspaceServiceLifecycleSchema = z.enum(["running", "stopped"]);
|
||||
export const WorkspaceServiceHealthSchema = z.enum(["healthy", "unhealthy"]);
|
||||
|
||||
export const WorkspaceServicePayloadSchema = z.object({
|
||||
serviceName: z.string(),
|
||||
hostname: z.string(),
|
||||
port: z.number().int().positive(),
|
||||
port: z.number().int().positive().nullable(),
|
||||
url: z.string().nullable(),
|
||||
status: z.enum(["running", "stopped"]),
|
||||
lifecycle: WorkspaceServiceLifecycleSchema,
|
||||
health: WorkspaceServiceHealthSchema.nullable(),
|
||||
});
|
||||
|
||||
export const WorkspaceDescriptorPayloadSchema = z.object({
|
||||
@@ -1893,6 +1905,16 @@ export const OpenProjectResponseMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const StartWorkspaceServiceResponseMessageSchema = z.object({
|
||||
type: z.literal("start_workspace_service_response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
workspaceId: z.string(),
|
||||
serviceName: z.string(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ArchiveWorkspaceResponseMessageSchema = z.object({
|
||||
type: z.literal("archive_workspace_response"),
|
||||
payload: z.object({
|
||||
@@ -2585,6 +2607,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
FetchAgentsResponseMessageSchema,
|
||||
FetchWorkspacesResponseMessageSchema,
|
||||
OpenProjectResponseMessageSchema,
|
||||
StartWorkspaceServiceResponseMessageSchema,
|
||||
ArchiveWorkspaceResponseMessageSchema,
|
||||
FetchAgentResponseMessageSchema,
|
||||
FetchAgentTimelineResponseMessageSchema,
|
||||
@@ -2682,11 +2705,16 @@ export type ProjectCheckoutLitePayload = z.infer<typeof ProjectCheckoutLitePaylo
|
||||
export type ProjectPlacementPayload = z.infer<typeof ProjectPlacementPayloadSchema>;
|
||||
export type WorkspaceStateBucket = z.infer<typeof WorkspaceStateBucketSchema>;
|
||||
export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPayloadSchema>;
|
||||
export type WorkspaceServiceLifecycle = z.infer<typeof WorkspaceServiceLifecycleSchema>;
|
||||
export type WorkspaceServiceHealth = z.infer<typeof WorkspaceServiceHealthSchema>;
|
||||
export type WorkspaceServicePayload = z.infer<typeof WorkspaceServicePayloadSchema>;
|
||||
export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>;
|
||||
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
|
||||
export type ServiceStatusUpdateMessage = z.infer<typeof ServiceStatusUpdateMessageSchema>;
|
||||
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>;
|
||||
export type StartWorkspaceServiceResponseMessage = z.infer<
|
||||
typeof StartWorkspaceServiceResponseMessageSchema
|
||||
>;
|
||||
export type ArchiveWorkspaceResponseMessage = z.infer<typeof ArchiveWorkspaceResponseMessageSchema>;
|
||||
export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessageSchema>;
|
||||
export type FetchAgentTimelineResponseMessage = z.infer<
|
||||
@@ -2860,6 +2888,8 @@ export type UnsubscribeTerminalsRequest = z.infer<typeof UnsubscribeTerminalsReq
|
||||
export type TerminalsChanged = z.infer<typeof TerminalsChangedSchema>;
|
||||
export type CreateTerminalRequest = z.infer<typeof CreateTerminalRequestSchema>;
|
||||
export type CreateTerminalResponse = z.infer<typeof CreateTerminalResponseSchema>;
|
||||
export type StartWorkspaceServiceRequest = z.infer<typeof StartWorkspaceServiceRequestSchema>;
|
||||
export type StartWorkspaceServiceResponse = z.infer<typeof StartWorkspaceServiceResponseMessageSchema>;
|
||||
export type SubscribeTerminalRequest = z.infer<typeof SubscribeTerminalRequestSchema>;
|
||||
export type SubscribeTerminalResponse = z.infer<typeof SubscribeTerminalResponseSchema>;
|
||||
export type UnsubscribeTerminalRequest = z.infer<typeof UnsubscribeTerminalRequestSchema>;
|
||||
|
||||
@@ -74,7 +74,8 @@ describe("workspace message schemas", () => {
|
||||
hostname: "web.localhost",
|
||||
port: 3000,
|
||||
url: "http://web.localhost:6767",
|
||||
status: "running",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -91,7 +92,8 @@ describe("workspace message schemas", () => {
|
||||
hostname: "web.localhost",
|
||||
port: 3000,
|
||||
url: "http://web.localhost:6767",
|
||||
status: "running",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -134,9 +136,10 @@ describe("workspace message schemas", () => {
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "web.localhost",
|
||||
port: 3000,
|
||||
port: null,
|
||||
url: null,
|
||||
status: "stopped",
|
||||
lifecycle: "stopped",
|
||||
health: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user