@pikku/core 0.12.64 → 0.12.67
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 +311 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/permissions.d.ts +12 -4
- package/dist/permissions.js +11 -32
- package/dist/scopes.d.ts +14 -0
- package/dist/scopes.js +39 -8
- package/dist/testing/service-tests.js +37 -0
- package/dist/types/core.types.d.ts +22 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +64 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.js +103 -5
- package/dist/wirings/ai-agent/ai-agent-runner.js +5 -1
- package/dist/wirings/ai-agent/ai-agent-stream.js +28 -7
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +29 -1
- package/dist/wirings/ai-agent/index.d.ts +1 -1
- package/dist/wirings/ai-agent/index.js +1 -1
- package/dist/wirings/ai-agent/voice-input.js +3 -3
- package/dist/wirings/cli/cli-runner.js +3 -0
- package/dist/wirings/cli/command-parser.d.ts +2 -0
- package/dist/wirings/cli/command-parser.js +59 -2
- package/dist/wirings/credential/credential.types.d.ts +14 -0
- package/dist/wirings/credential/validate-credential-definitions.js +1 -0
- package/dist/wirings/gateway/gateway-runner.js +100 -50
- package/dist/wirings/gateway/gateway.types.d.ts +8 -5
- package/dist/wirings/http/http.types.d.ts +3 -3
- package/dist/wirings/scope/validate-scope-definitions.d.ts +8 -0
- package/dist/wirings/scope/validate-scope-definitions.js +16 -1
- package/dist/wirings/secret/secret.types.d.ts +14 -0
- package/dist/wirings/secret/validate-secret-definitions.js +2 -0
- package/dist/wirings/variable/validate-variable-definitions.js +2 -0
- package/dist/wirings/variable/variable.types.d.ts +14 -0
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +36 -6
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +8 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +16 -0
- package/dist/wirings/workflow/workflow.types.d.ts +0 -2
- package/package.json +2 -1
- package/src/index.ts +2 -1
- package/src/permissions.test.ts +14 -8
- package/src/permissions.ts +14 -36
- package/src/scopes.test.ts +37 -1
- package/src/scopes.ts +48 -9
- package/src/testing/service-tests.ts +49 -0
- package/src/types/core.types.ts +23 -0
- package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
- package/src/wirings/ai-agent/ai-agent-prepare.test.ts +175 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +132 -5
- package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
- package/src/wirings/ai-agent/ai-agent-runner.ts +7 -0
- package/src/wirings/ai-agent/ai-agent-stream.test.ts +103 -0
- package/src/wirings/ai-agent/ai-agent-stream.ts +38 -6
- package/src/wirings/ai-agent/ai-agent.types.ts +29 -0
- package/src/wirings/ai-agent/index.ts +4 -0
- package/src/wirings/ai-agent/voice-input.test.ts +90 -0
- package/src/wirings/ai-agent/voice-input.ts +8 -10
- package/src/wirings/cli/cli-runner.ts +4 -0
- package/src/wirings/cli/command-parser.test.ts +130 -0
- package/src/wirings/cli/command-parser.ts +80 -2
- package/src/wirings/credential/credential.types.ts +14 -0
- package/src/wirings/credential/validate-credential-definitions.ts +1 -0
- package/src/wirings/gateway/gateway-authorization.test.ts +444 -0
- package/src/wirings/gateway/gateway-runner.ts +114 -68
- package/src/wirings/gateway/gateway.types.ts +7 -9
- package/src/wirings/http/http.types.ts +6 -4
- package/src/wirings/scope/scope.test.ts +25 -0
- package/src/wirings/scope/validate-scope-definitions.ts +16 -1
- package/src/wirings/secret/secret.types.ts +14 -0
- package/src/wirings/secret/validate-secret-definitions.ts +2 -0
- package/src/wirings/variable/validate-variable-definitions.ts +2 -0
- package/src/wirings/variable/variable.types.ts +14 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +36 -6
- package/src/wirings/workflow/pikku-workflow-service.ts +36 -0
- package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
- package/src/wirings/workflow/workflow.types.ts +0 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -4,11 +4,7 @@ import type {
|
|
|
4
4
|
CorePikkuMiddlewareGroup,
|
|
5
5
|
CoreSingletonServices,
|
|
6
6
|
} from '../../types/core.types.js'
|
|
7
|
-
import type {
|
|
8
|
-
CorePikkuFunctionConfig,
|
|
9
|
-
CorePermissionGroup,
|
|
10
|
-
CorePikkuPermission,
|
|
11
|
-
} from '../../function/functions.types.js'
|
|
7
|
+
import type { CorePikkuFunctionConfig } from '../../function/functions.types.js'
|
|
12
8
|
import type { PikkuHTTPRequest } from '../http/http.types.js'
|
|
13
9
|
|
|
14
10
|
/**
|
|
@@ -124,7 +120,6 @@ export type GatewayTransportType = 'webhook' | 'websocket' | 'listener'
|
|
|
124
120
|
*/
|
|
125
121
|
export type CoreGateway<
|
|
126
122
|
PikkuFunctionConfig = CorePikkuFunctionConfig<any, any>,
|
|
127
|
-
PikkuPermission extends CorePikkuPermission = CorePikkuPermission,
|
|
128
123
|
PikkuMiddleware extends CorePikkuMiddleware = CorePikkuMiddleware,
|
|
129
124
|
> = Partial<
|
|
130
125
|
Pick<CommonWireMeta, 'title' | 'summary' | 'description' | 'errors'>
|
|
@@ -143,11 +138,14 @@ export type CoreGateway<
|
|
|
143
138
|
func: PikkuFunctionConfig
|
|
144
139
|
/** Optional middleware chain (e.g., auth) */
|
|
145
140
|
middleware?: CorePikkuMiddlewareGroup<any, any>
|
|
146
|
-
/** Optional permissions */
|
|
147
|
-
permissions?: CorePermissionGroup | PikkuPermission[]
|
|
148
141
|
/** Optional tags for categorization */
|
|
149
142
|
tags?: string[]
|
|
150
|
-
/**
|
|
143
|
+
/**
|
|
144
|
+
* Whether the handler requires a session. Left unset, the handler's own
|
|
145
|
+
* `auth` governs, and a gateway handler is sessionless by default — inbound
|
|
146
|
+
* gateway traffic is platform-authenticated by the adapter, not
|
|
147
|
+
* session-bearing. Set `true` to require a session for every message.
|
|
148
|
+
*/
|
|
151
149
|
auth?: boolean
|
|
152
150
|
}
|
|
153
151
|
|
|
@@ -126,8 +126,10 @@ export type CoreHTTPFunctionWiring<
|
|
|
126
126
|
> = CorePikkuFunctionSessionless<In, Out>,
|
|
127
127
|
PikkuPermission extends CorePikkuPermission<In, any, any> =
|
|
128
128
|
CorePikkuPermission<In, any, any>,
|
|
129
|
-
PikkuMiddleware extends CorePikkuMiddleware<any, any> =
|
|
130
|
-
|
|
129
|
+
PikkuMiddleware extends CorePikkuMiddleware<any, any> = CorePikkuMiddleware<
|
|
130
|
+
any,
|
|
131
|
+
any
|
|
132
|
+
>,
|
|
131
133
|
> =
|
|
132
134
|
| (CoreHTTPFunction & {
|
|
133
135
|
route: R
|
|
@@ -287,7 +289,7 @@ export type HTTPRouteConfig<
|
|
|
287
289
|
| CorePikkuFunction<any, any, any, any, any>
|
|
288
290
|
| CorePikkuFunctionSessionless<any, any, any, any, any>,
|
|
289
291
|
PikkuPermission extends CorePikkuPermission<any, any, any> =
|
|
290
|
-
CorePikkuPermission<any>,
|
|
292
|
+
CorePikkuPermission<any, any, any>,
|
|
291
293
|
PikkuMiddleware extends CorePikkuMiddleware<any, any> = CorePikkuMiddleware<
|
|
292
294
|
any,
|
|
293
295
|
any
|
|
@@ -313,7 +315,7 @@ export type HTTPRouteConfig<
|
|
|
313
315
|
*/
|
|
314
316
|
export type HTTPRoutesGroupConfig<
|
|
315
317
|
PikkuPermission extends CorePikkuPermission<any, any, any> =
|
|
316
|
-
CorePikkuPermission<any>,
|
|
318
|
+
CorePikkuPermission<any, any, any>,
|
|
317
319
|
PikkuMiddleware extends CorePikkuMiddleware<any, any> = CorePikkuMiddleware<
|
|
318
320
|
any,
|
|
319
321
|
any
|
|
@@ -62,6 +62,31 @@ describe('flattenScopeDefinitions', () => {
|
|
|
62
62
|
['admin', 'admin:users', 'billing']
|
|
63
63
|
)
|
|
64
64
|
})
|
|
65
|
+
|
|
66
|
+
// An addon and its host app may both contribute the same root. Codegen writes
|
|
67
|
+
// these ids into an object literal, where a repeat is a TypeScript error.
|
|
68
|
+
test('a root declared twice is flattened once', () => {
|
|
69
|
+
const tree = { users: { scopes: { ban: {} } } }
|
|
70
|
+
const definitions: ScopeDefinitions = [
|
|
71
|
+
{ name: 'admin', description: 'Administration', scopes: tree },
|
|
72
|
+
{ name: 'admin', description: 'Administration', scopes: tree },
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
assert.deepEqual(
|
|
76
|
+
flattenScopeDefinitions(definitions).map((s) => s.id),
|
|
77
|
+
['admin', 'admin:users', 'admin:users:ban']
|
|
78
|
+
)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('the first description of a repeated scope wins', () => {
|
|
82
|
+
assert.deepEqual(
|
|
83
|
+
flattenScopeDefinitions([
|
|
84
|
+
{ name: 'admin', description: 'Administration' },
|
|
85
|
+
{ name: 'admin', description: 'Something else' },
|
|
86
|
+
]),
|
|
87
|
+
[{ id: 'admin', description: 'Administration' }]
|
|
88
|
+
)
|
|
89
|
+
})
|
|
65
90
|
})
|
|
66
91
|
|
|
67
92
|
describe('validateAndBuildScopeDefinitionsMeta', () => {
|
|
@@ -52,6 +52,14 @@ const flattenNodes = (
|
|
|
52
52
|
* Flattens declared scope trees into the full list of grantable scope ids,
|
|
53
53
|
* depth-first. Every node is emitted, including intermediate ones.
|
|
54
54
|
*
|
|
55
|
+
* Ids are unique. A root may legitimately be declared more than once — an addon
|
|
56
|
+
* and its host app both contributing the same `admin` tree, say — and
|
|
57
|
+
* {@link validateAndBuildScopeDefinitionsMeta} already guarantees those
|
|
58
|
+
* declarations are identical, so the second one is redundant rather than
|
|
59
|
+
* conflicting. Collapsing it here keeps every consumer honest: codegen emits an
|
|
60
|
+
* object literal keyed by id (duplicates are a TypeScript error), and a
|
|
61
|
+
* ScopeService syncs one row per scope instead of re-writing the same one.
|
|
62
|
+
*
|
|
55
63
|
* Used by codegen to build the `ScopeId` union, and by a ScopeService to sync
|
|
56
64
|
* the declared set into its store.
|
|
57
65
|
*/
|
|
@@ -63,7 +71,14 @@ export const flattenScopeDefinitions = (
|
|
|
63
71
|
out.push({ id: def.name, description: def.description })
|
|
64
72
|
flattenNodes(def.scopes, def.name, out)
|
|
65
73
|
}
|
|
66
|
-
|
|
74
|
+
const seen = new Set<string>()
|
|
75
|
+
return out.filter((scope) => {
|
|
76
|
+
if (seen.has(scope.id)) {
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
seen.add(scope.id)
|
|
80
|
+
return true
|
|
81
|
+
})
|
|
67
82
|
}
|
|
68
83
|
|
|
69
84
|
/**
|
|
@@ -4,6 +4,13 @@ export type CoreSecret<T = unknown> = {
|
|
|
4
4
|
description?: string
|
|
5
5
|
secretId: string
|
|
6
6
|
schema: T
|
|
7
|
+
/**
|
|
8
|
+
* Link to documentation explaining how to obtain this value — a provider's
|
|
9
|
+
* API-key page, a setup guide, an internal runbook. Surfaced by consoles and
|
|
10
|
+
* deploy UIs so a user facing a missing value has somewhere to go instead of
|
|
11
|
+
* an opaque identifier.
|
|
12
|
+
*/
|
|
13
|
+
docsUrl?: string
|
|
7
14
|
/**
|
|
8
15
|
* Optional rotation cadence for this secret, e.g. '1d', '30day', '1w'.
|
|
9
16
|
* Stored in the generated secrets metadata so consumers can tell when a
|
|
@@ -27,6 +34,13 @@ export type SecretDefinitionMeta = {
|
|
|
27
34
|
description?: string
|
|
28
35
|
secretId: string
|
|
29
36
|
schema?: Record<string, unknown> | string
|
|
37
|
+
/**
|
|
38
|
+
* Link to documentation explaining how to obtain this value — a provider's
|
|
39
|
+
* API-key page, a setup guide, an internal runbook. Surfaced by consoles and
|
|
40
|
+
* deploy UIs so a user facing a missing value has somewhere to go instead of
|
|
41
|
+
* an opaque identifier.
|
|
42
|
+
*/
|
|
43
|
+
docsUrl?: string
|
|
30
44
|
oauth2?: OAuth2CredentialConfig
|
|
31
45
|
rotationPeriod?: string
|
|
32
46
|
sourceFile?: string
|
|
@@ -58,6 +58,7 @@ export function validateAndBuildSecretDefinitionsMeta(
|
|
|
58
58
|
schema: def.schema,
|
|
59
59
|
oauth2: def.oauth2,
|
|
60
60
|
rotationPeriod: def.rotationPeriod,
|
|
61
|
+
docsUrl: def.docsUrl,
|
|
61
62
|
sourceFile: def.sourceFile,
|
|
62
63
|
}
|
|
63
64
|
}
|
|
@@ -75,6 +76,7 @@ export function validateAndBuildSecretDefinitionsMeta(
|
|
|
75
76
|
schema: def.schema,
|
|
76
77
|
oauth2: def.oauth2,
|
|
77
78
|
rotationPeriod: def.rotationPeriod,
|
|
79
|
+
docsUrl: def.docsUrl,
|
|
78
80
|
sourceFile: def.sourceFile,
|
|
79
81
|
}
|
|
80
82
|
}
|
|
@@ -45,6 +45,7 @@ export function validateAndBuildVariableDefinitionsMeta(
|
|
|
45
45
|
description: def.description,
|
|
46
46
|
variableId: def.variableId,
|
|
47
47
|
schema: def.schema,
|
|
48
|
+
docsUrl: def.docsUrl,
|
|
48
49
|
sourceFile: def.sourceFile,
|
|
49
50
|
}
|
|
50
51
|
}
|
|
@@ -60,6 +61,7 @@ export function validateAndBuildVariableDefinitionsMeta(
|
|
|
60
61
|
description: def.description,
|
|
61
62
|
variableId: def.variableId,
|
|
62
63
|
schema: def.schema,
|
|
64
|
+
docsUrl: def.docsUrl,
|
|
63
65
|
sourceFile: def.sourceFile,
|
|
64
66
|
}
|
|
65
67
|
}
|
|
@@ -4,6 +4,13 @@ export type CoreVariable<T = unknown> = {
|
|
|
4
4
|
description?: string
|
|
5
5
|
variableId: string
|
|
6
6
|
schema: T
|
|
7
|
+
/**
|
|
8
|
+
* Link to documentation explaining how to obtain this value — a provider's
|
|
9
|
+
* API-key page, a setup guide, an internal runbook. Surfaced by consoles and
|
|
10
|
+
* deploy UIs so a user facing a missing value has somewhere to go instead of
|
|
11
|
+
* an opaque identifier.
|
|
12
|
+
*/
|
|
13
|
+
docsUrl?: string
|
|
7
14
|
}
|
|
8
15
|
|
|
9
16
|
export type VariableDefinitionMeta = {
|
|
@@ -12,6 +19,13 @@ export type VariableDefinitionMeta = {
|
|
|
12
19
|
description?: string
|
|
13
20
|
variableId: string
|
|
14
21
|
schema?: Record<string, unknown> | string
|
|
22
|
+
/**
|
|
23
|
+
* Link to documentation explaining how to obtain this value — a provider's
|
|
24
|
+
* API-key page, a setup guide, an internal runbook. Surfaced by consoles and
|
|
25
|
+
* deploy UIs so a user facing a missing value has somewhere to go instead of
|
|
26
|
+
* an opaque identifier.
|
|
27
|
+
*/
|
|
28
|
+
docsUrl?: string
|
|
15
29
|
sourceFile?: string
|
|
16
30
|
}
|
|
17
31
|
|
|
@@ -18,6 +18,13 @@ export interface WorkflowStepOptions {
|
|
|
18
18
|
retries?: number
|
|
19
19
|
/** Delay between retry attempts (e.g., '1s', '2s', '2min') */
|
|
20
20
|
retryDelay?: string | number
|
|
21
|
+
/**
|
|
22
|
+
* RPC to invoke for compensation when this step fails after exhausting its
|
|
23
|
+
* retries. Mirrors a graph node's `onError`: the handler receives
|
|
24
|
+
* `{ error: { message } }` and the original error is still thrown, so the
|
|
25
|
+
* workflow fails — this is compensation, not recovery.
|
|
26
|
+
*/
|
|
27
|
+
onError?: string
|
|
21
28
|
/**
|
|
22
29
|
* Run this step as an actor (scenarios). The RPC is sent through the
|
|
23
30
|
* actor's authenticated client over the REAL transport — never dispatched
|
|
@@ -224,16 +231,20 @@ export interface ParallelGroupStepMeta {
|
|
|
224
231
|
*/
|
|
225
232
|
export interface FanoutStepMeta {
|
|
226
233
|
type: 'fanout'
|
|
227
|
-
/**
|
|
228
|
-
|
|
234
|
+
/**
|
|
235
|
+
* Step name for this fanout. Optional: a fanout is not itself a cached step,
|
|
236
|
+
* and node ids are step names — borrowing a body step's name would give the
|
|
237
|
+
* loop and that step the same id, collapsing one onto the other.
|
|
238
|
+
*/
|
|
239
|
+
stepName?: string
|
|
229
240
|
/** Source array variable name */
|
|
230
241
|
sourceVar: string
|
|
231
242
|
/** Iterator variable name */
|
|
232
243
|
itemVar: string
|
|
233
244
|
/** Execution mode */
|
|
234
245
|
mode: 'parallel' | 'sequential'
|
|
235
|
-
/**
|
|
236
|
-
|
|
246
|
+
/** Steps to execute inline per iteration, in order */
|
|
247
|
+
body: Array<RpcStepMeta | SleepStepMeta | SuspendStepMeta>
|
|
237
248
|
/** Time between iterations (sequential mode only) */
|
|
238
249
|
timeBetween?: string
|
|
239
250
|
}
|
|
@@ -245,6 +256,12 @@ export interface ReturnStepMeta {
|
|
|
245
256
|
type: 'return'
|
|
246
257
|
/** Output bindings */
|
|
247
258
|
outputs: Record<string, OutputBinding>
|
|
259
|
+
/**
|
|
260
|
+
* Variables spread into the returned object (`return { ...r }`), or the sole
|
|
261
|
+
* returned variable (`return r`). Their fields are not enumerable statically,
|
|
262
|
+
* so they are recorded by name rather than expanded into `outputs`.
|
|
263
|
+
*/
|
|
264
|
+
spread?: string[]
|
|
248
265
|
}
|
|
249
266
|
|
|
250
267
|
/**
|
|
@@ -271,6 +288,13 @@ export interface SleepStepMeta {
|
|
|
271
288
|
stepName: string
|
|
272
289
|
/** Sleep duration */
|
|
273
290
|
duration: string | number
|
|
291
|
+
/**
|
|
292
|
+
* Source text of a duration only known at runtime (e.g. a loop variable).
|
|
293
|
+
* The closure evaluates it, so it is legal DSL; it is kept separate from
|
|
294
|
+
* `duration` so regenerated code emits it raw rather than as a string
|
|
295
|
+
* literal, exactly as `expression` does on a set step.
|
|
296
|
+
*/
|
|
297
|
+
expression?: string
|
|
274
298
|
}
|
|
275
299
|
|
|
276
300
|
/**
|
|
@@ -291,8 +315,14 @@ export interface SetStepMeta {
|
|
|
291
315
|
type: 'set'
|
|
292
316
|
/** Variable name to set (must be in context) */
|
|
293
317
|
variable: string
|
|
294
|
-
/**
|
|
295
|
-
value
|
|
318
|
+
/** Literal value to assign. Mutually exclusive with `expression`. */
|
|
319
|
+
value?: unknown
|
|
320
|
+
/**
|
|
321
|
+
* Source text of a non-literal assignment (e.g. `count + 1`). Kept separate
|
|
322
|
+
* from `value` so regenerated code can emit it raw — a string `value` is a
|
|
323
|
+
* string literal, an `expression` is code.
|
|
324
|
+
*/
|
|
325
|
+
expression?: string
|
|
296
326
|
}
|
|
297
327
|
|
|
298
328
|
/**
|
|
@@ -1825,6 +1825,30 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1825
1825
|
}
|
|
1826
1826
|
}
|
|
1827
1827
|
|
|
1828
|
+
/**
|
|
1829
|
+
* Run a failed step's compensation handler as a durable step of its own.
|
|
1830
|
+
*
|
|
1831
|
+
* Durable rather than a bare invoke so a replay does not compensate twice —
|
|
1832
|
+
* a handler is typically a refund or a rollback. `onError` is deliberately
|
|
1833
|
+
* not forwarded: a compensation handler cannot itself compensate.
|
|
1834
|
+
*/
|
|
1835
|
+
private async runStepCompensation(
|
|
1836
|
+
runId: string,
|
|
1837
|
+
stepName: string,
|
|
1838
|
+
onErrorRpcName: string,
|
|
1839
|
+
rpcService: any,
|
|
1840
|
+
error: Error
|
|
1841
|
+
): Promise<void> {
|
|
1842
|
+
await this.rpcStep(
|
|
1843
|
+
runId,
|
|
1844
|
+
`${stepName}:onError`,
|
|
1845
|
+
onErrorRpcName,
|
|
1846
|
+
{ error: { message: error.message } },
|
|
1847
|
+
rpcService,
|
|
1848
|
+
{ retries: 0 }
|
|
1849
|
+
)
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1828
1852
|
private async rpcStep(
|
|
1829
1853
|
runId: string,
|
|
1830
1854
|
logicalStepName: string,
|
|
@@ -1844,6 +1868,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1844
1868
|
retries: stepOptions?.retries ?? DEFAULT_STEP_RETRIES,
|
|
1845
1869
|
retryDelay: stepOptions?.retryDelay,
|
|
1846
1870
|
actor: stepOptions?.actor,
|
|
1871
|
+
onError: stepOptions?.onError,
|
|
1847
1872
|
}
|
|
1848
1873
|
// Check if step already exists
|
|
1849
1874
|
let stepState: StepState
|
|
@@ -1872,6 +1897,17 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1872
1897
|
stepState.error?.message ||
|
|
1873
1898
|
`Step '${stepName}' failed after exhausting all retries`
|
|
1874
1899
|
)
|
|
1900
|
+
// Compensation, mirroring a graph node's onError: run the handler, then
|
|
1901
|
+
// still throw — the workflow fails either way.
|
|
1902
|
+
if (resolvedStepOptions.onError) {
|
|
1903
|
+
await this.runStepCompensation(
|
|
1904
|
+
runId,
|
|
1905
|
+
stepName,
|
|
1906
|
+
resolvedStepOptions.onError,
|
|
1907
|
+
rpcService,
|
|
1908
|
+
error
|
|
1909
|
+
)
|
|
1910
|
+
}
|
|
1875
1911
|
// Preserve original error properties if available
|
|
1876
1912
|
if (stepState.error) {
|
|
1877
1913
|
Object.assign(error, stepState.error)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Drive the real failure branch: a step that is already terminally failed must
|
|
8
|
+
* invoke its onError handler and still throw. Only the nested compensation
|
|
9
|
+
* call is intercepted, so the branch under test is the production one.
|
|
10
|
+
*/
|
|
11
|
+
class TestWorkflowService extends InMemoryWorkflowService {
|
|
12
|
+
public compensations: Array<{ rpcName: string; data: any }> = []
|
|
13
|
+
public dispatches = 0
|
|
14
|
+
private pretendDispatchSucceeds = false
|
|
15
|
+
|
|
16
|
+
public alwaysDispatch() {
|
|
17
|
+
this.pretendDispatchSucceeds = true
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
protected override async dispatchStep(...args: any[]): Promise<boolean> {
|
|
21
|
+
this.dispatches++
|
|
22
|
+
if (this.pretendDispatchSucceeds) {
|
|
23
|
+
return true
|
|
24
|
+
}
|
|
25
|
+
return (super.dispatchStep as any)(...args)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public async callFailedStep(
|
|
29
|
+
runId: string,
|
|
30
|
+
stepName: string,
|
|
31
|
+
onError?: string
|
|
32
|
+
) {
|
|
33
|
+
const self = this as any
|
|
34
|
+
const realRpcStep = self.rpcStep.bind(self)
|
|
35
|
+
let depth = 0
|
|
36
|
+
self.rpcStep = async (...args: any[]) => {
|
|
37
|
+
if (depth++ > 0) {
|
|
38
|
+
this.compensations.push({ rpcName: args[2], data: args[3] })
|
|
39
|
+
return { compensated: true }
|
|
40
|
+
}
|
|
41
|
+
return realRpcStep(...args)
|
|
42
|
+
}
|
|
43
|
+
return self.rpcStep(runId, stepName, 'chargeCard', {}, {}, { onError })
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Seed a step in a non-terminal state, as a replay would find one that is
|
|
49
|
+
* waiting on the queue rather than one that has failed.
|
|
50
|
+
*/
|
|
51
|
+
async function seedPendingStep(ws: TestWorkflowService, stepName: string) {
|
|
52
|
+
const runId = await ws.createRun('wf', {}, true, 'hash', {
|
|
53
|
+
type: 'inline',
|
|
54
|
+
} as any)
|
|
55
|
+
const step = await ws.insertStepState(runId, stepName, 'chargeCard', {})
|
|
56
|
+
return { runId, step }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function seedFailedStep(ws: TestWorkflowService, stepName: string) {
|
|
60
|
+
const runId = await ws.createRun('wf', {}, true, 'hash', {
|
|
61
|
+
type: 'inline',
|
|
62
|
+
} as any)
|
|
63
|
+
const step = await ws.insertStepState(runId, stepName, 'chargeCard', {})
|
|
64
|
+
await (ws as any).updateStepStateImpl?.(runId, stepName, {
|
|
65
|
+
status: 'failed',
|
|
66
|
+
error: { message: 'card declined' },
|
|
67
|
+
})
|
|
68
|
+
const raw = (ws as any).steps ?? (ws as any).stepStates
|
|
69
|
+
if (raw?.get) {
|
|
70
|
+
const key = [...raw.keys()].find((k: string) => k.includes(stepName))
|
|
71
|
+
if (key) {
|
|
72
|
+
raw.set(key, {
|
|
73
|
+
...raw.get(key),
|
|
74
|
+
status: 'failed',
|
|
75
|
+
error: { message: 'card declined' },
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { runId, step }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe('workflow onError — compensation on terminal step failure', () => {
|
|
83
|
+
test('a failed step runs its handler and still throws the original error', async () => {
|
|
84
|
+
const ws = new TestWorkflowService()
|
|
85
|
+
const { runId } = await seedFailedStep(ws, 'Charge')
|
|
86
|
+
|
|
87
|
+
await assert.rejects(
|
|
88
|
+
() => ws.callFailedStep(runId, 'Charge', 'refundOrder'),
|
|
89
|
+
/card declined/,
|
|
90
|
+
'compensation must not swallow the failure — the workflow still fails'
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
assert.equal(
|
|
94
|
+
ws.compensations.length,
|
|
95
|
+
1,
|
|
96
|
+
'the onError handler must have been invoked'
|
|
97
|
+
)
|
|
98
|
+
assert.equal(ws.compensations[0].rpcName, 'refundOrder')
|
|
99
|
+
assert.deepEqual(
|
|
100
|
+
ws.compensations[0].data,
|
|
101
|
+
{ error: { message: 'card declined' } },
|
|
102
|
+
'the handler receives the failure reason, as a graph onError node does'
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('a step still sitting on the queue pauses without compensating', async () => {
|
|
107
|
+
const ws = new TestWorkflowService()
|
|
108
|
+
const { runId, step } = await seedPendingStep(ws, 'Charge')
|
|
109
|
+
await ws.setStepScheduled(step.stepId)
|
|
110
|
+
|
|
111
|
+
await assert.rejects(
|
|
112
|
+
() => ws.callFailedStep(runId, 'Charge', 'refundOrder'),
|
|
113
|
+
(error: Error) => error.name === 'WorkflowAsyncException',
|
|
114
|
+
'a scheduled step must pause the workflow, not fail it'
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
assert.equal(
|
|
118
|
+
ws.compensations.length,
|
|
119
|
+
0,
|
|
120
|
+
'compensating a step that has not run yet would refund a charge that was never made'
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('the pause thrown right after dispatch does not compensate', async () => {
|
|
125
|
+
const ws = new TestWorkflowService()
|
|
126
|
+
ws.alwaysDispatch()
|
|
127
|
+
const { runId } = await seedPendingStep(ws, 'Charge')
|
|
128
|
+
|
|
129
|
+
await assert.rejects(
|
|
130
|
+
() => ws.callFailedStep(runId, 'Charge', 'refundOrder'),
|
|
131
|
+
(error: Error) => error.name === 'WorkflowAsyncException',
|
|
132
|
+
'a freshly dispatched step pauses the workflow'
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
assert.equal(ws.dispatches, 1, 'the step should have been dispatched once')
|
|
136
|
+
assert.equal(
|
|
137
|
+
ws.compensations.length,
|
|
138
|
+
0,
|
|
139
|
+
'queued is not failed — the handler must not run'
|
|
140
|
+
)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('a failed step with no onError simply throws', async () => {
|
|
144
|
+
const ws = new TestWorkflowService()
|
|
145
|
+
const { runId } = await seedFailedStep(ws, 'Charge')
|
|
146
|
+
|
|
147
|
+
await assert.rejects(() => ws.callFailedStep(runId, 'Charge', undefined))
|
|
148
|
+
assert.equal(
|
|
149
|
+
ws.compensations.length,
|
|
150
|
+
0,
|
|
151
|
+
'no handler configured means nothing to compensate'
|
|
152
|
+
)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
@@ -286,8 +286,6 @@ export type CoreWorkflow<
|
|
|
286
286
|
func: PikkuFunctionConfig
|
|
287
287
|
/** Middleware chain for this workflow */
|
|
288
288
|
middleware?: PikkuFunctionConfig['middleware']
|
|
289
|
-
/** Permission requirements */
|
|
290
|
-
permissions?: PikkuFunctionConfig['permissions']
|
|
291
289
|
/** Tags for organization and filtering */
|
|
292
290
|
tags?: string[]
|
|
293
291
|
}
|