@pikku/inspector 0.12.43 → 0.12.45

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 (36) hide show
  1. package/CHANGELOG.md +261 -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-auth.test.ts +4 -4
  13. package/src/add/add-credential.ts +2 -0
  14. package/src/add/add-keyed-wiring.ts +2 -0
  15. package/src/add/add-workflow-edge-cases.test.ts +201 -0
  16. package/src/add/add-workflow-fanout.test.ts +60 -0
  17. package/src/add/add-workflow-on-error.test.ts +118 -0
  18. package/src/add/add-workflow-return-switch.test.ts +121 -0
  19. package/src/add/add-workflow-silent-drops.test.ts +163 -0
  20. package/src/add/add-workflow-sleep-expression.test.ts +97 -0
  21. package/src/add/add-workflow.ts +9 -4
  22. package/src/add/config-value-metadata.test.ts +169 -0
  23. package/src/utils/schema-generator.test.ts +164 -0
  24. package/src/utils/schema-generator.ts +26 -3
  25. package/src/utils/workflow/dsl/deserialize-dsl-workflow.ts +480 -119
  26. package/src/utils/workflow/dsl/deserialize-fanout-body.test.ts +71 -0
  27. package/src/utils/workflow/dsl/deserialize-fanout-fidelity.test.ts +99 -0
  28. package/src/utils/workflow/dsl/deserialize-graph-next.test.ts +161 -0
  29. package/src/utils/workflow/dsl/deserialize-roundtrip-bindings.test.ts +121 -0
  30. package/src/utils/workflow/dsl/deserialize-roundtrip-values.test.ts +116 -0
  31. package/src/utils/workflow/dsl/deserialize-roundtrip.test.ts +145 -0
  32. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +245 -105
  33. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +67 -10
  34. package/src/utils/workflow/graph/fanout-node-identity.test.ts +105 -0
  35. package/src/utils/workflow/graph/workflow-graph.types.ts +3 -2
  36. package/tsconfig.tsbuildinfo +1 -1
@@ -91,6 +91,7 @@ function convertStepToNode(
91
91
  const node: FunctionNode = {
92
92
  nodeId,
93
93
  rpcName: step.rpcName,
94
+ stepName: step.stepName,
94
95
  next: nextNodeId,
95
96
  }
96
97
  if (step.actor) {
@@ -116,9 +117,24 @@ function convertStepToNode(
116
117
  if (step.options) {
117
118
  node.options = {
118
119
  retries: step.options.retries,
119
- retryDelay: step.options.retryDelay?.toString(),
120
+ retryDelay: step.options.retryDelay,
120
121
  }
121
122
  }
123
+ // An onError handler is written as an rpc name, but the graph routes to
124
+ // node ids — materialise a node for it so the console has something to
125
+ // draw the error edge to, exactly as in a hand-authored graph.
126
+ if (step.options?.onError) {
127
+ const handlerId = `${nodeId}_onError`
128
+ node.onError = handlerId
129
+ return [
130
+ node,
131
+ {
132
+ nodeId: handlerId,
133
+ rpcName: step.options.onError,
134
+ stepName: `${step.stepName} (on error)`,
135
+ } satisfies FunctionNode,
136
+ ]
137
+ }
122
138
  return [node]
123
139
  }
124
140
 
@@ -126,7 +142,22 @@ function convertStepToNode(
126
142
  const node: FlowNode = {
127
143
  nodeId,
128
144
  flow: 'sleep',
145
+ stepName: step.stepName,
129
146
  duration: step.duration,
147
+ expression: step.expression,
148
+ next: nextNodeId,
149
+ }
150
+ return [node]
151
+ }
152
+
153
+ case 'suspend': {
154
+ // A suspend yields no value, but it must still be a node: the previous
155
+ // step's `next` points at it, so omitting it dead-ends traversal and
156
+ // silently deletes every step that follows.
157
+ const node: FlowNode = {
158
+ nodeId,
159
+ flow: 'suspend',
160
+ reason: step.reason,
130
161
  next: nextNodeId,
131
162
  }
132
163
  return [node]
@@ -214,16 +245,32 @@ function convertStepToNode(
214
245
  entry: string
215
246
  }> = []
216
247
 
248
+ // A case with no statements falls through to the next one. Dropping it
249
+ // would lose the value entirely, so it is held until an entry exists.
250
+ let fallingThrough: Array<{ value?: unknown; expression?: string }> = []
251
+
217
252
  for (let i = 0; i < step.cases.length; i++) {
218
253
  const caseSteps = convertStepsToNodes(
219
254
  step.cases[i].steps,
220
255
  `${nodeId}_case${i}`
221
256
  )
222
- if (caseSteps.length > 0) {
257
+ if (caseSteps.length === 0) {
258
+ fallingThrough.push({
259
+ value: step.cases[i].value,
260
+ expression: step.cases[i].expression,
261
+ })
262
+ continue
263
+ }
264
+ {
265
+ const entry = caseSteps[0].nodeId
266
+ for (const pending of fallingThrough) {
267
+ cases.push({ ...pending, entry })
268
+ }
269
+ fallingThrough = []
223
270
  cases.push({
224
271
  value: step.cases[i].value,
225
272
  expression: step.cases[i].expression,
226
- entry: caseSteps[0].nodeId,
273
+ entry,
227
274
  })
228
275
  // Link last case node to next (unless terminal flow)
229
276
  if (nextNodeId) {
@@ -253,6 +300,14 @@ function convertStepToNode(
253
300
  }
254
301
  }
255
302
 
303
+ // Cases falling through past the last one land in default, or exit.
304
+ const trailingEntry = defaultEntry ?? nextNodeId
305
+ if (trailingEntry) {
306
+ for (const pending of fallingThrough) {
307
+ cases.push({ ...pending, entry: trailingEntry })
308
+ }
309
+ }
310
+
256
311
  const node: FlowNode = {
257
312
  nodeId,
258
313
  flow: 'switch',
@@ -294,13 +349,13 @@ function convertStepToNode(
294
349
  }
295
350
 
296
351
  case 'fanout': {
297
- // Convert child step
298
- const childNodes = convertStepToNode(
299
- step.child,
300
- 0,
301
- [step.child],
302
- `${nodeId}_item`
303
- )
352
+ // Convert the per-iteration body into a `next`-chained sequence
353
+ const childNodes: SerializedGraphNode[] = []
354
+ for (let i = 0; i < step.body.length; i++) {
355
+ childNodes.push(
356
+ ...convertStepToNode(step.body[i], i, step.body, `${nodeId}_item`)
357
+ )
358
+ }
304
359
 
305
360
  const node: FlowNode = {
306
361
  nodeId,
@@ -348,6 +403,7 @@ function convertStepToNode(
348
403
  nodeId,
349
404
  flow: 'return',
350
405
  outputs: step.outputs,
406
+ spread: step.spread,
351
407
  }
352
408
  return [node]
353
409
  }
@@ -367,6 +423,7 @@ function convertStepToNode(
367
423
  flow: 'set',
368
424
  variable: step.variable,
369
425
  value: step.value,
426
+ expression: step.expression,
370
427
  next: nextNodeId,
371
428
  }
372
429
  return [node]
@@ -0,0 +1,105 @@
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
+
9
+ const STEP_FILE = [
10
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
11
+ 'export const notify = pikkuSessionlessFunc({',
12
+ " func: async () => ({ id: 'n' }),",
13
+ '})',
14
+ ].join('\n')
15
+
16
+ async function nodesFor(body: string): Promise<Record<string, any>> {
17
+ const logger = {
18
+ debug: () => {},
19
+ info: () => {},
20
+ warn: () => {},
21
+ error: () => {},
22
+ diagnostic: () => {},
23
+ critical: () => {},
24
+ hasCriticalErrors: () => false,
25
+ } as unknown as InspectorLogger
26
+
27
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-fid-'))
28
+ const stepFile = join(rootDir, 'w.steps.ts')
29
+ const wfFile = join(rootDir, 'w.workflow.ts')
30
+ await writeFile(stepFile, STEP_FILE)
31
+ await writeFile(
32
+ wfFile,
33
+ [
34
+ "import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
35
+ 'export const wf = pikkuWorkflowFunc(async (_, data: any, { workflow }) => {',
36
+ body,
37
+ '})',
38
+ ].join('\n')
39
+ )
40
+ try {
41
+ const state: any = await inspect(logger, [stepFile, wfFile], { rootDir })
42
+ const gm = state.workflows.graphMeta
43
+ const graph = (
44
+ gm instanceof Map ? [...gm.values()] : Object.values(gm)
45
+ )[0] as any
46
+ return graph?.nodes ?? {}
47
+ } finally {
48
+ await rm(rootDir, { recursive: true, force: true })
49
+ }
50
+ }
51
+
52
+ describe('fanout node identity — a body step must not overwrite its own loop', () => {
53
+ test('the fanout node survives alongside its named body step', async () => {
54
+ const nodes = await nodesFor(
55
+ [
56
+ ' await Promise.all(',
57
+ ' data.users.map(async (u: any) => {',
58
+ " await workflow.do('Notify', 'notify', { id: u.id })",
59
+ ' })',
60
+ ' )',
61
+ ' return { ok: true }',
62
+ ].join('\n')
63
+ )
64
+
65
+ const fanout = Object.values(nodes).find(
66
+ (n: any) => n.flow === 'fanout'
67
+ ) as any
68
+ assert.ok(
69
+ fanout,
70
+ `the loop must be in the graph — without it the workflow renders as a single call, got: ${JSON.stringify(nodes)}`
71
+ )
72
+ assert.ok(
73
+ fanout.childEntry && nodes[fanout.childEntry],
74
+ 'the fanout must point at a body entry node that exists'
75
+ )
76
+ assert.notEqual(
77
+ fanout.nodeId,
78
+ fanout.childEntry,
79
+ 'a fanout and its body entry must not share a node id'
80
+ )
81
+ assert.equal((nodes[fanout.childEntry] as any).rpcName, 'notify')
82
+ })
83
+
84
+ test('the step following a fanout is still reachable', async () => {
85
+ const nodes = await nodesFor(
86
+ [
87
+ ' await Promise.all(',
88
+ ' data.users.map(async (u: any) => {',
89
+ " await workflow.do('Notify', 'notify', { id: u.id })",
90
+ ' })',
91
+ ' )',
92
+ ' return { ok: true }',
93
+ ].join('\n')
94
+ )
95
+
96
+ const fanout = Object.values(nodes).find(
97
+ (n: any) => n.flow === 'fanout'
98
+ ) as any
99
+ assert.ok(fanout, 'a fanout node should exist')
100
+ assert.ok(
101
+ fanout.next && nodes[fanout.next],
102
+ `the return after the loop must stay reachable, got: ${JSON.stringify(nodes)}`
103
+ )
104
+ })
105
+ })
@@ -86,8 +86,8 @@ export type SerializedNext =
86
86
  export interface NodeOptions {
87
87
  /** Number of retry attempts on failure */
88
88
  retries?: number
89
- /** Delay between retries (e.g., '1s', '5s') */
90
- retryDelay?: string
89
+ /** Delay between retries (e.g., '1s', '5s', or a number of milliseconds) */
90
+ retryDelay?: string | number
91
91
  /** Timeout for node execution (e.g., '30s', '5m') */
92
92
  timeout?: string
93
93
  }
@@ -97,6 +97,7 @@ export interface NodeOptions {
97
97
  */
98
98
  export type FlowType =
99
99
  | 'sleep'
100
+ | 'suspend'
100
101
  | 'approval'
101
102
  | 'branch'
102
103
  | 'parallel'