From 17548b4ef9cc731c6d428981b6f06207d7249908 Mon Sep 17 00:00:00 2001 From: Geoff Johnson Date: Tue, 14 Apr 2026 11:40:12 -0700 Subject: [PATCH 1/2] feat(ui): implement graph-to-API serialization for workflow canvas From 4801e65b0205c182ab2d57f92668c1f1d0b5bb90 Mon Sep 17 00:00:00 2001 From: Geoff Johnson Date: Tue, 14 Apr 2026 11:42:17 -0700 Subject: [PATCH 2/2] feat(ui): implement graph-to-API serialization for workflow canvas - graphToWorkflows(): converts each trigger->agent edge to CreateWorkflowRequest - workflowsToGraph(): converts Workflow[] to React Flow nodes/edges with agent dedup - validateGraph(): detects unconnected nodes, missing config, invalid edges - CanvasLayout interface for storing node positions + viewport in localStorage - saveLayout/loadLayout with stable hash-based key for layout persistence - Workflow name auto-derived as [trigger_type]-to-[agent_name] - 36 tests: single/multi workflow, shared agents, round-trip, edge cases, layout --- ui/src/components/workflows/canvas/index.ts | 20 + .../workflows/canvas/serialization.test.ts | 532 ++++++++++++++++++ .../workflows/canvas/serialization.ts | 391 +++++++++++++ 3 files changed, 943 insertions(+) create mode 100644 ui/src/components/workflows/canvas/serialization.test.ts create mode 100644 ui/src/components/workflows/canvas/serialization.ts diff --git a/ui/src/components/workflows/canvas/index.ts b/ui/src/components/workflows/canvas/index.ts index fe98c693..71c0fd04 100644 --- a/ui/src/components/workflows/canvas/index.ts +++ b/ui/src/components/workflows/canvas/index.ts @@ -1,2 +1,22 @@ export type { WorkflowCanvasProps } from "./WorkflowCanvas"; export { WorkflowCanvas } from "./WorkflowCanvas"; +export type { AgentNodeData } from "./nodes/AgentNode"; +export { AgentNode } from "./nodes/AgentNode"; +export type { TriggerNodeData } from "./nodes/TriggerNode"; +export { TriggerNode } from "./nodes/TriggerNode"; +export type { PromptEdgeData } from "./edges/PromptEdge"; +export { PromptEdge } from "./edges/PromptEdge"; +export { workflowNodeTypes, workflowEdgeTypes } from "./nodeTypes"; +export type { + CanvasLayout, + SerializationError, + SerializationErrorType, +} from "./serialization"; +export { + graphToWorkflows, + workflowsToGraph, + validateGraph, + layoutStorageKey, + saveLayout, + loadLayout, +} from "./serialization"; diff --git a/ui/src/components/workflows/canvas/serialization.test.ts b/ui/src/components/workflows/canvas/serialization.test.ts new file mode 100644 index 00000000..d95bf743 --- /dev/null +++ b/ui/src/components/workflows/canvas/serialization.test.ts @@ -0,0 +1,532 @@ +/** + * Serialization tests. + * + * Covers: + * - graphToWorkflows: converts graph state to CreateWorkflowRequest[] + * - workflowsToGraph: converts Workflow[] to React Flow nodes/edges + * - validateGraph: catches unconnected nodes, missing config, invalid edges + * - Round-trip: graph → API → graph preserves all workflow data + * - Edge cases: empty graph, single node, multi-workflow with shared agent + * - Layout helpers: saveLayout / loadLayout / layoutStorageKey + */ + +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import type { Edge, Node } from "@xyflow/react"; +import type { Agent, Workflow } from "@/types/orchestrator"; +import type { AgentNodeData } from "./nodes/AgentNode"; +import type { TriggerNodeData } from "./nodes/TriggerNode"; +import type { PromptEdgeData } from "./edges/PromptEdge"; +import { + graphToWorkflows, + layoutStorageKey, + loadLayout, + saveLayout, + validateGraph, + workflowsToGraph, +} from "./serialization"; + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +function makeTriggerNode( + id: string, + overrides: Partial = {}, +): Node { + return { + id, + type: "trigger", + position: { x: 0, y: 0 }, + data: { + triggerConfig: { + type: "github_issues", + owner: "acme", + repo: "myrepo", + labels: [], + state: "open", + }, + category: "external", + enabled: true, + ...overrides, + }, + }; +} + +function makeAgentNode( + id: string, + overrides: Partial = {}, +): Node { + return { + id, + type: "agent", + position: { x: 300, y: 0 }, + data: { + agentId: `agent-${id}`, + name: "Test Agent", + status: "running", + toolPolicy: { mode: "allow_all" }, + ...overrides, + }, + }; +} + +function makeEdge( + id: string, + source: string, + target: string, + overrides: Partial = {}, +): Edge { + return { + id, + source, + target, + type: "prompt", + data: { + promptTemplate: "Fix: {{title}}", + pollIntervalSecs: 300, + enabled: true, + ...overrides, + }, + }; +} + +function makeWorkflow(id: string, agentId = "agent-1"): Workflow { + return { + id, + name: `wf-${id}`, + agent_id: agentId, + trigger_config: { + type: "github_issues", + owner: "acme", + repo: "myrepo", + labels: ["bug"], + state: "open", + }, + prompt_template: "Fix: {{title}}", + poll_interval_secs: 300, + enabled: true, + tool_policy: { mode: "allow_all" }, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }; +} + +function makeAgent(id: string, name = "Test Agent"): Agent { + return { + id, + name, + status: "running", + config: { + working_dir: "/tmp", + shell: "/bin/sh", + interactive: false, + tool_policy: { mode: "allow_all" }, + model: "claude-sonnet", + }, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }; +} + +// --------------------------------------------------------------------------- +// validateGraph +// --------------------------------------------------------------------------- + +describe("validateGraph", () => { + it("returns empty array for a valid single-workflow graph", () => { + const nodes = [makeTriggerNode("t1"), makeAgentNode("a1")]; + const edges = [makeEdge("e1", "t1", "a1")]; + expect(validateGraph(nodes, edges)).toHaveLength(0); + }); + + it("returns empty array for an empty graph", () => { + expect(validateGraph([], [])).toHaveLength(0); + }); + + it("reports unconnected trigger node", () => { + const nodes = [makeTriggerNode("t1"), makeAgentNode("a1")]; + const edges: Edge[] = []; // no connection + const errors = validateGraph(nodes, edges); + expect(errors.some((e) => e.type === "unconnected_trigger")).toBe(true); + }); + + it("reports unconnected agent node", () => { + const nodes = [makeTriggerNode("t1"), makeAgentNode("a1")]; + const edges: Edge[] = []; // no connection + const errors = validateGraph(nodes, edges); + expect(errors.some((e) => e.type === "unconnected_agent")).toBe(true); + }); + + it("reports missing triggerConfig", () => { + const nodes = [ + { + id: "t1", + type: "trigger", + position: { x: 0, y: 0 }, + data: {} as TriggerNodeData, + }, + makeAgentNode("a1"), + ]; + const edges = [makeEdge("e1", "t1", "a1")]; + const errors = validateGraph(nodes, edges); + expect(errors.some((e) => e.type === "missing_config")).toBe(true); + }); + + it("reports missing owner/repo for github_issues", () => { + const nodes = [ + makeTriggerNode("t1", { + triggerConfig: { + type: "github_issues", + owner: "", + repo: "", + labels: [], + state: "open", + }, + }), + makeAgentNode("a1"), + ]; + const edges = [makeEdge("e1", "t1", "a1")]; + const errors = validateGraph(nodes, edges); + expect(errors.some((e) => e.type === "missing_config")).toBe(true); + }); + + it("reports invalid edge (agent → trigger reversed)", () => { + const nodes = [makeTriggerNode("t1"), makeAgentNode("a1")]; + // Reversed direction + const edges = [makeEdge("e1", "a1", "t1")]; + const errors = validateGraph(nodes, edges); + expect(errors.some((e) => e.type === "invalid_edge")).toBe(true); + }); + + it("returns no errors for multi-workflow graph with shared agent", () => { + const nodes = [ + makeTriggerNode("t1"), + makeTriggerNode("t2"), + makeAgentNode("a1", { agentId: "agent-shared" }), + ]; + const edges = [ + makeEdge("e1", "t1", "a1"), + makeEdge("e2", "t2", "a1"), + ]; + expect(validateGraph(nodes, edges)).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// graphToWorkflows +// --------------------------------------------------------------------------- + +describe("graphToWorkflows", () => { + it("converts a single trigger→agent edge to one request", () => { + const nodes = [makeTriggerNode("t1"), makeAgentNode("a1")]; + const edges = [makeEdge("e1", "t1", "a1")]; + const requests = graphToWorkflows(nodes, edges); + expect(requests).toHaveLength(1); + }); + + it("maps trigger_config from trigger node data", () => { + const nodes = [makeTriggerNode("t1"), makeAgentNode("a1")]; + const edges = [makeEdge("e1", "t1", "a1")]; + const [req] = graphToWorkflows(nodes, edges); + expect(req.trigger_config).toMatchObject({ + type: "github_issues", + owner: "acme", + repo: "myrepo", + }); + }); + + it("maps agent_id from agent node data", () => { + const nodes = [ + makeTriggerNode("t1"), + makeAgentNode("a1", { agentId: "uuid-1234" }), + ]; + const edges = [makeEdge("e1", "t1", "a1")]; + const [req] = graphToWorkflows(nodes, edges); + expect(req.agent_id).toBe("uuid-1234"); + }); + + it("maps prompt_template from edge data", () => { + const nodes = [makeTriggerNode("t1"), makeAgentNode("a1")]; + const edges = [ + makeEdge("e1", "t1", "a1", { promptTemplate: "My custom prompt" }), + ]; + const [req] = graphToWorkflows(nodes, edges); + expect(req.prompt_template).toBe("My custom prompt"); + }); + + it("maps poll_interval_secs from edge data", () => { + const nodes = [makeTriggerNode("t1"), makeAgentNode("a1")]; + const edges = [makeEdge("e1", "t1", "a1", { pollIntervalSecs: 900 })]; + const [req] = graphToWorkflows(nodes, edges); + expect(req.poll_interval_secs).toBe(900); + }); + + it("maps enabled from trigger node data", () => { + const nodes = [ + makeTriggerNode("t1", { enabled: false }), + makeAgentNode("a1"), + ]; + const edges = [makeEdge("e1", "t1", "a1")]; + const [req] = graphToWorkflows(nodes, edges); + expect(req.enabled).toBe(false); + }); + + it("derives workflow name from trigger type and agent name", () => { + const nodes = [ + makeTriggerNode("t1"), + makeAgentNode("a1", { agentId: "uuid", name: "My Worker Agent" }), + ]; + const edges = [makeEdge("e1", "t1", "a1")]; + const [req] = graphToWorkflows(nodes, edges); + expect(req.name).toBe("github-issues-to-my-worker-agent"); + }); + + it("produces one request per edge for multi-workflow graph", () => { + const nodes = [ + makeTriggerNode("t1"), + makeTriggerNode("t2"), + makeAgentNode("a1"), + ]; + const edges = [ + makeEdge("e1", "t1", "a1"), + makeEdge("e2", "t2", "a1"), + ]; + expect(graphToWorkflows(nodes, edges)).toHaveLength(2); + }); + + it("throws on invalid graph (unconnected trigger)", () => { + const nodes = [makeTriggerNode("t1"), makeAgentNode("a1")]; + expect(() => graphToWorkflows(nodes, [])).toThrow(/validation failed/i); + }); + + it("returns empty array for empty graph", () => { + expect(graphToWorkflows([], [])).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// workflowsToGraph +// --------------------------------------------------------------------------- + +describe("workflowsToGraph", () => { + it("creates one trigger node per workflow", () => { + const workflows = [makeWorkflow("w1"), makeWorkflow("w2")]; + const { nodes } = workflowsToGraph(workflows, []); + const triggers = nodes.filter((n) => n.type === "trigger"); + expect(triggers).toHaveLength(2); + }); + + it("deduplicates agent nodes for shared agent_id", () => { + const workflows = [ + makeWorkflow("w1", "agent-1"), + makeWorkflow("w2", "agent-1"), + ]; + const { nodes } = workflowsToGraph(workflows, []); + const agentNodes = nodes.filter((n) => n.type === "agent"); + expect(agentNodes).toHaveLength(1); + }); + + it("creates separate agent nodes for different agent_ids", () => { + const workflows = [ + makeWorkflow("w1", "agent-1"), + makeWorkflow("w2", "agent-2"), + ]; + const { nodes } = workflowsToGraph(workflows, []); + const agentNodes = nodes.filter((n) => n.type === "agent"); + expect(agentNodes).toHaveLength(2); + }); + + it("creates one edge per workflow", () => { + const workflows = [makeWorkflow("w1"), makeWorkflow("w2")]; + const { edges } = workflowsToGraph(workflows, []); + expect(edges).toHaveLength(2); + }); + + it("populates trigger node with triggerConfig", () => { + const wf = makeWorkflow("w1"); + const { nodes } = workflowsToGraph([wf], []); + const trigger = nodes.find((n) => n.type === "trigger"); + expect( + (trigger?.data as TriggerNodeData).triggerConfig, + ).toMatchObject({ type: "github_issues" }); + }); + + it("populates agent node with name from agent list", () => { + const wf = makeWorkflow("w1", "agent-42"); + const agent = makeAgent("agent-42", "Smart Worker"); + const { nodes } = workflowsToGraph([wf], [agent]); + const agentNode = nodes.find((n) => n.type === "agent"); + expect((agentNode?.data as AgentNodeData).name).toBe("Smart Worker"); + }); + + it("uses fallback name when agent is not in list", () => { + const wf = makeWorkflow("w1", "unknown-agent-id"); + const { nodes } = workflowsToGraph([wf], []); + const agentNode = nodes.find((n) => n.type === "agent"); + expect( + (agentNode?.data as AgentNodeData).name, + ).toMatch(/Agent \(unknown-/); + }); + + it("populates edge with promptTemplate from workflow", () => { + const wf = { ...makeWorkflow("w1"), prompt_template: "My prompt" }; + const { edges } = workflowsToGraph([wf], []); + expect((edges[0].data as PromptEdgeData).promptTemplate).toBe("My prompt"); + }); + + it("populates edge with pollIntervalSecs from workflow", () => { + const wf = { ...makeWorkflow("w1"), poll_interval_secs: 600 }; + const { edges } = workflowsToGraph([wf], []); + expect((edges[0].data as PromptEdgeData).pollIntervalSecs).toBe(600); + }); + + it("returns empty nodes and edges for empty workflow list", () => { + const { nodes, edges } = workflowsToGraph([], []); + expect(nodes).toHaveLength(0); + expect(edges).toHaveLength(0); + }); + + it("applies saved layout positions when provided", () => { + const wf = makeWorkflow("w1", "agent-1"); + const layout = { + nodes: { + "trigger-w1": { x: 999, y: 888 }, + "agent-agent-1": { x: 777, y: 666 }, + }, + viewport: { x: 0, y: 0, zoom: 1 }, + }; + const { nodes } = workflowsToGraph([wf], [], layout); + const trigger = nodes.find((n) => n.id === "trigger-w1"); + const agentNode = nodes.find((n) => n.id === "agent-agent-1"); + expect(trigger?.position).toEqual({ x: 999, y: 888 }); + expect(agentNode?.position).toEqual({ x: 777, y: 666 }); + }); +}); + +// --------------------------------------------------------------------------- +// Round-trip test +// --------------------------------------------------------------------------- + +describe("round-trip: graph → API → graph", () => { + it("preserves trigger config, agent id, prompt, and interval", () => { + // Build a simple graph + const origNodes = [ + makeTriggerNode("t1", { + triggerConfig: { + type: "cron", + expression: "*/5 * * * *", + }, + enabled: true, + }), + makeAgentNode("a1", { + agentId: "uuid-abc", + name: "My Agent", + toolPolicy: { mode: "require_approval" }, + }), + ]; + const origEdges = [ + makeEdge("e1", "t1", "a1", { + promptTemplate: "Run the cron task: {{title}}", + pollIntervalSecs: 60, + }), + ]; + + // Serialize to API requests + const requests = graphToWorkflows(origNodes, origEdges); + expect(requests).toHaveLength(1); + const req = requests[0]; + + // Simulate API round-trip: build a Workflow from the request + const workflow: Workflow = { + id: "wf-new", + name: req.name, + agent_id: req.agent_id, + trigger_config: req.trigger_config, + prompt_template: req.prompt_template, + poll_interval_secs: req.poll_interval_secs, + enabled: req.enabled, + tool_policy: req.tool_policy, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }; + + const agent = makeAgent("uuid-abc", "My Agent"); + const { nodes: outNodes, edges: outEdges } = workflowsToGraph( + [workflow], + [agent], + ); + + // Verify round-trip fidelity + const triggerOut = outNodes.find((n) => n.type === "trigger"); + const agentOut = outNodes.find((n) => n.type === "agent"); + const edgeOut = outEdges[0]; + + expect( + (triggerOut?.data as TriggerNodeData).triggerConfig, + ).toMatchObject({ type: "cron", expression: "*/5 * * * *" }); + expect( + (agentOut?.data as AgentNodeData).name, + ).toBe("My Agent"); + expect( + (edgeOut?.data as PromptEdgeData).promptTemplate, + ).toBe("Run the cron task: {{title}}"); + expect( + (edgeOut?.data as PromptEdgeData).pollIntervalSecs, + ).toBe(60); + }); +}); + +// --------------------------------------------------------------------------- +// Layout persistence +// --------------------------------------------------------------------------- + +describe("layoutStorageKey", () => { + it("returns a stable key for the same workflow IDs", () => { + const ids = ["wf-1", "wf-2", "wf-3"]; + expect(layoutStorageKey(ids)).toBe(layoutStorageKey(ids)); + }); + + it("returns the same key regardless of input order", () => { + expect(layoutStorageKey(["wf-1", "wf-2"])).toBe( + layoutStorageKey(["wf-2", "wf-1"]), + ); + }); + + it("returns different keys for different workflow sets", () => { + expect(layoutStorageKey(["wf-1"])).not.toBe(layoutStorageKey(["wf-2"])); + }); +}); + +describe("saveLayout / loadLayout", () => { + beforeEach(() => localStorage.clear()); + afterEach(() => localStorage.clear()); + + it("saves and loads a layout", () => { + const ids = ["wf-1", "wf-2"]; + const layout = { + nodes: { "trigger-wf-1": { x: 100, y: 200 } }, + viewport: { x: 0, y: 0, zoom: 1.5 }, + }; + saveLayout(ids, layout); + expect(loadLayout(ids)).toEqual(layout); + }); + + it("returns undefined when no layout is saved", () => { + expect(loadLayout(["wf-unknown"])).toBeUndefined(); + }); + + it("overwrites previous layout on re-save", () => { + const ids = ["wf-1"]; + const layout1 = { + nodes: { "trigger-wf-1": { x: 10, y: 20 } }, + viewport: { x: 0, y: 0, zoom: 1 }, + }; + const layout2 = { + nodes: { "trigger-wf-1": { x: 99, y: 88 } }, + viewport: { x: 5, y: 5, zoom: 2 }, + }; + saveLayout(ids, layout1); + saveLayout(ids, layout2); + expect(loadLayout(ids)).toEqual(layout2); + }); +}); diff --git a/ui/src/components/workflows/canvas/serialization.ts b/ui/src/components/workflows/canvas/serialization.ts new file mode 100644 index 00000000..bd4b92d9 --- /dev/null +++ b/ui/src/components/workflows/canvas/serialization.ts @@ -0,0 +1,391 @@ +/** + * Workflow canvas serialization layer. + * + * Converts between React Flow graph state (nodes + edges) and the workflow + * REST API format (CreateWorkflowRequest / Workflow). + * + * Data model mapping: + * + * Canvas Graph REST API + * ──────────────────────────────────── ──────────────────────── + * TriggerNode ─PromptEdge─> AgentNode = WorkflowConfig { + * triggerConfig trigger_config, + * promptTemplate prompt_template, + * pollIntervalSecs poll_interval_secs, + * agentId agent_id, + * name, + * enabled, + * tool_policy, + * } + */ + +import type { Edge, Node } from "@xyflow/react"; +import type { + Agent, + CreateWorkflowRequest, + TriggerConfig, + ToolPolicy, + Workflow, +} from "@/types/orchestrator"; +import { getTriggerCategory, getTriggerLabel } from "@/types/orchestrator"; +import type { AgentNodeData } from "./nodes/AgentNode"; +import type { TriggerNodeData } from "./nodes/TriggerNode"; +import type { PromptEdgeData } from "./edges/PromptEdge"; + +// --------------------------------------------------------------------------- +// Canvas layout (stored separately from the workflow API) +// --------------------------------------------------------------------------- + +export interface CanvasLayout { + /** nodeId -> { x, y } position */ + nodes: Record; + viewport: { x: number; y: number; zoom: number }; +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +export type SerializationErrorType = + | "unconnected_trigger" + | "unconnected_agent" + | "missing_config" + | "invalid_edge"; + +export interface SerializationError { + type: SerializationErrorType; + nodeId?: string; + edgeId?: string; + message: string; +} + +/** + * Validate a React Flow graph before serialization. + * + * Returns an array of errors; an empty array means the graph is valid. + */ +export function validateGraph( + nodes: Node[], + edges: Edge[], +): SerializationError[] { + const errors: SerializationError[] = []; + + const triggerNodes = nodes.filter((n) => n.type === "trigger"); + const agentNodes = nodes.filter((n) => n.type === "agent"); + + // Build sets of connected node IDs + const connectedSources = new Set(edges.map((e) => e.source)); + const connectedTargets = new Set(edges.map((e) => e.target)); + + // Every trigger node must have at least one outgoing edge + for (const node of triggerNodes) { + if (!connectedSources.has(node.id)) { + errors.push({ + type: "unconnected_trigger", + nodeId: node.id, + message: `Trigger node "${node.id}" is not connected to any agent`, + }); + } + + // Validate required trigger config fields + const data = node.data as TriggerNodeData; + if (!data?.triggerConfig) { + errors.push({ + type: "missing_config", + nodeId: node.id, + message: `Trigger node "${node.id}" is missing triggerConfig`, + }); + } else { + const cfg = data.triggerConfig; + if ( + (cfg.type === "github_issues" || cfg.type === "github_pull_requests") && + (!cfg.owner || !cfg.repo) + ) { + errors.push({ + type: "missing_config", + nodeId: node.id, + message: `GitHub trigger "${node.id}" requires owner and repo`, + }); + } + if (cfg.type === "cron" && !cfg.expression) { + errors.push({ + type: "missing_config", + nodeId: node.id, + message: `Cron trigger "${node.id}" requires an expression`, + }); + } + if (cfg.type === "queue" && !cfg.queue_name) { + errors.push({ + type: "missing_config", + nodeId: node.id, + message: `Queue trigger "${node.id}" requires a queue_name`, + }); + } + } + } + + // Every agent node must have at least one incoming edge + for (const node of agentNodes) { + if (!connectedTargets.has(node.id)) { + errors.push({ + type: "unconnected_agent", + nodeId: node.id, + message: `Agent node "${node.id}" has no incoming trigger connections`, + }); + } + + const data = node.data as AgentNodeData; + if (!data?.agentId) { + errors.push({ + type: "missing_config", + nodeId: node.id, + message: `Agent node "${node.id}" is missing agentId`, + }); + } + } + + // Validate edges connect trigger -> agent + for (const edge of edges) { + const srcNode = nodes.find((n) => n.id === edge.source); + const tgtNode = nodes.find((n) => n.id === edge.target); + if (!srcNode || !tgtNode) { + errors.push({ + type: "invalid_edge", + edgeId: edge.id, + message: `Edge "${edge.id}" references a missing node`, + }); + continue; + } + if (srcNode.type !== "trigger" || tgtNode.type !== "agent") { + errors.push({ + type: "invalid_edge", + edgeId: edge.id, + message: `Edge "${edge.id}" must connect a trigger node to an agent node`, + }); + } + } + + return errors; +} + +// --------------------------------------------------------------------------- +// Graph → API +// --------------------------------------------------------------------------- + +/** + * Convert React Flow graph state into an array of workflow API requests. + * + * Each trigger→agent edge becomes one `CreateWorkflowRequest`. + * + * @param nodes - React Flow node list (trigger + agent nodes) + * @param edges - React Flow edge list (prompt edges) + * @param defaultPolicy - tool policy applied when no override is present + * @throws {Error} when the graph contains validation errors + */ +export function graphToWorkflows( + nodes: Node[], + edges: Edge[], + defaultPolicy: ToolPolicy = { mode: "allow_all" }, +): CreateWorkflowRequest[] { + const errors = validateGraph(nodes, edges); + if (errors.length > 0) { + throw new Error( + `Graph validation failed:\n${errors.map((e) => ` • ${e.message}`).join("\n")}`, + ); + } + + const nodeMap = new Map(nodes.map((n) => [n.id, n])); + const requests: CreateWorkflowRequest[] = []; + + for (const edge of edges) { + const triggerNode = nodeMap.get(edge.source); + const agentNode = nodeMap.get(edge.target); + + if ( + !triggerNode || + triggerNode.type !== "trigger" || + !agentNode || + agentNode.type !== "agent" + ) { + continue; + } + + const triggerData = triggerNode.data as TriggerNodeData; + const agentData = agentNode.data as AgentNodeData; + const edgeData = (edge.data ?? {}) as Partial; + + const triggerConfig = triggerData.triggerConfig as TriggerConfig; + const agentId = agentData.agentId; + const promptTemplate = edgeData.promptTemplate ?? ""; + const pollIntervalSecs = edgeData.pollIntervalSecs ?? 300; + const enabled = triggerData.enabled ?? true; + + // Derive workflow name from trigger type and agent name + const triggerSlug = triggerConfig.type.replace(/_/g, "-"); + const agentSlug = agentData.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + const name = `${triggerSlug}-to-${agentSlug}`; + + requests.push({ + name, + agent_id: agentId, + trigger_config: triggerConfig, + prompt_template: promptTemplate, + poll_interval_secs: pollIntervalSecs, + enabled, + tool_policy: agentData.toolPolicy ?? defaultPolicy, + }); + } + + return requests; +} + +// --------------------------------------------------------------------------- +// API → Graph +// --------------------------------------------------------------------------- + +/** Default column spacing for auto-generated layout */ +const LAYOUT_TRIGGER_X = 80; +const LAYOUT_AGENT_X = 380; +const LAYOUT_ROW_HEIGHT = 120; +const LAYOUT_START_Y = 60; + +/** + * Convert a set of workflow API responses into React Flow graph state. + * + * Agent nodes are deduplicated: if multiple workflows share the same + * `agent_id`, they all connect to a single agent node. + * + * @param workflows - Workflow responses from the API + * @param agents - Full agent list (used to populate node data) + * @param layout - Optional saved layout; auto-generated when absent + */ +export function workflowsToGraph( + workflows: Workflow[], + agents: Agent[], + layout?: CanvasLayout, +): { nodes: Node[]; edges: Edge[] } { + const agentMap = new Map(agents.map((a) => [a.id, a])); + + const triggerNodes: Node[] = []; + const agentNodeMap = new Map>(); + const edges: Edge[] = []; + + // Track row index for auto-layout + let rowIndex = 0; + + for (const wf of workflows) { + // ── Trigger node ────────────────────────────────────────────── + const triggerId = `trigger-${wf.id}`; + const triggerConfig = wf.trigger_config; + const category = getTriggerCategory(triggerConfig.type); + const savedTriggerPos = layout?.nodes[triggerId]; + + triggerNodes.push({ + id: triggerId, + type: "trigger", + position: savedTriggerPos ?? { + x: LAYOUT_TRIGGER_X, + y: LAYOUT_START_Y + rowIndex * LAYOUT_ROW_HEIGHT, + }, + data: { + triggerConfig, + label: getTriggerLabel(triggerConfig.type), + category, + enabled: wf.enabled, + }, + }); + + // ── Agent node (deduplicate) ────────────────────────────────── + const agentId = wf.agent_id; + const agentNodeId = `agent-${agentId}`; + + if (!agentNodeMap.has(agentNodeId)) { + const agent = agentMap.get(agentId); + const savedAgentPos = layout?.nodes[agentNodeId]; + + agentNodeMap.set(agentNodeId, { + id: agentNodeId, + type: "agent", + position: savedAgentPos ?? { + x: LAYOUT_AGENT_X, + // Centre agent vertically on first workflow that references it + y: LAYOUT_START_Y + rowIndex * LAYOUT_ROW_HEIGHT, + }, + data: { + agentId, + name: agent?.name ?? `Agent (${agentId.slice(0, 8)})`, + status: agent?.status ?? "stopped", + model: agent?.config?.model, + toolPolicy: wf.tool_policy, + }, + }); + } + + // ── Edge ────────────────────────────────────────────────────── + const edgeId = `edge-${wf.id}`; + edges.push({ + id: edgeId, + source: triggerId, + target: agentNodeId, + type: "prompt", + data: { + promptTemplate: wf.prompt_template, + pollIntervalSecs: wf.poll_interval_secs, + enabled: wf.enabled, + }, + }); + + rowIndex++; + } + + return { + nodes: [...triggerNodes, ...agentNodeMap.values()], + edges, + }; +} + +// --------------------------------------------------------------------------- +// Layout persistence helpers +// --------------------------------------------------------------------------- + +/** + * Derive a stable storage key from a set of workflow IDs. + * Used to namespace localStorage layout data per canvas composition. + */ +export function layoutStorageKey(workflowIds: string[]): string { + const sorted = [...workflowIds].sort().join(","); + // Simple djb2-style hash for a stable short key + let hash = 5381; + for (let i = 0; i < sorted.length; i++) { + hash = ((hash << 5) + hash) ^ sorted.charCodeAt(i); + } + return `wf-layout-${(hash >>> 0).toString(16)}`; +} + +/** Persist canvas layout to localStorage */ +export function saveLayout( + workflowIds: string[], + layout: CanvasLayout, +): void { + try { + const key = layoutStorageKey(workflowIds); + localStorage.setItem(key, JSON.stringify(layout)); + } catch { + // localStorage may be unavailable (private browsing, storage quota) + } +} + +/** Load canvas layout from localStorage; returns undefined when not found */ +export function loadLayout(workflowIds: string[]): CanvasLayout | undefined { + try { + const key = layoutStorageKey(workflowIds); + const raw = localStorage.getItem(key); + if (!raw) return undefined; + return JSON.parse(raw) as CanvasLayout; + } catch { + return undefined; + } +}