From 686d4177520c0a8bf6425bb6c9d5dd1ef6be0d96 Mon Sep 17 00:00:00 2001 From: Code UX Date: Sun, 12 Jul 2026 07:51:25 +0000 Subject: [PATCH] feat(task T10): implement via codex --- dashboard/src/v2/NodesPage.tsx | 6 +- dashboard/src/v2/lib/nodes-canvas-state.ts | 124 +++++--- docs-web/architecture/node-flow-foundation.md | 4 +- .../architecture-node-flow-foundation.mdx | 4 +- docs-web/content/docs/registry.ts | 4 +- .../docs/user-dashboard-nodes-canvas.mdx | 48 +-- docs-web/user/dashboard/nodes-canvas.md | 48 +-- docs/architecture/node-flow-foundation.md | 2 + docs/dashboard/nodes-canvas.md | 4 +- src/domain/node-flows/node-flow-migrators.ts | 161 ++++++---- src/domain/node-flows/node-flow-validation.ts | 277 ++++++++++++++---- .../node-flows/node-flow-migrators.test.ts | 40 ++- .../node-flows/node-flow-validation.test.ts | 67 +++++ .../dashboard/lib/nodes-canvas-state.test.ts | 26 ++ tests/dashboard/v2/nodes-page.test.tsx | 30 +- 15 files changed, 610 insertions(+), 235 deletions(-) diff --git a/dashboard/src/v2/NodesPage.tsx b/dashboard/src/v2/NodesPage.tsx index f550fa51fc..15deca241b 100644 --- a/dashboard/src/v2/NodesPage.tsx +++ b/dashboard/src/v2/NodesPage.tsx @@ -69,7 +69,11 @@ export const NodesPage: FunctionComponent = () => { let nextFlows = library.flows; const legacy = typeof window !== "undefined" ? window.localStorage.getItem(NODES_CANVAS_STORAGE_KEY) : null; if (legacy && !window.localStorage.getItem(migrationMarker(nextProjectId))) { - const importedGraph = toCanonicalNodeFlowGraph(deserializeNodeCanvasGraphWithMigration(legacy).graph); + const legacySnapshot: unknown = JSON.parse(legacy); + const importedGraph = toCanonicalNodeFlowGraph( + deserializeNodeCanvasGraphWithMigration(legacy).graph, + legacySnapshot, + ); const imported = await createNodeFlowDraft(nextProjectId, { title: "Imported Nodes Canvas", description: "One-time import from the legacy browser canvas.", graph: importedGraph }); window.localStorage.setItem(migrationMarker(nextProjectId), imported.flowId); window.localStorage.removeItem(NODES_CANVAS_STORAGE_KEY); diff --git a/dashboard/src/v2/lib/nodes-canvas-state.ts b/dashboard/src/v2/lib/nodes-canvas-state.ts index 0b363bd42f..d963f62114 100644 --- a/dashboard/src/v2/lib/nodes-canvas-state.ts +++ b/dashboard/src/v2/lib/nodes-canvas-state.ts @@ -1,4 +1,4 @@ -import type { NodeFlowGraph, NodeFlowJsonObject, NodeFlowJsonValue, NodeFlowPort } from "../../../../src/contracts/node-flow-types.js"; +import type { NodeFlowGraph, NodeFlowJsonObject, NodeFlowJsonValue } from "../../../../src/contracts/node-flow-types.js"; export type NodeCanvasNodeKind = "trigger" | "agent" | "task" | "condition" | "output"; export type NodeCanvasPortDirection = "input" | "output"; @@ -133,6 +133,19 @@ const NODE_KINDS: readonly NodeCanvasNodeKind[] = ["trigger", "agent", "task", " const AGENT_INTENTS: readonly NodeCanvasAgentIntent[] = ["plan", "implement", "review", "qa"]; const TASK_INTENTS: readonly NodeCanvasTaskIntent[] = ["feature", "refactor", "test", "docs", "ops"]; +interface CanonicalCanvasDefinition { + type: string; + handles: Readonly>; +} + +const CANONICAL_CANVAS_DEFINITIONS: Readonly> = { + trigger: { type: "input", handles: { event: "output" } }, + agent: { type: "set_fields", handles: { in: "input", agent: "output" } }, + task: { type: "template", handles: { agent: "input", task: "output" } }, + condition: { type: "condition", handles: { task: "input", pass: "true", fail: "false" } }, + output: { type: "output", handles: { result: "input" } }, +}; + interface NodeTemplate { label: string; description: string; @@ -500,47 +513,60 @@ export const serializeNodeCanvasGraph = (graph: NodeCanvasGraph): string => ( JSON.stringify(toStableJson(toCanonicalNodeFlowGraph(graph)), null, 2) ); -export const toCanonicalNodeFlowGraph = (graph: NodeCanvasGraph): NodeFlowGraph => { +export const toCanonicalNodeFlowGraph = (graph: NodeCanvasGraph, legacySnapshot?: unknown): NodeFlowGraph => { const normalized = normalizeNodeCanvasGraph(graph); return { schemaVersion: 2, - nodes: normalized.nodes.map((node) => ({ - id: node.id, - type: node.kind, - title: node.label, - description: node.description, - position: node.position, - definition: { type: node.kind, version: 1 }, - ports: [...node.inputPorts, ...node.outputPorts].map(toCanonicalPort), - credentialBindings: [], - policy: {}, - capabilities: [], - sideEffect: "none", - disabled: false, - data: { - canvas: { - config: node.config as unknown as NodeFlowJsonValue, - metadata: node.metadata as unknown as NodeFlowJsonValue, + nodes: normalized.nodes.map(toCanonicalNode), + edges: normalized.edges.map((edge) => { + const source = normalized.nodes.find((node) => node.id === edge.source.nodeId); + const target = normalized.nodes.find((node) => node.id === edge.target.nodeId); + return { + id: edge.id, + fromNodeId: edge.source.nodeId, + toNodeId: edge.target.nodeId, + ...(source ? { fromHandle: CANONICAL_CANVAS_DEFINITIONS[source.kind].handles[edge.source.portId] } : {}), + ...(target ? { toHandle: CANONICAL_CANVAS_DEFINITIONS[target.kind].handles[edge.target.portId] } : {}), + }; + }), + metadata: { + canvasSelection: normalized.selection as unknown as NodeFlowJsonObject, + ...(legacySnapshot !== undefined ? { + migration: { + source: "browser_canvas_v1", + legacySnapshot: toStableJson(cloneStableValue(legacySnapshot)) as NodeFlowJsonValue, }, - }, - })), - edges: normalized.edges.map((edge) => ({ - id: edge.id, - fromNodeId: edge.source.nodeId, - toNodeId: edge.target.nodeId, - fromHandle: edge.source.portId, - toHandle: edge.target.portId, - })), - metadata: { canvasSelection: normalized.selection as unknown as NodeFlowJsonObject }, + } : {}), + }, }; }; -const toCanonicalPort = (port: NodeCanvasPort): NodeFlowPort => ({ - id: port.id, - direction: port.direction, - schema: { type: "object", description: port.type }, - required: port.required, -}); +const toCanonicalNode = (node: NodeCanvasNode): NodeFlowGraph["nodes"][number] => { + const definition = CANONICAL_CANVAS_DEFINITIONS[node.kind]; + const config = Object.fromEntries(node.config.map((field) => [field.id, field.value])); + const prompt = typeof config.prompt === "string" && config.prompt.trim() + ? config.prompt + : "Use the selected agent to complete the task."; + return { + id: node.id, + type: definition.type, + title: node.label, + description: node.description, + position: node.position, + definition: { type: definition.type, version: 1 }, + disabled: false, + data: { + ...(node.kind === "agent" ? { fields: { legacyAgent: config } } : {}), + ...(node.kind === "task" ? { template: prompt, outputKey: "task" } : {}), + canvas: { + kind: node.kind, + config: toStableJson(node.config) as NodeFlowJsonValue, + values: toStableJson(config) as NodeFlowJsonValue, + metadata: toStableJson(node.metadata) as NodeFlowJsonValue, + }, + }, + }; +}; export const deserializeNodeCanvasGraph = (serialized: string): NodeCanvasGraph => { return deserializeNodeCanvasGraphWithMigration(serialized).graph; @@ -577,9 +603,11 @@ export const normalizeNodeCanvasGraph = (input: unknown): NodeCanvasGraph => { return createInitialNodeCanvasGraph(); } - const parsedEdges = Array.isArray(input.edges) + const rawParsedEdges = Array.isArray(input.edges) ? input.edges.map(parseEdge).filter((edge): edge is NodeCanvasEdge => edge !== null) : []; + const nodeById = new Map(parsedNodes.map((node) => [node.id, node])); + const parsedEdges = rawParsedEdges.map((edge) => normalizeCanvasEdgeHandles(edge, nodeById)); const validNodeIds = new Set(parsedNodes.map((node) => node.id)); const canvasSelection = isRecord(input.metadata) ? input.metadata.canvasSelection : undefined; @@ -688,7 +716,8 @@ const normalizeMetadata = (metadata: NodeCanvasNodeMetadata): NodeCanvasNodeMeta }); const parseNode = (value: unknown): NodeCanvasNode | null => { - const kindValue = isRecord(value) ? value.kind ?? value.type : undefined; + const canvasData = isRecord(value) ? readCanonicalCanvasData(value.data) : null; + const kindValue = isRecord(value) ? value.kind ?? canvasData?.kind ?? value.type : undefined; if (!isRecord(value) || !isString(value.id) || !isNodeKind(kindValue)) { return null; } @@ -700,7 +729,6 @@ const parseNode = (value: unknown): NodeCanvasNode | null => { } : template.position; - const canvasData = readCanonicalCanvasData(value.data); const canonicalPorts = Array.isArray(value.ports) ? value.ports : undefined; return { ...template, @@ -821,6 +849,26 @@ const parseEdge = (value: unknown): NodeCanvasEdge | null => { }; }; +const normalizeCanvasEdgeHandles = ( + edge: NodeCanvasEdge, + nodeById: ReadonlyMap, +): NodeCanvasEdge => { + const source = nodeById.get(edge.source.nodeId); + const target = nodeById.get(edge.target.nodeId); + const legacyHandle = (node: NodeCanvasNode | undefined, handle: string): string => { + if (!node) return handle; + const ports = [...node.inputPorts, ...node.outputPorts]; + if (ports.some((port) => port.id === handle)) return handle; + return Object.entries(CANONICAL_CANVAS_DEFINITIONS[node.kind].handles) + .find(([, canonical]) => canonical === handle)?.[0] ?? handle; + }; + return { + ...edge, + source: { ...edge.source, portId: legacyHandle(source, edge.source.portId) }, + target: { ...edge.target, portId: legacyHandle(target, edge.target.portId) }, + }; +}; + const parseSelection = ( value: unknown, nodeIds: ReadonlySet, diff --git a/docs-web/architecture/node-flow-foundation.md b/docs-web/architecture/node-flow-foundation.md index acd3237449..d1dbf7317f 100644 --- a/docs-web/architecture/node-flow-foundation.md +++ b/docs-web/architecture/node-flow-foundation.md @@ -4,4 +4,6 @@ Code UX uses one canonical Graph v2 contract across backend, MCP, runtime, and d Validation resolves definitions, checks configuration, ports, policies, graph limits, and cycles, and rejects plaintext secrets and custom source. Only `input`, `set_fields`, `template`, `provider_prompt`, `http_request`, and `output` are executable. -Persisted Graph v1 records retain their original immutable version and append deterministic Graph v2. Browser migration retains its original snapshot outside executable graph JSON. +Persisted Graph v1 records retain their original immutable version and append deterministic Graph v2. One-time browser migration maps legacy planning kinds to registered definitions and retains the JSON-safe original snapshot in non-executable graph migration metadata. + +Graph JSON is treated as untrusted input. Malformed definition references, ports, credential bindings, policies, widget entries, schemas, publication metadata, and null array members return deterministic field-level validation issues instead of throwing. Secret-shaped keys and custom source fields remain rejected through migration and normalization. diff --git a/docs-web/content/docs/architecture-node-flow-foundation.mdx b/docs-web/content/docs/architecture-node-flow-foundation.mdx index acd3237449..d1dbf7317f 100644 --- a/docs-web/content/docs/architecture-node-flow-foundation.mdx +++ b/docs-web/content/docs/architecture-node-flow-foundation.mdx @@ -4,4 +4,6 @@ Code UX uses one canonical Graph v2 contract across backend, MCP, runtime, and d Validation resolves definitions, checks configuration, ports, policies, graph limits, and cycles, and rejects plaintext secrets and custom source. Only `input`, `set_fields`, `template`, `provider_prompt`, `http_request`, and `output` are executable. -Persisted Graph v1 records retain their original immutable version and append deterministic Graph v2. Browser migration retains its original snapshot outside executable graph JSON. +Persisted Graph v1 records retain their original immutable version and append deterministic Graph v2. One-time browser migration maps legacy planning kinds to registered definitions and retains the JSON-safe original snapshot in non-executable graph migration metadata. + +Graph JSON is treated as untrusted input. Malformed definition references, ports, credential bindings, policies, widget entries, schemas, publication metadata, and null array members return deterministic field-level validation issues instead of throwing. Secret-shaped keys and custom source fields remain rejected through migration and normalization. diff --git a/docs-web/content/docs/registry.ts b/docs-web/content/docs/registry.ts index 6bf849a8b0..d0f9792640 100644 --- a/docs-web/content/docs/registry.ts +++ b/docs-web/content/docs/registry.ts @@ -266,8 +266,8 @@ export const docsRegistry: Record = { id: 'user-dashboard-nodes-canvas', path: '/docs/user-dashboard-nodes-canvas', section: 'User Guide', - title: "Nodes Canvas", - description: "The Nodes Canvas page (/nodes) is a browser-local workspace for drafting Code UX workflow graphs. It combines the canvas, palette, inspector, validation panel, JSON exchange controls, and agent command summary without...", + title: "Nodes Automation Workspace", + description: "The Nodes page (/nodes) is a project-scoped automation workspace backed by the canonical node-flow repository. Browser storage is not a workflow database and edits are never auto-saved locally.", }, 'user-dashboard-node-flows': { id: 'user-dashboard-node-flows', diff --git a/docs-web/content/docs/user-dashboard-nodes-canvas.mdx b/docs-web/content/docs/user-dashboard-nodes-canvas.mdx index 2c7a5bc4b7..a8a2710611 100644 --- a/docs-web/content/docs/user-dashboard-nodes-canvas.mdx +++ b/docs-web/content/docs/user-dashboard-nodes-canvas.mdx @@ -1,47 +1,19 @@ -# Nodes Canvas +# Nodes Automation Workspace -The **Nodes Canvas** page (`/nodes`) is a browser-local workspace for drafting Code UX workflow graphs. It combines the canvas, palette, inspector, validation panel, JSON exchange controls, and agent command summary without calling backend APIs or writing to the database. +The **Nodes** page (`/nodes`) is a project-scoped automation workspace backed by the canonical node-flow repository. Browser storage is not a workflow database and edits are never auto-saved locally. -This page does not synchronize graphs to projects, execute n8n workflows, or run node flows through the Code UX runtime. +## Legacy canvas import -## Local persistence +On the first load for a selected project, the dashboard checks the former `codeux:nodes-canvas:v1` key. When present, it normalizes the payload to Graph v2, creates an **Imported Nodes Canvas** backend draft, records a project-specific migration marker, and removes the legacy graph value. A failed import leaves the value available for retry. The marker prevents duplicates, so browser storage is a one-time migration source rather than a second workflow database. -The page saves the current graph to browser `localStorage` under `codeux:nodes-canvas:v1`. Reloading `/nodes` restores that graph when it can be parsed through the canvas contract. Malformed persisted data falls back to the starter graph. +Legacy planning nodes map to registered definitions and handles: `trigger` becomes `input`, `agent` becomes `set_fields`, `task` becomes `template`, and `condition` and `output` use their matching definitions. The original JSON-safe browser snapshot is retained in non-executable graph migration metadata for review. Secret-shaped keys and custom source fields remain visible to backend validation and cause the import to be rejected instead of being silently discarded. -The inspector's enabled switch is an editing-session flag only and is not persisted in the graph JSON. +## Governed editing -## Node types +The registry supplies executable state, typed ports, widget schemas, capabilities, credential slots, side-effect classification, and default policy. The graph stores only a type/version reference and configuration; it never stores custom source or credential values. -| Type | Purpose | -| --- | --- | -| `trigger` | Starts the graph from a manual or scheduled event source. | -| `agent` | Routes downstream work to a planning, implementation, review, or QA agent intent. | -| `task` | Captures a concrete task prompt and task intent. | -| `condition` | Branches based on an expression such as a validation result. | -| `output` | Collects the final graph result. | +Draft saves use `draftRevision`. A concurrent update returns a visible conflict. Validation, policy findings, credential status, dry runs, publication, version comparison, and rollback use the governed draft APIs. -## Validation behavior +## Operations -Validation runs locally after each graph change. It checks duplicate node ids, missing edge nodes or ports, self-connections, input/output direction mismatches, incompatible port types, empty required values, and invalid agent or task intent metadata. - -The status strip reports the issue count. The validation panel groups issues by node or edge and provides select/focus actions. Valid JSON imports can still contain validation issues so users can repair them on the canvas. - -## Import and export format - -`Export JSON` writes the deterministic graph JSON into the exchange textarea. The JSON contains `nodes`, `edges`, and `selection`. - -`Import JSON` applies the textarea content through the agent `replace_graph` command helper. Invalid JSON leaves the current graph unchanged and reports a live error. Valid JSON is normalized, loaded into the canvas, saved locally, and revalidated. - -## Agent command surface - -Agents should use the node canvas agent helper contract rather than driving the UI. Supported commands are `add_node`, `patch_node`, `connect_ports`, `delete_entities`, `select_entities`, and `replace_graph`. - -The page displays a deterministic graph summary for command workflows, including node and edge counts, selected ids, ports, config values, and validation blockers. - -## Empty and reset states - -`Clear` empties the canvas while keeping the palette available. `Reset` restores the starter trigger -> agent -> task -> condition -> output graph. The layout collapses to a single column at smaller widths so controls remain reachable without overlapping. - -## Graph v2 migration - -Serialization writes `schemaVersion: 2`. Legacy browser v1 values migrate deterministically with their untouched snapshot retained separately. Trigger, agent, task, condition, and output are planning concepts; executable definitions are limited to `input`, `set_fields`, `template`, `provider_prompt`, `http_request`, and `output`. +Only published versions run. The debugger shows redacted run output, graph and node states, attempts, retry classifications and decisions, invocation links, timing, cancellation, and safe retry controls. Scheduling is entered through the Scheduler page. diff --git a/docs-web/user/dashboard/nodes-canvas.md b/docs-web/user/dashboard/nodes-canvas.md index 2c7a5bc4b7..a8a2710611 100644 --- a/docs-web/user/dashboard/nodes-canvas.md +++ b/docs-web/user/dashboard/nodes-canvas.md @@ -1,47 +1,19 @@ -# Nodes Canvas +# Nodes Automation Workspace -The **Nodes Canvas** page (`/nodes`) is a browser-local workspace for drafting Code UX workflow graphs. It combines the canvas, palette, inspector, validation panel, JSON exchange controls, and agent command summary without calling backend APIs or writing to the database. +The **Nodes** page (`/nodes`) is a project-scoped automation workspace backed by the canonical node-flow repository. Browser storage is not a workflow database and edits are never auto-saved locally. -This page does not synchronize graphs to projects, execute n8n workflows, or run node flows through the Code UX runtime. +## Legacy canvas import -## Local persistence +On the first load for a selected project, the dashboard checks the former `codeux:nodes-canvas:v1` key. When present, it normalizes the payload to Graph v2, creates an **Imported Nodes Canvas** backend draft, records a project-specific migration marker, and removes the legacy graph value. A failed import leaves the value available for retry. The marker prevents duplicates, so browser storage is a one-time migration source rather than a second workflow database. -The page saves the current graph to browser `localStorage` under `codeux:nodes-canvas:v1`. Reloading `/nodes` restores that graph when it can be parsed through the canvas contract. Malformed persisted data falls back to the starter graph. +Legacy planning nodes map to registered definitions and handles: `trigger` becomes `input`, `agent` becomes `set_fields`, `task` becomes `template`, and `condition` and `output` use their matching definitions. The original JSON-safe browser snapshot is retained in non-executable graph migration metadata for review. Secret-shaped keys and custom source fields remain visible to backend validation and cause the import to be rejected instead of being silently discarded. -The inspector's enabled switch is an editing-session flag only and is not persisted in the graph JSON. +## Governed editing -## Node types +The registry supplies executable state, typed ports, widget schemas, capabilities, credential slots, side-effect classification, and default policy. The graph stores only a type/version reference and configuration; it never stores custom source or credential values. -| Type | Purpose | -| --- | --- | -| `trigger` | Starts the graph from a manual or scheduled event source. | -| `agent` | Routes downstream work to a planning, implementation, review, or QA agent intent. | -| `task` | Captures a concrete task prompt and task intent. | -| `condition` | Branches based on an expression such as a validation result. | -| `output` | Collects the final graph result. | +Draft saves use `draftRevision`. A concurrent update returns a visible conflict. Validation, policy findings, credential status, dry runs, publication, version comparison, and rollback use the governed draft APIs. -## Validation behavior +## Operations -Validation runs locally after each graph change. It checks duplicate node ids, missing edge nodes or ports, self-connections, input/output direction mismatches, incompatible port types, empty required values, and invalid agent or task intent metadata. - -The status strip reports the issue count. The validation panel groups issues by node or edge and provides select/focus actions. Valid JSON imports can still contain validation issues so users can repair them on the canvas. - -## Import and export format - -`Export JSON` writes the deterministic graph JSON into the exchange textarea. The JSON contains `nodes`, `edges`, and `selection`. - -`Import JSON` applies the textarea content through the agent `replace_graph` command helper. Invalid JSON leaves the current graph unchanged and reports a live error. Valid JSON is normalized, loaded into the canvas, saved locally, and revalidated. - -## Agent command surface - -Agents should use the node canvas agent helper contract rather than driving the UI. Supported commands are `add_node`, `patch_node`, `connect_ports`, `delete_entities`, `select_entities`, and `replace_graph`. - -The page displays a deterministic graph summary for command workflows, including node and edge counts, selected ids, ports, config values, and validation blockers. - -## Empty and reset states - -`Clear` empties the canvas while keeping the palette available. `Reset` restores the starter trigger -> agent -> task -> condition -> output graph. The layout collapses to a single column at smaller widths so controls remain reachable without overlapping. - -## Graph v2 migration - -Serialization writes `schemaVersion: 2`. Legacy browser v1 values migrate deterministically with their untouched snapshot retained separately. Trigger, agent, task, condition, and output are planning concepts; executable definitions are limited to `input`, `set_fields`, `template`, `provider_prompt`, `http_request`, and `output`. +Only published versions run. The debugger shows redacted run output, graph and node states, attempts, retry classifications and decisions, invocation links, timing, cancellation, and safe retry controls. Scheduling is entered through the Scheduler page. diff --git a/docs/architecture/node-flow-foundation.md b/docs/architecture/node-flow-foundation.md index 7a854f6f65..8212685de4 100644 --- a/docs/architecture/node-flow-foundation.md +++ b/docs/architecture/node-flow-foundation.md @@ -85,3 +85,5 @@ Normalized graphs carry `schemaVersion: 2`. Nodes reference a stable definition The typed registry includes configuration and UI schemas, ports, credential slots, capabilities, side effects, default policies, documentation, deprecation, and execution kind. Only `input`, `set_fields`, `template`, `provider_prompt`, `http_request`, and `output` are executable. Other palette ideas are planned concepts without runtime handlers. Validation resolves definitions and checks configuration, handles, policies, graph limits, and cycles with field-level issues. Graph JSON rejects secret-shaped fields and generated/custom source fields. Persisted Graph v1 rows keep their original immutable snapshot and append deterministic Graph v2 as a new current version. + +Validation treats graph JSON as untrusted input. Malformed definition references, ports, credential bindings, policies, widget entries, schemas, publication metadata, and null array members produce deterministic field-level issues; the validation API does not throw while inspecting them. One-time browser canvas migration maps legacy planning kinds to registered canonical definitions and retains the original JSON snapshot as non-executable migration metadata. diff --git a/docs/dashboard/nodes-canvas.md b/docs/dashboard/nodes-canvas.md index 6d906e6db2..28ffae0d24 100644 --- a/docs/dashboard/nodes-canvas.md +++ b/docs/dashboard/nodes-canvas.md @@ -4,7 +4,9 @@ The **Nodes** page (`/nodes`) is a project-scoped automation workspace backed by ## Legacy canvas import -On the first load for a selected project, the dashboard checks the former `codeux:nodes-canvas:v1` key. When present, it normalizes the payload to Graph v2, creates an **Imported Nodes Canvas** backend draft, records a project-specific migration marker, and removes the legacy graph value. A failed import leaves the value available for retry. The marker prevents duplicates. +On the first load for a selected project, the dashboard checks the former `codeux:nodes-canvas:v1` key. When present, it normalizes the payload to Graph v2, creates an **Imported Nodes Canvas** backend draft, records a project-specific migration marker, and removes the legacy graph value. A failed import leaves the value available for retry. The marker prevents duplicates, so browser storage is a one-time migration source rather than a second workflow database. + +Legacy planning nodes map to registered definitions and handles: `trigger` becomes `input`, `agent` becomes `set_fields`, `task` becomes `template`, and `condition` and `output` use their matching definitions. The original JSON-safe browser snapshot is retained in non-executable graph migration metadata for review. Secret-shaped keys and custom source fields remain visible to backend validation and cause the import to be rejected instead of being silently discarded. ## Governed editing diff --git a/src/domain/node-flows/node-flow-migrators.ts b/src/domain/node-flows/node-flow-migrators.ts index 527ae746c3..daf257a1a5 100644 --- a/src/domain/node-flows/node-flow-migrators.ts +++ b/src/domain/node-flows/node-flow-migrators.ts @@ -1,5 +1,6 @@ import { NODE_FLOW_SCHEMA_VERSION, + type NodeFlowEdge, type NodeFlowGraph, type NodeFlowJsonObject, type NodeFlowJsonValue, @@ -13,98 +14,148 @@ export interface NodeFlowMigrationResult { legacySnapshot: TLegacy | null; } -const cloneJson = (value: T): T => JSON.parse(JSON.stringify(value)) as T; +interface LegacyCanvasDefinition { + type: string; + handles: Readonly>; +} + +const LEGACY_CANVAS_DEFINITIONS: Readonly> = { + trigger: { type: "input", handles: { event: "output" } }, + agent: { type: "set_fields", handles: { in: "input", agent: "output" } }, + task: { type: "template", handles: { agent: "input", task: "output" } }, + condition: { type: "condition", handles: { task: "input", pass: "true", fail: "false" } }, + output: { type: "output", handles: { result: "input" } }, +}; + +const cloneValue = (value: T): T => { + try { + return structuredClone(value); + } catch { + return value; + } +}; export function migrateNodeFlowGraph(graph: unknown): NodeFlowMigrationResult { if (isRecord(graph) && graph.schemaVersion === NODE_FLOW_SCHEMA_VERSION) { - return { graph: cloneJson(graph as unknown as NodeFlowGraph), migrated: false, legacySnapshot: null }; + return { graph: cloneValue(graph) as unknown as NodeFlowGraph, migrated: false, legacySnapshot: null }; } - const legacy = isRecord(graph) ? cloneJson(graph as unknown as NodeFlowGraph) : { nodes: [], edges: [] }; - const nodes = Array.isArray(legacy.nodes) ? legacy.nodes.map(migrateNode) : []; - const definitionByNode = new Map(nodes.map((node) => [node.id, resolveLatestNodeDefinition(node.type)])); - const edges = Array.isArray(legacy.edges) ? legacy.edges.map((edge) => ({ - ...edge, - ...(edge.fromHandle ? {} : { fromHandle: definitionByNode.get(edge.fromNodeId)?.ports.find((port) => port.direction === "output")?.id }), - ...(edge.toHandle ? {} : { toHandle: definitionByNode.get(edge.toNodeId)?.ports.find((port) => port.direction === "input")?.id }), - })) : []; + const legacy = isRecord(graph) ? cloneValue(graph) : { nodes: [], edges: [] }; + const rawNodes = Array.isArray(legacy.nodes) ? legacy.nodes : []; + const nodes = rawNodes.map((node) => isRecord(node) ? migrateNode(node) : node as NodeFlowNode); + const definitionByNode = new Map(nodes.filter(isNodeWithId).map((node) => [node.id, resolveLatestNodeDefinition(node.type)])); + const rawEdges = Array.isArray(legacy.edges) ? legacy.edges : []; + const edges = rawEdges.map((edge) => { + if (!isRecord(edge)) return edge as NodeFlowEdge; + const fromNodeId = stringValue(edge.fromNodeId); + const toNodeId = stringValue(edge.toNodeId); + return { + ...edge, + ...(trimmedString(edge.fromHandle) ? {} : { fromHandle: definitionByNode.get(fromNodeId)?.ports.find((port) => port.direction === "output")?.id }), + ...(trimmedString(edge.toHandle) ? {} : { toHandle: definitionByNode.get(toNodeId)?.ports.find((port) => port.direction === "input")?.id }), + } as NodeFlowEdge; + }); return { migrated: true, - legacySnapshot: legacy, + legacySnapshot: legacy as unknown as NodeFlowGraph, graph: { schemaVersion: NODE_FLOW_SCHEMA_VERSION, nodes, edges, - ...(legacy.inputSchema ? { inputSchema: legacy.inputSchema } : {}), - ...(legacy.schemas ? { schemas: legacy.schemas } : {}), - ...(legacy.metadata ? { metadata: legacy.metadata } : {}), - ...(legacy.publication ? { publication: legacy.publication } : {}), + ...(legacy.inputSchema !== undefined ? { inputSchema: legacy.inputSchema as NodeFlowGraph["inputSchema"] } : {}), + ...(legacy.schemas !== undefined ? { schemas: legacy.schemas as NodeFlowGraph["schemas"] } : {}), + ...(legacy.metadata !== undefined ? { metadata: legacy.metadata as NodeFlowGraph["metadata"] } : {}), + ...(legacy.publication !== undefined ? { publication: legacy.publication as NodeFlowGraph["publication"] } : {}), }, }; } -function migrateNode(node: NodeFlowNode): NodeFlowNode { - const definition = node.definition - ? resolveLatestNodeDefinition(node.definition.type) - : resolveLatestNodeDefinition(node.type); +function migrateNode(node: Record): NodeFlowNode { + const type = stringValue(node.type); + const definitionRef = isRecord(node.definition) ? node.definition : null; + const definitionType = stringValue(definitionRef?.type) || type; + const definition = resolveLatestNodeDefinition(definitionType); + const ports = node.ports === undefined ? definition?.ports.map((port) => cloneValue(port)) ?? [] : node.ports; return { ...node, - definition: node.definition ?? { type: node.type, version: definition?.version ?? 1 }, - ports: node.ports ?? definition?.ports.map((port) => cloneJson(port)) ?? [], - credentialBindings: node.credentialBindings ?? [], - policy: node.policy ?? (definition ? cloneJson(definition.defaultPolicy) : {}), - capabilities: node.capabilities ?? [...(definition?.capabilities ?? [])], + definition: node.definition === undefined ? { type, version: definition?.version ?? 1 } : node.definition, + ports, + credentialBindings: node.credentialBindings === undefined ? [] : node.credentialBindings, + policy: node.policy === undefined ? definition ? cloneValue(definition.defaultPolicy) : {} : node.policy, + capabilities: node.capabilities === undefined ? [...(definition?.capabilities ?? [])] : node.capabilities, sideEffect: node.sideEffect ?? definition?.sideEffect ?? "none", disabled: node.disabled ?? false, - }; + } as unknown as NodeFlowNode; } export function migrateNodeCanvasGraphV1(graph: unknown): NodeFlowMigrationResult { if (isRecord(graph) && graph.schemaVersion === NODE_FLOW_SCHEMA_VERSION && Array.isArray(graph.nodes) && graph.nodes.every(isCanonicalNode)) { return migrateNodeFlowGraph(graph); } - const legacySnapshot = cloneJson(graph); + const legacySnapshot = cloneValue(graph); const legacy = isRecord(graph) ? graph : {}; - const nodes = Array.isArray(legacy.nodes) ? legacy.nodes.filter(isRecord).map((node): NodeFlowNode => ({ - id: stringValue(node.id), - type: stringValue(node.kind) || stringValue(node.type), - title: stringValue(node.label) || stringValue(node.title) || stringValue(node.id), - description: stringValue(node.description) || undefined, - position: isPosition(node.position) ? { x: node.position.x, y: node.position.y } : undefined, - definition: { type: stringValue(node.kind) || stringValue(node.type), version: 1 }, - ports: [...readPorts(node.inputPorts, "input"), ...readPorts(node.outputPorts, "output")], - data: canvasNodeData(node), - credentialBindings: [], policy: {}, capabilities: [], sideEffect: "none", disabled: false, - })).filter((node) => node.id && node.type) : []; - const edges = Array.isArray(legacy.edges) ? legacy.edges.filter(isRecord).map((edge) => ({ - id: stringValue(edge.id) || undefined, - fromNodeId: endpointValue(edge.source, "nodeId"), - toNodeId: endpointValue(edge.target, "nodeId"), - fromHandle: endpointValue(edge.source, "portId") || undefined, - toHandle: endpointValue(edge.target, "portId") || undefined, - })).filter((edge) => edge.fromNodeId && edge.toNodeId) : []; + const nodeDefinitions = new Map(); + const nodes = Array.isArray(legacy.nodes) ? legacy.nodes.filter(isRecord).map((node): NodeFlowNode | null => { + const id = stringValue(node.id); + const legacyType = stringValue(node.kind) || stringValue(node.type); + const mapping = LEGACY_CANVAS_DEFINITIONS[legacyType]; + if (!id || !mapping) return null; + nodeDefinitions.set(id, mapping); + return migrateNode({ + id, + type: mapping.type, + title: stringValue(node.label) || stringValue(node.title) || id, + description: stringValue(node.description) || undefined, + position: isPosition(node.position) ? { x: node.position.x, y: node.position.y } : undefined, + data: canvasNodeData(node, legacyType), + disabled: false, + }); + }).filter((node): node is NodeFlowNode => node !== null) : []; + const edges = Array.isArray(legacy.edges) ? legacy.edges.filter(isRecord).map((edge) => { + const fromNodeId = endpointValue(edge.source, "nodeId"); + const toNodeId = endpointValue(edge.target, "nodeId"); + const sourceMapping = nodeDefinitions.get(fromNodeId); + const targetMapping = nodeDefinitions.get(toNodeId); + return { + id: stringValue(edge.id) || undefined, + fromNodeId, + toNodeId, + fromHandle: sourceMapping?.handles[endpointValue(edge.source, "portId")], + toHandle: targetMapping?.handles[endpointValue(edge.target, "portId")], + }; + }).filter((edge) => edge.fromNodeId && edge.toNodeId) : []; return { migrated: true, legacySnapshot, - graph: { schemaVersion: NODE_FLOW_SCHEMA_VERSION, nodes, edges, metadata: { canvasSelection: jsonValue(legacy.selection) } }, + graph: { + schemaVersion: NODE_FLOW_SCHEMA_VERSION, + nodes, + edges, + metadata: { + canvasSelection: jsonValue(legacy.selection), + migration: { source: "browser_canvas_v1", legacySnapshot: jsonValue(legacySnapshot) }, + }, + }, }; } -function readPorts(value: unknown, direction: "input" | "output") { - return Array.isArray(value) ? value.filter(isRecord).map((port) => ({ - id: stringValue(port.id), direction, schema: { type: "any" as const }, required: port.required === true, - })).filter((port) => port.id) : []; -} - -function canvasNodeData(node: Record): NodeFlowJsonObject { - const config = Array.isArray(node.config) ? Object.fromEntries(node.config.filter(isRecord).map((entry) => [stringValue(entry.id), jsonValue(entry.value)]).filter(([id]) => id)) : {}; - return { config, canvasMetadata: jsonValue(node.metadata) }; +function canvasNodeData(node: Record, legacyType: string): NodeFlowJsonObject { + const configEntries = Array.isArray(node.config) ? node.config.filter(isRecord) : []; + const config = Object.fromEntries(configEntries.map((entry) => [stringValue(entry.id), jsonValue(entry.value)]).filter(([id]) => id)); + const prompt = typeof config.prompt === "string" && config.prompt.trim() ? config.prompt : "Use the selected agent to complete the task."; + return { + ...(legacyType === "agent" ? { fields: { legacyAgent: cloneValue(config) } } : {}), + ...(legacyType === "task" ? { template: prompt, outputKey: "task" } : {}), + canvas: { kind: legacyType, config: jsonValue(node.config), values: config, metadata: jsonValue(node.metadata) }, + }; } -const jsonValue = (value: unknown): NodeFlowJsonValue => JSON.parse(JSON.stringify(value ?? null)) as NodeFlowJsonValue; +const jsonValue = (value: unknown): NodeFlowJsonValue => cloneValue(value ?? null) as NodeFlowJsonValue; const endpointValue = (value: unknown, key: string): string => isRecord(value) ? stringValue(value[key]) : ""; const stringValue = (value: unknown): string => typeof value === "string" ? value : ""; +const trimmedString = (value: unknown): string => stringValue(value).trim(); const isRecord = (value: unknown): value is Record => Boolean(value) && typeof value === "object" && !Array.isArray(value); const isPosition = (value: unknown): value is { x: number; y: number } => isRecord(value) && Number.isFinite(value.x) && Number.isFinite(value.y); const isCanonicalNode = (value: unknown): boolean => isRecord(value) && isRecord(value.definition); +const isNodeWithId = (value: NodeFlowNode): value is NodeFlowNode & { id: string; type: string } => isRecord(value) && typeof value.id === "string" && typeof value.type === "string"; diff --git a/src/domain/node-flows/node-flow-validation.ts b/src/domain/node-flows/node-flow-validation.ts index ad98c35254..8877bd9c92 100644 --- a/src/domain/node-flows/node-flow-validation.ts +++ b/src/domain/node-flows/node-flow-validation.ts @@ -58,6 +58,10 @@ function trimmedString(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + function isJsonValue(value: unknown, seen = new Set()): value is NodeFlowJsonValue { if (value === null) { return true; @@ -252,48 +256,81 @@ function normalizeWidgetSchema( return { fields: normalizedFields }; } -function containsForbiddenGraphValue(value: NodeFlowJsonValue): boolean { - if (Array.isArray(value)) return value.some(containsForbiddenGraphValue); +function containsForbiddenGraphValue(value: unknown, seen = new Set()): boolean { + if (Array.isArray(value)) return value.some((entry) => containsForbiddenGraphValue(entry, seen)); if (!value || typeof value !== "object") return false; - return Object.entries(value).some(([key, entry]) => FORBIDDEN_GRAPH_KEY.test(key) || containsForbiddenGraphValue(entry)); + if (seen.has(value)) return false; + seen.add(value); + return Object.entries(value).some(([key, entry]) => FORBIDDEN_GRAPH_KEY.test(key) || containsForbiddenGraphValue(entry, seen)); } function validatePolicy( - policy: NodeFlowNode["policy"], + policy: unknown, path: string, issues: NodeFlowValidationIssue[], ): void { - if (!policy) return; - const retry = policy.retry; - if (retry && (!Number.isInteger(retry.maxAttempts) || retry.maxAttempts < 1 || retry.maxAttempts > 10)) { + if (policy === undefined) return; + if (!policy || typeof policy !== "object" || Array.isArray(policy)) { + issues.push(issue(path, "invalid_policy", "Node policy must be an object.")); + return; + } + const typedPolicy = policy as Record; + if (typedPolicy.retry !== undefined && (!typedPolicy.retry || typeof typedPolicy.retry !== "object" || Array.isArray(typedPolicy.retry))) { + issues.push(issue(`${path}.retry`, "invalid_retry_policy", "Retry policy must be an object.")); + } + if (typedPolicy.timeout !== undefined && (!typedPolicy.timeout || typeof typedPolicy.timeout !== "object" || Array.isArray(typedPolicy.timeout))) { + issues.push(issue(`${path}.timeout`, "invalid_timeout_policy", "Timeout policy must be an object.")); + } + const retry = isRecord(typedPolicy.retry) ? typedPolicy.retry : undefined; + const timeout = isRecord(typedPolicy.timeout) ? typedPolicy.timeout : undefined; + if (retry && (typeof retry.maxAttempts !== "number" || !Number.isInteger(retry.maxAttempts) || retry.maxAttempts < 1 || retry.maxAttempts > 10)) { issues.push(issue(`${path}.retry.maxAttempts`, "invalid_retry_policy", "Retry maxAttempts must be an integer from 1 to 10.")); } - if (retry && (!Number.isFinite(retry.backoffMs) || retry.backoffMs < 0 || retry.backoffMs > 300_000)) { + if (retry && (typeof retry.backoffMs !== "number" || !Number.isFinite(retry.backoffMs) || retry.backoffMs < 0 || retry.backoffMs > 300_000)) { issues.push(issue(`${path}.retry.backoffMs`, "invalid_retry_policy", "Retry backoffMs must be between 0 and 300000.")); } - if (retry?.maxBackoffMs !== undefined && (!Number.isFinite(retry.maxBackoffMs) || retry.maxBackoffMs < retry.backoffMs)) { + if (retry?.maxBackoffMs !== undefined && ( + typeof retry.maxBackoffMs !== "number" + || !Number.isFinite(retry.maxBackoffMs) + || typeof retry.backoffMs !== "number" + || retry.maxBackoffMs < retry.backoffMs + )) { issues.push(issue(`${path}.retry.maxBackoffMs`, "invalid_retry_policy", "Retry maxBackoffMs must be at least backoffMs.")); } - if (policy.timeout && (!Number.isInteger(policy.timeout.timeoutMs) || policy.timeout.timeoutMs < 1 || policy.timeout.timeoutMs > 300_000)) { + if (timeout && (typeof timeout.timeoutMs !== "number" || !Number.isInteger(timeout.timeoutMs) || timeout.timeoutMs < 1 || timeout.timeoutMs > 300_000)) { issues.push(issue(`${path}.timeout.timeoutMs`, "invalid_timeout_policy", "Timeout must be an integer from 1 to 300000 milliseconds.")); } } function validateCredentialBindings( - node: NodeFlowNode, + bindings: unknown, nodePath: string, allowedSlots: string[], issues: NodeFlowValidationIssue[], -): void { +): NodeFlowNode["credentialBindings"] { + if (bindings === undefined) return []; + if (!Array.isArray(bindings)) { + issues.push(issue(`${nodePath}.credentialBindings`, "invalid_credential_bindings", "Credential bindings must be an array.")); + return []; + } const slots = new Set(); - (node.credentialBindings ?? []).forEach((binding, index) => { + const normalized: NonNullable = []; + bindings.forEach((binding, index) => { const path = `${nodePath}.credentialBindings[${index}]`; - if (!trimmedString(binding.slot)) issues.push(issue(`${path}.slot`, "required", "Credential slot is required.")); - if (!trimmedString(binding.credentialId)) issues.push(issue(`${path}.credentialId`, "required", "Credential binding must reference a credential id.")); - if (slots.has(binding.slot)) issues.push(issue(`${path}.slot`, "duplicate_credential_slot", `Duplicate credential slot: ${binding.slot}`)); - if (!allowedSlots.includes(binding.slot)) issues.push(issue(`${path}.slot`, "unknown_credential_slot", `Definition does not declare credential slot: ${binding.slot}`)); - slots.add(binding.slot); + if (!isRecord(binding)) { + issues.push(issue(path, "invalid_credential_binding", "Credential binding must be an object.")); + return; + } + const slot = trimmedString(binding.slot); + const credentialId = trimmedString(binding.credentialId); + if (!slot) issues.push(issue(`${path}.slot`, "required", "Credential slot is required.")); + if (!credentialId) issues.push(issue(`${path}.credentialId`, "required", "Credential binding must reference a credential id.")); + if (slot && slots.has(slot)) issues.push(issue(`${path}.slot`, "duplicate_credential_slot", `Duplicate credential slot: ${slot}`)); + if (slot && !allowedSlots.includes(slot)) issues.push(issue(`${path}.slot`, "unknown_credential_slot", `Definition does not declare credential slot: ${slot}`)); + if (slot) slots.add(slot); + if (slot && credentialId) normalized.push({ slot, credentialId }); }); + return normalized; } function validateConfiguration( @@ -324,9 +361,70 @@ function matchesValueSchema(value: NodeFlowJsonValue, type: NodeFlowValueSchema[ return typeof value === type; } -function normalizeNode(rawNode: NodeFlowNode, index: number, issues: NodeFlowValidationIssue[]): NodeFlowNode | null { +const VALUE_SCHEMA_TYPES = new Set([ + "any", "object", "array", "string", "number", "boolean", "null", +]); + +function normalizeValueSchema( + value: unknown, + path: string, + issues: NodeFlowValidationIssue[], + ancestors = new Set(), +): NodeFlowValueSchema | undefined { + if (!isRecord(value)) { + issues.push(issue(path, "invalid_value_schema", "Value schema must be an object.")); + return undefined; + } + if (ancestors.has(value)) { + issues.push(issue(path, "invalid_value_schema", "Value schema cannot contain circular references.")); + return undefined; + } + const type = value.type; + if (typeof type !== "string" || !VALUE_SCHEMA_TYPES.has(type as NodeFlowValueSchema["type"])) { + issues.push(issue(`${path}.type`, "invalid_value_schema_type", "Value schema type is not supported.")); + return undefined; + } + const nextAncestors = new Set(ancestors).add(value); + const normalized: NodeFlowValueSchema = { type: type as NodeFlowValueSchema["type"] }; + if (value.description !== undefined) { + if (typeof value.description === "string") normalized.description = value.description.trim(); + else issues.push(issue(`${path}.description`, "invalid_value_schema", "Value schema description must be a string.")); + } + if (value.required !== undefined) { + if (!Array.isArray(value.required)) { + issues.push(issue(`${path}.required`, "invalid_value_schema", "Value schema required must be an array of strings.")); + } else { + const required: string[] = []; + value.required.forEach((entry, index) => { + const name = trimmedString(entry); + if (!name) issues.push(issue(`${path}.required[${index}]`, "invalid_value_schema", "Required property name must be a non-empty string.")); + else required.push(name); + }); + normalized.required = required; + } + } + if (value.properties !== undefined) { + if (!isRecord(value.properties)) { + issues.push(issue(`${path}.properties`, "invalid_value_schema", "Value schema properties must be an object.")); + } else { + const properties: Record = {}; + for (const [key, property] of Object.entries(value.properties)) { + const normalizedProperty = normalizeValueSchema(property, `${path}.properties.${key}`, issues, nextAncestors); + if (normalizedProperty) properties[key] = normalizedProperty; + } + normalized.properties = properties; + } + } + if (value.items !== undefined) { + const items = normalizeValueSchema(value.items, `${path}.items`, issues, nextAncestors); + if (items) normalized.items = items; + } + return normalized; +} + +function normalizeNode(rawNode: unknown, index: number, issues: NodeFlowValidationIssue[]): NodeFlowNode | null { const nodePath = `nodes[${index}]`; - if (!rawNode || typeof rawNode !== "object" || Array.isArray(rawNode)) { + if (!isRecord(rawNode)) { issues.push(issue(nodePath, "invalid_node", "Node must be an object.")); return null; } @@ -346,8 +444,8 @@ function normalizeNode(rawNode: NodeFlowNode, index: number, issues: NodeFlowVal return null; } - const widgetSchema = normalizeWidgetSchema(rawNode.widgetSchema, `${nodePath}.widgetSchema`, issues); - const position = rawNode.position + const widgetSchema = normalizeWidgetSchema(rawNode.widgetSchema as NodeWidgetSchema | undefined, `${nodePath}.widgetSchema`, issues); + const position = isRecord(rawNode.position) && typeof rawNode.position.x === "number" && Number.isFinite(rawNode.position.x) && typeof rawNode.position.y === "number" @@ -361,31 +459,76 @@ function normalizeNode(rawNode: NodeFlowNode, index: number, issues: NodeFlowVal issues.push(issue(`${nodePath}.data`, "invalid_data", `Node ${id} data must be a JSON object.`)); } - const definitionRef = rawNode.definition ?? { type, version: 1 }; - const definition = resolveNodeDefinition(definitionRef.type, definitionRef.version); - if (definitionRef.type !== type) { + const rawDefinitionRef = rawNode.definition === undefined ? { type, version: 1 } : rawNode.definition; + let definitionType: string | null = null; + let definitionVersion: number | null = null; + if (!isRecord(rawDefinitionRef)) { + issues.push(issue(`${nodePath}.definition`, "invalid_definition_reference", "Node definition reference must be an object.")); + } else { + definitionType = trimmedString(rawDefinitionRef.type); + if (!definitionType) issues.push(issue(`${nodePath}.definition.type`, "required", "Node definition type is required.")); + if (typeof rawDefinitionRef.version !== "number" || !Number.isInteger(rawDefinitionRef.version) || rawDefinitionRef.version < 1) { + issues.push(issue(`${nodePath}.definition.version`, "invalid_definition_version", "Node definition version must be a positive integer.")); + } else { + definitionVersion = rawDefinitionRef.version; + } + } + const definition = definitionType && definitionVersion ? resolveNodeDefinition(definitionType, definitionVersion) : null; + if (definitionType && definitionType !== type) { issues.push(issue(`${nodePath}.definition.type`, "definition_type_mismatch", "Node type must match its definition reference.")); } - if (!definition) { - issues.push(issue(`${nodePath}.definition`, "unknown_node_definition", `Unknown node definition: ${definitionRef.type}@${definitionRef.version}`)); + if (definitionType && definitionVersion && !definition) { + issues.push(issue(`${nodePath}.definition`, "unknown_node_definition", `Unknown node definition: ${definitionType}@${definitionVersion}`)); } if (definition && rawNode.sideEffect !== undefined && rawNode.sideEffect !== definition.sideEffect) { issues.push(issue(`${nodePath}.sideEffect`, "definition_metadata_mismatch", "Node side effect must match its definition.")); } - if (definition && rawNode.capabilities !== undefined && [...rawNode.capabilities].sort().join("\0") !== [...definition.capabilities].sort().join("\0")) { - issues.push(issue(`${nodePath}.capabilities`, "definition_metadata_mismatch", "Node capabilities must match its definition.")); + let suppliedCapabilities: string[] | undefined; + if (rawNode.capabilities !== undefined) { + if (!Array.isArray(rawNode.capabilities) || rawNode.capabilities.some((capability) => typeof capability !== "string")) { + issues.push(issue(`${nodePath}.capabilities`, "invalid_capabilities", "Node capabilities must be an array of strings.")); + } else { + suppliedCapabilities = rawNode.capabilities; + if (definition && [...suppliedCapabilities].sort().join("\0") !== [...definition.capabilities].sort().join("\0")) { + issues.push(issue(`${nodePath}.capabilities`, "definition_metadata_mismatch", "Node capabilities must match its definition.")); + } + } } - const ports = rawNode.ports ?? definition?.ports ?? []; + const rawPorts = rawNode.ports === undefined ? definition?.ports ?? [] : rawNode.ports; + if (!Array.isArray(rawPorts)) { + issues.push(issue(`${nodePath}.ports`, "invalid_ports", "Node ports must be an array.")); + } + const ports: NonNullable = []; const portIds = new Set(); - ports.forEach((port, portIndex) => { + (Array.isArray(rawPorts) ? rawPorts : []).forEach((port, portIndex) => { const portPath = `${nodePath}.ports[${portIndex}]`; - if (!trimmedString(port.id)) issues.push(issue(`${portPath}.id`, "required", "Port id is required.")); - if (portIds.has(port.id)) issues.push(issue(`${portPath}.id`, "duplicate_port_id", `Duplicate port id: ${port.id}`)); - portIds.add(port.id); - if (port.direction !== "input" && port.direction !== "output") issues.push(issue(`${portPath}.direction`, "invalid_port_direction", "Port direction must be input or output.")); + if (!isRecord(port)) { + issues.push(issue(portPath, "invalid_port", "Port must be an object.")); + return; + } + const portId = trimmedString(port.id); + if (!portId) issues.push(issue(`${portPath}.id`, "required", "Port id is required.")); + if (portId && portIds.has(portId)) issues.push(issue(`${portPath}.id`, "duplicate_port_id", `Duplicate port id: ${portId}`)); + if (portId) portIds.add(portId); + const direction = port.direction; + if (direction !== "input" && direction !== "output") issues.push(issue(`${portPath}.direction`, "invalid_port_direction", "Port direction must be input or output.")); + const schema = normalizeValueSchema(port.schema, `${portPath}.schema`, issues); + const cardinality = port.cardinality; + if (cardinality !== undefined && cardinality !== "one" && cardinality !== "many") { + issues.push(issue(`${portPath}.cardinality`, "invalid_port_cardinality", "Port cardinality must be one or many.")); + } + if (portId && (direction === "input" || direction === "output") && schema) { + ports.push({ + id: portId, + direction, + schema, + ...(port.required !== undefined ? { required: Boolean(port.required) } : {}), + ...(cardinality === "one" || cardinality === "many" ? { cardinality } : {}), + }); + } }); validatePolicy(rawNode.policy, `${nodePath}.policy`, issues); - validateCredentialBindings(rawNode, nodePath, definition?.credentials.map((credential) => credential.slot) ?? [], issues); + const credentialBindings = validateCredentialBindings(rawNode.credentialBindings, nodePath, definition?.credentials.map((credential) => credential.slot) ?? [], issues); if (rawNode.data && containsForbiddenGraphValue(rawNode.data)) { issues.push(issue(`${nodePath}.data`, "unsafe_graph_data", "Graph data cannot contain raw secrets or custom source code.")); } @@ -399,19 +542,19 @@ function normalizeNode(rawNode: NodeFlowNode, index: number, issues: NodeFlowVal ...(widgetSchema ? { widgetSchema } : {}), ...(position ? { position } : {}), ...(rawNode.data !== undefined && isPlainJsonObject(rawNode.data) ? { data: rawNode.data } : {}), - definition: definitionRef, + definition: { type: definitionType ?? type, version: definitionVersion ?? 1 }, ports, - credentialBindings: rawNode.credentialBindings ?? [], - policy: rawNode.policy ?? definition?.defaultPolicy ?? {}, - capabilities: definition?.capabilities ?? rawNode.capabilities ?? [], - sideEffect: definition?.sideEffect ?? rawNode.sideEffect ?? "none", - disabled: rawNode.disabled ?? false, + credentialBindings, + policy: isRecord(rawNode.policy) ? rawNode.policy as NodeFlowNode["policy"] : definition?.defaultPolicy ?? {}, + capabilities: definition?.capabilities ?? suppliedCapabilities ?? [], + sideEffect: definition?.sideEffect ?? (typeof rawNode.sideEffect === "string" ? rawNode.sideEffect as NodeFlowNode["sideEffect"] : "none"), + disabled: typeof rawNode.disabled === "boolean" ? rawNode.disabled : false, }; } -function normalizeEdge(rawEdge: NodeFlowEdge, index: number, issues: NodeFlowValidationIssue[]): NodeFlowEdge | null { +function normalizeEdge(rawEdge: unknown, index: number, issues: NodeFlowValidationIssue[]): NodeFlowEdge | null { const edgePath = `edges[${index}]`; - if (!rawEdge || typeof rawEdge !== "object" || Array.isArray(rawEdge)) { + if (!isRecord(rawEdge)) { issues.push(issue(edgePath, "invalid_edge", "Edge must be an object.")); return null; } @@ -534,22 +677,36 @@ export function validateNodeFlowGraph(graph: unknown): NodeFlowValidationRespons }); const inputSchema = normalizeWidgetSchema(rawGraph.inputSchema, "inputSchema", issues); + let schemas: NodeFlowGraph["schemas"] | undefined; + if (rawGraph.schemas !== undefined) { + if (!isRecord(rawGraph.schemas)) { + issues.push(issue("schemas", "invalid_schemas", "Node flow schemas must be an object.")); + } else { + const input = rawGraph.schemas.input === undefined + ? undefined + : normalizeValueSchema(rawGraph.schemas.input, "schemas.input", issues); + const output = rawGraph.schemas.output === undefined + ? undefined + : normalizeValueSchema(rawGraph.schemas.output, "schemas.output", issues); + schemas = { ...(input ? { input } : {}), ...(output ? { output } : {}) }; + } + } if (rawGraph.metadata !== undefined && !isPlainJsonObject(rawGraph.metadata)) { issues.push(issue("metadata", "invalid_metadata", "Node flow graph metadata must be a JSON object.")); } if (rawGraph.metadata && containsForbiddenGraphValue(rawGraph.metadata)) { issues.push(issue("metadata", "unsafe_graph_metadata", "Graph metadata cannot contain raw secrets or custom source code.")); } - validatePublication(rawGraph.publication, issues); + const publication = validatePublication(rawGraph.publication, issues); const normalizedGraph: NodeFlowGraph = { schemaVersion: NODE_FLOW_SCHEMA_VERSION, nodes, edges, ...(inputSchema ? { inputSchema } : {}), - ...(rawGraph.schemas ? { schemas: rawGraph.schemas } : {}), + ...(schemas ? { schemas } : {}), ...(rawGraph.metadata !== undefined && isPlainJsonObject(rawGraph.metadata) ? { metadata: rawGraph.metadata } : {}), - ...(rawGraph.publication ? { publication: rawGraph.publication } : {}), + ...(publication ? { publication } : {}), }; const executionOrder = computeExecutionOrder(nodes, edges, issues); const valid = issues.length === 0; @@ -561,18 +718,30 @@ export function validateNodeFlowGraph(graph: unknown): NodeFlowValidationRespons } function validatePublication( - publication: NodeFlowGraph["publication"], + publication: unknown, issues: NodeFlowValidationIssue[], -): void { - if (!publication) return; - if (!trimmedString(publication.publicationId)) issues.push(issue("publication.publicationId", "required", "Publication id is required.")); - if (!trimmedString(publication.publishedBy)) issues.push(issue("publication.publishedBy", "required", "Publication author is required.")); - if (!trimmedString(publication.publishedAt) || !Number.isFinite(Date.parse(publication.publishedAt))) { +): NodeFlowGraph["publication"] | undefined { + if (publication === undefined) return undefined; + if (!isRecord(publication)) { + issues.push(issue("publication", "invalid_publication", "Publication metadata must be an object.")); + return undefined; + } + const publicationId = trimmedString(publication.publicationId); + const publishedBy = trimmedString(publication.publishedBy); + const publishedAt = trimmedString(publication.publishedAt); + const sourceVersion = publication.sourceVersion; + if (!publicationId) issues.push(issue("publication.publicationId", "required", "Publication id is required.")); + if (!publishedBy) issues.push(issue("publication.publishedBy", "required", "Publication author is required.")); + if (!publishedAt || !Number.isFinite(Date.parse(publishedAt))) { issues.push(issue("publication.publishedAt", "invalid_publication", "Publication timestamp must be ISO-compatible.")); } - if (!Number.isInteger(publication.sourceVersion) || publication.sourceVersion < 1) { + if (typeof sourceVersion !== "number" || !Number.isInteger(sourceVersion) || sourceVersion < 1) { issues.push(issue("publication.sourceVersion", "invalid_publication", "Publication sourceVersion must be a positive integer.")); } + if (!publicationId || !publishedBy || !publishedAt || typeof sourceVersion !== "number" || !Number.isInteger(sourceVersion) || sourceVersion < 1) { + return undefined; + } + return { publicationId, publishedBy, publishedAt, sourceVersion }; } export function normalizeNodeFlowGraph(graph: unknown): NormalizedNodeFlowValidation { diff --git a/tests/backend/domain/node-flows/node-flow-migrators.test.ts b/tests/backend/domain/node-flows/node-flow-migrators.test.ts index 5ee0b0727a..770dbf693f 100644 --- a/tests/backend/domain/node-flows/node-flow-migrators.test.ts +++ b/tests/backend/domain/node-flows/node-flow-migrators.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { migrateNodeCanvasGraphV1, migrateNodeFlowGraph } from "../../../../src/domain/node-flows/node-flow-migrators.js"; +import { validateNodeFlowGraph } from "../../../../src/domain/node-flows/node-flow-validation.js"; describe("node flow migrators", () => { it("deterministically migrates backend v1 while retaining an untouched snapshot", () => { @@ -16,16 +17,45 @@ describe("node flow migrators", () => { expect(legacy).toEqual({ nodes: [{ id: "start", type: "input", title: "Start" }], edges: [] }); }); - it("migrates a browser v1 canvas into the canonical graph without embedding its snapshot", () => { + it("migrates a browser v1 canvas into a valid canonical graph with reviewable snapshot metadata", () => { const legacy = { - nodes: [{ id: "trigger-1", kind: "trigger", label: "Trigger", description: "Draft", position: { x: 1, y: 2 }, inputPorts: [], outputPorts: [{ id: "event" }], config: [], metadata: {} }], - edges: [], selection: { nodeIds: ["trigger-1"], edgeIds: [] }, + nodes: [ + { id: "trigger-1", kind: "trigger", label: "Trigger", description: "Draft", position: { x: 1, y: 2 }, inputPorts: [], outputPorts: [{ id: "event" }], config: [], metadata: {} }, + { id: "agent-1", kind: "agent", label: "Agent", inputPorts: [{ id: "in" }], outputPorts: [{ id: "agent" }], config: [], metadata: {} }, + { id: "task-1", kind: "task", label: "Task", inputPorts: [{ id: "agent" }], outputPorts: [{ id: "task" }], config: [{ id: "prompt", value: "Do the work" }], metadata: {} }, + ], + edges: [ + { id: "one", source: { nodeId: "trigger-1", portId: "event" }, target: { nodeId: "agent-1", portId: "in" } }, + { id: "two", source: { nodeId: "agent-1", portId: "agent" }, target: { nodeId: "task-1", portId: "agent" } }, + ], + selection: { nodeIds: ["trigger-1"], edgeIds: [] }, }; const result = migrateNodeCanvasGraphV1(legacy); expect(result.legacySnapshot).toEqual(legacy); expect(result.graph.schemaVersion).toBe(2); - expect(result.graph.metadata).toEqual({ canvasSelection: legacy.selection }); - expect(JSON.stringify(result.graph)).not.toContain("legacySnapshot"); + expect(result.graph.nodes.map((node) => node.type)).toEqual(["input", "set_fields", "template"]); + expect(result.graph.edges).toEqual([ + expect.objectContaining({ fromHandle: "output", toHandle: "input" }), + expect.objectContaining({ fromHandle: "output", toHandle: "input" }), + ]); + expect(result.graph.metadata).toEqual({ + canvasSelection: legacy.selection, + migration: { source: "browser_canvas_v1", legacySnapshot: legacy }, + }); + expect(validateNodeFlowGraph(result.graph)).toMatchObject({ valid: true, errors: [] }); + expect(migrateNodeCanvasGraphV1(result.graph)).toMatchObject({ migrated: false, legacySnapshot: null }); + }); + + it("preserves unsafe legacy keys so canonical validation rejects them", () => { + const result = migrateNodeCanvasGraphV1({ + nodes: [{ id: "task-1", kind: "task", label: "Task", config: [{ id: "prompt", value: "Safe" }, { id: "generatedSource", value: "unsafe" }] }], + edges: [], + }); + + expect(validateNodeFlowGraph(result.graph).errors).toContainEqual(expect.objectContaining({ + field: "nodes[0].data", + code: "unsafe_graph_data", + })); }); }); diff --git a/tests/backend/domain/node-flows/node-flow-validation.test.ts b/tests/backend/domain/node-flows/node-flow-validation.test.ts index 2e56cc6bbb..481aff17ee 100644 --- a/tests/backend/domain/node-flows/node-flow-validation.test.ts +++ b/tests/backend/domain/node-flows/node-flow-validation.test.ts @@ -112,4 +112,71 @@ describe("node flow validation", () => { expect(validateNodeFlowGraph(graph).executionOrder).toEqual(["a", "z"]); }); + + it("returns deterministic field-level issues for malformed nested graph entries", () => { + const malformed = { + schemaVersion: 2, + nodes: [ + null, + { + id: "broken", + type: "input", + title: "Broken", + definition: { type: null, version: "one" }, + ports: [null, { id: "", direction: "sideways", schema: null }], + credentialBindings: [null, { slot: "provider", credentialId: null }], + policy: { retry: null, timeout: [] }, + data: { nested: [null, { value: true }] }, + }, + ], + edges: [null], + inputSchema: { fields: [null] }, + schemas: { input: { type: "object", required: [null], properties: { child: null } }, output: [] }, + publication: null, + } as unknown; + + const first = validateNodeFlowGraph(malformed); + const second = validateNodeFlowGraph(malformed); + + expect(first).toEqual(second); + expect(first.valid).toBe(false); + expect(first.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ field: "nodes[0]", code: "invalid_node" }), + expect.objectContaining({ field: "nodes[1].definition.type", code: "required" }), + expect.objectContaining({ field: "nodes[1].definition.version", code: "invalid_definition_version" }), + expect.objectContaining({ field: "nodes[1].ports[0]", code: "invalid_port" }), + expect.objectContaining({ field: "nodes[1].credentialBindings[0]", code: "invalid_credential_binding" }), + expect.objectContaining({ field: "nodes[1].policy.retry", code: "invalid_retry_policy" }), + expect.objectContaining({ field: "edges[0]", code: "invalid_edge" }), + expect.objectContaining({ field: "inputSchema.fields[0]", code: "invalid_widget_field" }), + expect.objectContaining({ field: "schemas.input.required[0]", code: "invalid_value_schema" }), + expect.objectContaining({ field: "schemas.input.properties.child", code: "invalid_value_schema" }), + expect.objectContaining({ field: "schemas.output", code: "invalid_value_schema" }), + expect.objectContaining({ field: "publication", code: "invalid_publication" }), + ])); + }); + + it("rejects malformed collection containers without throwing", () => { + const result = validateNodeFlowGraph({ + nodes: [{ + id: "start", + type: "input", + title: "Start", + definition: null, + ports: null, + credentialBindings: {}, + policy: "forever", + capabilities: [null], + }], + edges: [], + }); + + expect(result.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ field: "nodes[0].definition", code: "invalid_definition_reference" }), + expect.objectContaining({ field: "nodes[0].ports", code: "invalid_ports" }), + expect.objectContaining({ field: "nodes[0].credentialBindings", code: "invalid_credential_bindings" }), + expect.objectContaining({ field: "nodes[0].policy", code: "invalid_policy" }), + expect.objectContaining({ field: "nodes[0].capabilities", code: "invalid_capabilities" }), + ])); + }); }); diff --git a/tests/dashboard/lib/nodes-canvas-state.test.ts b/tests/dashboard/lib/nodes-canvas-state.test.ts index be3bb89561..829fc89f8f 100644 --- a/tests/dashboard/lib/nodes-canvas-state.test.ts +++ b/tests/dashboard/lib/nodes-canvas-state.test.ts @@ -7,8 +7,10 @@ import { migrateNodeCanvasGraph, nodesCanvasReducer, serializeNodeCanvasGraph, + toCanonicalNodeFlowGraph, validateNodeCanvasGraph, } from "../../../dashboard/src/v2/lib/nodes-canvas-state.js"; +import { validateNodeFlowGraph } from "../../../src/domain/node-flows/node-flow-validation.js"; const validationCodes = (graph: NodeCanvasGraph): string[] => ( validateNodeCanvasGraph(graph).map((issue) => `${issue.code}:${issue.entityId}`) @@ -33,6 +35,15 @@ describe("nodes canvas state", () => { ]); expect(graph.selection).toEqual({ nodeIds: ["trigger-1"], edgeIds: [] }); expect(validateNodeCanvasGraph(graph)).toEqual([]); + const canonical = toCanonicalNodeFlowGraph(graph); + expect(validateNodeFlowGraph(canonical)).toMatchObject({ valid: true, errors: [] }); + expect(canonical.nodes.map((node) => node.type)).toEqual([ + "set_fields", + "condition", + "output", + "template", + "input", + ]); }); it("handles reducer node, edge, config, selection, and delete operations", () => { @@ -211,6 +222,21 @@ describe("nodes canvas state", () => { expect(migrateNodeCanvasGraph(first.graph)).toMatchObject({ migrated: false, legacySnapshot: null }); }); + it("retains a legacy localStorage snapshot as non-executable migration metadata", () => { + const current = createInitialNodeCanvasGraph(); + const { schemaVersion: _schemaVersion, ...legacyGraph } = current; + const legacy = JSON.parse(JSON.stringify(legacyGraph)) as typeof legacyGraph; + const migrated = migrateNodeCanvasGraph(legacy); + const canonical = toCanonicalNodeFlowGraph(migrated.graph, migrated.legacySnapshot); + + expect(canonical.metadata?.migration).toEqual({ + source: "browser_canvas_v1", + legacySnapshot: legacy, + }); + expect(validateNodeFlowGraph(canonical)).toMatchObject({ valid: true, errors: [] }); + expect(toCanonicalNodeFlowGraph(migrated.graph, migrated.legacySnapshot)).toEqual(canonical); + }); + it("lays out graphs deterministically from ids and edges", () => { const graph = { ...createInitialNodeCanvasGraph(), diff --git a/tests/dashboard/v2/nodes-page.test.tsx b/tests/dashboard/v2/nodes-page.test.tsx index f3c48eb3e8..7810e3895f 100644 --- a/tests/dashboard/v2/nodes-page.test.tsx +++ b/tests/dashboard/v2/nodes-page.test.tsx @@ -7,6 +7,7 @@ import "@testing-library/jest-dom/vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { NODES_CANVAS_STORAGE_KEY, NodesPage } from "../../../dashboard/src/v2/NodesPage.js"; import { ProjectDataContext } from "../../../dashboard/src/v2/context/project-data.js"; +import { createInitialNodeCanvasGraph } from "../../../dashboard/src/v2/lib/nodes-canvas-state.js"; const api = vi.hoisted(() => ({ fetchNodeFlows: vi.fn(), fetchNodeFlowCatalog: vi.fn(), createNodeFlowDraft: vi.fn(), fetchNodeFlow: vi.fn(), fetchNodeFlowRuns: vi.fn(), fetchNodeFlowNodeRuns: vi.fn(), fetchNodeFlowAttempts: vi.fn(), patchNodeFlowDraft: vi.fn(), fetchNodeDefinition: vi.fn(), validateNodeFlowDraft: vi.fn(), deleteNodeFlow: vi.fn() })); vi.mock("../../../dashboard/src/v2/lib/node-flow-api.js", async (original) => ({ ...(await original()), ...api })); @@ -31,15 +32,42 @@ describe("NodesPage governed workspace", () => { }); it("imports legacy localStorage once and removes it as a source of truth", async () => { - window.localStorage.setItem(NODES_CANVAS_STORAGE_KEY, JSON.stringify({ schemaVersion: 2, nodes: [{ id: "input-1", type: "input", title: "Input", position: { x: 1, y: 1 } }], edges: [] })); + const { schemaVersion: _schemaVersion, ...legacy } = createInitialNodeCanvasGraph(); + window.localStorage.setItem(NODES_CANVAS_STORAGE_KEY, JSON.stringify(legacy)); api.createNodeFlowDraft.mockResolvedValue({ flowId: "imported", draftRevision: 1 }); api.fetchNodeFlows.mockResolvedValueOnce({ flows: [] }).mockResolvedValueOnce({ flows: [flow] }); render(); await waitFor(() => expect(api.createNodeFlowDraft).toHaveBeenCalledTimes(1)); + expect(api.createNodeFlowDraft).toHaveBeenCalledWith("project-1", expect.objectContaining({ + graph: expect.objectContaining({ + schemaVersion: 2, + nodes: expect.arrayContaining([ + expect.objectContaining({ type: "input", definition: { type: "input", version: 1 } }), + expect.objectContaining({ type: "set_fields", definition: { type: "set_fields", version: 1 } }), + expect.objectContaining({ type: "template", definition: { type: "template", version: 1 } }), + ]), + metadata: expect.objectContaining({ + migration: { source: "browser_canvas_v1", legacySnapshot: legacy }, + }), + }), + })); expect(window.localStorage.getItem(NODES_CANVAS_STORAGE_KEY)).toBeNull(); expect(window.localStorage.getItem("codeux:nodes-canvas:imported:project-1")).toBe("imported"); }); + it("keeps legacy localStorage available when backend persistence fails", async () => { + const legacy = JSON.stringify(createInitialNodeCanvasGraph()); + window.localStorage.setItem(NODES_CANVAS_STORAGE_KEY, legacy); + api.createNodeFlowDraft.mockRejectedValue(new Error("Draft persistence failed")); + api.fetchNodeFlows.mockResolvedValueOnce({ flows: [] }); + + render(); + + expect(await screen.findByRole("alert")).toHaveTextContent("Draft persistence failed"); + expect(window.localStorage.getItem(NODES_CANVAS_STORAGE_KEY)).toBe(legacy); + expect(window.localStorage.getItem("codeux:nodes-canvas:imported:project-1")).toBeNull(); + }); + it("surfaces optimistic save conflicts", async () => { const user = userEvent.setup(); api.patchNodeFlowDraft.mockResolvedValue({ conflict: { message: "The draft changed after it was read; reload the summary and reapply the patch.", actualDraftRevision: 3 } }); render();