mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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 { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.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 { spawnWorkspaceScript } from "./worktree-bootstrap.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 { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
|
||||
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
|
||||
@@ -589,6 +588,7 @@ export class Session {
|
||||
private readonly agentConfigSession: AgentConfigSession;
|
||||
private readonly projectConfigSession: ProjectConfigSession;
|
||||
private readonly daemonSession: DaemonSession;
|
||||
private readonly workspaceScripts: WorkspaceScriptsService;
|
||||
private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch;
|
||||
|
||||
constructor(options: SessionOptions) {
|
||||
@@ -848,6 +848,20 @@ export class Session {
|
||||
this.getDaemonTcpHost = getDaemonTcpHost ?? null;
|
||||
this.serviceProxyPublicBaseUrl = serviceProxyPublicBaseUrl ?? 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.workspaceDirectory = new WorkspaceDirectory({
|
||||
logger: this.sessionLogger,
|
||||
@@ -3688,20 +3702,7 @@ export class Session {
|
||||
statusEnteredAt: null,
|
||||
activityAt: null,
|
||||
diffStat,
|
||||
scripts:
|
||||
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,
|
||||
})
|
||||
: [],
|
||||
scripts: this.buildWorkspaceScriptPayloadSnapshot(workspace.workspaceId, workspace.cwd),
|
||||
...(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(
|
||||
workspaceId: string,
|
||||
workspaceDirectory: string,
|
||||
): WorkspaceDescriptorPayload["scripts"] {
|
||||
if (!this.serviceProxy || !this.scriptRuntimeStore) {
|
||||
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,
|
||||
});
|
||||
return this.workspaceScripts.buildSnapshot(workspaceId, workspaceDirectory);
|
||||
}
|
||||
|
||||
private resolveWorkspaceScriptGitMetadata(
|
||||
workspaceDirectory: string,
|
||||
): { 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
private handleStartWorkspaceScriptRequest(request: StartWorkspaceScriptRequest): Promise<void> {
|
||||
return this.workspaceScripts.start(request);
|
||||
}
|
||||
|
||||
// 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,
|
||||
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
||||
onScriptsChanged: (workspaceId, workspaceDirectory) => {
|
||||
this.emitWorkspaceScriptStatusUpdate(workspaceId, workspaceDirectory);
|
||||
this.workspaceScripts.emitStatusUpdate(workspaceId, workspaceDirectory);
|
||||
},
|
||||
},
|
||||
input,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
interface SpawnWorkspaceScriptOptions {
|
||||
export interface SpawnWorkspaceScriptOptions {
|
||||
repoRoot: string;
|
||||
workspaceId: string;
|
||||
projectSlug: string;
|
||||
|
||||
Reference in New Issue
Block a user