feat(cli): manage workspace scripts (#1992)

* feat(cli): manage workspace scripts

* docs: document workspace script management

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Matt Cowger
2026-07-24 21:12:14 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent 21597bdc1b
commit 21404fbdec
22 changed files with 800 additions and 55 deletions
@@ -10,6 +10,7 @@ import {
AgentListItemPayloadSchema,
AgentPermissionResponseSchema,
AgentSnapshotPayloadSchema,
WorkspaceScriptPayloadSchema,
} from "../../messages.js";
import type { AgentListItemPayload } from "../../messages.js";
import {
@@ -74,6 +75,7 @@ import type {
WorkspaceRegistry,
} from "../../workspace-registry.js";
import { resolveWorktreeSourceCwd } from "../../workspace-source.js";
import type { WorkspaceScriptsService } from "../../session/workspace-scripts/workspace-scripts-service.js";
import {
type ArchiveCommandDependencies,
type CreatePaseoWorktreeCommandInput,
@@ -112,6 +114,7 @@ export interface PaseoToolHostDependencies {
title?: string | null,
projectId?: string,
) => Promise<PersistedWorkspaceRecord>;
workspaceScripts?: Pick<WorkspaceScriptsService, "list" | "launch" | "stop">;
markWorkspaceArchiving?: ArchiveDependencies["markWorkspaceArchiving"];
clearWorkspaceArchiving?: ArchiveDependencies["clearWorkspaceArchiving"];
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
@@ -537,6 +540,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
agentManager,
agentStorage,
terminalManager,
workspaceScripts,
scheduleService,
providerSnapshotManager,
callerAgentId,
@@ -2220,6 +2224,83 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
},
);
registerTool(
"list_workspace_scripts",
{
title: "List workspace scripts",
description:
"List configured workspace scripts and their lifecycle, service port, proxy URL, health, and terminal ID.",
inputSchema: {
workspaceId: z.string().describe("Workspace ID whose configured scripts to list."),
},
outputSchema: {
scripts: z.array(WorkspaceScriptPayloadSchema),
},
},
async ({ workspaceId }) => {
if (!workspaceScripts) {
throw new Error("Workspace script management is not configured");
}
return {
content: [],
structuredContent: ensureValidJson({ scripts: await workspaceScripts.list(workspaceId) }),
};
},
);
registerTool(
"start_workspace_script",
{
title: "Start workspace script",
description:
"Start one configured workspace script through Paseo's managed workspace-script launcher.",
inputSchema: {
workspaceId: z.string().describe("Workspace ID containing the configured script."),
scriptName: z.string().min(1).describe("Configured paseo.json script name to start."),
},
outputSchema: {
script: WorkspaceScriptPayloadSchema,
},
},
async ({ workspaceId, scriptName }) => {
if (!workspaceScripts) {
throw new Error("Workspace script management is not configured");
}
return {
content: [],
structuredContent: ensureValidJson({
script: await workspaceScripts.launch({ workspaceId, scriptName }),
}),
};
},
);
registerTool(
"stop_workspace_script",
{
title: "Stop workspace script",
description: "Stop a running workspace script through its supervised terminal lifecycle.",
inputSchema: {
workspaceId: z.string().describe("Workspace ID containing the running script."),
scriptName: z.string().min(1).describe("Configured paseo.json script name to stop."),
},
outputSchema: {
script: WorkspaceScriptPayloadSchema,
},
},
async ({ workspaceId, scriptName }) => {
if (!workspaceScripts) {
throw new Error("Workspace script management is not configured");
}
return {
content: [],
structuredContent: ensureValidJson({
script: await workspaceScripts.stop({ workspaceId, scriptName }),
}),
};
},
);
registerTool(
"list_terminals",
{
+21 -1
View File
@@ -171,12 +171,14 @@ import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
} from "./agent/provider-launch-config.js";
import type { PersistedConfig } from "./persisted-config.js";
import { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js";
import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js";
import { ScriptHealthMonitor } from "./script-health-monitor.js";
import { createScriptStatusEmitter } from "./script-status-projection.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import { createWorkspaceScriptsService } from "./session/workspace-scripts/workspace-scripts-service.js";
import { spawnWorkspaceScript } from "./worktree-bootstrap.js";
import {
createManagedProcessRegistry,
createSystemManagedProcessTable,
@@ -1250,6 +1252,24 @@ export async function createPaseoDaemon(
await emitWorkspaceUpdatesExternal([workspace.workspaceId]);
return workspace;
},
workspaceScripts: createWorkspaceScriptsService({
serviceProxy,
scriptRuntimeStore,
terminalManager,
workspaceRegistry,
projectRegistry,
workspaceGitService,
getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
getDaemonTcpHost: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null),
serviceProxyPublicBaseUrl,
resolveScriptHealth: (hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
logger,
// MCP operations do not belong to one WebSocket session, so lifecycle
// status updates fan out to every connected client.
emit: (message) => wsServer?.broadcast(wrapSessionMessage(message)),
spawnWorkspaceScript,
globalServicePorts: loadPersistedConfig(config.paseoHome).worktrees?.servicePorts,
}),
markWorkspaceArchiving: markWorkspaceArchivingExternal,
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
ensureWorkspaceForCreate: createAgentCommandDependencies.ensureWorkspaceForCreate,
+99 -3
View File
@@ -13,6 +13,9 @@ import {
type SessionOutboundMessage,
type GitSetupOptions,
type StartWorkspaceScriptRequest,
type WorkspaceScriptListRequest,
type WorkspaceScriptStartRequest,
type WorkspaceScriptStopRequest,
type CloseItemsRequest,
type DirectorySuggestionsRequest,
type ProjectPlacementPayload,
@@ -2157,10 +2160,18 @@ export class Session {
}
private dispatchTerminalMessage(msg: SessionInboundMessage): Promise<void> | undefined {
if (msg.type === "start_workspace_script_request") {
return this.handleStartWorkspaceScriptRequest(msg);
switch (msg.type) {
case "start_workspace_script_request":
return this.handleStartWorkspaceScriptRequest(msg);
case "workspace.script.list.request":
return this.handleWorkspaceScriptListRequest(msg);
case "workspace.script.start.request":
return this.handleWorkspaceScriptStartRequest(msg);
case "workspace.script.stop.request":
return this.handleWorkspaceScriptStopRequest(msg);
default:
return this.terminalController.dispatch(msg);
}
return this.terminalController.dispatch(msg);
}
// eslint-disable-next-line complexity
@@ -5496,6 +5507,91 @@ export class Session {
return this.workspaceScripts.start(request);
}
private async handleWorkspaceScriptListRequest(
request: WorkspaceScriptListRequest,
): Promise<void> {
try {
const scripts = await this.workspaceScripts.list(request.workspaceId);
this.emit({
type: "workspace.script.list.response",
payload: {
requestId: request.requestId,
workspaceId: request.workspaceId,
scripts,
error: null,
},
});
} catch (error) {
this.emit({
type: "workspace.script.list.response",
payload: {
requestId: request.requestId,
workspaceId: request.workspaceId,
scripts: [],
error: error instanceof Error ? error.message : "Failed to list workspace scripts",
},
});
}
}
private async handleWorkspaceScriptStartRequest(
request: WorkspaceScriptStartRequest,
): Promise<void> {
try {
const script = await this.workspaceScripts.launch(request);
this.emit({
type: "workspace.script.start.response",
payload: {
requestId: request.requestId,
workspaceId: request.workspaceId,
scriptName: request.scriptName,
script,
error: null,
},
});
} catch (error) {
this.emit({
type: "workspace.script.start.response",
payload: {
requestId: request.requestId,
workspaceId: request.workspaceId,
scriptName: request.scriptName,
script: null,
error: error instanceof Error ? error.message : "Failed to start workspace script",
},
});
}
}
private async handleWorkspaceScriptStopRequest(
request: WorkspaceScriptStopRequest,
): Promise<void> {
try {
const script = await this.workspaceScripts.stop(request);
this.emit({
type: "workspace.script.stop.response",
payload: {
requestId: request.requestId,
workspaceId: request.workspaceId,
scriptName: request.scriptName,
script,
error: null,
},
});
} catch (error) {
this.emit({
type: "workspace.script.stop.response",
payload: {
requestId: request.requestId,
workspaceId: request.workspaceId,
scriptName: request.scriptName,
script: null,
error: error instanceof Error ? error.message : "Failed to stop workspace script",
},
});
}
}
// COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs.
private async handleLegacyListAvailableEditorsRequest(
request: Extract<SessionInboundMessage, { type: "list_available_editors_request" }>,
@@ -220,6 +220,54 @@ describe("emitStatusUpdate", () => {
});
});
describe("stop", () => {
test("kills the supervised terminal and returns the stopped service metadata", async () => {
const dir = mkdtempSync(join(tmpdir(), "workspace-scripts-"));
tempDirs.push(dir);
writeFileSync(
join(dir, "paseo.json"),
JSON.stringify({ scripts: { web: { type: "service", command: "npm run web", port: 3000 } } }),
);
const runtimeStore = new WorkspaceScriptRuntimeStore();
runtimeStore.set({
workspaceId: "ws-1",
scriptName: "web",
type: "service",
lifecycle: "running",
terminalId: "terminal-1",
exitCode: null,
});
const terminalManager = {
getTerminal: (terminalId: string) => (terminalId === "terminal-1" ? {} : undefined),
async killTerminalAndWait(terminalId: string) {
expect(terminalId).toBe("terminal-1");
runtimeStore.set({
workspaceId: "ws-1",
scriptName: "web",
type: "service",
lifecycle: "stopped",
terminalId,
exitCode: 143,
});
},
} as unknown as TerminalManager;
const { service } = buildService({
workspace: { workspaceId: "ws-1", cwd: dir } as PersistedWorkspaceRecord,
scriptRuntimeStore: runtimeStore,
terminalManager,
});
await expect(service.stop({ workspaceId: "ws-1", scriptName: "web" })).resolves.toMatchObject({
scriptName: "web",
type: "service",
port: 3000,
lifecycle: "stopped",
exitCode: 143,
terminalId: "terminal-1",
});
});
});
describe("start", () => {
test("reports an error when workspace scripts are unavailable", async () => {
const { service, emitted, spawnCalls } = buildService({ terminalManager: null });
@@ -3,6 +3,7 @@ import type {
SessionOutboundMessage,
StartWorkspaceScriptRequest,
WorkspaceDescriptorPayload,
WorkspaceScriptPayload,
} from "../../messages.js";
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
import type { ServiceProxySubsystem } from "../../service-proxy.js";
@@ -43,6 +44,9 @@ export interface WorkspaceScriptsService {
project?: PersistedProjectRecord | null,
): WorkspaceScriptsPayload;
emitStatusUpdate(workspaceId: string, workspaceDirectory: string): Promise<void>;
list(workspaceId: string): Promise<WorkspaceScriptPayload[]>;
launch(input: { workspaceId: string; scriptName: string }): Promise<WorkspaceScriptPayload>;
stop(input: { workspaceId: string; scriptName: string }): Promise<WorkspaceScriptPayload>;
start(request: StartWorkspaceScriptRequest): Promise<void>;
}
@@ -93,11 +97,10 @@ export function createWorkspaceScriptsService(deps: {
currentBranch,
};
}
if (!snapshot) return undefined;
return {
projectSlug: deriveProjectSlug(
workspace.cwd,
snapshot.git.isGit ? snapshot.git.remoteUrl : null,
snapshot?.git.isGit ? snapshot.git.remoteUrl : null,
),
currentBranch,
};
@@ -137,47 +140,104 @@ export function createWorkspaceScriptsService(deps: {
}
}
async function getWorkspace(workspaceId: string) {
const workspace = await workspaceRegistry.get(workspaceId);
if (!workspace) {
throw new Error(`Workspace not found: ${workspaceId}`);
}
return workspace;
}
function requireAvailable(): {
serviceProxy: ServiceProxySubsystem;
runtimeStore: WorkspaceScriptRuntimeStore;
terminalManager: TerminalManager;
} {
if (!terminalManager || !serviceProxy || !scriptRuntimeStore) {
throw new Error("Workspace scripts are not available on this daemon");
}
return { serviceProxy, runtimeStore: scriptRuntimeStore, terminalManager };
}
async function list(workspaceId: string): Promise<WorkspaceScriptPayload[]> {
requireAvailable();
const workspace = await getWorkspace(workspaceId);
const project = await projectRegistry.get(workspace.projectId);
return buildSnapshot(workspace, project);
}
async function launchProcess(input: { workspaceId: string; scriptName: string }) {
const available = requireAvailable();
const workspace = await getWorkspace(input.workspaceId);
const project = await projectRegistry.get(workspace.projectId);
const gitMetadata = resolveGitMetadata(workspace, project);
const result = await spawnWorkspaceScript({
repoRoot: workspace.cwd,
workspaceId: workspace.workspaceId,
projectSlug: gitMetadata.projectSlug,
branchName: gitMetadata.currentBranch,
scriptName: input.scriptName,
daemonPort: getDaemonTcpPort?.() ?? null,
daemonListenHost: getDaemonTcpHost?.() ?? null,
serviceProxyPublicBaseUrl,
serviceProxy: available.serviceProxy,
runtimeStore: available.runtimeStore,
terminalManager: available.terminalManager,
globalServicePorts,
logger,
onLifecycleChanged: () => {
void emitStatusUpdate(workspace.workspaceId, workspace.cwd);
},
});
return { workspace, project, terminalId: result.terminalId };
}
async function launch(input: {
workspaceId: string;
scriptName: string;
}): Promise<WorkspaceScriptPayload> {
const { workspace, project } = await launchProcess(input);
const script = buildSnapshot(workspace, project).find(
(entry) => entry.scriptName === input.scriptName,
);
if (!script) {
throw new Error(`Script '${input.scriptName}' did not produce a status record`);
}
void emitStatusUpdate(workspace.workspaceId, workspace.cwd);
return script;
}
async function stop(input: {
workspaceId: string;
scriptName: string;
}): Promise<WorkspaceScriptPayload> {
const available = requireAvailable();
const workspace = await getWorkspace(input.workspaceId);
const project = await projectRegistry.get(workspace.projectId);
const runtime = available.runtimeStore.get(input);
if (!runtime || runtime.lifecycle !== "running") {
throw new Error(`Script '${input.scriptName}' is not running`);
}
if (!available.terminalManager.getTerminal(runtime.terminalId)) {
throw new Error(`Terminal for script '${input.scriptName}' is no longer available`);
}
// The launcher's terminal exit listener owns route removal and runtime state updates.
await available.terminalManager.killTerminalAndWait(runtime.terminalId);
const script = buildSnapshot(workspace, project).find(
(entry) => entry.scriptName === input.scriptName,
);
if (!script) {
throw new Error(`Script '${input.scriptName}' did not produce a status record`);
}
void emitStatusUpdate(workspace.workspaceId, workspace.cwd);
return script;
}
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 project = await projectRegistry.get(workspace.projectId);
const projectSlug = project
? deriveProjectServiceSlug(project)
: deriveProjectSlug(
workspace.cwd,
workspaceGitService.peekSnapshot(workspace.cwd)?.git.remoteUrl ?? null,
);
const branchName =
workspaceGitService.peekSnapshot(workspace.cwd)?.git.currentBranch ??
workspace.branch ??
null;
const serviceResult = await spawnWorkspaceScript({
repoRoot: workspace.cwd,
workspaceId: workspace.workspaceId,
projectSlug,
branchName,
scriptName: request.scriptName,
daemonPort: getDaemonTcpPort?.() ?? null,
daemonListenHost: getDaemonTcpHost?.() ?? null,
serviceProxyPublicBaseUrl,
serviceProxy,
runtimeStore: scriptRuntimeStore,
terminalManager,
globalServicePorts,
logger,
onLifecycleChanged: () => {
void emitStatusUpdate(workspace.workspaceId, workspace.cwd);
},
});
const { workspace, terminalId } = await launchProcess(request);
void emitStatusUpdate(workspace.workspaceId, workspace.cwd);
emit({
type: "start_workspace_script_response",
@@ -185,18 +245,14 @@ export function createWorkspaceScriptsService(deps: {
requestId: request.requestId,
workspaceId: request.workspaceId,
scriptName: request.scriptName,
terminalId: serviceResult.terminalId,
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,
},
{ err: error, workspaceId: request.workspaceId, scriptName: request.scriptName },
"Failed to start workspace script",
);
emit({
@@ -212,5 +268,5 @@ export function createWorkspaceScriptsService(deps: {
}
}
return { buildSnapshot, emitStatusUpdate, start };
return { buildSnapshot, emitStatusUpdate, list, launch, stop, start };
}
@@ -1445,6 +1445,8 @@ export class VoiceAssistantWebSocketServer {
selectiveAgentTimeline: true,
// COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15.
stableProjectIdentity: true,
// COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10.
workspaceScriptManagement: true,
},
};
}