@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/dist/add/add-credential.js +2 -0
  3. package/dist/add/add-keyed-wiring.js +2 -0
  4. package/dist/add/add-workflow.js +9 -4
  5. package/dist/utils/schema-generator.js +19 -4
  6. package/dist/utils/workflow/dsl/deserialize-dsl-workflow.js +373 -91
  7. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +218 -99
  8. package/dist/utils/workflow/graph/convert-dsl-to-graph.js +62 -5
  9. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +3 -3
  10. package/package.json +3 -3
  11. package/run-tests.sh +1 -1
  12. package/src/add/add-credential.ts +2 -0
  13. package/src/add/add-keyed-wiring.ts +2 -0
  14. package/src/add/add-workflow-edge-cases.test.ts +201 -0
  15. package/src/add/add-workflow-fanout.test.ts +60 -0
  16. package/src/add/add-workflow-on-error.test.ts +118 -0
  17. package/src/add/add-workflow-return-switch.test.ts +121 -0
  18. package/src/add/add-workflow-silent-drops.test.ts +163 -0
  19. package/src/add/add-workflow-sleep-expression.test.ts +97 -0
  20. package/src/add/add-workflow.ts +9 -4
  21. package/src/add/config-value-metadata.test.ts +169 -0
  22. package/src/utils/schema-generator.test.ts +164 -0
  23. package/src/utils/schema-generator.ts +26 -3
  24. package/src/utils/workflow/dsl/deserialize-dsl-workflow.ts +480 -119
  25. package/src/utils/workflow/dsl/deserialize-fanout-body.test.ts +71 -0
  26. package/src/utils/workflow/dsl/deserialize-fanout-fidelity.test.ts +99 -0
  27. package/src/utils/workflow/dsl/deserialize-graph-next.test.ts +161 -0
  28. package/src/utils/workflow/dsl/deserialize-roundtrip-bindings.test.ts +121 -0
  29. package/src/utils/workflow/dsl/deserialize-roundtrip-values.test.ts +116 -0
  30. package/src/utils/workflow/dsl/deserialize-roundtrip.test.ts +145 -0
  31. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +245 -105
  32. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +67 -10
  33. package/src/utils/workflow/graph/fanout-node-identity.test.ts +105 -0
  34. package/src/utils/workflow/graph/workflow-graph.types.ts +3 -2
  35. 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 null
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 - use expression as string
550
+ // Non-literal assignment to context var - keep the source expression
482
551
  return {
483
552
  type: 'set',
484
553
  variable: outputVar,
485
- value: getSourceText(expr.right),
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
- duration = extractStringLiteral(args[1], context.checker)
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
- // Extract workflow.do call from map body
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 childStep = extractRpcStep(doCall, childContext)
1193
- if (!childStep) {
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
- child: childStep,
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 step and optional sleep from loop body
1259
- if (!ts.isBlock(statement.statement)) {
1260
- return null
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
- let childStep: RpcStepMeta | null = null
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
- let workflowDoCount = 0
1436
+ for (const stmt of bodyStatements) {
1437
+ extractFanoutBodyStep(stmt, childContext, body)
1274
1438
 
1275
- for (const stmt of statement.statement.statements) {
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 (!childStep) {
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
- child: childStep,
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
  }