diff --git a/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx b/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx index d60fa1c0..6126af70 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx +++ b/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx @@ -560,9 +560,10 @@ function SpecRendererInner({ // clear the target's source to avoid stale runnable config. if (sourceType === 'extractor' && (targetType === 'objective' || targetType === 'constraint')) { updateNode(edge.target, { - // Setting to an empty object would violate schema; clear to placeholder - // and let validation catch missing wiring. - source: { extractor_id: '', output_name: '' }, + // Objective/constraint.source is required by schema. + // Use explicit UNSET placeholders so validation can catch it + // without risking accidental execution. + source: { extractor_id: '__UNSET__', output_name: '__UNSET__' }, }).catch((err) => { console.error('Failed to clear source on node:', err); setError(err.message); diff --git a/atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanelV2.tsx b/atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanelV2.tsx index aa677d5b..271c05c8 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanelV2.tsx +++ b/atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanelV2.tsx @@ -820,6 +820,34 @@ interface ObjectiveNodeConfigProps { } function ObjectiveNodeConfig({ node, onChange }: ObjectiveNodeConfigProps) { + const spec = useSpec(); + const extractors = spec?.extractors || []; + + const currentExtractorId = node.source?.extractor_id || '__UNSET__'; + const currentOutputName = node.source?.output_name || '__UNSET__'; + + const selectedExtractor = extractors.find((e) => e.id === currentExtractorId); + const outputOptions = selectedExtractor?.outputs?.map((o) => o.name) || []; + + const handleExtractorChange = (extractorId: string) => { + // Reset output_name to a sensible default when extractor changes + const ext = extractors.find((e) => e.id === extractorId); + const outs = ext?.outputs?.map((o) => o.name) || []; + const preferred = outs.includes('value') ? 'value' : outs[0] || '__UNSET__'; + + onChange('source', { + extractor_id: extractorId, + output_name: preferred, + }); + }; + + const handleOutputChange = (outputName: string) => { + onChange('source', { + extractor_id: currentExtractorId, + output_name: outputName, + }); + }; + return ( <>
@@ -831,6 +859,45 @@ function ObjectiveNodeConfig({ node, onChange }: ObjectiveNodeConfigProps) { className={inputClass} />
+ +
+ + +
+ +
+ + +

+ This drives execution. Canvas wires are just a visual check. +

+
@@ -877,6 +944,33 @@ interface ConstraintNodeConfigProps { } function ConstraintNodeConfig({ node, onChange }: ConstraintNodeConfigProps) { + const spec = useSpec(); + const extractors = spec?.extractors || []; + + const currentExtractorId = node.source?.extractor_id || '__UNSET__'; + const currentOutputName = node.source?.output_name || '__UNSET__'; + + const selectedExtractor = extractors.find((e) => e.id === currentExtractorId); + const outputOptions = selectedExtractor?.outputs?.map((o) => o.name) || []; + + const handleExtractorChange = (extractorId: string) => { + const ext = extractors.find((e) => e.id === extractorId); + const outs = ext?.outputs?.map((o) => o.name) || []; + const preferred = outs.includes('value') ? 'value' : outs[0] || '__UNSET__'; + + onChange('source', { + extractor_id: extractorId, + output_name: preferred, + }); + }; + + const handleOutputChange = (outputName: string) => { + onChange('source', { + extractor_id: currentExtractorId, + output_name: outputName, + }); + }; + return ( <>
@@ -888,6 +982,45 @@ function ConstraintNodeConfig({ node, onChange }: ConstraintNodeConfigProps) { className={inputClass} />
+ +
+ + +
+ +
+ + +

+ This drives execution. Canvas wires are just a visual check. +

+
@@ -897,24 +1030,37 @@ function ConstraintNodeConfig({ node, onChange }: ConstraintNodeConfigProps) { onChange={(e) => onChange('type', e.target.value)} className={selectClass} > - - - - - + + +

Spec type (hard/soft). Operator is set below.

- - onChange('threshold', parseFloat(e.target.value))} - className={inputClass} - /> + +
+
+ + onChange('threshold', parseFloat(e.target.value))} + className={inputClass} + /> +
+ ); } diff --git a/atomizer-dashboard/frontend/src/lib/validation/specValidator.ts b/atomizer-dashboard/frontend/src/lib/validation/specValidator.ts index fee82c17..8ad326fe 100644 --- a/atomizer-dashboard/frontend/src/lib/validation/specValidator.ts +++ b/atomizer-dashboard/frontend/src/lib/validation/specValidator.ts @@ -178,9 +178,14 @@ const validationRules: ValidationRule[] = [ edge => edge.target === obj.id && edge.source.startsWith('ext_') ); - // Also check if source.extractor_id is set - const hasDirectSource = obj.source?.extractor_id && - spec.extractors.some(e => e.id === obj.source.extractor_id); + // Also check if source.extractor_id is set (and not UNSET placeholders) + const extractorId = obj.source?.extractor_id; + const outputName = obj.source?.output_name; + const hasDirectSource = Boolean(extractorId) && + extractorId !== '__UNSET__' && + Boolean(outputName) && + outputName !== '__UNSET__' && + spec.extractors.some(e => e.id === extractorId); if (!hasSource && !hasDirectSource) { return {