@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,225 @@
|
|
|
1
|
+
import * as ts from 'typescript'
|
|
2
|
+
import { getPropertyValue } from '../utils/get-property-value.js'
|
|
3
|
+
import type { AddWiring, InspectorLogger } from '../types.js'
|
|
4
|
+
import { ErrorCode } from '../error-codes.js'
|
|
5
|
+
import type { ScopeNodeMeta } from '@pikku/core/scope'
|
|
6
|
+
|
|
7
|
+
const SEPARATOR = ':'
|
|
8
|
+
const WILDCARD = '*'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Validates a single scope segment, reporting rather than throwing so the
|
|
12
|
+
* inspector can surface every problem in one pass.
|
|
13
|
+
*
|
|
14
|
+
* @returns true when the segment is usable.
|
|
15
|
+
*/
|
|
16
|
+
const isValidSegment = (
|
|
17
|
+
segment: string,
|
|
18
|
+
scopeName: string,
|
|
19
|
+
logger: InspectorLogger
|
|
20
|
+
): boolean => {
|
|
21
|
+
if (segment.length === 0) {
|
|
22
|
+
logger.critical(
|
|
23
|
+
ErrorCode.INVALID_VALUE,
|
|
24
|
+
`Scope '${scopeName}' contains an empty segment.`
|
|
25
|
+
)
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
if (segment.includes(SEPARATOR)) {
|
|
29
|
+
logger.critical(
|
|
30
|
+
ErrorCode.INVALID_VALUE,
|
|
31
|
+
`Scope segment '${segment}' in '${scopeName}' contains the '${SEPARATOR}' separator. ` +
|
|
32
|
+
`Nest scopes with the 'scopes' property instead of embedding '${SEPARATOR}' in a name.`
|
|
33
|
+
)
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
if (segment === WILDCARD) {
|
|
37
|
+
logger.critical(
|
|
38
|
+
ErrorCode.INVALID_VALUE,
|
|
39
|
+
`Scope segment '${segment}' in '${scopeName}' is the wildcard. ` +
|
|
40
|
+
`'${WILDCARD}' is reserved for granting a scope and its descendants, and cannot be declared.`
|
|
41
|
+
)
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Recursively extracts a `scopes: { ... }` object literal into nested metadata.
|
|
49
|
+
*/
|
|
50
|
+
const extractScopeNodes = (
|
|
51
|
+
obj: ts.ObjectLiteralExpression,
|
|
52
|
+
scopeName: string,
|
|
53
|
+
logger: InspectorLogger
|
|
54
|
+
): Record<string, ScopeNodeMeta> | undefined => {
|
|
55
|
+
const nodes: Record<string, ScopeNodeMeta> = {}
|
|
56
|
+
|
|
57
|
+
for (const prop of obj.properties) {
|
|
58
|
+
if (!ts.isPropertyAssignment(prop)) {
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let segment: string | undefined
|
|
63
|
+
if (ts.isIdentifier(prop.name)) {
|
|
64
|
+
segment = prop.name.text
|
|
65
|
+
} else if (ts.isStringLiteral(prop.name)) {
|
|
66
|
+
segment = prop.name.text
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (segment === undefined) {
|
|
70
|
+
logger.critical(
|
|
71
|
+
ErrorCode.NON_LITERAL_WIRE_NAME,
|
|
72
|
+
`Scope '${scopeName}' has a nested scope whose key is not a literal.`
|
|
73
|
+
)
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!isValidSegment(segment, scopeName, logger)) {
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!ts.isObjectLiteralExpression(prop.initializer)) {
|
|
82
|
+
logger.critical(
|
|
83
|
+
ErrorCode.INVALID_VALUE,
|
|
84
|
+
`Nested scope '${segment}' in '${scopeName}' must be an object literal.`
|
|
85
|
+
)
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const node: ScopeNodeMeta = {}
|
|
90
|
+
const displayName = getPropertyValue(prop.initializer, 'displayName') as
|
|
91
|
+
| string
|
|
92
|
+
| null
|
|
93
|
+
if (displayName) {
|
|
94
|
+
node.displayName = displayName
|
|
95
|
+
}
|
|
96
|
+
const description = getPropertyValue(prop.initializer, 'description') as
|
|
97
|
+
| string
|
|
98
|
+
| null
|
|
99
|
+
if (description) {
|
|
100
|
+
node.description = description
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const nestedProp = prop.initializer.properties.find(
|
|
104
|
+
(p) =>
|
|
105
|
+
ts.isPropertyAssignment(p) &&
|
|
106
|
+
ts.isIdentifier(p.name) &&
|
|
107
|
+
p.name.text === 'scopes'
|
|
108
|
+
)
|
|
109
|
+
if (
|
|
110
|
+
nestedProp &&
|
|
111
|
+
ts.isPropertyAssignment(nestedProp) &&
|
|
112
|
+
ts.isObjectLiteralExpression(nestedProp.initializer)
|
|
113
|
+
) {
|
|
114
|
+
const nested = extractScopeNodes(
|
|
115
|
+
nestedProp.initializer,
|
|
116
|
+
scopeName,
|
|
117
|
+
logger
|
|
118
|
+
)
|
|
119
|
+
if (nested) {
|
|
120
|
+
node.scopes = nested
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
nodes[segment] = node
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return Object.keys(nodes).length > 0 ? nodes : undefined
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Unwraps `x as const` / `x as any` so a cast declaration is still extracted
|
|
132
|
+
* rather than silently skipped.
|
|
133
|
+
*/
|
|
134
|
+
const unwrapAs = (node: ts.Expression): ts.Expression =>
|
|
135
|
+
ts.isAsExpression(node) || ts.isSatisfiesExpression(node)
|
|
136
|
+
? unwrapAs(node.expression)
|
|
137
|
+
: node
|
|
138
|
+
|
|
139
|
+
export const addScope: AddWiring = (logger, node, checker, state, _options) => {
|
|
140
|
+
if (!ts.isCallExpression(node)) {
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const expression = node.expression
|
|
145
|
+
if (!ts.isIdentifier(expression) || expression.text !== 'wireScope') {
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const firstArg = node.arguments[0]
|
|
150
|
+
if (!firstArg) {
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const unwrapped = unwrapAs(firstArg)
|
|
155
|
+
if (!ts.isObjectLiteralExpression(unwrapped)) {
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const sourceFile = node.getSourceFile().fileName
|
|
160
|
+
|
|
161
|
+
// Roots are keyed exactly like the nodes beneath them, so each property of
|
|
162
|
+
// the call's single argument is one tree.
|
|
163
|
+
for (const prop of unwrapped.properties) {
|
|
164
|
+
if (!ts.isPropertyAssignment(prop)) {
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let name: string | undefined
|
|
169
|
+
if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) {
|
|
170
|
+
name = prop.name.text
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (name === undefined) {
|
|
174
|
+
logger.critical(
|
|
175
|
+
ErrorCode.NON_LITERAL_WIRE_NAME,
|
|
176
|
+
'A scope is declared with a key that is not a literal.'
|
|
177
|
+
)
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!isValidSegment(name, name, logger)) {
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!ts.isObjectLiteralExpression(prop.initializer)) {
|
|
186
|
+
logger.critical(
|
|
187
|
+
ErrorCode.INVALID_VALUE,
|
|
188
|
+
`Scope '${name}' must be an object literal.`
|
|
189
|
+
)
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const root = prop.initializer
|
|
194
|
+
const displayNameValue = getPropertyValue(root, 'displayName') as
|
|
195
|
+
| string
|
|
196
|
+
| null
|
|
197
|
+
const descriptionValue = getPropertyValue(root, 'description') as
|
|
198
|
+
| string
|
|
199
|
+
| null
|
|
200
|
+
|
|
201
|
+
let scopes: Record<string, ScopeNodeMeta> | undefined
|
|
202
|
+
const scopesProp = root.properties.find(
|
|
203
|
+
(p) =>
|
|
204
|
+
ts.isPropertyAssignment(p) &&
|
|
205
|
+
ts.isIdentifier(p.name) &&
|
|
206
|
+
p.name.text === 'scopes'
|
|
207
|
+
)
|
|
208
|
+
if (
|
|
209
|
+
scopesProp &&
|
|
210
|
+
ts.isPropertyAssignment(scopesProp) &&
|
|
211
|
+
ts.isObjectLiteralExpression(scopesProp.initializer)
|
|
212
|
+
) {
|
|
213
|
+
scopes = extractScopeNodes(scopesProp.initializer, name, logger)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
state.scopes.files.add(sourceFile)
|
|
217
|
+
state.scopes.definitions.push({
|
|
218
|
+
name,
|
|
219
|
+
displayName: displayNameValue || undefined,
|
|
220
|
+
description: descriptionValue || undefined,
|
|
221
|
+
scopes,
|
|
222
|
+
sourceFile,
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
}
|
package/src/add/add-trigger.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
makeContextBasedId,
|
|
10
10
|
} from '../utils/extract-function-name.js'
|
|
11
11
|
import { getPropertyAssignmentInitializer } from '../utils/type-utils.js'
|
|
12
|
+
import { ensureInlineWiringFunction } from '../utils/ensure-function-metadata.js'
|
|
12
13
|
import { resolveMiddleware } from '../utils/middleware.js'
|
|
13
14
|
import { extractWireNames } from '../utils/post-process.js'
|
|
14
15
|
import { resolveAddonName } from '../utils/resolve-addon-package.js'
|
|
@@ -83,6 +84,16 @@ const addWireTrigger: (
|
|
|
83
84
|
return
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
// Register metadata for a func inlined into the wiring (see helper).
|
|
88
|
+
ensureInlineWiringFunction(
|
|
89
|
+
state,
|
|
90
|
+
pikkuFuncId,
|
|
91
|
+
nameValue,
|
|
92
|
+
funcInitializer,
|
|
93
|
+
checker,
|
|
94
|
+
extracted.isHelper
|
|
95
|
+
)
|
|
96
|
+
|
|
86
97
|
// --- resolve middleware ---
|
|
87
98
|
const middleware = resolveMiddleware(state, obj, tags, checker)
|
|
88
99
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as ts from 'typescript'
|
|
2
|
+
import type { InspectorState, InspectorLogger } from '../types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Detect wireRemoteAddon({ name: '...', package: '...' }) call expressions and
|
|
6
|
+
* record the namespace as a **remote** addon declaration. Only name + package
|
|
7
|
+
* are needed for codegen (serverUrl/auth are runtime closures resolved by the
|
|
8
|
+
* RPC runner); the `remote` marker tells the consumer's RPC-map generator to
|
|
9
|
+
* import the addon's `.remote.gen` map instead of `.internal.gen`.
|
|
10
|
+
*/
|
|
11
|
+
export function addWireRemoteAddon(
|
|
12
|
+
node: ts.Node,
|
|
13
|
+
state: InspectorState,
|
|
14
|
+
logger: InspectorLogger
|
|
15
|
+
) {
|
|
16
|
+
if (!ts.isCallExpression(node)) return
|
|
17
|
+
|
|
18
|
+
const { expression, arguments: args } = node
|
|
19
|
+
if (!ts.isIdentifier(expression) || expression.text !== 'wireRemoteAddon')
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
const [firstArg] = args
|
|
23
|
+
if (!firstArg || !ts.isObjectLiteralExpression(firstArg)) return
|
|
24
|
+
|
|
25
|
+
let name: string | undefined
|
|
26
|
+
let pkg: string | undefined
|
|
27
|
+
// The auth binding is a runtime closure; we only extract the statically
|
|
28
|
+
// knowable slot names so `pikku verify` can check they exist in the
|
|
29
|
+
// consumer's own wirings. `hasAuth` distinguishes "public" (omitted) from
|
|
30
|
+
// "bound via a custom resolve()" (present but no static id).
|
|
31
|
+
let hasAuth = false
|
|
32
|
+
let authCredentialId: string | undefined
|
|
33
|
+
let authSecretId: string | undefined
|
|
34
|
+
|
|
35
|
+
for (const prop of firstArg.properties) {
|
|
36
|
+
if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue
|
|
37
|
+
const key = prop.name.text
|
|
38
|
+
if (key === 'name' && ts.isStringLiteral(prop.initializer)) {
|
|
39
|
+
name = prop.initializer.text
|
|
40
|
+
} else if (key === 'package' && ts.isStringLiteral(prop.initializer)) {
|
|
41
|
+
pkg = prop.initializer.text
|
|
42
|
+
} else if (
|
|
43
|
+
key === 'auth' &&
|
|
44
|
+
ts.isObjectLiteralExpression(prop.initializer)
|
|
45
|
+
) {
|
|
46
|
+
hasAuth = true
|
|
47
|
+
for (const authProp of prop.initializer.properties) {
|
|
48
|
+
if (!ts.isPropertyAssignment(authProp) || !ts.isIdentifier(authProp.name))
|
|
49
|
+
continue
|
|
50
|
+
if (
|
|
51
|
+
authProp.name.text === 'credentialId' &&
|
|
52
|
+
ts.isStringLiteral(authProp.initializer)
|
|
53
|
+
) {
|
|
54
|
+
authCredentialId = authProp.initializer.text
|
|
55
|
+
} else if (
|
|
56
|
+
authProp.name.text === 'secretId' &&
|
|
57
|
+
ts.isStringLiteral(authProp.initializer)
|
|
58
|
+
) {
|
|
59
|
+
authSecretId = authProp.initializer.text
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!name || !pkg) return
|
|
66
|
+
|
|
67
|
+
logger.debug(`• Found wireRemoteAddon: ${name} → ${pkg} (remote)`)
|
|
68
|
+
state.rpc.wireAddonDeclarations.set(name, {
|
|
69
|
+
package: pkg,
|
|
70
|
+
remote: true,
|
|
71
|
+
hasAuth,
|
|
72
|
+
authCredentialId,
|
|
73
|
+
authSecretId,
|
|
74
|
+
})
|
|
75
|
+
state.rpc.usedAddons.add(name)
|
|
76
|
+
state.rpc.wireAddonFiles.add(node.getSourceFile().fileName)
|
|
77
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
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
|
+
'export const notify = pikkuSessionlessFunc({',
|
|
19
|
+
" func: async () => ({ id: 'n' }),",
|
|
20
|
+
'})',
|
|
21
|
+
'export const audit = pikkuSessionlessFunc({',
|
|
22
|
+
" func: async () => ({ id: 'a' }),",
|
|
23
|
+
'})',
|
|
24
|
+
].join('\n')
|
|
25
|
+
|
|
26
|
+
async function run(body: string): Promise<{
|
|
27
|
+
nodes: Record<string, any>
|
|
28
|
+
diags: string[]
|
|
29
|
+
code: string
|
|
30
|
+
}> {
|
|
31
|
+
const diags: string[] = []
|
|
32
|
+
const logger = {
|
|
33
|
+
debug: () => {},
|
|
34
|
+
info: () => {},
|
|
35
|
+
warn: () => {},
|
|
36
|
+
error: () => {},
|
|
37
|
+
diagnostic: (...a: any[]) => diags.push(a.map(String).join(' ')),
|
|
38
|
+
critical: (...a: any[]) => diags.push(a.map(String).join(' ')),
|
|
39
|
+
hasCriticalErrors: () => false,
|
|
40
|
+
} as unknown as InspectorLogger
|
|
41
|
+
|
|
42
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-edge-'))
|
|
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
|
+
try {
|
|
56
|
+
const state: any = await inspect(logger, [stepFile, wfFile], { rootDir })
|
|
57
|
+
const gm = state.workflows.graphMeta
|
|
58
|
+
const graph = (
|
|
59
|
+
gm instanceof Map ? [...gm.values()] : Object.values(gm)
|
|
60
|
+
)[0] as any
|
|
61
|
+
return {
|
|
62
|
+
nodes: graph?.nodes ?? {},
|
|
63
|
+
diags,
|
|
64
|
+
code: graph ? deserializeDslWorkflow(graph) : '',
|
|
65
|
+
}
|
|
66
|
+
} finally {
|
|
67
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('onError — edge cases', () => {
|
|
72
|
+
test('the handler is not also emitted as a step that always runs', async () => {
|
|
73
|
+
const { code } = await run(
|
|
74
|
+
[
|
|
75
|
+
" await workflow.do('Charge', 'chargeCard', {}, { onError: 'refundOrder' })",
|
|
76
|
+
' return { ok: true }',
|
|
77
|
+
].join('\n')
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
const refundCalls = code.match(/'refundOrder'/g) ?? []
|
|
81
|
+
assert.equal(
|
|
82
|
+
refundCalls.length,
|
|
83
|
+
1,
|
|
84
|
+
`the handler must appear only as onError, never as its own sequential step, got:\n${code}`
|
|
85
|
+
)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('a non-literal onError is reported, not silently ignored', async () => {
|
|
89
|
+
const { diags } = await run(
|
|
90
|
+
[
|
|
91
|
+
' const target = data.handler',
|
|
92
|
+
" await workflow.do('Charge', 'chargeCard', {}, { onError: target })",
|
|
93
|
+
' return { ok: true }',
|
|
94
|
+
].join('\n')
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
assert.ok(
|
|
98
|
+
diags.some((d) => d.includes('onError')),
|
|
99
|
+
`a non-literal onError must be diagnosed, got: ${JSON.stringify(diags)}`
|
|
100
|
+
)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('onError inside a branch still resolves', async () => {
|
|
104
|
+
const { nodes } = await run(
|
|
105
|
+
[
|
|
106
|
+
' if (data.paid) {',
|
|
107
|
+
" await workflow.do('Charge', 'chargeCard', {}, { onError: 'refundOrder' })",
|
|
108
|
+
' }',
|
|
109
|
+
' return { ok: true }',
|
|
110
|
+
].join('\n')
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
const charge = Object.values(nodes).find(
|
|
114
|
+
(n: any) => n.rpcName === 'chargeCard'
|
|
115
|
+
) as any
|
|
116
|
+
assert.ok(
|
|
117
|
+
charge?.onError,
|
|
118
|
+
'a branch step should still carry its error route'
|
|
119
|
+
)
|
|
120
|
+
assert.equal((nodes[charge.onError] as any)?.rpcName, 'refundOrder')
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('switch — fall-through to default', () => {
|
|
125
|
+
test('a trailing empty case routes to the default body', async () => {
|
|
126
|
+
const { nodes } = await run(
|
|
127
|
+
[
|
|
128
|
+
' switch (data.kind) {',
|
|
129
|
+
" case 'a':",
|
|
130
|
+
" await workflow.do('Notify', 'notify', {})",
|
|
131
|
+
' break',
|
|
132
|
+
" case 'b':",
|
|
133
|
+
' default:',
|
|
134
|
+
" await workflow.do('Audit', 'audit', {})",
|
|
135
|
+
' }',
|
|
136
|
+
' return { ok: true }',
|
|
137
|
+
].join('\n')
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
const sw = Object.values(nodes).find((n: any) => n.flow === 'switch') as any
|
|
141
|
+
assert.ok(sw, 'a switch node should exist')
|
|
142
|
+
const caseB = (sw.cases ?? []).find((c: any) => c.value === 'b')
|
|
143
|
+
assert.ok(
|
|
144
|
+
caseB,
|
|
145
|
+
`a case falling through to default must not be dropped, got: ${JSON.stringify(sw.cases)}`
|
|
146
|
+
)
|
|
147
|
+
assert.equal(
|
|
148
|
+
caseB.entry,
|
|
149
|
+
sw.defaultEntry,
|
|
150
|
+
'it should route to the default body it falls into'
|
|
151
|
+
)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('options — numeric values keep their type', () => {
|
|
156
|
+
test('a numeric retryDelay is not quoted', async () => {
|
|
157
|
+
const { code } = await run(
|
|
158
|
+
[
|
|
159
|
+
" await workflow.do('Charge', 'chargeCard', {}, { retries: 2, retryDelay: 500 })",
|
|
160
|
+
' return { ok: true }',
|
|
161
|
+
].join('\n')
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
assert.ok(
|
|
165
|
+
code.includes('retryDelay: 500'),
|
|
166
|
+
`a numeric retryDelay must stay numeric, got:\n${code}`
|
|
167
|
+
)
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe('sequential fanout — for..of bodies', () => {
|
|
172
|
+
test('a multi-step for..of body keeps every step', async () => {
|
|
173
|
+
const { nodes } = await run(
|
|
174
|
+
[
|
|
175
|
+
' for (const u of data.users) {',
|
|
176
|
+
" const c = await workflow.do('Charge', 'chargeCard', { id: u.id })",
|
|
177
|
+
" await workflow.do('Notify', 'notify', { id: c.id })",
|
|
178
|
+
' }',
|
|
179
|
+
' return { ok: true }',
|
|
180
|
+
].join('\n')
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
const fanout = Object.values(nodes).find(
|
|
184
|
+
(n: any) => n.flow === 'fanout'
|
|
185
|
+
) as any
|
|
186
|
+
assert.ok(fanout, 'a for..of should produce a fanout node')
|
|
187
|
+
assert.notEqual(
|
|
188
|
+
fanout.nodeId,
|
|
189
|
+
fanout.childEntry,
|
|
190
|
+
'the loop and its body entry must not share an id'
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
const rpcs = Object.values(nodes)
|
|
194
|
+
.filter((n: any) => n.rpcName)
|
|
195
|
+
.map((n: any) => n.rpcName)
|
|
196
|
+
assert.ok(
|
|
197
|
+
rpcs.includes('chargeCard') && rpcs.includes('notify'),
|
|
198
|
+
`both body steps must survive, got: ${JSON.stringify(rpcs)}`
|
|
199
|
+
)
|
|
200
|
+
})
|
|
201
|
+
})
|
|
@@ -103,4 +103,64 @@ describe('addWorkflow — Promise.all fanout RPC detection', () => {
|
|
|
103
103
|
await rm(rootDir, { recursive: true, force: true })
|
|
104
104
|
}
|
|
105
105
|
})
|
|
106
|
+
|
|
107
|
+
test('registers every RPC in a multi-step map body, including a `const`-captured step feeding the next', async () => {
|
|
108
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-fanout-multi-'))
|
|
109
|
+
const wfFile = join(rootDir, 'digest.workflow.ts')
|
|
110
|
+
const stepFile = join(rootDir, 'digest.steps.ts')
|
|
111
|
+
|
|
112
|
+
await writeFile(
|
|
113
|
+
stepFile,
|
|
114
|
+
[
|
|
115
|
+
"import { pikkuSessionlessFunc } from '@pikku/core'",
|
|
116
|
+
'export const getDigestData = pikkuSessionlessFunc({',
|
|
117
|
+
' func: async ({ logger }) => ({ dealsByStage: {}, totalDealValue: 0 }),',
|
|
118
|
+
'})',
|
|
119
|
+
'export const sendDigestEmail = pikkuSessionlessFunc({',
|
|
120
|
+
' func: async ({ logger }) => ({ sent: true }),',
|
|
121
|
+
'})',
|
|
122
|
+
].join('\n')
|
|
123
|
+
)
|
|
124
|
+
await writeFile(
|
|
125
|
+
wfFile,
|
|
126
|
+
[
|
|
127
|
+
"import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
|
|
128
|
+
'export const dailyDigestWorkflow = pikkuWorkflowFunc(async (_, data, { workflow }) => {',
|
|
129
|
+
' const users = [{ id: "1", email: "a@b.c" }]',
|
|
130
|
+
' await Promise.all(',
|
|
131
|
+
' users.map(async (u) => {',
|
|
132
|
+
' const digestData = await workflow.do(',
|
|
133
|
+
' `Get pipeline for ${u.email}`,',
|
|
134
|
+
" 'getDigestData',",
|
|
135
|
+
' { userId: u.id },',
|
|
136
|
+
' )',
|
|
137
|
+
" await workflow.do(`Send digest to ${u.email}`, 'sendDigestEmail', {",
|
|
138
|
+
' email: u.email,',
|
|
139
|
+
' dealsByStage: digestData.dealsByStage,',
|
|
140
|
+
' totalDealValue: digestData.totalDealValue,',
|
|
141
|
+
' })',
|
|
142
|
+
' }),',
|
|
143
|
+
' )',
|
|
144
|
+
' return { sent: users.length }',
|
|
145
|
+
'})',
|
|
146
|
+
].join('\n')
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
const criticals: Array<{ code: string; message: string }> = []
|
|
150
|
+
try {
|
|
151
|
+
const state = await inspect(makeLogger(criticals), [stepFile, wfFile], {
|
|
152
|
+
rootDir,
|
|
153
|
+
})
|
|
154
|
+
assert.ok(
|
|
155
|
+
state.rpc.invokedFunctions.has('sendDigestEmail'),
|
|
156
|
+
'sendDigestEmail should be registered'
|
|
157
|
+
)
|
|
158
|
+
assert.ok(
|
|
159
|
+
state.rpc.invokedFunctions.has('getDigestData'),
|
|
160
|
+
'getDigestData should be registered — a `const`-captured workflow.do inside the map body must not be dropped'
|
|
161
|
+
)
|
|
162
|
+
} finally {
|
|
163
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
164
|
+
}
|
|
165
|
+
})
|
|
106
166
|
})
|
|
@@ -0,0 +1,94 @@
|
|
|
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 — node input extraction', () => {
|
|
22
|
+
test('`as const` and imported template() values survive into the serialized input', async () => {
|
|
23
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-graph-input-'))
|
|
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 edit = pikkuSessionlessFunc({ func: async () => ({ ok: true }) })',
|
|
32
|
+
].join('\n')
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
await writeFile(
|
|
36
|
+
graphFile,
|
|
37
|
+
[
|
|
38
|
+
"import { pikkuWorkflowGraph } from '@pikku/core/workflow'",
|
|
39
|
+
"import { template } from '@pikku/core/workflow'",
|
|
40
|
+
'export const myGraph = pikkuWorkflowGraph({',
|
|
41
|
+
" name: 'input-graph',",
|
|
42
|
+
" nodes: { edit: 'edit' },",
|
|
43
|
+
' config: {',
|
|
44
|
+
' edit: {',
|
|
45
|
+
' input: (ref) => ({',
|
|
46
|
+
" method: 'GET' as const,",
|
|
47
|
+
' operations: [',
|
|
48
|
+
" { field: 'product', operation: 'set' as const, value: 'widget' },",
|
|
49
|
+
" { field: 'email', operation: 'set' as const, value: ref('trigger', 'body.email') },",
|
|
50
|
+
" { field: 'greeting', operation: 'set' as const, value: template('Hi $0', [ref('trigger', 'body.name')]) },",
|
|
51
|
+
' ],',
|
|
52
|
+
' }),',
|
|
53
|
+
' },',
|
|
54
|
+
' },',
|
|
55
|
+
'})',
|
|
56
|
+
].join('\n')
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const state = await inspect(makeLogger(), [stepFile, graphFile], {
|
|
61
|
+
rootDir,
|
|
62
|
+
})
|
|
63
|
+
const graph = state.workflows.graphMeta['input-graph']
|
|
64
|
+
assert.ok(graph, 'graph meta should be registered')
|
|
65
|
+
const input = (graph.nodes['edit'] as { input?: any }).input
|
|
66
|
+
assert.ok(input, 'node input should be extracted')
|
|
67
|
+
|
|
68
|
+
// Bug 1 — top-level `X as const` must not be dropped.
|
|
69
|
+
assert.equal(input.method, 'GET', 'top-level `as const` value preserved')
|
|
70
|
+
|
|
71
|
+
// Bug 1 — nested `as const` inside an array element must not be dropped.
|
|
72
|
+
assert.equal(input.operations[0].operation, 'set')
|
|
73
|
+
assert.equal(input.operations[0].value, 'widget')
|
|
74
|
+
assert.equal(input.operations[1].operation, 'set')
|
|
75
|
+
|
|
76
|
+
// ref() still works alongside the fix.
|
|
77
|
+
assert.deepEqual(input.operations[1].value, {
|
|
78
|
+
$ref: 'trigger',
|
|
79
|
+
path: 'body.email',
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// Bug 2 — imported template() (arrow has only `ref`, no 2nd param) preserved.
|
|
83
|
+
assert.equal(input.operations[2].operation, 'set')
|
|
84
|
+
assert.deepEqual(input.operations[2].value, {
|
|
85
|
+
$template: {
|
|
86
|
+
parts: ['Hi ', ''],
|
|
87
|
+
expressions: [{ $ref: 'trigger', path: 'body.name' }],
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
} finally {
|
|
91
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
})
|