From 38618479e45ba75d6fdf8bed374fd72adf62cdc7 Mon Sep 17 00:00:00 2001 From: eleven_qy <64311644+alph-cmky@users.noreply.github.com> Date: Fri, 29 May 2026 14:07:57 +0800 Subject: [PATCH] feat: add workflow canvas mvp --- apps/web/package.json | 3 + .../src/features/workflows/node-inspector.tsx | 45 +++++ .../src/features/workflows/node-palette.tsx | 46 +++++ .../workflows/workflow-canvas.test.ts | 85 +++++++++ .../features/workflows/workflow-canvas.tsx | 76 ++++++++ apps/web/src/index.ts | 9 + apps/web/src/styles/globals.css | 175 ++++++++++++++++++ docs/development/task-backlog.md | 8 +- packages/workflow-core/package.json | 2 +- pnpm-lock.yaml | 6 +- vitest.config.ts | 9 + 11 files changed, 458 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/features/workflows/node-inspector.tsx create mode 100644 apps/web/src/features/workflows/node-palette.tsx create mode 100644 apps/web/src/features/workflows/workflow-canvas.test.ts create mode 100644 apps/web/src/features/workflows/workflow-canvas.tsx diff --git a/apps/web/package.json b/apps/web/package.json index ea23393..bc67fc4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,5 +10,8 @@ "lint": "echo \"@agentdeck/web lint is not scaffolded yet\"", "typecheck": "tsc -p tsconfig.json --noEmit", "format": "echo \"@agentdeck/web format is not scaffolded yet\"" + }, + "dependencies": { + "@agentdeck/workflow-core": "workspace:*" } } diff --git a/apps/web/src/features/workflows/node-inspector.tsx b/apps/web/src/features/workflows/node-inspector.tsx new file mode 100644 index 0000000..0bce05d --- /dev/null +++ b/apps/web/src/features/workflows/node-inspector.tsx @@ -0,0 +1,45 @@ +import type { WorkflowNode } from "@agentdeck/workflow-core"; + +import { labelForWorkflowNodeType } from "./node-palette.js"; + +export function renderNodeInspectorHtml(node: WorkflowNode | undefined): string { + if (!node) { + return ``; + } + + return ``; +} + +function renderNodeSpecificSettings(node: WorkflowNode): string { + switch (node.type) { + case "agent": + return `
Agent ID
${escapeHtml(node.agentId)}
`; + case "tool": + return `
Tool ID
${escapeHtml(node.toolId)}
`; + case "condition": + return `
Expression
${escapeHtml(node.expression)}
`; + case "humanApproval": + return `
Approver role
${escapeHtml(node.approverRole)}
`; + case "start": + case "end": + return `
Settings
No additional settings
`; + } +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} diff --git a/apps/web/src/features/workflows/node-palette.tsx b/apps/web/src/features/workflows/node-palette.tsx new file mode 100644 index 0000000..07f4466 --- /dev/null +++ b/apps/web/src/features/workflows/node-palette.tsx @@ -0,0 +1,46 @@ +import type { WorkflowNodeType } from "@agentdeck/workflow-core"; + +export interface WorkflowPaletteItem { + readonly type: WorkflowNodeType; + readonly label: string; + readonly description: string; +} + +export const WORKFLOW_PALETTE_ITEMS: readonly WorkflowPaletteItem[] = [ + { type: "start", label: "Start", description: "Entry point for one workflow run." }, + { type: "agent", label: "Agent", description: "Delegate work to a configured coding agent." }, + { type: "tool", label: "Tool", description: "Call an approved local tool or MCP action." }, + { type: "condition", label: "Condition", description: "Branch the DAG with an expression." }, + { + type: "humanApproval", + label: "Human Approval", + description: "Pause until a reviewer approves the next step.", + }, + { type: "end", label: "End", description: "Finish the workflow run." }, +] as const; + +export function renderNodePaletteHtml(): string { + return ``; +} + +export function labelForWorkflowNodeType(type: WorkflowNodeType): string { + return WORKFLOW_PALETTE_ITEMS.find((item) => item.type === type)?.label ?? type; +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} diff --git a/apps/web/src/features/workflows/workflow-canvas.test.ts b/apps/web/src/features/workflows/workflow-canvas.test.ts new file mode 100644 index 0000000..4a94dca --- /dev/null +++ b/apps/web/src/features/workflows/workflow-canvas.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; + +import { renderWorkflowCanvasHtml, type WorkflowCanvasState } from "./workflow-canvas.js"; + +const validWorkflow: WorkflowCanvasState["workflow"] = { + id: "code-review", + name: "Code Review", + version: 1, + status: "draft", + variables: {}, + permissions: { + read: true, + write: true, + install: false, + arbitraryCommands: false, + commit: false, + push: false, + }, + nodes: [ + { id: "start", type: "start", label: "Start" }, + { id: "agent-review", type: "agent", label: "Review", agentId: "reviewer" }, + { id: "tool-diff", type: "tool", label: "Diff", toolId: "git.diff" }, + { id: "condition-risk", type: "condition", label: "Risk check", expression: "risk > 0.7" }, + { + id: "approval", + type: "humanApproval", + label: "Approve", + approverRole: "maintainer", + }, + { id: "end", type: "end", label: "End" }, + ], + edges: [ + { id: "edge-start-review", from: "start", to: "agent-review" }, + { id: "edge-review-diff", from: "agent-review", to: "tool-diff" }, + { id: "edge-diff-risk", from: "tool-diff", to: "condition-risk" }, + { + id: "edge-risk-approval", + from: "condition-risk", + to: "approval", + condition: "needsApproval", + }, + { id: "edge-approval-end", from: "approval", to: "end" }, + ], + createdAt: "2026-05-29T00:00:00.000Z", + updatedAt: "2026-05-29T00:00:00.000Z", +}; + +describe("renderWorkflowCanvasHtml", () => { + it("renders a palette for all MVP workflow node types and a valid DAG canvas", async () => { + const html = await renderWorkflowCanvasHtml({ workflow: validWorkflow }); + + for (const label of ["Start", "Agent", "Tool", "Condition", "Human Approval", "End"]) { + expect(html).toContain(label); + } + expect(html).toContain("Code Review"); + expect(html).toContain("edge-risk-approval"); + expect(html).toContain("Ready to save"); + }); + + it("opens selected node settings in the right inspector", async () => { + const html = await renderWorkflowCanvasHtml({ + workflow: validWorkflow, + selectedNodeId: "approval", + }); + + expect(html).toContain("Node Settings"); + expect(html).toContain("Approve"); + expect(html).toContain("Human Approval"); + expect(html).toContain("Approver role"); + expect(html).toContain("maintainer"); + }); + + it("runs validation before saving and renders invalid DAG errors", async () => { + const html = await renderWorkflowCanvasHtml({ + workflow: { + ...validWorkflow, + edges: [...validWorkflow.edges, { id: "edge-cycle", from: "approval", to: "agent-review" }], + }, + selectedNodeId: "agent-review", + }); + + expect(html).toContain("Save disabled"); + expect(html).toContain("workflow contains a cycle involving node "agent-review"."); + }); +}); diff --git a/apps/web/src/features/workflows/workflow-canvas.tsx b/apps/web/src/features/workflows/workflow-canvas.tsx new file mode 100644 index 0000000..788c7f0 --- /dev/null +++ b/apps/web/src/features/workflows/workflow-canvas.tsx @@ -0,0 +1,76 @@ +import { + validateWorkflowSafety, + type WorkflowDefinition, + type WorkflowNode, +} from "@agentdeck/workflow-core"; + +import { renderNodeInspectorHtml } from "./node-inspector.js"; +import { labelForWorkflowNodeType, renderNodePaletteHtml } from "./node-palette.js"; + +export interface WorkflowCanvasState { + readonly workflow: WorkflowDefinition; + readonly selectedNodeId?: string; +} + +export async function renderWorkflowCanvasHtml(state: WorkflowCanvasState): Promise { + const selectedNode = state.workflow.nodes.find((node) => node.id === state.selectedNodeId); + const validation = await validateWorkflowSafety(state.workflow); + const canSave = validation.success; + + return `
+ ${renderNodePaletteHtml()} +
+
+
+

${escapeHtml(state.workflow.name)}

+

${state.workflow.nodes.length} nodes, ${state.workflow.edges.length} edges

+
+ +
+
+ ${state.workflow.nodes.map((node) => renderCanvasNodeHtml(node, node.id === state.selectedNodeId)).join("")} +
+
+ ${state.workflow.edges + .map( + (edge) => `
+ ${escapeHtml(edge.id)} + ${escapeHtml(edge.from)} -> ${escapeHtml(edge.to)} + ${edge.condition ? `${escapeHtml(edge.condition)}` : ""} +
`, + ) + .join("")} +
+ ${renderValidationHtml(validation.errors)} +
+ ${renderNodeInspectorHtml(selectedNode)} +
`; +} + +function renderCanvasNodeHtml(node: WorkflowNode, selected: boolean): string { + return `
+ ${escapeHtml(node.label)} + ${escapeHtml(labelForWorkflowNodeType(node.type))} +
`; +} + +function renderValidationHtml(errors: readonly { readonly message: string }[]): string { + if (errors.length === 0) { + return `

Ready to save

`; + } + + return `
+

Validation errors

+ +
`; +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} diff --git a/apps/web/src/index.ts b/apps/web/src/index.ts index 4852018..5d03aa4 100644 --- a/apps/web/src/index.ts +++ b/apps/web/src/index.ts @@ -15,3 +15,12 @@ export type { RuntimeDashboardCapabilities, RuntimeDashboardResult, } from "./features/runtimes/runtime-status-row.js"; +export { renderNodeInspectorHtml } from "./features/workflows/node-inspector.js"; +export { + WORKFLOW_PALETTE_ITEMS, + labelForWorkflowNodeType, + renderNodePaletteHtml, +} from "./features/workflows/node-palette.js"; +export type { WorkflowPaletteItem } from "./features/workflows/node-palette.js"; +export { renderWorkflowCanvasHtml } from "./features/workflows/workflow-canvas.js"; +export type { WorkflowCanvasState } from "./features/workflows/workflow-canvas.js"; diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index e20e91d..4f31ed3 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -171,3 +171,178 @@ body { margin: 3px 0 0; font-size: 13px; } + +.workflow-canvas-shell { + display: grid; + grid-template-columns: 220px minmax(0, 1fr) 300px; + min-height: 620px; + border: 1px solid #d9e0e4; + background: #ffffff; +} + +.workflow-palette, +.workflow-node-inspector { + padding: 12px; + background: #fbfcfc; +} + +.workflow-palette { + border-right: 1px solid #d9e0e4; +} + +.workflow-node-inspector { + border-left: 1px solid #d9e0e4; +} + +.workflow-palette h2, +.workflow-node-inspector h2, +.workflow-validation h2 { + margin: 0 0 10px; + font-size: 13px; +} + +.workflow-palette-list { + display: grid; + gap: 8px; +} + +.workflow-palette-list button { + display: grid; + gap: 3px; + width: 100%; + padding: 9px; + border: 1px solid #d9e0e4; + border-radius: 6px; + background: #ffffff; + color: #172026; + text-align: left; +} + +.workflow-palette-list span { + font-size: 13px; + font-weight: 700; +} + +.workflow-palette-list small, +.workflow-canvas-header p, +.workflow-edge small, +.workflow-node span, +.workflow-node-inspector p { + color: #66747d; + font-size: 12px; +} + +.workflow-canvas-main { + min-width: 0; + padding: 14px; + background: #f4f6f7; +} + +.workflow-canvas-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.workflow-canvas-header h1 { + margin: 0 0 4px; + font-size: 18px; +} + +.workflow-canvas-header p { + margin: 0; +} + +.workflow-canvas-header button { + height: 32px; + padding: 0 12px; + border: 1px solid #0e4f43; + border-radius: 6px; + background: #0e4f43; + color: #ffffff; + font-weight: 700; +} + +.workflow-canvas-header button:disabled { + border-color: #a94333; + background: #a94333; +} + +.workflow-canvas-board { + display: grid; + grid-template-columns: repeat(3, minmax(120px, 1fr)); + gap: 10px; +} + +.workflow-node { + display: grid; + gap: 6px; + min-height: 72px; + padding: 10px; + border: 1px solid #cfd8dd; + border-radius: 8px; + background: #ffffff; +} + +.workflow-node-selected { + border-color: #0e4f43; + box-shadow: inset 3px 0 0 #0e4f43; +} + +.workflow-edge-list { + display: grid; + gap: 8px; + margin-top: 12px; +} + +.workflow-edge { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 10px; + border: 1px solid #d9e0e4; + border-radius: 6px; + background: #ffffff; +} + +.workflow-edge code { + color: #28343b; + font-size: 12px; +} + +.workflow-validation { + margin-top: 12px; + padding: 10px; + border: 1px solid #d9e0e4; + border-radius: 8px; + background: #ffffff; +} + +.workflow-validation p, +.workflow-validation ul { + margin: 0; + font-size: 13px; +} + +.workflow-validation li { + color: #8a3326; +} + +.workflow-node-inspector dl { + display: grid; + gap: 10px; + margin: 0; +} + +.workflow-node-inspector dt { + color: #66747d; + font-size: 11px; +} + +.workflow-node-inspector dd { + margin: 3px 0 0; + font-size: 13px; +} diff --git a/docs/development/task-backlog.md b/docs/development/task-backlog.md index 204d98d..7b9a459 100644 --- a/docs/development/task-backlog.md +++ b/docs/development/task-backlog.md @@ -468,10 +468,10 @@ Status markers: **Tasks:** -- [ ] Support Start, Agent, Tool, Condition, Human Approval, and End nodes. -- [ ] Open selected node settings in the right inspector. -- [ ] Run validation before saving. -- [ ] Commit with message `feat: add workflow canvas mvp`. +- [x] Support Start, Agent, Tool, Condition, Human Approval, and End nodes. +- [x] Open selected node settings in the right inspector. +- [x] Run validation before saving. +- [x] Commit with message `feat: add workflow canvas mvp`. **Acceptance Criteria:** diff --git a/packages/workflow-core/package.json b/packages/workflow-core/package.json index 60782b6..1242be5 100644 --- a/packages/workflow-core/package.json +++ b/packages/workflow-core/package.json @@ -5,7 +5,7 @@ "type": "module", "exports": { ".": { - "types": "./dist/index.d.ts", + "types": "./src/index.ts", "import": "./dist/index.js" } }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5bf347b..8b271ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,7 +47,11 @@ importers: apps/server: {} - apps/web: {} + apps/web: + dependencies: + '@agentdeck/workflow-core': + specifier: workspace:* + version: link:../../packages/workflow-core packages/mcp-server: {} diff --git a/vitest.config.ts b/vitest.config.ts index 1f78b7f..ae7aaf4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,15 @@ +import { fileURLToPath } from "node:url"; + import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + alias: { + "@agentdeck/workflow-core": fileURLToPath( + new URL("./packages/workflow-core/src/index.ts", import.meta.url), + ), + }, + }, test: { include: ["**/*.test.ts", "**/*.test.tsx"], exclude: ["**/node_modules/**", "**/dist/**", ".worktrees/**"],