@pikku/inspector 0.12.74 → 0.12.81

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 (219) hide show
  1. package/CHANGELOG.md +347 -0
  2. package/dist/add/add-analytics.d.ts +9 -0
  3. package/dist/add/add-analytics.js +178 -0
  4. package/dist/add/add-auth.js +0 -54
  5. package/dist/add/add-feature-flag.d.ts +2 -0
  6. package/dist/add/add-feature-flag.js +162 -0
  7. package/dist/add/add-wire-addon.js +1 -0
  8. package/dist/add/add-wire-remote-addon.js +1 -0
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.js +3 -1
  11. package/dist/inspector.js +4 -0
  12. package/dist/surface/collect-surface.d.ts +58 -0
  13. package/dist/surface/collect-surface.js +613 -0
  14. package/dist/types.d.ts +20 -11
  15. package/dist/utils/filter-inspector-state.js +23 -13
  16. package/dist/utils/post-process.js +15 -0
  17. package/dist/utils/read-module-specifiers.d.ts +8 -0
  18. package/dist/utils/read-module-specifiers.js +23 -0
  19. package/dist/utils/read-tsconfig-out-dir.d.ts +5 -0
  20. package/dist/utils/read-tsconfig-out-dir.js +12 -0
  21. package/dist/utils/resolve-deploy-target.d.ts +7 -0
  22. package/dist/utils/resolve-deploy-target.js +15 -7
  23. package/dist/utils/serialize-inspector-state.d.ts +5 -0
  24. package/dist/utils/serialize-inspector-state.js +8 -0
  25. package/dist/visit.js +4 -0
  26. package/package.json +16 -6
  27. package/run-tests.sh +21 -6
  28. package/src/add/add-addon-bans.ts +0 -85
  29. package/src/add/add-agent-scorer.test.ts +0 -197
  30. package/src/add/add-agent-scorer.ts +0 -83
  31. package/src/add/add-agent.test.ts +0 -87
  32. package/src/add/add-agent.ts +0 -638
  33. package/src/add/add-approval-description.ts +0 -76
  34. package/src/add/add-auth.test.ts +0 -791
  35. package/src/add/add-auth.ts +0 -424
  36. package/src/add/add-channel.ts +0 -778
  37. package/src/add/add-cli-renderers.test.ts +0 -74
  38. package/src/add/add-cli.ts +0 -1006
  39. package/src/add/add-credential.ts +0 -186
  40. package/src/add/add-feature.test.ts +0 -306
  41. package/src/add/add-feature.ts +0 -253
  42. package/src/add/add-file-extends-core-type.ts +0 -66
  43. package/src/add/add-file-with-config.ts +0 -45
  44. package/src/add/add-file-with-factory.test.ts +0 -134
  45. package/src/add/add-file-with-factory.ts +0 -288
  46. package/src/add/add-functions.test.ts +0 -743
  47. package/src/add/add-functions.ts +0 -1548
  48. package/src/add/add-gateway.ts +0 -143
  49. package/src/add/add-http-route-ref.test.ts +0 -149
  50. package/src/add/add-http-route.ts +0 -530
  51. package/src/add/add-http-routes.ts +0 -471
  52. package/src/add/add-keyed-wiring.ts +0 -180
  53. package/src/add/add-mcp-prompt.ts +0 -157
  54. package/src/add/add-mcp-resource.ts +0 -175
  55. package/src/add/add-middleware.test.ts +0 -196
  56. package/src/add/add-middleware.ts +0 -879
  57. package/src/add/add-permission-auth.test.ts +0 -59
  58. package/src/add/add-permission.ts +0 -311
  59. package/src/add/add-personas.test.ts +0 -406
  60. package/src/add/add-personas.ts +0 -287
  61. package/src/add/add-queue-worker.ts +0 -117
  62. package/src/add/add-rpc-invocations.test.ts +0 -287
  63. package/src/add/add-rpc-invocations.ts +0 -221
  64. package/src/add/add-scenario-step.test.ts +0 -509
  65. package/src/add/add-schedule.ts +0 -120
  66. package/src/add/add-scope.test.ts +0 -419
  67. package/src/add/add-scope.ts +0 -221
  68. package/src/add/add-secret.ts +0 -10
  69. package/src/add/add-subject-scenario-steps.test.ts +0 -161
  70. package/src/add/add-system-role.test.ts +0 -297
  71. package/src/add/add-system-role.ts +0 -162
  72. package/src/add/add-trigger.ts +0 -165
  73. package/src/add/add-variable.ts +0 -10
  74. package/src/add/add-wire-addon.test.ts +0 -185
  75. package/src/add/add-wire-addon.ts +0 -146
  76. package/src/add/add-wire-remote-addon.ts +0 -80
  77. package/src/add/add-workflow-edge-cases.test.ts +0 -201
  78. package/src/add/add-workflow-fanout.test.ts +0 -166
  79. package/src/add/add-workflow-graph-foreach.test.ts +0 -174
  80. package/src/add/add-workflow-graph-input.test.ts +0 -94
  81. package/src/add/add-workflow-graph-notes.test.ts +0 -74
  82. package/src/add/add-workflow-graph.ts +0 -644
  83. package/src/add/add-workflow-on-error.test.ts +0 -118
  84. package/src/add/add-workflow-return-switch.test.ts +0 -121
  85. package/src/add/add-workflow-silent-drops.test.ts +0 -261
  86. package/src/add/add-workflow-sleep-expression.test.ts +0 -96
  87. package/src/add/add-workflow.test.ts +0 -155
  88. package/src/add/add-workflow.ts +0 -482
  89. package/src/add/addon-bans.test.ts +0 -121
  90. package/src/add/addon-contracts.test.ts +0 -221
  91. package/src/add/allow-escape-hatches.test.ts +0 -144
  92. package/src/add/audit-meta.test.ts +0 -75
  93. package/src/add/classified-sink-check.test.ts +0 -347
  94. package/src/add/config-value-metadata.test.ts +0 -263
  95. package/src/add/dynamic-import-check.test.ts +0 -119
  96. package/src/add/expect-eventually-scenario-only.test.ts +0 -108
  97. package/src/add/function-body-span.test.ts +0 -96
  98. package/src/add/inline-wiring-function.test.ts +0 -104
  99. package/src/add/pii-check.test.ts +0 -258
  100. package/src/add/rpc-type-cast.test.ts +0 -123
  101. package/src/add/scenario-instrumentation.test.ts +0 -89
  102. package/src/add/scenario-instrumentation.ts +0 -42
  103. package/src/add/scenario-marker.test.ts +0 -89
  104. package/src/add/scenario-not-extractable.test.ts +0 -159
  105. package/src/add/scenario-services.test.ts +0 -103
  106. package/src/add/scenario-skip.test.ts +0 -83
  107. package/src/add/validate-workflow-graph-addons.test.ts +0 -102
  108. package/src/add/wire-name-literal.test.ts +0 -117
  109. package/src/error-codes-emitted.test.ts +0 -76
  110. package/src/error-codes.ts +0 -145
  111. package/src/index.ts +0 -66
  112. package/src/inspector.ts +0 -575
  113. package/src/schema-generator.ts +0 -1
  114. package/src/source-file-cache.ts +0 -73
  115. package/src/tsconfig-paths.test.ts +0 -90
  116. package/src/types-map.ts +0 -141
  117. package/src/types.ts +0 -775
  118. package/src/utils/annotate-http-route-auth.test.ts +0 -161
  119. package/src/utils/annotate-http-route-auth.ts +0 -53
  120. package/src/utils/check-classified-sinks.ts +0 -215
  121. package/src/utils/check-pii-output.ts +0 -120
  122. package/src/utils/compute-diagnostics.test.ts +0 -106
  123. package/src/utils/compute-required-schemas.ts +0 -48
  124. package/src/utils/contract-hashes.test.ts +0 -553
  125. package/src/utils/contract-hashes.ts +0 -387
  126. package/src/utils/custom-types-generator.test.ts +0 -124
  127. package/src/utils/custom-types-generator.ts +0 -150
  128. package/src/utils/detect-schema-vendor.test.ts +0 -123
  129. package/src/utils/detect-schema-vendor.ts +0 -120
  130. package/src/utils/does-type-extend-core-type.ts +0 -53
  131. package/src/utils/ensure-function-metadata.ts +0 -385
  132. package/src/utils/extract-function-name.ts +0 -612
  133. package/src/utils/extract-function-node.ts +0 -70
  134. package/src/utils/extract-node-value.test.ts +0 -117
  135. package/src/utils/extract-node-value.ts +0 -172
  136. package/src/utils/extract-secret-usage.test.ts +0 -81
  137. package/src/utils/extract-secret-usage.ts +0 -73
  138. package/src/utils/extract-services.ts +0 -70
  139. package/src/utils/extract-surface-usage.test.ts +0 -147
  140. package/src/utils/extract-surface-usage.ts +0 -121
  141. package/src/utils/filter-inspector-state.test.ts +0 -2207
  142. package/src/utils/filter-inspector-state.ts +0 -1302
  143. package/src/utils/filter-utils.test.ts +0 -843
  144. package/src/utils/filter-utils.ts +0 -161
  145. package/src/utils/find-root-dir.ts +0 -68
  146. package/src/utils/find-zod-transform.test.ts +0 -70
  147. package/src/utils/find-zod-transform.ts +0 -103
  148. package/src/utils/get-exported-variable-name.ts +0 -48
  149. package/src/utils/get-files-and-methods.ts +0 -157
  150. package/src/utils/get-property-value.test.ts +0 -141
  151. package/src/utils/get-property-value.ts +0 -331
  152. package/src/utils/hash.ts +0 -26
  153. package/src/utils/literal-properties.ts +0 -97
  154. package/src/utils/load-addon-functions-meta.test.ts +0 -157
  155. package/src/utils/load-addon-functions-meta.ts +0 -508
  156. package/src/utils/load-addon-scopes.test.ts +0 -138
  157. package/src/utils/middleware.test.ts +0 -204
  158. package/src/utils/middleware.ts +0 -303
  159. package/src/utils/nested-project-filter.test.ts +0 -53
  160. package/src/utils/nested-project-filter.ts +0 -28
  161. package/src/utils/permissions-shorthand.test.ts +0 -79
  162. package/src/utils/permissions.test.ts +0 -125
  163. package/src/utils/permissions.ts +0 -129
  164. package/src/utils/post-process.test.ts +0 -1094
  165. package/src/utils/post-process.ts +0 -1604
  166. package/src/utils/resolve-addon-package.ts +0 -106
  167. package/src/utils/resolve-core-type.test.ts +0 -142
  168. package/src/utils/resolve-core-type.ts +0 -35
  169. package/src/utils/resolve-deploy-target.test.ts +0 -135
  170. package/src/utils/resolve-deploy-target.ts +0 -65
  171. package/src/utils/resolve-function-meta.ts +0 -25
  172. package/src/utils/resolve-function-types.ts +0 -42
  173. package/src/utils/resolve-identifier.ts +0 -46
  174. package/src/utils/resolve-ref-contract.ts +0 -71
  175. package/src/utils/resolve-versions.test.ts +0 -499
  176. package/src/utils/resolve-versions.ts +0 -228
  177. package/src/utils/schema-cache.test.ts +0 -124
  178. package/src/utils/schema-generator.test.ts +0 -272
  179. package/src/utils/schema-generator.ts +0 -780
  180. package/src/utils/secret-alias-services.test.ts +0 -146
  181. package/src/utils/secret-alias-services.ts +0 -58
  182. package/src/utils/serialize-inspector-state.ts +0 -742
  183. package/src/utils/serialize-mcp-json.ts +0 -150
  184. package/src/utils/serialize-middleware-groups-meta.ts +0 -36
  185. package/src/utils/serialize-openapi-json.ts +0 -277
  186. package/src/utils/serialize-permissions-groups-meta.ts +0 -7
  187. package/src/utils/single-declaration.ts +0 -57
  188. package/src/utils/type-utils.ts +0 -145
  189. package/src/utils/validate-auth-sessionless.ts +0 -30
  190. package/src/utils/validate-exposed-functions-gated.test.ts +0 -230
  191. package/src/utils/validate-exposed-functions-gated.ts +0 -103
  192. package/src/utils/validate-tags-resolve-to-middleware.test.ts +0 -108
  193. package/src/utils/validate-tags-resolve-to-middleware.ts +0 -48
  194. package/src/utils/workflow/derive-workflow-plan.test.ts +0 -178
  195. package/src/utils/workflow/derive-workflow-plan.ts +0 -117
  196. package/src/utils/workflow/dsl/deserialize-dsl-workflow.ts +0 -1465
  197. package/src/utils/workflow/dsl/deserialize-fanout-body.test.ts +0 -71
  198. package/src/utils/workflow/dsl/deserialize-fanout-fidelity.test.ts +0 -99
  199. package/src/utils/workflow/dsl/deserialize-graph-next.test.ts +0 -161
  200. package/src/utils/workflow/dsl/deserialize-roundtrip-bindings.test.ts +0 -121
  201. package/src/utils/workflow/dsl/deserialize-roundtrip-values.test.ts +0 -116
  202. package/src/utils/workflow/dsl/deserialize-roundtrip.test.ts +0 -145
  203. package/src/utils/workflow/dsl/extract-approval-policy.test.ts +0 -71
  204. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +0 -2254
  205. package/src/utils/workflow/dsl/index.ts +0 -11
  206. package/src/utils/workflow/dsl/patterns.ts +0 -440
  207. package/src/utils/workflow/dsl/validation.ts +0 -203
  208. package/src/utils/workflow/graph/convert-dsl-to-graph-skip.test.ts +0 -35
  209. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +0 -543
  210. package/src/utils/workflow/graph/fanout-node-identity.test.ts +0 -105
  211. package/src/utils/workflow/graph/finalize-workflow-wires.ts +0 -310
  212. package/src/utils/workflow/graph/finalize-workflows.test.ts +0 -50
  213. package/src/utils/workflow/graph/finalize-workflows.ts +0 -130
  214. package/src/utils/workflow/graph/index.ts +0 -28
  215. package/src/utils/workflow/graph/serialize-workflow-graph.ts +0 -240
  216. package/src/utils/workflow/graph/workflow-graph.types.ts +0 -281
  217. package/src/visit.ts +0 -181
  218. package/tsconfig.json +0 -19
  219. package/tsconfig.tsbuildinfo +0 -1
@@ -1,1465 +0,0 @@
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
- SerializedNext,
10
- DataRef,
11
- ContextVariable,
12
- } from '../graph/workflow-graph.types.js'
13
-
14
- interface DeserializeOptions {
15
- /** Import path for pikkuWorkflowFunc */
16
- pikkuImportPath?: string
17
- /** Whether to include type annotations */
18
- includeTypes?: boolean
19
- }
20
-
21
- /**
22
- * Check if a value is a DataRef
23
- */
24
- function isDataRef(value: unknown): value is DataRef {
25
- return (
26
- typeof value === 'object' &&
27
- value !== null &&
28
- '$ref' in value &&
29
- typeof (value as DataRef).$ref === 'string'
30
- )
31
- }
32
-
33
- /**
34
- * State reference (context variable)
35
- */
36
- interface StateRef {
37
- $state: string
38
- path?: string
39
- }
40
-
41
- /**
42
- * Check if a value is a StateRef
43
- */
44
- function isStateRef(value: unknown): value is StateRef {
45
- return (
46
- typeof value === 'object' &&
47
- value !== null &&
48
- '$state' in value &&
49
- typeof (value as StateRef).$state === 'string'
50
- )
51
- }
52
-
53
- /**
54
- * Check if value is a template literal reference
55
- */
56
- interface TemplateRef {
57
- $template: {
58
- parts: string[]
59
- expressions: unknown[]
60
- }
61
- }
62
-
63
- function isTemplateRef(value: unknown): value is TemplateRef {
64
- return (
65
- typeof value === 'object' &&
66
- value !== null &&
67
- '$template' in value &&
68
- typeof (value as TemplateRef).$template === 'object'
69
- )
70
- }
71
-
72
- /**
73
- * Convert a DataRef to code expression
74
- */
75
- function dataRefToCode(ref: DataRef, itemVar?: string): string {
76
- if (ref.$ref === 'trigger') {
77
- // Reference to trigger input (data)
78
- return ref.path ? `data.${ref.path}` : 'data'
79
- }
80
- if (ref.$ref === '$item') {
81
- // Reference to the current loop item
82
- // The path contains the variable name in this case
83
- return ref.path || itemVar || 'item'
84
- }
85
- // Reference to a step output variable
86
- return ref.path ? `${ref.$ref}.${ref.path}` : ref.$ref
87
- }
88
-
89
- /**
90
- * Convert a template ref to template literal code
91
- */
92
- function templateRefToCode(template: TemplateRef, itemVar?: string): string {
93
- const { parts, expressions } = template.$template
94
- let result = '`'
95
-
96
- for (let i = 0; i < parts.length; i++) {
97
- result += parts[i]
98
- if (i < expressions.length) {
99
- const expr = expressions[i]
100
- let exprCode: string
101
- if (isDataRef(expr)) {
102
- exprCode = dataRefToCode(expr, itemVar)
103
- } else if (isTemplateRef(expr)) {
104
- // Nested template (unlikely but handle it)
105
- exprCode = templateRefToCode(expr, itemVar)
106
- } else {
107
- // Literal value
108
- exprCode = String(expr)
109
- }
110
- result += '${' + exprCode + '}'
111
- }
112
- }
113
-
114
- result += '`'
115
- return result
116
- }
117
-
118
- /**
119
- * Convert a StateRef to code expression
120
- */
121
- function stateRefToCode(ref: StateRef): string {
122
- return ref.path ? `${ref.$state}.${ref.path}` : ref.$state
123
- }
124
-
125
- /**
126
- * Convert a single value to code (handles refs, templates, state refs, and literals)
127
- */
128
- function valueToCode(value: unknown, itemVar?: string): string {
129
- if (isDataRef(value)) {
130
- return dataRefToCode(value, itemVar)
131
- }
132
- if (isStateRef(value)) {
133
- return stateRefToCode(value)
134
- }
135
- if (isTemplateRef(value)) {
136
- return templateRefToCode(value, itemVar)
137
- }
138
- if (Array.isArray(value)) {
139
- const elements = value.map((v) => valueToCode(v, itemVar))
140
- return `[${elements.join(', ')}]`
141
- }
142
- if (typeof value === 'object' && value !== null) {
143
- const entries = Object.entries(value)
144
- const props = entries.map(([k, v]) => `${k}: ${valueToCode(v, itemVar)}`)
145
- return `{ ${props.join(', ')} }`
146
- }
147
- return JSON.stringify(value)
148
- }
149
-
150
- /**
151
- * Check if input represents passthrough (entire data object)
152
- */
153
- function isPassthrough(input: Record<string, unknown>): boolean {
154
- if (Object.keys(input).length === 1 && '$passthrough' in input) {
155
- const passthrough = input.$passthrough
156
- return isDataRef(passthrough) && passthrough.$ref === 'trigger'
157
- }
158
- return false
159
- }
160
-
161
- /**
162
- * Convert input object to code
163
- */
164
- function inputToCode(
165
- input: Record<string, unknown>,
166
- indent: string,
167
- itemVar?: string
168
- ): string {
169
- // Check if this is a passthrough (entire data object)
170
- if (isPassthrough(input)) {
171
- return 'data'
172
- }
173
-
174
- const entries = Object.entries(input)
175
- if (entries.length === 0) return '{}'
176
-
177
- const lines = entries.map(([key, value]) => {
178
- return `${indent} ${key}: ${valueToCode(value, itemVar)},`
179
- })
180
-
181
- return `{\n${lines.join('\n')}\n${indent}}`
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
-
347
- /**
348
- * Convert options to code
349
- */
350
- function optionsToCode(
351
- options: Record<string, unknown>,
352
- onErrorRpcName?: string
353
- ): string {
354
- const parts: string[] = []
355
- if (onErrorRpcName) {
356
- parts.push(`onError: '${escapeSingleQuotes(onErrorRpcName)}'`)
357
- }
358
- if (options.retries !== undefined) {
359
- parts.push(`retries: ${options.retries}`)
360
- }
361
- if (options.retryDelay !== undefined) {
362
- parts.push(`retryDelay: ${durationToCode(options.retryDelay)}`)
363
- }
364
- return parts.length > 0 ? `{ ${parts.join(', ')} }` : ''
365
- }
366
-
367
- /**
368
- * Convert a simple condition to code expression
369
- */
370
- function conditionToCode(condition: any): string {
371
- if (!condition) return 'true'
372
-
373
- if (condition.type === 'simple') {
374
- return condition.expression
375
- }
376
-
377
- if (condition.type === 'and') {
378
- const parts = condition.conditions.map(conditionToCode)
379
- return parts.length > 1 ? `(${parts.join(' && ')})` : parts[0]
380
- }
381
-
382
- if (condition.type === 'or') {
383
- const parts = condition.conditions.map(conditionToCode)
384
- return parts.length > 1 ? `(${parts.join(' || ')})` : parts[0]
385
- }
386
-
387
- return 'true'
388
- }
389
-
390
- /**
391
- * Traverse nodes in execution order starting from entry
392
- */
393
- function traverseNodes(
394
- nodes: Record<string, SerializedGraphNode>,
395
- entryNodeIds: string[]
396
- ): SerializedGraphNode[] {
397
- const result: SerializedGraphNode[] = []
398
- const visited = new Set<string>()
399
-
400
- function visit(nodeId: string) {
401
- if (visited.has(nodeId)) return
402
- visited.add(nodeId)
403
-
404
- const node = nodes[nodeId]
405
- if (!node) return
406
-
407
- result.push(node)
408
-
409
- // Follow next pointer
410
- if ('next' in node && node.next) {
411
- if (typeof node.next === 'string') {
412
- visit(node.next)
413
- }
414
- }
415
- }
416
-
417
- for (const entryId of entryNodeIds) {
418
- visit(entryId)
419
- }
420
-
421
- return result
422
- }
423
-
424
- /**
425
- * Collect conditional variables that need to be declared before a branch
426
- */
427
- function collectBranchConditionalVars(
428
- branchNode: any,
429
- nodes: Record<string, SerializedGraphNode>,
430
- conditionalVars: Set<string>
431
- ): string[] {
432
- const vars: string[] = []
433
-
434
- // Check all branches (if/else-if chain)
435
- if (branchNode.branches) {
436
- for (const branch of branchNode.branches) {
437
- if (branch.entry) {
438
- collectVarsFromBranch(
439
- branch.entry,
440
- nodes,
441
- conditionalVars,
442
- vars,
443
- branchNode.next as string | undefined
444
- )
445
- }
446
- }
447
- }
448
-
449
- // Check else branch
450
- if (branchNode.elseEntry) {
451
- collectVarsFromBranch(
452
- branchNode.elseEntry,
453
- nodes,
454
- conditionalVars,
455
- vars,
456
- branchNode.next as string | undefined
457
- )
458
- }
459
-
460
- return vars
461
- }
462
-
463
- /**
464
- * Recursively collect output variables from a branch that are in conditionalVars
465
- */
466
- function collectVarsFromBranch(
467
- nodeId: string,
468
- nodes: Record<string, SerializedGraphNode>,
469
- conditionalVars: Set<string>,
470
- result: string[],
471
- exitNodeId?: string,
472
- seen: Set<string> = new Set()
473
- ): void {
474
- if (seen.has(nodeId)) return
475
- seen.add(nodeId)
476
- const node = nodes[nodeId]
477
- if (!node) return
478
-
479
- // Check if this node has an outputVar that's conditional
480
- if ('outputVar' in node && node.outputVar) {
481
- const varName = node.outputVar as string
482
- if (conditionalVars.has(varName) && !result.includes(varName)) {
483
- result.push(varName)
484
- }
485
- }
486
-
487
- // Follow the chain of nodes within the branch
488
- if ('next' in node && node.next) {
489
- const nextId = node.next as string
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
- )
500
- }
501
- }
502
- }
503
-
504
- /**
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.
509
- */
510
- function isWithinBody(
511
- nodeId: string,
512
- exitNodeId: string | undefined,
513
- seen: Set<string>
514
- ): boolean {
515
- return nodeId !== exitNodeId && !seen.has(nodeId)
516
- }
517
-
518
- /**
519
- * Generate code for branch content (then/else blocks)
520
- */
521
- function generateBranchContent(
522
- entryNodeId: string,
523
- nodes: Record<string, SerializedGraphNode>,
524
- indent: string,
525
- conditionalVars: Set<string>,
526
- exitNodeId?: string
527
- ): string[] {
528
- const lines: string[] = []
529
- const seen = new Set<string>()
530
- let currentId: string | undefined = entryNodeId
531
-
532
- while (currentId) {
533
- const node = nodes[currentId]
534
- if (!node) break
535
- seen.add(currentId)
536
-
537
- const nodeLines = nodeToCode(node, nodes, indent, conditionalVars, true)
538
- lines.push(...nodeLines)
539
-
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)) {
544
- currentId = nextId
545
- } else {
546
- break
547
- }
548
- } else {
549
- break
550
- }
551
- }
552
-
553
- return lines
554
- }
555
-
556
- /**
557
- * Generate DSL code for a single node
558
- */
559
- function nodeToCode(
560
- node: SerializedGraphNode,
561
- nodes: Record<string, SerializedGraphNode>,
562
- indent: string,
563
- conditionalVars: Set<string> = new Set(),
564
- isInsideBranch: boolean = false
565
- ): string[] {
566
- const lines: string[] = []
567
-
568
- // Handle RPC nodes (function calls)
569
- if ('rpcName' in node && node.rpcName && node.rpcName !== 'unknown') {
570
- const stepName = node.stepName || `Call ${node.rpcName}`
571
- const input = (node.input || {}) as Record<string, unknown>
572
- const inputCode = inputToCode(input, indent)
573
- const outputVar = (node as any).outputVar
574
-
575
- let doCall = `await workflow.do('${stepName}', '${node.rpcName}', ${inputCode}`
576
-
577
- // Add options if present
578
- const onErrorRpc = onErrorRpcNameOf(node, nodes)
579
- if ((node as any).options || onErrorRpc) {
580
- const optCode = optionsToCode((node as any).options ?? {}, onErrorRpc)
581
- if (optCode) {
582
- doCall += `, ${optCode}`
583
- }
584
- }
585
- doCall += ')'
586
-
587
- if (outputVar) {
588
- // If this is a conditional var inside a branch, use assignment (let was declared above)
589
- if (isInsideBranch && conditionalVars.has(outputVar)) {
590
- lines.push(`${indent}${outputVar} = ${doCall}`)
591
- } else {
592
- lines.push(`${indent}const ${outputVar} = ${doCall}`)
593
- }
594
- } else {
595
- lines.push(`${indent}${doCall}`)
596
- }
597
- lines.push('')
598
- return lines
599
- }
600
-
601
- // Handle flow nodes
602
- if ('flow' in node) {
603
- const flowNode = node as any
604
-
605
- switch (flowNode.flow) {
606
- case 'sleep':
607
- lines.push(
608
- `${indent}await workflow.sleep('${escapeSingleQuotes(flowNode.stepName || 'Sleep')}', ${
609
- typeof flowNode.expression === 'string'
610
- ? flowNode.expression
611
- : durationToCode(flowNode.duration)
612
- })`
613
- )
614
- lines.push('')
615
- break
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
-
635
- case 'cancel':
636
- const cancelReason =
637
- flowNode.reason || flowNode.stepName || 'Workflow cancelled'
638
- lines.push(
639
- `${indent}throw new WorkflowCancelledException('${cancelReason}')`
640
- )
641
- lines.push('')
642
- break
643
-
644
- case 'branch':
645
- // Declare conditional variables before the if statement
646
- const branchConditionalVars = collectBranchConditionalVars(
647
- flowNode,
648
- nodes,
649
- conditionalVars
650
- )
651
- for (const varName of branchConditionalVars) {
652
- lines.push(`${indent}let ${varName}`)
653
- }
654
-
655
- // Generate if/else-if/else chain
656
- const branches = flowNode.branches || []
657
- for (let i = 0; i < branches.length; i++) {
658
- const branch = branches[i]
659
- const condition = conditionToCode(branch.condition)
660
- const keyword = i === 0 ? 'if' : 'else if'
661
- lines.push(`${indent}${keyword} (${condition}) {`)
662
-
663
- if (branch.entry && nodes[branch.entry]) {
664
- const branchLines = generateBranchContent(
665
- branch.entry,
666
- nodes,
667
- indent + ' ',
668
- conditionalVars,
669
- flowNode.next as string | undefined
670
- )
671
- lines.push(...branchLines)
672
- }
673
- lines.push(`${indent}}`)
674
- }
675
-
676
- // Generate else block if present
677
- if (flowNode.elseEntry && nodes[flowNode.elseEntry]) {
678
- lines.push(`${indent}else {`)
679
- const elseLines = generateBranchContent(
680
- flowNode.elseEntry,
681
- nodes,
682
- indent + ' ',
683
- conditionalVars,
684
- flowNode.next as string | undefined
685
- )
686
- lines.push(...elseLines)
687
- lines.push(`${indent}}`)
688
- }
689
- lines.push('')
690
- break
691
-
692
- case 'switch': {
693
- const caseExit = flowNode.next as string | undefined
694
- lines.push(`${indent}switch (${flowNode.expression}) {`)
695
- for (const caseItem of flowNode.cases || []) {
696
- lines.push(`${indent} case ${caseValueToCode(caseItem.value)}:`)
697
- if (caseItem.entry && nodes[caseItem.entry]) {
698
- lines.push(
699
- ...generateBranchContent(
700
- caseItem.entry,
701
- nodes,
702
- indent + ' ',
703
- conditionalVars,
704
- caseExit
705
- )
706
- )
707
- }
708
- lines.push(`${indent} break`)
709
- }
710
- if (flowNode.defaultEntry && nodes[flowNode.defaultEntry]) {
711
- lines.push(`${indent} default:`)
712
- lines.push(
713
- ...generateBranchContent(
714
- flowNode.defaultEntry,
715
- nodes,
716
- indent + ' ',
717
- conditionalVars,
718
- caseExit
719
- )
720
- )
721
- lines.push(`${indent} break`)
722
- }
723
- lines.push(`${indent}}`)
724
- lines.push('')
725
- break
726
- }
727
-
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}`
755
- }
756
- }
757
- call += ')'
758
- lines.push(`${indent} ${call},`)
759
- }
760
- lines.push(`${indent}])`)
761
- lines.push('')
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
- )
772
-
773
- if (flowNode.mode === 'parallel') {
774
- lines.push(`${indent}await Promise.all(`)
775
- lines.push(
776
- `${indent} ${flowNode.sourceVar}.map(async (${flowNode.itemVar}) => {`
777
- )
778
- lines.push(...bodyLines)
779
- lines.push(`${indent} })`)
780
- lines.push(`${indent})`)
781
- } else {
782
- // Sequential fanout
783
- lines.push(
784
- `${indent}for (const ${flowNode.itemVar} of ${flowNode.sourceVar}) {`
785
- )
786
- lines.push(...bodyLines)
787
- if (flowNode.timeBetween) {
788
- lines.push(
789
- `${indent} await workflow.sleep('Wait between iterations', '${flowNode.timeBetween}')`
790
- )
791
- }
792
- lines.push(`${indent}}`)
793
- }
794
- lines.push('')
795
- break
796
- }
797
-
798
- case 'filter':
799
- lines.push(
800
- `${indent}${bindingPrefix(flowNode.outputVar)}${flowNode.sourceVar}.filter((${flowNode.itemVar}) => ${conditionToCode(flowNode.condition)})`
801
- )
802
- lines.push('')
803
- break
804
-
805
- case 'arrayPredicate':
806
- const method = flowNode.mode === 'some' ? 'some' : 'every'
807
- lines.push(
808
- `${indent}${bindingPrefix(flowNode.outputVar)}${flowNode.sourceVar}.${method}((${flowNode.itemVar}) => ${conditionToCode(flowNode.condition)})`
809
- )
810
- lines.push('')
811
- break
812
-
813
- case 'return':
814
- if (flowNode.outputs || flowNode.spread) {
815
- const returnObj: string[] = []
816
- for (const spreadVar of (flowNode.spread as string[]) ?? []) {
817
- returnObj.push(`${indent} ...${spreadVar},`)
818
- }
819
- for (const [key, output] of Object.entries(
820
- (flowNode.outputs ?? {}) as Record<string, any>
821
- )) {
822
- let value: string
823
- if (output.from === 'outputVar') {
824
- value = output.path
825
- ? `${output.name}?.${output.path}`
826
- : output.name
827
- } else if (output.from === 'stateVar') {
828
- value = output.path
829
- ? `${output.name}.${output.path}`
830
- : output.name
831
- } else if (output.from === 'input') {
832
- value = `data.${output.path}`
833
- } else if (output.from === 'literal') {
834
- value = JSON.stringify(output.value)
835
- } else if (output.from === 'expression') {
836
- value = output.expression
837
- } else {
838
- continue
839
- }
840
- returnObj.push(`${indent} ${key}: ${value},`)
841
- }
842
- if (returnObj.length > 0) {
843
- lines.push(`${indent}return {`)
844
- lines.push(...returnObj)
845
- lines.push(`${indent}}`)
846
- }
847
- }
848
- break
849
-
850
- case 'inline':
851
- lines.push(
852
- `${indent}// Inline step: ${flowNode.stepName || 'Dynamic code'}`
853
- )
854
- lines.push(`${indent}// ${flowNode.description || '<dynamic code>'}`)
855
- lines.push('')
856
- break
857
-
858
- case 'set':
859
- // Generate variable assignment: varName = value
860
- const setVar = flowNode.variable
861
- const setValue =
862
- typeof flowNode.expression === 'string'
863
- ? flowNode.expression
864
- : typeof flowNode.value === 'string'
865
- ? `'${escapeSingleQuotes(flowNode.value)}'`
866
- : JSON.stringify(flowNode.value)
867
- lines.push(`${indent}${setVar} = ${setValue}`)
868
- lines.push('')
869
- break
870
- }
871
- }
872
-
873
- return lines
874
- }
875
-
876
- /**
877
- * Find variables that are defined inside branches but used in return statements
878
- * These need to be hoisted with `let` declarations
879
- */
880
- function findConditionalVars(
881
- nodes: Record<string, SerializedGraphNode>
882
- ): Set<string> {
883
- const conditionalVars = new Set<string>()
884
- const varsInBranches = new Set<string>()
885
- const varsUsedInReturn = new Set<string>()
886
-
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
923
- }
924
- }
925
- }
926
-
927
- // Collect variables used in return statements
928
- for (const node of Object.values(nodes)) {
929
- if ('flow' in node && node.flow === 'return' && node.outputs) {
930
- for (const output of Object.values(
931
- node.outputs as Record<string, { from: string; name?: string }>
932
- )) {
933
- if (output.from === 'outputVar' && output.name) {
934
- varsUsedInReturn.add(output.name)
935
- }
936
- }
937
- }
938
- }
939
-
940
- // Variables that are both in branches and used in return need hoisting
941
- for (const varName of varsInBranches) {
942
- if (varsUsedInReturn.has(varName)) {
943
- conditionalVars.add(varName)
944
- }
945
- }
946
-
947
- return conditionalVars
948
- }
949
-
950
- /**
951
- * Get default value for a context variable type
952
- */
953
- function getDefaultForType(type: string): string {
954
- switch (type) {
955
- case 'string':
956
- return "''"
957
- case 'number':
958
- return '0'
959
- case 'boolean':
960
- return 'false'
961
- case 'array':
962
- return '[]'
963
- case 'object':
964
- return '{}'
965
- default:
966
- return 'undefined'
967
- }
968
- }
969
-
970
- /**
971
- * Deserialize a workflow graph to DSL code
972
- */
973
- export function deserializeDslWorkflow(
974
- workflow: SerializedWorkflowGraph,
975
- options: DeserializeOptions = {}
976
- ): string {
977
- const { pikkuImportPath = '../.pikku/workflow/pikku-workflow-types.gen.js' } =
978
- options
979
-
980
- const lines: string[] = []
981
-
982
- // Check if workflow has any cancel nodes
983
- const hasCancelNode = Object.values(workflow.nodes).some(
984
- (node) => 'flow' in node && (node as any).flow === 'cancel'
985
- )
986
-
987
- // Find variables defined in branches that need hoisting
988
- const conditionalVars = findConditionalVars(workflow.nodes)
989
-
990
- // Import statement
991
- if (hasCancelNode) {
992
- lines.push(
993
- `import { pikkuWorkflowFunc, WorkflowCancelledException } from '${pikkuImportPath}'`
994
- )
995
- } else {
996
- lines.push(`import { pikkuWorkflowFunc } from '${pikkuImportPath}'`)
997
- }
998
- lines.push('')
999
-
1000
- // Add description as comment if present
1001
- if (workflow.description) {
1002
- lines.push(`/**`)
1003
- lines.push(` * ${workflow.description}`)
1004
- lines.push(` */`)
1005
- }
1006
-
1007
- // Function signature
1008
- const tagsComment = workflow.tags?.length
1009
- ? ` // tags: ${workflow.tags.join(', ')}`
1010
- : ''
1011
-
1012
- lines.push(
1013
- `export const ${workflow.name} = pikkuWorkflowFunc(async ({}, data, { workflow }) => {${tagsComment}`
1014
- )
1015
-
1016
- // Generate context variable declarations at the top
1017
- if (workflow.context && Object.keys(workflow.context).length > 0) {
1018
- for (const [varName, varDef] of Object.entries(workflow.context) as [
1019
- string,
1020
- ContextVariable,
1021
- ][]) {
1022
- const defaultValue =
1023
- varDef.default !== undefined
1024
- ? typeof varDef.default === 'string'
1025
- ? `'${varDef.default}'`
1026
- : JSON.stringify(varDef.default)
1027
- : getDefaultForType(varDef.type)
1028
- lines.push(` let ${varName} = ${defaultValue}`)
1029
- }
1030
- lines.push('')
1031
- }
1032
-
1033
- // Process nodes in order
1034
- const orderedNodes = traverseNodes(workflow.nodes, workflow.entryNodeIds)
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
-
1042
- for (const node of orderedNodes) {
1043
- if (ownedByParent.has(node.nodeId)) {
1044
- continue
1045
- }
1046
-
1047
- const nodeLines = nodeToCode(node, workflow.nodes, ' ', conditionalVars)
1048
- lines.push(...nodeLines)
1049
- }
1050
-
1051
- lines.push('})')
1052
- lines.push('')
1053
-
1054
- return lines.join('\n')
1055
- }
1056
-
1057
- /**
1058
- * Convert a DataRef to graph ref() call
1059
- * @param ref - The data reference
1060
- * @param outputVarToNodeId - Map from outputVar names to node IDs
1061
- */
1062
- function dataRefToGraphRef(
1063
- ref: DataRef,
1064
- outputVarToNodeId: Map<string, string>
1065
- ): string {
1066
- // Convert outputVar reference to nodeId reference
1067
- const nodeId = outputVarToNodeId.get(ref.$ref) || ref.$ref
1068
- if (ref.path) {
1069
- return `ref('${nodeId}', '${ref.path}')`
1070
- }
1071
- return `ref('${nodeId}')`
1072
- }
1073
-
1074
- /**
1075
- * Convert a template ref to template() function call for graph code
1076
- * e.g. {$template: {parts: ["Hello ", ""], expressions: [{$ref: "trigger", path: "name"}]}}
1077
- * becomes: template('Hello $0', [ref('trigger', 'name')])
1078
- */
1079
- function templateRefToGraphCode(
1080
- tmpl: TemplateRef,
1081
- outputVarToNodeId: Map<string, string>
1082
- ): string {
1083
- const { parts, expressions } = tmpl.$template
1084
-
1085
- // Build the template string with $0, $1, etc. placeholders
1086
- let templateStr = ''
1087
- for (let i = 0; i < parts.length; i++) {
1088
- templateStr += parts[i]
1089
- if (i < expressions.length) {
1090
- templateStr += `$${i}`
1091
- }
1092
- }
1093
-
1094
- // Build the refs array
1095
- const refs: string[] = []
1096
- for (const expr of expressions) {
1097
- if (isDataRef(expr)) {
1098
- refs.push(dataRefToGraphRef(expr, outputVarToNodeId))
1099
- } else {
1100
- // Literal JS expression - can't be represented as a typed ref
1101
- refs.push(`{ $ref: '${String(expr).replace(/'/g, "\\'")}' } as any`)
1102
- }
1103
- }
1104
-
1105
- // Escape single quotes and newlines in the template string
1106
- templateStr = templateStr
1107
- .replace(/\\/g, '\\\\')
1108
- .replace(/'/g, "\\'")
1109
- .replace(/\n/g, '\\n')
1110
- .replace(/\r/g, '\\r')
1111
-
1112
- return `template('${templateStr}', [${refs.join(', ')}])`
1113
- }
1114
-
1115
- function valueToGraphCode(
1116
- value: unknown,
1117
- outputVarToNodeId: Map<string, string>,
1118
- refTracker: { hasRefs: boolean }
1119
- ): string {
1120
- if (isDataRef(value)) {
1121
- refTracker.hasRefs = true
1122
- return dataRefToGraphRef(value, outputVarToNodeId)
1123
- }
1124
- if (isTemplateRef(value)) {
1125
- refTracker.hasRefs = true
1126
- return templateRefToGraphCode(value, outputVarToNodeId)
1127
- }
1128
- if (Array.isArray(value)) {
1129
- const elements = value.map((v) =>
1130
- valueToGraphCode(v, outputVarToNodeId, refTracker)
1131
- )
1132
- return `[${elements.join(', ')}]`
1133
- }
1134
- if (typeof value === 'object' && value !== null) {
1135
- const entries = Object.entries(value)
1136
- const props = entries.map(
1137
- ([k, v]) => `${k}: ${valueToGraphCode(v, outputVarToNodeId, refTracker)}`
1138
- )
1139
- return `{ ${props.join(', ')} }`
1140
- }
1141
- return JSON.stringify(value)
1142
- }
1143
-
1144
- /**
1145
- * Convert input object to graph input code using ref()
1146
- * @param input - The input mapping
1147
- * @param outputVarToNodeId - Map from outputVar names to node IDs
1148
- */
1149
- function inputToGraphCode(
1150
- input: Record<string, unknown>,
1151
- outputVarToNodeId: Map<string, string>
1152
- ): {
1153
- hasRefs: boolean
1154
- code: string
1155
- } {
1156
- const entries = Object.entries(input)
1157
- if (entries.length === 0) return { hasRefs: false, code: '{}' }
1158
-
1159
- const refTracker = { hasRefs: false }
1160
- const lines = entries.map(([key, value]) => {
1161
- return ` ${key}: ${valueToGraphCode(value, outputVarToNodeId, refTracker)},`
1162
- })
1163
-
1164
- return {
1165
- hasRefs: refTracker.hasRefs,
1166
- code: `{\n${lines.join('\n')}\n }`,
1167
- }
1168
- }
1169
-
1170
- /**
1171
- * Check if a node is a flow node (non-RPC control flow)
1172
- */
1173
- function isFlowNode(node: any): boolean {
1174
- return 'flow' in node
1175
- }
1176
-
1177
- /**
1178
- * Follow through flow nodes to find the next RPC node
1179
- * This traverses the 'next' chain, skipping flow nodes until finding an RPC node
1180
- */
1181
- function findNextRpcNode(
1182
- startNextId: string,
1183
- nodes: Record<string, SerializedGraphNode>,
1184
- flowNodeIds: Set<string>,
1185
- visited: Set<string> = new Set()
1186
- ): string | null {
1187
- if (visited.has(startNextId)) {
1188
- return null // Cycle detected, stop
1189
- }
1190
- visited.add(startNextId)
1191
-
1192
- // If it's not a flow node, we found our target
1193
- if (!flowNodeIds.has(startNextId)) {
1194
- // Make sure the node exists and has an rpcName (is an RPC node)
1195
- const node = nodes[startNextId]
1196
- if (node && 'rpcName' in node) {
1197
- return startNextId
1198
- }
1199
- return null
1200
- }
1201
-
1202
- // It's a flow node - follow its 'next' if it has one
1203
- const flowNode = nodes[startNextId]
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
1289
- }
1290
-
1291
- return null
1292
- }
1293
-
1294
- /**
1295
- * Deserialize a graph workflow to pikkuWorkflowGraph code
1296
- */
1297
- export function deserializeGraphWorkflow(
1298
- workflow: SerializedWorkflowGraph,
1299
- options: DeserializeOptions = {}
1300
- ): string {
1301
- const { pikkuImportPath = '../.pikku/workflow/pikku-workflow-types.gen.js' } =
1302
- options
1303
-
1304
- const lines: string[] = []
1305
-
1306
- // Import statement
1307
- lines.push(`import { pikkuWorkflowGraph } from '${pikkuImportPath}'`)
1308
- lines.push('')
1309
-
1310
- // Add description as comment if present
1311
- if (workflow.description) {
1312
- lines.push(`/**`)
1313
- lines.push(` * ${workflow.description}`)
1314
- lines.push(` */`)
1315
- }
1316
-
1317
- // Identify flow nodes (non-RPC nodes like return, sleep, branch)
1318
- const flowNodeIds = new Set<string>()
1319
- for (const [nodeId, node] of Object.entries(workflow.nodes)) {
1320
- if (isFlowNode(node)) {
1321
- flowNodeIds.add(nodeId)
1322
- }
1323
- }
1324
-
1325
- // Build outputVar to nodeId mapping (for resolving variable references to node IDs)
1326
- const outputVarToNodeId = new Map<string, string>()
1327
- for (const [nodeId, node] of Object.entries(workflow.nodes)) {
1328
- if ('outputVar' in node && typeof node.outputVar === 'string') {
1329
- outputVarToNodeId.set(node.outputVar, nodeId)
1330
- }
1331
- }
1332
-
1333
- // Build node to RPC mapping (only RPC nodes, not flow nodes)
1334
- const nodeRpcMap: Record<string, string> = {}
1335
- for (const [nodeId, node] of Object.entries(workflow.nodes)) {
1336
- if (
1337
- 'rpcName' in node &&
1338
- typeof node.rpcName === 'string' &&
1339
- node.rpcName !== 'unknown'
1340
- ) {
1341
- nodeRpcMap[nodeId] = node.rpcName
1342
- }
1343
- }
1344
-
1345
- // Build node configurations (only for RPC nodes)
1346
- const nodeConfigs: string[] = []
1347
-
1348
- for (const [nodeId, node] of Object.entries(workflow.nodes)) {
1349
- // Skip flow nodes - they can't be represented in pikkuWorkflowGraph
1350
- if (flowNodeIds.has(nodeId)) {
1351
- continue
1352
- }
1353
-
1354
- const configParts: string[] = []
1355
-
1356
- // Add next if present - follow through flow nodes to find the actual next RPC node
1357
- if ('next' in node && node.next) {
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)}`)
1380
- }
1381
- }
1382
-
1383
- // Add input if present
1384
- // Always use callback form to avoid excess property checking in TypeScript
1385
- if ('input' in node && node.input) {
1386
- const input = node.input as Record<string, unknown>
1387
- if (Object.keys(input).length > 0) {
1388
- const { hasRefs, code } = inputToGraphCode(input, outputVarToNodeId)
1389
- if (hasRefs) {
1390
- // Always pass both ref and template for consistent type signature
1391
- configParts.push(`input: (ref, template) => (${code})`)
1392
- } else {
1393
- // Wrap in callback to avoid TypeScript excess property checking
1394
- configParts.push(`input: () => (${code})`)
1395
- }
1396
- }
1397
- }
1398
-
1399
- if (configParts.length > 0) {
1400
- nodeConfigs.push(
1401
- ` ${nodeId}: {\n ${configParts.join(',\n ')},\n }`
1402
- )
1403
- }
1404
- }
1405
-
1406
- // Generate the pikkuWorkflowGraph call (builds graph and registers with core)
1407
- lines.push(`export const ${workflow.name} = pikkuWorkflowGraph({`)
1408
- lines.push(` name: '${workflow.name}',`)
1409
- if (workflow.description) {
1410
- lines.push(` description: '${workflow.description}',`)
1411
- }
1412
- if (workflow.tags && workflow.tags.length > 0) {
1413
- lines.push(` tags: [${workflow.tags.map((t) => `'${t}'`).join(', ')}],`)
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
- }
1421
-
1422
- // Generate nodes (RPC mapping)
1423
- const rpcMapEntries = Object.entries(nodeRpcMap)
1424
- if (rpcMapEntries.length > 0) {
1425
- lines.push(` nodes: {`)
1426
- for (const [nodeId, rpcName] of rpcMapEntries) {
1427
- lines.push(` ${nodeId}: '${rpcName}',`)
1428
- }
1429
- lines.push(` },`)
1430
- } else {
1431
- lines.push(` nodes: {},`)
1432
- }
1433
-
1434
- // Generate config (node configurations)
1435
- if (nodeConfigs.length > 0) {
1436
- lines.push(` config: {`)
1437
- lines.push(nodeConfigs.join(',\n'))
1438
- lines.push(` },`)
1439
- }
1440
-
1441
- lines.push(`})`)
1442
- lines.push('')
1443
-
1444
- return lines.join('\n')
1445
- }
1446
-
1447
- /**
1448
- * Deserialize all workflows from JSON to DSL code
1449
- */
1450
- export function deserializeAllDslWorkflows(
1451
- workflows: Record<string, SerializedWorkflowGraph>,
1452
- options: DeserializeOptions = {}
1453
- ): Record<string, string> {
1454
- const result: Record<string, string> = {}
1455
-
1456
- for (const [name, workflow] of Object.entries(workflows)) {
1457
- if (workflow.source === 'dsl') {
1458
- result[name] = deserializeDslWorkflow(workflow, options)
1459
- } else if (workflow.source === 'graph') {
1460
- result[name] = deserializeGraphWorkflow(workflow, options)
1461
- }
1462
- }
1463
-
1464
- return result
1465
- }