@pikku/inspector 0.11.1 → 0.11.2

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 (68) hide show
  1. package/CHANGELOG.md +16 -1
  2. package/dist/add/add-forge-credential.d.ts +8 -0
  3. package/dist/add/add-forge-credential.js +77 -0
  4. package/dist/add/add-forge-node.d.ts +7 -0
  5. package/dist/add/add-forge-node.js +77 -0
  6. package/dist/add/add-functions.js +102 -9
  7. package/dist/add/add-http-route.js +24 -1
  8. package/dist/add/add-rpc-invocations.d.ts +3 -0
  9. package/dist/add/add-rpc-invocations.js +51 -25
  10. package/dist/add/add-workflow-graph.d.ts +6 -0
  11. package/dist/add/add-workflow-graph.js +659 -0
  12. package/dist/add/add-workflow.js +118 -22
  13. package/dist/error-codes.d.ts +3 -1
  14. package/dist/error-codes.js +3 -1
  15. package/dist/index.d.ts +3 -0
  16. package/dist/index.js +2 -0
  17. package/dist/inspector.js +19 -3
  18. package/dist/types.d.ts +26 -0
  19. package/dist/utils/extract-function-name.js +7 -7
  20. package/dist/utils/get-property-value.d.ts +2 -1
  21. package/dist/utils/get-property-value.js +6 -2
  22. package/dist/utils/serialize-inspector-state.d.ts +24 -1
  23. package/dist/utils/serialize-inspector-state.js +24 -0
  24. package/dist/utils/workflow/dsl/deserialize-dsl-workflow.d.ts +24 -0
  25. package/dist/utils/workflow/dsl/deserialize-dsl-workflow.js +898 -0
  26. package/dist/{workflow/extract-simple-workflow.d.ts → utils/workflow/dsl/extract-dsl-workflow.d.ts} +4 -2
  27. package/dist/{workflow/extract-simple-workflow.js → utils/workflow/dsl/extract-dsl-workflow.js} +549 -68
  28. package/dist/utils/workflow/dsl/index.d.ts +7 -0
  29. package/dist/utils/workflow/dsl/index.js +7 -0
  30. package/dist/{workflow → utils/workflow/dsl}/patterns.d.ts +21 -0
  31. package/dist/{workflow → utils/workflow/dsl}/patterns.js +90 -10
  32. package/dist/{workflow → utils/workflow/dsl}/validation.d.ts +2 -0
  33. package/dist/{workflow → utils/workflow/dsl}/validation.js +25 -7
  34. package/dist/utils/workflow/graph/convert-dsl-to-graph.d.ts +13 -0
  35. package/dist/utils/workflow/graph/convert-dsl-to-graph.js +316 -0
  36. package/dist/utils/workflow/graph/index.d.ts +6 -0
  37. package/dist/utils/workflow/graph/index.js +6 -0
  38. package/dist/utils/workflow/graph/serialize-workflow-graph.d.ts +43 -0
  39. package/dist/utils/workflow/graph/serialize-workflow-graph.js +152 -0
  40. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +229 -0
  41. package/dist/utils/workflow/graph/workflow-graph.types.js +38 -0
  42. package/dist/visit.js +6 -0
  43. package/package.json +14 -2
  44. package/src/add/add-forge-credential.ts +119 -0
  45. package/src/add/add-forge-node.ts +132 -0
  46. package/src/add/add-functions.ts +129 -15
  47. package/src/add/add-http-route.ts +25 -1
  48. package/src/add/add-rpc-invocations.ts +61 -31
  49. package/src/add/add-workflow-graph.ts +864 -0
  50. package/src/add/add-workflow.ts +112 -26
  51. package/src/error-codes.ts +3 -1
  52. package/src/index.ts +10 -0
  53. package/src/inspector.ts +20 -4
  54. package/src/types.ts +25 -1
  55. package/src/utils/extract-function-name.ts +7 -7
  56. package/src/utils/get-property-value.ts +9 -2
  57. package/src/utils/serialize-inspector-state.ts +39 -1
  58. package/src/utils/workflow/dsl/deserialize-dsl-workflow.ts +1180 -0
  59. package/src/{workflow/extract-simple-workflow.ts → utils/workflow/dsl/extract-dsl-workflow.ts} +654 -81
  60. package/src/utils/workflow/dsl/index.ts +11 -0
  61. package/src/{workflow → utils/workflow/dsl}/patterns.ts +108 -11
  62. package/src/{workflow → utils/workflow/dsl}/validation.ts +34 -7
  63. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +415 -0
  64. package/src/utils/workflow/graph/index.ts +6 -0
  65. package/src/utils/workflow/graph/serialize-workflow-graph.ts +223 -0
  66. package/src/utils/workflow/graph/workflow-graph.types.ts +280 -0
  67. package/src/visit.ts +6 -0
  68. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,1180 @@
1
+ /**
2
+ * Deserialize workflow JSON back to DSL code
3
+ * Converts the serialized workflow graph format back to TypeScript DSL code
4
+ */
5
+
6
+ import type {
7
+ SerializedWorkflowGraph,
8
+ SerializedGraphNode,
9
+ DataRef,
10
+ ContextVariable,
11
+ } from '../graph/workflow-graph.types.js'
12
+
13
+ interface DeserializeOptions {
14
+ /** Import path for pikkuWorkflowFunc */
15
+ pikkuImportPath?: string
16
+ /** Whether to include type annotations */
17
+ includeTypes?: boolean
18
+ }
19
+
20
+ /**
21
+ * Check if a value is a DataRef
22
+ */
23
+ function isDataRef(value: unknown): value is DataRef {
24
+ return (
25
+ typeof value === 'object' &&
26
+ value !== null &&
27
+ '$ref' in value &&
28
+ typeof (value as DataRef).$ref === 'string'
29
+ )
30
+ }
31
+
32
+ /**
33
+ * State reference (context variable)
34
+ */
35
+ interface StateRef {
36
+ $state: string
37
+ path?: string
38
+ }
39
+
40
+ /**
41
+ * Check if a value is a StateRef
42
+ */
43
+ function isStateRef(value: unknown): value is StateRef {
44
+ return (
45
+ typeof value === 'object' &&
46
+ value !== null &&
47
+ '$state' in value &&
48
+ typeof (value as StateRef).$state === 'string'
49
+ )
50
+ }
51
+
52
+ /**
53
+ * Check if value is a template literal reference
54
+ */
55
+ interface TemplateRef {
56
+ $template: {
57
+ parts: string[]
58
+ expressions: unknown[]
59
+ }
60
+ }
61
+
62
+ function isTemplateRef(value: unknown): value is TemplateRef {
63
+ return (
64
+ typeof value === 'object' &&
65
+ value !== null &&
66
+ '$template' in value &&
67
+ typeof (value as TemplateRef).$template === 'object'
68
+ )
69
+ }
70
+
71
+ /**
72
+ * Convert a DataRef to code expression
73
+ */
74
+ function dataRefToCode(ref: DataRef, itemVar?: string): string {
75
+ if (ref.$ref === 'trigger') {
76
+ // Reference to trigger input (data)
77
+ return ref.path ? `data.${ref.path}` : 'data'
78
+ }
79
+ if (ref.$ref === '$item') {
80
+ // Reference to the current loop item
81
+ // The path contains the variable name in this case
82
+ return ref.path || itemVar || 'item'
83
+ }
84
+ // Reference to a step output variable
85
+ return ref.path ? `${ref.$ref}.${ref.path}` : ref.$ref
86
+ }
87
+
88
+ /**
89
+ * Convert a template ref to template literal code
90
+ */
91
+ function templateRefToCode(template: TemplateRef, itemVar?: string): string {
92
+ const { parts, expressions } = template.$template
93
+ let result = '`'
94
+
95
+ for (let i = 0; i < parts.length; i++) {
96
+ result += parts[i]
97
+ if (i < expressions.length) {
98
+ const expr = expressions[i]
99
+ let exprCode: string
100
+ if (isDataRef(expr)) {
101
+ exprCode = dataRefToCode(expr, itemVar)
102
+ } else if (isTemplateRef(expr)) {
103
+ // Nested template (unlikely but handle it)
104
+ exprCode = templateRefToCode(expr, itemVar)
105
+ } else {
106
+ // Literal value
107
+ exprCode = String(expr)
108
+ }
109
+ result += '${' + exprCode + '}'
110
+ }
111
+ }
112
+
113
+ result += '`'
114
+ return result
115
+ }
116
+
117
+ /**
118
+ * Convert a StateRef to code expression
119
+ */
120
+ function stateRefToCode(ref: StateRef): string {
121
+ return ref.path ? `${ref.$state}.${ref.path}` : ref.$state
122
+ }
123
+
124
+ /**
125
+ * Convert a single value to code (handles refs, templates, state refs, and literals)
126
+ */
127
+ function valueToCode(value: unknown, itemVar?: string): string {
128
+ if (isDataRef(value)) {
129
+ return dataRefToCode(value, itemVar)
130
+ }
131
+ if (isStateRef(value)) {
132
+ return stateRefToCode(value)
133
+ }
134
+ if (isTemplateRef(value)) {
135
+ return templateRefToCode(value, itemVar)
136
+ }
137
+ return JSON.stringify(value)
138
+ }
139
+
140
+ /**
141
+ * Check if input represents passthrough (entire data object)
142
+ */
143
+ function isPassthrough(input: Record<string, unknown>): boolean {
144
+ if (Object.keys(input).length === 1 && '$passthrough' in input) {
145
+ const passthrough = input.$passthrough
146
+ return isDataRef(passthrough) && passthrough.$ref === 'trigger'
147
+ }
148
+ return false
149
+ }
150
+
151
+ /**
152
+ * Convert input object to code
153
+ */
154
+ function inputToCode(
155
+ input: Record<string, unknown>,
156
+ indent: string,
157
+ itemVar?: string
158
+ ): string {
159
+ // Check if this is a passthrough (entire data object)
160
+ if (isPassthrough(input)) {
161
+ return 'data'
162
+ }
163
+
164
+ const entries = Object.entries(input)
165
+ if (entries.length === 0) return '{}'
166
+
167
+ const lines = entries.map(([key, value]) => {
168
+ return `${indent} ${key}: ${valueToCode(value, itemVar)},`
169
+ })
170
+
171
+ return `{\n${lines.join('\n')}\n${indent}}`
172
+ }
173
+
174
+ /**
175
+ * Convert options to code
176
+ */
177
+ function optionsToCode(options: Record<string, unknown>): string {
178
+ const parts: string[] = []
179
+ if (options.retries !== undefined) {
180
+ parts.push(`retries: ${options.retries}`)
181
+ }
182
+ if (options.retryDelay !== undefined) {
183
+ parts.push(`retryDelay: '${options.retryDelay}'`)
184
+ }
185
+ return parts.length > 0 ? `{ ${parts.join(', ')} }` : ''
186
+ }
187
+
188
+ /**
189
+ * Convert a simple condition to code expression
190
+ */
191
+ function conditionToCode(condition: any): string {
192
+ if (!condition) return 'true'
193
+
194
+ if (condition.type === 'simple') {
195
+ return condition.expression
196
+ }
197
+
198
+ if (condition.type === 'and') {
199
+ const parts = condition.conditions.map(conditionToCode)
200
+ return parts.length > 1 ? `(${parts.join(' && ')})` : parts[0]
201
+ }
202
+
203
+ if (condition.type === 'or') {
204
+ const parts = condition.conditions.map(conditionToCode)
205
+ return parts.length > 1 ? `(${parts.join(' || ')})` : parts[0]
206
+ }
207
+
208
+ return 'true'
209
+ }
210
+
211
+ /**
212
+ * Traverse nodes in execution order starting from entry
213
+ */
214
+ function traverseNodes(
215
+ nodes: Record<string, SerializedGraphNode>,
216
+ entryNodeIds: string[]
217
+ ): SerializedGraphNode[] {
218
+ const result: SerializedGraphNode[] = []
219
+ const visited = new Set<string>()
220
+
221
+ function visit(nodeId: string) {
222
+ if (visited.has(nodeId)) return
223
+ visited.add(nodeId)
224
+
225
+ const node = nodes[nodeId]
226
+ if (!node) return
227
+
228
+ result.push(node)
229
+
230
+ // Follow next pointer
231
+ if ('next' in node && node.next) {
232
+ if (typeof node.next === 'string') {
233
+ visit(node.next)
234
+ }
235
+ }
236
+ }
237
+
238
+ for (const entryId of entryNodeIds) {
239
+ visit(entryId)
240
+ }
241
+
242
+ return result
243
+ }
244
+
245
+ /**
246
+ * Collect conditional variables that need to be declared before a branch
247
+ */
248
+ function collectBranchConditionalVars(
249
+ branchNode: any,
250
+ nodes: Record<string, SerializedGraphNode>,
251
+ conditionalVars: Set<string>
252
+ ): string[] {
253
+ const vars: string[] = []
254
+
255
+ // Check all branches (if/else-if chain)
256
+ if (branchNode.branches) {
257
+ for (const branch of branchNode.branches) {
258
+ if (branch.entry) {
259
+ collectVarsFromBranch(branch.entry, nodes, conditionalVars, vars)
260
+ }
261
+ }
262
+ }
263
+
264
+ // Check else branch
265
+ if (branchNode.elseEntry) {
266
+ collectVarsFromBranch(branchNode.elseEntry, nodes, conditionalVars, vars)
267
+ }
268
+
269
+ return vars
270
+ }
271
+
272
+ /**
273
+ * Recursively collect output variables from a branch that are in conditionalVars
274
+ */
275
+ function collectVarsFromBranch(
276
+ nodeId: string,
277
+ nodes: Record<string, SerializedGraphNode>,
278
+ conditionalVars: Set<string>,
279
+ result: string[]
280
+ ): void {
281
+ const node = nodes[nodeId]
282
+ if (!node) return
283
+
284
+ // Check if this node has an outputVar that's conditional
285
+ if ('outputVar' in node && node.outputVar) {
286
+ const varName = node.outputVar as string
287
+ if (conditionalVars.has(varName) && !result.includes(varName)) {
288
+ result.push(varName)
289
+ }
290
+ }
291
+
292
+ // Follow the chain of nodes within the branch
293
+ if ('next' in node && node.next) {
294
+ const nextId = node.next as string
295
+ // Only follow if it's still within the branch
296
+ if (isWithinBranch(nextId)) {
297
+ collectVarsFromBranch(nextId, nodes, conditionalVars, result)
298
+ }
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Check if a node ID is still within a branch (not the main flow)
304
+ */
305
+ function isWithinBranch(nodeId: string): boolean {
306
+ return (
307
+ nodeId.includes('_then_') ||
308
+ nodeId.includes('_else_') ||
309
+ nodeId.includes('_branch')
310
+ )
311
+ }
312
+
313
+ /**
314
+ * Generate code for branch content (then/else blocks)
315
+ */
316
+ function generateBranchContent(
317
+ entryNodeId: string,
318
+ nodes: Record<string, SerializedGraphNode>,
319
+ indent: string,
320
+ conditionalVars: Set<string>
321
+ ): string[] {
322
+ const lines: string[] = []
323
+ let currentId: string | undefined = entryNodeId
324
+
325
+ while (currentId) {
326
+ const node = nodes[currentId]
327
+ if (!node) break
328
+
329
+ const nodeLines = nodeToCode(node, nodes, indent, conditionalVars, true)
330
+ lines.push(...nodeLines)
331
+
332
+ // Follow to next node within the branch
333
+ if ('next' in node && node.next) {
334
+ const nextId = node.next as string
335
+ // Only continue if it's still within the branch
336
+ if (isWithinBranch(nextId)) {
337
+ currentId = nextId
338
+ } else {
339
+ break
340
+ }
341
+ } else {
342
+ break
343
+ }
344
+ }
345
+
346
+ return lines
347
+ }
348
+
349
+ /**
350
+ * Generate DSL code for a single node
351
+ */
352
+ function nodeToCode(
353
+ node: SerializedGraphNode,
354
+ nodes: Record<string, SerializedGraphNode>,
355
+ indent: string,
356
+ conditionalVars: Set<string> = new Set(),
357
+ isInsideBranch: boolean = false
358
+ ): string[] {
359
+ const lines: string[] = []
360
+
361
+ // Handle RPC nodes (function calls)
362
+ if ('rpcName' in node && node.rpcName && node.rpcName !== 'unknown') {
363
+ const stepName = node.stepName || `Call ${node.rpcName}`
364
+ const input = (node.input || {}) as Record<string, unknown>
365
+ const inputCode = inputToCode(input, indent)
366
+ const outputVar = (node as any).outputVar
367
+
368
+ let doCall = `await workflow.do('${stepName}', '${node.rpcName}', ${inputCode}`
369
+
370
+ // Add options if present
371
+ if ((node as any).options) {
372
+ const optCode = optionsToCode((node as any).options)
373
+ if (optCode) {
374
+ doCall += `, ${optCode}`
375
+ }
376
+ }
377
+ doCall += ')'
378
+
379
+ if (outputVar) {
380
+ // If this is a conditional var inside a branch, use assignment (let was declared above)
381
+ if (isInsideBranch && conditionalVars.has(outputVar)) {
382
+ lines.push(`${indent}${outputVar} = ${doCall}`)
383
+ } else {
384
+ lines.push(`${indent}const ${outputVar} = ${doCall}`)
385
+ }
386
+ } else {
387
+ lines.push(`${indent}${doCall}`)
388
+ }
389
+ lines.push('')
390
+ return lines
391
+ }
392
+
393
+ // Handle flow nodes
394
+ if ('flow' in node) {
395
+ const flowNode = node as any
396
+
397
+ switch (flowNode.flow) {
398
+ case 'sleep':
399
+ lines.push(
400
+ `${indent}await workflow.sleep('${flowNode.stepName || 'Sleep'}', '${flowNode.duration}')`
401
+ )
402
+ lines.push('')
403
+ break
404
+
405
+ case 'cancel':
406
+ const cancelReason =
407
+ flowNode.reason || flowNode.stepName || 'Workflow cancelled'
408
+ lines.push(
409
+ `${indent}throw new WorkflowCancelledException('${cancelReason}')`
410
+ )
411
+ lines.push('')
412
+ break
413
+
414
+ case 'branch':
415
+ // Declare conditional variables before the if statement
416
+ const branchConditionalVars = collectBranchConditionalVars(
417
+ flowNode,
418
+ nodes,
419
+ conditionalVars
420
+ )
421
+ for (const varName of branchConditionalVars) {
422
+ lines.push(`${indent}let ${varName}`)
423
+ }
424
+
425
+ // Generate if/else-if/else chain
426
+ const branches = flowNode.branches || []
427
+ for (let i = 0; i < branches.length; i++) {
428
+ const branch = branches[i]
429
+ const condition = conditionToCode(branch.condition)
430
+ const keyword = i === 0 ? 'if' : 'else if'
431
+ lines.push(`${indent}${keyword} (${condition}) {`)
432
+
433
+ if (branch.entry && nodes[branch.entry]) {
434
+ const branchLines = generateBranchContent(
435
+ branch.entry,
436
+ nodes,
437
+ indent + ' ',
438
+ conditionalVars
439
+ )
440
+ lines.push(...branchLines)
441
+ }
442
+ lines.push(`${indent}}`)
443
+ }
444
+
445
+ // Generate else block if present
446
+ if (flowNode.elseEntry && nodes[flowNode.elseEntry]) {
447
+ lines.push(`${indent}else {`)
448
+ const elseLines = generateBranchContent(
449
+ flowNode.elseEntry,
450
+ nodes,
451
+ indent + ' ',
452
+ conditionalVars
453
+ )
454
+ lines.push(...elseLines)
455
+ lines.push(`${indent}}`)
456
+ }
457
+ lines.push('')
458
+ break
459
+
460
+ case 'switch':
461
+ lines.push(`${indent}switch (${flowNode.expression}) {`)
462
+ for (const caseItem of flowNode.cases || []) {
463
+ lines.push(`${indent} case '${caseItem.value}':`)
464
+ if (caseItem.entry && nodes[caseItem.entry]) {
465
+ const caseLines = nodeToCode(
466
+ nodes[caseItem.entry],
467
+ nodes,
468
+ indent + ' '
469
+ )
470
+ lines.push(...caseLines)
471
+ }
472
+ lines.push(`${indent} break`)
473
+ }
474
+ if (flowNode.defaultEntry && nodes[flowNode.defaultEntry]) {
475
+ lines.push(`${indent} default:`)
476
+ const defaultLines = nodeToCode(
477
+ nodes[flowNode.defaultEntry],
478
+ nodes,
479
+ indent + ' '
480
+ )
481
+ lines.push(...defaultLines)
482
+ lines.push(`${indent} break`)
483
+ }
484
+ lines.push(`${indent}}`)
485
+ lines.push('')
486
+ break
487
+
488
+ case 'parallel':
489
+ lines.push(`${indent}await Promise.all([`)
490
+ for (const childId of flowNode.children || []) {
491
+ if (nodes[childId]) {
492
+ const childNode = nodes[childId]
493
+ if ('rpcName' in childNode && childNode.rpcName) {
494
+ const stepName = childNode.stepName || `Call ${childNode.rpcName}`
495
+ const input = (childNode.input || {}) as Record<string, unknown>
496
+ const inputCode = inputToCode(input, indent + ' ')
497
+ lines.push(
498
+ `${indent} workflow.do('${stepName}', '${childNode.rpcName}', ${inputCode}),`
499
+ )
500
+ }
501
+ }
502
+ }
503
+ lines.push(`${indent}])`)
504
+ lines.push('')
505
+ break
506
+
507
+ case 'fanout':
508
+ if (flowNode.mode === 'parallel') {
509
+ lines.push(`${indent}await Promise.all(`)
510
+ lines.push(
511
+ `${indent} ${flowNode.sourceVar}.map(async (${flowNode.itemVar}) =>`
512
+ )
513
+ if (flowNode.childEntry && nodes[flowNode.childEntry]) {
514
+ const childNode = nodes[flowNode.childEntry]
515
+ if ('rpcName' in childNode && childNode.rpcName) {
516
+ const stepName = childNode.stepName || `Call ${childNode.rpcName}`
517
+ const input = (childNode.input || {}) as Record<string, unknown>
518
+ const inputCode = inputToCode(
519
+ input,
520
+ indent + ' ',
521
+ flowNode.itemVar
522
+ )
523
+ lines.push(
524
+ `${indent} await workflow.do('${stepName}', '${childNode.rpcName}', ${inputCode})`
525
+ )
526
+ }
527
+ }
528
+ lines.push(`${indent} )`)
529
+ lines.push(`${indent})`)
530
+ } else {
531
+ // Sequential fanout
532
+ lines.push(
533
+ `${indent}for (const ${flowNode.itemVar} of ${flowNode.sourceVar}) {`
534
+ )
535
+ if (flowNode.childEntry && nodes[flowNode.childEntry]) {
536
+ const childNode = nodes[flowNode.childEntry]
537
+ if ('rpcName' in childNode && childNode.rpcName) {
538
+ const stepName = childNode.stepName || `Call ${childNode.rpcName}`
539
+ const input = (childNode.input || {}) as Record<string, unknown>
540
+ const inputCode = inputToCode(
541
+ input,
542
+ indent + ' ',
543
+ flowNode.itemVar
544
+ )
545
+ lines.push(
546
+ `${indent} await workflow.do('${stepName}', '${childNode.rpcName}', ${inputCode})`
547
+ )
548
+ }
549
+ }
550
+ if (flowNode.timeBetween) {
551
+ lines.push(
552
+ `${indent} await workflow.sleep('Wait between iterations', '${flowNode.timeBetween}')`
553
+ )
554
+ }
555
+ lines.push(`${indent}}`)
556
+ }
557
+ lines.push('')
558
+ break
559
+
560
+ case 'filter':
561
+ lines.push(
562
+ `${indent}const ${flowNode.outputVar} = ${flowNode.sourceVar}.filter((${flowNode.itemVar}) => ${conditionToCode(flowNode.condition)})`
563
+ )
564
+ lines.push('')
565
+ break
566
+
567
+ case 'arrayPredicate':
568
+ const method = flowNode.mode === 'some' ? 'some' : 'every'
569
+ lines.push(
570
+ `${indent}const ${flowNode.outputVar} = ${flowNode.sourceVar}.${method}((${flowNode.itemVar}) => ${conditionToCode(flowNode.condition)})`
571
+ )
572
+ lines.push('')
573
+ break
574
+
575
+ case 'return':
576
+ if (flowNode.outputs) {
577
+ const returnObj: string[] = []
578
+ for (const [key, output] of Object.entries(
579
+ flowNode.outputs as Record<string, any>
580
+ )) {
581
+ let value: string
582
+ if (output.from === 'outputVar') {
583
+ value = output.path
584
+ ? `${output.name}?.${output.path}`
585
+ : output.name
586
+ } else if (output.from === 'stateVar') {
587
+ value = output.path
588
+ ? `${output.name}.${output.path}`
589
+ : output.name
590
+ } else if (output.from === 'input') {
591
+ value = `data.${output.path}`
592
+ } else if (output.from === 'literal') {
593
+ value = JSON.stringify(output.value)
594
+ } else if (output.from === 'expression') {
595
+ value = output.expression
596
+ } else {
597
+ continue
598
+ }
599
+ returnObj.push(`${indent} ${key}: ${value},`)
600
+ }
601
+ if (returnObj.length > 0) {
602
+ lines.push(`${indent}return {`)
603
+ lines.push(...returnObj)
604
+ lines.push(`${indent}}`)
605
+ }
606
+ }
607
+ break
608
+
609
+ case 'inline':
610
+ lines.push(
611
+ `${indent}// Inline step: ${flowNode.stepName || 'Dynamic code'}`
612
+ )
613
+ lines.push(`${indent}// ${flowNode.description || '<dynamic code>'}`)
614
+ lines.push('')
615
+ break
616
+
617
+ case 'set':
618
+ // Generate variable assignment: varName = value
619
+ const setVar = flowNode.variable
620
+ const setValue =
621
+ typeof flowNode.value === 'string'
622
+ ? `'${flowNode.value}'`
623
+ : JSON.stringify(flowNode.value)
624
+ lines.push(`${indent}${setVar} = ${setValue}`)
625
+ lines.push('')
626
+ break
627
+ }
628
+ }
629
+
630
+ return lines
631
+ }
632
+
633
+ /**
634
+ * Find variables that are defined inside branches but used in return statements
635
+ * These need to be hoisted with `let` declarations
636
+ */
637
+ function findConditionalVars(
638
+ nodes: Record<string, SerializedGraphNode>
639
+ ): Set<string> {
640
+ const conditionalVars = new Set<string>()
641
+ const varsInBranches = new Set<string>()
642
+ const varsUsedInReturn = new Set<string>()
643
+
644
+ // Collect variables defined in branches (then/else/case/default nodes)
645
+ for (const [nodeId, node] of Object.entries(nodes)) {
646
+ if (
647
+ nodeId.includes('_then_') ||
648
+ nodeId.includes('_else_') ||
649
+ nodeId.includes('_case') ||
650
+ nodeId.includes('_default_')
651
+ ) {
652
+ if ('outputVar' in node && node.outputVar) {
653
+ varsInBranches.add(node.outputVar as string)
654
+ }
655
+ }
656
+ }
657
+
658
+ // Collect variables used in return statements
659
+ for (const node of Object.values(nodes)) {
660
+ if ('flow' in node && node.flow === 'return' && node.outputs) {
661
+ for (const output of Object.values(
662
+ node.outputs as Record<string, { from: string; name?: string }>
663
+ )) {
664
+ if (output.from === 'outputVar' && output.name) {
665
+ varsUsedInReturn.add(output.name)
666
+ }
667
+ }
668
+ }
669
+ }
670
+
671
+ // Variables that are both in branches and used in return need hoisting
672
+ for (const varName of varsInBranches) {
673
+ if (varsUsedInReturn.has(varName)) {
674
+ conditionalVars.add(varName)
675
+ }
676
+ }
677
+
678
+ return conditionalVars
679
+ }
680
+
681
+ /**
682
+ * Get default value for a context variable type
683
+ */
684
+ function getDefaultForType(type: string): string {
685
+ switch (type) {
686
+ case 'string':
687
+ return "''"
688
+ case 'number':
689
+ return '0'
690
+ case 'boolean':
691
+ return 'false'
692
+ case 'array':
693
+ return '[]'
694
+ case 'object':
695
+ return '{}'
696
+ default:
697
+ return 'undefined'
698
+ }
699
+ }
700
+
701
+ /**
702
+ * Deserialize a workflow graph to DSL code
703
+ */
704
+ export function deserializeDslWorkflow(
705
+ workflow: SerializedWorkflowGraph,
706
+ options: DeserializeOptions = {}
707
+ ): string {
708
+ const { pikkuImportPath = '../.pikku/workflow/pikku-workflow-types.gen.js' } =
709
+ options
710
+
711
+ const lines: string[] = []
712
+
713
+ // Check if workflow has any cancel nodes
714
+ const hasCancelNode = Object.values(workflow.nodes).some(
715
+ (node) => 'flow' in node && (node as any).flow === 'cancel'
716
+ )
717
+
718
+ // Find variables defined in branches that need hoisting
719
+ const conditionalVars = findConditionalVars(workflow.nodes)
720
+
721
+ // Import statement
722
+ if (hasCancelNode) {
723
+ lines.push(
724
+ `import { pikkuWorkflowFunc, WorkflowCancelledException } from '${pikkuImportPath}'`
725
+ )
726
+ } else {
727
+ lines.push(`import { pikkuWorkflowFunc } from '${pikkuImportPath}'`)
728
+ }
729
+ lines.push('')
730
+
731
+ // Add description as comment if present
732
+ if (workflow.description) {
733
+ lines.push(`/**`)
734
+ lines.push(` * ${workflow.description}`)
735
+ lines.push(` */`)
736
+ }
737
+
738
+ // Function signature
739
+ const tagsComment = workflow.tags?.length
740
+ ? ` // tags: ${workflow.tags.join(', ')}`
741
+ : ''
742
+
743
+ lines.push(
744
+ `export const ${workflow.name} = pikkuWorkflowFunc(async ({}, data, { workflow }) => {${tagsComment}`
745
+ )
746
+
747
+ // Generate context variable declarations at the top
748
+ if (workflow.context && Object.keys(workflow.context).length > 0) {
749
+ for (const [varName, varDef] of Object.entries(workflow.context) as [
750
+ string,
751
+ ContextVariable,
752
+ ][]) {
753
+ const defaultValue =
754
+ varDef.default !== undefined
755
+ ? typeof varDef.default === 'string'
756
+ ? `'${varDef.default}'`
757
+ : JSON.stringify(varDef.default)
758
+ : getDefaultForType(varDef.type)
759
+ lines.push(` let ${varName} = ${defaultValue}`)
760
+ }
761
+ lines.push('')
762
+ }
763
+
764
+ // Process nodes in order
765
+ const orderedNodes = traverseNodes(workflow.nodes, workflow.entryNodeIds)
766
+
767
+ for (const node of orderedNodes) {
768
+ // Skip child nodes that are processed as part of their parent
769
+ if (
770
+ node.nodeId.includes('_then_') ||
771
+ node.nodeId.includes('_else_') ||
772
+ node.nodeId.includes('_case') ||
773
+ node.nodeId.includes('_default_') ||
774
+ node.nodeId.includes('_child_') ||
775
+ node.nodeId.includes('_item_')
776
+ ) {
777
+ continue
778
+ }
779
+
780
+ const nodeLines = nodeToCode(node, workflow.nodes, ' ', conditionalVars)
781
+ lines.push(...nodeLines)
782
+ }
783
+
784
+ lines.push('})')
785
+ lines.push('')
786
+
787
+ return lines.join('\n')
788
+ }
789
+
790
+ /**
791
+ * Convert a DataRef to graph ref() call
792
+ * @param ref - The data reference
793
+ * @param outputVarToNodeId - Map from outputVar names to node IDs
794
+ */
795
+ function dataRefToGraphRef(
796
+ ref: DataRef,
797
+ outputVarToNodeId: Map<string, string>
798
+ ): string {
799
+ // Convert outputVar reference to nodeId reference
800
+ const nodeId = outputVarToNodeId.get(ref.$ref) || ref.$ref
801
+ if (ref.path) {
802
+ return `ref('${nodeId}', '${ref.path}')`
803
+ }
804
+ return `ref('${nodeId}')`
805
+ }
806
+
807
+ /**
808
+ * Convert a template ref to template() function call for graph code
809
+ * e.g. {$template: {parts: ["Hello ", ""], expressions: [{$ref: "trigger", path: "name"}]}}
810
+ * becomes: template('Hello $0', [ref('trigger', 'name')])
811
+ */
812
+ function templateRefToGraphCode(
813
+ tmpl: TemplateRef,
814
+ outputVarToNodeId: Map<string, string>
815
+ ): string {
816
+ const { parts, expressions } = tmpl.$template
817
+
818
+ // Build the template string with $0, $1, etc. placeholders
819
+ let templateStr = ''
820
+ for (let i = 0; i < parts.length; i++) {
821
+ templateStr += parts[i]
822
+ if (i < expressions.length) {
823
+ templateStr += `$${i}`
824
+ }
825
+ }
826
+
827
+ // Build the refs array
828
+ const refs: string[] = []
829
+ for (const expr of expressions) {
830
+ if (isDataRef(expr)) {
831
+ refs.push(dataRefToGraphRef(expr, outputVarToNodeId))
832
+ } else {
833
+ // Literal JS expression - can't be represented as a typed ref
834
+ refs.push(`{ $ref: '${String(expr).replace(/'/g, "\\'")}' } as any`)
835
+ }
836
+ }
837
+
838
+ // Escape single quotes and newlines in the template string
839
+ templateStr = templateStr
840
+ .replace(/\\/g, '\\\\')
841
+ .replace(/'/g, "\\'")
842
+ .replace(/\n/g, '\\n')
843
+ .replace(/\r/g, '\\r')
844
+
845
+ return `template('${templateStr}', [${refs.join(', ')}])`
846
+ }
847
+
848
+ /**
849
+ * Convert input object to graph input code using ref()
850
+ * @param input - The input mapping
851
+ * @param outputVarToNodeId - Map from outputVar names to node IDs
852
+ */
853
+ function inputToGraphCode(
854
+ input: Record<string, unknown>,
855
+ outputVarToNodeId: Map<string, string>
856
+ ): {
857
+ hasRefs: boolean
858
+ code: string
859
+ } {
860
+ const entries = Object.entries(input)
861
+ if (entries.length === 0) return { hasRefs: false, code: '{}' }
862
+
863
+ let hasRefs = false
864
+ const lines = entries.map(([key, value]) => {
865
+ if (isDataRef(value)) {
866
+ hasRefs = true
867
+ return ` ${key}: ${dataRefToGraphRef(value, outputVarToNodeId)},`
868
+ }
869
+ if (isTemplateRef(value)) {
870
+ hasRefs = true
871
+ return ` ${key}: ${templateRefToGraphCode(value, outputVarToNodeId)},`
872
+ }
873
+ return ` ${key}: ${JSON.stringify(value)},`
874
+ })
875
+
876
+ return {
877
+ hasRefs,
878
+ code: `{\n${lines.join('\n')}\n }`,
879
+ }
880
+ }
881
+
882
+ /**
883
+ * Serialize wires to code
884
+ */
885
+ function wiresToCode(wires: SerializedWorkflowGraph['wires']): string {
886
+ if (!wires || Object.keys(wires).length === 0) return '{}'
887
+
888
+ const parts: string[] = []
889
+
890
+ if (wires.http && wires.http.length > 0) {
891
+ const httpItems = wires.http.map(
892
+ (h) =>
893
+ `{ route: '${h.route}', method: '${h.method}', startNode: '${h.startNode}' }`
894
+ )
895
+ parts.push(`http: [${httpItems.join(', ')}]`)
896
+ }
897
+
898
+ if (wires.channel && wires.channel.length > 0) {
899
+ const channelItems = wires.channel.map((c) => {
900
+ const channelParts: string[] = [`name: '${c.name}'`]
901
+ if (c.onConnect) channelParts.push(`onConnect: '${c.onConnect}'`)
902
+ if (c.onDisconnect) channelParts.push(`onDisconnect: '${c.onDisconnect}'`)
903
+ if (c.onMessage) channelParts.push(`onMessage: '${c.onMessage}'`)
904
+ return `{ ${channelParts.join(', ')} }`
905
+ })
906
+ parts.push(`channel: [${channelItems.join(', ')}]`)
907
+ }
908
+
909
+ if (wires.queue && wires.queue.length > 0) {
910
+ const queueItems = wires.queue.map(
911
+ (q) => `{ name: '${q.name}', startNode: '${q.startNode}' }`
912
+ )
913
+ parts.push(`queue: [${queueItems.join(', ')}]`)
914
+ }
915
+
916
+ if (wires.cli && wires.cli.length > 0) {
917
+ const cliItems = wires.cli.map(
918
+ (c) => `{ command: '${c.command}', startNode: '${c.startNode}' }`
919
+ )
920
+ parts.push(`cli: [${cliItems.join(', ')}]`)
921
+ }
922
+
923
+ if (wires.schedule && wires.schedule.length > 0) {
924
+ const scheduleItems = wires.schedule.map((s) => {
925
+ const scheduleParts: string[] = []
926
+ if (s.cron) scheduleParts.push(`cron: '${s.cron}'`)
927
+ if (s.interval) scheduleParts.push(`interval: '${s.interval}'`)
928
+ scheduleParts.push(`startNode: '${s.startNode}'`)
929
+ return `{ ${scheduleParts.join(', ')} }`
930
+ })
931
+ parts.push(`schedule: [${scheduleItems.join(', ')}]`)
932
+ }
933
+
934
+ if (wires.trigger && wires.trigger.length > 0) {
935
+ const triggerItems = wires.trigger.map(
936
+ (t) => `{ name: '${t.name}', startNode: '${t.startNode}' }`
937
+ )
938
+ parts.push(`trigger: [${triggerItems.join(', ')}]`)
939
+ }
940
+
941
+ return `{ ${parts.join(', ')} }`
942
+ }
943
+
944
+ /**
945
+ * Check if a node is a flow node (non-RPC control flow)
946
+ */
947
+ function isFlowNode(node: any): boolean {
948
+ return 'flow' in node
949
+ }
950
+
951
+ /**
952
+ * Follow through flow nodes to find the next RPC node
953
+ * This traverses the 'next' chain, skipping flow nodes until finding an RPC node
954
+ */
955
+ function findNextRpcNode(
956
+ startNextId: string,
957
+ nodes: Record<string, SerializedGraphNode>,
958
+ flowNodeIds: Set<string>,
959
+ visited: Set<string> = new Set()
960
+ ): string | null {
961
+ if (visited.has(startNextId)) {
962
+ return null // Cycle detected, stop
963
+ }
964
+ visited.add(startNextId)
965
+
966
+ // If it's not a flow node, we found our target
967
+ if (!flowNodeIds.has(startNextId)) {
968
+ // Make sure the node exists and has an rpcName (is an RPC node)
969
+ const node = nodes[startNextId]
970
+ if (node && 'rpcName' in node) {
971
+ return startNextId
972
+ }
973
+ return null
974
+ }
975
+
976
+ // It's a flow node - follow its 'next' if it has one
977
+ const flowNode = nodes[startNextId]
978
+ if (flowNode && 'next' in flowNode && flowNode.next) {
979
+ return findNextRpcNode(flowNode.next as string, nodes, flowNodeIds, visited)
980
+ }
981
+
982
+ return null
983
+ }
984
+
985
+ /**
986
+ * Deserialize a graph workflow to pikkuWorkflowGraph code
987
+ */
988
+ export function deserializeGraphWorkflow(
989
+ workflow: SerializedWorkflowGraph,
990
+ options: DeserializeOptions = {}
991
+ ): string {
992
+ const { pikkuImportPath = '../.pikku/workflow/pikku-workflow-types.gen.js' } =
993
+ options
994
+
995
+ const lines: string[] = []
996
+
997
+ // Import statement
998
+ lines.push(
999
+ `import { pikkuWorkflowGraph, wireWorkflow } from '${pikkuImportPath}'`
1000
+ )
1001
+ lines.push('')
1002
+
1003
+ // Add description as comment if present
1004
+ if (workflow.description) {
1005
+ lines.push(`/**`)
1006
+ lines.push(` * ${workflow.description}`)
1007
+ lines.push(` */`)
1008
+ }
1009
+
1010
+ // Identify flow nodes (non-RPC nodes like return, sleep, branch)
1011
+ const flowNodeIds = new Set<string>()
1012
+ for (const [nodeId, node] of Object.entries(workflow.nodes)) {
1013
+ if (isFlowNode(node)) {
1014
+ flowNodeIds.add(nodeId)
1015
+ }
1016
+ }
1017
+
1018
+ // Build outputVar to nodeId mapping (for resolving variable references to node IDs)
1019
+ const outputVarToNodeId = new Map<string, string>()
1020
+ for (const [nodeId, node] of Object.entries(workflow.nodes)) {
1021
+ if ('outputVar' in node && typeof node.outputVar === 'string') {
1022
+ outputVarToNodeId.set(node.outputVar, nodeId)
1023
+ }
1024
+ }
1025
+
1026
+ // Build node to RPC mapping (only RPC nodes, not flow nodes)
1027
+ const nodeRpcMap: Record<string, string> = {}
1028
+ for (const [nodeId, node] of Object.entries(workflow.nodes)) {
1029
+ if (
1030
+ 'rpcName' in node &&
1031
+ typeof node.rpcName === 'string' &&
1032
+ node.rpcName !== 'unknown'
1033
+ ) {
1034
+ nodeRpcMap[nodeId] = node.rpcName
1035
+ }
1036
+ }
1037
+
1038
+ // Build node configurations (only for RPC nodes)
1039
+ const nodeConfigs: string[] = []
1040
+
1041
+ for (const [nodeId, node] of Object.entries(workflow.nodes)) {
1042
+ // Skip flow nodes - they can't be represented in pikkuWorkflowGraph
1043
+ if (flowNodeIds.has(nodeId)) {
1044
+ continue
1045
+ }
1046
+
1047
+ const configParts: string[] = []
1048
+
1049
+ // Add next if present - follow through flow nodes to find the actual next RPC node
1050
+ if ('next' in node && node.next) {
1051
+ const nextId = node.next as string
1052
+ // If next points to a flow node, follow through to find the next RPC node
1053
+ const actualNextId = flowNodeIds.has(nextId)
1054
+ ? findNextRpcNode(nextId, workflow.nodes, flowNodeIds)
1055
+ : nextId
1056
+ // Only add if we found a valid next RPC node
1057
+ if (actualNextId && !flowNodeIds.has(actualNextId)) {
1058
+ configParts.push(`next: '${actualNextId}'`)
1059
+ }
1060
+ }
1061
+
1062
+ // Add input if present
1063
+ // Always use callback form to avoid excess property checking in TypeScript
1064
+ if ('input' in node && node.input) {
1065
+ const input = node.input as Record<string, unknown>
1066
+ if (Object.keys(input).length > 0) {
1067
+ const { hasRefs, code } = inputToGraphCode(input, outputVarToNodeId)
1068
+ if (hasRefs) {
1069
+ // Always pass both ref and template for consistent type signature
1070
+ configParts.push(`input: (ref, template) => (${code})`)
1071
+ } else {
1072
+ // Wrap in callback to avoid TypeScript excess property checking
1073
+ configParts.push(`input: () => (${code})`)
1074
+ }
1075
+ }
1076
+ }
1077
+
1078
+ if (configParts.length > 0) {
1079
+ nodeConfigs.push(
1080
+ ` ${nodeId}: {\n ${configParts.join(',\n ')},\n }`
1081
+ )
1082
+ }
1083
+ }
1084
+
1085
+ // Compute entry node (first node with no incoming edges from RPC nodes)
1086
+ const rpcNodeIds = new Set(Object.keys(nodeRpcMap))
1087
+ const nodesWithIncomingEdges = new Set<string>()
1088
+ for (const [nodeId, node] of Object.entries(workflow.nodes)) {
1089
+ if (!rpcNodeIds.has(nodeId)) continue
1090
+ if ('next' in node && node.next) {
1091
+ const nextId = node.next as string
1092
+ // Follow through flow nodes to find the actual next RPC node
1093
+ const actualNextId = flowNodeIds.has(nextId)
1094
+ ? findNextRpcNode(nextId, workflow.nodes, flowNodeIds)
1095
+ : nextId
1096
+ if (actualNextId && rpcNodeIds.has(actualNextId)) {
1097
+ nodesWithIncomingEdges.add(actualNextId)
1098
+ }
1099
+ }
1100
+ }
1101
+ // Entry node is the first RPC node with no incoming edges
1102
+ const entryNode = Object.keys(nodeRpcMap).find(
1103
+ (id) => !nodesWithIncomingEdges.has(id)
1104
+ )
1105
+
1106
+ // Generate the pikkuWorkflowGraph call
1107
+ lines.push(`export const ${workflow.name} = pikkuWorkflowGraph({`)
1108
+ lines.push(` name: '${workflow.name}',`)
1109
+ if (workflow.description) {
1110
+ lines.push(` description: '${workflow.description}',`)
1111
+ }
1112
+ if (workflow.tags && workflow.tags.length > 0) {
1113
+ lines.push(` tags: [${workflow.tags.map((t) => `'${t}'`).join(', ')}],`)
1114
+ }
1115
+
1116
+ // Generate nodes (RPC mapping)
1117
+ const rpcMapEntries = Object.entries(nodeRpcMap)
1118
+ if (rpcMapEntries.length > 0) {
1119
+ lines.push(` nodes: {`)
1120
+ for (const [nodeId, rpcName] of rpcMapEntries) {
1121
+ lines.push(` ${nodeId}: '${rpcName}',`)
1122
+ }
1123
+ lines.push(` },`)
1124
+ } else {
1125
+ lines.push(` nodes: {},`)
1126
+ }
1127
+
1128
+ // Generate wires with api entry point
1129
+ if (entryNode) {
1130
+ lines.push(` wires: {`)
1131
+ lines.push(` api: '${entryNode}',`)
1132
+ lines.push(` },`)
1133
+ }
1134
+
1135
+ // Generate config (node configurations)
1136
+ if (nodeConfigs.length > 0) {
1137
+ lines.push(` config: {`)
1138
+ lines.push(nodeConfigs.join(',\n'))
1139
+ lines.push(` },`)
1140
+ }
1141
+
1142
+ lines.push(`})`)
1143
+ lines.push('')
1144
+
1145
+ // Always generate wireWorkflow to register the graph workflow
1146
+ // (needed for testing even without explicit wires)
1147
+ if (workflow.wires && Object.keys(workflow.wires).length > 0) {
1148
+ lines.push(`wireWorkflow({`)
1149
+ lines.push(` wires: ${wiresToCode(workflow.wires)},`)
1150
+ lines.push(` graph: ${workflow.name},`)
1151
+ lines.push(`})`)
1152
+ } else {
1153
+ lines.push(`wireWorkflow({`)
1154
+ lines.push(` graph: ${workflow.name},`)
1155
+ lines.push(`})`)
1156
+ }
1157
+ lines.push('')
1158
+
1159
+ return lines.join('\n')
1160
+ }
1161
+
1162
+ /**
1163
+ * Deserialize all workflows from JSON to DSL code
1164
+ */
1165
+ export function deserializeAllDslWorkflows(
1166
+ workflows: Record<string, SerializedWorkflowGraph>,
1167
+ options: DeserializeOptions = {}
1168
+ ): Record<string, string> {
1169
+ const result: Record<string, string> = {}
1170
+
1171
+ for (const [name, workflow] of Object.entries(workflows)) {
1172
+ if (workflow.source === 'dsl') {
1173
+ result[name] = deserializeDslWorkflow(workflow, options)
1174
+ } else if (workflow.source === 'graph') {
1175
+ result[name] = deserializeGraphWorkflow(workflow, options)
1176
+ }
1177
+ }
1178
+
1179
+ return result
1180
+ }