Add the code node to the workflow builder

Expose the workflow code node in the builder UI: a draggable palette entry, a
canvas node, and a config panel for the code, inputs, output variable, timeout,
and an optional JSON schema (validated before save). The panel serializes to the
same config the engine reads, so a code node built in the UI runs and produces an
artifact reference; workflows without code nodes load unchanged.
This commit is contained in:
Alex committed 2026-06-24 15:47:49 +01:00
1 parent e7f765b628
commit d573bab4a0
8 files changed
+673 -10

No files matched your search

+11 -1
View File
@@ -4,7 +4,17 @@ export type NodeType =
| 'agent'
| 'note'
| 'state'
| 'condition';
| 'condition'
| 'code';
export interface CodeNodeConfig {
code: string;
language: string;
inputs: string[];
output_variable?: string;
timeout?: number;
json_schema?: Record<string, unknown>;
}
export interface ConditionCase {
name?: string;
@@ -3,6 +3,7 @@ import 'reactflow/dist/style.css';
import {
AlertCircle,
Bot,
Code2,
Database,
Flag,
GitBranch,
@@ -64,10 +65,20 @@ import { getToolDisplayName } from '../../utils/toolUtils';
import AgentPageHeader from '../AgentPageHeader';
import { Agent } from '../types';
import { ConditionCase, WorkflowNode } from '../types/workflow';
import {
createDefaultCodeConfig,
normalizeCodeConfig,
parseCodeInputs,
parseCodeJsonSchemaDraft,
serializeCodeConfig,
stringifyCodeInputs,
validateCodeJsonSchema,
} from './codeNodeConfig';
import MobileBlocker from './components/MobileBlocker';
import PromptTextArea from './components/PromptTextArea';
import {
AgentNode,
CodeNode,
ConditionNode,
EndNode,
NoteNode,
@@ -283,15 +294,18 @@ function createWorkflowPayload(
| 'agent'
| 'note'
| 'state'
| 'condition',
| 'condition'
| 'code',
title: node.data.title || node.data.label || node.type,
position: node.position,
data:
node.type === 'agent' ||
node.type === 'condition' ||
node.type === 'state'
? node.data.config
: node.data,
node.type === 'code'
? serializeCodeConfig(node.data.config)
: node.type === 'agent' ||
node.type === 'condition' ||
node.type === 'state'
? node.data.config
: node.data,
})),
edges: workflowEdges.map((edge) => ({
id: edge.id,
@@ -310,6 +324,7 @@ const NODE_TYPES: NodeTypes = {
note: NoteNode,
state: SetStateNode,
condition: ConditionNode,
code: CodeNode,
};
function WorkflowBuilderInner() {
@@ -515,6 +530,10 @@ function WorkflowBuilderInner() {
mode: 'simple',
cases: [{ name: '', expression: '', sourceHandle: 'case_0' }],
};
} else if (type === 'code') {
baseNode.data.title = 'Code';
baseNode.data.label = 'Code';
baseNode.data.config = createDefaultCodeConfig();
} else if (type === 'note') {
baseNode.data.title = 'Note';
baseNode.data.label = 'Note';
@@ -615,6 +634,27 @@ function WorkflowBuilderInner() {
[handleUpdateNodeData, selectedNode],
);
const handleCodeJsonSchemaChange = useCallback(
(text: string) => {
if (!selectedNode || selectedNode.type !== 'code') return;
const nodeId = selectedNode.id;
setAgentJsonSchemaDrafts((prev) => ({ ...prev, [nodeId]: text }));
const { schema, error } = parseCodeJsonSchemaDraft(text);
setAgentJsonSchemaErrors((prev) => ({ ...prev, [nodeId]: error }));
if (!error) {
handleUpdateNodeData({
config: {
...(selectedNode.data.config || {}),
json_schema: schema,
},
});
}
},
[handleUpdateNodeData, selectedNode],
);
const handleUpload = useCallback((files: File[]) => {
if (files && files.length > 0) {
setImageFile(files[0]);
@@ -781,6 +821,29 @@ function WorkflowBuilderInner() {
});
}, [selectedNode]);
useEffect(() => {
if (!selectedNode || selectedNode.type !== 'code') return;
const nodeId = selectedNode.id;
const rawSchema = selectedNode.data.config?.json_schema;
setAgentJsonSchemaDrafts((prev) => {
if (prev[nodeId] !== undefined) return prev;
if (rawSchema === undefined || rawSchema === null) {
return { ...prev, [nodeId]: '' };
}
try {
return { ...prev, [nodeId]: JSON.stringify(rawSchema, null, 2) };
} catch {
return { ...prev, [nodeId]: String(rawSchema) };
}
});
setAgentJsonSchemaErrors((prev) => {
if (prev[nodeId] !== undefined) return prev;
return { ...prev, [nodeId]: validateCodeJsonSchema(rawSchema) };
});
}, [selectedNode]);
useEffect(() => {
const loadAgentDetails = async () => {
if (!agentId) return;
@@ -834,6 +897,10 @@ function WorkflowBuilderInner() {
};
} else if (n.type === 'state' && n.data) {
nodeData.config = n.data;
} else if (n.type === 'code') {
nodeData.config = normalizeCodeConfig(
n.data as Record<string, unknown> | undefined,
);
} else if (n.data) {
Object.assign(nodeData, n.data);
}
@@ -1089,6 +1156,27 @@ function WorkflowBuilderInner() {
});
});
const codeNodes = nodes.filter((n) => n.type === 'code');
codeNodes.forEach((node) => {
const codeTitle = node.data?.title || node.id;
const config = node.data?.config;
if (!(config?.code || '').trim()) {
errors.push(`Code node "${codeTitle}" must have code to run`);
}
const schemaValidationError = validateCodeJsonSchema(config?.json_schema);
const draftSchemaError = agentJsonSchemaErrors[node.id];
const effectiveSchemaError =
draftSchemaError !== undefined
? draftSchemaError
: schemaValidationError;
if (effectiveSchemaError) {
errors.push(
`Code node "${codeTitle}" JSON schema ${effectiveSchemaError}`,
);
}
});
return errors;
}, [workflowName, nodes, edges, agentJsonSchemaErrors, availableModels]);
@@ -1367,6 +1455,31 @@ function WorkflowBuilderInner() {
return selectedModel.supports_structured_output;
}, [selectedNode, availableModels]);
const selectedCodeJsonSchemaText = useMemo(() => {
if (!selectedNode || selectedNode.type !== 'code') return '';
const draft = agentJsonSchemaDrafts[selectedNode.id];
if (draft !== undefined) return draft;
const schema = selectedNode.data.config?.json_schema;
if (schema === undefined || schema === null) return '';
try {
return JSON.stringify(schema, null, 2);
} catch {
return String(schema);
}
}, [selectedNode, agentJsonSchemaDrafts]);
const selectedCodeJsonSchemaError = useMemo(() => {
if (!selectedNode || selectedNode.type !== 'code') return null;
const cachedError = agentJsonSchemaErrors[selectedNode.id];
if (cachedError !== undefined) return cachedError;
return validateCodeJsonSchema(selectedNode.data.config?.json_schema);
}, [selectedNode, agentJsonSchemaErrors]);
return (
<>
<MobileBlocker />
@@ -1724,6 +1837,23 @@ function WorkflowBuilderInner() {
</span>
</div>
</div>
<div
className="group border-border bg-card flex cursor-move items-center gap-3 rounded-full border px-4 py-3 shadow-sm transition-all hover:shadow-md"
draggable
onDragStart={(e) => handleNodeDragStart(e, 'code')}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-indigo-100 text-indigo-600 transition-colors group-hover:bg-indigo-600 group-hover:text-white dark:bg-indigo-900/40 dark:text-indigo-300">
<Code2 size={18} />
</div>
<div className="flex flex-col">
<span className="text-foreground text-sm font-medium">
Code
</span>
<span className="text-muted-foreground text-xs">
Run code in a sandbox
</span>
</div>
</div>
</div>
</div>
</div>
@@ -1765,6 +1895,7 @@ function WorkflowBuilderInner() {
{selectedNode.type === 'note' && 'Note'}
{selectedNode.type === 'state' && 'Set global variables'}
{selectedNode.type === 'condition' && 'If / Else'}
{selectedNode.type === 'code' && 'Code'}
</h3>
<Button
type="button"
@@ -2592,6 +2723,148 @@ function WorkflowBuilderInner() {
</Button>
</>
)}
{selectedNode.type === 'code' && (
<>
<p className="text-xs text-gray-500 dark:text-gray-400">
Run code in the workflow sandbox. Produced
files are saved as artifacts.
</p>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Code
</label>
<textarea
value={selectedNode.data.config?.code ?? ''}
onChange={(e) =>
handleUpdateNodeData({
config: {
...(selectedNode.data.config || {}),
code: e.target.value,
},
})
}
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card w-full rounded-xl border px-3 py-2 font-mono text-xs transition-all outline-none focus-visible:ring-2 dark:text-white"
rows={10}
spellCheck={false}
placeholder={'print("hello world")'}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Inputs
</label>
<textarea
value={stringifyCodeInputs(
selectedNode.data.config?.inputs,
)}
onChange={(e) =>
handleUpdateNodeData({
config: {
...(selectedNode.data.config || {}),
inputs: parseCodeInputs(
e.target.value,
),
},
})
}
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card w-full rounded-xl border px-3 py-2 text-sm transition-all outline-none focus-visible:ring-2 dark:text-white"
rows={2}
placeholder="One per line: state variable or artifact ref"
/>
<p className="text-muted-foreground mt-1 text-xs">
Artifact refs or state-variable names, one
per line.
</p>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Output Variable
</label>
<Input
type="text"
value={
selectedNode.data.config
?.output_variable || ''
}
onChange={(e) =>
handleUpdateNodeData({
config: {
...(selectedNode.data.config || {}),
output_variable: e.target.value,
},
})
}
className="bg-card h-auto rounded-xl px-3 py-2 text-sm shadow-none"
placeholder="Variable name for output"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Timeout (seconds)
</label>
<Input
type="number"
min={1}
value={
selectedNode.data.config?.timeout ?? ''
}
onChange={(e) => {
const raw = e.target.value;
const parsed =
raw.trim() === ''
? undefined
: Number.parseInt(raw, 10);
handleUpdateNodeData({
config: {
...(selectedNode.data.config || {}),
timeout:
parsed !== undefined &&
Number.isFinite(parsed)
? parsed
: undefined,
},
});
}}
className="bg-card h-auto rounded-xl px-3 py-2 text-sm shadow-none"
placeholder="Optional"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Structured Output (JSON Schema)
</label>
<textarea
value={selectedCodeJsonSchemaText}
onChange={(e) =>
handleCodeJsonSchemaChange(e.target.value)
}
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card w-full rounded-xl border px-3 py-2 font-mono text-xs transition-all outline-none focus-visible:ring-2 dark:text-white"
rows={6}
placeholder={`{
"type": "object",
"properties": {
"result": { "type": "string" }
},
"required": ["result"]
}`}
/>
{selectedCodeJsonSchemaText.trim() !== '' && (
<p
className={`mt-2 text-xs ${
selectedCodeJsonSchemaError
? 'text-red-600 dark:text-red-400'
: 'text-green-600 dark:text-green-400'
}`}
>
{selectedCodeJsonSchemaError
? `Invalid JSON schema: ${selectedCodeJsonSchemaError}`
: 'Valid JSON schema'}
</p>
)}
</div>
</>
)}
</>
)}
</div>
@@ -2629,10 +2902,20 @@ function WorkflowBuilderInner() {
.filter((n) => n.type !== 'note')
.map((n) => ({
id: n.id,
type: n.type as 'start' | 'end' | 'agent' | 'state',
type: n.type as
| 'start'
| 'end'
| 'agent'
| 'state'
| 'code',
title: n.data.title || n.data.label || n.type,
position: n.position,
data: n.type === 'agent' ? n.data.config : n.data,
data:
n.type === 'code'
? serializeCodeConfig(n.data.config)
: n.type === 'agent'
? n.data.config
: n.data,
})),
edges: edges.map((e) => ({
id: e.id,
@@ -2,6 +2,7 @@ import {
Bot,
CheckCircle2,
Circle,
Code2,
Database,
Flag,
GitBranch,
@@ -56,6 +57,7 @@ const NODE_ICONS: Record<string, React.ReactNode> = {
note: <StickyNote className="h-3 w-3" />,
state: <Database className="h-3 w-3" />,
condition: <GitBranch className="h-3 w-3" />,
code: <Code2 className="h-3 w-3" />,
};
const NODE_COLORS: Record<string, string> = {
@@ -65,6 +67,7 @@ const NODE_COLORS: Record<string, string> = {
note: 'text-yellow-600 dark:text-yellow-400',
state: 'text-blue-600 dark:text-blue-400',
condition: 'text-orange-600 dark:text-orange-400',
code: 'text-indigo-600 dark:text-indigo-400',
};
function ExecutionDetails({
@@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest';
import {
createDefaultCodeConfig,
normalizeCodeConfig,
parseCodeInputs,
parseCodeJsonSchemaDraft,
serializeCodeConfig,
stringifyCodeInputs,
validateCodeJsonSchema,
} from './codeNodeConfig';
describe('validateCodeJsonSchema', () => {
it('treats undefined/null as valid (no schema)', () => {
expect(validateCodeJsonSchema(undefined)).toBeNull();
expect(validateCodeJsonSchema(null)).toBeNull();
});
it('rejects arrays and non-objects', () => {
expect(validateCodeJsonSchema([])).toMatch(/valid JSON object/);
expect(validateCodeJsonSchema('x')).toMatch(/valid JSON object/);
});
it('requires a type or schema field', () => {
expect(validateCodeJsonSchema({})).toMatch(/"type" or "schema"/);
expect(validateCodeJsonSchema({ type: 'object' })).toBeNull();
expect(validateCodeJsonSchema({ schema: {} })).toBeNull();
});
});
describe('parseCodeJsonSchemaDraft', () => {
it('returns undefined schema and no error for blank input', () => {
expect(parseCodeJsonSchemaDraft(' ')).toEqual({
schema: undefined,
error: null,
});
});
it('flags invalid JSON', () => {
const result = parseCodeJsonSchemaDraft('{ not json');
expect(result.schema).toBeUndefined();
expect(result.error).toMatch(/valid JSON/);
});
it('flags structurally invalid schema', () => {
const result = parseCodeJsonSchemaDraft('{"foo": 1}');
expect(result.schema).toBeUndefined();
expect(result.error).toMatch(/"type" or "schema"/);
});
it('parses a valid schema', () => {
const result = parseCodeJsonSchemaDraft(
'{"type":"object","properties":{}}',
);
expect(result.error).toBeNull();
expect(result.schema).toEqual({ type: 'object', properties: {} });
});
});
describe('inputs (de)serialization', () => {
it('splits on newlines and commas and trims', () => {
expect(parseCodeInputs('A1\nfoo, bar\n\n baz ')).toEqual([
'A1',
'foo',
'bar',
'baz',
]);
});
it('round-trips through stringify', () => {
const inputs = ['A1', 'state_var'];
expect(parseCodeInputs(stringifyCodeInputs(inputs))).toEqual(inputs);
});
it('stringifies undefined to empty string', () => {
expect(stringifyCodeInputs(undefined)).toBe('');
});
});
describe('createDefaultCodeConfig', () => {
it('returns python defaults with empty code and inputs', () => {
expect(createDefaultCodeConfig()).toEqual({
code: '',
language: 'python',
inputs: [],
});
});
});
describe('serializeCodeConfig', () => {
it('omits optional fields when empty', () => {
const serialized = serializeCodeConfig({
code: 'print(1)',
language: 'python',
inputs: ['', ' '],
output_variable: ' ',
});
expect(serialized).toEqual({
code: 'print(1)',
language: 'python',
inputs: [],
});
expect('output_variable' in serialized).toBe(false);
expect('timeout' in serialized).toBe(false);
expect('json_schema' in serialized).toBe(false);
});
it('keeps all CodeNodeConfig fields when present', () => {
const serialized = serializeCodeConfig({
code: 'print(1)',
language: 'python',
inputs: ['A1', 'state_var'],
output_variable: 'result',
timeout: 30,
json_schema: { type: 'object' },
});
expect(serialized).toEqual({
code: 'print(1)',
language: 'python',
inputs: ['A1', 'state_var'],
output_variable: 'result',
timeout: 30,
json_schema: { type: 'object' },
});
});
it('falls back to python defaults for an undefined config', () => {
expect(serializeCodeConfig(undefined)).toEqual({
code: '',
language: 'python',
inputs: [],
});
});
});
describe('normalizeCodeConfig', () => {
it('tolerates a missing/empty payload', () => {
expect(normalizeCodeConfig(undefined)).toEqual({
code: '',
language: 'python',
inputs: [],
});
});
it('coerces bad field types without throwing', () => {
const config = normalizeCodeConfig({
code: 42,
language: '',
inputs: ['A1', 5, 'B2'],
output_variable: '',
timeout: 'soon',
json_schema: [],
} as unknown as Record<string, unknown>);
expect(config).toEqual({
code: '',
language: 'python',
inputs: ['A1', 'B2'],
});
});
it('preserves a fully populated saved config (round-trip with serialize)', () => {
const saved = {
code: 'print(1)',
language: 'python',
inputs: ['A1'],
output_variable: 'result',
timeout: 30,
json_schema: { type: 'object' },
};
const normalized = normalizeCodeConfig(saved);
expect(serializeCodeConfig(normalized)).toEqual(saved);
});
});
@@ -0,0 +1,133 @@
import type { CodeNodeConfig } from '../types/workflow';
const DEFAULT_LANGUAGE = 'python';
/**
* Validate a parsed JSON schema object the same way the agent node does:
* it must be a non-array object that declares a "type" or "schema" field.
* Returns null when valid (or absent), otherwise a short error fragment.
*/
export function validateCodeJsonSchema(schema: unknown): string | null {
if (schema === undefined || schema === null) return null;
if (typeof schema !== 'object' || Array.isArray(schema)) {
return 'must be a valid JSON object';
}
const schemaObject = schema as Record<string, unknown>;
if (!('schema' in schemaObject) && !('type' in schemaObject)) {
return 'must include either a "type" or "schema" field';
}
return null;
}
/**
* Parse the raw json_schema textarea content. Returns the parsed schema (or
* undefined when blank) and a validation error message when invalid.
*/
export function parseCodeJsonSchemaDraft(text: string): {
schema: Record<string, unknown> | undefined;
error: string | null;
} {
if (text.trim() === '') {
return { schema: undefined, error: null };
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return { schema: undefined, error: 'must be valid JSON' };
}
const error = validateCodeJsonSchema(parsed);
if (error) {
return { schema: undefined, error };
}
return { schema: parsed as Record<string, unknown>, error: null };
}
/** Split a newline/comma separated inputs textarea into a clean ref list. */
export function parseCodeInputs(text: string): string[] {
return text
.split(/[\n,]/)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
/** Render an inputs list back into the textarea (one ref per line). */
export function stringifyCodeInputs(inputs: string[] | undefined): string {
return (inputs || []).join('\n');
}
/** Default config applied when a fresh code node is dropped onto the canvas. */
export function createDefaultCodeConfig(): CodeNodeConfig {
return {
code: '',
language: DEFAULT_LANGUAGE,
inputs: [],
};
}
/**
* Normalize an arbitrary loaded config (from a saved workflow) into the editor
* shape, tolerating missing/extra fields so existing workflows keep loading.
*/
export function normalizeCodeConfig(
raw: Record<string, unknown> | undefined,
): CodeNodeConfig {
const source = raw || {};
const config: CodeNodeConfig = {
code: typeof source.code === 'string' ? source.code : '',
language:
typeof source.language === 'string' && source.language.trim() !== ''
? source.language
: DEFAULT_LANGUAGE,
inputs: Array.isArray(source.inputs)
? source.inputs.filter(
(entry): entry is string => typeof entry === 'string',
)
: [],
};
if (typeof source.output_variable === 'string' && source.output_variable) {
config.output_variable = source.output_variable;
}
if (typeof source.timeout === 'number' && Number.isFinite(source.timeout)) {
config.timeout = source.timeout;
}
if (
source.json_schema &&
typeof source.json_schema === 'object' &&
!Array.isArray(source.json_schema)
) {
config.json_schema = source.json_schema as Record<string, unknown>;
}
return config;
}
/**
* Serialize the editor config into the exact CodeNodeConfig shape the backend
* expects. Optional fields are omitted when empty so the payload stays clean.
*/
export function serializeCodeConfig(
config: CodeNodeConfig | undefined,
): CodeNodeConfig {
const source = config || createDefaultCodeConfig();
const serialized: CodeNodeConfig = {
code: source.code || '',
language: source.language || DEFAULT_LANGUAGE,
inputs: (source.inputs || []).filter(
(entry) => typeof entry === 'string' && entry.trim() !== '',
),
};
if (source.output_variable && source.output_variable.trim() !== '') {
serialized.output_variable = source.output_variable.trim();
}
if (typeof source.timeout === 'number' && Number.isFinite(source.timeout)) {
serialized.timeout = source.timeout;
}
if (
source.json_schema &&
typeof source.json_schema === 'object' &&
!Array.isArray(source.json_schema)
) {
serialized.json_schema = source.json_schema;
}
return serialized;
}
@@ -5,7 +5,7 @@ interface BaseNodeProps {
title: string;
children?: ReactNode;
selected?: boolean;
type?: 'start' | 'end' | 'default' | 'state' | 'agent' | 'condition';
type?: 'start' | 'end' | 'default' | 'state' | 'agent' | 'condition' | 'code';
icon?: ReactNode;
handles?: {
source?: boolean;
@@ -42,6 +42,9 @@ export const BaseNode: React.FC<BaseNodeProps> = ({
} else if (type === 'condition') {
iconBg = 'bg-orange-100 dark:bg-orange-900/30';
iconColor = 'text-orange-600 dark:text-orange-400';
} else if (type === 'code') {
iconBg = 'bg-indigo-100 dark:bg-indigo-900/30';
iconColor = 'text-indigo-600 dark:text-indigo-400';
}
return (
@@ -0,0 +1,56 @@
import { Code2 } from 'lucide-react';
import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { CodeNodeConfig } from '../../types/workflow';
import { BaseNode } from './BaseNode';
type CodeNodeData = {
title?: string;
label?: string;
config?: Partial<CodeNodeConfig>;
};
const CodeNode = ({ data, selected }: NodeProps<CodeNodeData>) => {
const title = data.title || data.label || 'Code';
const config = data.config || {};
const code = (config.code || '').trim();
const firstLine = code.split('\n').find((line) => line.trim() !== '') || '';
const codeHint =
firstLine.length > 40 ? `${firstLine.slice(0, 40)}…` : firstLine;
return (
<BaseNode
title={title}
type="code"
selected={selected}
icon={<Code2 size={16} />}
handles={{ source: true, target: true }}
>
<div className="flex flex-col gap-1">
{codeHint ? (
<div
className="truncate font-mono text-xs text-gray-500 dark:text-gray-400"
title={code}
>
{codeHint}
</div>
) : (
<div className="text-xs text-gray-500 dark:text-gray-400">
No code yet
</div>
)}
{config.output_variable && (
<div
className="truncate text-xs text-gray-500 dark:text-gray-400"
title={`Output: ${config.output_variable}`}
>
Output: {config.output_variable}
</div>
)}
</div>
</BaseNode>
);
};
export default memo(CodeNode);
@@ -2,6 +2,7 @@ import { Bot, Flag, Play, StickyNote } from 'lucide-react';
import { memo } from 'react';
import { BaseNode } from './BaseNode';
import CodeNode from './CodeNode';
import ConditionNode from './ConditionNode';
import SetStateNode from './SetStateNode';
@@ -145,3 +146,4 @@ export const NoteNode = memo(function NoteNode({
export { SetStateNode };
export { ConditionNode };
export { CodeNode };