@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
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
298
|
-
const childNodes =
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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'
|