|
| 1 | +import { fileURLToPath } from "node:url"; |
| 2 | +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 3 | +import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; |
| 4 | +import * as z from "zod/v4"; |
| 5 | +import type { ServerConfig } from "./config.js"; |
| 6 | +import type { WorkspaceRegistry } from "./workspaces.js"; |
| 7 | +import { |
| 8 | + persistWorkflowScript, |
| 9 | + resolveNamedWorkflowScript, |
| 10 | + readWorkflowScriptFile, |
| 11 | +} from "./workflow-files.js"; |
| 12 | +import { parseWorkflowScript } from "./workflow-script.js"; |
| 13 | +import { createWorkflowStore } from "./workflow-store.js"; |
| 14 | +import { |
| 15 | + WORKFLOW_MCP_YIELD_MS, |
| 16 | + WORKFLOW_LIMITS, |
| 17 | + type AgentProvidersConfig, |
| 18 | + type WorkflowEventRecord, |
| 19 | + type WorkflowRunRecord, |
| 20 | +} from "./workflow-types.js"; |
| 21 | +import { resolveWorkspaceHead } from "./workflow-worktrees.js"; |
| 22 | +import { spawnWorkflowWorkerFromCli } from "./workflow-cli.js"; |
| 23 | +import { getLocalAgentProviderAvailabilitySnapshot } from "./local-agent-availability.js"; |
| 24 | +import { |
| 25 | + isLocalAgentProvider, |
| 26 | + LOCAL_AGENT_PROVIDERS, |
| 27 | + type LocalAgentProvider, |
| 28 | +} from "./local-agent-profiles.js"; |
| 29 | + |
| 30 | +const WORKFLOW_API_CHEATSHEET = ` |
| 31 | +Workflow scripts (JS only): |
| 32 | + export const meta = { name, description, phases?, defaultProvider?, concurrency? } |
| 33 | + agent(prompt, { label?, phase?, schema?, model?, effort?, provider?, isolation?: 'worktree' }) |
| 34 | + parallel(thunks) → Array<T|null> // barrier; throw → null |
| 35 | + pipeline(items, ...stages) // no cross-item barrier |
| 36 | + phase(title); log(msg); args; budget (stub) |
| 37 | + workflow(name | { scriptPath }, args?) // nest depth 1 |
| 38 | +Bans: Date.now(), Math.random(), new Date() without args. |
| 39 | +No writeMode — teach RO vs write in prompts; isolation contains writes. |
| 40 | +`.trim(); |
| 41 | + |
| 42 | +export function registerWorkflowTools( |
| 43 | + server: McpServer, |
| 44 | + config: ServerConfig, |
| 45 | + workspaces: WorkspaceRegistry, |
| 46 | +): void { |
| 47 | + if (!config.subagents) return; |
| 48 | + |
| 49 | + registerAppTool( |
| 50 | + server, |
| 51 | + "run_workflow", |
| 52 | + { |
| 53 | + title: "Run workflow", |
| 54 | + description: |
| 55 | + `Start a DevSpace Dynamic Workflow in an open workspace. Prefer named scripts or short inline scripts. ` + |
| 56 | + `Poll with workflow_status until terminal. Cancel with workflow_cancel. ${WORKFLOW_API_CHEATSHEET}`, |
| 57 | + inputSchema: { |
| 58 | + workspaceId: z.string().describe("Workspace id from open_workspace."), |
| 59 | + script: z |
| 60 | + .string() |
| 61 | + .optional() |
| 62 | + .describe("Inline workflow script source (export const meta = …)."), |
| 63 | + name: z.string().optional().describe("Named workflow under .devspace/workflows/<name>.js"), |
| 64 | + resumeFromRunId: z.string().optional().describe("Prior run id to resume (new run + cache)."), |
| 65 | + args: z.unknown().optional().describe("Args object/array passed to script as `args`."), |
| 66 | + yieldTimeMs: z |
| 67 | + .number() |
| 68 | + .int() |
| 69 | + .min(0) |
| 70 | + .max(WORKFLOW_MCP_YIELD_MS) |
| 71 | + .optional() |
| 72 | + .describe(`Ms to wait for early completion (default 2000, max ${WORKFLOW_MCP_YIELD_MS}).`), |
| 73 | + }, |
| 74 | + annotations: { readOnlyHint: false }, |
| 75 | + _meta: {}, |
| 76 | + }, |
| 77 | + async ({ workspaceId, script, name, resumeFromRunId, args, yieldTimeMs }) => { |
| 78 | + const workspace = workspaces.getWorkspace(workspaceId); |
| 79 | + const store = createWorkflowStore(config); |
| 80 | + try { |
| 81 | + const provided = [script, name, resumeFromRunId].filter((v) => v !== undefined); |
| 82 | + if (provided.length !== 1) { |
| 83 | + throw new Error("Provide exactly one of script, name, or resumeFromRunId"); |
| 84 | + } |
| 85 | + |
| 86 | + let source: string; |
| 87 | + let scriptHash: string; |
| 88 | + let nameHint: string; |
| 89 | + let priorRunId: string | undefined; |
| 90 | + let priorScriptPath: string | undefined; |
| 91 | + let runSource: "inline" | "named" | "resume" = "inline"; |
| 92 | + |
| 93 | + if (resumeFromRunId) { |
| 94 | + const prior = store.getRun(resumeFromRunId); |
| 95 | + if (!prior) throw new Error(`Unknown run: ${resumeFromRunId}`); |
| 96 | + priorRunId = prior.id; |
| 97 | + priorScriptPath = prior.scriptPath; |
| 98 | + const resolved = await readWorkflowScriptFile(prior.scriptPath); |
| 99 | + source = resolved.source; |
| 100 | + scriptHash = prior.scriptHash; |
| 101 | + nameHint = prior.name; |
| 102 | + runSource = "resume"; |
| 103 | + if (args === undefined && prior.argsJson && prior.argsJson !== "null") { |
| 104 | + try { |
| 105 | + args = JSON.parse(prior.argsJson); |
| 106 | + } catch { |
| 107 | + // keep undefined |
| 108 | + } |
| 109 | + } |
| 110 | + } else if (name) { |
| 111 | + const resolved = await resolveNamedWorkflowScript({ |
| 112 | + name, |
| 113 | + workspaceRoot: workspace.root, |
| 114 | + stateDir: config.stateDir, |
| 115 | + }); |
| 116 | + source = resolved.source; |
| 117 | + scriptHash = resolved.scriptHash; |
| 118 | + nameHint = resolved.nameHint; |
| 119 | + runSource = "named"; |
| 120 | + } else { |
| 121 | + source = script!; |
| 122 | + const parsed = parseWorkflowScript(source); |
| 123 | + scriptHash = parsed.scriptHash; |
| 124 | + nameHint = parsed.meta.name; |
| 125 | + runSource = "inline"; |
| 126 | + } |
| 127 | + |
| 128 | + const parsed = parseWorkflowScript(source); |
| 129 | + const baseSha = await resolveWorkspaceHead(workspace.root); |
| 130 | + const run = store.createRun({ |
| 131 | + name: parsed.meta.name || nameHint, |
| 132 | + source: runSource, |
| 133 | + scriptPath: priorScriptPath ?? "pending", |
| 134 | + scriptHash, |
| 135 | + workspaceRoot: workspace.root, |
| 136 | + workspaceId, |
| 137 | + argsJson: JSON.stringify(args ?? null), |
| 138 | + resumedFromRunId: priorRunId, |
| 139 | + baseSha, |
| 140 | + }); |
| 141 | + |
| 142 | + const persisted = |
| 143 | + priorScriptPath ?? |
| 144 | + (await persistWorkflowScript({ |
| 145 | + stateDir: config.stateDir, |
| 146 | + runId: run.id, |
| 147 | + source, |
| 148 | + preferredName: parsed.meta.name || nameHint, |
| 149 | + })); |
| 150 | + if (!priorScriptPath) store.setScriptPath(run.id, persisted); |
| 151 | + |
| 152 | + const cliEntry = fileURLToPath( |
| 153 | + import.meta.url.replace(/workflow-tools\.(ts|js)$/, "cli.$1"), |
| 154 | + ); |
| 155 | + spawnWorkflowWorkerFromCli(run.id, cliEntry); |
| 156 | + |
| 157 | + const yieldMs = yieldTimeMs ?? 2_000; |
| 158 | + const page = await yieldEvents(store, run.id, 0, yieldMs); |
| 159 | + return toolResult(page); |
| 160 | + } finally { |
| 161 | + store.close(); |
| 162 | + } |
| 163 | + }, |
| 164 | + ); |
| 165 | + |
| 166 | + registerAppTool( |
| 167 | + server, |
| 168 | + "workflow_status", |
| 169 | + { |
| 170 | + title: "Workflow status", |
| 171 | + description: "Drain events for a workflow run; optional long-poll yield.", |
| 172 | + inputSchema: { |
| 173 | + runId: z.string(), |
| 174 | + sinceSeq: z.number().int().min(0).optional(), |
| 175 | + yieldTimeMs: z |
| 176 | + .number() |
| 177 | + .int() |
| 178 | + .min(0) |
| 179 | + .max(WORKFLOW_MCP_YIELD_MS) |
| 180 | + .optional() |
| 181 | + .describe(`Long-poll ms (default 0, max ${WORKFLOW_MCP_YIELD_MS}).`), |
| 182 | + }, |
| 183 | + annotations: { readOnlyHint: true }, |
| 184 | + _meta: {}, |
| 185 | + }, |
| 186 | + async ({ runId, sinceSeq, yieldTimeMs }) => { |
| 187 | + const store = createWorkflowStore(config); |
| 188 | + try { |
| 189 | + if (!store.getRun(runId)) throw new Error(`Unknown workflow run: ${runId}`); |
| 190 | + const page = await yieldEvents(store, runId, sinceSeq ?? 0, yieldTimeMs ?? 0); |
| 191 | + return toolResult(page); |
| 192 | + } finally { |
| 193 | + store.close(); |
| 194 | + } |
| 195 | + }, |
| 196 | + ); |
| 197 | + |
| 198 | + registerAppTool( |
| 199 | + server, |
| 200 | + "workflow_cancel", |
| 201 | + { |
| 202 | + title: "Cancel workflow", |
| 203 | + description: "Request cooperative cancel of a running workflow.", |
| 204 | + inputSchema: { |
| 205 | + runId: z.string(), |
| 206 | + }, |
| 207 | + annotations: { readOnlyHint: false }, |
| 208 | + _meta: {}, |
| 209 | + }, |
| 210 | + async ({ runId }) => { |
| 211 | + const store = createWorkflowStore(config); |
| 212 | + try { |
| 213 | + const run = store.requestCancel(runId); |
| 214 | + if (run.pid && (run.status === "running" || run.status === "starting")) { |
| 215 | + try { |
| 216 | + process.kill(run.pid, "SIGTERM"); |
| 217 | + } catch { |
| 218 | + // already gone |
| 219 | + } |
| 220 | + } |
| 221 | + const latest = store.getRun(runId)!; |
| 222 | + return { |
| 223 | + content: [{ type: "text" as const, text: JSON.stringify({ runId, status: latest.status }) }], |
| 224 | + structuredContent: { runId, status: latest.status }, |
| 225 | + }; |
| 226 | + } finally { |
| 227 | + store.close(); |
| 228 | + } |
| 229 | + }, |
| 230 | + ); |
| 231 | + |
| 232 | + } |
| 233 | + |
| 234 | +async function yieldEvents( |
| 235 | + store: ReturnType<typeof createWorkflowStore>, |
| 236 | + runId: string, |
| 237 | + sinceSeq: number, |
| 238 | + yieldMs: number, |
| 239 | +): Promise<{ |
| 240 | + run: WorkflowRunRecord; |
| 241 | + events: WorkflowEventRecord[]; |
| 242 | + nextSeq: number; |
| 243 | + terminal: boolean; |
| 244 | +}> { |
| 245 | + const deadline = Date.now() + Math.min(yieldMs, WORKFLOW_MCP_YIELD_MS); |
| 246 | + let cursor = sinceSeq; |
| 247 | + let events: WorkflowEventRecord[] = []; |
| 248 | + let terminal = false; |
| 249 | + let run = store.getRun(runId)!; |
| 250 | + |
| 251 | + for (;;) { |
| 252 | + const page = store.drainEvents(runId, cursor, WORKFLOW_LIMITS.eventDrainDefault); |
| 253 | + events = events.concat(page.events); |
| 254 | + cursor = page.nextSeq; |
| 255 | + terminal = page.terminal; |
| 256 | + run = page.run; |
| 257 | + if (terminal || Date.now() >= deadline) break; |
| 258 | + await sleep(250); |
| 259 | + } |
| 260 | + |
| 261 | + return { run, events, nextSeq: cursor, terminal }; |
| 262 | +} |
| 263 | + |
| 264 | +function toolResult(page: { |
| 265 | + run: WorkflowRunRecord; |
| 266 | + events: WorkflowEventRecord[]; |
| 267 | + nextSeq: number; |
| 268 | + terminal: boolean; |
| 269 | +}) { |
| 270 | + const payload = { |
| 271 | + runId: page.run.id, |
| 272 | + status: page.run.status, |
| 273 | + events: page.events.map((e) => ({ |
| 274 | + seq: e.seq, |
| 275 | + type: e.type, |
| 276 | + phase: e.phase, |
| 277 | + label: e.label, |
| 278 | + dataJson: e.dataJson, |
| 279 | + })), |
| 280 | + nextSeq: page.nextSeq, |
| 281 | + result: page.run.resultJson ? safeJson(page.run.resultJson) : undefined, |
| 282 | + error: page.run.error, |
| 283 | + errorKind: page.run.errorKind, |
| 284 | + }; |
| 285 | + return { |
| 286 | + content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }], |
| 287 | + structuredContent: payload, |
| 288 | + }; |
| 289 | +} |
| 290 | + |
| 291 | +function safeJson(text: string): unknown { |
| 292 | + try { |
| 293 | + return JSON.parse(text); |
| 294 | + } catch { |
| 295 | + return text; |
| 296 | + } |
| 297 | +} |
| 298 | + |
| 299 | +function sleep(ms: number): Promise<void> { |
| 300 | + return new Promise((r) => setTimeout(r, ms)); |
| 301 | +} |
| 302 | + |
| 303 | +/** Resolve enabled ∩ live providers for workflows. */ |
| 304 | +export function resolveWorkflowEnabledProviders( |
| 305 | + agentProviders: AgentProvidersConfig | undefined, |
| 306 | +): LocalAgentProvider[] { |
| 307 | + const snapshot = getLocalAgentProviderAvailabilitySnapshot(); |
| 308 | + const live = new Set( |
| 309 | + snapshot.filter((row) => row.available).map((row) => row.name), |
| 310 | + ); |
| 311 | + if (!agentProviders) { |
| 312 | + return LOCAL_AGENT_PROVIDERS.filter((id) => live.has(id)); |
| 313 | + } |
| 314 | + return agentProviders.enabled.filter( |
| 315 | + (id): id is LocalAgentProvider => isLocalAgentProvider(id) && live.has(id), |
| 316 | + ); |
| 317 | +} |
0 commit comments