refactor(server): extract the workspace-scripts feature into a deep module (#1716)
* refactor(server): extract the workspace-scripts feature into a deep module
The service-proxy-backed "workspace scripts" feature had no home: building a
workspace's scripts payload was duplicated between the descriptor builder and
buildWorkspaceScriptPayloadSnapshot (the same 9-input buildWorkspaceScriptPayloads
assembly), and the "scripts available on this daemon?" guard appeared a third time
in the start handler.
Move the feature into session/workspace-scripts/workspace-scripts-service.ts behind
createWorkspaceScriptsService(deps): { buildSnapshot, emitStatusUpdate, start }. The
payload assembly and availability guard now live in one place; the descriptor builder,
the status-emission path, and the start RPC all funnel through it. spawnWorkspaceScript
is injected as a port so the feature is testable without a real process. session.ts
drops ~98 lines; the two methods existing tests reach stay as thin delegators.
New zero-mock unit test covers the guard, status emission, and the start branch matrix
with injected fakes, the real service proxy + runtime store, and a fake launcher.
* refactor(server): address review — durable comment + documented test stand-in
- Drop the transient "#1714" PR reference from the buildWorkspaceScriptPayloadSnapshot
accessor comment; state the durable reason instead.
- Hoist the opaque terminalManager test stand-in to a named const documenting the
non-call guarantee, rather than an inline `as unknown as` cast.
This commit is contained in:
@@ -52,14 +52,13 @@ import { respondToAgentPermission } from "./agent/permission-response.js";
|
|||||||
import { experimental_createMCPClient } from "ai";
|
import { experimental_createMCPClient } from "ai";
|
||||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||||
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
|
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
|
||||||
import {
|
|
||||||
buildWorkspaceScriptPayloads,
|
|
||||||
readPaseoConfigForProjection,
|
|
||||||
} from "./script-status-projection.js";
|
|
||||||
import { deriveProjectSlug } from "./workspace-git-metadata.js";
|
|
||||||
import type { ScriptHealthState } from "./script-health-monitor.js";
|
import type { ScriptHealthState } from "./script-health-monitor.js";
|
||||||
import { spawnWorkspaceScript } from "./worktree-bootstrap.js";
|
import { spawnWorkspaceScript } from "./worktree-bootstrap.js";
|
||||||
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||||
|
import {
|
||||||
|
createWorkspaceScriptsService,
|
||||||
|
type WorkspaceScriptsService,
|
||||||
|
} from "./session/workspace-scripts/workspace-scripts-service.js";
|
||||||
import type { DaemonConfigStore } from "./daemon-config-store.js";
|
import type { DaemonConfigStore } from "./daemon-config-store.js";
|
||||||
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
|
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
|
||||||
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
|
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
|
||||||
@@ -589,6 +588,7 @@ export class Session {
|
|||||||
private readonly agentConfigSession: AgentConfigSession;
|
private readonly agentConfigSession: AgentConfigSession;
|
||||||
private readonly projectConfigSession: ProjectConfigSession;
|
private readonly projectConfigSession: ProjectConfigSession;
|
||||||
private readonly daemonSession: DaemonSession;
|
private readonly daemonSession: DaemonSession;
|
||||||
|
private readonly workspaceScripts: WorkspaceScriptsService;
|
||||||
private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch;
|
private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch;
|
||||||
|
|
||||||
constructor(options: SessionOptions) {
|
constructor(options: SessionOptions) {
|
||||||
@@ -848,6 +848,20 @@ export class Session {
|
|||||||
this.getDaemonTcpHost = getDaemonTcpHost ?? null;
|
this.getDaemonTcpHost = getDaemonTcpHost ?? null;
|
||||||
this.serviceProxyPublicBaseUrl = serviceProxyPublicBaseUrl ?? null;
|
this.serviceProxyPublicBaseUrl = serviceProxyPublicBaseUrl ?? null;
|
||||||
this.resolveScriptHealth = resolveScriptHealth ?? null;
|
this.resolveScriptHealth = resolveScriptHealth ?? null;
|
||||||
|
this.workspaceScripts = createWorkspaceScriptsService({
|
||||||
|
serviceProxy: this.serviceProxy,
|
||||||
|
scriptRuntimeStore: this.scriptRuntimeStore,
|
||||||
|
terminalManager: this.terminalManager,
|
||||||
|
workspaceRegistry: this.workspaceRegistry,
|
||||||
|
workspaceGitService: this.workspaceGitService,
|
||||||
|
getDaemonTcpPort: this.getDaemonTcpPort,
|
||||||
|
getDaemonTcpHost: this.getDaemonTcpHost,
|
||||||
|
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
||||||
|
resolveScriptHealth: this.resolveScriptHealth,
|
||||||
|
logger: this.sessionLogger,
|
||||||
|
emit: (message) => this.emit(message),
|
||||||
|
spawnWorkspaceScript,
|
||||||
|
});
|
||||||
this.subscribeToOptionalManagers();
|
this.subscribeToOptionalManagers();
|
||||||
this.workspaceDirectory = new WorkspaceDirectory({
|
this.workspaceDirectory = new WorkspaceDirectory({
|
||||||
logger: this.sessionLogger,
|
logger: this.sessionLogger,
|
||||||
@@ -3688,20 +3702,7 @@ export class Session {
|
|||||||
statusEnteredAt: null,
|
statusEnteredAt: null,
|
||||||
activityAt: null,
|
activityAt: null,
|
||||||
diffStat,
|
diffStat,
|
||||||
scripts:
|
scripts: this.buildWorkspaceScriptPayloadSnapshot(workspace.workspaceId, workspace.cwd),
|
||||||
this.serviceProxy && this.scriptRuntimeStore
|
|
||||||
? buildWorkspaceScriptPayloads({
|
|
||||||
workspaceId: workspace.workspaceId,
|
|
||||||
workspaceDirectory: workspace.cwd,
|
|
||||||
paseoConfig: readPaseoConfigForProjection(workspace.cwd, this.sessionLogger),
|
|
||||||
serviceProxy: this.serviceProxy,
|
|
||||||
runtimeStore: this.scriptRuntimeStore,
|
|
||||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
|
||||||
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
|
||||||
gitMetadata: this.resolveWorkspaceScriptGitMetadata(workspace.cwd),
|
|
||||||
resolveHealth: this.resolveScriptHealth ?? undefined,
|
|
||||||
})
|
|
||||||
: [],
|
|
||||||
...(resolvedProjectRecord
|
...(resolvedProjectRecord
|
||||||
? {
|
? {
|
||||||
project: await this.buildProjectPlacementForWorkspace(workspace, resolvedProjectRecord),
|
project: await this.buildProjectPlacementForWorkspace(workspace, resolvedProjectRecord),
|
||||||
@@ -4862,116 +4863,17 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Named accessor: the workspace descriptor builder and the git-watch test both read a workspace's
|
||||||
|
// scripts snapshot through here; the workspace-scripts module owns the payload assembly.
|
||||||
private buildWorkspaceScriptPayloadSnapshot(
|
private buildWorkspaceScriptPayloadSnapshot(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
workspaceDirectory: string,
|
workspaceDirectory: string,
|
||||||
): WorkspaceDescriptorPayload["scripts"] {
|
): WorkspaceDescriptorPayload["scripts"] {
|
||||||
if (!this.serviceProxy || !this.scriptRuntimeStore) {
|
return this.workspaceScripts.buildSnapshot(workspaceId, workspaceDirectory);
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return buildWorkspaceScriptPayloads({
|
|
||||||
workspaceId,
|
|
||||||
workspaceDirectory,
|
|
||||||
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, this.sessionLogger),
|
|
||||||
serviceProxy: this.serviceProxy,
|
|
||||||
runtimeStore: this.scriptRuntimeStore,
|
|
||||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
|
||||||
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
|
||||||
gitMetadata: this.resolveWorkspaceScriptGitMetadata(workspaceDirectory),
|
|
||||||
resolveHealth: this.resolveScriptHealth ?? undefined,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveWorkspaceScriptGitMetadata(
|
private handleStartWorkspaceScriptRequest(request: StartWorkspaceScriptRequest): Promise<void> {
|
||||||
workspaceDirectory: string,
|
return this.workspaceScripts.start(request);
|
||||||
): { projectSlug: string; currentBranch: string | null } | undefined {
|
|
||||||
const snapshot = this.workspaceGitService.peekSnapshot(workspaceDirectory);
|
|
||||||
if (!snapshot) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
projectSlug: deriveProjectSlug(
|
|
||||||
workspaceDirectory,
|
|
||||||
snapshot.git.isGit ? snapshot.git.remoteUrl : null,
|
|
||||||
),
|
|
||||||
currentBranch: snapshot.git.currentBranch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private emitWorkspaceScriptStatusUpdate(workspaceId: string, workspaceDirectory: string): void {
|
|
||||||
this.emit({
|
|
||||||
type: "script_status_update",
|
|
||||||
payload: {
|
|
||||||
workspaceId,
|
|
||||||
scripts: this.buildWorkspaceScriptPayloadSnapshot(workspaceId, workspaceDirectory),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleStartWorkspaceScriptRequest(
|
|
||||||
request: StartWorkspaceScriptRequest,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (!this.terminalManager || !this.serviceProxy || !this.scriptRuntimeStore) {
|
|
||||||
throw new Error("Workspace scripts are not available on this daemon");
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspace = await this.workspaceRegistry.get(request.workspaceId);
|
|
||||||
if (!workspace) {
|
|
||||||
throw new Error(`Workspace not found: ${request.workspaceId}`);
|
|
||||||
}
|
|
||||||
const gitMetadata = await this.workspaceGitService.getWorkspaceGitMetadata(workspace.cwd);
|
|
||||||
|
|
||||||
const serviceResult = await spawnWorkspaceScript({
|
|
||||||
repoRoot: workspace.cwd,
|
|
||||||
workspaceId: workspace.workspaceId,
|
|
||||||
projectSlug: gitMetadata.projectSlug,
|
|
||||||
branchName: gitMetadata.currentBranch,
|
|
||||||
scriptName: request.scriptName,
|
|
||||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
|
||||||
daemonListenHost: this.getDaemonTcpHost?.() ?? null,
|
|
||||||
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
|
||||||
serviceProxy: this.serviceProxy,
|
|
||||||
runtimeStore: this.scriptRuntimeStore,
|
|
||||||
terminalManager: this.terminalManager,
|
|
||||||
logger: this.sessionLogger,
|
|
||||||
onLifecycleChanged: () => {
|
|
||||||
this.emitWorkspaceScriptStatusUpdate(workspace.workspaceId, workspace.cwd);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.emitWorkspaceScriptStatusUpdate(workspace.workspaceId, workspace.cwd);
|
|
||||||
this.emit({
|
|
||||||
type: "start_workspace_script_response",
|
|
||||||
payload: {
|
|
||||||
requestId: request.requestId,
|
|
||||||
workspaceId: request.workspaceId,
|
|
||||||
scriptName: request.scriptName,
|
|
||||||
terminalId: serviceResult.terminalId,
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : "Failed to start workspace script";
|
|
||||||
this.sessionLogger.error(
|
|
||||||
{
|
|
||||||
err: error,
|
|
||||||
workspaceId: request.workspaceId,
|
|
||||||
scriptName: request.scriptName,
|
|
||||||
},
|
|
||||||
"Failed to start workspace script",
|
|
||||||
);
|
|
||||||
this.emit({
|
|
||||||
type: "start_workspace_script_response",
|
|
||||||
payload: {
|
|
||||||
requestId: request.requestId,
|
|
||||||
workspaceId: request.workspaceId,
|
|
||||||
scriptName: request.scriptName,
|
|
||||||
terminalId: null,
|
|
||||||
error: message,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs.
|
// COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs.
|
||||||
@@ -5047,7 +4949,7 @@ export class Session {
|
|||||||
getDaemonTcpHost: this.getDaemonTcpHost,
|
getDaemonTcpHost: this.getDaemonTcpHost,
|
||||||
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
||||||
onScriptsChanged: (workspaceId, workspaceDirectory) => {
|
onScriptsChanged: (workspaceId, workspaceDirectory) => {
|
||||||
this.emitWorkspaceScriptStatusUpdate(workspaceId, workspaceDirectory);
|
this.workspaceScripts.emitStatusUpdate(workspaceId, workspaceDirectory);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
input,
|
input,
|
||||||
|
|||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { pino } from "pino";
|
||||||
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
|
import type { SessionOutboundMessage, StartWorkspaceScriptRequest } from "../../messages.js";
|
||||||
|
import { createServiceProxySubsystem, type ServiceProxySubsystem } from "../../service-proxy.js";
|
||||||
|
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
|
||||||
|
import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "../../workspace-registry.js";
|
||||||
|
import type { WorkspaceGitMetadata } from "../../workspace-git-metadata.js";
|
||||||
|
import { WorkspaceScriptRuntimeStore } from "../../workspace-script-runtime-store.js";
|
||||||
|
import type {
|
||||||
|
SpawnWorkspaceScriptOptions,
|
||||||
|
WorktreeScriptResult,
|
||||||
|
} from "../../worktree-bootstrap.js";
|
||||||
|
import { createWorkspaceScriptsService } from "./workspace-scripts-service.js";
|
||||||
|
|
||||||
|
// The production module reads only WorkspaceGitService.{peekSnapshot,getWorkspaceGitMetadata},
|
||||||
|
// WorkspaceRegistry.get, and forwards the launcher + opaque managers to the injected
|
||||||
|
// spawnWorkspaceScript port. The fakes below implement exactly that slice; the service proxy and
|
||||||
|
// runtime store are the real in-memory implementations, and spawning is injected so no process runs.
|
||||||
|
|
||||||
|
const logger = pino({ level: "silent" });
|
||||||
|
|
||||||
|
const gitMetadata: WorkspaceGitMetadata = {
|
||||||
|
projectKind: "git",
|
||||||
|
projectDisplayName: "repo",
|
||||||
|
workspaceDisplayName: "repo",
|
||||||
|
gitRemote: null,
|
||||||
|
isWorktree: false,
|
||||||
|
projectSlug: "paseo",
|
||||||
|
repoRoot: "/tmp/repo",
|
||||||
|
currentBranch: "feature/scripts",
|
||||||
|
remoteUrl: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function fakeWorkspaceRegistry(
|
||||||
|
record: PersistedWorkspaceRecord | null,
|
||||||
|
): Pick<WorkspaceRegistry, "get"> {
|
||||||
|
return {
|
||||||
|
async get() {
|
||||||
|
return record;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeGitService(metadata: WorkspaceGitMetadata = gitMetadata) {
|
||||||
|
return {
|
||||||
|
peekSnapshot() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
async getWorkspaceGitMetadata() {
|
||||||
|
return metadata;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The service only truthiness-checks terminalManager in its availability guard and then forwards it
|
||||||
|
// opaquely to the injected spawnWorkspaceScript fake, which ignores it — an empty stand-in is enough.
|
||||||
|
const availableTerminalManager = {} as unknown as TerminalManager;
|
||||||
|
|
||||||
|
interface BuildOptions {
|
||||||
|
serviceProxy?: ServiceProxySubsystem | null;
|
||||||
|
scriptRuntimeStore?: WorkspaceScriptRuntimeStore | null;
|
||||||
|
terminalManager?: TerminalManager | null;
|
||||||
|
workspace?: PersistedWorkspaceRecord | null;
|
||||||
|
spawnThrows?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildService(options: BuildOptions = {}) {
|
||||||
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
|
const spawnCalls: SpawnWorkspaceScriptOptions[] = [];
|
||||||
|
const workspace =
|
||||||
|
options.workspace === undefined
|
||||||
|
? ({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord)
|
||||||
|
: options.workspace;
|
||||||
|
|
||||||
|
const service = createWorkspaceScriptsService({
|
||||||
|
serviceProxy:
|
||||||
|
options.serviceProxy === undefined
|
||||||
|
? createServiceProxySubsystem({ logger })
|
||||||
|
: options.serviceProxy,
|
||||||
|
scriptRuntimeStore:
|
||||||
|
options.scriptRuntimeStore === undefined
|
||||||
|
? new WorkspaceScriptRuntimeStore()
|
||||||
|
: options.scriptRuntimeStore,
|
||||||
|
terminalManager:
|
||||||
|
options.terminalManager === undefined ? availableTerminalManager : options.terminalManager,
|
||||||
|
workspaceRegistry: fakeWorkspaceRegistry(workspace),
|
||||||
|
workspaceGitService: fakeGitService(),
|
||||||
|
getDaemonTcpPort: () => 6767,
|
||||||
|
getDaemonTcpHost: () => "127.0.0.1",
|
||||||
|
serviceProxyPublicBaseUrl: null,
|
||||||
|
resolveScriptHealth: null,
|
||||||
|
logger,
|
||||||
|
emit: (message) => emitted.push(message),
|
||||||
|
async spawnWorkspaceScript(spawnOptions): Promise<WorktreeScriptResult> {
|
||||||
|
spawnCalls.push(spawnOptions);
|
||||||
|
if (options.spawnThrows) {
|
||||||
|
throw new Error(options.spawnThrows);
|
||||||
|
}
|
||||||
|
spawnOptions.onLifecycleChanged?.();
|
||||||
|
return {
|
||||||
|
scriptName: spawnOptions.scriptName,
|
||||||
|
hostname: null,
|
||||||
|
port: null,
|
||||||
|
terminalId: "terminal-1",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { service, emitted, spawnCalls };
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: StartWorkspaceScriptRequest = {
|
||||||
|
type: "start_workspace_script_request",
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
scriptName: "app",
|
||||||
|
requestId: "req-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
while (tempDirs.length > 0) {
|
||||||
|
const dir = tempDirs.pop();
|
||||||
|
if (dir) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildSnapshot", () => {
|
||||||
|
test("returns no scripts when the service proxy is unavailable", () => {
|
||||||
|
const { service } = buildService({ serviceProxy: null });
|
||||||
|
expect(service.buildSnapshot("ws-1", "/tmp/repo")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns no scripts when the runtime store is unavailable", () => {
|
||||||
|
const { service } = buildService({ scriptRuntimeStore: null });
|
||||||
|
expect(service.buildSnapshot("ws-1", "/tmp/repo")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns no scripts for a workspace without a paseo.json", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "workspace-scripts-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
const { service } = buildService();
|
||||||
|
expect(service.buildSnapshot("ws-1", dir)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("emitStatusUpdate", () => {
|
||||||
|
test("emits one script_status_update carrying the snapshot", () => {
|
||||||
|
const { service, emitted } = buildService();
|
||||||
|
service.emitStatusUpdate("ws-1", "/tmp/repo");
|
||||||
|
expect(emitted).toEqual([
|
||||||
|
{ type: "script_status_update", payload: { workspaceId: "ws-1", scripts: [] } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("start", () => {
|
||||||
|
test("reports an error when workspace scripts are unavailable", async () => {
|
||||||
|
const { service, emitted, spawnCalls } = buildService({ terminalManager: null });
|
||||||
|
await service.start(request);
|
||||||
|
expect(spawnCalls).toEqual([]);
|
||||||
|
expect(emitted).toEqual([
|
||||||
|
{
|
||||||
|
type: "start_workspace_script_response",
|
||||||
|
payload: {
|
||||||
|
requestId: "req-1",
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
scriptName: "app",
|
||||||
|
terminalId: null,
|
||||||
|
error: "Workspace scripts are not available on this daemon",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports an error when the workspace is not found", async () => {
|
||||||
|
const { service, emitted, spawnCalls } = buildService({ workspace: null });
|
||||||
|
await service.start(request);
|
||||||
|
expect(spawnCalls).toEqual([]);
|
||||||
|
expect(emitted).toEqual([
|
||||||
|
{
|
||||||
|
type: "start_workspace_script_response",
|
||||||
|
payload: {
|
||||||
|
requestId: "req-1",
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
scriptName: "app",
|
||||||
|
terminalId: null,
|
||||||
|
error: "Workspace not found: ws-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("spawns the script with resolved git metadata and reports success", async () => {
|
||||||
|
const { service, emitted, spawnCalls } = buildService();
|
||||||
|
await service.start(request);
|
||||||
|
|
||||||
|
expect(spawnCalls).toHaveLength(1);
|
||||||
|
expect(spawnCalls[0]).toMatchObject({
|
||||||
|
repoRoot: "/tmp/repo",
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
projectSlug: "paseo",
|
||||||
|
branchName: "feature/scripts",
|
||||||
|
scriptName: "app",
|
||||||
|
daemonPort: 6767,
|
||||||
|
daemonListenHost: "127.0.0.1",
|
||||||
|
});
|
||||||
|
expect(emitted).toContainEqual({
|
||||||
|
type: "script_status_update",
|
||||||
|
payload: { workspaceId: "ws-1", scripts: [] },
|
||||||
|
});
|
||||||
|
expect(emitted).toContainEqual({
|
||||||
|
type: "start_workspace_script_response",
|
||||||
|
payload: {
|
||||||
|
requestId: "req-1",
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
scriptName: "app",
|
||||||
|
terminalId: "terminal-1",
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports the launcher error when spawning fails", async () => {
|
||||||
|
const { service, emitted } = buildService({ spawnThrows: "boom" });
|
||||||
|
await service.start(request);
|
||||||
|
expect(emitted).toEqual([
|
||||||
|
{
|
||||||
|
type: "start_workspace_script_response",
|
||||||
|
payload: {
|
||||||
|
requestId: "req-1",
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
scriptName: "app",
|
||||||
|
terminalId: null,
|
||||||
|
error: "boom",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import type pino from "pino";
|
||||||
|
import type {
|
||||||
|
SessionOutboundMessage,
|
||||||
|
StartWorkspaceScriptRequest,
|
||||||
|
WorkspaceDescriptorPayload,
|
||||||
|
} from "../../messages.js";
|
||||||
|
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
|
||||||
|
import type { ServiceProxySubsystem } from "../../service-proxy.js";
|
||||||
|
import type { WorkspaceScriptRuntimeStore } from "../../workspace-script-runtime-store.js";
|
||||||
|
import type { ScriptHealthState } from "../../script-health-monitor.js";
|
||||||
|
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||||
|
import type { WorkspaceRegistry } from "../../workspace-registry.js";
|
||||||
|
import type {
|
||||||
|
SpawnWorkspaceScriptOptions,
|
||||||
|
WorktreeScriptResult,
|
||||||
|
} from "../../worktree-bootstrap.js";
|
||||||
|
import {
|
||||||
|
buildWorkspaceScriptPayloads,
|
||||||
|
readPaseoConfigForProjection,
|
||||||
|
} from "../../script-status-projection.js";
|
||||||
|
import { deriveProjectSlug } from "../../workspace-git-metadata.js";
|
||||||
|
|
||||||
|
type WorkspaceScriptsPayload = WorkspaceDescriptorPayload["scripts"];
|
||||||
|
|
||||||
|
interface WorkspaceScriptGitMetadata {
|
||||||
|
projectSlug: string;
|
||||||
|
currentBranch: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The service-proxy-backed scripts a workspace exposes: build the scripts payload
|
||||||
|
* snapshot, emit a script_status_update to clients, and start a script.
|
||||||
|
*
|
||||||
|
* The workspace descriptor builder, the script-status emission path, and the
|
||||||
|
* start-script RPC all funnel through one assembly of buildWorkspaceScriptPayloads'
|
||||||
|
* inputs and one "scripts available on this daemon?" guard, instead of duplicating
|
||||||
|
* that assembly and guard across the session.
|
||||||
|
*/
|
||||||
|
export interface WorkspaceScriptsService {
|
||||||
|
buildSnapshot(workspaceId: string, workspaceDirectory: string): WorkspaceScriptsPayload;
|
||||||
|
emitStatusUpdate(workspaceId: string, workspaceDirectory: string): void;
|
||||||
|
start(request: StartWorkspaceScriptRequest): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkspaceScriptsGitSource = Pick<
|
||||||
|
WorkspaceGitService,
|
||||||
|
"peekSnapshot" | "getWorkspaceGitMetadata"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createWorkspaceScriptsService(deps: {
|
||||||
|
serviceProxy: ServiceProxySubsystem | null;
|
||||||
|
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
|
||||||
|
terminalManager: TerminalManager | null;
|
||||||
|
workspaceRegistry: Pick<WorkspaceRegistry, "get">;
|
||||||
|
workspaceGitService: WorkspaceScriptsGitSource;
|
||||||
|
getDaemonTcpPort: (() => number | null) | null;
|
||||||
|
getDaemonTcpHost: (() => string | null) | null;
|
||||||
|
serviceProxyPublicBaseUrl: string | null;
|
||||||
|
resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | null;
|
||||||
|
logger: pino.Logger;
|
||||||
|
emit: (message: SessionOutboundMessage) => void;
|
||||||
|
spawnWorkspaceScript: (options: SpawnWorkspaceScriptOptions) => Promise<WorktreeScriptResult>;
|
||||||
|
}): WorkspaceScriptsService {
|
||||||
|
const {
|
||||||
|
serviceProxy,
|
||||||
|
scriptRuntimeStore,
|
||||||
|
terminalManager,
|
||||||
|
workspaceRegistry,
|
||||||
|
workspaceGitService,
|
||||||
|
getDaemonTcpPort,
|
||||||
|
getDaemonTcpHost,
|
||||||
|
serviceProxyPublicBaseUrl,
|
||||||
|
resolveScriptHealth,
|
||||||
|
logger,
|
||||||
|
emit,
|
||||||
|
spawnWorkspaceScript,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
function resolveGitMetadata(workspaceDirectory: string): WorkspaceScriptGitMetadata | undefined {
|
||||||
|
const snapshot = workspaceGitService.peekSnapshot(workspaceDirectory);
|
||||||
|
if (!snapshot) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
projectSlug: deriveProjectSlug(
|
||||||
|
workspaceDirectory,
|
||||||
|
snapshot.git.isGit ? snapshot.git.remoteUrl : null,
|
||||||
|
),
|
||||||
|
currentBranch: snapshot.git.currentBranch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSnapshot(workspaceId: string, workspaceDirectory: string): WorkspaceScriptsPayload {
|
||||||
|
if (!serviceProxy || !scriptRuntimeStore) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return buildWorkspaceScriptPayloads({
|
||||||
|
workspaceId,
|
||||||
|
workspaceDirectory,
|
||||||
|
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger),
|
||||||
|
serviceProxy,
|
||||||
|
runtimeStore: scriptRuntimeStore,
|
||||||
|
daemonPort: getDaemonTcpPort?.() ?? null,
|
||||||
|
serviceProxyPublicBaseUrl,
|
||||||
|
gitMetadata: resolveGitMetadata(workspaceDirectory),
|
||||||
|
resolveHealth: resolveScriptHealth ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitStatusUpdate(workspaceId: string, workspaceDirectory: string): void {
|
||||||
|
emit({
|
||||||
|
type: "script_status_update",
|
||||||
|
payload: {
|
||||||
|
workspaceId,
|
||||||
|
scripts: buildSnapshot(workspaceId, workspaceDirectory),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(request: StartWorkspaceScriptRequest): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!terminalManager || !serviceProxy || !scriptRuntimeStore) {
|
||||||
|
throw new Error("Workspace scripts are not available on this daemon");
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await workspaceRegistry.get(request.workspaceId);
|
||||||
|
if (!workspace) {
|
||||||
|
throw new Error(`Workspace not found: ${request.workspaceId}`);
|
||||||
|
}
|
||||||
|
const gitMetadata = await workspaceGitService.getWorkspaceGitMetadata(workspace.cwd);
|
||||||
|
|
||||||
|
const serviceResult = await spawnWorkspaceScript({
|
||||||
|
repoRoot: workspace.cwd,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
projectSlug: gitMetadata.projectSlug,
|
||||||
|
branchName: gitMetadata.currentBranch,
|
||||||
|
scriptName: request.scriptName,
|
||||||
|
daemonPort: getDaemonTcpPort?.() ?? null,
|
||||||
|
daemonListenHost: getDaemonTcpHost?.() ?? null,
|
||||||
|
serviceProxyPublicBaseUrl,
|
||||||
|
serviceProxy,
|
||||||
|
runtimeStore: scriptRuntimeStore,
|
||||||
|
terminalManager,
|
||||||
|
logger,
|
||||||
|
onLifecycleChanged: () => {
|
||||||
|
emitStatusUpdate(workspace.workspaceId, workspace.cwd);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
emitStatusUpdate(workspace.workspaceId, workspace.cwd);
|
||||||
|
emit({
|
||||||
|
type: "start_workspace_script_response",
|
||||||
|
payload: {
|
||||||
|
requestId: request.requestId,
|
||||||
|
workspaceId: request.workspaceId,
|
||||||
|
scriptName: request.scriptName,
|
||||||
|
terminalId: serviceResult.terminalId,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to start workspace script";
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
err: error,
|
||||||
|
workspaceId: request.workspaceId,
|
||||||
|
scriptName: request.scriptName,
|
||||||
|
},
|
||||||
|
"Failed to start workspace script",
|
||||||
|
);
|
||||||
|
emit({
|
||||||
|
type: "start_workspace_script_response",
|
||||||
|
payload: {
|
||||||
|
requestId: request.requestId,
|
||||||
|
workspaceId: request.workspaceId,
|
||||||
|
scriptName: request.scriptName,
|
||||||
|
terminalId: null,
|
||||||
|
error: message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { buildSnapshot, emitStatusUpdate, start };
|
||||||
|
}
|
||||||
@@ -694,7 +694,7 @@ export interface WorktreeScriptResult {
|
|||||||
terminalId: string;
|
terminalId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SpawnWorkspaceScriptOptions {
|
export interface SpawnWorkspaceScriptOptions {
|
||||||
repoRoot: string;
|
repoRoot: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
projectSlug: string;
|
projectSlug: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user