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 {