Skip to content

Commit d3643e8

Browse files
committed
feat(workflow): expose read-only app snapshots
1 parent dbe26f7 commit d3643e8

2 files changed

Lines changed: 208 additions & 5 deletions

File tree

src/server.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ import { createWorkspaceStore } from "./workspace-store.js";
4848
import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js";
4949
import { summarizeLocalAgentProfile } from "./local-agent-profiles.js";
5050
import { registerWorkflowTools } from "./workflow-tools.js";
51+
import { createWorkflowStore } from "./workflow-store.js";
52+
import { loadActiveWorkflowSummaries } from "./workflow-ui.js";
5153
import {
5254
formatLocalAgentProviderAvailabilitySummary,
5355
getLocalAgentProviderAvailabilitySnapshot,
@@ -264,6 +266,24 @@ const workspaceAvailableAgentsFileOutputSchema = z.object({
264266
path: z.string(),
265267
});
266268

269+
const workflowCallCountsOutputSchema = z.object({
270+
running: z.number(),
271+
completed: z.number(),
272+
cached: z.number(),
273+
failed: z.number(),
274+
cancelled: z.number(),
275+
observed: z.number(),
276+
});
277+
278+
const workflowRunSummaryOutputSchema = z.object({
279+
id: z.string(),
280+
name: z.string(),
281+
status: z.enum(["starting", "running", "completed", "failed", "cancelled"]),
282+
currentPhase: z.string().optional(),
283+
calls: workflowCallCountsOutputSchema,
284+
updatedAt: z.string(),
285+
});
286+
267287
const reviewFileOutputSchema = z.object({
268288
path: z.string(),
269289
previousPath: z.string().optional(),
@@ -779,6 +799,7 @@ function createMcpServer(
779799
agentProviders: z.array(workspaceLocalAgentProviderOutputSchema),
780800
agents: z.array(workspaceLocalAgentOutputSchema),
781801
skillDiagnostics: z.array(z.unknown()),
802+
activeWorkflows: z.array(workflowRunSummaryOutputSchema),
782803
instruction: z.string(),
783804
},
784805
...toolWidgetDescriptorMeta(config, "workspace"),
@@ -817,6 +838,16 @@ function createMcpServer(
817838
const availableAgentsFileOutputs = availableAgentsFiles.map((file) => ({
818839
path: formatAgentsPath(file.path, workspace.root),
819840
}));
841+
const activeWorkflows = config.subagents
842+
? (() => {
843+
const workflowStore = createWorkflowStore(config);
844+
try {
845+
return loadActiveWorkflowSummaries(workflowStore, workspace.root);
846+
} finally {
847+
workflowStore.close();
848+
}
849+
})()
850+
: [];
820851
const instruction = config.skillsEnabled
821852
? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding."
822853
: "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file.";
@@ -870,6 +901,7 @@ function createMcpServer(
870901
agentsFiles: loadedAgentsFiles.length,
871902
availableAgentsFiles: availableAgentsFileOutputs.length,
872903
skills: visibleSkills.length,
904+
activeWorkflows: activeWorkflows.length,
873905
agentProviders: visibleAgentProviders.length,
874906
agents: visibleAgents.length,
875907
skillDiagnostics: workspace.skillDiagnostics.length,
@@ -885,6 +917,7 @@ function createMcpServer(
885917
agentsFiles: loadedAgentsFiles,
886918
availableAgentsFiles: availableAgentsFileOutputs,
887919
skills: visibleSkills,
920+
activeWorkflows,
888921
agentProviders: visibleAgentProviders,
889922
agents: visibleAgents,
890923
skillDiagnostics: workspace.skillDiagnostics,

src/workflow-tools.ts

Lines changed: 175 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ import {
3434
WorkflowNotFoundError,
3535
WorkflowStoredDataError,
3636
} from "./workflow-errors.js";
37+
import {
38+
loadWorkflowUiCallDetail,
39+
loadWorkflowUiProject,
40+
loadWorkflowUiRun,
41+
} from "./workflow-ui.js";
42+
43+
const WORKSPACE_APP_URI = "ui://devspace/workspace-app.html";
44+
const WORKFLOW_UI_WAIT_MAX_MS = 30_000;
3745

3846
const WORKFLOW_API_CHEATSHEET = `
3947
Workflow scripts (JS only):
@@ -84,7 +92,7 @@ export function registerWorkflowTools(
8492
.describe(`Ms to wait for early completion (default 2000, max ${WORKFLOW_MCP_YIELD_MS}).`),
8593
},
8694
annotations: { readOnlyHint: false },
87-
_meta: {},
95+
_meta: workflowWidgetMeta(config),
8896
},
8997
async ({ workspaceId, script, name, scriptPath, resumeFromRunId, args, yieldTimeMs }) => {
9098
const workspace = workspaces.getWorkspace(workspaceId);
@@ -200,7 +208,7 @@ export function registerWorkflowTools(
200208

201209
const yieldMs = yieldTimeMs ?? 2_000;
202210
const page = await yieldEvents(store, run.id, 0, yieldMs);
203-
return toolResult(page);
211+
return toolResult(page, "run_workflow");
204212
} catch (error) {
205213
if (isWorkflowOperationError(error)) return workflowToolError(error);
206214
throw error;
@@ -228,14 +236,14 @@ export function registerWorkflowTools(
228236
.describe(`Long-poll ms (default 0, max ${WORKFLOW_MCP_YIELD_MS}).`),
229237
},
230238
annotations: { readOnlyHint: true },
231-
_meta: {},
239+
_meta: workflowWidgetMeta(config),
232240
},
233241
async ({ runId, sinceSeq, yieldTimeMs }) => {
234242
const store = createWorkflowStore(config);
235243
try {
236244
if (!store.getRun(runId)) throw new WorkflowNotFoundError(runId);
237245
const page = await yieldEvents(store, runId, sinceSeq ?? 0, yieldTimeMs ?? 0);
238-
return toolResult(page);
246+
return toolResult(page, "workflow_status");
239247
} catch (error) {
240248
if (isWorkflowOperationError(error)) return workflowToolError(error);
241249
throw error;
@@ -284,7 +292,104 @@ export function registerWorkflowTools(
284292
},
285293
);
286294

295+
if (config.widgets !== "off") {
296+
registerWorkflowUiTools(server, config, workspaces);
287297
}
298+
}
299+
300+
function registerWorkflowUiTools(
301+
server: McpServer,
302+
config: ServerConfig,
303+
workspaces: WorkspaceRegistry,
304+
): void {
305+
registerAppTool(
306+
server,
307+
"workspace_workflow_activity",
308+
{
309+
title: "Workspace workflow activity",
310+
description: "Read-only workflow activity for the DevSpace app.",
311+
inputSchema: {
312+
workspaceId: z.string(),
313+
knownVersion: z.string().optional(),
314+
waitMs: z.number().int().min(0).max(WORKFLOW_UI_WAIT_MAX_MS).optional(),
315+
},
316+
annotations: { readOnlyHint: true },
317+
_meta: appOnlyToolMeta(),
318+
},
319+
async ({ workspaceId, knownVersion, waitMs }) => {
320+
const workspace = workspaces.getWorkspace(workspaceId);
321+
const store = createWorkflowStore(config);
322+
try {
323+
const project = await waitForProjectSnapshot(
324+
store,
325+
workspace.root,
326+
knownVersion,
327+
waitMs ?? 0,
328+
);
329+
return appToolResult({ workspaceId, project });
330+
} finally {
331+
store.close();
332+
}
333+
},
334+
);
335+
336+
registerAppTool(
337+
server,
338+
"workflow_ui_snapshot",
339+
{
340+
title: "Workflow UI snapshot",
341+
description: "Read-only workflow snapshot for the DevSpace app.",
342+
inputSchema: {
343+
runId: z.string(),
344+
knownVersion: z.string().optional(),
345+
waitMs: z.number().int().min(0).max(WORKFLOW_UI_WAIT_MAX_MS).optional(),
346+
},
347+
annotations: { readOnlyHint: true },
348+
_meta: appOnlyToolMeta(),
349+
},
350+
async ({ runId, knownVersion, waitMs }) => {
351+
const store = createWorkflowStore(config);
352+
try {
353+
const run = await waitForRunSnapshot(store, runId, knownVersion, waitMs ?? 0);
354+
if (!run) throw new WorkflowNotFoundError(runId);
355+
return appToolResult({ run });
356+
} finally {
357+
store.close();
358+
}
359+
},
360+
);
361+
362+
registerAppTool(
363+
server,
364+
"workflow_ui_call_detail",
365+
{
366+
title: "Workflow call detail",
367+
description: "Read-only workflow call detail for the DevSpace app.",
368+
inputSchema: {
369+
runId: z.string(),
370+
callIndex: z.number().int().min(0),
371+
},
372+
annotations: { readOnlyHint: true },
373+
_meta: appOnlyToolMeta(),
374+
},
375+
async ({ runId, callIndex }) => {
376+
const store = createWorkflowStore(config);
377+
try {
378+
if (!store.getRun(runId)) throw new WorkflowNotFoundError(runId);
379+
const call = loadWorkflowUiCallDetail(store, runId, callIndex);
380+
if (!call) {
381+
throw new InvalidWorkflowInputError({
382+
code: "invalid_argument",
383+
message: `Unknown workflow agent call: ${runId}#${callIndex}`,
384+
});
385+
}
386+
return appToolResult({ call });
387+
} finally {
388+
store.close();
389+
}
390+
},
391+
);
392+
}
288393

289394
async function yieldEvents(
290395
store: ReturnType<typeof createWorkflowStore>,
@@ -329,7 +434,7 @@ function toolResult(page: {
329434
nextSeq: number;
330435
terminal: boolean;
331436
callSummary: ReturnType<typeof summarizeCalls>;
332-
}) {
437+
}, tool: "run_workflow" | "workflow_status") {
333438
const payload = {
334439
runId: page.run.id,
335440
status: page.run.status,
@@ -354,9 +459,74 @@ function toolResult(page: {
354459
return {
355460
content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }],
356461
structuredContent: payload,
462+
_meta: {
463+
tool,
464+
card: {
465+
runId: page.run.id,
466+
status: page.run.status,
467+
name: page.run.name,
468+
},
469+
},
470+
};
471+
}
472+
473+
function workflowWidgetMeta(config: ServerConfig) {
474+
if (config.widgets !== "full") return {};
475+
return {
476+
ui: {
477+
resourceUri: WORKSPACE_APP_URI,
478+
visibility: ["model"] as const,
479+
},
480+
};
481+
}
482+
483+
function appOnlyToolMeta() {
484+
return {
485+
ui: {
486+
visibility: ["app"] as const,
487+
},
357488
};
358489
}
359490

491+
function appToolResult(structuredContent: Record<string, unknown>) {
492+
return {
493+
content: [{ type: "text" as const, text: JSON.stringify(structuredContent) }],
494+
structuredContent,
495+
};
496+
}
497+
498+
async function waitForProjectSnapshot(
499+
store: ReturnType<typeof createWorkflowStore>,
500+
workspaceRoot: string,
501+
knownVersion: string | undefined,
502+
waitMs: number,
503+
) {
504+
const deadline = Date.now() + Math.min(waitMs, WORKFLOW_UI_WAIT_MAX_MS);
505+
for (;;) {
506+
const project = loadWorkflowUiProject(store, workspaceRoot);
507+
if (!knownVersion || project.version !== knownVersion || Date.now() >= deadline) {
508+
return project;
509+
}
510+
await sleep(250);
511+
}
512+
}
513+
514+
async function waitForRunSnapshot(
515+
store: ReturnType<typeof createWorkflowStore>,
516+
runId: string,
517+
knownVersion: string | undefined,
518+
waitMs: number,
519+
) {
520+
const deadline = Date.now() + Math.min(waitMs, WORKFLOW_UI_WAIT_MAX_MS);
521+
for (;;) {
522+
const run = loadWorkflowUiRun(store, runId);
523+
if (!run || !knownVersion || run.version !== knownVersion || Date.now() >= deadline) {
524+
return run;
525+
}
526+
await sleep(250);
527+
}
528+
}
529+
360530
function summarizeCalls(calls: ReturnType<ReturnType<typeof createWorkflowStore>["listAgentCalls"]>) {
361531
return {
362532
reused: calls.filter((call) => call.fromCache).length,

0 commit comments

Comments
 (0)