Skip to content

Commit e9be48c

Browse files
committed
fix: prevent duplicate node execution + clean node naming
- tick_execution: mark nodes as enqueued (null sentinel in node_outputs) after inserting job, preventing re-enqueue on subsequent ticks. Root cause of 16x email sends: each inline node completion triggered tick which re-enqueued all not-yet-completed nodes. - Node naming: use {type}{N} pattern (string1, string2, send-email1) instead of random suffixes or _copy_ chains. Shared nextNodeName() utility used by drag-drop, duplicate, paste, and sidebar add. - Graph name: sync flowName into graph object on save/execute so exported JSON has the correct name instead of empty string. - Export/Import: download/upload buttons in toolbar for JSON graph debugging.
1 parent 8441aec commit e9be48c

6 files changed

Lines changed: 70 additions & 24 deletions

File tree

packages/fbp-graph-editor/src/components/GraphCanvas.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useRef, useCallback, useEffect, useState } from 'react';
2-
import { useGraph, useSelection, useNavigation, useScopedGraph, Point } from '../context/GraphContext';
2+
import { useGraph, useSelection, useNavigation, useScopedGraph, Point, nextNodeName } from '../context/GraphContext';
33
import { GraphNode } from './GraphNode';
44
import { GraphEdge, TempEdge } from './GraphEdge';
55
import { screenToCanvas, clamp } from '../utils/geometry';
@@ -300,8 +300,9 @@ export function GraphCanvas() {
300300
: 'prop';
301301
dispatch({ type: 'ADD_BOUNDARY_NODE', boundaryType, position });
302302
} else {
303+
const existingNames = scopedNodesRef.current.map(n => n.name);
303304
const newNode = {
304-
name: `${definitionName.split('/').pop()}_${Date.now().toString(36)}`,
305+
name: nextNodeName(definitionName, existingNames),
305306
type: definitionName,
306307
meta: position
307308
};

packages/fbp-graph-editor/src/context/GraphContext.tsx

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,25 @@ import {
1616
} from '../utils/graphTransform';
1717
import { BOUNDARY_NODE_KINDS, isBoundaryNodeKind } from '../types';
1818

19+
/**
20+
* Generate the next node name for a given type using {shortType}{N} pattern.
21+
* e.g. "const/string" → "string1", "string2", etc.
22+
* Scans existing names to find the highest N and increments.
23+
*/
24+
export function nextNodeName(nodeType: string, existingNames: string[]): string {
25+
const base = nodeType.includes('/') ? nodeType.split('/').pop()! : nodeType;
26+
const pattern = new RegExp(`^${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)$`);
27+
let max = 0;
28+
for (const name of existingNames) {
29+
const m = pattern.exec(name);
30+
if (m) {
31+
const n = parseInt(m[1], 10);
32+
if (n > max) max = n;
33+
}
34+
}
35+
return `${base}${max + 1}`;
36+
}
37+
1938
// Built-in definitions for boundary node kinds
2039
// These ensure graphInput/graphOutput/graphProp nodes render their ports
2140
const BOUNDARY_NODE_DEFINITIONS: NodeDefinition[] = [
@@ -428,9 +447,11 @@ function graphReducer(state: GraphEditorState, action: GraphAction): GraphEditor
428447
const scopedNodes = getNodesAtScope(state.graph, state.cwd);
429448
const scopedEdges = getEdgesAtScope(state.graph, state.cwd);
430449
const selectedNodes = scopedNodes.filter(n => state.selection.nodeIds.has(n.name));
450+
const allNames = scopedNodes.map(n => n.name);
431451
const nameMap = new Map<string, string>();
432452
const duplicatedNodes = selectedNodes.map(n => {
433-
const newName = `${n.name}_copy_${Date.now().toString(36)}`;
453+
const newName = nextNodeName(n.type, allNames);
454+
allNames.push(newName);
434455
nameMap.set(n.name, newName);
435456
return {
436457
...n,
@@ -474,9 +495,12 @@ function graphReducer(state: GraphEditorState, action: GraphAction): GraphEditor
474495
case 'PASTE_SELECTION': {
475496
if (state.clipboard.nodes.length === 0) return state;
476497

498+
const scopedNodesPaste = getNodesAtScope(state.graph, state.cwd);
499+
const allNamesPaste = scopedNodesPaste.map(n => n.name);
477500
const nameMap = new Map<string, string>();
478501
const pastedNodes = state.clipboard.nodes.map(n => {
479-
const newName = `${n.name}_copy_${Date.now().toString(36)}`;
502+
const newName = nextNodeName(n.type, allNamesPaste);
503+
allNamesPaste.push(newName);
480504
nameMap.set(n.name, newName);
481505
return {
482506
...n,

packages/fbp-graph-editor/src/context/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export {
33
useGraph,
44
useSelection,
55
useNavigation,
6+
nextNodeName,
67
type GraphEditorState,
78
type ViewState,
89
type SelectionState,

pgpm/constructive-compute/deploy/schemas/constructive_compute_private/procedures/platform_tick_execution/procedure.sql

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,13 @@ BEGIN
179179
)
180180
VALUES
181181
(v_exec.database_id, (('fbp:eval:' || v_graph.context) || ':') || v_node_type, (json_build_object('execution_id', v_exec.id, 'node_name', v_node_name, 'node_type', v_node_type, 'inputs', v_inputs, 'props', v_node->'props'))::json);
182+
-- Mark node as enqueued (null sentinel) so subsequent ticks don't re-enqueue it.
183+
-- complete_node will overwrite with the real output UUID when the job finishes.
184+
UPDATE "constructive_compute_private".platform_function_graph_executions SET
185+
node_outputs = node_outputs || jsonb_build_object(v_node_name, null)
186+
WHERE
187+
id = platform_tick_execution.execution_id
188+
RETURNING * INTO v_exec;
182189
v_jobs_enqueued := v_jobs_enqueued + 1;
183190
END LOOP;
184191
UPDATE "constructive_compute_private".platform_function_graph_executions SET

pgpm/constructive-compute/sql/constructive-compute--0.0.1.sql

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,13 @@ BEGIN
390390
)
391391
VALUES
392392
(v_exec.database_id, v_node_type, (json_build_object('execution_id', v_exec.id, 'node_name', v_node_name, 'node_type', v_node_type, 'inputs', v_inputs, 'props', v_node->'props'))::json);
393+
-- Mark node as enqueued (null sentinel) so subsequent ticks don't re-enqueue it.
394+
-- complete_node will overwrite with the real output UUID when the job finishes.
395+
UPDATE "constructive_compute_private".platform_function_graph_executions SET
396+
node_outputs = node_outputs || jsonb_build_object(v_node_name, null)
397+
WHERE
398+
id = platform_tick_execution.execution_id
399+
RETURNING * INTO v_exec;
393400
v_jobs_enqueued := v_jobs_enqueued + 1;
394401
END LOOP;
395402
UPDATE "constructive_compute_private".platform_function_graph_executions SET

www/src/components/FlowsPanel.tsx

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
2-
import { GraphEditor, NodeIcon } from '@fbp/graph-editor';
2+
import { GraphEditor, NodeIcon, nextNodeName } from '@fbp/graph-editor';
33
import { evaluate } from '@fbp/evaluator';
44
import {
55
mathDefinitions,
@@ -96,10 +96,11 @@ function platformFnToDefinition(fn: FunctionNode): NodeDefinition {
9696
};
9797
}
9898

99-
function functionToNode(fn: FunctionNode, position: { x: number; y: number }): Node {
99+
function functionToNode(fn: FunctionNode, position: { x: number; y: number }, existingNames: string[]): Node {
100+
const nodeType = fn.taskIdentifier || fn.name || '';
100101
return {
101-
name: `${fn.name}_${Date.now()}`,
102-
type: fn.taskIdentifier || fn.name || '',
102+
name: nextNodeName(nodeType, existingNames),
103+
type: nodeType,
103104
meta: position,
104105
props: [
105106
{ name: 'secretsCount', type: 'number', value: ((fn.requiredSecrets ?? []) as FunctionRequirement[]).length || 0 },
@@ -109,9 +110,9 @@ function functionToNode(fn: FunctionNode, position: { x: number; y: number }): N
109110
};
110111
}
111112

112-
function definitionToNode(def: NodeDefinition, position: { x: number; y: number }): Node {
113+
function definitionToNode(def: NodeDefinition, position: { x: number; y: number }, existingNames: string[]): Node {
113114
return {
114-
name: `${def.name.split('/').pop()}_${Date.now().toString(36)}`,
115+
name: nextNodeName(def.name, existingNames),
115116
type: def.name,
116117
meta: position,
117118
};
@@ -742,7 +743,7 @@ export function FlowsPanel() {
742743
}, [implDefinitions]);
743744

744745
const handleExecute = useCallback(async () => {
745-
const graph = graphRef.current;
746+
const graph = { ...graphRef.current, name: flowName.trim() || 'untitled' };
746747
if (!graph.nodes.length) return;
747748

748749
setIsExecuting(true);
@@ -819,11 +820,11 @@ export function FlowsPanel() {
819820
setExecutionState({ executionId: '', status: 'failed', nodeStates: {}, error: msg });
820821
setIsExecuting(false);
821822
}
822-
}, []);
823+
}, [flowName]);
823824

824825
const handleSave = useCallback(async () => {
825826
const name = flowName.trim() || `Flow ${stores.length + 1}`;
826-
const graph = graphRef.current;
827+
const graph = { ...graphRef.current, name };
827828
setIsSaving(true);
828829
setSaveStatus(null);
829830

@@ -884,20 +885,25 @@ export function FlowsPanel() {
884885

885886
const handleAddNode = useCallback((def: NodeDefinition, fn?: FunctionNode) => {
886887
const position = { x: 300 + Math.random() * 200, y: 100 + Math.random() * 200 };
887-
if (fn) {
888-
const node = functionToNode(fn, position);
889-
setCurrentGraph(prev => ({ ...prev, nodes: [...prev.nodes, node] }));
890-
} else {
891-
const node = definitionToNode(def, position);
892-
setCurrentGraph(prev => ({ ...prev, nodes: [...prev.nodes, node] }));
893-
}
888+
setCurrentGraph(prev => {
889+
const existingNames = prev.nodes.map(n => n.name);
890+
const node = fn
891+
? functionToNode(fn, position, existingNames)
892+
: definitionToNode(def, position, existingNames);
893+
return { ...prev, nodes: [...prev.nodes, node] };
894+
});
894895
}, []);
895896

896897
const handleLoadAll = useCallback(() => {
897-
const newNodes = functions.map((fn, i) =>
898-
functionToNode(fn, { x: 300, y: 50 + i * 200 })
899-
);
900-
setCurrentGraph(prev => ({ ...prev, nodes: [...prev.nodes, ...newNodes] }));
898+
setCurrentGraph(prev => {
899+
const existingNames = prev.nodes.map(n => n.name);
900+
const newNodes = functions.map((fn, i) => {
901+
const node = functionToNode(fn, { x: 300, y: 50 + i * 200 }, existingNames);
902+
existingNames.push(node.name);
903+
return node;
904+
});
905+
return { ...prev, nodes: [...prev.nodes, ...newNodes] };
906+
});
901907
}, [functions]);
902908

903909
const handleDragStart = useCallback((e: React.DragEvent, def: NodeDefinition) => {

0 commit comments

Comments
 (0)