@pikku/inspector 0.12.75 → 0.12.82

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 (223) hide show
  1. package/CHANGELOG.md +357 -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-middleware.js +66 -42
  8. package/dist/add/add-wire-addon.js +7 -4
  9. package/dist/add/add-wire-remote-addon.js +1 -0
  10. package/dist/error-codes.d.ts +1 -0
  11. package/dist/error-codes.js +1 -0
  12. package/dist/index.d.ts +3 -1
  13. package/dist/index.js +3 -1
  14. package/dist/inspector.js +5 -0
  15. package/dist/surface/collect-surface.d.ts +58 -0
  16. package/dist/surface/collect-surface.js +613 -0
  17. package/dist/types.d.ts +36 -12
  18. package/dist/utils/filter-inspector-state.js +14 -0
  19. package/dist/utils/load-addon-functions-meta.js +24 -11
  20. package/dist/utils/post-process.js +15 -0
  21. package/dist/utils/read-module-specifiers.d.ts +8 -0
  22. package/dist/utils/read-module-specifiers.js +23 -0
  23. package/dist/utils/read-tsconfig-out-dir.d.ts +5 -0
  24. package/dist/utils/read-tsconfig-out-dir.js +12 -0
  25. package/dist/utils/resolve-deploy-target.d.ts +7 -0
  26. package/dist/utils/resolve-deploy-target.js +15 -7
  27. package/dist/utils/serialize-inspector-state.d.ts +6 -0
  28. package/dist/utils/serialize-inspector-state.js +10 -0
  29. package/dist/visit.js +4 -0
  30. package/package.json +16 -6
  31. package/run-tests.sh +21 -6
  32. package/src/add/add-addon-bans.ts +0 -85
  33. package/src/add/add-agent-scorer.test.ts +0 -197
  34. package/src/add/add-agent-scorer.ts +0 -83
  35. package/src/add/add-agent.test.ts +0 -87
  36. package/src/add/add-agent.ts +0 -638
  37. package/src/add/add-approval-description.ts +0 -76
  38. package/src/add/add-auth.test.ts +0 -791
  39. package/src/add/add-auth.ts +0 -424
  40. package/src/add/add-channel.ts +0 -778
  41. package/src/add/add-cli-renderers.test.ts +0 -74
  42. package/src/add/add-cli.ts +0 -1006
  43. package/src/add/add-credential.ts +0 -186
  44. package/src/add/add-feature.test.ts +0 -306
  45. package/src/add/add-feature.ts +0 -253
  46. package/src/add/add-file-extends-core-type.ts +0 -66
  47. package/src/add/add-file-with-config.ts +0 -45
  48. package/src/add/add-file-with-factory.test.ts +0 -134
  49. package/src/add/add-file-with-factory.ts +0 -288
  50. package/src/add/add-functions.test.ts +0 -743
  51. package/src/add/add-functions.ts +0 -1548
  52. package/src/add/add-gateway.ts +0 -143
  53. package/src/add/add-http-route-ref.test.ts +0 -149
  54. package/src/add/add-http-route.ts +0 -530
  55. package/src/add/add-http-routes.ts +0 -471
  56. package/src/add/add-keyed-wiring.ts +0 -180
  57. package/src/add/add-mcp-prompt.ts +0 -157
  58. package/src/add/add-mcp-resource.ts +0 -175
  59. package/src/add/add-middleware.test.ts +0 -196
  60. package/src/add/add-middleware.ts +0 -879
  61. package/src/add/add-permission-auth.test.ts +0 -59
  62. package/src/add/add-permission.ts +0 -311
  63. package/src/add/add-personas.test.ts +0 -406
  64. package/src/add/add-personas.ts +0 -287
  65. package/src/add/add-queue-worker.ts +0 -117
  66. package/src/add/add-rpc-invocations.test.ts +0 -287
  67. package/src/add/add-rpc-invocations.ts +0 -221
  68. package/src/add/add-scenario-step.test.ts +0 -509
  69. package/src/add/add-schedule.ts +0 -120
  70. package/src/add/add-scope.test.ts +0 -419
  71. package/src/add/add-scope.ts +0 -221
  72. package/src/add/add-secret.ts +0 -10
  73. package/src/add/add-subject-scenario-steps.test.ts +0 -161
  74. package/src/add/add-system-role.test.ts +0 -297
  75. package/src/add/add-system-role.ts +0 -162
  76. package/src/add/add-trigger.ts +0 -165
  77. package/src/add/add-variable.ts +0 -10
  78. package/src/add/add-wire-addon.test.ts +0 -185
  79. package/src/add/add-wire-addon.ts +0 -146
  80. package/src/add/add-wire-remote-addon.ts +0 -80
  81. package/src/add/add-workflow-edge-cases.test.ts +0 -201
  82. package/src/add/add-workflow-fanout.test.ts +0 -166
  83. package/src/add/add-workflow-graph-foreach.test.ts +0 -174
  84. package/src/add/add-workflow-graph-input.test.ts +0 -94
  85. package/src/add/add-workflow-graph-notes.test.ts +0 -74
  86. package/src/add/add-workflow-graph.ts +0 -644
  87. package/src/add/add-workflow-on-error.test.ts +0 -118
  88. package/src/add/add-workflow-return-switch.test.ts +0 -121
  89. package/src/add/add-workflow-silent-drops.test.ts +0 -261
  90. package/src/add/add-workflow-sleep-expression.test.ts +0 -96
  91. package/src/add/add-workflow.test.ts +0 -155
  92. package/src/add/add-workflow.ts +0 -482
  93. package/src/add/addon-bans.test.ts +0 -121
  94. package/src/add/addon-contracts.test.ts +0 -221
  95. package/src/add/allow-escape-hatches.test.ts +0 -144
  96. package/src/add/audit-meta.test.ts +0 -75
  97. package/src/add/classified-sink-check.test.ts +0 -347
  98. package/src/add/config-value-metadata.test.ts +0 -263
  99. package/src/add/dynamic-import-check.test.ts +0 -119
  100. package/src/add/expect-eventually-scenario-only.test.ts +0 -108
  101. package/src/add/function-body-span.test.ts +0 -96
  102. package/src/add/inline-wiring-function.test.ts +0 -104
  103. package/src/add/pii-check.test.ts +0 -258
  104. package/src/add/rpc-type-cast.test.ts +0 -123
  105. package/src/add/scenario-instrumentation.test.ts +0 -89
  106. package/src/add/scenario-instrumentation.ts +0 -42
  107. package/src/add/scenario-marker.test.ts +0 -89
  108. package/src/add/scenario-not-extractable.test.ts +0 -159
  109. package/src/add/scenario-services.test.ts +0 -103
  110. package/src/add/scenario-skip.test.ts +0 -83
  111. package/src/add/validate-workflow-graph-addons.test.ts +0 -102
  112. package/src/add/wire-name-literal.test.ts +0 -117
  113. package/src/error-codes-emitted.test.ts +0 -76
  114. package/src/error-codes.ts +0 -145
  115. package/src/index.ts +0 -66
  116. package/src/inspector.ts +0 -575
  117. package/src/schema-generator.ts +0 -1
  118. package/src/source-file-cache.ts +0 -73
  119. package/src/tsconfig-paths.test.ts +0 -90
  120. package/src/types-map.ts +0 -141
  121. package/src/types.ts +0 -775
  122. package/src/utils/annotate-http-route-auth.test.ts +0 -161
  123. package/src/utils/annotate-http-route-auth.ts +0 -53
  124. package/src/utils/check-classified-sinks.ts +0 -215
  125. package/src/utils/check-pii-output.ts +0 -120
  126. package/src/utils/compute-diagnostics.test.ts +0 -106
  127. package/src/utils/compute-required-schemas.ts +0 -48
  128. package/src/utils/contract-hashes.test.ts +0 -553
  129. package/src/utils/contract-hashes.ts +0 -387
  130. package/src/utils/custom-types-generator.test.ts +0 -124
  131. package/src/utils/custom-types-generator.ts +0 -150
  132. package/src/utils/detect-schema-vendor.test.ts +0 -123
  133. package/src/utils/detect-schema-vendor.ts +0 -120
  134. package/src/utils/does-type-extend-core-type.ts +0 -53
  135. package/src/utils/ensure-function-metadata.ts +0 -385
  136. package/src/utils/extract-function-name.ts +0 -612
  137. package/src/utils/extract-function-node.ts +0 -70
  138. package/src/utils/extract-node-value.test.ts +0 -117
  139. package/src/utils/extract-node-value.ts +0 -172
  140. package/src/utils/extract-secret-usage.test.ts +0 -81
  141. package/src/utils/extract-secret-usage.ts +0 -73
  142. package/src/utils/extract-services.ts +0 -70
  143. package/src/utils/extract-surface-usage.test.ts +0 -147
  144. package/src/utils/extract-surface-usage.ts +0 -121
  145. package/src/utils/filter-inspector-state.test.ts +0 -2232
  146. package/src/utils/filter-inspector-state.ts +0 -1298
  147. package/src/utils/filter-utils.test.ts +0 -843
  148. package/src/utils/filter-utils.ts +0 -161
  149. package/src/utils/find-root-dir.ts +0 -68
  150. package/src/utils/find-zod-transform.test.ts +0 -70
  151. package/src/utils/find-zod-transform.ts +0 -103
  152. package/src/utils/get-exported-variable-name.ts +0 -48
  153. package/src/utils/get-files-and-methods.ts +0 -157
  154. package/src/utils/get-property-value.test.ts +0 -141
  155. package/src/utils/get-property-value.ts +0 -331
  156. package/src/utils/hash.ts +0 -26
  157. package/src/utils/literal-properties.ts +0 -97
  158. package/src/utils/load-addon-functions-meta.test.ts +0 -157
  159. package/src/utils/load-addon-functions-meta.ts +0 -508
  160. package/src/utils/load-addon-scopes.test.ts +0 -138
  161. package/src/utils/middleware.test.ts +0 -204
  162. package/src/utils/middleware.ts +0 -303
  163. package/src/utils/nested-project-filter.test.ts +0 -53
  164. package/src/utils/nested-project-filter.ts +0 -28
  165. package/src/utils/permissions-shorthand.test.ts +0 -79
  166. package/src/utils/permissions.test.ts +0 -125
  167. package/src/utils/permissions.ts +0 -129
  168. package/src/utils/post-process.test.ts +0 -1094
  169. package/src/utils/post-process.ts +0 -1604
  170. package/src/utils/resolve-addon-package.ts +0 -106
  171. package/src/utils/resolve-core-type.test.ts +0 -142
  172. package/src/utils/resolve-core-type.ts +0 -35
  173. package/src/utils/resolve-deploy-target.test.ts +0 -135
  174. package/src/utils/resolve-deploy-target.ts +0 -65
  175. package/src/utils/resolve-function-meta.ts +0 -25
  176. package/src/utils/resolve-function-types.ts +0 -42
  177. package/src/utils/resolve-identifier.ts +0 -46
  178. package/src/utils/resolve-ref-contract.ts +0 -71
  179. package/src/utils/resolve-versions.test.ts +0 -499
  180. package/src/utils/resolve-versions.ts +0 -228
  181. package/src/utils/schema-cache.test.ts +0 -124
  182. package/src/utils/schema-generator.test.ts +0 -272
  183. package/src/utils/schema-generator.ts +0 -780
  184. package/src/utils/secret-alias-services.test.ts +0 -146
  185. package/src/utils/secret-alias-services.ts +0 -58
  186. package/src/utils/serialize-inspector-state.ts +0 -742
  187. package/src/utils/serialize-mcp-json.ts +0 -150
  188. package/src/utils/serialize-middleware-groups-meta.ts +0 -36
  189. package/src/utils/serialize-openapi-json.ts +0 -277
  190. package/src/utils/serialize-permissions-groups-meta.ts +0 -7
  191. package/src/utils/single-declaration.ts +0 -57
  192. package/src/utils/type-utils.ts +0 -145
  193. package/src/utils/validate-auth-sessionless.ts +0 -30
  194. package/src/utils/validate-exposed-functions-gated.test.ts +0 -230
  195. package/src/utils/validate-exposed-functions-gated.ts +0 -103
  196. package/src/utils/validate-tags-resolve-to-middleware.test.ts +0 -108
  197. package/src/utils/validate-tags-resolve-to-middleware.ts +0 -48
  198. package/src/utils/workflow/derive-workflow-plan.test.ts +0 -178
  199. package/src/utils/workflow/derive-workflow-plan.ts +0 -117
  200. package/src/utils/workflow/dsl/deserialize-dsl-workflow.ts +0 -1465
  201. package/src/utils/workflow/dsl/deserialize-fanout-body.test.ts +0 -71
  202. package/src/utils/workflow/dsl/deserialize-fanout-fidelity.test.ts +0 -99
  203. package/src/utils/workflow/dsl/deserialize-graph-next.test.ts +0 -161
  204. package/src/utils/workflow/dsl/deserialize-roundtrip-bindings.test.ts +0 -121
  205. package/src/utils/workflow/dsl/deserialize-roundtrip-values.test.ts +0 -116
  206. package/src/utils/workflow/dsl/deserialize-roundtrip.test.ts +0 -145
  207. package/src/utils/workflow/dsl/extract-approval-policy.test.ts +0 -71
  208. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +0 -2254
  209. package/src/utils/workflow/dsl/index.ts +0 -11
  210. package/src/utils/workflow/dsl/patterns.ts +0 -440
  211. package/src/utils/workflow/dsl/validation.ts +0 -203
  212. package/src/utils/workflow/graph/convert-dsl-to-graph-skip.test.ts +0 -35
  213. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +0 -543
  214. package/src/utils/workflow/graph/fanout-node-identity.test.ts +0 -105
  215. package/src/utils/workflow/graph/finalize-workflow-wires.ts +0 -310
  216. package/src/utils/workflow/graph/finalize-workflows.test.ts +0 -50
  217. package/src/utils/workflow/graph/finalize-workflows.ts +0 -130
  218. package/src/utils/workflow/graph/index.ts +0 -28
  219. package/src/utils/workflow/graph/serialize-workflow-graph.ts +0 -240
  220. package/src/utils/workflow/graph/workflow-graph.types.ts +0 -281
  221. package/src/visit.ts +0 -181
  222. package/tsconfig.json +0 -19
  223. 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
- }