@pikku/inspector 0.12.42 → 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 +266 -0
- package/dist/add/add-addon-bans.js +1 -0
- package/dist/add/add-ai-agent.d.ts +3 -1
- package/dist/add/add-ai-agent.js +82 -0
- package/dist/add/add-credential.js +5 -1
- package/dist/add/add-functions.js +10 -9
- package/dist/add/add-gateway.js +3 -0
- package/dist/add/add-http-route.js +2 -2
- package/dist/add/add-keyed-wiring.js +2 -0
- package/dist/add/add-mcp-prompt.js +0 -1
- package/dist/add/add-mcp-resource.js +0 -1
- package/dist/add/add-permission.d.ts +1 -1
- package/dist/add/add-permission.js +3 -177
- package/dist/add/add-queue-worker.js +3 -0
- package/dist/add/add-schedule.js +3 -0
- package/dist/add/add-scope.d.ts +2 -0
- package/dist/add/add-scope.js +146 -0
- package/dist/add/add-trigger.js +3 -0
- package/dist/add/add-wire-remote-addon.d.ts +10 -0
- package/dist/add/add-wire-remote-addon.js +66 -0
- package/dist/add/add-workflow-graph.js +32 -3
- package/dist/add/add-workflow.js +9 -4
- package/dist/error-codes.d.ts +3 -0
- package/dist/error-codes.js +4 -0
- package/dist/inspector.js +11 -5
- package/dist/types.d.ts +19 -13
- package/dist/utils/custom-types-generator.js +2 -1
- package/dist/utils/ensure-function-metadata.d.ts +15 -0
- package/dist/utils/ensure-function-metadata.js +24 -0
- package/dist/utils/filter-inspector-state.js +9 -0
- package/dist/utils/get-property-value.d.ts +7 -2
- package/dist/utils/get-property-value.js +48 -1
- package/dist/utils/load-addon-functions-meta.js +81 -6
- package/dist/utils/permissions.d.ts +1 -21
- package/dist/utils/permissions.js +17 -108
- package/dist/utils/post-process.d.ts +30 -1
- package/dist/utils/post-process.js +150 -72
- package/dist/utils/schema-generator.js +19 -4
- package/dist/utils/serialize-inspector-state.d.ts +13 -8
- package/dist/utils/serialize-inspector-state.js +12 -6
- package/dist/utils/serialize-permissions-groups-meta.d.ts +0 -2
- package/dist/utils/serialize-permissions-groups-meta.js +0 -26
- package/dist/utils/workflow/derive-workflow-plan.js +9 -3
- package/dist/utils/workflow/dsl/deserialize-dsl-workflow.js +373 -91
- package/dist/utils/workflow/dsl/extract-dsl-workflow.js +274 -100
- package/dist/utils/workflow/dsl/patterns.d.ts +4 -0
- package/dist/utils/workflow/dsl/patterns.js +11 -0
- package/dist/utils/workflow/graph/convert-dsl-to-graph.js +76 -5
- package/dist/utils/workflow/graph/finalize-workflows.d.ts +7 -0
- package/dist/utils/workflow/graph/finalize-workflows.js +11 -2
- package/dist/utils/workflow/graph/serialize-workflow-graph.d.ts +2 -0
- package/dist/utils/workflow/graph/serialize-workflow-graph.js +4 -0
- package/dist/utils/workflow/graph/workflow-graph.types.d.ts +8 -4
- package/dist/visit.js +4 -0
- package/package.json +3 -3
- package/run-tests.sh +1 -1
- package/src/add/add-addon-bans.ts +1 -0
- package/src/add/add-ai-agent.test.ts +87 -0
- package/src/add/add-ai-agent.ts +122 -0
- package/src/add/add-credential.ts +8 -0
- package/src/add/add-functions.ts +10 -12
- package/src/add/add-gateway.ts +11 -0
- package/src/add/add-http-route.ts +2 -8
- package/src/add/add-keyed-wiring.ts +2 -0
- package/src/add/add-mcp-prompt.ts +0 -1
- package/src/add/add-mcp-resource.ts +0 -1
- package/src/add/add-permission.ts +4 -242
- package/src/add/add-queue-worker.ts +11 -0
- package/src/add/add-schedule.ts +11 -0
- package/src/add/add-scope.test.ts +346 -0
- package/src/add/add-scope.ts +225 -0
- package/src/add/add-trigger.ts +11 -0
- package/src/add/add-wire-remote-addon.ts +77 -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-graph-input.test.ts +94 -0
- package/src/add/add-workflow-graph-notes.test.ts +74 -0
- package/src/add/add-workflow-graph.ts +35 -3
- 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/add/inline-wiring-function.test.ts +104 -0
- package/src/add/validate-workflow-graph-addons.test.ts +102 -0
- package/src/error-codes.ts +5 -0
- package/src/inspector.ts +14 -4
- package/src/types.ts +16 -17
- package/src/utils/custom-types-generator.test.ts +26 -1
- package/src/utils/custom-types-generator.ts +2 -1
- package/src/utils/ensure-function-metadata.ts +42 -0
- package/src/utils/filter-inspector-state.test.ts +0 -2
- package/src/utils/filter-inspector-state.ts +9 -0
- package/src/utils/get-property-value.test.ts +141 -0
- package/src/utils/get-property-value.ts +62 -1
- package/src/utils/load-addon-functions-meta.test.ts +157 -0
- package/src/utils/load-addon-functions-meta.ts +94 -6
- package/src/utils/load-addon-scopes.test.ts +138 -0
- package/src/utils/permissions.test.ts +7 -232
- package/src/utils/permissions.ts +20 -160
- package/src/utils/post-process.test.ts +269 -4
- package/src/utils/post-process.ts +216 -95
- package/src/utils/schema-generator.test.ts +164 -0
- package/src/utils/schema-generator.ts +26 -3
- package/src/utils/serialize-inspector-state.ts +22 -25
- package/src/utils/serialize-permissions-groups-meta.ts +0 -28
- package/src/utils/workflow/derive-workflow-plan.test.ts +41 -3
- package/src/utils/workflow/derive-workflow-plan.ts +11 -4
- 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 +311 -105
- package/src/utils/workflow/dsl/patterns.ts +18 -0
- package/src/utils/workflow/graph/convert-dsl-to-graph.ts +82 -10
- package/src/utils/workflow/graph/fanout-node-identity.test.ts +105 -0
- package/src/utils/workflow/graph/finalize-workflows.test.ts +50 -0
- package/src/utils/workflow/graph/finalize-workflows.ts +13 -2
- package/src/utils/workflow/graph/serialize-workflow-graph.ts +6 -0
- package/src/utils/workflow/graph/workflow-graph.types.ts +11 -2
- package/src/visit.ts +4 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
function makeLogger(): InspectorLogger {
|
|
10
|
+
return {
|
|
11
|
+
debug: () => {},
|
|
12
|
+
info: () => {},
|
|
13
|
+
warn: () => {},
|
|
14
|
+
error: () => {},
|
|
15
|
+
diagnostic: () => {},
|
|
16
|
+
critical: () => {},
|
|
17
|
+
hasCriticalErrors: () => false,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('addWorkflowGraph — notes extraction', () => {
|
|
22
|
+
test('node-level and graph-level notes are carried into the serialized graph', async () => {
|
|
23
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-graph-notes-'))
|
|
24
|
+
const stepFile = join(rootDir, 'my.steps.ts')
|
|
25
|
+
const graphFile = join(rootDir, 'my.graph.ts')
|
|
26
|
+
|
|
27
|
+
await writeFile(
|
|
28
|
+
stepFile,
|
|
29
|
+
[
|
|
30
|
+
"import { pikkuSessionlessFunc } from '@pikku/core'",
|
|
31
|
+
'export const assess = pikkuSessionlessFunc({ func: async () => ({ ok: true }) })',
|
|
32
|
+
'export const notify = pikkuSessionlessFunc({ func: async () => ({ sent: true }) })',
|
|
33
|
+
].join('\n')
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
await writeFile(
|
|
37
|
+
graphFile,
|
|
38
|
+
[
|
|
39
|
+
"import { pikkuWorkflowGraph } from '@pikku/core/workflow'",
|
|
40
|
+
'export const myGraph = pikkuWorkflowGraph({',
|
|
41
|
+
" name: 'my-graph',",
|
|
42
|
+
" notes: ['imported sticky note'],",
|
|
43
|
+
' nodes: {',
|
|
44
|
+
" assess: 'assess',",
|
|
45
|
+
" notify: 'notify',",
|
|
46
|
+
' },',
|
|
47
|
+
' config: {',
|
|
48
|
+
" assess: { next: 'notify', notes: 'STUB — generated from node \"Assess\"' },",
|
|
49
|
+
' notify: {},',
|
|
50
|
+
' },',
|
|
51
|
+
'})',
|
|
52
|
+
].join('\n')
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const state = await inspect(makeLogger(), [stepFile, graphFile], {
|
|
57
|
+
rootDir,
|
|
58
|
+
})
|
|
59
|
+
const graph = state.workflows.graphMeta['my-graph']
|
|
60
|
+
assert.ok(graph, 'graph meta should be registered')
|
|
61
|
+
assert.deepEqual(graph.notes, ['imported sticky note'])
|
|
62
|
+
assert.equal(
|
|
63
|
+
(graph.nodes['assess'] as { notes?: string }).notes,
|
|
64
|
+
'STUB — generated from node "Assess"'
|
|
65
|
+
)
|
|
66
|
+
assert.equal(
|
|
67
|
+
(graph.nodes['notify'] as { notes?: string }).notes,
|
|
68
|
+
undefined
|
|
69
|
+
)
|
|
70
|
+
} finally {
|
|
71
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -12,6 +12,11 @@ function extractAstValue(
|
|
|
12
12
|
refParamName: string,
|
|
13
13
|
templateParamName: string | undefined
|
|
14
14
|
): unknown {
|
|
15
|
+
// `'GET' as const` / parenthesised values wrap the real initializer — see
|
|
16
|
+
// through them or the whole property is silently dropped from the meta.
|
|
17
|
+
while (ts.isAsExpression(expr) || ts.isParenthesizedExpression(expr)) {
|
|
18
|
+
expr = expr.expression
|
|
19
|
+
}
|
|
15
20
|
if (ts.isStringLiteral(expr)) {
|
|
16
21
|
return expr.text
|
|
17
22
|
}
|
|
@@ -146,10 +151,14 @@ function extractInputMapping(
|
|
|
146
151
|
? node.parameters[0].name.text
|
|
147
152
|
: 'ref'
|
|
148
153
|
|
|
154
|
+
// The canonical form imports `template` from '@pikku/core/workflow' and calls
|
|
155
|
+
// it inside a single-param `(ref) => (...)` arrow; the older form passes it as
|
|
156
|
+
// a second arrow parameter. Support both — default to the imported name so a
|
|
157
|
+
// `template(...)` value is never dropped just because it isn't a 2nd param.
|
|
149
158
|
const templateParamName =
|
|
150
159
|
node.parameters.length > 1 && ts.isIdentifier(node.parameters[1].name)
|
|
151
160
|
? node.parameters[1].name.text
|
|
152
|
-
:
|
|
161
|
+
: 'template'
|
|
153
162
|
|
|
154
163
|
const input: Record<string, unknown | DataRef> = {}
|
|
155
164
|
|
|
@@ -184,6 +193,13 @@ function extractNextConfig(
|
|
|
184
193
|
node: ts.Node,
|
|
185
194
|
_checker: ts.TypeChecker
|
|
186
195
|
): string | string[] | Record<string, string | string[]> | undefined {
|
|
196
|
+
// Key-based branch `next` (`{ key: [targets] }`) is authored with an `as any`
|
|
197
|
+
// cast because the graph's NextConfig can't narrow record targets to node-id
|
|
198
|
+
// literals; see through that (and parens) so the routing survives into the meta.
|
|
199
|
+
while (ts.isAsExpression(node) || ts.isParenthesizedExpression(node)) {
|
|
200
|
+
node = node.expression
|
|
201
|
+
}
|
|
202
|
+
|
|
187
203
|
if (ts.isStringLiteral(node)) {
|
|
188
204
|
return node.text
|
|
189
205
|
}
|
|
@@ -254,6 +270,7 @@ interface PikkuWorkflowGraphExtract {
|
|
|
254
270
|
name?: string
|
|
255
271
|
description?: string
|
|
256
272
|
tags?: string[]
|
|
273
|
+
notes?: string[]
|
|
257
274
|
disabled?: true
|
|
258
275
|
nodesNode?: ts.ObjectLiteralExpression
|
|
259
276
|
configNode?: ts.ObjectLiteralExpression
|
|
@@ -270,6 +287,7 @@ function extractWorkflowGraphConfig(
|
|
|
270
287
|
let name: string | undefined
|
|
271
288
|
let description: string | undefined
|
|
272
289
|
let tags: string[] | undefined
|
|
290
|
+
let notes: string[] | undefined
|
|
273
291
|
let disabled: true | undefined
|
|
274
292
|
let nodesNode: ts.ObjectLiteralExpression | undefined
|
|
275
293
|
let configNode: ts.ObjectLiteralExpression | undefined
|
|
@@ -293,6 +311,13 @@ function extractWorkflowGraphConfig(
|
|
|
293
311
|
tags = prop.initializer.elements
|
|
294
312
|
.filter(ts.isStringLiteral)
|
|
295
313
|
.map((el) => (el as ts.StringLiteral).text)
|
|
314
|
+
} else if (
|
|
315
|
+
propName === 'notes' &&
|
|
316
|
+
ts.isArrayLiteralExpression(prop.initializer)
|
|
317
|
+
) {
|
|
318
|
+
notes = prop.initializer.elements
|
|
319
|
+
.filter(ts.isStringLiteral)
|
|
320
|
+
.map((el) => (el as ts.StringLiteral).text)
|
|
296
321
|
} else if (
|
|
297
322
|
propName === 'nodes' &&
|
|
298
323
|
ts.isObjectLiteralExpression(prop.initializer)
|
|
@@ -306,7 +331,7 @@ function extractWorkflowGraphConfig(
|
|
|
306
331
|
}
|
|
307
332
|
}
|
|
308
333
|
|
|
309
|
-
return { name, description, tags, disabled, nodesNode, configNode }
|
|
334
|
+
return { name, description, tags, notes, disabled, nodesNode, configNode }
|
|
310
335
|
}
|
|
311
336
|
|
|
312
337
|
/**
|
|
@@ -359,6 +384,7 @@ function extractGraphFromNewFormat(
|
|
|
359
384
|
onError: undefined,
|
|
360
385
|
retries: undefined,
|
|
361
386
|
retryDelay: undefined,
|
|
387
|
+
notes: undefined,
|
|
362
388
|
}
|
|
363
389
|
}
|
|
364
390
|
|
|
@@ -386,6 +412,7 @@ function extractGraphFromNewFormat(
|
|
|
386
412
|
nodes[nodeId].input = nodeConfig.input
|
|
387
413
|
nodes[nodeId].retries = nodeConfig.retries
|
|
388
414
|
nodes[nodeId].retryDelay = nodeConfig.retryDelay
|
|
415
|
+
nodes[nodeId].notes = nodeConfig.notes
|
|
389
416
|
}
|
|
390
417
|
}
|
|
391
418
|
}
|
|
@@ -406,12 +433,14 @@ function extractNodeConfigFromObject(
|
|
|
406
433
|
input: Record<string, any>
|
|
407
434
|
retries: number | undefined
|
|
408
435
|
retryDelay: string | number | undefined
|
|
436
|
+
notes: string | undefined
|
|
409
437
|
} {
|
|
410
438
|
let next: any = undefined
|
|
411
439
|
let onError: any = undefined
|
|
412
440
|
let input: Record<string, any> = {}
|
|
413
441
|
let retries: number | undefined = undefined
|
|
414
442
|
let retryDelay: string | number | undefined = undefined
|
|
443
|
+
let notes: string | undefined = undefined
|
|
415
444
|
|
|
416
445
|
for (const prop of obj.properties) {
|
|
417
446
|
if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue
|
|
@@ -434,10 +463,12 @@ function extractNodeConfigFromObject(
|
|
|
434
463
|
} else if (ts.isStringLiteral(prop.initializer)) {
|
|
435
464
|
retryDelay = prop.initializer.text
|
|
436
465
|
}
|
|
466
|
+
} else if (propName === 'notes') {
|
|
467
|
+
notes = extractStringLiteral(prop.initializer, checker)
|
|
437
468
|
}
|
|
438
469
|
}
|
|
439
470
|
|
|
440
|
-
return { next, onError, input, retries, retryDelay }
|
|
471
|
+
return { next, onError, input, retries, retryDelay, notes }
|
|
441
472
|
}
|
|
442
473
|
|
|
443
474
|
/**
|
|
@@ -526,6 +557,7 @@ export const addWorkflowGraph: AddWiring = (logger, node, checker, state) => {
|
|
|
526
557
|
source: 'graph',
|
|
527
558
|
description: graphConfig.description,
|
|
528
559
|
tags: graphConfig.tags,
|
|
560
|
+
notes: graphConfig.notes,
|
|
529
561
|
nodes: graphNodes,
|
|
530
562
|
entryNodeIds,
|
|
531
563
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
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 '../utils/workflow/dsl/deserialize-dsl-workflow.js'
|
|
9
|
+
|
|
10
|
+
const STEP_FILE = [
|
|
11
|
+
"import { pikkuSessionlessFunc } from '@pikku/core'",
|
|
12
|
+
'export const chargeCard = pikkuSessionlessFunc({',
|
|
13
|
+
" func: async () => ({ id: 'c' }),",
|
|
14
|
+
'})',
|
|
15
|
+
'export const refundOrder = pikkuSessionlessFunc({',
|
|
16
|
+
" func: async () => ({ id: 'r' }),",
|
|
17
|
+
'})',
|
|
18
|
+
].join('\n')
|
|
19
|
+
|
|
20
|
+
const WORKFLOW_BODY = [
|
|
21
|
+
" await workflow.do('Charge', 'chargeCard', { id: data.id }, {",
|
|
22
|
+
' retries: 3,',
|
|
23
|
+
" onError: 'refundOrder',",
|
|
24
|
+
' })',
|
|
25
|
+
' return { ok: true }',
|
|
26
|
+
].join('\n')
|
|
27
|
+
|
|
28
|
+
async function inspectWorkflow(body: string): Promise<{
|
|
29
|
+
nodes: Record<string, any>
|
|
30
|
+
invoked: string[]
|
|
31
|
+
code: string
|
|
32
|
+
}> {
|
|
33
|
+
const logger = {
|
|
34
|
+
debug: () => {},
|
|
35
|
+
info: () => {},
|
|
36
|
+
warn: () => {},
|
|
37
|
+
error: () => {},
|
|
38
|
+
diagnostic: () => {},
|
|
39
|
+
critical: () => {},
|
|
40
|
+
hasCriticalErrors: () => false,
|
|
41
|
+
} as unknown as InspectorLogger
|
|
42
|
+
|
|
43
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-oe-'))
|
|
44
|
+
const stepFile = join(rootDir, 'w.steps.ts')
|
|
45
|
+
const wfFile = join(rootDir, 'w.workflow.ts')
|
|
46
|
+
await writeFile(stepFile, STEP_FILE)
|
|
47
|
+
await writeFile(
|
|
48
|
+
wfFile,
|
|
49
|
+
[
|
|
50
|
+
"import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
|
|
51
|
+
'export const wf = pikkuWorkflowFunc(async (_, data: any, { workflow }) => {',
|
|
52
|
+
body,
|
|
53
|
+
'})',
|
|
54
|
+
].join('\n')
|
|
55
|
+
)
|
|
56
|
+
try {
|
|
57
|
+
const state: any = await inspect(logger, [stepFile, wfFile], { rootDir })
|
|
58
|
+
const gm = state.workflows.graphMeta
|
|
59
|
+
const graph = (
|
|
60
|
+
gm instanceof Map ? [...gm.values()] : Object.values(gm)
|
|
61
|
+
)[0] as any
|
|
62
|
+
assert.ok(graph, 'a graph should have been produced')
|
|
63
|
+
return {
|
|
64
|
+
nodes: graph.nodes,
|
|
65
|
+
invoked: [...(state.rpc?.invokedFunctions ?? [])],
|
|
66
|
+
code: deserializeDslWorkflow(graph),
|
|
67
|
+
}
|
|
68
|
+
} finally {
|
|
69
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('DSL onError — compensation is part of the graph', () => {
|
|
74
|
+
test('the handler becomes a node the failing step points at', async () => {
|
|
75
|
+
const { nodes } = await inspectWorkflow(WORKFLOW_BODY)
|
|
76
|
+
|
|
77
|
+
const charge = Object.values(nodes).find(
|
|
78
|
+
(n: any) => n.rpcName === 'chargeCard'
|
|
79
|
+
) as any
|
|
80
|
+
assert.ok(charge, 'the charging step should exist')
|
|
81
|
+
assert.ok(
|
|
82
|
+
charge.onError,
|
|
83
|
+
`the step must record its error route so the graph can render it, got: ${JSON.stringify(charge)}`
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
const handlerId = Array.isArray(charge.onError)
|
|
87
|
+
? charge.onError[0]
|
|
88
|
+
: charge.onError
|
|
89
|
+
const handler = nodes[handlerId]
|
|
90
|
+
assert.ok(
|
|
91
|
+
handler,
|
|
92
|
+
`onError must point at a real node so an edge can be drawn, got id '${handlerId}' in ${JSON.stringify(Object.keys(nodes))}`
|
|
93
|
+
)
|
|
94
|
+
assert.equal(handler.rpcName, 'refundOrder')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('the handler RPC is registered as invoked so it gets wired', async () => {
|
|
98
|
+
const { invoked } = await inspectWorkflow(WORKFLOW_BODY)
|
|
99
|
+
|
|
100
|
+
assert.ok(
|
|
101
|
+
invoked.includes('refundOrder'),
|
|
102
|
+
`the compensation RPC must be wired, got: ${JSON.stringify(invoked)}`
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('onError survives regeneration back to code', async () => {
|
|
107
|
+
const { code } = await inspectWorkflow(WORKFLOW_BODY)
|
|
108
|
+
|
|
109
|
+
assert.ok(
|
|
110
|
+
code.includes("onError: 'refundOrder'"),
|
|
111
|
+
`onError must round-trip as an rpc name, got:\n${code}`
|
|
112
|
+
)
|
|
113
|
+
assert.ok(
|
|
114
|
+
code.includes('retries: 3'),
|
|
115
|
+
'other options must still round-trip'
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
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 ship = pikkuSessionlessFunc({',
|
|
12
|
+
" func: async () => ({ id: 's', ok: true }),",
|
|
13
|
+
'})',
|
|
14
|
+
].join('\n')
|
|
15
|
+
|
|
16
|
+
async function inspectWorkflow(body: string): Promise<{
|
|
17
|
+
nodes: Record<string, any>
|
|
18
|
+
diags: string[]
|
|
19
|
+
}> {
|
|
20
|
+
const diags: string[] = []
|
|
21
|
+
const logger = {
|
|
22
|
+
debug: () => {},
|
|
23
|
+
info: () => {},
|
|
24
|
+
warn: () => {},
|
|
25
|
+
error: () => {},
|
|
26
|
+
diagnostic: (d: any) => diags.push(d?.message ?? String(d)),
|
|
27
|
+
critical: (d: any) => diags.push(d?.message ?? String(d)),
|
|
28
|
+
hasCriticalErrors: () => false,
|
|
29
|
+
} as unknown as InspectorLogger
|
|
30
|
+
|
|
31
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-rs-'))
|
|
32
|
+
const stepFile = join(rootDir, 'w.steps.ts')
|
|
33
|
+
const wfFile = join(rootDir, 'w.workflow.ts')
|
|
34
|
+
await writeFile(stepFile, STEP_FILE)
|
|
35
|
+
await writeFile(
|
|
36
|
+
wfFile,
|
|
37
|
+
[
|
|
38
|
+
"import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
|
|
39
|
+
'export const wf = pikkuWorkflowFunc(async (_, data: any, { workflow }) => {',
|
|
40
|
+
body,
|
|
41
|
+
'})',
|
|
42
|
+
].join('\n')
|
|
43
|
+
)
|
|
44
|
+
try {
|
|
45
|
+
const state: any = await inspect(logger, [stepFile, wfFile], { rootDir })
|
|
46
|
+
const gm = state.workflows.graphMeta
|
|
47
|
+
const graph = (
|
|
48
|
+
gm instanceof Map ? [...gm.values()] : Object.values(gm)
|
|
49
|
+
)[0] as any
|
|
50
|
+
return { nodes: graph?.nodes ?? {}, diags }
|
|
51
|
+
} finally {
|
|
52
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('switch extraction — a fall-through case is a real case', () => {
|
|
57
|
+
test("case 'a': case 'b': records both values", async () => {
|
|
58
|
+
const { nodes } = await inspectWorkflow(
|
|
59
|
+
[
|
|
60
|
+
' switch (data.kind) {',
|
|
61
|
+
" case 'a':",
|
|
62
|
+
" case 'b':",
|
|
63
|
+
" await workflow.do('Ship', 'ship', {})",
|
|
64
|
+
' break',
|
|
65
|
+
' }',
|
|
66
|
+
' return { ok: true }',
|
|
67
|
+
].join('\n')
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const switchNode = Object.values(nodes).find(
|
|
71
|
+
(n: any) => n.flow === 'switch'
|
|
72
|
+
) as any
|
|
73
|
+
assert.ok(switchNode, 'a switch node should exist')
|
|
74
|
+
const values = (switchNode.cases ?? []).map((c: any) => c.value)
|
|
75
|
+
assert.deepEqual(
|
|
76
|
+
values.sort(),
|
|
77
|
+
['a', 'b'],
|
|
78
|
+
`a fall-through case must not be dropped, got: ${JSON.stringify(switchNode.cases)}`
|
|
79
|
+
)
|
|
80
|
+
const entries = new Set((switchNode.cases ?? []).map((c: any) => c.entry))
|
|
81
|
+
assert.equal(entries.size, 1, 'both cases should share the same entry')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('return extraction — a spread output is not silently dropped', () => {
|
|
86
|
+
test('return { ...r, extra } keeps the spread', async () => {
|
|
87
|
+
const { nodes } = await inspectWorkflow(
|
|
88
|
+
[
|
|
89
|
+
" const r = await workflow.do('Ship', 'ship', {})",
|
|
90
|
+
' return { ...r, extra: 1 }',
|
|
91
|
+
].join('\n')
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
const returnNode = Object.values(nodes).find(
|
|
95
|
+
(n: any) => n.flow === 'return'
|
|
96
|
+
) as any
|
|
97
|
+
assert.ok(returnNode, 'a return node should exist')
|
|
98
|
+
assert.deepEqual(
|
|
99
|
+
returnNode.spread,
|
|
100
|
+
['r'],
|
|
101
|
+
`the spread source must be recorded, got: ${JSON.stringify(returnNode)}`
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('return r produces a return node', async () => {
|
|
106
|
+
const { nodes } = await inspectWorkflow(
|
|
107
|
+
[" const r = await workflow.do('Ship', 'ship', {})", ' return r'].join(
|
|
108
|
+
'\n'
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
const returnNode = Object.values(nodes).find(
|
|
113
|
+
(n: any) => n.flow === 'return'
|
|
114
|
+
) as any
|
|
115
|
+
assert.ok(
|
|
116
|
+
returnNode,
|
|
117
|
+
`returning a variable must still produce a return node, got: ${JSON.stringify(nodes)}`
|
|
118
|
+
)
|
|
119
|
+
assert.deepEqual(returnNode.spread, ['r'])
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,163 @@
|
|
|
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
|
+
function makeLogger(diags: string[]): InspectorLogger {
|
|
10
|
+
return {
|
|
11
|
+
debug: () => {},
|
|
12
|
+
info: () => {},
|
|
13
|
+
warn: () => {},
|
|
14
|
+
error: () => {},
|
|
15
|
+
diagnostic: ({ code, message }: any) => {
|
|
16
|
+
diags.push(`${code}: ${message}`)
|
|
17
|
+
},
|
|
18
|
+
critical: (code: any, message: string) => {
|
|
19
|
+
diags.push(`${code}: ${message}`)
|
|
20
|
+
},
|
|
21
|
+
hasCriticalErrors: () => false,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const STEP_FILE = [
|
|
26
|
+
"import { pikkuSessionlessFunc } from '@pikku/core'",
|
|
27
|
+
'export const listUsers = pikkuSessionlessFunc({',
|
|
28
|
+
" func: async () => ({ users: [{ id: '1', email: 'a@b.c' }] }),",
|
|
29
|
+
'})',
|
|
30
|
+
'export const rpcA = pikkuSessionlessFunc({',
|
|
31
|
+
" func: async () => ({ id: 'a' }),",
|
|
32
|
+
'})',
|
|
33
|
+
'export const rpcB = pikkuSessionlessFunc({',
|
|
34
|
+
" func: async () => ({ id: 'b' }),",
|
|
35
|
+
'})',
|
|
36
|
+
'export const rpcC = pikkuSessionlessFunc({',
|
|
37
|
+
' func: async () => ({ ok: true }),',
|
|
38
|
+
'})',
|
|
39
|
+
].join('\n')
|
|
40
|
+
|
|
41
|
+
async function inspectWorkflow(body: string) {
|
|
42
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-drops-'))
|
|
43
|
+
const stepFile = join(rootDir, 'w.steps.ts')
|
|
44
|
+
const wfFile = join(rootDir, 'w.workflow.ts')
|
|
45
|
+
await writeFile(stepFile, STEP_FILE)
|
|
46
|
+
await writeFile(
|
|
47
|
+
wfFile,
|
|
48
|
+
[
|
|
49
|
+
"import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
|
|
50
|
+
'export const wf = pikkuWorkflowFunc(async (_, data: any, { workflow }) => {',
|
|
51
|
+
body,
|
|
52
|
+
'})',
|
|
53
|
+
].join('\n')
|
|
54
|
+
)
|
|
55
|
+
const diags: string[] = []
|
|
56
|
+
try {
|
|
57
|
+
const state: any = await inspect(makeLogger(diags), [stepFile, wfFile], {
|
|
58
|
+
rootDir,
|
|
59
|
+
})
|
|
60
|
+
const metaMap = state.workflows.meta
|
|
61
|
+
const meta = (
|
|
62
|
+
metaMap instanceof Map ? [...metaMap.values()] : Object.values(metaMap)
|
|
63
|
+
)[0] as any
|
|
64
|
+
return {
|
|
65
|
+
invoked: [...state.rpc.invokedFunctions] as string[],
|
|
66
|
+
steps: (meta?.steps ?? []) as any[],
|
|
67
|
+
diags,
|
|
68
|
+
}
|
|
69
|
+
} finally {
|
|
70
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('DSL extraction — constructs that must not be silently dropped', () => {
|
|
75
|
+
test('a property access on the loop item (u.id) survives as an item input', async () => {
|
|
76
|
+
const { steps } = await inspectWorkflow(
|
|
77
|
+
[
|
|
78
|
+
" const audience = await workflow.do('collect', 'listUsers', {})",
|
|
79
|
+
' await Promise.all(',
|
|
80
|
+
' audience.users.map(async (u) => {',
|
|
81
|
+
" await workflow.do('send', 'rpcC', { userId: u.id, tag: 'x' })",
|
|
82
|
+
' })',
|
|
83
|
+
' )',
|
|
84
|
+
' return { ok: true }',
|
|
85
|
+
].join('\n')
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const fanout = steps.find((s) => s.type === 'fanout')
|
|
89
|
+
assert.ok(fanout, 'fanout step should be extracted')
|
|
90
|
+
const send = fanout.body?.[0]
|
|
91
|
+
assert.ok(send, 'the fanout body step should be extracted')
|
|
92
|
+
assert.deepEqual(
|
|
93
|
+
send.inputs?.userId,
|
|
94
|
+
{ from: 'item', path: 'id' },
|
|
95
|
+
`u.id must map to the loop item, got: ${JSON.stringify(send.inputs)}`
|
|
96
|
+
)
|
|
97
|
+
assert.deepEqual(
|
|
98
|
+
send.inputs?.tag,
|
|
99
|
+
{ from: 'literal', value: 'x' },
|
|
100
|
+
'sibling literal inputs must still survive'
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('array destructuring of a Promise.all group keeps both steps and binds each result', async () => {
|
|
105
|
+
const { invoked, steps } = await inspectWorkflow(
|
|
106
|
+
[
|
|
107
|
+
' const [org, user] = await Promise.all([',
|
|
108
|
+
" workflow.do('create org', 'rpcA', {}),",
|
|
109
|
+
" workflow.do('create user', 'rpcB', {}),",
|
|
110
|
+
' ])',
|
|
111
|
+
' return { orgId: org.id, userId: user.id }',
|
|
112
|
+
].join('\n')
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
assert.ok(invoked.includes('rpcA'), 'rpcA must not be dropped')
|
|
116
|
+
assert.ok(invoked.includes('rpcB'), 'rpcB must not be dropped')
|
|
117
|
+
|
|
118
|
+
const parallel = steps.find((s) => s.type === 'parallel')
|
|
119
|
+
assert.ok(parallel, 'a parallel step should be extracted')
|
|
120
|
+
assert.deepEqual(
|
|
121
|
+
parallel.children?.map((c: any) => c.outputVar),
|
|
122
|
+
['org', 'user'],
|
|
123
|
+
'each destructured name should bind to its matching child step'
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
const ret = steps.find((s) => s.type === 'return')
|
|
127
|
+
assert.deepEqual(
|
|
128
|
+
ret?.outputs?.orgId,
|
|
129
|
+
{ from: 'outputVar', name: 'org', path: 'id' },
|
|
130
|
+
'org.id must resolve to the bound step output, not degrade to a trigger input'
|
|
131
|
+
)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('object destructuring of a step result reports a diagnostic instead of silently dropping', async () => {
|
|
135
|
+
const { invoked, diags } = await inspectWorkflow(
|
|
136
|
+
[
|
|
137
|
+
" const { id } = await workflow.do('s', 'rpcA', {})",
|
|
138
|
+
' return { id }',
|
|
139
|
+
].join('\n')
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
const dropped = !invoked.includes('rpcA')
|
|
143
|
+
assert.ok(
|
|
144
|
+
!dropped || diags.length > 0,
|
|
145
|
+
'if the step is dropped the user must be told — silence is the bug'
|
|
146
|
+
)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('a brace-less for-of body is still extracted as a sequential fanout', async () => {
|
|
150
|
+
const { invoked, steps } = await inspectWorkflow(
|
|
151
|
+
[
|
|
152
|
+
' for (const email of data.emails)',
|
|
153
|
+
" await workflow.do('invite', 'rpcA', { email })",
|
|
154
|
+
' return { ok: true }',
|
|
155
|
+
].join('\n')
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
assert.ok(invoked.includes('rpcA'), 'rpcA must not be dropped')
|
|
159
|
+
const fanout = steps.find((s) => s.type === 'fanout')
|
|
160
|
+
assert.ok(fanout, 'a brace-less for-of should still produce a fanout step')
|
|
161
|
+
assert.equal(fanout.mode, 'sequential')
|
|
162
|
+
})
|
|
163
|
+
})
|