@pikku/inspector 0.10.2 → 0.11.1

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 (82) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/add/add-channel.js +11 -10
  3. package/dist/add/add-file-with-factory.js +10 -10
  4. package/dist/add/add-functions.js +65 -44
  5. package/dist/add/add-http-route.js +5 -4
  6. package/dist/add/add-mcp-prompt.js +6 -5
  7. package/dist/add/add-mcp-resource.js +6 -5
  8. package/dist/add/add-mcp-tool.js +6 -5
  9. package/dist/add/add-middleware.js +1 -1
  10. package/dist/add/add-permission.js +1 -1
  11. package/dist/add/add-queue-worker.js +6 -5
  12. package/dist/add/add-schedule.js +5 -4
  13. package/dist/add/add-workflow.d.ts +6 -0
  14. package/dist/add/add-workflow.js +178 -0
  15. package/dist/error-codes.d.ts +5 -1
  16. package/dist/error-codes.js +5 -0
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.js +1 -0
  19. package/dist/inspector.js +13 -5
  20. package/dist/types.d.ts +27 -9
  21. package/dist/utils/extract-function-node.d.ts +10 -0
  22. package/dist/utils/extract-function-node.js +38 -0
  23. package/dist/utils/extract-node-value.d.ts +32 -0
  24. package/dist/utils/extract-node-value.js +103 -0
  25. package/dist/utils/extract-service-metadata.d.ts +19 -0
  26. package/dist/utils/extract-service-metadata.js +244 -0
  27. package/dist/utils/get-files-and-methods.d.ts +3 -3
  28. package/dist/utils/get-files-and-methods.js +3 -3
  29. package/dist/utils/get-property-value.d.ts +13 -6
  30. package/dist/utils/get-property-value.js +51 -43
  31. package/dist/utils/post-process.d.ts +9 -0
  32. package/dist/utils/post-process.js +30 -3
  33. package/dist/utils/serialize-inspector-state.d.ts +21 -4
  34. package/dist/utils/serialize-inspector-state.js +18 -8
  35. package/dist/utils/type-utils.d.ts +4 -0
  36. package/dist/utils/type-utils.js +55 -0
  37. package/dist/utils/write-service-metadata.d.ts +13 -0
  38. package/dist/utils/write-service-metadata.js +37 -0
  39. package/dist/visit.js +4 -2
  40. package/dist/workflow/extract-simple-workflow.d.ts +15 -0
  41. package/dist/workflow/extract-simple-workflow.js +803 -0
  42. package/dist/workflow/patterns.d.ts +39 -0
  43. package/dist/workflow/patterns.js +138 -0
  44. package/dist/workflow/validation.d.ts +28 -0
  45. package/dist/workflow/validation.js +124 -0
  46. package/package.json +4 -4
  47. package/src/add/add-channel.ts +37 -17
  48. package/src/add/add-file-with-factory.ts +10 -10
  49. package/src/add/add-functions.ts +81 -57
  50. package/src/add/add-http-route.ts +10 -5
  51. package/src/add/add-mcp-prompt.ts +11 -7
  52. package/src/add/add-mcp-resource.ts +11 -7
  53. package/src/add/add-mcp-tool.ts +11 -7
  54. package/src/add/add-middleware.ts +1 -1
  55. package/src/add/add-permission.ts +1 -1
  56. package/src/add/add-queue-worker.ts +11 -12
  57. package/src/add/add-schedule.ts +10 -5
  58. package/src/add/add-workflow.ts +241 -0
  59. package/src/error-codes.ts +6 -0
  60. package/src/index.ts +2 -0
  61. package/src/inspector.ts +19 -5
  62. package/src/types.ts +24 -9
  63. package/src/utils/extract-function-node.ts +58 -0
  64. package/src/utils/extract-node-value.ts +132 -0
  65. package/src/utils/extract-service-metadata.ts +353 -0
  66. package/src/utils/filter-inspector-state.test.ts +3 -3
  67. package/src/utils/filter-utils.test.ts +45 -51
  68. package/src/utils/get-files-and-methods.ts +11 -11
  69. package/src/utils/get-property-value.ts +60 -53
  70. package/src/utils/permissions.test.ts +3 -3
  71. package/src/utils/post-process.ts +56 -3
  72. package/src/utils/serialize-inspector-state.ts +37 -15
  73. package/src/utils/test-data/inspector-state.json +13 -9
  74. package/src/utils/type-utils.ts +69 -0
  75. package/src/utils/write-service-metadata.ts +51 -0
  76. package/src/visit.ts +5 -3
  77. package/src/workflow/extract-simple-workflow.ts +1035 -0
  78. package/src/workflow/patterns.ts +182 -0
  79. package/src/workflow/validation.ts +153 -0
  80. package/tsconfig.tsbuildinfo +1 -1
  81. package/src/add/add-mcp-prompt.ts.tmp +0 -0
  82. package/src/add/add-mcp-resource.ts.tmp +0 -0
@@ -0,0 +1,1035 @@
1
+ import * as ts from 'typescript'
2
+ import {
3
+ WorkflowStepMeta,
4
+ RpcStepMeta,
5
+ BranchStepMeta,
6
+ ParallelGroupStepMeta,
7
+ FanoutStepMeta,
8
+ ReturnStepMeta,
9
+ InputSource,
10
+ OutputBinding,
11
+ } from '@pikku/core/workflow'
12
+ import {
13
+ extractStringLiteral,
14
+ extractNumberLiteral,
15
+ } from '../utils/extract-node-value.js'
16
+ import {
17
+ isWorkflowDoCall,
18
+ isWorkflowSleepCall,
19
+ isParallelFanout,
20
+ isParallelGroup,
21
+ isSequentialFanout,
22
+ extractForOfVariable,
23
+ isArrayType,
24
+ getSourceText,
25
+ } from './patterns.js'
26
+ import {
27
+ validateNoDisallowedPatterns,
28
+ validateAwaitedCalls,
29
+ formatValidationErrors,
30
+ ValidationError,
31
+ } from './validation.js'
32
+
33
+ /**
34
+ * Extraction context to track state during AST traversal
35
+ */
36
+ interface ExtractionContext {
37
+ checker: ts.TypeChecker
38
+ outputVars: Map<string, { type: ts.Type; node: ts.Node }>
39
+ arrayVars: Set<string>
40
+ conditionalVars: Set<string>
41
+ inputParamName: string
42
+ errors: ValidationError[]
43
+ }
44
+
45
+ /**
46
+ * Result of simple workflow extraction
47
+ */
48
+ export interface ExtractionResult {
49
+ status: 'ok' | 'error'
50
+ steps?: WorkflowStepMeta[]
51
+ reason?: string
52
+ simple?: boolean
53
+ }
54
+
55
+ /**
56
+ * Extract simple workflow metadata from a function declaration
57
+ */
58
+ export function extractSimpleWorkflow(
59
+ funcNode: ts.Node,
60
+ checker: ts.TypeChecker
61
+ ): ExtractionResult {
62
+ try {
63
+ // Find the async arrow function
64
+ const arrowFunc = findWorkflowFunction(funcNode)
65
+ if (!arrowFunc) {
66
+ return {
67
+ status: 'error',
68
+ reason: 'Could not find async arrow function in workflow definition',
69
+ simple: false,
70
+ }
71
+ }
72
+
73
+ // Extract input parameter name (second parameter)
74
+ const inputParamName = extractInputParamName(arrowFunc)
75
+ if (!inputParamName) {
76
+ return {
77
+ status: 'error',
78
+ reason: 'Could not determine input parameter name',
79
+ simple: false,
80
+ }
81
+ }
82
+
83
+ // Initialize extraction context
84
+ const context: ExtractionContext = {
85
+ checker,
86
+ outputVars: new Map(),
87
+ arrayVars: new Set(),
88
+ conditionalVars: new Set(),
89
+ inputParamName,
90
+ errors: [],
91
+ }
92
+
93
+ // Validate no disallowed patterns
94
+ const patternErrors = validateNoDisallowedPatterns(arrowFunc.body)
95
+ if (patternErrors.length > 0) {
96
+ return {
97
+ status: 'error',
98
+ reason: formatValidationErrors(patternErrors),
99
+ simple: false,
100
+ }
101
+ }
102
+
103
+ // Validate all workflow calls are awaited
104
+ const awaitErrors = validateAwaitedCalls(arrowFunc.body)
105
+ if (awaitErrors.length > 0) {
106
+ return {
107
+ status: 'error',
108
+ reason: formatValidationErrors(awaitErrors),
109
+ simple: false,
110
+ }
111
+ }
112
+
113
+ // Extract steps from function body
114
+ const steps = extractSteps(arrowFunc.body, context)
115
+
116
+ // Check for any accumulated errors
117
+ if (context.errors.length > 0) {
118
+ return {
119
+ status: 'error',
120
+ reason: formatValidationErrors(context.errors),
121
+ simple: false,
122
+ }
123
+ }
124
+
125
+ return {
126
+ status: 'ok',
127
+ steps,
128
+ simple: true,
129
+ }
130
+ } catch (error) {
131
+ return {
132
+ status: 'error',
133
+ reason: error instanceof Error ? error.message : String(error),
134
+ simple: false,
135
+ }
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Find the workflow function (async arrow function)
141
+ */
142
+ function findWorkflowFunction(node: ts.Node): ts.ArrowFunction | null {
143
+ // Handle pikkuSimpleWorkflowFunc(async () => {}) or pikkuWorkflowFunc(async () => {})
144
+ if (ts.isCallExpression(node)) {
145
+ const arg = node.arguments[0]
146
+ if (arg && ts.isArrowFunction(arg)) {
147
+ return arg
148
+ }
149
+ // Also check if first argument is an object with func property
150
+ if (arg && ts.isObjectLiteralExpression(arg)) {
151
+ for (const prop of arg.properties) {
152
+ if (
153
+ ts.isPropertyAssignment(prop) &&
154
+ ts.isIdentifier(prop.name) &&
155
+ prop.name.text === 'func'
156
+ ) {
157
+ if (ts.isArrowFunction(prop.initializer)) {
158
+ return prop.initializer
159
+ }
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ // Handle pikkuSimpleWorkflowFunc({ func: async () => {} })
166
+ if (ts.isObjectLiteralExpression(node)) {
167
+ for (const prop of node.properties) {
168
+ if (
169
+ ts.isPropertyAssignment(prop) &&
170
+ ts.isIdentifier(prop.name) &&
171
+ prop.name.text === 'func'
172
+ ) {
173
+ if (ts.isArrowFunction(prop.initializer)) {
174
+ return prop.initializer
175
+ }
176
+ }
177
+ }
178
+ }
179
+
180
+ return null
181
+ }
182
+
183
+ /**
184
+ * Extract the input parameter name from the arrow function
185
+ */
186
+ function extractInputParamName(arrowFunc: ts.ArrowFunction): string | null {
187
+ if (arrowFunc.parameters.length < 2) {
188
+ return null
189
+ }
190
+
191
+ const secondParam = arrowFunc.parameters[1]
192
+ if (ts.isIdentifier(secondParam.name)) {
193
+ return secondParam.name.text
194
+ }
195
+
196
+ return null
197
+ }
198
+
199
+ /**
200
+ * Extract steps from the function body
201
+ */
202
+ function extractSteps(
203
+ body: ts.Node,
204
+ context: ExtractionContext
205
+ ): WorkflowStepMeta[] {
206
+ const steps: WorkflowStepMeta[] = []
207
+
208
+ if (!ts.isBlock(body)) {
209
+ return steps
210
+ }
211
+
212
+ for (const statement of body.statements) {
213
+ const extracted = extractStep(statement, context)
214
+ if (extracted) {
215
+ steps.push(extracted)
216
+ }
217
+ }
218
+
219
+ return steps
220
+ }
221
+
222
+ /**
223
+ * Extract a single step from a statement
224
+ */
225
+ function extractStep(
226
+ statement: ts.Statement,
227
+ context: ExtractionContext
228
+ ): WorkflowStepMeta | null {
229
+ // Variable declaration with workflow.do assignment
230
+ if (ts.isVariableStatement(statement)) {
231
+ return extractVariableDeclaration(statement, context)
232
+ }
233
+
234
+ // Expression statement (await workflow.do without assignment)
235
+ if (ts.isExpressionStatement(statement)) {
236
+ return extractExpressionStatement(statement, context)
237
+ }
238
+
239
+ // If statement (branch)
240
+ if (ts.isIfStatement(statement)) {
241
+ return extractBranch(statement, context)
242
+ }
243
+
244
+ // For-of statement (sequential fanout)
245
+ if (ts.isForOfStatement(statement)) {
246
+ return extractSequentialFanout(statement, context)
247
+ }
248
+
249
+ // Return statement
250
+ if (ts.isReturnStatement(statement)) {
251
+ return extractReturn(statement, context)
252
+ }
253
+
254
+ return null
255
+ }
256
+
257
+ /**
258
+ * Extract variable declaration (const x = await workflow.do(...))
259
+ */
260
+ function extractVariableDeclaration(
261
+ statement: ts.VariableStatement,
262
+ context: ExtractionContext
263
+ ): WorkflowStepMeta | null {
264
+ const declList = statement.declarationList
265
+ if (declList.declarations.length !== 1) {
266
+ return null
267
+ }
268
+
269
+ const decl = declList.declarations[0]
270
+ if (!ts.isIdentifier(decl.name)) {
271
+ return null
272
+ }
273
+
274
+ const varName = decl.name.text
275
+ const init = decl.initializer
276
+
277
+ if (!init) {
278
+ return null
279
+ }
280
+
281
+ // Check for await workflow.do(...)
282
+ if (ts.isAwaitExpression(init) && ts.isCallExpression(init.expression)) {
283
+ const call = init.expression
284
+ if (isWorkflowDoCall(call, context.checker)) {
285
+ const step = extractRpcStep(call, context, varName)
286
+ if (step) {
287
+ // Track output variable
288
+ const type = context.checker.getTypeAtLocation(decl)
289
+ context.outputVars.set(varName, { type, node: decl })
290
+
291
+ // Check if it's an array type
292
+ if (isArrayType(type, context.checker)) {
293
+ context.arrayVars.add(varName)
294
+ }
295
+
296
+ // Check if it's a conditional variable (let x: T | undefined)
297
+ if (declList.flags & ts.NodeFlags.Let) {
298
+ const typeNode = decl.type
299
+ if (typeNode && ts.isUnionTypeNode(typeNode)) {
300
+ // Check if union includes undefined
301
+ const hasUndefined = typeNode.types.some(
302
+ (t) =>
303
+ (ts.isLiteralTypeNode(t) &&
304
+ t.literal.kind === ts.SyntaxKind.UndefinedKeyword) ||
305
+ t.kind === ts.SyntaxKind.UndefinedKeyword
306
+ )
307
+ if (hasUndefined) {
308
+ context.conditionalVars.add(varName)
309
+ }
310
+ }
311
+ }
312
+
313
+ return step
314
+ }
315
+ }
316
+ }
317
+
318
+ return null
319
+ }
320
+
321
+ /**
322
+ * Extract expression statement (await workflow.do(...) without assignment)
323
+ */
324
+ function extractExpressionStatement(
325
+ statement: ts.ExpressionStatement,
326
+ context: ExtractionContext
327
+ ): WorkflowStepMeta | null {
328
+ let expr = statement.expression
329
+
330
+ // Handle assignment: owner = await workflow.do(...)
331
+ let outputVar: string | undefined
332
+ if (
333
+ ts.isBinaryExpression(expr) &&
334
+ expr.operatorToken.kind === ts.SyntaxKind.EqualsToken
335
+ ) {
336
+ // Extract variable name from left side
337
+ if (ts.isIdentifier(expr.left)) {
338
+ outputVar = expr.left.text
339
+ }
340
+ // Use right side as the expression to extract from
341
+ expr = expr.right
342
+ }
343
+
344
+ // await workflow.do(...)
345
+ if (ts.isAwaitExpression(expr) && ts.isCallExpression(expr.expression)) {
346
+ const call = expr.expression
347
+
348
+ if (isWorkflowDoCall(call, context.checker)) {
349
+ const step = extractRpcStep(call, context, outputVar)
350
+
351
+ // Track output variable if this is an assignment
352
+ if (outputVar && step) {
353
+ const type = context.checker.getTypeAtLocation(expr)
354
+ context.outputVars.set(outputVar, { type, node: expr })
355
+
356
+ // Check if it's an array type
357
+ if (isArrayType(type, context.checker)) {
358
+ context.arrayVars.add(outputVar)
359
+ }
360
+ }
361
+
362
+ return step
363
+ }
364
+
365
+ if (isWorkflowSleepCall(call, context.checker)) {
366
+ return extractSleepStep(call, context)
367
+ }
368
+
369
+ // Check for parallel group or fanout
370
+ if (isParallelFanout(call)) {
371
+ return extractParallelFanout(call, context)
372
+ }
373
+
374
+ if (isParallelGroup(call)) {
375
+ return extractParallelGroup(call, context)
376
+ }
377
+ }
378
+
379
+ return null
380
+ }
381
+
382
+ /**
383
+ * Extract RPC step from workflow.do() call
384
+ */
385
+ function extractRpcStep(
386
+ call: ts.CallExpression,
387
+ context: ExtractionContext,
388
+ outputVar?: string
389
+ ): RpcStepMeta | null {
390
+ const args = call.arguments
391
+
392
+ if (args.length < 2) {
393
+ return null
394
+ }
395
+
396
+ try {
397
+ const stepName = extractStringLiteral(args[0], context.checker)
398
+ const rpcName = extractStringLiteral(args[1], context.checker)
399
+
400
+ // Extract inputs from third argument
401
+ const inputs =
402
+ args.length >= 3 ? extractInputSources(args[2], context) : undefined
403
+
404
+ // Extract options from fourth argument
405
+ const options =
406
+ args.length >= 4 && ts.isObjectLiteralExpression(args[3])
407
+ ? extractStepOptions(args[3], context)
408
+ : undefined
409
+
410
+ return {
411
+ type: 'rpc',
412
+ stepName,
413
+ rpcName,
414
+ outputVar,
415
+ inputs,
416
+ options,
417
+ }
418
+ } catch (error) {
419
+ context.errors.push({
420
+ message: `Failed to extract RPC step: ${error instanceof Error ? error.message : String(error)}`,
421
+ node: call,
422
+ })
423
+ return null
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Extract step options from options object
429
+ */
430
+ function extractStepOptions(
431
+ optionsNode: ts.ObjectLiteralExpression,
432
+ context: ExtractionContext
433
+ ): RpcStepMeta['options'] {
434
+ const options: RpcStepMeta['options'] = {}
435
+
436
+ for (const prop of optionsNode.properties) {
437
+ if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
438
+ const propName = prop.name.text
439
+
440
+ if (propName === 'retries') {
441
+ const retries = extractNumberLiteral(prop.initializer)
442
+ if (retries !== null) {
443
+ options.retries = retries
444
+ }
445
+ } else if (propName === 'retryDelay') {
446
+ try {
447
+ if (ts.isStringLiteral(prop.initializer)) {
448
+ options.retryDelay = prop.initializer.text
449
+ } else {
450
+ const delay = extractNumberLiteral(prop.initializer)
451
+ if (delay !== null) {
452
+ options.retryDelay = delay
453
+ }
454
+ }
455
+ } catch {
456
+ // Ignore extraction errors for retryDelay
457
+ }
458
+ } else if (propName === 'description') {
459
+ try {
460
+ options.description = extractStringLiteral(
461
+ prop.initializer,
462
+ context.checker
463
+ )
464
+ } catch {
465
+ // Ignore extraction errors for description
466
+ }
467
+ }
468
+ }
469
+ }
470
+
471
+ return Object.keys(options).length > 0 ? options : undefined
472
+ }
473
+
474
+ /**
475
+ * Extract sleep step from workflow.sleep() call
476
+ */
477
+ function extractSleepStep(
478
+ call: ts.CallExpression,
479
+ context: ExtractionContext
480
+ ): WorkflowStepMeta | null {
481
+ const args = call.arguments
482
+
483
+ if (args.length < 2) {
484
+ return null
485
+ }
486
+
487
+ try {
488
+ const stepName = extractStringLiteral(args[0], context.checker)
489
+ let duration: string | number
490
+
491
+ const numValue = extractNumberLiteral(args[1])
492
+ if (numValue !== null) {
493
+ duration = numValue
494
+ } else {
495
+ duration = extractStringLiteral(args[1], context.checker)
496
+ }
497
+
498
+ return {
499
+ type: 'sleep',
500
+ stepName,
501
+ duration,
502
+ }
503
+ } catch (error) {
504
+ context.errors.push({
505
+ message: `Failed to extract sleep step: ${error instanceof Error ? error.message : String(error)}`,
506
+ node: call,
507
+ })
508
+ return null
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Extract branch step from if statement
514
+ */
515
+ function extractBranch(
516
+ statement: ts.IfStatement,
517
+ context: ExtractionContext
518
+ ): BranchStepMeta | null {
519
+ const condition = getSourceText(statement.expression)
520
+
521
+ // Handle both block statements and single statements
522
+ const thenSteps = ts.isBlock(statement.thenStatement)
523
+ ? extractSteps(statement.thenStatement, context)
524
+ : extractStepsFromStatement(statement.thenStatement, context)
525
+
526
+ const elseSteps = statement.elseStatement
527
+ ? ts.isBlock(statement.elseStatement)
528
+ ? extractSteps(statement.elseStatement, context)
529
+ : extractStepsFromStatement(statement.elseStatement, context)
530
+ : undefined
531
+
532
+ return {
533
+ type: 'branch',
534
+ condition,
535
+ branches: {
536
+ then: thenSteps,
537
+ else: elseSteps,
538
+ },
539
+ }
540
+ }
541
+
542
+ /**
543
+ * Extract steps from a single statement (non-block)
544
+ */
545
+ function extractStepsFromStatement(
546
+ statement: ts.Statement,
547
+ context: ExtractionContext
548
+ ): WorkflowStepMeta[] {
549
+ const step = extractStep(statement, context)
550
+ return step ? [step] : []
551
+ }
552
+
553
+ /**
554
+ * Extract parallel fanout from Promise.all(array.map(...))
555
+ */
556
+ function extractParallelFanout(
557
+ call: ts.CallExpression,
558
+ context: ExtractionContext
559
+ ): FanoutStepMeta | null {
560
+ const mapCall = call.arguments[0]
561
+ if (!ts.isCallExpression(mapCall)) {
562
+ return null
563
+ }
564
+
565
+ if (!ts.isPropertyAccessExpression(mapCall.expression)) {
566
+ return null
567
+ }
568
+
569
+ // Extract source array
570
+ const sourceExpr = mapCall.expression.expression
571
+ let sourceVar: string | null = null
572
+
573
+ if (ts.isIdentifier(sourceExpr)) {
574
+ sourceVar = sourceExpr.text
575
+ } else if (
576
+ ts.isPropertyAccessExpression(sourceExpr) &&
577
+ ts.isIdentifier(sourceExpr.expression)
578
+ ) {
579
+ sourceVar = sourceExpr.expression.text
580
+ }
581
+
582
+ if (!sourceVar) {
583
+ return null
584
+ }
585
+
586
+ // Extract map function
587
+ const mapFn = mapCall.arguments[0]
588
+ if (!ts.isArrowFunction(mapFn)) {
589
+ return null
590
+ }
591
+
592
+ // Extract item variable
593
+ const itemParam = mapFn.parameters[0]
594
+ if (!itemParam || !ts.isIdentifier(itemParam.name)) {
595
+ return null
596
+ }
597
+
598
+ const itemVar = itemParam.name.text
599
+
600
+ // Extract workflow.do call from map body
601
+ let doCall: ts.CallExpression | null = null
602
+
603
+ if (ts.isCallExpression(mapFn.body)) {
604
+ doCall = mapFn.body
605
+ } else if (ts.isAwaitExpression(mapFn.body)) {
606
+ // Handle: async (email) => await workflow.do(...)
607
+ if (ts.isCallExpression(mapFn.body.expression)) {
608
+ doCall = mapFn.body.expression
609
+ }
610
+ } else if (ts.isBlock(mapFn.body)) {
611
+ // Look for workflow.do in block
612
+ for (const stmt of mapFn.body.statements) {
613
+ if (ts.isReturnStatement(stmt) && stmt.expression) {
614
+ if (ts.isCallExpression(stmt.expression)) {
615
+ doCall = stmt.expression
616
+ break
617
+ } else if (ts.isAwaitExpression(stmt.expression)) {
618
+ // Handle: return await workflow.do(...)
619
+ if (ts.isCallExpression(stmt.expression.expression)) {
620
+ doCall = stmt.expression.expression
621
+ break
622
+ }
623
+ }
624
+ }
625
+ }
626
+ }
627
+
628
+ if (!doCall || !isWorkflowDoCall(doCall, context.checker)) {
629
+ return null
630
+ }
631
+
632
+ // Create a temporary context for the child step
633
+ const childContext: ExtractionContext = {
634
+ ...context,
635
+ outputVars: new Map(context.outputVars),
636
+ }
637
+
638
+ const childStep = extractRpcStep(doCall, childContext)
639
+ if (!childStep) {
640
+ return null
641
+ }
642
+
643
+ return {
644
+ type: 'fanout',
645
+ stepName: childStep.stepName,
646
+ sourceVar,
647
+ itemVar,
648
+ mode: 'parallel',
649
+ child: childStep,
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Extract parallel group from Promise.all([...])
655
+ */
656
+ function extractParallelGroup(
657
+ call: ts.CallExpression,
658
+ context: ExtractionContext
659
+ ): ParallelGroupStepMeta | null {
660
+ const arrayArg = call.arguments[0]
661
+ if (!ts.isArrayLiteralExpression(arrayArg)) {
662
+ return null
663
+ }
664
+
665
+ const children: RpcStepMeta[] = []
666
+
667
+ for (const elem of arrayArg.elements) {
668
+ if (ts.isCallExpression(elem) && isWorkflowDoCall(elem, context.checker)) {
669
+ const step = extractRpcStep(elem, context)
670
+ if (step) {
671
+ children.push(step)
672
+ }
673
+ }
674
+ }
675
+
676
+ if (children.length === 0) {
677
+ return null
678
+ }
679
+
680
+ return {
681
+ type: 'parallel',
682
+ children,
683
+ }
684
+ }
685
+
686
+ /**
687
+ * Extract sequential fanout from for-of loop
688
+ */
689
+ function extractSequentialFanout(
690
+ statement: ts.ForOfStatement,
691
+ context: ExtractionContext
692
+ ): FanoutStepMeta | null {
693
+ if (!isSequentialFanout(statement)) {
694
+ return null
695
+ }
696
+
697
+ const vars = extractForOfVariable(statement)
698
+ if (!vars) {
699
+ return null
700
+ }
701
+
702
+ const { itemVar, sourceVar } = vars
703
+
704
+ // Extract child step and optional sleep from loop body
705
+ if (!ts.isBlock(statement.statement)) {
706
+ return null
707
+ }
708
+
709
+ let childStep: RpcStepMeta | null = null
710
+ let timeBetween: string | undefined = undefined
711
+
712
+ for (const stmt of statement.statement.statements) {
713
+ // Look for workflow.do
714
+ if (ts.isExpressionStatement(stmt)) {
715
+ const expr = stmt.expression
716
+
717
+ if (ts.isAwaitExpression(expr) && ts.isCallExpression(expr.expression)) {
718
+ const call = expr.expression
719
+
720
+ if (isWorkflowDoCall(call, context.checker)) {
721
+ const step = extractRpcStep(call, context)
722
+ if (step) {
723
+ childStep = step
724
+ }
725
+ }
726
+
727
+ if (isWorkflowSleepCall(call, context.checker)) {
728
+ // Extract duration for timeBetween
729
+ const args = call.arguments
730
+ if (args.length >= 2) {
731
+ try {
732
+ const numValue = extractNumberLiteral(args[1])
733
+ if (numValue !== null) {
734
+ timeBetween = `${numValue}ms`
735
+ } else {
736
+ timeBetween = extractStringLiteral(args[1], context.checker)
737
+ }
738
+ } catch {
739
+ // Ignore extraction errors
740
+ }
741
+ }
742
+ }
743
+ }
744
+ }
745
+
746
+ // Look for if statement with sleep
747
+ if (ts.isIfStatement(stmt)) {
748
+ if (ts.isBlock(stmt.thenStatement)) {
749
+ for (const thenStmt of stmt.thenStatement.statements) {
750
+ if (ts.isExpressionStatement(thenStmt)) {
751
+ const expr = thenStmt.expression
752
+
753
+ if (
754
+ ts.isAwaitExpression(expr) &&
755
+ ts.isCallExpression(expr.expression)
756
+ ) {
757
+ const call = expr.expression
758
+
759
+ if (isWorkflowSleepCall(call, context.checker)) {
760
+ const args = call.arguments
761
+ if (args.length >= 2) {
762
+ try {
763
+ const numValue = extractNumberLiteral(args[1])
764
+ if (numValue !== null) {
765
+ timeBetween = `${numValue}ms`
766
+ } else {
767
+ timeBetween = extractStringLiteral(
768
+ args[1],
769
+ context.checker
770
+ )
771
+ }
772
+ } catch {
773
+ // Ignore extraction errors
774
+ }
775
+ }
776
+ }
777
+ }
778
+ }
779
+ }
780
+ }
781
+ }
782
+ }
783
+
784
+ if (!childStep) {
785
+ return null
786
+ }
787
+
788
+ return {
789
+ type: 'fanout',
790
+ stepName: childStep.stepName,
791
+ sourceVar,
792
+ itemVar,
793
+ mode: 'sequential',
794
+ child: childStep,
795
+ timeBetween,
796
+ }
797
+ }
798
+
799
+ /**
800
+ * Extract return step
801
+ */
802
+ function extractReturn(
803
+ statement: ts.ReturnStatement,
804
+ context: ExtractionContext
805
+ ): ReturnStepMeta | null {
806
+ if (!statement.expression) {
807
+ return null
808
+ }
809
+
810
+ if (!ts.isObjectLiteralExpression(statement.expression)) {
811
+ return null
812
+ }
813
+
814
+ const outputs: Record<string, OutputBinding> = {}
815
+
816
+ for (const prop of statement.expression.properties) {
817
+ if (
818
+ ts.isPropertyAssignment(prop) ||
819
+ ts.isShorthandPropertyAssignment(prop)
820
+ ) {
821
+ const propName = ts.isIdentifier(prop.name) ? prop.name.text : null
822
+ if (!propName) {
823
+ continue
824
+ }
825
+
826
+ let binding: OutputBinding | null = null
827
+
828
+ if (ts.isShorthandPropertyAssignment(prop)) {
829
+ // { orgId } - must be an output variable
830
+ const varName = prop.name.text
831
+ if (context.outputVars.has(varName)) {
832
+ binding = { from: 'outputVar', name: varName }
833
+ }
834
+ } else if (ts.isPropertyAssignment(prop)) {
835
+ const init = prop.initializer
836
+
837
+ // Check for property access (e.g., org.id, owner?.id)
838
+ if (ts.isPropertyAccessExpression(init)) {
839
+ const objName = ts.isIdentifier(init.expression)
840
+ ? init.expression.text
841
+ : null
842
+ const propPath = init.name.text
843
+
844
+ if (objName && context.outputVars.has(objName)) {
845
+ binding = { from: 'outputVar', name: objName, path: propPath }
846
+ }
847
+ }
848
+
849
+ // Check for optional chaining (e.g., owner?.id)
850
+ if (
851
+ init.kind === ts.SyntaxKind.PropertyAccessExpression ||
852
+ init.kind === ts.SyntaxKind.NonNullExpression
853
+ ) {
854
+ const text = init.getText()
855
+ const match = text.match(/^(\w+)\??\.(\w+)$/)
856
+ if (match) {
857
+ const [, objName, propPath] = match
858
+ if (context.outputVars.has(objName)) {
859
+ binding = { from: 'outputVar', name: objName, path: propPath }
860
+ }
861
+ }
862
+ }
863
+
864
+ // Check for identifier (simple variable reference)
865
+ if (ts.isIdentifier(init)) {
866
+ const varName = init.text
867
+ if (context.outputVars.has(varName)) {
868
+ binding = { from: 'outputVar', name: varName }
869
+ }
870
+ }
871
+ }
872
+
873
+ if (binding) {
874
+ outputs[propName] = binding
875
+ }
876
+ }
877
+ }
878
+
879
+ if (Object.keys(outputs).length === 0) {
880
+ return null
881
+ }
882
+
883
+ return {
884
+ type: 'return',
885
+ outputs,
886
+ }
887
+ }
888
+
889
+ /**
890
+ * Extract input sources from an argument node
891
+ */
892
+ function extractInputSources(
893
+ node: ts.Node,
894
+ context: ExtractionContext
895
+ ): Record<string, InputSource> | undefined {
896
+ if (!ts.isObjectLiteralExpression(node)) {
897
+ return undefined
898
+ }
899
+
900
+ const inputs: Record<string, InputSource> = {}
901
+
902
+ for (const prop of node.properties) {
903
+ if (
904
+ ts.isPropertyAssignment(prop) ||
905
+ ts.isShorthandPropertyAssignment(prop)
906
+ ) {
907
+ const propName = ts.isIdentifier(prop.name) ? prop.name.text : null
908
+ if (!propName) {
909
+ continue
910
+ }
911
+
912
+ let source: InputSource | null = null
913
+
914
+ if (ts.isShorthandPropertyAssignment(prop)) {
915
+ // { email } - could be from input or output var
916
+ const varName = prop.name.text
917
+ if (context.outputVars.has(varName)) {
918
+ source = { from: 'outputVar', name: varName }
919
+ } else {
920
+ source = { from: 'input', path: varName }
921
+ }
922
+ } else if (ts.isPropertyAssignment(prop)) {
923
+ source = extractInputSource(prop.initializer, context)
924
+ }
925
+
926
+ if (source) {
927
+ inputs[propName] = source
928
+ }
929
+ }
930
+
931
+ if (ts.isSpreadAssignment(prop)) {
932
+ // Handle spread: { ...data }
933
+ if (ts.isIdentifier(prop.expression)) {
934
+ const varName = prop.expression.text
935
+ if (varName === context.inputParamName) {
936
+ // This is spreading the input data
937
+ // We can't fully model this in v1, so we'll skip it
938
+ continue
939
+ }
940
+ }
941
+ }
942
+ }
943
+
944
+ return Object.keys(inputs).length > 0 ? inputs : undefined
945
+ }
946
+
947
+ /**
948
+ * Extract a single input source
949
+ */
950
+ function extractInputSource(
951
+ node: ts.Node,
952
+ context: ExtractionContext
953
+ ): InputSource | null {
954
+ // Property access: data.email, org.id
955
+ if (ts.isPropertyAccessExpression(node)) {
956
+ const objExpr = node.expression
957
+ const propName = node.name.text
958
+
959
+ if (ts.isIdentifier(objExpr)) {
960
+ const objName = objExpr.text
961
+
962
+ if (objName === context.inputParamName) {
963
+ return { from: 'input', path: propName }
964
+ }
965
+
966
+ if (context.outputVars.has(objName)) {
967
+ return { from: 'outputVar', name: objName, path: propName }
968
+ }
969
+ }
970
+ }
971
+
972
+ // Identifier: email, orgId
973
+ if (ts.isIdentifier(node)) {
974
+ const varName = node.text
975
+
976
+ if (context.outputVars.has(varName)) {
977
+ return { from: 'outputVar', name: varName }
978
+ }
979
+
980
+ // Assume it's from input
981
+ return { from: 'input', path: varName }
982
+ }
983
+
984
+ // Literal: "string", 123, true, false, null
985
+ if (
986
+ ts.isStringLiteral(node) ||
987
+ ts.isNumericLiteral(node) ||
988
+ node.kind === ts.SyntaxKind.TrueKeyword ||
989
+ node.kind === ts.SyntaxKind.FalseKeyword ||
990
+ node.kind === ts.SyntaxKind.NullKeyword
991
+ ) {
992
+ let value: unknown
993
+ if (ts.isStringLiteral(node)) {
994
+ value = node.text
995
+ } else if (ts.isNumericLiteral(node)) {
996
+ value = Number(node.text)
997
+ } else if (node.kind === ts.SyntaxKind.TrueKeyword) {
998
+ value = true
999
+ } else if (node.kind === ts.SyntaxKind.FalseKeyword) {
1000
+ value = false
1001
+ } else if (node.kind === ts.SyntaxKind.NullKeyword) {
1002
+ value = null
1003
+ }
1004
+ return { from: 'literal', value }
1005
+ }
1006
+
1007
+ // Object literal
1008
+ if (ts.isObjectLiteralExpression(node)) {
1009
+ const obj: Record<string, unknown> = {}
1010
+ for (const prop of node.properties) {
1011
+ if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
1012
+ const propName = prop.name.text
1013
+ const propSource = extractInputSource(prop.initializer, context)
1014
+ if (propSource && propSource.from === 'literal') {
1015
+ obj[propName] = propSource.value
1016
+ }
1017
+ }
1018
+ }
1019
+ return { from: 'literal', value: obj }
1020
+ }
1021
+
1022
+ // Array literal
1023
+ if (ts.isArrayLiteralExpression(node)) {
1024
+ const arr: unknown[] = []
1025
+ for (const elem of node.elements) {
1026
+ const elemSource = extractInputSource(elem, context)
1027
+ if (elemSource && elemSource.from === 'literal') {
1028
+ arr.push(elemSource.value)
1029
+ }
1030
+ }
1031
+ return { from: 'literal', value: arr }
1032
+ }
1033
+
1034
+ return null
1035
+ }