diff --git a/docs/maestro-cue-configuration.md b/docs/maestro-cue-configuration.md index dcf47d7b06..da2ed68cfe 100644 --- a/docs/maestro-cue-configuration.md +++ b/docs/maestro-cue-configuration.md @@ -198,6 +198,41 @@ subscriptions: **Visual-node identity (`target_node_key`, `fan_out_node_keys`):** When you save from the Pipeline Editor, you may see UUID-valued `target_node_key` / `fan_out_node_keys` fields on subscriptions. These are renderer-only — the Cue engine ignores them. They let the editor distinguish "two visual nodes that happen to point at the same agent" (different keys → two nodes on the canvas) from "one shared node with multiple inputs" (same key → explicit fan-in onto a single node). If you hand-edit YAML and want two separate visual instances of the same agent for the same trigger, give each sub a different `target_node_key`; if you want them to merge into one fan-in target, give them the same key. Leave the keys alone when round-tripping through the editor — clearing them silently re-merges your visual nodes by `agent_id` on the next reload. +#### Agent-authored Trigger -> Command -> Agent YAML checklist + +If an AI agent writes `cue.yaml` directly (without using the visual editor), include all of the following so Maestro reconstructs the graph correctly: + +1. Initial trigger subscription uses `action: command` with a valid `command` object. +2. The downstream `agent.completed` subscription includes `source_sub` pointing to that command subscription name. +3. Keep `pipeline_name` consistent across all subs in the pipeline. +4. Keep per-node identity fields (`target_node_key`, `fan_out_node_keys`) stable once created. + +Example: + +```yaml +subscriptions: + - name: Build Pipeline-cmd-1 + pipeline_name: Build Pipeline + event: time.scheduled + schedule_times: ['09:00'] + action: command + command: + mode: shell + shell: npm run build + agent_id: AGENT_UUID_A + target_node_key: node-cmd-1 + + - name: Build Pipeline-chain-1 + pipeline_name: Build Pipeline + event: agent.completed + source_session: Agent A + source_session_ids: [AGENT_UUID_A] + source_sub: Build Pipeline-cmd-1 + prompt: "{{CUE_SOURCE_OUTPUT}}\n\nSummarize build output and next steps." + agent_id: AGENT_UUID_A + target_node_key: node-agent-1 +``` + ### Labels The `label` field provides a human-readable name displayed in the Cue dashboard and pipeline editor. When subscriptions are grouped into a pipeline, the label distinguishes each line within the pipeline. diff --git a/src/__tests__/main/cue/cue-yaml-loader.test.ts b/src/__tests__/main/cue/cue-yaml-loader.test.ts index 60238b735e..861661ce87 100644 --- a/src/__tests__/main/cue/cue-yaml-loader.test.ts +++ b/src/__tests__/main/cue/cue-yaml-loader.test.ts @@ -757,6 +757,66 @@ subscriptions: ); }); + it('requires source_sub for agent.completed command subscriptions', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'cmd-chain', + event: 'agent.completed', + source_session: 'Builder', + action: 'command', + command: { mode: 'shell', shell: 'echo ok' }, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('source_sub')]) + ); + }); + + it('rejects source_sub/source_session array length mismatch', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'fan-in', + event: 'agent.completed', + source_session: ['A', 'B'], + source_sub: ['fan-in-chain-a'], + prompt: '{{CUE_SOURCE_OUTPUT}}', + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.stringContaining('source_sub" length (1) must match "source_session" length (2)'), + ]) + ); + }); + + it('rejects source_sub array when source_session is a string', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'fan-in-invalid-shape', + event: 'agent.completed', + source_session: 'A', + source_sub: ['chain-a', 'chain-b'], + prompt: '{{CUE_SOURCE_OUTPUT}}', + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.stringContaining( + '"source_sub" must be a string when "source_session" is a string' + ), + ]) + ); + }); + it('accepts prompt_file as alternative to prompt', () => { const result = validateCueConfig({ subscriptions: [ @@ -1244,6 +1304,7 @@ subscriptions: name: 'forward', event: 'agent.completed', source_session: 'researcher', + source_sub: 'researcher-step', action: 'command', command: { mode: 'cli', diff --git a/src/main/cue/config/cue-config-validator.ts b/src/main/cue/config/cue-config-validator.ts index 5ff1d2ecbb..2ea0daa768 100644 --- a/src/main/cue/config/cue-config-validator.ts +++ b/src/main/cue/config/cue-config-validator.ts @@ -357,6 +357,32 @@ function validateEventSpecificFields( errors.push(`${prefix}: "source_sub" must be a string or array of strings when provided`); } } + // Command-chain links must carry explicit upstream subscription identity. + // Without source_sub, YAML->graph reconstruction has to guess by session + // name and can collapse Command->Agent into Agent->Agent when both share + // an owning session. + if (sub.action === 'command' && sub.source_sub === undefined) { + errors.push( + `${prefix}: "source_sub" is required for agent.completed subscriptions when action is "command"` + ); + } + // For fan-in chains, source_sub should align positionally with + // source_session so each upstream source maps to its exact upstream sub. + const sourceSession = sub.source_session; + const sourceSub = sub.source_sub; + const sourceSessionIsArray = Array.isArray(sourceSession); + const sourceSubIsArray = Array.isArray(sourceSub); + if (Array.isArray(sourceSession) && Array.isArray(sourceSub)) { + if (sourceSession.length !== sourceSub.length) { + errors.push( + `${prefix}: "source_sub" length (${sourceSub.length}) must match "source_session" length (${sourceSession.length})` + ); + } + } else if (sourceSessionIsArray && typeof sourceSub === 'string') { + errors.push(`${prefix}: "source_sub" must be an array when "source_session" is an array`); + } else if (!sourceSessionIsArray && sourceSubIsArray) { + errors.push(`${prefix}: "source_sub" must be a string when "source_session" is a string`); + } } else if (event === 'task.pending') { if (!sub.watch || typeof sub.watch !== 'string') { errors.push( diff --git a/src/renderer/components/CuePipelineEditor/utils/yamlToPipeline.ts b/src/renderer/components/CuePipelineEditor/utils/yamlToPipeline.ts index 56df083d02..fc8070890c 100644 --- a/src/renderer/components/CuePipelineEditor/utils/yamlToPipeline.ts +++ b/src/renderer/components/CuePipelineEditor/utils/yamlToPipeline.ts @@ -532,6 +532,7 @@ export function subscriptionsToPipelines( if (aIdx !== bIdx) return aIdx - bIdx; return a.name.localeCompare(b.name); }); + const knownSubNames = new Set(sorted.map((s) => s.name)); let triggerCount = 0; let columnIndex = 0; @@ -862,6 +863,25 @@ export function subscriptionsToPipelines( const bySubRef = subRef ? subNameToNode.get(subRef) : undefined; if (bySubRef) { sourceNode = bySubRef; + } else if ( + typeof subRef === 'string' && + subRef.length > 0 && + !knownSubNames.has(subRef) + ) { + const errorNodeId = `error-source-sub-${sub.name}-${i}`; + sourceNode = createErrorNode( + errorNodeId, + { + reason: 'missing-source', + subscriptionName: sub.name, + message: `Upstream subscription "${subRef}" could not be resolved.`, + }, + { + x: LAYOUT.firstAgentX + (targetCol - 2) * LAYOUT.stepSpacing, + y: LAYOUT.baseY + (existingRows + i) * LAYOUT.verticalSpacing, + } + ); + nodeMap.set(errorNodeId, sourceNode); } else if (position.kind === 'resolved' && position.sessionName) { sourceNode = sessionToNode.get(position.sessionName) ?? @@ -938,6 +958,26 @@ export function subscriptionsToPipelines( const bySubRef = subRef ? subNameToNode.get(subRef) : undefined; if (bySubRef) { resolvedSources.push(bySubRef); + } else if ( + typeof subRef === 'string' && + subRef.length > 0 && + !knownSubNames.has(subRef) + ) { + const errorNodeId = `error-source-sub-${sub.name}-${i}`; + const errorNode = createErrorNode( + errorNodeId, + { + reason: 'missing-source', + subscriptionName: sub.name, + message: `Upstream subscription "${subRef}" could not be resolved.`, + }, + { + x: LAYOUT.firstAgentX + (targetCol - 2) * LAYOUT.stepSpacing, + y: LAYOUT.baseY + (existingRows + i) * LAYOUT.verticalSpacing, + } + ); + nodeMap.set(errorNodeId, errorNode); + resolvedSources.push(errorNode); } else if (position.kind === 'resolved' && position.sessionName) { const sourceNode = sessionToNode.get(position.sessionName) ??