@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.
- package/CHANGELOG.md +347 -0
- package/dist/add/add-analytics.d.ts +9 -0
- package/dist/add/add-analytics.js +178 -0
- package/dist/add/add-auth.js +0 -54
- package/dist/add/add-feature-flag.d.ts +2 -0
- package/dist/add/add-feature-flag.js +162 -0
- package/dist/add/add-wire-addon.js +1 -0
- package/dist/add/add-wire-remote-addon.js +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/inspector.js +4 -0
- package/dist/surface/collect-surface.d.ts +58 -0
- package/dist/surface/collect-surface.js +613 -0
- package/dist/types.d.ts +20 -11
- package/dist/utils/filter-inspector-state.js +23 -13
- package/dist/utils/post-process.js +15 -0
- package/dist/utils/read-module-specifiers.d.ts +8 -0
- package/dist/utils/read-module-specifiers.js +23 -0
- package/dist/utils/read-tsconfig-out-dir.d.ts +5 -0
- package/dist/utils/read-tsconfig-out-dir.js +12 -0
- package/dist/utils/resolve-deploy-target.d.ts +7 -0
- package/dist/utils/resolve-deploy-target.js +15 -7
- package/dist/utils/serialize-inspector-state.d.ts +5 -0
- package/dist/utils/serialize-inspector-state.js +8 -0
- package/dist/visit.js +4 -0
- package/package.json +16 -6
- package/run-tests.sh +21 -6
- package/src/add/add-addon-bans.ts +0 -85
- package/src/add/add-agent-scorer.test.ts +0 -197
- package/src/add/add-agent-scorer.ts +0 -83
- package/src/add/add-agent.test.ts +0 -87
- package/src/add/add-agent.ts +0 -638
- package/src/add/add-approval-description.ts +0 -76
- package/src/add/add-auth.test.ts +0 -791
- package/src/add/add-auth.ts +0 -424
- package/src/add/add-channel.ts +0 -778
- package/src/add/add-cli-renderers.test.ts +0 -74
- package/src/add/add-cli.ts +0 -1006
- package/src/add/add-credential.ts +0 -186
- package/src/add/add-feature.test.ts +0 -306
- package/src/add/add-feature.ts +0 -253
- package/src/add/add-file-extends-core-type.ts +0 -66
- package/src/add/add-file-with-config.ts +0 -45
- package/src/add/add-file-with-factory.test.ts +0 -134
- package/src/add/add-file-with-factory.ts +0 -288
- package/src/add/add-functions.test.ts +0 -743
- package/src/add/add-functions.ts +0 -1548
- package/src/add/add-gateway.ts +0 -143
- package/src/add/add-http-route-ref.test.ts +0 -149
- package/src/add/add-http-route.ts +0 -530
- package/src/add/add-http-routes.ts +0 -471
- package/src/add/add-keyed-wiring.ts +0 -180
- package/src/add/add-mcp-prompt.ts +0 -157
- package/src/add/add-mcp-resource.ts +0 -175
- package/src/add/add-middleware.test.ts +0 -196
- package/src/add/add-middleware.ts +0 -879
- package/src/add/add-permission-auth.test.ts +0 -59
- package/src/add/add-permission.ts +0 -311
- package/src/add/add-personas.test.ts +0 -406
- package/src/add/add-personas.ts +0 -287
- package/src/add/add-queue-worker.ts +0 -117
- package/src/add/add-rpc-invocations.test.ts +0 -287
- package/src/add/add-rpc-invocations.ts +0 -221
- package/src/add/add-scenario-step.test.ts +0 -509
- package/src/add/add-schedule.ts +0 -120
- package/src/add/add-scope.test.ts +0 -419
- package/src/add/add-scope.ts +0 -221
- package/src/add/add-secret.ts +0 -10
- package/src/add/add-subject-scenario-steps.test.ts +0 -161
- package/src/add/add-system-role.test.ts +0 -297
- package/src/add/add-system-role.ts +0 -162
- package/src/add/add-trigger.ts +0 -165
- package/src/add/add-variable.ts +0 -10
- package/src/add/add-wire-addon.test.ts +0 -185
- package/src/add/add-wire-addon.ts +0 -146
- package/src/add/add-wire-remote-addon.ts +0 -80
- package/src/add/add-workflow-edge-cases.test.ts +0 -201
- package/src/add/add-workflow-fanout.test.ts +0 -166
- package/src/add/add-workflow-graph-foreach.test.ts +0 -174
- package/src/add/add-workflow-graph-input.test.ts +0 -94
- package/src/add/add-workflow-graph-notes.test.ts +0 -74
- package/src/add/add-workflow-graph.ts +0 -644
- package/src/add/add-workflow-on-error.test.ts +0 -118
- package/src/add/add-workflow-return-switch.test.ts +0 -121
- package/src/add/add-workflow-silent-drops.test.ts +0 -261
- package/src/add/add-workflow-sleep-expression.test.ts +0 -96
- package/src/add/add-workflow.test.ts +0 -155
- package/src/add/add-workflow.ts +0 -482
- package/src/add/addon-bans.test.ts +0 -121
- package/src/add/addon-contracts.test.ts +0 -221
- package/src/add/allow-escape-hatches.test.ts +0 -144
- package/src/add/audit-meta.test.ts +0 -75
- package/src/add/classified-sink-check.test.ts +0 -347
- package/src/add/config-value-metadata.test.ts +0 -263
- package/src/add/dynamic-import-check.test.ts +0 -119
- package/src/add/expect-eventually-scenario-only.test.ts +0 -108
- package/src/add/function-body-span.test.ts +0 -96
- package/src/add/inline-wiring-function.test.ts +0 -104
- package/src/add/pii-check.test.ts +0 -258
- package/src/add/rpc-type-cast.test.ts +0 -123
- package/src/add/scenario-instrumentation.test.ts +0 -89
- package/src/add/scenario-instrumentation.ts +0 -42
- package/src/add/scenario-marker.test.ts +0 -89
- package/src/add/scenario-not-extractable.test.ts +0 -159
- package/src/add/scenario-services.test.ts +0 -103
- package/src/add/scenario-skip.test.ts +0 -83
- package/src/add/validate-workflow-graph-addons.test.ts +0 -102
- package/src/add/wire-name-literal.test.ts +0 -117
- package/src/error-codes-emitted.test.ts +0 -76
- package/src/error-codes.ts +0 -145
- package/src/index.ts +0 -66
- package/src/inspector.ts +0 -575
- package/src/schema-generator.ts +0 -1
- package/src/source-file-cache.ts +0 -73
- package/src/tsconfig-paths.test.ts +0 -90
- package/src/types-map.ts +0 -141
- package/src/types.ts +0 -775
- package/src/utils/annotate-http-route-auth.test.ts +0 -161
- package/src/utils/annotate-http-route-auth.ts +0 -53
- package/src/utils/check-classified-sinks.ts +0 -215
- package/src/utils/check-pii-output.ts +0 -120
- package/src/utils/compute-diagnostics.test.ts +0 -106
- package/src/utils/compute-required-schemas.ts +0 -48
- package/src/utils/contract-hashes.test.ts +0 -553
- package/src/utils/contract-hashes.ts +0 -387
- package/src/utils/custom-types-generator.test.ts +0 -124
- package/src/utils/custom-types-generator.ts +0 -150
- package/src/utils/detect-schema-vendor.test.ts +0 -123
- package/src/utils/detect-schema-vendor.ts +0 -120
- package/src/utils/does-type-extend-core-type.ts +0 -53
- package/src/utils/ensure-function-metadata.ts +0 -385
- package/src/utils/extract-function-name.ts +0 -612
- package/src/utils/extract-function-node.ts +0 -70
- package/src/utils/extract-node-value.test.ts +0 -117
- package/src/utils/extract-node-value.ts +0 -172
- package/src/utils/extract-secret-usage.test.ts +0 -81
- package/src/utils/extract-secret-usage.ts +0 -73
- package/src/utils/extract-services.ts +0 -70
- package/src/utils/extract-surface-usage.test.ts +0 -147
- package/src/utils/extract-surface-usage.ts +0 -121
- package/src/utils/filter-inspector-state.test.ts +0 -2207
- package/src/utils/filter-inspector-state.ts +0 -1302
- package/src/utils/filter-utils.test.ts +0 -843
- package/src/utils/filter-utils.ts +0 -161
- package/src/utils/find-root-dir.ts +0 -68
- package/src/utils/find-zod-transform.test.ts +0 -70
- package/src/utils/find-zod-transform.ts +0 -103
- package/src/utils/get-exported-variable-name.ts +0 -48
- package/src/utils/get-files-and-methods.ts +0 -157
- package/src/utils/get-property-value.test.ts +0 -141
- package/src/utils/get-property-value.ts +0 -331
- package/src/utils/hash.ts +0 -26
- package/src/utils/literal-properties.ts +0 -97
- package/src/utils/load-addon-functions-meta.test.ts +0 -157
- package/src/utils/load-addon-functions-meta.ts +0 -508
- package/src/utils/load-addon-scopes.test.ts +0 -138
- package/src/utils/middleware.test.ts +0 -204
- package/src/utils/middleware.ts +0 -303
- package/src/utils/nested-project-filter.test.ts +0 -53
- package/src/utils/nested-project-filter.ts +0 -28
- package/src/utils/permissions-shorthand.test.ts +0 -79
- package/src/utils/permissions.test.ts +0 -125
- package/src/utils/permissions.ts +0 -129
- package/src/utils/post-process.test.ts +0 -1094
- package/src/utils/post-process.ts +0 -1604
- package/src/utils/resolve-addon-package.ts +0 -106
- package/src/utils/resolve-core-type.test.ts +0 -142
- package/src/utils/resolve-core-type.ts +0 -35
- package/src/utils/resolve-deploy-target.test.ts +0 -135
- package/src/utils/resolve-deploy-target.ts +0 -65
- package/src/utils/resolve-function-meta.ts +0 -25
- package/src/utils/resolve-function-types.ts +0 -42
- package/src/utils/resolve-identifier.ts +0 -46
- package/src/utils/resolve-ref-contract.ts +0 -71
- package/src/utils/resolve-versions.test.ts +0 -499
- package/src/utils/resolve-versions.ts +0 -228
- package/src/utils/schema-cache.test.ts +0 -124
- package/src/utils/schema-generator.test.ts +0 -272
- package/src/utils/schema-generator.ts +0 -780
- package/src/utils/secret-alias-services.test.ts +0 -146
- package/src/utils/secret-alias-services.ts +0 -58
- package/src/utils/serialize-inspector-state.ts +0 -742
- package/src/utils/serialize-mcp-json.ts +0 -150
- package/src/utils/serialize-middleware-groups-meta.ts +0 -36
- package/src/utils/serialize-openapi-json.ts +0 -277
- package/src/utils/serialize-permissions-groups-meta.ts +0 -7
- package/src/utils/single-declaration.ts +0 -57
- package/src/utils/type-utils.ts +0 -145
- package/src/utils/validate-auth-sessionless.ts +0 -30
- package/src/utils/validate-exposed-functions-gated.test.ts +0 -230
- package/src/utils/validate-exposed-functions-gated.ts +0 -103
- package/src/utils/validate-tags-resolve-to-middleware.test.ts +0 -108
- package/src/utils/validate-tags-resolve-to-middleware.ts +0 -48
- package/src/utils/workflow/derive-workflow-plan.test.ts +0 -178
- package/src/utils/workflow/derive-workflow-plan.ts +0 -117
- package/src/utils/workflow/dsl/deserialize-dsl-workflow.ts +0 -1465
- package/src/utils/workflow/dsl/deserialize-fanout-body.test.ts +0 -71
- package/src/utils/workflow/dsl/deserialize-fanout-fidelity.test.ts +0 -99
- package/src/utils/workflow/dsl/deserialize-graph-next.test.ts +0 -161
- package/src/utils/workflow/dsl/deserialize-roundtrip-bindings.test.ts +0 -121
- package/src/utils/workflow/dsl/deserialize-roundtrip-values.test.ts +0 -116
- package/src/utils/workflow/dsl/deserialize-roundtrip.test.ts +0 -145
- package/src/utils/workflow/dsl/extract-approval-policy.test.ts +0 -71
- package/src/utils/workflow/dsl/extract-dsl-workflow.ts +0 -2254
- package/src/utils/workflow/dsl/index.ts +0 -11
- package/src/utils/workflow/dsl/patterns.ts +0 -440
- package/src/utils/workflow/dsl/validation.ts +0 -203
- package/src/utils/workflow/graph/convert-dsl-to-graph-skip.test.ts +0 -35
- package/src/utils/workflow/graph/convert-dsl-to-graph.ts +0 -543
- package/src/utils/workflow/graph/fanout-node-identity.test.ts +0 -105
- package/src/utils/workflow/graph/finalize-workflow-wires.ts +0 -310
- package/src/utils/workflow/graph/finalize-workflows.test.ts +0 -50
- package/src/utils/workflow/graph/finalize-workflows.ts +0 -130
- package/src/utils/workflow/graph/index.ts +0 -28
- package/src/utils/workflow/graph/serialize-workflow-graph.ts +0 -240
- package/src/utils/workflow/graph/workflow-graph.types.ts +0 -281
- package/src/visit.ts +0 -181
- package/tsconfig.json +0 -19
- 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
|
-
}
|