@pikku/inspector 0.12.43 → 0.12.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +168 -0
- package/dist/add/add-credential.js +2 -0
- package/dist/add/add-keyed-wiring.js +2 -0
- package/dist/add/add-workflow.js +9 -4
- package/dist/utils/schema-generator.js +19 -4
- package/dist/utils/workflow/dsl/deserialize-dsl-workflow.js +373 -91
- package/dist/utils/workflow/dsl/extract-dsl-workflow.js +218 -99
- package/dist/utils/workflow/graph/convert-dsl-to-graph.js +62 -5
- package/dist/utils/workflow/graph/workflow-graph.types.d.ts +3 -3
- package/package.json +3 -3
- package/run-tests.sh +1 -1
- package/src/add/add-credential.ts +2 -0
- package/src/add/add-keyed-wiring.ts +2 -0
- package/src/add/add-workflow-edge-cases.test.ts +201 -0
- package/src/add/add-workflow-fanout.test.ts +60 -0
- package/src/add/add-workflow-on-error.test.ts +118 -0
- package/src/add/add-workflow-return-switch.test.ts +121 -0
- package/src/add/add-workflow-silent-drops.test.ts +163 -0
- package/src/add/add-workflow-sleep-expression.test.ts +97 -0
- package/src/add/add-workflow.ts +9 -4
- package/src/add/config-value-metadata.test.ts +169 -0
- package/src/utils/schema-generator.test.ts +164 -0
- package/src/utils/schema-generator.ts +26 -3
- package/src/utils/workflow/dsl/deserialize-dsl-workflow.ts +480 -119
- package/src/utils/workflow/dsl/deserialize-fanout-body.test.ts +71 -0
- package/src/utils/workflow/dsl/deserialize-fanout-fidelity.test.ts +99 -0
- package/src/utils/workflow/dsl/deserialize-graph-next.test.ts +161 -0
- package/src/utils/workflow/dsl/deserialize-roundtrip-bindings.test.ts +121 -0
- package/src/utils/workflow/dsl/deserialize-roundtrip-values.test.ts +116 -0
- package/src/utils/workflow/dsl/deserialize-roundtrip.test.ts +145 -0
- package/src/utils/workflow/dsl/extract-dsl-workflow.ts +245 -105
- package/src/utils/workflow/graph/convert-dsl-to-graph.ts +67 -10
- package/src/utils/workflow/graph/fanout-node-identity.test.ts +105 -0
- package/src/utils/workflow/graph/workflow-graph.types.ts +3 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { strict as assert } from 'assert'
|
|
2
|
+
import { describe, test } from 'node:test'
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { inspect } from '../../../inspector.js'
|
|
7
|
+
import type { InspectorLogger } from '../../../types.js'
|
|
8
|
+
import { deserializeDslWorkflow } from './deserialize-dsl-workflow.js'
|
|
9
|
+
|
|
10
|
+
function makeLogger(): InspectorLogger {
|
|
11
|
+
return {
|
|
12
|
+
debug: () => {},
|
|
13
|
+
info: () => {},
|
|
14
|
+
warn: () => {},
|
|
15
|
+
error: () => {},
|
|
16
|
+
diagnostic: () => {},
|
|
17
|
+
critical: () => {},
|
|
18
|
+
hasCriticalErrors: () => false,
|
|
19
|
+
} as any
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const STEP_FILE = [
|
|
23
|
+
"import { pikkuSessionlessFunc } from '@pikku/core'",
|
|
24
|
+
'export const discount = pikkuSessionlessFunc({',
|
|
25
|
+
' func: async () => ({ amount: 1 }),',
|
|
26
|
+
'})',
|
|
27
|
+
'export const notifySales = pikkuSessionlessFunc({',
|
|
28
|
+
' func: async () => ({ ok: true }),',
|
|
29
|
+
'})',
|
|
30
|
+
'export const provision = pikkuSessionlessFunc({',
|
|
31
|
+
' func: async () => ({ ok: true }),',
|
|
32
|
+
'})',
|
|
33
|
+
'export const auditLog = pikkuSessionlessFunc({',
|
|
34
|
+
' func: async () => ({ ok: true }),',
|
|
35
|
+
'})',
|
|
36
|
+
].join('\n')
|
|
37
|
+
|
|
38
|
+
/** Extract a workflow from source, then regenerate its code from the graph. */
|
|
39
|
+
async function roundTrip(body: string): Promise<string> {
|
|
40
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-rt-'))
|
|
41
|
+
const stepFile = join(rootDir, 'w.steps.ts')
|
|
42
|
+
const wfFile = join(rootDir, 'w.workflow.ts')
|
|
43
|
+
await writeFile(stepFile, STEP_FILE)
|
|
44
|
+
await writeFile(
|
|
45
|
+
wfFile,
|
|
46
|
+
[
|
|
47
|
+
"import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
|
|
48
|
+
'export const wf = pikkuWorkflowFunc(async (_, data: any, { workflow }) => {',
|
|
49
|
+
body,
|
|
50
|
+
'})',
|
|
51
|
+
].join('\n')
|
|
52
|
+
)
|
|
53
|
+
try {
|
|
54
|
+
const state: any = await inspect(makeLogger(), [stepFile, wfFile], {
|
|
55
|
+
rootDir,
|
|
56
|
+
})
|
|
57
|
+
const gm = state.workflows.graphMeta
|
|
58
|
+
const graph = (
|
|
59
|
+
gm instanceof Map ? [...gm.values()] : Object.values(gm)
|
|
60
|
+
)[0] as any
|
|
61
|
+
assert.ok(graph, 'a graph should have been produced')
|
|
62
|
+
return deserializeDslWorkflow(graph)
|
|
63
|
+
} finally {
|
|
64
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('DSL round-trip — code must survive graph regeneration', () => {
|
|
69
|
+
test('every step inside an if-branch survives', async () => {
|
|
70
|
+
const code = await roundTrip(
|
|
71
|
+
[
|
|
72
|
+
' if (data.total > 100) {',
|
|
73
|
+
" await workflow.do('Apply discount', 'discount', {})",
|
|
74
|
+
" await workflow.do('Notify sales', 'notifySales', {})",
|
|
75
|
+
' }',
|
|
76
|
+
' return { ok: true }',
|
|
77
|
+
].join('\n')
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
assert.ok(code.includes("'discount'"), 'first branch step should survive')
|
|
81
|
+
assert.ok(
|
|
82
|
+
code.includes("'notifySales'"),
|
|
83
|
+
`second branch step must not be deleted, got:\n${code}`
|
|
84
|
+
)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('every step inside a switch case survives', async () => {
|
|
88
|
+
const code = await roundTrip(
|
|
89
|
+
[
|
|
90
|
+
' switch (data.kind) {',
|
|
91
|
+
" case 'premium':",
|
|
92
|
+
" await workflow.do('Provision premium', 'provision', {})",
|
|
93
|
+
" await workflow.do('Audit premium', 'auditLog', {})",
|
|
94
|
+
' break',
|
|
95
|
+
' }',
|
|
96
|
+
' return { ok: true }',
|
|
97
|
+
].join('\n')
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
assert.ok(code.includes("'provision'"), 'first case step should survive')
|
|
101
|
+
assert.ok(
|
|
102
|
+
code.includes("'auditLog'"),
|
|
103
|
+
`second case step must not be deleted, got:\n${code}`
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('step names survive — they are the durable replay cache key', async () => {
|
|
108
|
+
const code = await roundTrip(
|
|
109
|
+
[
|
|
110
|
+
" await workflow.do('Apply discount', 'discount', {})",
|
|
111
|
+
' return { ok: true }',
|
|
112
|
+
].join('\n')
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
assert.ok(
|
|
116
|
+
code.includes("'Apply discount'"),
|
|
117
|
+
`the authored step name must be preserved, got:\n${code}`
|
|
118
|
+
)
|
|
119
|
+
assert.ok(
|
|
120
|
+
!code.includes("'Call discount'"),
|
|
121
|
+
'the step must not be renamed to the Call <rpc> fallback'
|
|
122
|
+
)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('a suspend does not dead-end the graph and delete the steps after it', async () => {
|
|
126
|
+
const code = await roundTrip(
|
|
127
|
+
[
|
|
128
|
+
" await workflow.do('Create draft', 'discount', {})",
|
|
129
|
+
" await workflow.suspend('await legal review')",
|
|
130
|
+
" await workflow.do('Publish', 'notifySales', {})",
|
|
131
|
+
' return { ok: true }',
|
|
132
|
+
].join('\n')
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
assert.ok(code.includes("'discount'"), 'step before suspend should survive')
|
|
136
|
+
assert.ok(
|
|
137
|
+
code.includes("workflow.suspend('await legal review')"),
|
|
138
|
+
`the suspend itself must be regenerated, got:\n${code}`
|
|
139
|
+
)
|
|
140
|
+
assert.ok(
|
|
141
|
+
code.includes("'notifySales'"),
|
|
142
|
+
`steps after a suspend must not be deleted, got:\n${code}`
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
FanoutStepMeta,
|
|
9
9
|
CancelStepMeta,
|
|
10
10
|
SuspendStepMeta,
|
|
11
|
+
SleepStepMeta,
|
|
11
12
|
ApprovalStepMeta,
|
|
12
13
|
SetStepMeta,
|
|
13
14
|
SwitchStepMeta,
|
|
@@ -322,12 +323,16 @@ function extractVariableDeclaration(
|
|
|
322
323
|
): WorkflowStepMeta | null {
|
|
323
324
|
const declList = statement.declarationList
|
|
324
325
|
if (declList.declarations.length !== 1) {
|
|
326
|
+
context.errors.push({
|
|
327
|
+
message: `A single declaration statement may only declare one variable in DSL workflows. Split '${statement.getText().slice(0, 60)}' into separate statements.`,
|
|
328
|
+
node: statement,
|
|
329
|
+
})
|
|
325
330
|
return null
|
|
326
331
|
}
|
|
327
332
|
|
|
328
333
|
const decl = declList.declarations[0]
|
|
329
334
|
if (!ts.isIdentifier(decl.name)) {
|
|
330
|
-
return
|
|
335
|
+
return extractDestructuredDeclaration(statement, decl, context)
|
|
331
336
|
}
|
|
332
337
|
|
|
333
338
|
const varName = decl.name.text
|
|
@@ -437,6 +442,70 @@ function extractVariableDeclaration(
|
|
|
437
442
|
return null
|
|
438
443
|
}
|
|
439
444
|
|
|
445
|
+
/**
|
|
446
|
+
* Extract a declaration whose binding is a destructuring pattern.
|
|
447
|
+
*
|
|
448
|
+
* `const [a, b] = await Promise.all([...])` is the idiomatic way to run steps
|
|
449
|
+
* in parallel and keep both results, so each element of the pattern is bound to
|
|
450
|
+
* the matching child step's output. Every other destructuring shape reports a
|
|
451
|
+
* diagnostic rather than silently dropping the step.
|
|
452
|
+
*/
|
|
453
|
+
function extractDestructuredDeclaration(
|
|
454
|
+
statement: ts.VariableStatement,
|
|
455
|
+
decl: ts.VariableDeclaration,
|
|
456
|
+
context: ExtractionContext
|
|
457
|
+
): WorkflowStepMeta | null {
|
|
458
|
+
const init = decl.initializer
|
|
459
|
+
|
|
460
|
+
if (
|
|
461
|
+
init &&
|
|
462
|
+
ts.isAwaitExpression(init) &&
|
|
463
|
+
ts.isCallExpression(init.expression) &&
|
|
464
|
+
isParallelGroup(init.expression) &&
|
|
465
|
+
ts.isArrayBindingPattern(decl.name)
|
|
466
|
+
) {
|
|
467
|
+
const elements = decl.name.elements
|
|
468
|
+
const allSimple = elements.every(
|
|
469
|
+
(el) =>
|
|
470
|
+
ts.isBindingElement(el) &&
|
|
471
|
+
ts.isIdentifier(el.name) &&
|
|
472
|
+
!el.dotDotDotToken
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
if (allSimple) {
|
|
476
|
+
const step = extractParallelGroup(init.expression, context)
|
|
477
|
+
if (step) {
|
|
478
|
+
if (elements.length !== step.children.length) {
|
|
479
|
+
context.errors.push({
|
|
480
|
+
message: `Destructuring binds ${elements.length} name(s) but Promise.all has ${step.children.length} step(s). They must match so each result can be bound to its step.`,
|
|
481
|
+
node: statement,
|
|
482
|
+
})
|
|
483
|
+
return null
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
elements.forEach((el, i) => {
|
|
487
|
+
const name = (el as ts.BindingElement).name as ts.Identifier
|
|
488
|
+
const varName = name.text
|
|
489
|
+
step.children[i].outputVar = varName
|
|
490
|
+
const type = context.checker.getTypeAtLocation(name)
|
|
491
|
+
context.outputVars.set(varName, { type, node: name })
|
|
492
|
+
if (isArrayType(type, context.checker)) {
|
|
493
|
+
context.arrayVars.add(varName)
|
|
494
|
+
}
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
return step
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
context.errors.push({
|
|
503
|
+
message: `Destructuring a step result is not supported in DSL workflows. Assign it to a variable first (e.g. \`const result = await workflow.do(...)\`) and read its properties.`,
|
|
504
|
+
node: statement,
|
|
505
|
+
})
|
|
506
|
+
return null
|
|
507
|
+
}
|
|
508
|
+
|
|
440
509
|
/**
|
|
441
510
|
* Extract expression statement (await workflow.do(...) without assignment)
|
|
442
511
|
*/
|
|
@@ -478,11 +547,11 @@ function extractExpressionStatement(
|
|
|
478
547
|
value: literalValue,
|
|
479
548
|
} as SetStepMeta
|
|
480
549
|
}
|
|
481
|
-
// Non-literal assignment to context var -
|
|
550
|
+
// Non-literal assignment to context var - keep the source expression
|
|
482
551
|
return {
|
|
483
552
|
type: 'set',
|
|
484
553
|
variable: outputVar,
|
|
485
|
-
|
|
554
|
+
expression: getSourceText(expr.right),
|
|
486
555
|
} as SetStepMeta
|
|
487
556
|
}
|
|
488
557
|
}
|
|
@@ -677,6 +746,16 @@ function extractStepOptions(
|
|
|
677
746
|
} catch {
|
|
678
747
|
// Ignore extraction errors for retryDelay
|
|
679
748
|
}
|
|
749
|
+
} else if (propName === 'onError') {
|
|
750
|
+
if (ts.isStringLiteral(prop.initializer)) {
|
|
751
|
+
options.onError = prop.initializer.text
|
|
752
|
+
} else {
|
|
753
|
+
context.errors.push({
|
|
754
|
+
message:
|
|
755
|
+
'onError must be a literal RPC name so it can be wired and drawn in the graph.',
|
|
756
|
+
node: prop.initializer,
|
|
757
|
+
})
|
|
758
|
+
}
|
|
680
759
|
} else if (propName === 'description') {
|
|
681
760
|
try {
|
|
682
761
|
options.description = extractStringLiteral(
|
|
@@ -714,7 +793,19 @@ function extractSleepStep(
|
|
|
714
793
|
if (numValue !== null) {
|
|
715
794
|
duration = numValue
|
|
716
795
|
} else {
|
|
717
|
-
|
|
796
|
+
try {
|
|
797
|
+
duration = extractStringLiteral(args[1], context.checker)
|
|
798
|
+
} catch {
|
|
799
|
+
// A duration computed at runtime (a loop variable, a field off the
|
|
800
|
+
// input) is legal: the closure evaluates it. Record the source text so
|
|
801
|
+
// the graph can show what it waits on, rather than failing the workflow.
|
|
802
|
+
return {
|
|
803
|
+
type: 'sleep',
|
|
804
|
+
stepName,
|
|
805
|
+
duration: '',
|
|
806
|
+
expression: args[1].getText(),
|
|
807
|
+
}
|
|
808
|
+
}
|
|
718
809
|
}
|
|
719
810
|
|
|
720
811
|
return {
|
|
@@ -1103,6 +1194,92 @@ function extractArrayPredicate(
|
|
|
1103
1194
|
}
|
|
1104
1195
|
}
|
|
1105
1196
|
|
|
1197
|
+
/**
|
|
1198
|
+
* Extract an ordered list of workflow.do steps from a per-iteration body.
|
|
1199
|
+
*
|
|
1200
|
+
* Unlike if/switch blocks, a fanout body is a real per-iteration scope, so
|
|
1201
|
+
* `const x = await workflow.do(...)` is meaningful here: the binding is
|
|
1202
|
+
* registered on the child context so later steps in the same iteration can
|
|
1203
|
+
* reference it.
|
|
1204
|
+
*/
|
|
1205
|
+
function extractFanoutBodyStep(
|
|
1206
|
+
stmt: ts.Statement,
|
|
1207
|
+
childContext: ExtractionContext,
|
|
1208
|
+
body: Array<RpcStepMeta | SleepStepMeta | SuspendStepMeta>
|
|
1209
|
+
): void {
|
|
1210
|
+
if (ts.isVariableStatement(stmt)) {
|
|
1211
|
+
const declList = stmt.declarationList
|
|
1212
|
+
if (declList.declarations.length !== 1) {
|
|
1213
|
+
return
|
|
1214
|
+
}
|
|
1215
|
+
const decl = declList.declarations[0]
|
|
1216
|
+
const init = decl.initializer
|
|
1217
|
+
if (
|
|
1218
|
+
!init ||
|
|
1219
|
+
!ts.isAwaitExpression(init) ||
|
|
1220
|
+
!ts.isCallExpression(init.expression)
|
|
1221
|
+
) {
|
|
1222
|
+
return
|
|
1223
|
+
}
|
|
1224
|
+
const call = init.expression
|
|
1225
|
+
if (!isWorkflowDoCall(call, childContext.checker)) {
|
|
1226
|
+
return
|
|
1227
|
+
}
|
|
1228
|
+
const varName = ts.isIdentifier(decl.name) ? decl.name.text : undefined
|
|
1229
|
+
const step = extractRpcStep(call, childContext, varName)
|
|
1230
|
+
if (!step) {
|
|
1231
|
+
return
|
|
1232
|
+
}
|
|
1233
|
+
if (varName) {
|
|
1234
|
+
const type = childContext.checker.getTypeAtLocation(decl)
|
|
1235
|
+
childContext.outputVars.set(varName, { type, node: decl })
|
|
1236
|
+
if (isArrayType(type, childContext.checker)) {
|
|
1237
|
+
childContext.arrayVars.add(varName)
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
body.push(step)
|
|
1241
|
+
return
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
if (ts.isExpressionStatement(stmt) || ts.isReturnStatement(stmt)) {
|
|
1245
|
+
const expr = ts.isReturnStatement(stmt) ? stmt.expression : stmt.expression
|
|
1246
|
+
if (!expr) {
|
|
1247
|
+
return
|
|
1248
|
+
}
|
|
1249
|
+
const call = ts.isAwaitExpression(expr)
|
|
1250
|
+
? ts.isCallExpression(expr.expression)
|
|
1251
|
+
? expr.expression
|
|
1252
|
+
: null
|
|
1253
|
+
: ts.isCallExpression(expr)
|
|
1254
|
+
? expr
|
|
1255
|
+
: null
|
|
1256
|
+
if (!call) {
|
|
1257
|
+
return
|
|
1258
|
+
}
|
|
1259
|
+
if (isWorkflowSleepCall(call, childContext.checker)) {
|
|
1260
|
+
const sleepStep = extractSleepStep(call, childContext)
|
|
1261
|
+
if (sleepStep && sleepStep.type === 'sleep') {
|
|
1262
|
+
body.push(sleepStep)
|
|
1263
|
+
}
|
|
1264
|
+
return
|
|
1265
|
+
}
|
|
1266
|
+
if (isWorkflowSuspendCall(call, childContext.checker)) {
|
|
1267
|
+
const suspendStep = extractSuspendStep(call, childContext)
|
|
1268
|
+
if (suspendStep && suspendStep.type === 'suspend') {
|
|
1269
|
+
body.push(suspendStep)
|
|
1270
|
+
}
|
|
1271
|
+
return
|
|
1272
|
+
}
|
|
1273
|
+
if (!isWorkflowDoCall(call, childContext.checker)) {
|
|
1274
|
+
return
|
|
1275
|
+
}
|
|
1276
|
+
const step = extractRpcStep(call, childContext)
|
|
1277
|
+
if (step) {
|
|
1278
|
+
body.push(step)
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1106
1283
|
/**
|
|
1107
1284
|
* Extract parallel fanout from Promise.all(array.map(...))
|
|
1108
1285
|
*/
|
|
@@ -1141,66 +1318,49 @@ function extractParallelFanout(
|
|
|
1141
1318
|
|
|
1142
1319
|
const itemVar = itemParam.name.text
|
|
1143
1320
|
|
|
1144
|
-
//
|
|
1145
|
-
let doCall: ts.CallExpression | null = null
|
|
1146
|
-
|
|
1147
|
-
if (ts.isCallExpression(mapFn.body)) {
|
|
1148
|
-
doCall = mapFn.body
|
|
1149
|
-
} else if (ts.isAwaitExpression(mapFn.body)) {
|
|
1150
|
-
// Handle: async (email) => await workflow.do(...)
|
|
1151
|
-
if (ts.isCallExpression(mapFn.body.expression)) {
|
|
1152
|
-
doCall = mapFn.body.expression
|
|
1153
|
-
}
|
|
1154
|
-
} else if (ts.isBlock(mapFn.body)) {
|
|
1155
|
-
// Look for workflow.do in block
|
|
1156
|
-
for (const stmt of mapFn.body.statements) {
|
|
1157
|
-
if (ts.isExpressionStatement(stmt)) {
|
|
1158
|
-
// Handle: await workflow.do(...)
|
|
1159
|
-
if (
|
|
1160
|
-
ts.isAwaitExpression(stmt.expression) &&
|
|
1161
|
-
ts.isCallExpression(stmt.expression.expression)
|
|
1162
|
-
) {
|
|
1163
|
-
doCall = stmt.expression.expression
|
|
1164
|
-
break
|
|
1165
|
-
}
|
|
1166
|
-
} else if (ts.isReturnStatement(stmt) && stmt.expression) {
|
|
1167
|
-
if (ts.isCallExpression(stmt.expression)) {
|
|
1168
|
-
doCall = stmt.expression
|
|
1169
|
-
break
|
|
1170
|
-
} else if (ts.isAwaitExpression(stmt.expression)) {
|
|
1171
|
-
// Handle: return await workflow.do(...)
|
|
1172
|
-
if (ts.isCallExpression(stmt.expression.expression)) {
|
|
1173
|
-
doCall = stmt.expression.expression
|
|
1174
|
-
break
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1177
|
-
}
|
|
1178
|
-
}
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
if (!doCall || !isWorkflowDoCall(doCall, context.checker)) {
|
|
1182
|
-
return null
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
// Create a temporary context for the child step with the loop variable
|
|
1321
|
+
// Create a temporary context for the child steps with the loop variable
|
|
1186
1322
|
const childContext: ExtractionContext = {
|
|
1187
1323
|
...context,
|
|
1188
1324
|
outputVars: new Map(context.outputVars),
|
|
1325
|
+
arrayVars: new Set(context.arrayVars),
|
|
1189
1326
|
loopVars: new Set([...context.loopVars, itemVar]),
|
|
1190
1327
|
}
|
|
1191
1328
|
|
|
1192
|
-
const
|
|
1193
|
-
|
|
1329
|
+
const body: FanoutStepMeta['body'] = []
|
|
1330
|
+
|
|
1331
|
+
if (ts.isBlock(mapFn.body)) {
|
|
1332
|
+
for (const stmt of mapFn.body.statements) {
|
|
1333
|
+
extractFanoutBodyStep(stmt, childContext, body)
|
|
1334
|
+
}
|
|
1335
|
+
} else {
|
|
1336
|
+
// Concise body: (item) => workflow.do(...) / async (item) => await workflow.do(...)
|
|
1337
|
+
const expr = mapFn.body
|
|
1338
|
+
const doCall = ts.isAwaitExpression(expr)
|
|
1339
|
+
? ts.isCallExpression(expr.expression)
|
|
1340
|
+
? expr.expression
|
|
1341
|
+
: null
|
|
1342
|
+
: ts.isCallExpression(expr)
|
|
1343
|
+
? expr
|
|
1344
|
+
: null
|
|
1345
|
+
|
|
1346
|
+
if (doCall && isWorkflowDoCall(doCall, context.checker)) {
|
|
1347
|
+
const step = extractRpcStep(doCall, childContext)
|
|
1348
|
+
if (step) {
|
|
1349
|
+
body.push(step)
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
if (body.length === 0) {
|
|
1194
1355
|
return null
|
|
1195
1356
|
}
|
|
1196
1357
|
|
|
1197
1358
|
return {
|
|
1198
1359
|
type: 'fanout',
|
|
1199
|
-
stepName: childStep.stepName,
|
|
1200
1360
|
sourceVar,
|
|
1201
1361
|
itemVar,
|
|
1202
1362
|
mode: 'parallel',
|
|
1203
|
-
|
|
1363
|
+
body,
|
|
1204
1364
|
}
|
|
1205
1365
|
}
|
|
1206
1366
|
|
|
@@ -1255,65 +1415,34 @@ function extractSequentialFanout(
|
|
|
1255
1415
|
|
|
1256
1416
|
const { itemVar, sourceVar } = vars
|
|
1257
1417
|
|
|
1258
|
-
// Extract child
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1418
|
+
// Extract child steps and optional sleep from the loop body. A brace-less
|
|
1419
|
+
// body (`for (const x of xs) await workflow.do(...)`) is a single statement
|
|
1420
|
+
// rather than a block, and must still be extracted.
|
|
1421
|
+
const bodyStatements = ts.isBlock(statement.statement)
|
|
1422
|
+
? statement.statement.statements
|
|
1423
|
+
: [statement.statement]
|
|
1262
1424
|
|
|
1263
|
-
|
|
1425
|
+
const body: FanoutStepMeta['body'] = []
|
|
1264
1426
|
let timeBetween: string | undefined = undefined
|
|
1265
1427
|
|
|
1266
1428
|
// Create a child context with the loop variable added
|
|
1267
1429
|
const childContext: ExtractionContext = {
|
|
1268
1430
|
...context,
|
|
1269
1431
|
outputVars: new Map(context.outputVars),
|
|
1432
|
+
arrayVars: new Set(context.arrayVars),
|
|
1270
1433
|
loopVars: new Set([...context.loopVars, itemVar]),
|
|
1271
1434
|
}
|
|
1272
1435
|
|
|
1273
|
-
|
|
1436
|
+
for (const stmt of bodyStatements) {
|
|
1437
|
+
extractFanoutBodyStep(stmt, childContext, body)
|
|
1274
1438
|
|
|
1275
|
-
|
|
1276
|
-
// Look for workflow.do in VariableStatement (const x = await workflow.do(...))
|
|
1277
|
-
if (ts.isVariableStatement(stmt)) {
|
|
1278
|
-
const declList = stmt.declarationList
|
|
1279
|
-
if (declList.declarations.length === 1) {
|
|
1280
|
-
const decl = declList.declarations[0]
|
|
1281
|
-
const init = decl.initializer
|
|
1282
|
-
if (
|
|
1283
|
-
init &&
|
|
1284
|
-
ts.isAwaitExpression(init) &&
|
|
1285
|
-
ts.isCallExpression(init.expression)
|
|
1286
|
-
) {
|
|
1287
|
-
const call = init.expression
|
|
1288
|
-
if (isWorkflowDoCall(call, context.checker)) {
|
|
1289
|
-
workflowDoCount++
|
|
1290
|
-
const varName = ts.isIdentifier(decl.name)
|
|
1291
|
-
? decl.name.text
|
|
1292
|
-
: undefined
|
|
1293
|
-
const step = extractRpcStep(call, childContext, varName)
|
|
1294
|
-
if (step) {
|
|
1295
|
-
childStep = step
|
|
1296
|
-
}
|
|
1297
|
-
}
|
|
1298
|
-
}
|
|
1299
|
-
}
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1302
|
-
// Look for workflow.do in ExpressionStatement (await workflow.do(...))
|
|
1439
|
+
// Look for workflow.sleep in ExpressionStatement
|
|
1303
1440
|
if (ts.isExpressionStatement(stmt)) {
|
|
1304
1441
|
const expr = stmt.expression
|
|
1305
1442
|
|
|
1306
1443
|
if (ts.isAwaitExpression(expr) && ts.isCallExpression(expr.expression)) {
|
|
1307
1444
|
const call = expr.expression
|
|
1308
1445
|
|
|
1309
|
-
if (isWorkflowDoCall(call, context.checker)) {
|
|
1310
|
-
workflowDoCount++
|
|
1311
|
-
const step = extractRpcStep(call, childContext)
|
|
1312
|
-
if (step) {
|
|
1313
|
-
childStep = step
|
|
1314
|
-
}
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
1446
|
if (isWorkflowSleepCall(call, context.checker)) {
|
|
1318
1447
|
// Extract duration for timeBetween
|
|
1319
1448
|
const args = call.arguments
|
|
@@ -1371,26 +1500,16 @@ function extractSequentialFanout(
|
|
|
1371
1500
|
}
|
|
1372
1501
|
}
|
|
1373
1502
|
|
|
1374
|
-
if (
|
|
1375
|
-
return null
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
// If there are multiple workflow.do calls, the loop is too complex for DSL
|
|
1379
|
-
if (workflowDoCount > 1) {
|
|
1380
|
-
context.errors.push({
|
|
1381
|
-
message: `For-of loop has ${workflowDoCount} workflow.do calls but DSL only supports 1. Use pikkuWorkflowComplexFunc for complex loops.`,
|
|
1382
|
-
node: statement,
|
|
1383
|
-
})
|
|
1503
|
+
if (body.length === 0) {
|
|
1384
1504
|
return null
|
|
1385
1505
|
}
|
|
1386
1506
|
|
|
1387
1507
|
return {
|
|
1388
1508
|
type: 'fanout',
|
|
1389
|
-
stepName: childStep.stepName,
|
|
1390
1509
|
sourceVar,
|
|
1391
1510
|
itemVar,
|
|
1392
1511
|
mode: 'sequential',
|
|
1393
|
-
|
|
1512
|
+
body,
|
|
1394
1513
|
timeBetween,
|
|
1395
1514
|
}
|
|
1396
1515
|
}
|
|
@@ -1493,11 +1612,21 @@ function extractReturn(
|
|
|
1493
1612
|
}
|
|
1494
1613
|
}
|
|
1495
1614
|
|
|
1615
|
+
// `return r` returns every field of r — record it as a spread of one.
|
|
1616
|
+
if (ts.isIdentifier(statement.expression)) {
|
|
1617
|
+
const varName = statement.expression.text
|
|
1618
|
+
if (context.outputVars.has(varName) || context.contextVars.has(varName)) {
|
|
1619
|
+
return { type: 'return', outputs: {}, spread: [varName] }
|
|
1620
|
+
}
|
|
1621
|
+
return null
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1496
1624
|
if (!ts.isObjectLiteralExpression(statement.expression)) {
|
|
1497
1625
|
return null
|
|
1498
1626
|
}
|
|
1499
1627
|
|
|
1500
1628
|
const outputs: Record<string, OutputBinding> = {}
|
|
1629
|
+
const spread: string[] = []
|
|
1501
1630
|
|
|
1502
1631
|
for (const prop of statement.expression.properties) {
|
|
1503
1632
|
if (
|
|
@@ -1528,16 +1657,22 @@ function extractReturn(
|
|
|
1528
1657
|
if (binding) {
|
|
1529
1658
|
outputs[propName] = binding
|
|
1530
1659
|
}
|
|
1660
|
+
} else if (
|
|
1661
|
+
ts.isSpreadAssignment(prop) &&
|
|
1662
|
+
ts.isIdentifier(prop.expression)
|
|
1663
|
+
) {
|
|
1664
|
+
spread.push(prop.expression.text)
|
|
1531
1665
|
}
|
|
1532
1666
|
}
|
|
1533
1667
|
|
|
1534
|
-
if (Object.keys(outputs).length === 0) {
|
|
1668
|
+
if (Object.keys(outputs).length === 0 && spread.length === 0) {
|
|
1535
1669
|
return null
|
|
1536
1670
|
}
|
|
1537
1671
|
|
|
1538
1672
|
return {
|
|
1539
1673
|
type: 'return',
|
|
1540
1674
|
outputs,
|
|
1675
|
+
...(spread.length > 0 ? { spread } : {}),
|
|
1541
1676
|
}
|
|
1542
1677
|
}
|
|
1543
1678
|
|
|
@@ -1652,6 +1787,11 @@ function extractInputSource(
|
|
|
1652
1787
|
return { from: 'input', path: propName }
|
|
1653
1788
|
}
|
|
1654
1789
|
|
|
1790
|
+
// A field of the fanout item: users.map((u) => do(..., { id: u.id }))
|
|
1791
|
+
if (context.loopVars.has(objName)) {
|
|
1792
|
+
return { from: 'item', path: propName }
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1655
1795
|
if (context.outputVars.has(objName)) {
|
|
1656
1796
|
return { from: 'outputVar', name: objName, path: propName }
|
|
1657
1797
|
}
|