Skip to content

Commit 8f1a285

Browse files
committed
feat: configure control flows tasks
Signed-off-by: Simon Emms <simon@simonemms.com>
1 parent 7baf885 commit 8f1a285

13 files changed

Lines changed: 1167 additions & 111 deletions

File tree

src/lib/i18n/messages/en.json

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,22 @@
3737
"branches": "Branches",
3838
"removeBranch": "Remove branch {{label}}",
3939
"addBranch": "+ Add branch",
40+
"switch": {
41+
"branches": "Branches",
42+
"branchName": "Branch name",
43+
"condition": "Condition (when)",
44+
"targetWorkflow": "Target workflow",
45+
"addBranch": "+ Add branch",
46+
"addDefaultBranch": "+ Add default branch",
47+
"defaultBranch": "Default branch",
48+
"errorDuplicateName": "Duplicate branch name",
49+
"errorEmptyCondition": "Condition is required",
50+
"errorNoTarget": "Target workflow is required"
51+
},
4052
"sections": "Sections",
4153
"enterTryBody": "Enter try body",
4254
"enterCatchBlock": "Enter catch block",
4355
"addCatchBlock": "+ Add catch block",
44-
"enterLoopBody": "Enter loop body",
4556
"comingSoon": "Full editing UI coming soon.",
4657
"moveUp": "↑ Move up",
4758
"moveUpLabel": "Move task up",
@@ -72,15 +83,21 @@
7283
"days": "Days"
7384
},
7485
"fork": {
86+
"branches": "Branches",
87+
"addBranch": "+ Add branch",
88+
"renameBranch": "Rename branch {{label}}",
89+
"deleteBranch": "Delete branch {{label}}",
90+
"errorDuplicateName": "Duplicate branch name",
7591
"compete": "Compete mode",
7692
"competeHint": "First branch to finish wins"
7793
},
7894
"loop": {
79-
"title": "Loop configuration",
80-
"in": "Collection",
81-
"each": "Item variable",
82-
"at": "Index variable",
83-
"while": "Break condition"
95+
"configuration": "Loop configuration",
96+
"collection": "Collection",
97+
"itemVariable": "Item variable",
98+
"indexVariable": "Index variable",
99+
"breakCondition": "Break condition",
100+
"enterBody": "Enter loop body"
84101
},
85102
"callGrpc": {
86103
"title": "Call gRPC",
@@ -349,7 +366,7 @@
349366
"switch": "Switch",
350367
"fork": "Fork",
351368
"try": "Try / Catch",
352-
"loop": "Loop"
369+
"loop": "For Loop"
353370
},
354371
"sidebar": {
355372
"document": "Document",
@@ -372,7 +389,20 @@
372389
"canvas": {
373390
"ariaLabel": "Workflow canvas",
374391
"tryBody": "try body",
375-
"catchBlock": "catch block",
376-
"loopBody": "body"
392+
"catchBlock": "catch block"
393+
},
394+
"node": {
395+
"loop": {
396+
"for": "for",
397+
"body": "body",
398+
"bodyEmpty": "body (empty)",
399+
"bodyCount": "body ({{count}} tasks)"
400+
}
401+
},
402+
"validation": {
403+
"loop": {
404+
"collectionRequired": "Collection is required",
405+
"invalidIdentifier": "Must be a valid identifier (letters, digits, underscores; cannot start with a digit)"
406+
}
377407
}
378408
}

src/lib/tasks/actions.ts

Lines changed: 64 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,32 @@ export function renameSwitchBranch(
461461
};
462462
}
463463

464+
export function updateSwitchBranchCondition(
465+
node: SwitchNode,
466+
branchId: string,
467+
condition: string | undefined,
468+
): SwitchNode {
469+
return {
470+
...node,
471+
branches: node.branches.map((b) =>
472+
b.id === branchId ? { ...b, condition } : b,
473+
),
474+
};
475+
}
476+
477+
export function updateSwitchBranchTarget(
478+
node: SwitchNode,
479+
branchId: string,
480+
thenWorkflowName: string | undefined,
481+
): SwitchNode {
482+
return {
483+
...node,
484+
branches: node.branches.map((b) =>
485+
b.id === branchId ? { ...b, thenWorkflowName } : b,
486+
),
487+
};
488+
}
489+
464490
// ---------------------------------------------------------------------------
465491
// ForkNode
466492
// ---------------------------------------------------------------------------
@@ -477,11 +503,22 @@ export function createForkNode(name: string): ForkNode {
477503
};
478504
}
479505

480-
export function addForkBranch(node: ForkNode, label: string): ForkNode {
506+
/**
507+
* Returns the next available branch label of the form `branch-N`, starting
508+
* from 1 and incrementing until a name not already in use is found.
509+
*/
510+
export function nextForkBranchLabel(branches: ForkBranch[]): string {
511+
const existing = new Set(branches.map((b) => b.label));
512+
let n = 1;
513+
while (existing.has(`branch-${n}`)) n++;
514+
return `branch-${n}`;
515+
}
516+
517+
export function addForkBranch(node: ForkNode): ForkNode {
481518
const zid = newId();
482519
const branch: ForkBranch = {
483520
id: zid,
484-
label,
521+
label: nextForkBranchLabel(node.branches),
485522
graph: emptyFlowGraph(),
486523
metadata: { [ZIGFLOW_ID_KEY]: zid },
487524
};
@@ -593,7 +630,7 @@ export function insertNode(
593630
// Resolve the FlowGraph at the given GraphPath. Throws on invalid paths.
594631
//
595632
// Segment consumption rules per node type:
596-
// loop → 1 segment (nodeId → bodyGraph)
633+
// loop → 2 segments (nodeId + 'body' → bodyGraph)
597634
// switch → 2 segments (nodeId + branchId → branch.graph)
598635
// fork → 2 segments (nodeId + branchId → branch.graph)
599636
// try → 2 segments (nodeId + 'tryGraph'|'catchGraph' → section)
@@ -615,13 +652,7 @@ export function getGraphAtPath(file: WorkflowFile, path: GraphPath): FlowGraph {
615652
throw new Error(`Node ${nodeId} is a task and has no sub-graph`);
616653
}
617654

618-
if (node.type === 'loop') {
619-
graph = node.bodyGraph;
620-
i += 1;
621-
continue;
622-
}
623-
624-
// switch, fork, try — consume one additional segment for the sub-graph id
655+
// switch, fork, try, loop — consume one additional segment for the sub-graph id
625656
i += 1;
626657
if (i >= path.segments.length) {
627658
throw new Error(
@@ -630,6 +661,17 @@ export function getGraphAtPath(file: WorkflowFile, path: GraphPath): FlowGraph {
630661
}
631662
const subId = path.segments[i]!;
632663

664+
if (node.type === 'loop') {
665+
if (subId !== 'body') {
666+
throw new Error(
667+
`Expected "body" after loop node ${nodeId}, got "${subId}"`,
668+
);
669+
}
670+
graph = node.bodyGraph;
671+
i += 1;
672+
continue;
673+
}
674+
633675
if (node.type === 'switch') {
634676
const branch = node.branches.find((b) => b.id === subId);
635677
if (!branch) {
@@ -693,17 +735,7 @@ function applyTransformAt(
693735
const node = graph.nodes[nodeId];
694736
if (!node) throw new Error(`Node ${nodeId} not found at segment ${i}`);
695737

696-
if (node.type === 'loop') {
697-
const newBody = applyTransformAt(
698-
node.bodyGraph,
699-
segments,
700-
i + 1,
701-
transform,
702-
);
703-
return replaceNode(graph, { ...node, bodyGraph: newBody });
704-
}
705-
706-
// switch, fork, try — next segment identifies which sub-graph to descend into
738+
// switch, fork, try, loop — next segment identifies which sub-graph to descend into
707739
const subIndex = i + 1;
708740
if (subIndex >= segments.length) {
709741
throw new Error(
@@ -756,6 +788,17 @@ function applyTransformAt(
756788
}
757789
}
758790

791+
if (node.type === 'loop') {
792+
// subId is the 'body' literal — descend into bodyGraph.
793+
const newBody = applyTransformAt(
794+
node.bodyGraph,
795+
segments,
796+
subIndex + 1,
797+
transform,
798+
);
799+
return replaceNode(graph, { ...node, bodyGraph: newBody });
800+
}
801+
759802
throw new Error(
760803
`Node ${nodeId} (type: ${node.type}) cannot be navigated into`,
761804
);

src/lib/tasks/registry.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
// Factories use crypto.randomUUID() directly to avoid importing from actions.ts.
2121
import type {
2222
FlowGraph,
23+
ForkBranch,
2324
ForkNode,
2425
LoopNode,
2526
Node,
@@ -217,12 +218,19 @@ export const TASK_REGISTRY: readonly TaskDefinition[] = [
217218
description: 'Run branches in parallel',
218219
create: (): ForkNode => {
219220
const nid = id();
221+
const bid = id();
222+
const defaultBranch: ForkBranch = {
223+
id: bid,
224+
label: 'branch-1',
225+
graph: emptyGraph(),
226+
metadata: { [ZIGFLOW_ID_KEY]: bid },
227+
};
220228
return {
221229
id: nid,
222230
type: 'fork',
223231
name: 'fork',
224232
compete: false,
225-
branches: [],
233+
branches: [defaultBranch],
226234
metadata: { [ZIGFLOW_ID_KEY]: nid },
227235
};
228236
},
@@ -254,7 +262,9 @@ export const TASK_REGISTRY: readonly TaskDefinition[] = [
254262
id: nid,
255263
type: 'loop',
256264
name: 'loop',
257-
in: '$.',
265+
in: '${ $input.items }',
266+
each: 'item',
267+
at: 'index',
258268
bodyGraph: emptyGraph(),
259269
metadata: { [ZIGFLOW_ID_KEY]: nid },
260270
};

src/lib/tasks/validation.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,19 +211,30 @@ function validateSwitchNode(
211211
function validateForkNode(node: ForkNode, path: string[]): ValidationError[] {
212212
const errors: ValidationError[] = [];
213213

214-
if (node.branches.length < 2) {
214+
if (node.branches.length < 1) {
215215
errors.push({
216216
path,
217-
message: 'Fork node must have at least two branches',
217+
message: 'Fork node must have at least one branch',
218218
});
219219
}
220220

221+
const labelCounts = new Map<string, number>();
222+
for (const b of node.branches) {
223+
const l = b.label.trim();
224+
labelCounts.set(l, (labelCounts.get(l) ?? 0) + 1);
225+
}
226+
221227
for (const branch of node.branches) {
222228
if (!branch.label.trim()) {
223229
errors.push({
224230
path: [...path, 'branches', branch.id],
225231
message: 'Branch label is required',
226232
});
233+
} else if ((labelCounts.get(branch.label.trim()) ?? 0) > 1) {
234+
errors.push({
235+
path: [...path, 'branches', branch.id],
236+
message: 'Branch label must be unique',
237+
});
227238
}
228239
errors.push(
229240
...validateFlowGraph(branch.graph, [

src/lib/ui/Canvas.svelte

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,10 @@
9898
ROW_HEADER + rows * ROW_HEIGHT + ROW_PADDING,
9999
);
100100
}
101-
// loop: header(28) + body row(22) + padding(8) = 58 ≈ 60
101+
// loop: header(28) + expression line(22) + body row(22) + padding(8) = 80
102+
if (node.type === 'loop') {
103+
return ROW_HEADER + ROW_HEIGHT + ROW_HEIGHT + ROW_PADDING;
104+
}
102105
return NODE_HEIGHT_TASK;
103106
}
104107
@@ -133,6 +136,8 @@
133136
nodeType: string;
134137
typeLabel: string;
135138
navRows?: NavRow[];
139+
// Loop nodes only: the collection expression shown under the header.
140+
loopExpression?: string;
136141
};
137142
138143
type SFNode = {
@@ -177,7 +182,12 @@
177182
return rows;
178183
}
179184
if (node.type === 'loop') {
180-
return [{ id: 'body', label: t('canvas.loopBody'), kind: 'enter' }];
185+
const count = Object.keys(node.bodyGraph.nodes).length;
186+
const label =
187+
count === 0
188+
? t('node.loop.bodyEmpty')
189+
: t('node.loop.bodyCount', { count });
190+
return [{ id: 'body', label, kind: 'enter' }];
181191
}
182192
return [];
183193
}
@@ -188,6 +198,7 @@
188198
nodeType: node.type,
189199
typeLabel: nodeTypeLabel(node.type),
190200
navRows: node.type !== 'task' ? buildNavRows(node) : undefined,
201+
loopExpression: node.type === 'loop' ? node.in : undefined,
191202
};
192203
}
193204

0 commit comments

Comments
 (0)