Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion dashboard/src/v2/NodesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
124 changes: 86 additions & 38 deletions dashboard/src/v2/lib/nodes-canvas-state.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<Record<string, string>>;
}

const CANONICAL_CANVAS_DEFINITIONS: Readonly<Record<NodeCanvasNodeKind, CanonicalCanvasDefinition>> = {
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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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,
Expand Down Expand Up @@ -821,6 +849,26 @@ const parseEdge = (value: unknown): NodeCanvasEdge | null => {
};
};

const normalizeCanvasEdgeHandles = (
edge: NodeCanvasEdge,
nodeById: ReadonlyMap<string, NodeCanvasNode>,
): 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<string>,
Expand Down
4 changes: 3 additions & 1 deletion docs-web/architecture/node-flow-foundation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 3 additions & 1 deletion docs-web/content/docs/architecture-node-flow-foundation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions docs-web/content/docs/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,8 @@ export const docsRegistry: Record<DocsSlug, DocsRegistryEntry> = {
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',
Expand Down
48 changes: 10 additions & 38 deletions docs-web/content/docs/user-dashboard-nodes-canvas.mdx
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading