diff --git a/README.md b/README.md index 71a1d42..bddd271 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,14 @@ Mason is a desktop application that leverages Databricks Unity AI Gateway and Un Switching between Claude, GPT, Gemini, and Llama models in Mason

+## Agentic Workflow Designer + +Build multi-model agentic pipelines on a drag-and-drop canvas. Each **cell** picks a model, a subset of your connected tools, and a prompt; wire cells together with **flow** edges to pipe one cell's output into the next, or **feedback** edges to create bounded revision loops where a reviewer routes work back until it passes. Mix providers freely — a Fable cell can feed an Opus cell whose work a Sonnet cell validates — all through the same governed gateway. + +

+ Mason's Agentic Workflow Designer: cells wired into a multi-model pipeline with feedback loops +

+ ## Installation ### macOS (Apple Silicon) — one-line install diff --git a/css/app.css b/css/app.css index f4b07f2..02c4989 100644 --- a/css/app.css +++ b/css/app.css @@ -1066,3 +1066,380 @@ /* Notes block in update modal */ body.dark #updateNotes { background: rgba(255,255,255,0.05); } + + /* ============================================================ + Workflow Designer + ============================================================ */ + + .sidebar-designer-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border: 1px solid #ccc; + border-radius: 8px; + background: none; + color: inherit; + font-size: 0.82rem; + cursor: pointer; + width: 100%; + transition: background 0.2s, border-color 0.2s; + } + .sidebar-designer-btn:hover { background: rgba(0,0,0,0.04); border-color: #aaa; } + .sidebar-designer-btn.active { border-color: #ff3621; color: #ff3621; } + body.dark .sidebar-designer-btn { border-color: #444; } + body.dark .sidebar-designer-btn:hover { background: rgba(255,255,255,0.04); border-color: #666; } + body.dark .sidebar-designer-btn.active { border-color: #ff3621; color: #ff6a5b; } + + .designer-view { + display: none; + flex: 1; + flex-direction: column; + width: 100%; + min-height: 0; + position: relative; + } + .designer-view.visible { display: flex; } + + .designer-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border-bottom: 1px solid #e8e8e8; + flex-wrap: wrap; + } + body.dark .designer-toolbar { border-color: #2a2a2a; } + .designer-toolbar select, + .wf-name-input { + padding: 6px 10px; + border: 1px solid #ddd; + border-radius: 8px; + font-size: 0.82rem; + background: #fff; + color: #333; + outline: none; + } + .wf-name-input { flex: 0 1 220px; font-weight: 600; } + .wf-name-input:focus { border-color: #007aff; } + body.dark .designer-toolbar select, + body.dark .wf-name-input { background: #222; border-color: #3a3a3a; color: #ccc; } + + .wf-btn { + padding: 6px 12px; + border: 1px solid #ccc; + border-radius: 8px; + background: none; + color: inherit; + font-size: 0.82rem; + cursor: pointer; + transition: background 0.2s, border-color 0.2s; + white-space: nowrap; + } + .wf-btn:hover { background: rgba(0,0,0,0.04); border-color: #aaa; } + body.dark .wf-btn { border-color: #444; } + body.dark .wf-btn:hover { background: rgba(255,255,255,0.05); } + .wf-btn.primary { background: #ff3621; border-color: #ff3621; color: #fff; font-weight: 600; } + .wf-btn.primary:hover { background: #e62f1c; } + .wf-btn.primary.running { background: #555; border-color: #555; } + .wf-btn.danger:hover { border-color: #c00; color: #c00; } + .wf-btn.dirty::after { content: " •"; color: #ff3621; } + + .wf-status { font-size: 0.78rem; opacity: 0.6; margin-left: auto; max-width: 46%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .wf-status.error { color: #c00; opacity: 1; } + body.dark .wf-status.error { color: #ff6a5b; } + + .designer-canvas-wrap { + flex: 1; + position: relative; + overflow: hidden; + min-height: 0; + cursor: grab; + background-color: #fafafa; + background-image: radial-gradient(circle, rgba(0,0,0,0.13) 1px, transparent 1px); + background-size: 22px 22px; + } + .designer-canvas-wrap:active { cursor: grabbing; } + body.dark .designer-canvas-wrap { + background-color: #161616; + background-image: radial-gradient(circle, rgba(255,255,255,0.09) 1px, transparent 1px); + } + .designer-canvas { + position: absolute; + top: 0; left: 0; + width: 0; height: 0; + transform-origin: 0 0; + } + .designer-edges { + position: absolute; + left: -10000px; + top: -10000px; + width: 20000px; + height: 20000px; + overflow: visible; + pointer-events: none; + } + .wf-edge { + fill: none; + stroke: #9aa3ad; + stroke-width: 2; + pointer-events: none; + } + .wf-edge.selected { stroke: #007aff; stroke-width: 3; } + .wf-edge-feedback { stroke: #ff3621; stroke-dasharray: 7 5; } + .wf-edge-feedback.selected { stroke: #ff3621; stroke-width: 3.5; } + .wf-edge-ghost { stroke: #007aff; stroke-dasharray: 4 4; opacity: 0.7; } + .wf-edge-hit { + fill: none; + stroke: transparent; + stroke-width: 16; + pointer-events: stroke; + cursor: pointer; + } + .wf-edge-label { + font-size: 12px; + fill: #667; + text-anchor: middle; + pointer-events: none; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + } + body.dark .wf-edge-label { fill: #99a; } + body.dark .wf-edge { stroke: #5a626c; } + + .wf-edge-label-input { + position: absolute; + transform: translate(-50%, -50%); + z-index: 30; + padding: 4px 8px; + border: 1px solid #007aff; + border-radius: 6px; + font-size: 0.78rem; + background: #fff; + color: #333; + outline: none; + width: 170px; + } + body.dark .wf-edge-label-input { background: #2a2a2a; color: #ddd; } + + .wf-cell { + position: absolute; + width: 240px; + background: #fff; + border: 1.5px solid #d4d8dd; + border-radius: 12px; + box-shadow: 0 2px 10px rgba(0,0,0,0.07); + padding: 8px 10px 10px; + display: flex; + flex-direction: column; + gap: 6px; + cursor: default; + box-sizing: border-box; + } + .wf-cell.selected { border-color: #007aff; box-shadow: 0 3px 14px rgba(0,122,255,0.22); } + body.dark .wf-cell { background: #242424; border-color: #3c4046; } + body.dark .wf-cell.selected { border-color: #4a9bff; } + + .wf-cell-header { + display: flex; + align-items: center; + gap: 6px; + cursor: move; + margin: -8px -10px 0; + padding: 8px 10px 2px; + } + .wf-cell-name { + flex: 1; + min-width: 0; + border: none; + background: none; + font-weight: 700; + font-size: 0.86rem; + color: inherit; + outline: none; + cursor: text; + } + .wf-cell-delete { + border: none; + background: none; + color: inherit; + opacity: 0.4; + font-size: 1rem; + cursor: pointer; + padding: 0 2px; + } + .wf-cell-delete:hover { opacity: 1; color: #c00; } + + .wf-cell-model { + width: 100%; + padding: 4px 6px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 0.76rem; + background: #fff; + color: #333; + outline: none; + } + body.dark .wf-cell-model { background: #1d1d1d; border-color: #3a3a3a; color: #ccc; } + + .wf-cell-toolsrow { display: flex; align-items: center; justify-content: space-between; gap: 6px; } + .wf-cell-tools-btn { + border: 1px solid #ddd; + border-radius: 6px; + background: none; + color: inherit; + font-size: 0.72rem; + padding: 3px 8px; + cursor: pointer; + } + .wf-cell-tools-btn:hover { border-color: #aaa; background: rgba(0,0,0,0.03); } + body.dark .wf-cell-tools-btn { border-color: #3a3a3a; } + + .wf-cell-status { + font-size: 0.7rem; + padding: 2px 8px; + border-radius: 999px; + background: rgba(0,0,0,0.06); + opacity: 0.75; + } + body.dark .wf-cell-status { background: rgba(255,255,255,0.08); } + .wf-status-running { background: #ff3621 !important; color: #fff; opacity: 1; animation: wfPulse 1.2s ease-in-out infinite; } + .wf-status-queued { background: rgba(255,54,33,0.15) !important; color: #ff3621; opacity: 1; } + .wf-status-done { background: #1d9b4e !important; color: #fff; opacity: 1; } + .wf-status-failed { background: #c00 !important; color: #fff; opacity: 1; } + .wf-status-skipped { opacity: 0.4; } + @keyframes wfPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } } + + .wf-cell-prompt { + width: 100%; + resize: vertical; + min-height: 54px; + max-height: 220px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 0.76rem; + font-family: inherit; + padding: 6px 8px; + background: #fff; + color: #333; + outline: none; + box-sizing: border-box; + } + .wf-cell-prompt:focus { border-color: #007aff; } + body.dark .wf-cell-prompt { background: #1d1d1d; border-color: #3a3a3a; color: #ccc; } + + .wf-port { + position: absolute; + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + border: 2.5px solid #9aa3ad; + z-index: 2; + } + body.dark .wf-port { background: #242424; } + .wf-port-in { left: -8px; top: 50%; transform: translateY(-50%); } + .wf-port-out { right: -8px; top: 50%; transform: translateY(-50%); cursor: crosshair; } + .wf-port-out:hover { border-color: #007aff; background: #007aff; } + .wf-port-feedback { + left: 50%; top: -8px; transform: translateX(-50%); + border-style: dashed; + border-color: #ff3621; + } + + .designer-drawer { + border-top: 1px solid #e8e8e8; + height: 240px; + display: flex; + flex-direction: column; + flex-shrink: 0; + background: #fff; + } + body.dark .designer-drawer { border-color: #2a2a2a; background: #1c1c1c; } + .designer-drawer.collapsed { height: 38px; } + .designer-drawer.collapsed .designer-drawer-body { display: none; } + .designer-drawer-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 14px; + font-size: 0.82rem; + font-weight: 600; + flex-shrink: 0; + } + .designer-drawer-header .wf-cell-status { margin-left: 8px; } + .designer-drawer-body { + flex: 1; + overflow-y: auto; + padding: 4px 16px 14px; + font-size: 0.84rem; + } + .wf-drawer-hint { opacity: 0.5; font-size: 0.8rem; padding: 8px 0; } + .wf-tr { margin: 6px 0; } + .wf-tr-tool-call, .wf-tr-tool-result, .wf-tr-info { + font-family: ui-monospace, Menlo, monospace; + font-size: 0.74rem; + color: #3568a8; + background: rgba(0,122,255,0.07); + border-radius: 6px; + padding: 4px 8px; + white-space: pre-wrap; + word-break: break-word; + } + body.dark .wf-tr-tool-call, body.dark .wf-tr-tool-result, body.dark .wf-tr-info { color: #7fb0e8; } + .wf-tr-error { color: #c00; font-size: 0.78rem; } + body.dark .wf-tr-error { color: #ff6a5b; } + .wf-tr-verdict { + border-left: 3px solid #ff3621; + padding: 4px 10px; + font-size: 0.8rem; + font-weight: 600; + background: rgba(255,54,33,0.06); + border-radius: 0 6px 6px 0; + white-space: pre-wrap; + } + .wf-tr-output { border-top: 1px dashed #ddd; padding-top: 8px; } + body.dark .wf-tr-output { border-color: #3a3a3a; } + + .wf-tools-group { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + opacity: 0.5; + margin: 12px 0 4px; + } + .wf-tools-row { + display: flex; + align-items: baseline; + gap: 8px; + padding: 3px 2px; + font-size: 0.8rem; + cursor: pointer; + border-radius: 6px; + } + .wf-tools-row:hover { background: rgba(0,0,0,0.03); } + body.dark .wf-tools-row:hover { background: rgba(255,255,255,0.04); } + .wf-tools-name { font-family: ui-monospace, Menlo, monospace; font-size: 0.74rem; } + .wf-tools-desc { opacity: 0.45; font-size: 0.72rem; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + + /* Per-cell feedback loop cap */ + .wf-cell-loopcap { + display: flex; + align-items: center; + gap: 3px; + font-size: 0.74rem; + opacity: 0.75; + cursor: help; + } + .wf-cell-loopcap input { + width: 34px; + padding: 2px 4px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 0.72rem; + background: #fff; + color: #333; + outline: none; + text-align: center; + } + body.dark .wf-cell-loopcap input { background: #1d1d1d; border-color: #3a3a3a; color: #ccc; } diff --git a/docs/designer.png b/docs/designer.png new file mode 100644 index 0000000..0768dca Binary files /dev/null and b/docs/designer.png differ diff --git a/docs/specs/agentic-workflow-designer.md b/docs/specs/agentic-workflow-designer.md new file mode 100644 index 0000000..5b31884 --- /dev/null +++ b/docs/specs/agentic-workflow-designer.md @@ -0,0 +1,460 @@ +# Engineering Specification — Agentic Workflow Designer + +**Status:** Draft for review +**Author:** Mason core +**Target:** Mason v1.5.x (phased; see Rollout) +**Last updated:** 2026-06-11 + +--- + +## 1. Summary + +Add a visual, node-based **Workflow Designer** to Mason — a drag-and-drop canvas (in the spirit of n8n / ComfyUI) where users compose multi-model agentic pipelines out of **Cells**. Each Cell selects a model, a subset of available tools (built-in + MCP + UC MCP), and a prompt. Cells are wired together with edges: an edge from Cell A to Cell B means *A runs first, and A's output is injected into B's context*. Edges can also express **feedback loops** (B sends results back to A for revision) and **review gates** (a cell decides whether the workflow ends or routes work back for another pass). + +The designer is opened from a new button in the sidebar, directly **above the Profile section**. It replaces the chat pane with a full-pane canvas view, following the same view-swapping pattern as Dashboards/Settings/Onboarding. + +Everything executes through the existing Databricks AI Gateway plumbing — per-model format routing, OAuth, streaming, MCP tool dispatch, Anthropic prompt caching — none of which changes. The workflow engine is a thin orchestrator that runs the existing per-turn agent loop once per cell, in graph order. + +### Motivating user story (acceptance scenario) + +> I click **Workflow Designer**. A designer pane opens in the chat window. I create a cell, select **Fable 5**, pick a couple of MCP tools, and write a prompt with the high-level goals and specs of the project. I create a second cell with **Opus 4.8**, a different toolset, and an additional prompt. I drag a line from the Fable cell to the Opus cell — meaning the Fable cell runs first and its output feeds the Opus cell. I create a third cell named **"unit tests"** with a Sonnet model. The Fable cell feeds its unit-test specs to the unit-tests cell via a second line, and the unit-tests cell *also* receives a line from the Opus cell (two inputs) so it can run Opus's work against the spec sheet. The unit-tests cell has a **feedback** line back to the Opus cell: it reports which tests passed and what gaps remain, and Opus iterates until they're closed. When the unit-tests cell deems the work complete, it hands off to the Fable cell for **final review**. Fable either ends the session or passes the work back to Opus for another round. + +Section 12 walks this scenario through the spec end-to-end as the primary acceptance test. + +--- + +## 2. Current-state evaluation (what we're building on) + +A short audit of the parts of Mason this feature touches, and the constraints they impose. + +### 2.1 Architecture facts that shape this design + +| Fact | Where | Consequence for the designer | +|---|---|---| +| Renderer is **script-mode TypeScript** — no bundler, no imports; modules share one global scope and load via ` + + + diff --git a/src/agent-runner.ts b/src/agent-runner.ts new file mode 100644 index 0000000..e8afa03 --- /dev/null +++ b/src/agent-runner.ts @@ -0,0 +1,146 @@ +// Shared agent-loop primitives used by both the chat view (chat.ts) and the +// workflow engine (workflow-engine.ts). Script-mode global like every other +// renderer module — loaded before chat.js in index.html. +// +// What lives here is the *headless* core of a tool-bearing agent turn: +// • resolveModelRouting — per-model gateway/format resolution, including the +// tools→Responses promotion for models like GPT-5.5 +// • executeToolCore — execute one tool call (load_skill / builtin IPC / +// HTTP MCP / stdio MCP) and return its result content + a short preview +// • capToolResult — bound tool results so a single call can't blow context +// +// Deliberately *not* here: streaming/typewriter UI, history mutation, and +// ask_user (all UI-bound — each caller owns its own presentation). + +declare function getGatewayUrl(): string | null; + +const MAX_TOOL_RESULT_CHARS = 256 * 1024; +function capToolResult(text: string, toolName: string): string { + if (text.length <= MAX_TOOL_RESULT_CHARS) return text; + return ( + text.slice(0, MAX_TOOL_RESULT_CHARS) + + `\n\n[Truncated: ${toolName} returned ${text.length} chars, only first ${MAX_TOOL_RESULT_CHARS} kept. Ask for a more specific query or read in chunks.]` + ); +} + +interface ModelRouting { + model: string; + gateway: string; + format: "chat" | "responses"; +} + +// Resolve which model id / gateway / API format a chat request should use. +// `sel` is a model picker value: either a discovered model id or +// "custom:" for user-configured endpoints. When tools are attached +// and the model also supports the Responses API, promote to Responses — this +// works around GPT-5.5's server-side reasoning_effort injection that +// conflicts with tools in mlflow/v1/chat/completions. +function resolveModelRouting(sel: string, hasTools: boolean): ModelRouting { + let gateway = getGatewayUrl() || ""; + let model = sel; + let format: "chat" | "responses" | null = null; + + if (sel.startsWith("custom:")) { + model = sel.replace("custom:", ""); + const ep = mason.customEndpoints.find((e) => e.modelId === model); + if (ep) { + if (ep.gatewayUrl) gateway = ep.gatewayUrl; + format = ep.format || null; + } + } else { + for (const g of mason.discoveredModels) { + const m = g.models.find((x) => x.value === sel); + if (m) { + format = m.format || null; + const supportsResponses = m.apiTypes && m.apiTypes.includes("openai/v1/responses"); + if (hasTools && supportsResponses) { + format = "responses"; + } + break; + } + } + } + + return { model, gateway, format: format || "chat" }; +} + +interface ToolExecResult { + ok: boolean; + content: string; // goes into the role:"tool" message (already capped) + preview: string; // short human-readable line for UI +} + +// Execute one tool call, headlessly. Covers every dispatch path except +// ask_user (UI-bound; callers handle it themselves). Never throws — errors +// come back as { ok: false } with an "Error: …" content the model can read. +async function executeToolCore( + toolName: string, + args: Record +): Promise { + try { + if (toolName === "load_skill") { + const slug = String(args.slug || ""); + if (!slug) throw new Error("slug is required"); + const skill = (await window.api.skillsLoad(slug)) as + | { slug: string; name: string; description: string; body: string } + | null; + if (!skill) { + return { + ok: false, + content: `Error: skill "${slug}" not found.`, + preview: `skill "${slug}" not found`, + }; + } + const content = `# ${skill.name}\n\n${skill.body}`; + return { + ok: true, + content: capToolResult(content, toolName), + preview: `Loaded skill: ${slug}`, + }; + } + + if (BUILTIN_TOOL_NAMES.has(toolName)) { + const toolResult = (await window.api.builtinToolCall({ toolName, args })) as any; + const resultText = capToolResult(JSON.stringify(toolResult), toolName); + const preview = + toolResult?.message || + (typeof toolResult?.content === "string" && toolResult.content.slice(0, 200)) || + resultText; + return { ok: true, content: resultText, preview: String(preview) }; + } + + const server = findMcpServerForTool(toolName); + if (!server) { + return { + ok: false, + content: "Error: no MCP server found for this tool", + preview: "no MCP server found for this tool", + }; + } + + let toolResult: any; + if (server.type === "stdio") { + toolResult = await window.api.mcpStdioCallTool({ key: server.key!, toolName, args }); + } else { + const mcpToken = await getAuthToken(); + toolResult = await window.api.mcpCallTool({ + serverUrl: server.url!, + token: mcpToken, + toolName, + args, + }); + } + const rawText = toolResult?.content + ? toolResult.content.map((c: any) => c.text || JSON.stringify(c)).join("\n") + : JSON.stringify(toolResult); + const resultText = capToolResult(rawText, toolName); + return { + ok: true, + content: resultText, + preview: resultText.slice(0, 200) + (resultText.length > 200 ? "..." : ""), + }; + } catch (e) { + const msg = (e as Error).message; + return { ok: false, content: `Error: ${msg}`, preview: msg }; + } +} diff --git a/src/app.ts b/src/app.ts index a2bb2a8..81702d0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -879,6 +879,12 @@ function initEventListeners(): void { (el.settingsBtn as HTMLElement | null)?.addEventListener("click", () => switchToSettingsView()); (el.settingsViewClose as HTMLElement | null)?.addEventListener("click", () => switchToChatsTab()); + // Workflow designer (toggles back to chat when already open) + document.getElementById("designerBtn")?.addEventListener("click", () => { + if (mason.currentView === "designer") switchToChatsTab(); + else switchToDesignerView(); + }); + // Update modal const updateModal = el.updateModal as HTMLElement | null; const updateLatest = el.updateLatest as HTMLElement | null; @@ -1258,6 +1264,8 @@ function initEventListeners(): void { mason.autoConnectDone = false; await autoConnectMcp(); if (mason.currentView === "dashboards") loadDashboards(); + // Re-validate workflow cell models against the new workspace's model list. + designerOnProfileSwitch(); }); // Chat send @@ -1289,6 +1297,11 @@ function initEventListeners(): void { e.preventDefault(); (el.sidebar as HTMLElement | null)?.classList.toggle("hidden"); } + if (mod && e.key === "d") { + e.preventDefault(); + if (mason.currentView === "designer") switchToChatsTab(); + else switchToDesignerView(); + } if (e.key === "Escape") { popup?.classList.remove("open"); modelMenu?.classList.remove("open"); diff --git a/src/chat.ts b/src/chat.ts index f5fd44c..0a7ac39 100644 --- a/src/chat.ts +++ b/src/chat.ts @@ -161,15 +161,6 @@ function safeInlinePos(line: string): number { return lastSafe; } -const MAX_TOOL_RESULT_CHARS = 256 * 1024; -function capToolResult(text: string, toolName: string): string { - if (text.length <= MAX_TOOL_RESULT_CHARS) return text; - return ( - text.slice(0, MAX_TOOL_RESULT_CHARS) + - `\n\n[Truncated: ${toolName} returned ${text.length} chars, only first ${MAX_TOOL_RESULT_CHARS} kept. Ask for a more specific query or read in chunks.]` - ); -} - function setGenerating(active: boolean): void { mason.generating = active; const sendBtn = mason.el.send as HTMLButtonElement | null; @@ -196,6 +187,14 @@ async function send(): Promise { return; } + if (mason.workflowRun?.running) { + addMessageEl( + "error", + "A workflow is currently running — stop it in the Workflow Designer before chatting." + ); + return; + } + const profile = getSelectedProfile(); if (!profile) { addMessageEl("error", "Select a Databricks profile in the sidebar."); @@ -279,31 +278,10 @@ async function chatLoop(_profile: { host?: string }): Promise { while (maxIterations-- > 0) { iterationsUsed += 1; const chatToken = await getAuthToken(); - const sel = modelEl.value; - let chatGateway = getGatewayUrl(); - let chatModel = sel; - let chatFormat: "chat" | "responses" | null = null; - - if (sel.startsWith("custom:")) { - chatModel = sel.replace("custom:", ""); - const ep = mason.customEndpoints.find((e) => e.modelId === chatModel); - if (ep) { - if (ep.gatewayUrl) chatGateway = ep.gatewayUrl; - chatFormat = ep.format || null; - } - } else { - for (const g of mason.discoveredModels) { - const m = g.models.find((x) => x.value === sel); - if (m) { - chatFormat = m.format || null; - const supportsResponses = m.apiTypes && m.apiTypes.includes("openai/v1/responses"); - if (toolsForApi && toolsForApi.length > 0 && supportsResponses) { - chatFormat = "responses"; - } - break; - } - } - } + const routing = resolveModelRouting(modelEl.value, !!(toolsForApi && toolsForApi.length > 0)); + const chatGateway: string | null = routing.gateway || null; + const chatModel = routing.model; + const chatFormat: "chat" | "responses" = routing.format; // Stream chat completions regardless of tools — main.ts accumulates // tool_calls deltas now. Responses API stream format differs; keep it @@ -495,37 +473,14 @@ async function chatLoop(_profile: { host?: string }): Promise { // also skip the "Calling tool: …" announcement since they render their // own UI inline. if (toolName === "load_skill") { - try { - const slug = String(args.slug || ""); - if (!slug) throw new Error("slug is required"); - addMessageEl("tool-call", `Loading skill: ${slug}`); - const skill = (await window.api.skillsLoad(slug)) as - | { slug: string; name: string; description: string; body: string } - | null; - if (!skill) { - (mason.history as any[]).push({ - role: "tool", - tool_call_id: tc.id, - name: toolName, - content: `Error: skill "${slug}" not found.`, - }); - } else { - const content = `# ${skill.name}\n\n${skill.body}`; - (mason.history as any[]).push({ - role: "tool", - tool_call_id: tc.id, - name: toolName, - content: capToolResult(content, toolName), - }); - } - } catch (e) { - (mason.history as any[]).push({ - role: "tool", - tool_call_id: tc.id, - name: toolName, - content: `Error: ${(e as Error).message}`, - }); - } + addMessageEl("tool-call", `Loading skill: ${String(args.slug || "")}`); + const r = await executeToolCore(toolName, args); + (mason.history as any[]).push({ + role: "tool", + tool_call_id: tc.id, + name: toolName, + content: r.content, + }); continue; } @@ -574,83 +529,21 @@ async function chatLoop(_profile: { host?: string }): Promise { // stuck. showThinking(); - if (BUILTIN_TOOL_NAMES.has(toolName)) { - try { - const toolResult = (await window.api.builtinToolCall({ toolName, args })) as any; - const resultText = capToolResult(JSON.stringify(toolResult), toolName); - (mason.history as any[]).push({ - role: "tool", - tool_call_id: tc.id, - name: toolName, - content: resultText, - }); - const preview = - toolResult?.message || - (typeof toolResult?.content === "string" && toolResult.content.slice(0, 200)) || - resultText; - addMessageEl("tool-call", `${toolName}: ${preview}`); - } catch (e) { - (mason.history as any[]).push({ - role: "tool", - tool_call_id: tc.id, - name: toolName, - content: `Error: ${(e as Error).message}`, - }); - addMessageEl("error", `Tool error (${toolName}): ${(e as Error).message}`); - } - continue; - } - - const server = findMcpServerForTool(toolName); - if (!server) { - (mason.history as any[]).push({ - role: "tool", - tool_call_id: tc.id, - name: toolName, - content: "Error: no MCP server found for this tool", - }); - continue; - } - - try { - let toolResult: any; - if (server.type === "stdio") { - toolResult = await window.api.mcpStdioCallTool({ - key: server.key!, - toolName, - args, - }); - } else { - const mcpToken = await getAuthToken(); - toolResult = await window.api.mcpCallTool({ - serverUrl: server.url!, - token: mcpToken, - toolName, - args, - }); - } - const rawText = toolResult?.content - ? toolResult.content.map((c: any) => c.text || JSON.stringify(c)).join("\n") - : JSON.stringify(toolResult); - const resultText = capToolResult(rawText, toolName); - (mason.history as any[]).push({ - role: "tool", - tool_call_id: tc.id, - name: toolName, - content: resultText, - }); + const isBuiltin = BUILTIN_TOOL_NAMES.has(toolName); + const r = await executeToolCore(toolName, args); + (mason.history as any[]).push({ + role: "tool", + tool_call_id: tc.id, + name: toolName, + content: r.content, + }); + if (r.ok) { addMessageEl( "tool-call", - `${toolName} result: ${resultText.slice(0, 200)}${resultText.length > 200 ? "..." : ""}` + isBuiltin ? `${toolName}: ${r.preview}` : `${toolName} result: ${r.preview}` ); - } catch (e) { - (mason.history as any[]).push({ - role: "tool", - tool_call_id: tc.id, - name: toolName, - content: `Error: ${(e as Error).message}`, - }); - addMessageEl("error", `Tool error (${toolName}): ${(e as Error).message}`); + } else if (r.content !== "Error: no MCP server found for this tool") { + addMessageEl("error", `Tool error (${toolName}): ${r.preview}`); } } diff --git a/src/dashboards.ts b/src/dashboards.ts index 4270b77..3041d1e 100644 --- a/src/dashboards.ts +++ b/src/dashboards.ts @@ -11,6 +11,7 @@ declare function renderProfilesList(): void; declare function refreshSkillsState(): Promise; declare function renderSkillsSettingsList(): void; declare function updateSkillsAutoLoadVisual(): void; +declare function hideDesignerView(): void; function elAs(key: string): T | null { return mason.el[key] as T | null; @@ -39,6 +40,7 @@ function switchToChatsTab(): void { const settingsClose = elAs("settingsViewClose"); if (settingsClose) settingsClose.style.display = "none"; elAs("onboardingView")?.classList.remove("visible"); + hideDesignerView(); } function switchToDashboardsTab(): void { @@ -63,6 +65,7 @@ function switchToDashboardsTab(): void { elAs("settingsView")?.classList.remove("visible"); const settingsClose = elAs("settingsViewClose"); if (settingsClose) settingsClose.style.display = "none"; + hideDesignerView(); loadDashboards(); } @@ -82,6 +85,7 @@ function switchToSettingsView(): void { const settingsClose = elAs("settingsViewClose"); if (settingsClose) settingsClose.style.display = "inline-block"; elAs("onboardingView")?.classList.remove("visible"); + hideDesignerView(); if (typeof renderProfilesList === "function") renderProfilesList(); if (typeof refreshSkillsState === "function") { refreshSkillsState().then(() => { diff --git a/src/designer.ts b/src/designer.ts new file mode 100644 index 0000000..629ae02 --- /dev/null +++ b/src/designer.ts @@ -0,0 +1,1008 @@ +// Agentic Workflow Designer — canvas UI. +// See docs/specs/agentic-workflow-designer.md (Section 4) for the UX spec. +// +// The canvas is a hand-rolled DOM + SVG implementation: cells are absolutely +// positioned cards inside a single transformed container, edges are cubic +// bezier paths in an SVG underlay that shares the same transform, so pan and +// zoom move everything together. + +declare function switchToChatsTab(): void; +declare function renderMarkdown(text: string): string; +declare function renderQuestionCard( + questions: Array<{ question: string; options: string[]; multiSelect?: boolean }>, + container?: HTMLElement +): Promise; + +const CELL_WIDTH = 240; + +// --- module state --- +let dsgInited = false; +let dsgView = { x: 60, y: 40, scale: 1 }; +let dsgSelectedCellId: string | null = null; +let dsgSelectedEdgeId: string | null = null; +let dsgCellEls = new Map(); +let dsgListLoaded = false; + +function dsgEl(id: string): T { + return document.getElementById(id) as T; +} + +function dsgWf(): MasonWorkflow | null { + return mason.currentWorkflow; +} + +function dsgRunning(): boolean { + return !!mason.workflowRun?.running; +} + +function dsgMarkDirty(): void { + mason.workflowDirty = true; + const save = dsgEl("wfSave"); + if (save) save.classList.add("dirty"); +} + +function dsgClearDirty(): void { + mason.workflowDirty = false; + const save = dsgEl("wfSave"); + if (save) save.classList.remove("dirty"); +} + +// --- view switching --- + +function switchToDesignerView(): void { + mason.currentView = "designer"; + const main = document.querySelector(".main") as HTMLElement | null; + if (main) main.style.display = "none"; + document.getElementById("dashboardView")?.classList.remove("visible"); + const webview = document.getElementById("dashboardWebview"); + if (webview) webview.style.display = "none"; + document.getElementById("settingsView")?.classList.remove("visible"); + const settingsClose = document.getElementById("settingsViewClose"); + if (settingsClose) settingsClose.style.display = "none"; + document.getElementById("onboardingView")?.classList.remove("visible"); + document.getElementById("designerView")?.classList.add("visible"); + document.getElementById("designerBtn")?.classList.add("active"); + + initDesigner(); + if (!dsgListLoaded) { + dsgListLoaded = true; + refreshWorkflowList().then(() => { + if (!dsgWf()) { + if (mason.workflows.length > 0) { + openWorkflow(mason.workflows[0].id); + } else { + newWorkflow(); + } + } + }); + } +} + +// Called by every other switchTo* via hideDesignerView(). +function hideDesignerView(): void { + document.getElementById("designerView")?.classList.remove("visible"); + document.getElementById("designerBtn")?.classList.remove("active"); +} + +// --- workflow CRUD --- + +async function refreshWorkflowList(): Promise { + mason.workflows = await window.api.workflowList(); + const sel = dsgEl("wfSelect"); + if (!sel) return; + sel.innerHTML = ""; + for (const w of mason.workflows) { + const opt = document.createElement("option"); + opt.value = w.id; + opt.textContent = w.name; + if (dsgWf()?.id === w.id) opt.selected = true; + sel.appendChild(opt); + } + const newOpt = document.createElement("option"); + newOpt.value = "__new__"; + newOpt.textContent = "+ New workflow"; + sel.appendChild(newOpt); + if (dsgWf()) sel.value = dsgWf()!.id; +} + +function newWorkflow(): void { + mason.currentWorkflow = { + id: genId(), + name: "Untitled workflow", + version: 1, + cells: [], + edges: [], + createdAt: Date.now(), + updatedAt: Date.now(), + }; + mason.workflowRun = null; + dsgSelectedCellId = null; + dsgSelectedEdgeId = null; + dsgView = { x: 60, y: 40, scale: 1 }; + dsgClearDirty(); + renderDesigner(); + refreshWorkflowList(); +} + +async function openWorkflow(id: string): Promise { + const wf = (await window.api.workflowLoad(id)) as MasonWorkflow | null; + if (!wf) return; + mason.currentWorkflow = wf; + mason.workflowRun = null; + dsgSelectedCellId = null; + dsgSelectedEdgeId = null; + dsgClearDirty(); + renderDesigner(); + await refreshWorkflowList(); +} + +async function saveWorkflow(): Promise { + const wf = dsgWf(); + if (!wf) return; + wf.name = dsgEl("wfName")?.value.trim() || "Untitled workflow"; + wf.updatedAt = Date.now(); + const result = await window.api.workflowSave(wf); + if (result.ok) { + dsgClearDirty(); + await refreshWorkflowList(); + dsgSetStatus("Saved.", false); + } else { + dsgSetStatus(result.error || "Save failed.", true); + } +} + +async function deleteWorkflow(): Promise { + const wf = dsgWf(); + if (!wf) return; + if (!confirm(`Delete workflow "${wf.name}"? This cannot be undone.`)) return; + await window.api.workflowDelete(wf.id); + mason.currentWorkflow = null; + await refreshWorkflowList(); + if (mason.workflows.length > 0) { + await openWorkflow(mason.workflows[0].id); + } else { + newWorkflow(); + } +} + +// --- coordinate helpers --- + +function dsgApplyTransform(): void { + const canvas = dsgEl("wfCanvas"); + if (!canvas) return; + canvas.style.transform = `translate(${dsgView.x}px, ${dsgView.y}px) scale(${dsgView.scale})`; +} + +function dsgScreenToCanvas(clientX: number, clientY: number): { x: number; y: number } { + const wrap = dsgEl("wfCanvasWrap"); + const rect = wrap.getBoundingClientRect(); + return { + x: (clientX - rect.left - dsgView.x) / dsgView.scale, + y: (clientY - rect.top - dsgView.y) / dsgView.scale, + }; +} + +// --- rendering --- + +function renderDesigner(): void { + const wf = dsgWf(); + const nameInput = dsgEl("wfName"); + if (nameInput && wf) nameInput.value = wf.name; + const canvas = dsgEl("wfCanvas"); + if (!canvas || !wf) return; + + // Remove cell elements for deleted cells; (re)create the rest. + for (const [id, el] of dsgCellEls) { + if (!wf.cells.some((c) => c.id === id)) { + el.remove(); + dsgCellEls.delete(id); + } + } + for (const cell of wf.cells) renderCell(cell); + dsgApplyTransform(); + redrawEdges(); + renderDrawer(); + dsgUpdateRunButton(); +} + +function dsgModelOptions(selected: string): string { + let html = ""; + for (const g of mason.discoveredModels) { + html += ``; + for (const m of g.models) { + html += ``; + } + html += ""; + } + if (mason.customEndpoints.length > 0) { + html += ''; + for (const ep of mason.customEndpoints) { + const val = `custom:${ep.modelId}`; + html += ``; + } + html += ""; + } + if (selected && !workflowModelAvailable(selected)) { + html += ``; + } + return html; +} + +function dsgStatusInfo(cellId: string): { label: string; cls: string } { + const rec = mason.workflowRun?.cells[cellId]; + const status: CellRunStatus = rec?.status || "idle"; + // Surface feedback-loop progress ("running 3/5") once a cell is on its + // second iteration so loops are visibly bounded. + let label: string = status; + if (rec && rec.iterations > 1) { + const cell = dsgWf()?.cells.find((c) => c.id === cellId); + const cap = cell?.maxLoopIterations || 5; + label = `${status} ${Math.min(rec.iterations, cap)}/${cap}`; + } + return { label, cls: `wf-status-${status}` }; +} + +function renderCell(cell: WorkflowCellConfig): void { + const canvas = dsgEl("wfCanvas"); + let el = dsgCellEls.get(cell.id); + if (!el) { + el = document.createElement("div"); + el.className = "wf-cell"; + el.dataset.id = cell.id; + el.innerHTML = ` +
+
+ + +
+ +
+ + + +
+ +
+
+ `; + canvas.appendChild(el); + dsgCellEls.set(cell.id, el); + dsgWireCell(el, cell.id); + } + el.style.left = `${cell.position.x}px`; + el.style.top = `${cell.position.y}px`; + el.classList.toggle("selected", dsgSelectedCellId === cell.id); + + const nameEl = el.querySelector(".wf-cell-name") as HTMLInputElement; + if (document.activeElement !== nameEl) nameEl.value = cell.name; + const modelEl2 = el.querySelector(".wf-cell-model") as HTMLSelectElement; + modelEl2.innerHTML = dsgModelOptions(cell.model.value); + modelEl2.value = cell.model.value; + const toolsBtn = el.querySelector(".wf-cell-tools-btn") as HTMLButtonElement; + toolsBtn.textContent = `⚒ ${cell.enabledTools.length} tool${cell.enabledTools.length === 1 ? "" : "s"}`; + // The loop cap only matters when something feeds back into this cell — + // hide the control otherwise to keep cards quiet. + const loopWrap = el.querySelector(".wf-cell-loopcap") as HTMLElement; + const isFeedbackTarget = !!dsgWf()?.edges.some( + (e) => e.to === cell.id && e.kind === "feedback" + ); + loopWrap.style.display = isFeedbackTarget ? "" : "none"; + const loopInput = loopWrap.querySelector("input") as HTMLInputElement; + if (document.activeElement !== loopInput) { + loopInput.value = String(cell.maxLoopIterations || 5); + } + const promptEl = el.querySelector(".wf-cell-prompt") as HTMLTextAreaElement; + if (document.activeElement !== promptEl) promptEl.value = cell.prompt; + const statusEl = el.querySelector(".wf-cell-status") as HTMLElement; + const st = dsgStatusInfo(cell.id); + statusEl.textContent = st.label; + statusEl.className = `wf-cell-status ${st.cls}`; +} + +function dsgWireCell(el: HTMLElement, cellId: string): void { + const cellOf = (): WorkflowCellConfig | undefined => dsgWf()?.cells.find((c) => c.id === cellId); + + // Select on any mousedown inside the cell. + el.addEventListener("mousedown", () => { + if (dsgSelectedCellId !== cellId) { + dsgSelectedCellId = cellId; + dsgSelectedEdgeId = null; + for (const [id, cellEl] of dsgCellEls) cellEl.classList.toggle("selected", id === cellId); + redrawEdges(); + renderDrawer(); + } + }); + + // Drag by header (but not from the name input or delete button). + const header = el.querySelector(".wf-cell-header") as HTMLElement; + header.addEventListener("mousedown", (e) => { + const t = e.target as HTMLElement; + if (t.closest("input") || t.closest("button")) return; + e.preventDefault(); + const cell = cellOf(); + if (!cell) return; + const start = dsgScreenToCanvas(e.clientX, e.clientY); + const orig = { ...cell.position }; + const onMove = (me: MouseEvent): void => { + const now = dsgScreenToCanvas(me.clientX, me.clientY); + cell.position = { + x: Math.round(orig.x + (now.x - start.x)), + y: Math.round(orig.y + (now.y - start.y)), + }; + el.style.left = `${cell.position.x}px`; + el.style.top = `${cell.position.y}px`; + redrawEdges(); + }; + const onUp = (): void => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + dsgMarkDirty(); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }); + + (el.querySelector(".wf-cell-name") as HTMLInputElement).addEventListener("input", (e) => { + const cell = cellOf(); + if (cell) { + cell.name = (e.target as HTMLInputElement).value; + dsgMarkDirty(); + } + }); + + (el.querySelector(".wf-cell-delete") as HTMLButtonElement).addEventListener("click", () => { + const wf = dsgWf(); + if (!wf || dsgRunning()) return; + wf.cells = wf.cells.filter((c) => c.id !== cellId); + wf.edges = wf.edges.filter((e) => e.from !== cellId && e.to !== cellId); + if (dsgSelectedCellId === cellId) dsgSelectedCellId = null; + dsgMarkDirty(); + renderDesigner(); + }); + + (el.querySelector(".wf-cell-model") as HTMLSelectElement).addEventListener("change", (e) => { + const cell = cellOf(); + if (!cell) return; + const sel = e.target as HTMLSelectElement; + cell.model = { + value: sel.value, + label: sel.options[sel.selectedIndex]?.textContent || sel.value, + }; + dsgMarkDirty(); + }); + + (el.querySelector(".wf-cell-prompt") as HTMLTextAreaElement).addEventListener("input", (e) => { + const cell = cellOf(); + if (cell) { + cell.prompt = (e.target as HTMLTextAreaElement).value; + dsgMarkDirty(); + } + }); + + (el.querySelector(".wf-cell-tools-btn") as HTMLButtonElement).addEventListener("click", () => + openCellToolsModal(cellId) + ); + + (el.querySelector(".wf-cell-loopcap input") as HTMLInputElement).addEventListener( + "change", + (e) => { + const cell = cellOf(); + if (!cell) return; + const v = parseInt((e.target as HTMLInputElement).value, 10); + cell.maxLoopIterations = Number.isFinite(v) ? Math.min(20, Math.max(1, v)) : 5; + (e.target as HTMLInputElement).value = String(cell.maxLoopIterations); + dsgMarkDirty(); + } + ); + + // Edge creation from the output port. + const outPort = el.querySelector(".wf-port-out") as HTMLElement; + outPort.addEventListener("mousedown", (e) => { + e.preventDefault(); + e.stopPropagation(); + dsgStartEdgeDrag(cellId, e); + }); +} + +// --- edges --- + +function dsgPortPos( + cell: WorkflowCellConfig, + port: "in" | "out" | "feedback" +): { x: number; y: number } { + const el = dsgCellEls.get(cell.id); + const h = el ? el.offsetHeight : 140; + if (port === "out") return { x: cell.position.x + CELL_WIDTH, y: cell.position.y + h / 2 }; + if (port === "in") return { x: cell.position.x, y: cell.position.y + h / 2 }; + return { x: cell.position.x + CELL_WIDTH / 2, y: cell.position.y }; +} + +function dsgEdgePath(edge: WorkflowEdge): string { + const wf = dsgWf()!; + const from = wf.cells.find((c) => c.id === edge.from); + const to = wf.cells.find((c) => c.id === edge.to); + if (!from || !to) return ""; + const p1 = dsgPortPos(from, "out"); + if (edge.kind === "feedback") { + const p2 = dsgPortPos(to, "feedback"); + const dx = Math.max(50, Math.abs(p2.x - p1.x) / 2); + return `M ${p1.x} ${p1.y} C ${p1.x + dx} ${p1.y}, ${p2.x} ${p2.y - 70}, ${p2.x} ${p2.y}`; + } + const p2 = dsgPortPos(to, "in"); + const dx = Math.max(40, Math.abs(p2.x - p1.x) / 2); + return `M ${p1.x} ${p1.y} C ${p1.x + dx} ${p1.y}, ${p2.x - dx} ${p2.y}, ${p2.x} ${p2.y}`; +} + +function dsgEdgeMidpoint(edge: WorkflowEdge): { x: number; y: number } { + const wf = dsgWf()!; + const from = wf.cells.find((c) => c.id === edge.from); + const to = wf.cells.find((c) => c.id === edge.to); + if (!from || !to) return { x: 0, y: 0 }; + const p1 = dsgPortPos(from, "out"); + const p2 = dsgPortPos(to, edge.kind === "feedback" ? "feedback" : "in"); + return { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }; +} + +function redrawEdges(): void { + const svg = dsgEl("wfEdgeSvg"); + const wf = dsgWf(); + if (!svg || !wf) return; + const NS = "http://www.w3.org/2000/svg"; + svg.innerHTML = ""; + for (const edge of wf.edges) { + const d = dsgEdgePath(edge); + if (!d) continue; + + const path = document.createElementNS(NS, "path"); + path.setAttribute("d", d); + path.setAttribute( + "class", + `wf-edge wf-edge-${edge.kind}${dsgSelectedEdgeId === edge.id ? " selected" : ""}` + ); + svg.appendChild(path); + + // Fat invisible twin for clicks. + const hit = document.createElementNS(NS, "path"); + hit.setAttribute("d", d); + hit.setAttribute("class", "wf-edge-hit"); + hit.addEventListener("mousedown", (e) => { + e.stopPropagation(); + dsgSelectedEdgeId = edge.id; + dsgSelectedCellId = null; + for (const el of dsgCellEls.values()) el.classList.remove("selected"); + redrawEdges(); + renderDrawer(); + }); + hit.addEventListener("dblclick", (e) => { + e.stopPropagation(); + dsgEditEdgeLabel(edge.id); + }); + svg.appendChild(hit); + + if (edge.label) { + const mid = dsgEdgeMidpoint(edge); + const text = document.createElementNS(NS, "text"); + text.setAttribute("x", String(mid.x)); + text.setAttribute("y", String(mid.y - 6)); + text.setAttribute("class", "wf-edge-label"); + text.textContent = edge.label; + svg.appendChild(text); + } + } +} + +function dsgStartEdgeDrag(fromCellId: string, e: MouseEvent): void { + const svg = dsgEl("wfEdgeSvg"); + const wf = dsgWf(); + if (!svg || !wf || dsgRunning()) return; + const fromCell = wf.cells.find((c) => c.id === fromCellId)!; + const p1 = dsgPortPos(fromCell, "out"); + const NS = "http://www.w3.org/2000/svg"; + const ghost = document.createElementNS(NS, "path"); + ghost.setAttribute("class", "wf-edge wf-edge-ghost"); + svg.appendChild(ghost); + + const onMove = (me: MouseEvent): void => { + const p2 = dsgScreenToCanvas(me.clientX, me.clientY); + const dx = Math.max(40, Math.abs(p2.x - p1.x) / 2); + ghost.setAttribute( + "d", + `M ${p1.x} ${p1.y} C ${p1.x + dx} ${p1.y}, ${p2.x - dx} ${p2.y}, ${p2.x} ${p2.y}` + ); + }; + const onUp = (me: MouseEvent): void => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + ghost.remove(); + const target = document.elementFromPoint(me.clientX, me.clientY) as HTMLElement | null; + const cellEl = target?.closest(".wf-cell") as HTMLElement | null; + if (!cellEl || !cellEl.dataset.id || cellEl.dataset.id === fromCellId) return; + const kind: WorkflowEdgeKind = + target?.closest(".wf-port-feedback") != null ? "feedback" : "flow"; + const to = cellEl.dataset.id; + if (wf.edges.some((ed) => ed.from === fromCellId && ed.to === to && ed.kind === kind)) return; + wf.edges.push({ id: genId(), from: fromCellId, to, kind }); + dsgMarkDirty(); + redrawEdges(); + // A new feedback edge reveals the target's loop-cap control. + const toCell = wf.cells.find((c) => c.id === to); + if (toCell) renderCell(toCell); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + onMove(e); +} + +function dsgDeleteSelectedEdge(): void { + const wf = dsgWf(); + if (!wf || !dsgSelectedEdgeId || dsgRunning()) return; + const removed = wf.edges.find((e) => e.id === dsgSelectedEdgeId); + wf.edges = wf.edges.filter((e) => e.id !== dsgSelectedEdgeId); + dsgSelectedEdgeId = null; + dsgMarkDirty(); + redrawEdges(); + // Removing a feedback edge may hide the target's loop-cap control. + const toCell = removed && wf.cells.find((c) => c.id === removed.to); + if (toCell) renderCell(toCell); +} + +// Floating inline input for edge labels (window.prompt doesn't exist in +// Electron renderers). +function dsgEditEdgeLabel(edgeId: string): void { + const wf = dsgWf(); + const wrap = dsgEl("wfCanvasWrap"); + if (!wf || !wrap) return; + const edge = wf.edges.find((e) => e.id === edgeId); + if (!edge) return; + const mid = dsgEdgeMidpoint(edge); + const input = document.createElement("input"); + input.className = "wf-edge-label-input"; + input.value = edge.label || ""; + input.placeholder = "Edge label (e.g. spec)"; + input.style.left = `${mid.x * dsgView.scale + dsgView.x}px`; + input.style.top = `${mid.y * dsgView.scale + dsgView.y}px`; + wrap.appendChild(input); + input.focus(); + input.select(); + const commit = (): void => { + edge.label = input.value.trim() || undefined; + input.remove(); + dsgMarkDirty(); + redrawEdges(); + }; + input.addEventListener("blur", commit); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") input.blur(); + if (e.key === "Escape") { + input.removeEventListener("blur", commit); + input.remove(); + } + e.stopPropagation(); + }); +} + +// --- cells --- + +function addCell(at?: { x: number; y: number }): void { + const wf = dsgWf(); + if (!wf || dsgRunning()) return; + const wrap = dsgEl("wfCanvasWrap"); + const rect = wrap.getBoundingClientRect(); + const center = + at || + dsgScreenToCanvas(rect.left + rect.width / 2 - 120, rect.top + rect.height / 2 - 90); + const fallback = mason.defaultModel || { + value: mason.selectedModelValue, + label: mason.selectedModelLabel, + }; + wf.cells.push({ + id: genId(), + name: `Cell ${wf.cells.length + 1}`, + model: { ...fallback }, + enabledTools: [], + prompt: "", + position: { x: Math.round(center.x), y: Math.round(center.y) }, + }); + dsgMarkDirty(); + renderDesigner(); +} + +// --- per-cell tools modal --- + +let dsgToolsCellId: string | null = null; + +function openCellToolsModal(cellId: string): void { + const wf = dsgWf(); + const cell = wf?.cells.find((c) => c.id === cellId); + if (!cell) return; + dsgToolsCellId = cellId; + const modal = dsgEl("wfToolsModal"); + const list = dsgEl("wfToolsList"); + const title = dsgEl("wfToolsTitle"); + title.textContent = `Tools for "${cell.name}"`; + list.innerHTML = ""; + + const enabled = new Set(cell.enabledTools); + const all = getAllToolDefsUnfiltered(); + const bySource = new Map(); + for (const t of all) { + const src = t._source || "Other"; + if (!bySource.has(src)) bySource.set(src, []); + bySource.get(src)!.push(t); + } + for (const [source, tools] of bySource) { + const header = document.createElement("div"); + header.className = "wf-tools-group"; + header.textContent = source; + list.appendChild(header); + for (const t of tools) { + const name = t.function.name; + const row = document.createElement("label"); + row.className = "wf-tools-row"; + row.innerHTML = `${escapeHtml(name)}${escapeHtml((t.function.description || "").slice(0, 90))}`; + (row.querySelector("input") as HTMLInputElement).addEventListener("change", (e) => { + const on = (e.target as HTMLInputElement).checked; + if (on) enabled.add(name); + else enabled.delete(name); + cell.enabledTools = [...enabled]; + dsgMarkDirty(); + renderCell(cell); + }); + list.appendChild(row); + } + } + modal.classList.add("open"); +} + +// --- run / stop --- + +function dsgSetStatus(text: string, isError: boolean): void { + const el = dsgEl("wfStatus"); + if (!el) return; + el.textContent = text; + el.classList.toggle("error", isError); +} + +function dsgUpdateRunButton(): void { + const btn = dsgEl("wfRun"); + if (!btn) return; + const running = dsgRunning(); + btn.textContent = running ? "■ Stop" : "▶ Run"; + btn.classList.toggle("running", running); +} + +async function runCurrentWorkflow(): Promise { + const wf = dsgWf(); + if (!wf) return; + if (dsgRunning()) { + // Stop. + if (mason.workflowRun) mason.workflowRun.aborted = true; + window.api.abortChat(); + return; + } + if (mason.generating) { + dsgSetStatus("A chat response is still streaming — stop it first.", true); + return; + } + const errors = validateWorkflow(wf); + if (errors.length > 0) { + dsgSetStatus(errors[0] + (errors.length > 1 ? ` (+${errors.length - 1} more)` : ""), true); + return; + } + dsgSetStatus("", false); + + const callbacks: EngineCallbacks = { + onCellStatus: (cellId) => { + const cell = wf.cells.find((c) => c.id === cellId); + if (cell) renderCell(cell); + if (cellId === dsgSelectedCellId) renderDrawer(); + // Auto-follow the running cell so the drawer shows live output. + if (mason.workflowRun?.cells[cellId]?.status === "running") { + dsgSelectedCellId = cellId; + dsgSelectedEdgeId = null; + for (const [id, el] of dsgCellEls) el.classList.toggle("selected", id === cellId); + renderDrawer(); + } + dsgUpdateRunButton(); + }, + onCellTranscript: (cellId, entry) => { + mason.workflowRun?.cells[cellId]?.transcript.push(entry); + if (cellId === dsgSelectedCellId) renderDrawer(); + }, + onStreamText: (cellId, text) => { + if (cellId !== dsgSelectedCellId) return; + const live = document.getElementById("wfDrawerLive"); + if (live) { + live.innerHTML = renderMarkdown(text); + const body = dsgEl("wfDrawerBody"); + if (body) body.scrollTop = body.scrollHeight; + } + }, + askUser: (questions) => { + const body = dsgEl("wfDrawerBody"); + dsgEl("wfDrawer")?.classList.remove("collapsed"); + return renderQuestionCard(questions, body || undefined); + }, + }; + + dsgUpdateRunButton(); + try { + const state = await runWorkflow(wf, callbacks); + if (state.aborted) { + dsgSetStatus("Stopped.", false); + } else if (state.error) { + dsgSetStatus(state.error, true); + } else { + dsgSetStatus(`Done — ${state.totalSteps} cell run${state.totalSteps === 1 ? "" : "s"}.`, false); + } + } catch (e) { + dsgSetStatus((e as Error).message, true); + } finally { + dsgUpdateRunButton(); + renderDesigner(); + } +} + +// --- transcript drawer --- + +function renderDrawer(): void { + const title = dsgEl("wfDrawerTitle"); + const body = dsgEl("wfDrawerBody"); + if (!title || !body) return; + const wf = dsgWf(); + + if (dsgSelectedEdgeId && wf) { + const edge = wf.edges.find((e) => e.id === dsgSelectedEdgeId); + if (edge) { + const from = wf.cells.find((c) => c.id === edge.from)?.name || "?"; + const to = wf.cells.find((c) => c.id === edge.to)?.name || "?"; + title.textContent = `${edge.kind === "feedback" ? "Feedback edge" : "Edge"}: ${from} → ${to}`; + body.innerHTML = `
${edge.label ? `Label: ${escapeHtml(edge.label)}. ` : ""}Double-click the edge to ${edge.label ? "edit" : "add"} a label — labels become input headers downstream and route names for gates. Press Delete to remove the edge.
`; + return; + } + } + + const cell = wf?.cells.find((c) => c.id === dsgSelectedCellId); + if (!cell) { + title.textContent = "Transcript"; + body.innerHTML = + '
Select a cell to see its run transcript. Drag from a cell\'s right port onto another cell to connect them; drop on the top port for a feedback edge.
'; + return; + } + const rec = mason.workflowRun?.cells[cell.id]; + const st = dsgStatusInfo(cell.id); + title.innerHTML = `${escapeHtml(cell.name)} ${st.label}`; + body.innerHTML = ""; + if (!rec || (rec.transcript.length === 0 && !rec.output && rec.status !== "running")) { + body.innerHTML = '
No runs yet.
'; + return; + } + for (const entry of rec.transcript) { + const div = document.createElement("div"); + div.className = `wf-tr wf-tr-${entry.kind}`; + if (entry.kind === "assistant") { + div.innerHTML = renderMarkdown(entry.text); + } else { + div.textContent = entry.text; + } + body.appendChild(div); + } + if (rec.status === "running") { + const live = document.createElement("div"); + live.id = "wfDrawerLive"; + live.className = "wf-tr wf-tr-assistant"; + body.appendChild(live); + } else if (rec.output) { + const out = document.createElement("div"); + out.className = "wf-tr wf-tr-assistant wf-tr-output"; + out.innerHTML = renderMarkdown(rec.output); + body.appendChild(out); + } + if (rec.error) { + const err = document.createElement("div"); + err.className = "wf-tr wf-tr-error"; + err.textContent = rec.error; + body.appendChild(err); + } + body.scrollTop = body.scrollHeight; +} + +// --- template (spec section 12) --- + +function dsgFindModel(needle: string): { value: string; label: string } | null { + for (const g of mason.discoveredModels) { + for (const m of g.models) { + if (m.value.toLowerCase().includes(needle)) return { value: m.value, label: m.label }; + } + } + return null; +} + +function insertTemplateWorkflow(): void { + const fallback = mason.defaultModel || { + value: mason.selectedModelValue, + label: mason.selectedModelLabel, + }; + const fable = dsgFindModel("fable") || fallback; + const opus = dsgFindModel("opus") || fallback; + const sonnet = dsgFindModel("sonnet") || fallback; + + const spec: WorkflowCellConfig = { + id: genId(), + name: "Spec", + model: { ...fable }, + enabledTools: [], + prompt: + "You are the architect. Turn the high-level goals below into (1) a precise project specification and (2) a unit-test spec sheet listing concrete, verifiable acceptance tests. Be exhaustive but unambiguous.", + position: { x: 40, y: 160 }, + }; + const impl: WorkflowCellConfig = { + id: genId(), + name: "Implement", + model: { ...opus }, + enabledTools: ["write_file", "read_file"], + prompt: + "You are the implementer. Build exactly what the specification asks for. When you receive feedback about failing tests, close every gap it names.", + position: { x: 380, y: 40 }, + maxLoopIterations: 5, + }; + const tests: WorkflowCellConfig = { + id: genId(), + name: "Unit tests", + model: { ...sonnet }, + enabledTools: ["read_file"], + prompt: + "You are the test runner. Evaluate the implementation against the unit-test spec sheet, test by test. Report which pass and which fail with specifics. If any fail, route feedback to the implementer; when all pass, hand off for final review.", + position: { x: 720, y: 160 }, + }; + const review: WorkflowCellConfig = { + id: genId(), + name: "Final review", + model: { ...fable }, + enabledTools: [], + prompt: + "You are the original architect reviewing the finished work against your specification. If it fully satisfies the spec, end the workflow with a final summary. If gaps remain, route feedback to the implementer describing exactly what to fix.", + position: { x: 1060, y: 40 }, + }; + mason.currentWorkflow = { + id: genId(), + name: "Spec → Implement → Test → Review", + version: 1, + cells: [spec, impl, tests, review], + edges: [ + { id: genId(), from: spec.id, to: impl.id, kind: "flow", label: "goals" }, + { id: genId(), from: spec.id, to: tests.id, kind: "flow", label: "spec" }, + { id: genId(), from: impl.id, to: tests.id, kind: "flow", label: "implementation" }, + { id: genId(), from: tests.id, to: impl.id, kind: "feedback", label: "test failures" }, + { id: genId(), from: tests.id, to: review.id, kind: "flow", label: "passing work" }, + { id: genId(), from: review.id, to: impl.id, kind: "feedback", label: "review feedback" }, + ], + createdAt: Date.now(), + updatedAt: Date.now(), + }; + mason.workflowRun = null; + dsgSelectedCellId = null; + dsgSelectedEdgeId = null; + dsgView = { x: 30, y: 30, scale: 0.85 }; + dsgMarkDirty(); + renderDesigner(); + refreshWorkflowList(); +} + +// --- profile switch revalidation --- + +function designerOnProfileSwitch(): void { + if (mason.currentView === "designer") renderDesigner(); +} + +// --- init / event wiring --- + +function initDesigner(): void { + if (dsgInited) return; + dsgInited = true; + + dsgEl("wfAddCell")?.addEventListener("click", () => addCell()); + dsgEl("wfRun")?.addEventListener("click", () => runCurrentWorkflow()); + dsgEl("wfSave")?.addEventListener("click", () => saveWorkflow()); + dsgEl("wfNew")?.addEventListener("click", () => newWorkflow()); + dsgEl("wfTemplate")?.addEventListener("click", () => insertTemplateWorkflow()); + dsgEl("wfDeleteBtn")?.addEventListener("click", () => deleteWorkflow()); + dsgEl("wfName")?.addEventListener("input", () => { + const wf = dsgWf(); + if (wf) { + wf.name = dsgEl("wfName").value; + dsgMarkDirty(); + } + }); + dsgEl("wfSelect")?.addEventListener("change", (e) => { + const val = (e.target as HTMLSelectElement).value; + if (val === "__new__") newWorkflow(); + else openWorkflow(val); + }); + + const toolsModal = dsgEl("wfToolsModal"); + dsgEl("wfToolsClose")?.addEventListener("click", () => + toolsModal.classList.remove("open") + ); + toolsModal?.addEventListener("click", (e) => { + if (e.target === toolsModal) toolsModal.classList.remove("open"); + }); + + dsgEl("wfDrawerToggle")?.addEventListener("click", () => + dsgEl("wfDrawer")?.classList.toggle("collapsed") + ); + + // Canvas pan (drag empty space) + zoom (pinch / ctrl-wheel) + scroll-pan. + const wrap = dsgEl("wfCanvasWrap"); + wrap?.addEventListener("mousedown", (e) => { + const t = e.target as HTMLElement; + if (t.closest(".wf-cell") || t.closest(".wf-edge-label-input")) return; + // Clicking empty canvas clears selection. + dsgSelectedCellId = null; + dsgSelectedEdgeId = null; + for (const el of dsgCellEls.values()) el.classList.remove("selected"); + redrawEdges(); + renderDrawer(); + e.preventDefault(); + const start = { x: e.clientX, y: e.clientY }; + const orig = { ...dsgView }; + const onMove = (me: MouseEvent): void => { + dsgView.x = orig.x + (me.clientX - start.x); + dsgView.y = orig.y + (me.clientY - start.y); + dsgApplyTransform(); + }; + const onUp = (): void => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }); + wrap?.addEventListener( + "wheel", + (e) => { + e.preventDefault(); + if (e.ctrlKey || e.metaKey) { + // Pinch / ctrl-wheel zoom around the cursor. + const before = dsgScreenToCanvas(e.clientX, e.clientY); + const factor = Math.exp(-e.deltaY * 0.01); + dsgView.scale = Math.min(2, Math.max(0.25, dsgView.scale * factor)); + const rect = wrap.getBoundingClientRect(); + dsgView.x = e.clientX - rect.left - before.x * dsgView.scale; + dsgView.y = e.clientY - rect.top - before.y * dsgView.scale; + } else { + dsgView.x -= e.deltaX; + dsgView.y -= e.deltaY; + } + dsgApplyTransform(); + }, + { passive: false } + ); + wrap?.addEventListener("dblclick", (e) => { + const t = e.target as HTMLElement; + if (t.closest(".wf-cell")) return; + const pos = dsgScreenToCanvas(e.clientX, e.clientY); + addCell({ x: pos.x - 120, y: pos.y - 60 }); + }); + + // Delete selected edge (not while typing in a field). + document.addEventListener("keydown", (e) => { + if (mason.currentView !== "designer") return; + const tag = (document.activeElement?.tagName || "").toLowerCase(); + if (tag === "input" || tag === "textarea" || tag === "select") return; + if ((e.key === "Delete" || e.key === "Backspace") && dsgSelectedEdgeId) { + e.preventDefault(); + dsgDeleteSelectedEdge(); + } + }); + + // Autosave every 10s while the designer is open (mirrors chat autosave). + setInterval(() => { + if (mason.currentView === "designer" && mason.workflowDirty && !dsgRunning() && dsgWf()) { + saveWorkflow(); + } + }, 10_000); +} diff --git a/src/history.ts b/src/history.ts index b54c4bf..f047dac 100644 --- a/src/history.ts +++ b/src/history.ts @@ -5,6 +5,7 @@ declare function selectModelByValue(value: string): void; declare function renderMessages(): void; declare function newChat(): void; declare function genId(): string; +declare function switchToChatsTab(): void; interface HistoryListItem { id: string; @@ -39,6 +40,9 @@ async function loadChat(id: string): Promise { | { id: string; title?: string; model?: string; messages: unknown[] } | null; if (!data) return; + // Loading a chat while the designer is open should land the user back in + // the chat pane, like newChat() does. + if (mason.currentView === "designer") switchToChatsTab(); mason.currentChatId = id; mason.history = data.messages; // Only restore the saved model if this workspace actually has it. diff --git a/src/main.ts b/src/main.ts index 55bcc6a..2015063 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,6 +23,7 @@ try { const MASON_HOME = path.join(os.homedir(), ".mason"); const HISTORY_DIR = path.join(MASON_HOME, "chat_history"); +const WORKFLOWS_DIR = path.join(MASON_HOME, "workflows"); const CONFIG_DIR = path.join(MASON_HOME, "config"); const BIN_DIR = path.join(MASON_HOME, "bin"); const WORKSPACES_FILE = path.join(CONFIG_DIR, "workspaces.json"); @@ -245,6 +246,60 @@ ipcMain.handle("history-delete", (_event: IpcMainInvokeEvent, id: string) => { if (fs.existsSync(filePath)) fs.unlinkSync(filePath); }); +// --- Workflow designer persistence (~/.mason/workflows/.json) --- + +function ensureWorkflowsDir(): void { + if (!fs.existsSync(WORKFLOWS_DIR)) fs.mkdirSync(WORKFLOWS_DIR); +} + +// Workflow ids come from genId() in the renderer, but never trust a +// renderer-supplied string as a path segment. +function workflowFilePath(id: string): string | null { + if (!/^[a-zA-Z0-9_-]{1,64}$/.test(id)) return null; + return path.join(WORKFLOWS_DIR, `${id}.json`); +} + +ipcMain.handle("workflow-list", () => { + ensureWorkflowsDir(); + const files = fs.readdirSync(WORKFLOWS_DIR).filter((f) => f.endsWith(".json")); + const out: Array<{ id: string; name: string; updatedAt: number }> = []; + for (const f of files) { + try { + const data = JSON.parse(fs.readFileSync(path.join(WORKFLOWS_DIR, f), "utf-8")); + out.push({ + id: f.replace(/\.json$/, ""), + name: data.name || "Untitled workflow", + updatedAt: data.updatedAt || 0, + }); + } catch (_) {} + } + return out.sort((a, b) => b.updatedAt - a.updatedAt); +}); + +ipcMain.handle("workflow-load", (_event: IpcMainInvokeEvent, id: string) => { + const filePath = workflowFilePath(id); + if (!filePath || !fs.existsSync(filePath)) return null; + return JSON.parse(fs.readFileSync(filePath, "utf-8")); +}); + +ipcMain.handle("workflow-save", (_event: IpcMainInvokeEvent, wf: any) => { + ensureWorkflowsDir(); + const filePath = workflowFilePath(String(wf?.id || "")); + if (!filePath) return { ok: false, error: "Invalid workflow id" }; + // Atomic write: temp file + rename, so a crash mid-write can't corrupt + // the saved workflow. + const tmp = `${filePath}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(wf, null, 2)); + fs.renameSync(tmp, filePath); + return { ok: true }; +}); + +ipcMain.handle("workflow-delete", (_event: IpcMainInvokeEvent, id: string) => { + const filePath = workflowFilePath(id); + if (filePath && fs.existsSync(filePath)) fs.unlinkSync(filePath); + return { ok: true }; +}); + // --- OAuth via Databricks CLI --- interface TokenCacheEntry { diff --git a/src/messages.ts b/src/messages.ts index 07d5c93..14be21b 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -121,12 +121,16 @@ interface AskUserQuestion { // questions in sequence (single chat bubble — no round-trip to the model // between questions). Resolves with a JSON-stringified record of // { question: answer } pairs, or the literal "user_cancelled" if the user -// cancels at any step. -function renderQuestionCard(questions: AskUserQuestion[]): Promise { +// cancels at any step. `container` defaults to the chat messages pane; the +// workflow designer passes its transcript drawer instead. +function renderQuestionCard( + questions: AskUserQuestion[], + container?: HTMLElement +): Promise { return new Promise((resolve) => { removeThinking(); clearWelcome(); - const messagesEl = mason.el.messages as HTMLElement | null; + const messagesEl = container || (mason.el.messages as HTMLElement | null); const list = (questions || []).slice(0, 4); if (!messagesEl || list.length === 0) { resolve("user_cancelled"); diff --git a/src/preload.ts b/src/preload.ts index 180eb9e..f7becc9 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -70,6 +70,11 @@ const api: MasonApi = { settingsLoad: () => ipcRenderer.invoke("settings-load"), settingsSave: (partial) => ipcRenderer.invoke("settings-save", partial), + workflowList: () => ipcRenderer.invoke("workflow-list"), + workflowLoad: (id) => ipcRenderer.invoke("workflow-load", id), + workflowSave: (wf) => ipcRenderer.invoke("workflow-save", wf), + workflowDelete: (id) => ipcRenderer.invoke("workflow-delete", id), + skillsList: () => ipcRenderer.invoke("skills-list"), skillsLoad: (slug) => ipcRenderer.invoke("skills-load", slug), skillsSave: (params) => ipcRenderer.invoke("skills-save", params), diff --git a/src/shared/api.ts b/src/shared/api.ts index 11d5c41..4d1b39e 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -202,6 +202,12 @@ export interface MasonSkillsConfig { autoLoadSkills: boolean; } +export interface WorkflowSummary { + id: string; + name: string; + updatedAt: number; +} + export interface MasonApi { // Streaming chat chunk listener onChatChunk(callback: (chunk: ChatChunk) => void): void; @@ -288,6 +294,12 @@ export interface MasonApi { onDevkitInstallProgress(callback: (payload: DevkitInstallProgress) => void): void; removeDevkitInstallListeners(): void; + // Workflow designer + workflowList(): Promise; + workflowLoad(id: string): Promise; + workflowSave(wf: unknown): Promise<{ ok: boolean; error?: string }>; + workflowDelete(id: string): Promise<{ ok: boolean }>; + // Skills skillsList(): Promise; skillsLoad(slug: string): Promise<{ slug: string; name: string; description: string; body: string } | null>; diff --git a/src/state.ts b/src/state.ts index b72b7c6..42e643f 100644 --- a/src/state.ts +++ b/src/state.ts @@ -38,6 +38,12 @@ window.mason = { disabledSkills: new Set(), autoLoadSkills: true, + // Workflow designer + workflows: [], + currentWorkflow: null, + workflowRun: null, + workflowDirty: false, + // DOM refs (populated on init) el: {}, }; diff --git a/src/tools.ts b/src/tools.ts index 7e5c72b..49d9bec 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -108,14 +108,17 @@ const BUILTIN_TOOL_NAMES = new Set(BUILTIN_TOOLS.map((t) => t.function.name)); // these inline so they can render UI and await user input. const RENDERER_BUILTIN_TOOL_NAMES = new Set(["ask_user", "load_skill"]); -function getAllToolDefs(): ToolDef[] { - const tools: ToolDef[] = BUILTIN_TOOLS.filter( - (t) => !mason.disabledTools.has(t.function.name) - ); +// Without an allowlist: every tool the user hasn't globally disabled (chat +// behavior). With an allowlist (workflow cells): only tools that are both +// available and named in the set — per-cell narrowing, never widening. +function getAllToolDefs(allowlist?: Set): ToolDef[] { + const allowed = (name: string): boolean => + allowlist ? allowlist.has(name) : !mason.disabledTools.has(name); + const tools: ToolDef[] = BUILTIN_TOOLS.filter((t) => allowed(t.function.name)); for (const server of mason.mcpServers) { const serverTools = (server.tools || []) as McpToolDescriptor[]; for (const tool of serverTools) { - if (mason.disabledTools.has(tool.name)) continue; + if (!allowed(tool.name)) continue; tools.push({ type: "function", function: { diff --git a/src/types/state.d.ts b/src/types/state.d.ts index 1668cf2..3327bcb 100644 --- a/src/types/state.d.ts +++ b/src/types/state.d.ts @@ -97,7 +97,13 @@ declare global { settings: MasonSettings; systemPrompt: string; - currentView: "chat" | "dashboards" | "dashboard-detail" | "settings" | "onboarding"; + currentView: + | "chat" + | "dashboards" + | "dashboard-detail" + | "settings" + | "onboarding" + | "designer"; dashboardsList: MasonDashboard[]; dashboardsLoading?: boolean; autoConnectDone: boolean; @@ -106,6 +112,12 @@ declare global { disabledSkills: Set; autoLoadSkills: boolean; + // Workflow designer + workflows: MasonWorkflowSummary[]; + currentWorkflow: MasonWorkflow | null; + workflowRun: WorkflowRunState | null; + workflowDirty: boolean; + defaultModel?: { value: string; label: string } | null; el: Record; diff --git a/src/types/workflow.d.ts b/src/types/workflow.d.ts new file mode 100644 index 0000000..a718f54 --- /dev/null +++ b/src/types/workflow.d.ts @@ -0,0 +1,68 @@ +// Ambient types for the Agentic Workflow Designer. +// See docs/specs/agentic-workflow-designer.md for semantics. + +declare global { + interface WorkflowCellConfig { + id: string; + name: string; + model: { value: string; label: string }; + enabledTools: string[]; + prompt: string; + position: { x: number; y: number }; + maxLoopIterations?: number; // feedback re-entry cap, default 5 + } + + type WorkflowEdgeKind = "flow" | "feedback"; + + interface WorkflowEdge { + id: string; + from: string; // cell id + to: string; // cell id + kind: WorkflowEdgeKind; + label?: string; // input header downstream / route name for gates + } + + interface MasonWorkflow { + id: string; + name: string; + version: 1; + cells: WorkflowCellConfig[]; + edges: WorkflowEdge[]; + createdAt: number; + updatedAt: number; + } + + interface MasonWorkflowSummary { + id: string; + name: string; + updatedAt: number; + } + + type CellRunStatus = "idle" | "queued" | "running" | "done" | "failed" | "skipped"; + + interface CellTranscriptEntry { + kind: "assistant" | "tool-call" | "tool-result" | "error" | "verdict" | "info"; + text: string; + } + + interface CellRunRecord { + status: CellRunStatus; + transcript: CellTranscriptEntry[]; + output: string; // final assistant text of the most recent run + verdict?: { route: string; notes: string }; + iterations: number; // feedback re-entries (1 = first run) + error?: string; + } + + interface WorkflowRunState { + workflowId: string; + running: boolean; + aborted: boolean; + cells: Record; + startedAt: number; + totalSteps: number; // cell runs consumed from the global budget + error?: string; + } +} + +export {}; diff --git a/src/workflow-engine.ts b/src/workflow-engine.ts new file mode 100644 index 0000000..fef38b7 --- /dev/null +++ b/src/workflow-engine.ts @@ -0,0 +1,659 @@ +// Workflow execution engine for the Agentic Workflow Designer. +// See docs/specs/agentic-workflow-designer.md (Section 6) for semantics. +// +// A workflow run is a sequence of cell runs; each cell run is one bounded +// headless agent loop (reusing resolveModelRouting / executeToolCore from +// agent-runner.ts); edges define order and context. Cells run one at a time — +// main.ts has a single in-flight chat controller, so sequential execution +// keeps Stop and chunk routing correct (spec 7.6 defers parallelism). + +// --- budgets (spec 6.4) --- +const WORKFLOW_GLOBAL_BUDGET = 25; // cell runs per workflow run +const WORKFLOW_INNER_BUDGET = 40; // agent-loop iterations within one cell run +const WORKFLOW_DEFAULT_LOOP_CAP = 5; // feedback re-entries per cell +const ROUTE_TOOL_NAME = "route_output"; + +interface EngineCallbacks { + onCellStatus(cellId: string, status: CellRunStatus): void; + onCellTranscript(cellId: string, entry: CellTranscriptEntry): void; + // Streamed text of the in-progress assistant turn (full text so far). + onStreamText(cellId: string, text: string): void; + askUser( + questions: Array<{ question: string; options: string[]; multiSelect?: boolean }> + ): Promise; +} + +// --- graph helpers --- + +function wfFlowEdges(wf: MasonWorkflow): WorkflowEdge[] { + return wf.edges.filter((e) => e.kind === "flow"); +} + +function wfCellById(wf: MasonWorkflow, id: string): WorkflowCellConfig | undefined { + return wf.cells.find((c) => c.id === id); +} + +// Route key shown to the model for an outgoing edge: explicit label first, +// target cell name fallback. +function wfRouteKey(wf: MasonWorkflow, edge: WorkflowEdge): string { + if (edge.label && edge.label.trim()) return edge.label.trim(); + return wfCellById(wf, edge.to)?.name || edge.to; +} + +// Input section header the downstream model sees for an inbound edge. +function wfInputLabel(wf: MasonWorkflow, edge: WorkflowEdge): string { + if (edge.label && edge.label.trim()) return edge.label.trim(); + return wfCellById(wf, edge.from)?.name || edge.from; +} + +function wfIsGate(wf: MasonWorkflow, cellId: string): boolean { + return wf.edges.some((e) => e.from === cellId && e.kind === "feedback"); +} + +// Flow-descendants of a cell (feedback edges don't propagate invalidation). +function wfFlowDescendants(wf: MasonWorkflow, startId: string): Set { + const out = new Set(); + const queue = [startId]; + while (queue.length > 0) { + const id = queue.shift()!; + for (const e of wfFlowEdges(wf)) { + if (e.from === id && !out.has(e.to)) { + out.add(e.to); + queue.push(e.to); + } + } + } + return out; +} + +// Validation per spec 4.3. Returns a list of human-readable errors; empty +// means runnable. +function validateWorkflow(wf: MasonWorkflow): string[] { + const errors: string[] = []; + if (wf.cells.length === 0) { + errors.push("Add at least one cell."); + return errors; + } + const ids = new Set(wf.cells.map((c) => c.id)); + for (const c of wf.cells) { + if (!c.model?.value) errors.push(`Cell "${c.name}" has no model selected.`); + } + for (const e of wf.edges) { + if (!ids.has(e.from) || !ids.has(e.to)) errors.push("An edge points at a deleted cell."); + if (e.from === e.to) errors.push("A cell cannot connect to itself."); + } + + // Flow-edge-only cycles are illegal (cycles must include a feedback edge). + // Kahn's algorithm: if we can't consume every cell, there's a flow cycle. + const indeg = new Map(); + for (const c of wf.cells) indeg.set(c.id, 0); + for (const e of wfFlowEdges(wf)) { + if (ids.has(e.to)) indeg.set(e.to, (indeg.get(e.to) || 0) + 1); + } + const queue = wf.cells.filter((c) => (indeg.get(c.id) || 0) === 0).map((c) => c.id); + let consumed = 0; + while (queue.length > 0) { + const id = queue.shift()!; + consumed += 1; + for (const e of wfFlowEdges(wf)) { + if (e.from !== id) continue; + const d = (indeg.get(e.to) || 0) - 1; + indeg.set(e.to, d); + if (d === 0) queue.push(e.to); + } + } + if (consumed < wf.cells.length) { + errors.push( + "Cells are connected in a loop of solid (flow) edges. Loops are only allowed through dashed feedback edges." + ); + } + + // Model availability in the current profile. + for (const c of wf.cells) { + if (!c.model?.value) continue; + if (!workflowModelAvailable(c.model.value)) { + errors.push( + `Cell "${c.name}" uses model "${c.model.label || c.model.value}", which isn't available in this workspace.` + ); + } + } + return errors; +} + +function workflowModelAvailable(value: string): boolean { + if (value.startsWith("custom:")) { + const id = value.replace("custom:", ""); + return mason.customEndpoints.some((e) => e.modelId === id); + } + return mason.discoveredModels.some((g) => g.models.some((m) => m.value === value)); +} + +// --- context assembly (spec 6.2) --- + +interface FeedbackPayload { + fromCell: string; + notes: string; + prevOutput: string; + iteration: number; +} + +function buildCellPreamble( + wf: MasonWorkflow, + cell: WorkflowCellConfig, + routes: Array<{ key: string; edge: WorkflowEdge }> | null, + runState: WorkflowRunState | null +): string { + const lines: string[] = [ + `You are the cell "${cell.name}" in the agentic workflow "${wf.name}".`, + `Workflow cells run in sequence; your final message becomes the input of downstream cells.`, + ]; + const downstream = wfFlowEdges(wf) + .filter((e) => e.from === cell.id) + .map((e) => wfCellById(wf, e.to)?.name || e.to); + if (downstream.length > 0 && !routes) { + lines.push(`Your output will be passed to: ${downstream.join(", ")}.`); + } + if (routes) { + lines.push( + "", + `When your work is complete you MUST call the "${ROUTE_TOOL_NAME}" tool exactly once to decide where the workflow goes next. Available routes:` + ); + for (const r of routes) { + const target = wfCellById(wf, r.edge.to); + const targetName = target?.name || r.edge.to; + if (r.edge.kind === "feedback" && target) { + const cap = target.maxLoopIterations || WORKFLOW_DEFAULT_LOOP_CAP; + const used = runState?.cells[target.id]?.iterations || 0; + lines.push( + ` - "${r.key}": send feedback back to the "${targetName}" cell (revision ${used} of ${cap} used — after ${cap} this route closes)` + ); + } else { + lines.push(` - "${r.key}": hand off to the "${targetName}" cell`); + } + } + lines.push(` - "end": the workflow is complete; provide a final summary in notes`); + lines.push( + `Feedback loops are bounded — do not chase perfection. Judge against a concrete bar: once the work meets it, move forward or end. Reserve feedback routes for specific, fixable problems, and make each piece of feedback materially different from the last.` + ); + lines.push(`Do not end your turn without calling ${ROUTE_TOOL_NAME}.`); + } + return lines.join("\n"); +} + +function buildCellMessages( + wf: MasonWorkflow, + cell: WorkflowCellConfig, + delivered: Map, + feedback: FeedbackPayload | null, + routes: Array<{ key: string; edge: WorkflowEdge }> | null, + runState: WorkflowRunState | null +): any[] { + const messages: any[] = []; + if (cell.prompt.trim()) messages.push({ role: "system", content: cell.prompt.trim() }); + messages.push({ role: "system", content: buildCellPreamble(wf, cell, routes, runState) }); + + const sections: string[] = []; + for (const e of wfFlowEdges(wf)) { + if (e.to !== cell.id) continue; + const payload = delivered.get(e.id); + if (payload === undefined) continue; + // Annotate revised work with its revision count so gates feel the + // convergence pressure ("revision 4 of 5" reads very differently from a + // first draft). + const source = wfCellById(wf, e.from); + const srcIters = (source && runState?.cells[source.id]?.iterations) || 0; + const srcCap = source?.maxLoopIterations || WORKFLOW_DEFAULT_LOOP_CAP; + const revNote = srcIters > 1 ? ` (revision ${srcIters} of ${srcCap})` : ""; + sections.push(`## Input from "${wfInputLabel(wf, e)}"${revNote}\n\n${payload}`); + } + if (feedback) { + const cap = cell.maxLoopIterations || WORKFLOW_DEFAULT_LOOP_CAP; + sections.push( + `## Feedback from "${feedback.fromCell}" (revision ${feedback.iteration} of ${cap})\n\n${feedback.notes}` + ); + if (feedback.prevOutput) { + sections.push(`## Your previous output\n\n${feedback.prevOutput}`); + } + } + messages.push({ + role: "user", + content: sections.length > 0 ? sections.join("\n\n") : "Begin. Follow your instructions.", + }); + return messages; +} + +function buildRouteTool(routes: Array<{ key: string; edge: WorkflowEdge }>): ToolDef { + return { + type: "function", + function: { + name: ROUTE_TOOL_NAME, + description: + "REQUIRED final action: choose where this workflow goes next. Call exactly once, when your work in this cell is complete.", + parameters: { + type: "object", + properties: { + route: { + type: "string", + enum: [...routes.map((r) => r.key), "end"], + description: "The route to take next, or 'end' to complete the workflow.", + }, + notes: { + type: "string", + description: + "Feedback or instructions for the target cell — or the final summary if ending.", + }, + }, + required: ["route", "notes"], + }, + }, + }; +} + +// --- cell run (headless agent loop) --- + +interface CellRunOutcome { + outcome: "text" | "verdict" | "failed" | "aborted"; + finalText: string; + verdict?: { route: string; notes: string }; + error?: string; +} + +// Called when a gate proposes a verdict. Return null to accept, or an error +// string to reject (sent back as the tool result; the loop continues). +type VerdictGatekeeper = (verdict: { route: string; notes: string }) => string | null; + +async function runCellLoop( + wf: MasonWorkflow, + cell: WorkflowCellConfig, + initialMessages: any[], + routes: Array<{ key: string; edge: WorkflowEdge }> | null, + gatekeeper: VerdictGatekeeper | null, + runState: WorkflowRunState, + cb: EngineCallbacks +): Promise { + const transcript: any[] = [...initialMessages]; + + const allowlist = new Set(cell.enabledTools); + const toolDefs = getAllToolDefs(allowlist); + const missing = cell.enabledTools.filter( + (n) => !toolDefs.some((t) => t.function.name === n) + ); + if (missing.length > 0) { + console.warn(`[WORKFLOW] Cell "${cell.name}": tools unavailable, dropped: ${missing.join(", ")}`); + } + const toolsForApi: any[] = toolDefs.map(({ type, function: fn }) => ({ type, function: fn })); + if (routes) toolsForApi.push(buildRouteTool(routes)); + + let routeReminders = 0; + let budget = WORKFLOW_INNER_BUDGET; + + while (budget-- > 0) { + if (runState.aborted) return { outcome: "aborted", finalText: "" }; + + const token = await getAuthToken(); + const routing = resolveModelRouting(cell.model.value, toolsForApi.length > 0); + const canStream = routing.format !== "responses"; + + let streamedText = ""; + if (canStream) { + window.api.onChatChunk((chunk: any) => { + streamedText += chunk; + cb.onStreamText(cell.id, streamedText); + }); + } + + let result: any; + try { + result = await window.api.chat({ + token, + model: routing.model, + messages: transcript, + tools: toolsForApi.length > 0 ? toolsForApi : undefined, + gateway: routing.gateway, + format: routing.format, + stream: canStream, + }); + } catch (e) { + if (canStream) window.api.removeChatChunkListeners(); + if (runState.aborted) return { outcome: "aborted", finalText: streamedText }; + return { outcome: "failed", finalText: "", error: (e as Error).message }; + } + if (canStream) window.api.removeChatChunkListeners(); + if (runState.aborted) return { outcome: "aborted", finalText: streamedText }; + + if (result.type === "text") { + const content = (result.content || "").trim(); + if (!content) { + return { + outcome: "failed", + finalText: "", + error: + "Model returned an empty response — likely hit its token budget mid-thinking. Try a smaller prompt or a different model.", + }; + } + if (routes) { + // Gates must route. Remind twice, then fail (spec 6.3 fallback). + if (routeReminders < 2) { + routeReminders += 1; + transcript.push({ role: "assistant", content }); + cb.onCellTranscript(cell.id, { kind: "assistant", text: content }); + transcript.push({ + role: "user", + content: `You must now call the ${ROUTE_TOOL_NAME} tool to choose the next route. Do not reply with text.`, + }); + continue; + } + return { + outcome: "failed", + finalText: content, + error: `Cell "${cell.name}" never called ${ROUTE_TOOL_NAME}. Use a tool-capable model for cells with feedback edges.`, + }; + } + cb.onCellTranscript(cell.id, { kind: "assistant", text: content }); + return { outcome: "text", finalText: content }; + } + + if (result.type === "tool_calls") { + transcript.push({ + role: "assistant", + content: result.content || null, + tool_calls: result.tool_calls, + }); + if (result.content) { + cb.onCellTranscript(cell.id, { kind: "assistant", text: result.content }); + } + + for (const tc of result.tool_calls || []) { + const toolName = tc.function.name; + let args: Record = {}; + try { + args = JSON.parse(tc.function.arguments) as Record; + } catch (_) {} + + if (toolName === ROUTE_TOOL_NAME && routes) { + const verdict = { + route: String(args.route || ""), + notes: String(args.notes || ""), + }; + const rejection = gatekeeper ? gatekeeper(verdict) : null; + const known = + verdict.route === "end" || routes.some((r) => r.key === verdict.route); + if (!known) { + transcript.push({ + role: "tool", + tool_call_id: tc.id, + name: toolName, + content: `Error: "${verdict.route}" is not a valid route. Choose one of: ${[...routes.map((r) => r.key), "end"].join(", ")}.`, + }); + continue; + } + if (rejection) { + cb.onCellTranscript(cell.id, { kind: "info", text: rejection }); + transcript.push({ + role: "tool", + tool_call_id: tc.id, + name: toolName, + content: `Error: ${rejection}`, + }); + continue; + } + // Accepted — the verdict ends the cell run. The latest assistant + // text (preamble of this turn, or the previous turn's output) + // stands as the cell's output. + const lastText = + result.content || + [...transcript].reverse().find((m: any) => m.role === "assistant" && typeof m.content === "string" && m.content)?.content || + ""; + return { outcome: "verdict", finalText: lastText, verdict }; + } + + if (toolName === "ask_user") { + let questions: Array<{ question: string; options: string[]; multiSelect?: boolean }>; + if (Array.isArray(args.questions)) { + questions = args.questions as any[]; + } else if (typeof args.question === "string") { + questions = [ + { + question: args.question as string, + options: (args.options as string[]) || [], + multiSelect: Boolean(args.multiSelect), + }, + ]; + } else { + questions = []; + } + const answer = await cb.askUser(questions); + transcript.push({ + role: "tool", + tool_call_id: tc.id, + name: toolName, + content: answer, + }); + continue; + } + + cb.onCellTranscript(cell.id, { kind: "tool-call", text: `Calling tool: ${toolName}` }); + const r = await executeToolCore(toolName, args); + transcript.push({ + role: "tool", + tool_call_id: tc.id, + name: toolName, + content: r.content, + }); + cb.onCellTranscript(cell.id, { + kind: r.ok ? "tool-result" : "error", + text: r.ok ? `${toolName}: ${r.preview}` : `Tool error (${toolName}): ${r.preview}`, + }); + if (runState.aborted) return { outcome: "aborted", finalText: "" }; + } + continue; + } + + return { outcome: "failed", finalText: "", error: "Unexpected response type from gateway." }; + } + + return { + outcome: "failed", + finalText: "", + error: `Cell "${cell.name}" hit the ${WORKFLOW_INNER_BUDGET}-step agent-loop budget without finishing.`, + }; +} + +// --- workflow run (scheduler) --- + +async function runWorkflow(wf: MasonWorkflow, cb: EngineCallbacks): Promise { + const state: WorkflowRunState = { + workflowId: wf.id, + running: true, + aborted: false, + cells: {}, + startedAt: Date.now(), + totalSteps: 0, + }; + for (const c of wf.cells) { + state.cells[c.id] = { status: "idle", transcript: [], output: "", iterations: 0 }; + cb.onCellStatus(c.id, "idle"); + } + mason.workflowRun = state; + + // Payload delivered along each flow edge (edge id → text). + const delivered = new Map(); + const resolved = new Set(); // done or skipped + const pendingFeedback = new Map(); + + console.log(`[WORKFLOW] Run started: "${wf.name}" (${wf.cells.length} cells, ${wf.edges.length} edges)`); + + const inboundFlow = (cellId: string): WorkflowEdge[] => + wfFlowEdges(wf).filter((e) => e.to === cellId); + + // Stable order: topological-ish by canvas position (top-to-bottom, + // left-to-right) so execution order is visually predictable among ties. + const cellOrder = [...wf.cells].sort( + (a, b) => a.position.y - b.position.y || a.position.x - b.position.x + ); + + try { + while (!state.aborted) { + // Resolve skippable cells first: all inbound resolved, none delivered. + let movedSomething = true; + while (movedSomething) { + movedSomething = false; + for (const cell of cellOrder) { + if (resolved.has(cell.id)) continue; + const inbound = inboundFlow(cell.id); + if (inbound.length === 0) continue; + const allResolved = inbound.every((e) => resolved.has(e.from)); + const anyDelivered = inbound.some((e) => delivered.has(e.id)); + if (allResolved && !anyDelivered) { + resolved.add(cell.id); + state.cells[cell.id].status = "skipped"; + cb.onCellStatus(cell.id, "skipped"); + movedSomething = true; + } + } + } + + // Pick the next eligible cell. + const next = cellOrder.find((cell) => { + if (resolved.has(cell.id)) return false; + const inbound = inboundFlow(cell.id); + if (!inbound.every((e) => resolved.has(e.from))) return false; + return inbound.length === 0 || inbound.some((e) => delivered.has(e.id)); + }); + if (!next) break; // nothing left to run — workflow complete + + if (state.totalSteps >= WORKFLOW_GLOBAL_BUDGET) { + state.error = `Workflow hit the global budget of ${WORKFLOW_GLOBAL_BUDGET} cell runs. Check for runaway feedback loops.`; + break; + } + state.totalSteps += 1; + + const rec = state.cells[next.id]; + rec.status = "running"; + rec.iterations += 1; + rec.error = undefined; + rec.verdict = undefined; + rec.transcript = []; + cb.onCellStatus(next.id, "running"); + + const outgoing = wf.edges.filter((e) => e.from === next.id); + const isGate = outgoing.some((e) => e.kind === "feedback"); + // Route keys must be unique; disambiguate duplicates with a suffix. + let routes: Array<{ key: string; edge: WorkflowEdge }> | null = null; + if (isGate) { + const seen = new Set(); + routes = outgoing.map((edge) => { + let key = wfRouteKey(wf, edge); + while (seen.has(key) || key === "end") key = `${key}-2`; + seen.add(key); + return { key, edge }; + }); + } + + const feedback = pendingFeedback.get(next.id) || null; + pendingFeedback.delete(next.id); + const messages = buildCellMessages(wf, next, delivered, feedback, routes, state); + + console.log( + `[WORKFLOW] Cell "${next.name}" running (model ${next.model.value}, ${next.enabledTools.length} tools${isGate ? ", gate" : ""}${feedback ? `, iteration ${rec.iterations}` : ""})` + ); + + // Gatekeeper: reject feedback routes whose target is out of loop budget + // (spec 6.4 — force the gate to choose another route). + const gatekeeper: VerdictGatekeeper | null = routes + ? (verdict) => { + if (verdict.route === "end") return null; + const r = routes!.find((x) => x.key === verdict.route); + if (!r || r.edge.kind !== "feedback") return null; + const target = wfCellById(wf, r.edge.to)!; + const cap = target.maxLoopIterations || WORKFLOW_DEFAULT_LOOP_CAP; + if (state.cells[target.id].iterations >= cap) { + return `Loop budget exhausted for "${target.name}" (${cap} iterations). Choose a different route or "end".`; + } + return null; + } + : null; + + const out = await runCellLoop(wf, next, messages, routes, gatekeeper, state, cb); + + if (out.outcome === "aborted") { + rec.status = "failed"; + rec.error = "Stopped by user."; + cb.onCellStatus(next.id, "failed"); + break; + } + if (out.outcome === "failed") { + rec.status = "failed"; + rec.error = out.error; + cb.onCellTranscript(next.id, { kind: "error", text: out.error || "Cell failed." }); + cb.onCellStatus(next.id, "failed"); + state.error = out.error; + break; + } + + rec.output = out.finalText; + rec.status = "done"; + resolved.add(next.id); + cb.onCellStatus(next.id, "done"); + + if (out.outcome === "text") { + // Non-gate: deliver output on every outgoing flow edge (fan-out). + for (const e of outgoing) { + if (e.kind === "flow") delivered.set(e.id, out.finalText); + } + continue; + } + + // Gate verdict handling. + const verdict = out.verdict!; + rec.verdict = verdict; + cb.onCellTranscript(next.id, { + kind: "verdict", + text: `Route: ${verdict.route} — ${verdict.notes}`, + }); + console.log( + `[WORKFLOW] Cell "${next.name}" verdict: route="${verdict.route}" (iteration ${rec.iterations})` + ); + + if (verdict.route === "end") continue; // delivers nothing; downstream skips + + const chosen = routes!.find((r) => r.key === verdict.route)!; + if (chosen.edge.kind === "flow") { + const payload = out.finalText + ? `${out.finalText}\n\n## Notes from "${next.name}"\n\n${verdict.notes}` + : verdict.notes; + delivered.set(chosen.edge.id, payload); + continue; + } + + // Feedback route: re-open the target and everything downstream of it. + const target = wfCellById(wf, chosen.edge.to)!; + pendingFeedback.set(target.id, { + fromCell: next.name, + notes: verdict.notes, + prevOutput: state.cells[target.id].output, + iteration: state.cells[target.id].iterations + 1, + }); + const reopen = wfFlowDescendants(wf, target.id); + reopen.add(target.id); + for (const id of reopen) { + resolved.delete(id); + // Outputs from re-opened cells are stale — clear their deliveries. + for (const e of wfFlowEdges(wf)) { + if (e.from === id) delivered.delete(e.id); + } + if (state.cells[id].status !== "running") { + state.cells[id].status = "queued"; + cb.onCellStatus(id, "queued"); + } + } + } + } finally { + state.running = false; + const secs = Math.round((Date.now() - state.startedAt) / 1000); + console.log( + `[WORKFLOW] Run ${state.aborted ? "stopped" : state.error ? "failed" : "complete"}: ${state.totalSteps} cell runs, ${secs}s` + ); + } + return state; +}