@pikku/inspector 0.12.43 → 0.12.44

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 (35) hide show
  1. package/CHANGELOG.md +168 -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-credential.ts +2 -0
  13. package/src/add/add-keyed-wiring.ts +2 -0
  14. package/src/add/add-workflow-edge-cases.test.ts +201 -0
  15. package/src/add/add-workflow-fanout.test.ts +60 -0
  16. package/src/add/add-workflow-on-error.test.ts +118 -0
  17. package/src/add/add-workflow-return-switch.test.ts +121 -0
  18. package/src/add/add-workflow-silent-drops.test.ts +163 -0
  19. package/src/add/add-workflow-sleep-expression.test.ts +97 -0
  20. package/src/add/add-workflow.ts +9 -4
  21. package/src/add/config-value-metadata.test.ts +169 -0
  22. package/src/utils/schema-generator.test.ts +164 -0
  23. package/src/utils/schema-generator.ts +26 -3
  24. package/src/utils/workflow/dsl/deserialize-dsl-workflow.ts +480 -119
  25. package/src/utils/workflow/dsl/deserialize-fanout-body.test.ts +71 -0
  26. package/src/utils/workflow/dsl/deserialize-fanout-fidelity.test.ts +99 -0
  27. package/src/utils/workflow/dsl/deserialize-graph-next.test.ts +161 -0
  28. package/src/utils/workflow/dsl/deserialize-roundtrip-bindings.test.ts +121 -0
  29. package/src/utils/workflow/dsl/deserialize-roundtrip-values.test.ts +116 -0
  30. package/src/utils/workflow/dsl/deserialize-roundtrip.test.ts +145 -0
  31. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +245 -105
  32. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +67 -10
  33. package/src/utils/workflow/graph/fanout-node-identity.test.ts +105 -0
  34. package/src/utils/workflow/graph/workflow-graph.types.ts +3 -2
  35. package/tsconfig.tsbuildinfo +1 -1
@@ -6,6 +6,7 @@
6
6
  import type {
7
7
  SerializedWorkflowGraph,
8
8
  SerializedGraphNode,
9
+ SerializedNext,
9
10
  DataRef,
10
11
  ContextVariable,
11
12
  } from '../graph/workflow-graph.types.js'
@@ -180,16 +181,185 @@ function inputToCode(
180
181
  return `{\n${lines.join('\n')}\n${indent}}`
181
182
  }
182
183
 
184
+ /**
185
+ * Convert a fanout's per-iteration body into code by walking the `next` chain
186
+ * from the body's entry node. Steps whose result is referenced later in the
187
+ * same iteration are re-emitted as `const` bindings.
188
+ */
189
+ function fanoutBodyToCode(
190
+ entryNodeId: string | undefined,
191
+ nodes: Record<string, any>,
192
+ indent: string,
193
+ itemVar: string
194
+ ): string[] {
195
+ const lines: string[] = []
196
+ const seen = new Set<string>()
197
+ let currentId = entryNodeId
198
+
199
+ while (currentId && nodes[currentId] && !seen.has(currentId)) {
200
+ seen.add(currentId)
201
+ const node = nodes[currentId]
202
+
203
+ if ('rpcName' in node && node.rpcName) {
204
+ const stepName = node.stepName || `Call ${node.rpcName}`
205
+ const input = (node.input || {}) as Record<string, unknown>
206
+ const inputCode = inputToCode(input, indent, itemVar)
207
+ const options = node.options ? optionsToCode(node.options) : ''
208
+ const doCall = `await workflow.do('${escapeSingleQuotes(stepName)}', '${node.rpcName}', ${inputCode}${options ? `, ${options}` : ''})`
209
+ lines.push(
210
+ node.outputVar
211
+ ? `${indent}const ${node.outputVar} = ${doCall}`
212
+ : `${indent}${doCall}`
213
+ )
214
+ } else if ('flow' in node) {
215
+ // A body is not RPC-only — a sleep or suspend between calls is part of
216
+ // the iteration and would otherwise be dropped from the regenerated code.
217
+ lines.push(
218
+ ...nodeToCode(node, nodes, indent).filter((line) => line !== '')
219
+ )
220
+ }
221
+
222
+ currentId = node.next
223
+ }
224
+
225
+ return lines
226
+ }
227
+
228
+ /**
229
+ * Collect every node id that belongs to a parent construct's body, following
230
+ * each body's `next` chain to the parent's exit.
231
+ */
232
+ function collectChildNodeIds(
233
+ nodes: Record<string, SerializedGraphNode>
234
+ ): Set<string> {
235
+ const owned = new Set<string>()
236
+
237
+ // An error handler belongs to the step that routes to it, not to the main
238
+ // flow — emitting it top-level would turn compensation into a step that
239
+ // always runs.
240
+ for (const node of Object.values(nodes)) {
241
+ const onError = (node as any).onError
242
+ if (typeof onError === 'string') {
243
+ owned.add(onError)
244
+ } else if (Array.isArray(onError)) {
245
+ for (const handlerId of onError) owned.add(handlerId)
246
+ }
247
+ }
248
+
249
+ const walk = (entry: string | undefined, exit: string | undefined) => {
250
+ const seen = new Set<string>()
251
+ let currentId = entry
252
+ while (
253
+ currentId &&
254
+ nodes[currentId] &&
255
+ isWithinBody(currentId, exit, seen)
256
+ ) {
257
+ seen.add(currentId)
258
+ owned.add(currentId)
259
+ const next = (nodes[currentId] as any).next
260
+ currentId = typeof next === 'string' ? next : undefined
261
+ }
262
+ }
263
+
264
+ for (const node of Object.values(nodes)) {
265
+ if (!('flow' in node)) continue
266
+ const flowNode = node as any
267
+ const exit = flowNode.next as string | undefined
268
+
269
+ switch (flowNode.flow) {
270
+ case 'branch':
271
+ for (const branch of flowNode.branches || []) walk(branch.entry, exit)
272
+ walk(flowNode.elseEntry, exit)
273
+ break
274
+ case 'switch':
275
+ for (const caseItem of flowNode.cases || []) walk(caseItem.entry, exit)
276
+ walk(flowNode.defaultEntry, exit)
277
+ break
278
+ case 'fanout':
279
+ walk(flowNode.childEntry, undefined)
280
+ break
281
+ case 'parallel':
282
+ for (const childId of flowNode.children || []) walk(childId, exit)
283
+ break
284
+ }
285
+ }
286
+
287
+ return owned
288
+ }
289
+
290
+ /**
291
+ * Escape a value for embedding inside a single-quoted string literal.
292
+ */
293
+ function escapeSingleQuotes(value: string): string {
294
+ return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
295
+ }
296
+
297
+ /**
298
+ * `outputVar` is optional — a graph-authored node may have no binding. Emitting
299
+ * `const undefined = ...` for those would not parse, so the expression is kept
300
+ * as a bare statement instead.
301
+ */
302
+ function bindingPrefix(outputVar: unknown): string {
303
+ return typeof outputVar === 'string' && outputVar
304
+ ? `const ${outputVar} = `
305
+ : ''
306
+ }
307
+
308
+ /**
309
+ * Resolve a node's `onError` node id back to the rpc name the DSL author wrote.
310
+ */
311
+ function onErrorRpcNameOf(
312
+ node: SerializedGraphNode,
313
+ nodes: Record<string, SerializedGraphNode>
314
+ ): string | undefined {
315
+ const onError = (node as any).onError
316
+ const handlerId = Array.isArray(onError) ? onError[0] : onError
317
+ if (typeof handlerId !== 'string') {
318
+ return undefined
319
+ }
320
+ const handler = nodes[handlerId]
321
+ return handler && 'rpcName' in handler
322
+ ? (handler.rpcName as string)
323
+ : undefined
324
+ }
325
+
326
+ /**
327
+ * A duration is `string | number` ('5s' or 5000). Quoting a number would change
328
+ * the value the runtime parses, so only strings get quotes.
329
+ */
330
+ function durationToCode(duration: unknown): string {
331
+ return typeof duration === 'number'
332
+ ? String(duration)
333
+ : `'${escapeSingleQuotes(String(duration))}'`
334
+ }
335
+
336
+ /**
337
+ * Render a switch case value. `switch` compares with ===, so quoting a number
338
+ * or boolean would stop the case ever matching.
339
+ */
340
+ function caseValueToCode(value: unknown): string {
341
+ if (typeof value === 'string') {
342
+ return `'${escapeSingleQuotes(value)}'`
343
+ }
344
+ return String(value)
345
+ }
346
+
183
347
  /**
184
348
  * Convert options to code
185
349
  */
186
- function optionsToCode(options: Record<string, unknown>): string {
350
+ function optionsToCode(
351
+ options: Record<string, unknown>,
352
+ onErrorRpcName?: string
353
+ ): string {
187
354
  const parts: string[] = []
355
+ if (onErrorRpcName) {
356
+ parts.push(`onError: '${escapeSingleQuotes(onErrorRpcName)}'`)
357
+ }
188
358
  if (options.retries !== undefined) {
189
359
  parts.push(`retries: ${options.retries}`)
190
360
  }
191
361
  if (options.retryDelay !== undefined) {
192
- parts.push(`retryDelay: '${options.retryDelay}'`)
362
+ parts.push(`retryDelay: ${durationToCode(options.retryDelay)}`)
193
363
  }
194
364
  return parts.length > 0 ? `{ ${parts.join(', ')} }` : ''
195
365
  }
@@ -265,14 +435,26 @@ function collectBranchConditionalVars(
265
435
  if (branchNode.branches) {
266
436
  for (const branch of branchNode.branches) {
267
437
  if (branch.entry) {
268
- collectVarsFromBranch(branch.entry, nodes, conditionalVars, vars)
438
+ collectVarsFromBranch(
439
+ branch.entry,
440
+ nodes,
441
+ conditionalVars,
442
+ vars,
443
+ branchNode.next as string | undefined
444
+ )
269
445
  }
270
446
  }
271
447
  }
272
448
 
273
449
  // Check else branch
274
450
  if (branchNode.elseEntry) {
275
- collectVarsFromBranch(branchNode.elseEntry, nodes, conditionalVars, vars)
451
+ collectVarsFromBranch(
452
+ branchNode.elseEntry,
453
+ nodes,
454
+ conditionalVars,
455
+ vars,
456
+ branchNode.next as string | undefined
457
+ )
276
458
  }
277
459
 
278
460
  return vars
@@ -285,8 +467,12 @@ function collectVarsFromBranch(
285
467
  nodeId: string,
286
468
  nodes: Record<string, SerializedGraphNode>,
287
469
  conditionalVars: Set<string>,
288
- result: string[]
470
+ result: string[],
471
+ exitNodeId?: string,
472
+ seen: Set<string> = new Set()
289
473
  ): void {
474
+ if (seen.has(nodeId)) return
475
+ seen.add(nodeId)
290
476
  const node = nodes[nodeId]
291
477
  if (!node) return
292
478
 
@@ -301,22 +487,32 @@ function collectVarsFromBranch(
301
487
  // Follow the chain of nodes within the branch
302
488
  if ('next' in node && node.next) {
303
489
  const nextId = node.next as string
304
- // Only follow if it's still within the branch
305
- if (isWithinBranch(nextId)) {
306
- collectVarsFromBranch(nextId, nodes, conditionalVars, result)
490
+ // Only follow if it's still within the branch body
491
+ if (isWithinBody(nextId, exitNodeId, seen)) {
492
+ collectVarsFromBranch(
493
+ nextId,
494
+ nodes,
495
+ conditionalVars,
496
+ result,
497
+ exitNodeId,
498
+ seen
499
+ )
307
500
  }
308
501
  }
309
502
  }
310
503
 
311
504
  /**
312
- * Check if a node ID is still within a branch (not the main flow)
505
+ * A nested body (branch arm, switch case) ends where it rejoins the main flow.
506
+ * That rejoin point is the enclosing flow node's own `next`, so it is passed in
507
+ * explicitly rather than guessed from the node id — ids are step names, which
508
+ * carry no structural marker.
313
509
  */
314
- function isWithinBranch(nodeId: string): boolean {
315
- return (
316
- nodeId.includes('_then_') ||
317
- nodeId.includes('_else_') ||
318
- nodeId.includes('_branch')
319
- )
510
+ function isWithinBody(
511
+ nodeId: string,
512
+ exitNodeId: string | undefined,
513
+ seen: Set<string>
514
+ ): boolean {
515
+ return nodeId !== exitNodeId && !seen.has(nodeId)
320
516
  }
321
517
 
322
518
  /**
@@ -326,23 +522,25 @@ function generateBranchContent(
326
522
  entryNodeId: string,
327
523
  nodes: Record<string, SerializedGraphNode>,
328
524
  indent: string,
329
- conditionalVars: Set<string>
525
+ conditionalVars: Set<string>,
526
+ exitNodeId?: string
330
527
  ): string[] {
331
528
  const lines: string[] = []
529
+ const seen = new Set<string>()
332
530
  let currentId: string | undefined = entryNodeId
333
531
 
334
532
  while (currentId) {
335
533
  const node = nodes[currentId]
336
534
  if (!node) break
535
+ seen.add(currentId)
337
536
 
338
537
  const nodeLines = nodeToCode(node, nodes, indent, conditionalVars, true)
339
538
  lines.push(...nodeLines)
340
539
 
341
- // Follow to next node within the branch
342
- if ('next' in node && node.next) {
343
- const nextId = node.next as string
344
- // Only continue if it's still within the branch
345
- if (isWithinBranch(nextId)) {
540
+ // Follow to next node within the body
541
+ if ('next' in node && node.next && typeof node.next === 'string') {
542
+ const nextId = node.next
543
+ if (isWithinBody(nextId, exitNodeId, seen)) {
346
544
  currentId = nextId
347
545
  } else {
348
546
  break
@@ -377,8 +575,9 @@ function nodeToCode(
377
575
  let doCall = `await workflow.do('${stepName}', '${node.rpcName}', ${inputCode}`
378
576
 
379
577
  // Add options if present
380
- if ((node as any).options) {
381
- const optCode = optionsToCode((node as any).options)
578
+ const onErrorRpc = onErrorRpcNameOf(node, nodes)
579
+ if ((node as any).options || onErrorRpc) {
580
+ const optCode = optionsToCode((node as any).options ?? {}, onErrorRpc)
382
581
  if (optCode) {
383
582
  doCall += `, ${optCode}`
384
583
  }
@@ -406,11 +605,33 @@ function nodeToCode(
406
605
  switch (flowNode.flow) {
407
606
  case 'sleep':
408
607
  lines.push(
409
- `${indent}await workflow.sleep('${flowNode.stepName || 'Sleep'}', '${flowNode.duration}')`
608
+ `${indent}await workflow.sleep('${escapeSingleQuotes(flowNode.stepName || 'Sleep')}', ${
609
+ typeof flowNode.expression === 'string'
610
+ ? flowNode.expression
611
+ : durationToCode(flowNode.duration)
612
+ })`
410
613
  )
411
614
  lines.push('')
412
615
  break
413
616
 
617
+ case 'suspend':
618
+ lines.push(
619
+ `${indent}await workflow.suspend('${escapeSingleQuotes(String(flowNode.reason ?? ''))}')`
620
+ )
621
+ lines.push('')
622
+ break
623
+
624
+ case 'approval': {
625
+ const approvalCall = `await workflow.approval('${escapeSingleQuotes(String(flowNode.reason ?? ''))}')`
626
+ lines.push(
627
+ flowNode.outputVar
628
+ ? `${indent}const ${flowNode.outputVar} = ${approvalCall}`
629
+ : `${indent}${approvalCall}`
630
+ )
631
+ lines.push('')
632
+ break
633
+ }
634
+
414
635
  case 'cancel':
415
636
  const cancelReason =
416
637
  flowNode.reason || flowNode.stepName || 'Workflow cancelled'
@@ -444,7 +665,8 @@ function nodeToCode(
444
665
  branch.entry,
445
666
  nodes,
446
667
  indent + ' ',
447
- conditionalVars
668
+ conditionalVars,
669
+ flowNode.next as string | undefined
448
670
  )
449
671
  lines.push(...branchLines)
450
672
  }
@@ -458,7 +680,8 @@ function nodeToCode(
458
680
  flowNode.elseEntry,
459
681
  nodes,
460
682
  indent + ' ',
461
- conditionalVars
683
+ conditionalVars,
684
+ flowNode.next as string | undefined
462
685
  )
463
686
  lines.push(...elseLines)
464
687
  lines.push(`${indent}}`)
@@ -466,96 +689,101 @@ function nodeToCode(
466
689
  lines.push('')
467
690
  break
468
691
 
469
- case 'switch':
692
+ case 'switch': {
693
+ const caseExit = flowNode.next as string | undefined
470
694
  lines.push(`${indent}switch (${flowNode.expression}) {`)
471
695
  for (const caseItem of flowNode.cases || []) {
472
- lines.push(`${indent} case '${caseItem.value}':`)
696
+ lines.push(`${indent} case ${caseValueToCode(caseItem.value)}:`)
473
697
  if (caseItem.entry && nodes[caseItem.entry]) {
474
- const caseLines = nodeToCode(
475
- nodes[caseItem.entry],
476
- nodes,
477
- indent + ' '
698
+ lines.push(
699
+ ...generateBranchContent(
700
+ caseItem.entry,
701
+ nodes,
702
+ indent + ' ',
703
+ conditionalVars,
704
+ caseExit
705
+ )
478
706
  )
479
- lines.push(...caseLines)
480
707
  }
481
708
  lines.push(`${indent} break`)
482
709
  }
483
710
  if (flowNode.defaultEntry && nodes[flowNode.defaultEntry]) {
484
711
  lines.push(`${indent} default:`)
485
- const defaultLines = nodeToCode(
486
- nodes[flowNode.defaultEntry],
487
- nodes,
488
- indent + ' '
712
+ lines.push(
713
+ ...generateBranchContent(
714
+ flowNode.defaultEntry,
715
+ nodes,
716
+ indent + ' ',
717
+ conditionalVars,
718
+ caseExit
719
+ )
489
720
  )
490
- lines.push(...defaultLines)
491
721
  lines.push(`${indent} break`)
492
722
  }
493
723
  lines.push(`${indent}}`)
494
724
  lines.push('')
495
725
  break
726
+ }
496
727
 
497
- case 'parallel':
498
- lines.push(`${indent}await Promise.all([`)
499
- for (const childId of flowNode.children || []) {
500
- if (nodes[childId]) {
501
- const childNode = nodes[childId]
502
- if ('rpcName' in childNode && childNode.rpcName) {
503
- const stepName = childNode.stepName || `Call ${childNode.rpcName}`
504
- const input = (childNode.input || {}) as Record<string, unknown>
505
- const inputCode = inputToCode(input, indent + ' ')
506
- lines.push(
507
- `${indent} workflow.do('${stepName}', '${childNode.rpcName}', ${inputCode}),`
508
- )
728
+ case 'parallel': {
729
+ const childNodes = (flowNode.children || [])
730
+ .map((childId: string) => nodes[childId])
731
+ .filter(
732
+ (child: SerializedGraphNode | undefined) =>
733
+ !!child && 'rpcName' in child && !!child.rpcName
734
+ ) as any[]
735
+
736
+ // Results the caller bound (const [a, b] = await Promise.all([...]))
737
+ // must be re-declared, or every later reference to them is unbound.
738
+ const bindings = childNodes.map((child) => child.outputVar)
739
+ const prefix = bindings.some(Boolean)
740
+ ? `const [${bindings
741
+ .map((name: string | undefined, i: number) => name ?? `_${i}`)
742
+ .join(', ')}] = `
743
+ : ''
744
+
745
+ lines.push(`${indent}${prefix}await Promise.all([`)
746
+ for (const childNode of childNodes) {
747
+ const stepName = childNode.stepName || `Call ${childNode.rpcName}`
748
+ const input = (childNode.input || {}) as Record<string, unknown>
749
+ const inputCode = inputToCode(input, indent + ' ')
750
+ let call = `workflow.do('${stepName}', '${childNode.rpcName}', ${inputCode}`
751
+ if (childNode.options) {
752
+ const optCode = optionsToCode(childNode.options)
753
+ if (optCode) {
754
+ call += `, ${optCode}`
509
755
  }
510
756
  }
757
+ call += ')'
758
+ lines.push(`${indent} ${call},`)
511
759
  }
512
760
  lines.push(`${indent}])`)
513
761
  lines.push('')
514
762
  break
763
+ }
764
+
765
+ case 'fanout': {
766
+ const bodyLines = fanoutBodyToCode(
767
+ flowNode.childEntry,
768
+ nodes,
769
+ flowNode.mode === 'parallel' ? indent + ' ' : indent + ' ',
770
+ flowNode.itemVar
771
+ )
515
772
 
516
- case 'fanout':
517
773
  if (flowNode.mode === 'parallel') {
518
774
  lines.push(`${indent}await Promise.all(`)
519
775
  lines.push(
520
- `${indent} ${flowNode.sourceVar}.map(async (${flowNode.itemVar}) =>`
776
+ `${indent} ${flowNode.sourceVar}.map(async (${flowNode.itemVar}) => {`
521
777
  )
522
- if (flowNode.childEntry && nodes[flowNode.childEntry]) {
523
- const childNode = nodes[flowNode.childEntry]
524
- if ('rpcName' in childNode && childNode.rpcName) {
525
- const stepName = childNode.stepName || `Call ${childNode.rpcName}`
526
- const input = (childNode.input || {}) as Record<string, unknown>
527
- const inputCode = inputToCode(
528
- input,
529
- indent + ' ',
530
- flowNode.itemVar
531
- )
532
- lines.push(
533
- `${indent} await workflow.do('${stepName}', '${childNode.rpcName}', ${inputCode})`
534
- )
535
- }
536
- }
537
- lines.push(`${indent} )`)
778
+ lines.push(...bodyLines)
779
+ lines.push(`${indent} })`)
538
780
  lines.push(`${indent})`)
539
781
  } else {
540
782
  // Sequential fanout
541
783
  lines.push(
542
784
  `${indent}for (const ${flowNode.itemVar} of ${flowNode.sourceVar}) {`
543
785
  )
544
- if (flowNode.childEntry && nodes[flowNode.childEntry]) {
545
- const childNode = nodes[flowNode.childEntry]
546
- if ('rpcName' in childNode && childNode.rpcName) {
547
- const stepName = childNode.stepName || `Call ${childNode.rpcName}`
548
- const input = (childNode.input || {}) as Record<string, unknown>
549
- const inputCode = inputToCode(
550
- input,
551
- indent + ' ',
552
- flowNode.itemVar
553
- )
554
- lines.push(
555
- `${indent} await workflow.do('${stepName}', '${childNode.rpcName}', ${inputCode})`
556
- )
557
- }
558
- }
786
+ lines.push(...bodyLines)
559
787
  if (flowNode.timeBetween) {
560
788
  lines.push(
561
789
  `${indent} await workflow.sleep('Wait between iterations', '${flowNode.timeBetween}')`
@@ -565,10 +793,11 @@ function nodeToCode(
565
793
  }
566
794
  lines.push('')
567
795
  break
796
+ }
568
797
 
569
798
  case 'filter':
570
799
  lines.push(
571
- `${indent}const ${flowNode.outputVar} = ${flowNode.sourceVar}.filter((${flowNode.itemVar}) => ${conditionToCode(flowNode.condition)})`
800
+ `${indent}${bindingPrefix(flowNode.outputVar)}${flowNode.sourceVar}.filter((${flowNode.itemVar}) => ${conditionToCode(flowNode.condition)})`
572
801
  )
573
802
  lines.push('')
574
803
  break
@@ -576,16 +805,19 @@ function nodeToCode(
576
805
  case 'arrayPredicate':
577
806
  const method = flowNode.mode === 'some' ? 'some' : 'every'
578
807
  lines.push(
579
- `${indent}const ${flowNode.outputVar} = ${flowNode.sourceVar}.${method}((${flowNode.itemVar}) => ${conditionToCode(flowNode.condition)})`
808
+ `${indent}${bindingPrefix(flowNode.outputVar)}${flowNode.sourceVar}.${method}((${flowNode.itemVar}) => ${conditionToCode(flowNode.condition)})`
580
809
  )
581
810
  lines.push('')
582
811
  break
583
812
 
584
813
  case 'return':
585
- if (flowNode.outputs) {
814
+ if (flowNode.outputs || flowNode.spread) {
586
815
  const returnObj: string[] = []
816
+ for (const spreadVar of (flowNode.spread as string[]) ?? []) {
817
+ returnObj.push(`${indent} ...${spreadVar},`)
818
+ }
587
819
  for (const [key, output] of Object.entries(
588
- flowNode.outputs as Record<string, any>
820
+ (flowNode.outputs ?? {}) as Record<string, any>
589
821
  )) {
590
822
  let value: string
591
823
  if (output.from === 'outputVar') {
@@ -627,9 +859,11 @@ function nodeToCode(
627
859
  // Generate variable assignment: varName = value
628
860
  const setVar = flowNode.variable
629
861
  const setValue =
630
- typeof flowNode.value === 'string'
631
- ? `'${flowNode.value}'`
632
- : JSON.stringify(flowNode.value)
862
+ typeof flowNode.expression === 'string'
863
+ ? flowNode.expression
864
+ : typeof flowNode.value === 'string'
865
+ ? `'${escapeSingleQuotes(flowNode.value)}'`
866
+ : JSON.stringify(flowNode.value)
633
867
  lines.push(`${indent}${setVar} = ${setValue}`)
634
868
  lines.push('')
635
869
  break
@@ -650,16 +884,42 @@ function findConditionalVars(
650
884
  const varsInBranches = new Set<string>()
651
885
  const varsUsedInReturn = new Set<string>()
652
886
 
653
- // Collect variables defined in branches (then/else/case/default nodes)
654
- for (const [nodeId, node] of Object.entries(nodes)) {
655
- if (
656
- nodeId.includes('_then_') ||
657
- nodeId.includes('_else_') ||
658
- nodeId.includes('_case') ||
659
- nodeId.includes('_default_')
660
- ) {
661
- if ('outputVar' in node && node.outputVar) {
662
- varsInBranches.add(node.outputVar as string)
887
+ // Collect variables defined inside a branch arm or switch case. Found by
888
+ // walking each body from its entry to the enclosing node's exit — node ids
889
+ // are step names and carry no structural marker to match on.
890
+ for (const node of Object.values(nodes)) {
891
+ if (!('flow' in node)) continue
892
+ const flowNode = node as any
893
+ const exit = flowNode.next as string | undefined
894
+
895
+ const entries: string[] = []
896
+ if (flowNode.flow === 'branch') {
897
+ for (const branch of flowNode.branches || []) {
898
+ if (branch.entry) entries.push(branch.entry)
899
+ }
900
+ if (flowNode.elseEntry) entries.push(flowNode.elseEntry)
901
+ } else if (flowNode.flow === 'switch') {
902
+ for (const caseItem of flowNode.cases || []) {
903
+ if (caseItem.entry) entries.push(caseItem.entry)
904
+ }
905
+ if (flowNode.defaultEntry) entries.push(flowNode.defaultEntry)
906
+ }
907
+
908
+ for (const entry of entries) {
909
+ const seen = new Set<string>()
910
+ let currentId: string | undefined = entry
911
+ while (
912
+ currentId &&
913
+ nodes[currentId] &&
914
+ isWithinBody(currentId, exit, seen)
915
+ ) {
916
+ seen.add(currentId)
917
+ const bodyNode = nodes[currentId] as any
918
+ if (bodyNode.outputVar) {
919
+ varsInBranches.add(bodyNode.outputVar as string)
920
+ }
921
+ currentId =
922
+ typeof bodyNode.next === 'string' ? bodyNode.next : undefined
663
923
  }
664
924
  }
665
925
  }
@@ -773,16 +1033,14 @@ export function deserializeDslWorkflow(
773
1033
  // Process nodes in order
774
1034
  const orderedNodes = traverseNodes(workflow.nodes, workflow.entryNodeIds)
775
1035
 
1036
+ // Nodes emitted as part of a parent (branch arm, switch case, parallel group,
1037
+ // fanout body) must not also be emitted at the top level. Ownership is read
1038
+ // from the parents themselves — node ids are step names and a legitimate step
1039
+ // may be called anything.
1040
+ const ownedByParent = collectChildNodeIds(workflow.nodes)
1041
+
776
1042
  for (const node of orderedNodes) {
777
- // Skip child nodes that are processed as part of their parent
778
- if (
779
- node.nodeId.includes('_then_') ||
780
- node.nodeId.includes('_else_') ||
781
- node.nodeId.includes('_case') ||
782
- node.nodeId.includes('_default_') ||
783
- node.nodeId.includes('_child_') ||
784
- node.nodeId.includes('_item_')
785
- ) {
1043
+ if (ownedByParent.has(node.nodeId)) {
786
1044
  continue
787
1045
  }
788
1046
 
@@ -943,8 +1201,91 @@ function findNextRpcNode(
943
1201
 
944
1202
  // It's a flow node - follow its 'next' if it has one
945
1203
  const flowNode = nodes[startNextId]
946
- if (flowNode && 'next' in flowNode && flowNode.next) {
947
- return findNextRpcNode(flowNode.next as string, nodes, flowNodeIds, visited)
1204
+ if (flowNode && 'next' in flowNode && typeof flowNode.next === 'string') {
1205
+ return findNextRpcNode(flowNode.next, nodes, flowNodeIds, visited)
1206
+ }
1207
+
1208
+ return null
1209
+ }
1210
+
1211
+ /**
1212
+ * Resolve a single next target through any intervening flow nodes.
1213
+ */
1214
+ function resolveNextTarget(
1215
+ target: string,
1216
+ nodes: Record<string, SerializedGraphNode>,
1217
+ flowNodeIds: Set<string>
1218
+ ): string | null {
1219
+ const resolved = flowNodeIds.has(target)
1220
+ ? findNextRpcNode(target, nodes, flowNodeIds)
1221
+ : target
1222
+ return resolved && !flowNodeIds.has(resolved) ? resolved : null
1223
+ }
1224
+
1225
+ /**
1226
+ * Render a `next` in any of its shapes. A `next` is not always a single node
1227
+ * id — it can fan out to an array, route by branch key, or carry conditions.
1228
+ * Coercing those to a string produces node ids that match nothing ('a,b',
1229
+ * '[object Object]'), silently severing the rest of the graph.
1230
+ */
1231
+ function nextToCode(
1232
+ next: SerializedNext,
1233
+ nodes: Record<string, SerializedGraphNode>,
1234
+ flowNodeIds: Set<string>
1235
+ ): string | null {
1236
+ const renderTargets = (targets: string | string[]): string | null => {
1237
+ if (typeof targets === 'string') {
1238
+ const resolved = resolveNextTarget(targets, nodes, flowNodeIds)
1239
+ return resolved ? `'${resolved}'` : null
1240
+ }
1241
+ const resolved = targets
1242
+ .map((t) => resolveNextTarget(t, nodes, flowNodeIds))
1243
+ .filter((t): t is string => t !== null)
1244
+ return resolved.length > 0
1245
+ ? `[${resolved.map((t) => `'${t}'`).join(', ')}]`
1246
+ : null
1247
+ }
1248
+
1249
+ if (typeof next === 'string' || Array.isArray(next)) {
1250
+ return renderTargets(next)
1251
+ }
1252
+
1253
+ if (typeof next === 'object' && next !== null) {
1254
+ if ('conditions' in next && Array.isArray(next.conditions)) {
1255
+ const conditions = next.conditions
1256
+ .map((condition) => {
1257
+ const target = renderTargets(condition.target)
1258
+ return target
1259
+ ? `{ expression: '${escapeSingleQuotes(condition.expression)}', target: ${target} }`
1260
+ : null
1261
+ })
1262
+ .filter((c): c is string => c !== null)
1263
+ if (conditions.length === 0) {
1264
+ return null
1265
+ }
1266
+ const parts = [`conditions: [${conditions.join(', ')}]`]
1267
+ if (next.default !== undefined) {
1268
+ const defaultTarget = renderTargets(next.default)
1269
+ if (defaultTarget) {
1270
+ parts.push(`default: ${defaultTarget}`)
1271
+ }
1272
+ }
1273
+ return `{ ${parts.join(', ')} }`
1274
+ }
1275
+
1276
+ const entries = Object.entries(next)
1277
+ .map(([key, target]) => {
1278
+ const isTarget =
1279
+ typeof target === 'string' ||
1280
+ (Array.isArray(target) && target.every((t) => typeof t === 'string'))
1281
+ const rendered = isTarget ? renderTargets(target) : null
1282
+ const safeKey = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)
1283
+ ? key
1284
+ : JSON.stringify(key)
1285
+ return rendered ? `${safeKey}: ${rendered}` : null
1286
+ })
1287
+ .filter((e): e is string => e !== null)
1288
+ return entries.length > 0 ? `{ ${entries.join(', ')} }` : null
948
1289
  }
949
1290
 
950
1291
  return null
@@ -1014,14 +1355,28 @@ export function deserializeGraphWorkflow(
1014
1355
 
1015
1356
  // Add next if present - follow through flow nodes to find the actual next RPC node
1016
1357
  if ('next' in node && node.next) {
1017
- const nextId = node.next as string
1018
- // If next points to a flow node, follow through to find the next RPC node
1019
- const actualNextId = flowNodeIds.has(nextId)
1020
- ? findNextRpcNode(nextId, workflow.nodes, flowNodeIds)
1021
- : nextId
1022
- // Only add if we found a valid next RPC node
1023
- if (actualNextId && !flowNodeIds.has(actualNextId)) {
1024
- configParts.push(`next: '${actualNextId}'`)
1358
+ const nextCode = nextToCode(node.next, workflow.nodes, flowNodeIds)
1359
+ if (nextCode) {
1360
+ configParts.push(`next: ${nextCode}`)
1361
+ }
1362
+ }
1363
+
1364
+ // onError, retries and retryDelay are all honoured by the graph runner,
1365
+ // so dropping them here silently changes how the workflow behaves.
1366
+ if ('onError' in node && node.onError) {
1367
+ const onErrorCode = nextToCode(node.onError, workflow.nodes, flowNodeIds)
1368
+ if (onErrorCode) {
1369
+ configParts.push(`onError: ${onErrorCode}`)
1370
+ }
1371
+ }
1372
+
1373
+ if ('options' in node && node.options) {
1374
+ const { retries, retryDelay } = node.options
1375
+ if (retries !== undefined) {
1376
+ configParts.push(`retries: ${retries}`)
1377
+ }
1378
+ if (retryDelay !== undefined) {
1379
+ configParts.push(`retryDelay: ${durationToCode(retryDelay)}`)
1025
1380
  }
1026
1381
  }
1027
1382
 
@@ -1057,6 +1412,12 @@ export function deserializeGraphWorkflow(
1057
1412
  if (workflow.tags && workflow.tags.length > 0) {
1058
1413
  lines.push(` tags: [${workflow.tags.map((t) => `'${t}'`).join(', ')}],`)
1059
1414
  }
1415
+ if (workflow.notes && workflow.notes.length > 0) {
1416
+ const notes = workflow.notes
1417
+ .map((n) => `'${escapeSingleQuotes(n)}'`)
1418
+ .join(', ')
1419
+ lines.push(` notes: [${notes}],`)
1420
+ }
1060
1421
 
1061
1422
  // Generate nodes (RPC mapping)
1062
1423
  const rpcMapEntries = Object.entries(nodeRpcMap)