diff --git a/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx b/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx index 6126af70..d0ec4f79 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx +++ b/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx @@ -422,6 +422,89 @@ function SpecRendererInner({ } }, [studyId, loadSpec, onStudyChange]); + // ------------------------------------------------------------------------- + // Option A: Edge projection sync (source fields are truth) + // Keep canvas edges in sync when user edits objective/constraint source in panels. + // We only enforce Extractor -> Objective/Constraint wiring edges here. + // ------------------------------------------------------------------------- + + const isEdgeSyncingRef = useRef(false); + + useEffect(() => { + if (!spec || !studyId) return; + if (isEdgeSyncingRef.current) return; + + const current = spec.canvas?.edges || []; + + // Compute desired extractor->objective/constraint edges from source fields + const desiredPairs = new Set(); + + for (const obj of spec.objectives || []) { + const extractorId = obj.source?.extractor_id; + const outputName = obj.source?.output_name; + if (extractorId && outputName && extractorId !== '__UNSET__' && outputName !== '__UNSET__') { + desiredPairs.add(`${extractorId}__${obj.id}`); + } + } + + for (const con of spec.constraints || []) { + const extractorId = con.source?.extractor_id; + const outputName = con.source?.output_name; + if (extractorId && outputName && extractorId !== '__UNSET__' && outputName !== '__UNSET__') { + desiredPairs.add(`${extractorId}__${con.id}`); + } + } + + // Identify current wiring edges (ext_* -> obj_*/con_*) + const currentWiringPairs = new Set(); + for (const e of current) { + if (e.source?.startsWith('ext_') && (e.target?.startsWith('obj_') || e.target?.startsWith('con_'))) { + currentWiringPairs.add(`${e.source}__${e.target}`); + } + } + + // Determine adds/removes + const toAdd: Array<{ source: string; target: string }> = []; + for (const key of desiredPairs) { + if (!currentWiringPairs.has(key)) { + const [source, target] = key.split('__'); + toAdd.push({ source, target }); + } + } + + const toRemove: Array<{ source: string; target: string }> = []; + for (const key of currentWiringPairs) { + if (!desiredPairs.has(key)) { + const [source, target] = key.split('__'); + toRemove.push({ source, target }); + } + } + + if (toAdd.length === 0 && toRemove.length === 0) return; + + isEdgeSyncingRef.current = true; + + (async () => { + try { + // Remove stale edges first + for (const e of toRemove) { + await removeEdge(e.source, e.target); + } + // Add missing edges + for (const e of toAdd) { + await addEdge(e.source, e.target); + } + } catch (err) { + console.error('[SpecRenderer] Edge projection sync failed:', err); + } finally { + // Small delay avoids re-entrancy storms when backend broadcasts updates + setTimeout(() => { + isEdgeSyncingRef.current = false; + }, 250); + } + })(); + }, [spec, studyId, addEdge, removeEdge]); + // Convert spec to ReactFlow nodes const nodes = useMemo(() => { const baseNodes = specToNodes(spec);