@pikku/inspector 0.12.43 → 0.12.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +261 -0
  2. package/dist/add/add-credential.js +2 -0
  3. package/dist/add/add-keyed-wiring.js +2 -0
  4. package/dist/add/add-workflow.js +9 -4
  5. package/dist/utils/schema-generator.js +19 -4
  6. package/dist/utils/workflow/dsl/deserialize-dsl-workflow.js +373 -91
  7. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +218 -99
  8. package/dist/utils/workflow/graph/convert-dsl-to-graph.js +62 -5
  9. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +3 -3
  10. package/package.json +3 -3
  11. package/run-tests.sh +1 -1
  12. package/src/add/add-auth.test.ts +4 -4
  13. package/src/add/add-credential.ts +2 -0
  14. package/src/add/add-keyed-wiring.ts +2 -0
  15. package/src/add/add-workflow-edge-cases.test.ts +201 -0
  16. package/src/add/add-workflow-fanout.test.ts +60 -0
  17. package/src/add/add-workflow-on-error.test.ts +118 -0
  18. package/src/add/add-workflow-return-switch.test.ts +121 -0
  19. package/src/add/add-workflow-silent-drops.test.ts +163 -0
  20. package/src/add/add-workflow-sleep-expression.test.ts +97 -0
  21. package/src/add/add-workflow.ts +9 -4
  22. package/src/add/config-value-metadata.test.ts +169 -0
  23. package/src/utils/schema-generator.test.ts +164 -0
  24. package/src/utils/schema-generator.ts +26 -3
  25. package/src/utils/workflow/dsl/deserialize-dsl-workflow.ts +480 -119
  26. package/src/utils/workflow/dsl/deserialize-fanout-body.test.ts +71 -0
  27. package/src/utils/workflow/dsl/deserialize-fanout-fidelity.test.ts +99 -0
  28. package/src/utils/workflow/dsl/deserialize-graph-next.test.ts +161 -0
  29. package/src/utils/workflow/dsl/deserialize-roundtrip-bindings.test.ts +121 -0
  30. package/src/utils/workflow/dsl/deserialize-roundtrip-values.test.ts +116 -0
  31. package/src/utils/workflow/dsl/deserialize-roundtrip.test.ts +145 -0
  32. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +245 -105
  33. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +67 -10
  34. package/src/utils/workflow/graph/fanout-node-identity.test.ts +105 -0
  35. package/src/utils/workflow/graph/workflow-graph.types.ts +3 -2
  36. package/tsconfig.tsbuildinfo +1 -1
@@ -126,16 +126,157 @@ function inputToCode(input, indent, itemVar) {
126
126
  });
127
127
  return `{\n${lines.join('\n')}\n${indent}}`;
128
128
  }
129
+ /**
130
+ * Convert a fanout's per-iteration body into code by walking the `next` chain
131
+ * from the body's entry node. Steps whose result is referenced later in the
132
+ * same iteration are re-emitted as `const` bindings.
133
+ */
134
+ function fanoutBodyToCode(entryNodeId, nodes, indent, itemVar) {
135
+ const lines = [];
136
+ const seen = new Set();
137
+ let currentId = entryNodeId;
138
+ while (currentId && nodes[currentId] && !seen.has(currentId)) {
139
+ seen.add(currentId);
140
+ const node = nodes[currentId];
141
+ if ('rpcName' in node && node.rpcName) {
142
+ const stepName = node.stepName || `Call ${node.rpcName}`;
143
+ const input = (node.input || {});
144
+ const inputCode = inputToCode(input, indent, itemVar);
145
+ const options = node.options ? optionsToCode(node.options) : '';
146
+ const doCall = `await workflow.do('${escapeSingleQuotes(stepName)}', '${node.rpcName}', ${inputCode}${options ? `, ${options}` : ''})`;
147
+ lines.push(node.outputVar
148
+ ? `${indent}const ${node.outputVar} = ${doCall}`
149
+ : `${indent}${doCall}`);
150
+ }
151
+ else if ('flow' in node) {
152
+ // A body is not RPC-only — a sleep or suspend between calls is part of
153
+ // the iteration and would otherwise be dropped from the regenerated code.
154
+ lines.push(...nodeToCode(node, nodes, indent).filter((line) => line !== ''));
155
+ }
156
+ currentId = node.next;
157
+ }
158
+ return lines;
159
+ }
160
+ /**
161
+ * Collect every node id that belongs to a parent construct's body, following
162
+ * each body's `next` chain to the parent's exit.
163
+ */
164
+ function collectChildNodeIds(nodes) {
165
+ const owned = new Set();
166
+ // An error handler belongs to the step that routes to it, not to the main
167
+ // flow — emitting it top-level would turn compensation into a step that
168
+ // always runs.
169
+ for (const node of Object.values(nodes)) {
170
+ const onError = node.onError;
171
+ if (typeof onError === 'string') {
172
+ owned.add(onError);
173
+ }
174
+ else if (Array.isArray(onError)) {
175
+ for (const handlerId of onError)
176
+ owned.add(handlerId);
177
+ }
178
+ }
179
+ const walk = (entry, exit) => {
180
+ const seen = new Set();
181
+ let currentId = entry;
182
+ while (currentId &&
183
+ nodes[currentId] &&
184
+ isWithinBody(currentId, exit, seen)) {
185
+ seen.add(currentId);
186
+ owned.add(currentId);
187
+ const next = nodes[currentId].next;
188
+ currentId = typeof next === 'string' ? next : undefined;
189
+ }
190
+ };
191
+ for (const node of Object.values(nodes)) {
192
+ if (!('flow' in node))
193
+ continue;
194
+ const flowNode = node;
195
+ const exit = flowNode.next;
196
+ switch (flowNode.flow) {
197
+ case 'branch':
198
+ for (const branch of flowNode.branches || [])
199
+ walk(branch.entry, exit);
200
+ walk(flowNode.elseEntry, exit);
201
+ break;
202
+ case 'switch':
203
+ for (const caseItem of flowNode.cases || [])
204
+ walk(caseItem.entry, exit);
205
+ walk(flowNode.defaultEntry, exit);
206
+ break;
207
+ case 'fanout':
208
+ walk(flowNode.childEntry, undefined);
209
+ break;
210
+ case 'parallel':
211
+ for (const childId of flowNode.children || [])
212
+ walk(childId, exit);
213
+ break;
214
+ }
215
+ }
216
+ return owned;
217
+ }
218
+ /**
219
+ * Escape a value for embedding inside a single-quoted string literal.
220
+ */
221
+ function escapeSingleQuotes(value) {
222
+ return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
223
+ }
224
+ /**
225
+ * `outputVar` is optional — a graph-authored node may have no binding. Emitting
226
+ * `const undefined = ...` for those would not parse, so the expression is kept
227
+ * as a bare statement instead.
228
+ */
229
+ function bindingPrefix(outputVar) {
230
+ return typeof outputVar === 'string' && outputVar
231
+ ? `const ${outputVar} = `
232
+ : '';
233
+ }
234
+ /**
235
+ * Resolve a node's `onError` node id back to the rpc name the DSL author wrote.
236
+ */
237
+ function onErrorRpcNameOf(node, nodes) {
238
+ const onError = node.onError;
239
+ const handlerId = Array.isArray(onError) ? onError[0] : onError;
240
+ if (typeof handlerId !== 'string') {
241
+ return undefined;
242
+ }
243
+ const handler = nodes[handlerId];
244
+ return handler && 'rpcName' in handler
245
+ ? handler.rpcName
246
+ : undefined;
247
+ }
248
+ /**
249
+ * A duration is `string | number` ('5s' or 5000). Quoting a number would change
250
+ * the value the runtime parses, so only strings get quotes.
251
+ */
252
+ function durationToCode(duration) {
253
+ return typeof duration === 'number'
254
+ ? String(duration)
255
+ : `'${escapeSingleQuotes(String(duration))}'`;
256
+ }
257
+ /**
258
+ * Render a switch case value. `switch` compares with ===, so quoting a number
259
+ * or boolean would stop the case ever matching.
260
+ */
261
+ function caseValueToCode(value) {
262
+ if (typeof value === 'string') {
263
+ return `'${escapeSingleQuotes(value)}'`;
264
+ }
265
+ return String(value);
266
+ }
129
267
  /**
130
268
  * Convert options to code
131
269
  */
132
- function optionsToCode(options) {
270
+ function optionsToCode(options, onErrorRpcName) {
133
271
  const parts = [];
272
+ if (onErrorRpcName) {
273
+ parts.push(`onError: '${escapeSingleQuotes(onErrorRpcName)}'`);
274
+ }
134
275
  if (options.retries !== undefined) {
135
276
  parts.push(`retries: ${options.retries}`);
136
277
  }
137
278
  if (options.retryDelay !== undefined) {
138
- parts.push(`retryDelay: '${options.retryDelay}'`);
279
+ parts.push(`retryDelay: ${durationToCode(options.retryDelay)}`);
139
280
  }
140
281
  return parts.length > 0 ? `{ ${parts.join(', ')} }` : '';
141
282
  }
@@ -193,20 +334,23 @@ function collectBranchConditionalVars(branchNode, nodes, conditionalVars) {
193
334
  if (branchNode.branches) {
194
335
  for (const branch of branchNode.branches) {
195
336
  if (branch.entry) {
196
- collectVarsFromBranch(branch.entry, nodes, conditionalVars, vars);
337
+ collectVarsFromBranch(branch.entry, nodes, conditionalVars, vars, branchNode.next);
197
338
  }
198
339
  }
199
340
  }
200
341
  // Check else branch
201
342
  if (branchNode.elseEntry) {
202
- collectVarsFromBranch(branchNode.elseEntry, nodes, conditionalVars, vars);
343
+ collectVarsFromBranch(branchNode.elseEntry, nodes, conditionalVars, vars, branchNode.next);
203
344
  }
204
345
  return vars;
205
346
  }
206
347
  /**
207
348
  * Recursively collect output variables from a branch that are in conditionalVars
208
349
  */
209
- function collectVarsFromBranch(nodeId, nodes, conditionalVars, result) {
350
+ function collectVarsFromBranch(nodeId, nodes, conditionalVars, result, exitNodeId, seen = new Set()) {
351
+ if (seen.has(nodeId))
352
+ return;
353
+ seen.add(nodeId);
210
354
  const node = nodes[nodeId];
211
355
  if (!node)
212
356
  return;
@@ -220,37 +364,39 @@ function collectVarsFromBranch(nodeId, nodes, conditionalVars, result) {
220
364
  // Follow the chain of nodes within the branch
221
365
  if ('next' in node && node.next) {
222
366
  const nextId = node.next;
223
- // Only follow if it's still within the branch
224
- if (isWithinBranch(nextId)) {
225
- collectVarsFromBranch(nextId, nodes, conditionalVars, result);
367
+ // Only follow if it's still within the branch body
368
+ if (isWithinBody(nextId, exitNodeId, seen)) {
369
+ collectVarsFromBranch(nextId, nodes, conditionalVars, result, exitNodeId, seen);
226
370
  }
227
371
  }
228
372
  }
229
373
  /**
230
- * Check if a node ID is still within a branch (not the main flow)
374
+ * A nested body (branch arm, switch case) ends where it rejoins the main flow.
375
+ * That rejoin point is the enclosing flow node's own `next`, so it is passed in
376
+ * explicitly rather than guessed from the node id — ids are step names, which
377
+ * carry no structural marker.
231
378
  */
232
- function isWithinBranch(nodeId) {
233
- return (nodeId.includes('_then_') ||
234
- nodeId.includes('_else_') ||
235
- nodeId.includes('_branch'));
379
+ function isWithinBody(nodeId, exitNodeId, seen) {
380
+ return nodeId !== exitNodeId && !seen.has(nodeId);
236
381
  }
237
382
  /**
238
383
  * Generate code for branch content (then/else blocks)
239
384
  */
240
- function generateBranchContent(entryNodeId, nodes, indent, conditionalVars) {
385
+ function generateBranchContent(entryNodeId, nodes, indent, conditionalVars, exitNodeId) {
241
386
  const lines = [];
387
+ const seen = new Set();
242
388
  let currentId = entryNodeId;
243
389
  while (currentId) {
244
390
  const node = nodes[currentId];
245
391
  if (!node)
246
392
  break;
393
+ seen.add(currentId);
247
394
  const nodeLines = nodeToCode(node, nodes, indent, conditionalVars, true);
248
395
  lines.push(...nodeLines);
249
- // Follow to next node within the branch
250
- if ('next' in node && node.next) {
396
+ // Follow to next node within the body
397
+ if ('next' in node && node.next && typeof node.next === 'string') {
251
398
  const nextId = node.next;
252
- // Only continue if it's still within the branch
253
- if (isWithinBranch(nextId)) {
399
+ if (isWithinBody(nextId, exitNodeId, seen)) {
254
400
  currentId = nextId;
255
401
  }
256
402
  else {
@@ -276,8 +422,9 @@ function nodeToCode(node, nodes, indent, conditionalVars = new Set(), isInsideBr
276
422
  const outputVar = node.outputVar;
277
423
  let doCall = `await workflow.do('${stepName}', '${node.rpcName}', ${inputCode}`;
278
424
  // Add options if present
279
- if (node.options) {
280
- const optCode = optionsToCode(node.options);
425
+ const onErrorRpc = onErrorRpcNameOf(node, nodes);
426
+ if (node.options || onErrorRpc) {
427
+ const optCode = optionsToCode(node.options ?? {}, onErrorRpc);
281
428
  if (optCode) {
282
429
  doCall += `, ${optCode}`;
283
430
  }
@@ -303,9 +450,23 @@ function nodeToCode(node, nodes, indent, conditionalVars = new Set(), isInsideBr
303
450
  const flowNode = node;
304
451
  switch (flowNode.flow) {
305
452
  case 'sleep':
306
- lines.push(`${indent}await workflow.sleep('${flowNode.stepName || 'Sleep'}', '${flowNode.duration}')`);
453
+ lines.push(`${indent}await workflow.sleep('${escapeSingleQuotes(flowNode.stepName || 'Sleep')}', ${typeof flowNode.expression === 'string'
454
+ ? flowNode.expression
455
+ : durationToCode(flowNode.duration)})`);
307
456
  lines.push('');
308
457
  break;
458
+ case 'suspend':
459
+ lines.push(`${indent}await workflow.suspend('${escapeSingleQuotes(String(flowNode.reason ?? ''))}')`);
460
+ lines.push('');
461
+ break;
462
+ case 'approval': {
463
+ const approvalCall = `await workflow.approval('${escapeSingleQuotes(String(flowNode.reason ?? ''))}')`;
464
+ lines.push(flowNode.outputVar
465
+ ? `${indent}const ${flowNode.outputVar} = ${approvalCall}`
466
+ : `${indent}${approvalCall}`);
467
+ lines.push('');
468
+ break;
469
+ }
309
470
  case 'cancel':
310
471
  const cancelReason = flowNode.reason || flowNode.stepName || 'Workflow cancelled';
311
472
  lines.push(`${indent}throw new WorkflowCancelledException('${cancelReason}')`);
@@ -325,7 +486,7 @@ function nodeToCode(node, nodes, indent, conditionalVars = new Set(), isInsideBr
325
486
  const keyword = i === 0 ? 'if' : 'else if';
326
487
  lines.push(`${indent}${keyword} (${condition}) {`);
327
488
  if (branch.entry && nodes[branch.entry]) {
328
- const branchLines = generateBranchContent(branch.entry, nodes, indent + ' ', conditionalVars);
489
+ const branchLines = generateBranchContent(branch.entry, nodes, indent + ' ', conditionalVars, flowNode.next);
329
490
  lines.push(...branchLines);
330
491
  }
331
492
  lines.push(`${indent}}`);
@@ -333,75 +494,75 @@ function nodeToCode(node, nodes, indent, conditionalVars = new Set(), isInsideBr
333
494
  // Generate else block if present
334
495
  if (flowNode.elseEntry && nodes[flowNode.elseEntry]) {
335
496
  lines.push(`${indent}else {`);
336
- const elseLines = generateBranchContent(flowNode.elseEntry, nodes, indent + ' ', conditionalVars);
497
+ const elseLines = generateBranchContent(flowNode.elseEntry, nodes, indent + ' ', conditionalVars, flowNode.next);
337
498
  lines.push(...elseLines);
338
499
  lines.push(`${indent}}`);
339
500
  }
340
501
  lines.push('');
341
502
  break;
342
- case 'switch':
503
+ case 'switch': {
504
+ const caseExit = flowNode.next;
343
505
  lines.push(`${indent}switch (${flowNode.expression}) {`);
344
506
  for (const caseItem of flowNode.cases || []) {
345
- lines.push(`${indent} case '${caseItem.value}':`);
507
+ lines.push(`${indent} case ${caseValueToCode(caseItem.value)}:`);
346
508
  if (caseItem.entry && nodes[caseItem.entry]) {
347
- const caseLines = nodeToCode(nodes[caseItem.entry], nodes, indent + ' ');
348
- lines.push(...caseLines);
509
+ lines.push(...generateBranchContent(caseItem.entry, nodes, indent + ' ', conditionalVars, caseExit));
349
510
  }
350
511
  lines.push(`${indent} break`);
351
512
  }
352
513
  if (flowNode.defaultEntry && nodes[flowNode.defaultEntry]) {
353
514
  lines.push(`${indent} default:`);
354
- const defaultLines = nodeToCode(nodes[flowNode.defaultEntry], nodes, indent + ' ');
355
- lines.push(...defaultLines);
515
+ lines.push(...generateBranchContent(flowNode.defaultEntry, nodes, indent + ' ', conditionalVars, caseExit));
356
516
  lines.push(`${indent} break`);
357
517
  }
358
518
  lines.push(`${indent}}`);
359
519
  lines.push('');
360
520
  break;
361
- case 'parallel':
362
- lines.push(`${indent}await Promise.all([`);
363
- for (const childId of flowNode.children || []) {
364
- if (nodes[childId]) {
365
- const childNode = nodes[childId];
366
- if ('rpcName' in childNode && childNode.rpcName) {
367
- const stepName = childNode.stepName || `Call ${childNode.rpcName}`;
368
- const input = (childNode.input || {});
369
- const inputCode = inputToCode(input, indent + ' ');
370
- lines.push(`${indent} workflow.do('${stepName}', '${childNode.rpcName}', ${inputCode}),`);
521
+ }
522
+ case 'parallel': {
523
+ const childNodes = (flowNode.children || [])
524
+ .map((childId) => nodes[childId])
525
+ .filter((child) => !!child && 'rpcName' in child && !!child.rpcName);
526
+ // Results the caller bound (const [a, b] = await Promise.all([...]))
527
+ // must be re-declared, or every later reference to them is unbound.
528
+ const bindings = childNodes.map((child) => child.outputVar);
529
+ const prefix = bindings.some(Boolean)
530
+ ? `const [${bindings
531
+ .map((name, i) => name ?? `_${i}`)
532
+ .join(', ')}] = `
533
+ : '';
534
+ lines.push(`${indent}${prefix}await Promise.all([`);
535
+ for (const childNode of childNodes) {
536
+ const stepName = childNode.stepName || `Call ${childNode.rpcName}`;
537
+ const input = (childNode.input || {});
538
+ const inputCode = inputToCode(input, indent + ' ');
539
+ let call = `workflow.do('${stepName}', '${childNode.rpcName}', ${inputCode}`;
540
+ if (childNode.options) {
541
+ const optCode = optionsToCode(childNode.options);
542
+ if (optCode) {
543
+ call += `, ${optCode}`;
371
544
  }
372
545
  }
546
+ call += ')';
547
+ lines.push(`${indent} ${call},`);
373
548
  }
374
549
  lines.push(`${indent}])`);
375
550
  lines.push('');
376
551
  break;
377
- case 'fanout':
552
+ }
553
+ case 'fanout': {
554
+ const bodyLines = fanoutBodyToCode(flowNode.childEntry, nodes, flowNode.mode === 'parallel' ? indent + ' ' : indent + ' ', flowNode.itemVar);
378
555
  if (flowNode.mode === 'parallel') {
379
556
  lines.push(`${indent}await Promise.all(`);
380
- lines.push(`${indent} ${flowNode.sourceVar}.map(async (${flowNode.itemVar}) =>`);
381
- if (flowNode.childEntry && nodes[flowNode.childEntry]) {
382
- const childNode = nodes[flowNode.childEntry];
383
- if ('rpcName' in childNode && childNode.rpcName) {
384
- const stepName = childNode.stepName || `Call ${childNode.rpcName}`;
385
- const input = (childNode.input || {});
386
- const inputCode = inputToCode(input, indent + ' ', flowNode.itemVar);
387
- lines.push(`${indent} await workflow.do('${stepName}', '${childNode.rpcName}', ${inputCode})`);
388
- }
389
- }
390
- lines.push(`${indent} )`);
557
+ lines.push(`${indent} ${flowNode.sourceVar}.map(async (${flowNode.itemVar}) => {`);
558
+ lines.push(...bodyLines);
559
+ lines.push(`${indent} })`);
391
560
  lines.push(`${indent})`);
392
561
  }
393
562
  else {
394
563
  // Sequential fanout
395
564
  lines.push(`${indent}for (const ${flowNode.itemVar} of ${flowNode.sourceVar}) {`);
396
- if (flowNode.childEntry && nodes[flowNode.childEntry]) {
397
- const childNode = nodes[flowNode.childEntry];
398
- if ('rpcName' in childNode && childNode.rpcName) {
399
- const stepName = childNode.stepName || `Call ${childNode.rpcName}`;
400
- const input = (childNode.input || {});
401
- const inputCode = inputToCode(input, indent + ' ', flowNode.itemVar);
402
- lines.push(`${indent} await workflow.do('${stepName}', '${childNode.rpcName}', ${inputCode})`);
403
- }
404
- }
565
+ lines.push(...bodyLines);
405
566
  if (flowNode.timeBetween) {
406
567
  lines.push(`${indent} await workflow.sleep('Wait between iterations', '${flowNode.timeBetween}')`);
407
568
  }
@@ -409,19 +570,23 @@ function nodeToCode(node, nodes, indent, conditionalVars = new Set(), isInsideBr
409
570
  }
410
571
  lines.push('');
411
572
  break;
573
+ }
412
574
  case 'filter':
413
- lines.push(`${indent}const ${flowNode.outputVar} = ${flowNode.sourceVar}.filter((${flowNode.itemVar}) => ${conditionToCode(flowNode.condition)})`);
575
+ lines.push(`${indent}${bindingPrefix(flowNode.outputVar)}${flowNode.sourceVar}.filter((${flowNode.itemVar}) => ${conditionToCode(flowNode.condition)})`);
414
576
  lines.push('');
415
577
  break;
416
578
  case 'arrayPredicate':
417
579
  const method = flowNode.mode === 'some' ? 'some' : 'every';
418
- lines.push(`${indent}const ${flowNode.outputVar} = ${flowNode.sourceVar}.${method}((${flowNode.itemVar}) => ${conditionToCode(flowNode.condition)})`);
580
+ lines.push(`${indent}${bindingPrefix(flowNode.outputVar)}${flowNode.sourceVar}.${method}((${flowNode.itemVar}) => ${conditionToCode(flowNode.condition)})`);
419
581
  lines.push('');
420
582
  break;
421
583
  case 'return':
422
- if (flowNode.outputs) {
584
+ if (flowNode.outputs || flowNode.spread) {
423
585
  const returnObj = [];
424
- for (const [key, output] of Object.entries(flowNode.outputs)) {
586
+ for (const spreadVar of flowNode.spread ?? []) {
587
+ returnObj.push(`${indent} ...${spreadVar},`);
588
+ }
589
+ for (const [key, output] of Object.entries((flowNode.outputs ?? {}))) {
425
590
  let value;
426
591
  if (output.from === 'outputVar') {
427
592
  value = output.path
@@ -462,9 +627,11 @@ function nodeToCode(node, nodes, indent, conditionalVars = new Set(), isInsideBr
462
627
  case 'set':
463
628
  // Generate variable assignment: varName = value
464
629
  const setVar = flowNode.variable;
465
- const setValue = typeof flowNode.value === 'string'
466
- ? `'${flowNode.value}'`
467
- : JSON.stringify(flowNode.value);
630
+ const setValue = typeof flowNode.expression === 'string'
631
+ ? flowNode.expression
632
+ : typeof flowNode.value === 'string'
633
+ ? `'${escapeSingleQuotes(flowNode.value)}'`
634
+ : JSON.stringify(flowNode.value);
468
635
  lines.push(`${indent}${setVar} = ${setValue}`);
469
636
  lines.push('');
470
637
  break;
@@ -480,14 +647,44 @@ function findConditionalVars(nodes) {
480
647
  const conditionalVars = new Set();
481
648
  const varsInBranches = new Set();
482
649
  const varsUsedInReturn = new Set();
483
- // Collect variables defined in branches (then/else/case/default nodes)
484
- for (const [nodeId, node] of Object.entries(nodes)) {
485
- if (nodeId.includes('_then_') ||
486
- nodeId.includes('_else_') ||
487
- nodeId.includes('_case') ||
488
- nodeId.includes('_default_')) {
489
- if ('outputVar' in node && node.outputVar) {
490
- varsInBranches.add(node.outputVar);
650
+ // Collect variables defined inside a branch arm or switch case. Found by
651
+ // walking each body from its entry to the enclosing node's exit — node ids
652
+ // are step names and carry no structural marker to match on.
653
+ for (const node of Object.values(nodes)) {
654
+ if (!('flow' in node))
655
+ continue;
656
+ const flowNode = node;
657
+ const exit = flowNode.next;
658
+ const entries = [];
659
+ if (flowNode.flow === 'branch') {
660
+ for (const branch of flowNode.branches || []) {
661
+ if (branch.entry)
662
+ entries.push(branch.entry);
663
+ }
664
+ if (flowNode.elseEntry)
665
+ entries.push(flowNode.elseEntry);
666
+ }
667
+ else if (flowNode.flow === 'switch') {
668
+ for (const caseItem of flowNode.cases || []) {
669
+ if (caseItem.entry)
670
+ entries.push(caseItem.entry);
671
+ }
672
+ if (flowNode.defaultEntry)
673
+ entries.push(flowNode.defaultEntry);
674
+ }
675
+ for (const entry of entries) {
676
+ const seen = new Set();
677
+ let currentId = entry;
678
+ while (currentId &&
679
+ nodes[currentId] &&
680
+ isWithinBody(currentId, exit, seen)) {
681
+ seen.add(currentId);
682
+ const bodyNode = nodes[currentId];
683
+ if (bodyNode.outputVar) {
684
+ varsInBranches.add(bodyNode.outputVar);
685
+ }
686
+ currentId =
687
+ typeof bodyNode.next === 'string' ? bodyNode.next : undefined;
491
688
  }
492
689
  }
493
690
  }
@@ -571,14 +768,13 @@ export function deserializeDslWorkflow(workflow, options = {}) {
571
768
  }
572
769
  // Process nodes in order
573
770
  const orderedNodes = traverseNodes(workflow.nodes, workflow.entryNodeIds);
771
+ // Nodes emitted as part of a parent (branch arm, switch case, parallel group,
772
+ // fanout body) must not also be emitted at the top level. Ownership is read
773
+ // from the parents themselves — node ids are step names and a legitimate step
774
+ // may be called anything.
775
+ const ownedByParent = collectChildNodeIds(workflow.nodes);
574
776
  for (const node of orderedNodes) {
575
- // Skip child nodes that are processed as part of their parent
576
- if (node.nodeId.includes('_then_') ||
577
- node.nodeId.includes('_else_') ||
578
- node.nodeId.includes('_case') ||
579
- node.nodeId.includes('_default_') ||
580
- node.nodeId.includes('_child_') ||
581
- node.nodeId.includes('_item_')) {
777
+ if (ownedByParent.has(node.nodeId)) {
582
778
  continue;
583
779
  }
584
780
  const nodeLines = nodeToCode(node, workflow.nodes, ' ', conditionalVars);
@@ -699,11 +895,79 @@ function findNextRpcNode(startNextId, nodes, flowNodeIds, visited = new Set()) {
699
895
  }
700
896
  // It's a flow node - follow its 'next' if it has one
701
897
  const flowNode = nodes[startNextId];
702
- if (flowNode && 'next' in flowNode && flowNode.next) {
898
+ if (flowNode && 'next' in flowNode && typeof flowNode.next === 'string') {
703
899
  return findNextRpcNode(flowNode.next, nodes, flowNodeIds, visited);
704
900
  }
705
901
  return null;
706
902
  }
903
+ /**
904
+ * Resolve a single next target through any intervening flow nodes.
905
+ */
906
+ function resolveNextTarget(target, nodes, flowNodeIds) {
907
+ const resolved = flowNodeIds.has(target)
908
+ ? findNextRpcNode(target, nodes, flowNodeIds)
909
+ : target;
910
+ return resolved && !flowNodeIds.has(resolved) ? resolved : null;
911
+ }
912
+ /**
913
+ * Render a `next` in any of its shapes. A `next` is not always a single node
914
+ * id — it can fan out to an array, route by branch key, or carry conditions.
915
+ * Coercing those to a string produces node ids that match nothing ('a,b',
916
+ * '[object Object]'), silently severing the rest of the graph.
917
+ */
918
+ function nextToCode(next, nodes, flowNodeIds) {
919
+ const renderTargets = (targets) => {
920
+ if (typeof targets === 'string') {
921
+ const resolved = resolveNextTarget(targets, nodes, flowNodeIds);
922
+ return resolved ? `'${resolved}'` : null;
923
+ }
924
+ const resolved = targets
925
+ .map((t) => resolveNextTarget(t, nodes, flowNodeIds))
926
+ .filter((t) => t !== null);
927
+ return resolved.length > 0
928
+ ? `[${resolved.map((t) => `'${t}'`).join(', ')}]`
929
+ : null;
930
+ };
931
+ if (typeof next === 'string' || Array.isArray(next)) {
932
+ return renderTargets(next);
933
+ }
934
+ if (typeof next === 'object' && next !== null) {
935
+ if ('conditions' in next && Array.isArray(next.conditions)) {
936
+ const conditions = next.conditions
937
+ .map((condition) => {
938
+ const target = renderTargets(condition.target);
939
+ return target
940
+ ? `{ expression: '${escapeSingleQuotes(condition.expression)}', target: ${target} }`
941
+ : null;
942
+ })
943
+ .filter((c) => c !== null);
944
+ if (conditions.length === 0) {
945
+ return null;
946
+ }
947
+ const parts = [`conditions: [${conditions.join(', ')}]`];
948
+ if (next.default !== undefined) {
949
+ const defaultTarget = renderTargets(next.default);
950
+ if (defaultTarget) {
951
+ parts.push(`default: ${defaultTarget}`);
952
+ }
953
+ }
954
+ return `{ ${parts.join(', ')} }`;
955
+ }
956
+ const entries = Object.entries(next)
957
+ .map(([key, target]) => {
958
+ const isTarget = typeof target === 'string' ||
959
+ (Array.isArray(target) && target.every((t) => typeof t === 'string'));
960
+ const rendered = isTarget ? renderTargets(target) : null;
961
+ const safeKey = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)
962
+ ? key
963
+ : JSON.stringify(key);
964
+ return rendered ? `${safeKey}: ${rendered}` : null;
965
+ })
966
+ .filter((e) => e !== null);
967
+ return entries.length > 0 ? `{ ${entries.join(', ')} }` : null;
968
+ }
969
+ return null;
970
+ }
707
971
  /**
708
972
  * Deserialize a graph workflow to pikkuWorkflowGraph code
709
973
  */
@@ -752,14 +1016,26 @@ export function deserializeGraphWorkflow(workflow, options = {}) {
752
1016
  const configParts = [];
753
1017
  // Add next if present - follow through flow nodes to find the actual next RPC node
754
1018
  if ('next' in node && node.next) {
755
- const nextId = node.next;
756
- // If next points to a flow node, follow through to find the next RPC node
757
- const actualNextId = flowNodeIds.has(nextId)
758
- ? findNextRpcNode(nextId, workflow.nodes, flowNodeIds)
759
- : nextId;
760
- // Only add if we found a valid next RPC node
761
- if (actualNextId && !flowNodeIds.has(actualNextId)) {
762
- configParts.push(`next: '${actualNextId}'`);
1019
+ const nextCode = nextToCode(node.next, workflow.nodes, flowNodeIds);
1020
+ if (nextCode) {
1021
+ configParts.push(`next: ${nextCode}`);
1022
+ }
1023
+ }
1024
+ // onError, retries and retryDelay are all honoured by the graph runner,
1025
+ // so dropping them here silently changes how the workflow behaves.
1026
+ if ('onError' in node && node.onError) {
1027
+ const onErrorCode = nextToCode(node.onError, workflow.nodes, flowNodeIds);
1028
+ if (onErrorCode) {
1029
+ configParts.push(`onError: ${onErrorCode}`);
1030
+ }
1031
+ }
1032
+ if ('options' in node && node.options) {
1033
+ const { retries, retryDelay } = node.options;
1034
+ if (retries !== undefined) {
1035
+ configParts.push(`retries: ${retries}`);
1036
+ }
1037
+ if (retryDelay !== undefined) {
1038
+ configParts.push(`retryDelay: ${durationToCode(retryDelay)}`);
763
1039
  }
764
1040
  }
765
1041
  // Add input if present
@@ -791,6 +1067,12 @@ export function deserializeGraphWorkflow(workflow, options = {}) {
791
1067
  if (workflow.tags && workflow.tags.length > 0) {
792
1068
  lines.push(` tags: [${workflow.tags.map((t) => `'${t}'`).join(', ')}],`);
793
1069
  }
1070
+ if (workflow.notes && workflow.notes.length > 0) {
1071
+ const notes = workflow.notes
1072
+ .map((n) => `'${escapeSingleQuotes(n)}'`)
1073
+ .join(', ');
1074
+ lines.push(` notes: [${notes}],`);
1075
+ }
794
1076
  // Generate nodes (RPC mapping)
795
1077
  const rpcMapEntries = Object.entries(nodeRpcMap);
796
1078
  if (rpcMapEntries.length > 0) {