Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/maestro-cue-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
61 changes: 61 additions & 0 deletions src/__tests__/main/cue/cue-yaml-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -1244,6 +1304,7 @@ subscriptions:
name: 'forward',
event: 'agent.completed',
source_session: 'researcher',
source_sub: 'researcher-step',
action: 'command',
command: {
mode: 'cli',
Expand Down
26 changes: 26 additions & 0 deletions src/main/cue/config/cue-config-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
40 changes: 40 additions & 0 deletions src/renderer/components/CuePipelineEditor/utils/yamlToPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) ??
Expand Down Expand Up @@ -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) ??
Expand Down