@pikku/core 0.12.63 → 0.12.66
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 +368 -0
- package/dist/dev/hot-reload.js +13 -55
- package/dist/dev/module-runner.d.ts +14 -0
- package/dist/dev/module-runner.js +59 -0
- package/dist/dev/reload-meta.d.ts +13 -0
- package/dist/dev/reload-meta.js +22 -0
- package/dist/errors/errors.d.ts +12 -0
- package/dist/errors/errors.js +19 -0
- package/dist/function/function-runner.d.ts +5 -5
- package/dist/function/function-runner.js +39 -111
- package/dist/function/functions.types.d.ts +17 -7
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -1
- package/dist/middleware/auth-apikey.d.ts +1 -0
- package/dist/middleware/auth-bearer.d.ts +1 -0
- package/dist/middleware/auth-cookie.d.ts +1 -0
- package/dist/middleware/cors.d.ts +1 -0
- package/dist/middleware/index.d.ts +1 -1
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/remote-auth.d.ts +1 -0
- package/dist/middleware/telemetry.d.ts +2 -0
- package/dist/middleware/timeout.d.ts +1 -0
- package/dist/permissions.d.ts +42 -27
- package/dist/permissions.js +101 -203
- package/dist/pikku-state.js +0 -2
- package/dist/scopes.d.ts +17 -0
- package/dist/scopes.js +59 -0
- package/dist/services/ai-embedding-service.d.ts +31 -0
- package/dist/services/ai-embedding-service.js +1 -0
- package/dist/services/credential-wire-service.d.ts +3 -1
- package/dist/services/credential-wire-service.js +10 -4
- package/dist/services/index.d.ts +4 -0
- package/dist/services/index.js +2 -0
- package/dist/services/meta-service.d.ts +15 -2
- package/dist/services/meta-service.js +51 -1
- package/dist/services/queue-webhook-service.d.ts +55 -0
- package/dist/services/queue-webhook-service.js +136 -0
- package/dist/services/scope-service.d.ts +67 -0
- package/dist/services/scope-service.js +1 -0
- package/dist/services/typed-secret-service.d.ts +8 -0
- package/dist/services/typed-secret-service.js +39 -4
- package/dist/services/webhook-service.d.ts +140 -0
- package/dist/services/webhook-service.js +44 -0
- package/dist/services/workflow-service.d.ts +7 -0
- package/dist/testing/service-tests.js +37 -0
- package/dist/types/core.types.d.ts +34 -6
- package/dist/types/state.types.d.ts +20 -2
- package/dist/utils/hmac.d.ts +16 -0
- package/dist/utils/hmac.js +26 -0
- package/dist/utils/safe-fetch.d.ts +51 -0
- package/dist/utils/safe-fetch.js +192 -0
- package/dist/wirings/ai-agent/ai-agent-agui.d.ts +84 -0
- package/dist/wirings/ai-agent/ai-agent-agui.js +305 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +105 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +249 -11
- package/dist/wirings/ai-agent/ai-agent-runner.js +10 -1
- package/dist/wirings/ai-agent/ai-agent-stream.js +88 -16
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +56 -2
- package/dist/wirings/ai-agent/index.d.ts +2 -1
- package/dist/wirings/ai-agent/index.js +2 -1
- package/dist/wirings/ai-agent/voice-input.d.ts +1 -0
- package/dist/wirings/ai-agent/voice-input.js +5 -41
- package/dist/wirings/ai-agent/voice-output.d.ts +1 -0
- package/dist/wirings/channel/channel-common.js +0 -1
- package/dist/wirings/channel/channel-handler.js +1 -4
- package/dist/wirings/channel/channel-middleware-runner.d.ts +12 -0
- package/dist/wirings/channel/channel-middleware-runner.js +34 -19
- package/dist/wirings/channel/channel.types.d.ts +2 -6
- package/dist/wirings/cli/cli-runner.js +3 -2
- package/dist/wirings/cli/cli.types.d.ts +1 -2
- 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-routes.js +0 -3
- package/dist/wirings/http/http-runner.d.ts +9 -29
- package/dist/wirings/http/http-runner.js +9 -34
- package/dist/wirings/http/http.types.d.ts +4 -12
- package/dist/wirings/mcp/mcp-runner.js +0 -2
- package/dist/wirings/mcp/mcp.types.d.ts +5 -11
- package/dist/wirings/oauth2/index.d.ts +0 -3
- package/dist/wirings/oauth2/index.js +1 -2
- package/dist/wirings/rpc/addon-runner.d.ts +28 -0
- package/dist/wirings/rpc/addon-runner.js +173 -0
- package/dist/wirings/rpc/index.d.ts +5 -1
- package/dist/wirings/rpc/index.js +3 -1
- package/dist/wirings/rpc/remote-addon-auth.d.ts +26 -0
- package/dist/wirings/rpc/remote-addon-auth.js +43 -0
- package/dist/wirings/rpc/rpc-runner.d.ts +19 -0
- package/dist/wirings/rpc/rpc-runner.js +109 -3
- package/dist/wirings/rpc/rpc-types.d.ts +15 -1
- package/dist/wirings/rpc/wire-addon.js +9 -0
- package/dist/wirings/rpc/wire-remote-addon.d.ts +47 -0
- package/dist/wirings/rpc/wire-remote-addon.js +19 -0
- package/dist/wirings/scope/index.d.ts +3 -0
- package/dist/wirings/scope/index.js +2 -0
- package/dist/wirings/scope/scope.types.d.ts +39 -0
- package/dist/wirings/scope/scope.types.js +1 -0
- package/dist/wirings/scope/validate-scope-definitions.d.ts +16 -0
- package/dist/wirings/scope/validate-scope-definitions.js +76 -0
- package/dist/wirings/scope/wire-scope.d.ts +33 -0
- package/dist/wirings/scope/wire-scope.js +32 -0
- 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/index.d.ts +1 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +89 -7
- package/dist/wirings/workflow/graph/graph-node.d.ts +2 -0
- package/dist/wirings/workflow/graph/graph-runner.js +3 -0
- package/dist/wirings/workflow/graph/wire-workflow-graph.d.ts +6 -0
- package/dist/wirings/workflow/graph/wire-workflow-graph.js +1 -0
- package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +5 -0
- package/dist/wirings/workflow/index.d.ts +2 -2
- package/dist/wirings/workflow/index.js +1 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +59 -1
- package/dist/wirings/workflow/pikku-workflow-service.js +187 -0
- package/dist/wirings/workflow/workflow.types.d.ts +1 -3
- package/package.json +6 -1
- package/run-tests.sh +1 -0
- package/src/dev/hot-reload.ts +13 -68
- package/src/dev/module-runner.test.ts +169 -0
- package/src/dev/module-runner.ts +103 -0
- package/src/dev/reload-meta.test.ts +31 -2
- package/src/dev/reload-meta.ts +26 -0
- package/src/errors/errors.ts +24 -0
- package/src/function/function-runner.test.ts +276 -142
- package/src/function/function-runner.ts +52 -145
- package/src/function/functions.types.ts +28 -38
- package/src/index.ts +8 -6
- package/src/middleware/index.ts +1 -5
- package/src/permissions.test.ts +160 -373
- package/src/permissions.ts +137 -279
- package/src/pikku-state.ts +0 -2
- package/src/scopes.test.ts +167 -0
- package/src/scopes.ts +69 -0
- package/src/services/ai-embedding-service.ts +31 -0
- package/src/services/credential-wire-service.ts +10 -4
- package/src/services/index.ts +20 -0
- package/src/services/meta-service.ts +68 -3
- package/src/services/queue-webhook-service.test.ts +408 -0
- package/src/services/queue-webhook-service.ts +182 -0
- package/src/services/scope-service.ts +75 -0
- package/src/services/typed-secret-service.test.ts +35 -0
- package/src/services/typed-secret-service.ts +39 -4
- package/src/services/webhook-service.ts +180 -0
- package/src/services/workflow-service.ts +7 -0
- package/src/testing/service-tests.ts +49 -0
- package/src/types/core.types.ts +42 -14
- package/src/types/state.types.ts +23 -3
- package/src/utils/hmac.ts +27 -0
- package/src/utils/safe-fetch.test.ts +373 -0
- package/src/utils/safe-fetch.ts +213 -0
- package/src/wirings/ai-agent/ai-agent-agui.test.ts +1127 -0
- package/src/wirings/ai-agent/ai-agent-agui.ts +386 -0
- package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
- package/src/wirings/ai-agent/ai-agent-prepare.test.ts +483 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +324 -13
- package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
- package/src/wirings/ai-agent/ai-agent-runner.test.ts +48 -1
- package/src/wirings/ai-agent/ai-agent-runner.ts +28 -0
- package/src/wirings/ai-agent/ai-agent-stream.test.ts +301 -10
- package/src/wirings/ai-agent/ai-agent-stream.ts +122 -16
- package/src/wirings/ai-agent/ai-agent.types.ts +57 -0
- package/src/wirings/ai-agent/index.ts +5 -0
- package/src/wirings/ai-agent/voice-input.test.ts +90 -0
- package/src/wirings/ai-agent/voice-input.ts +9 -40
- package/src/wirings/channel/channel-common.ts +0 -1
- package/src/wirings/channel/channel-handler.ts +1 -9
- package/src/wirings/channel/channel-middleware-runner.test.ts +28 -2
- package/src/wirings/channel/channel-middleware-runner.ts +39 -27
- package/src/wirings/channel/channel.types.ts +0 -6
- package/src/wirings/cli/cli-runner.ts +4 -2
- package/src/wirings/cli/cli.types.ts +0 -2
- 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-routes.ts +0 -3
- package/src/wirings/http/http-runner.test.ts +1 -11
- package/src/wirings/http/http-runner.ts +16 -39
- package/src/wirings/http/http.types.ts +7 -14
- package/src/wirings/mcp/mcp-runner.ts +0 -2
- package/src/wirings/mcp/mcp.types.ts +3 -11
- package/src/wirings/oauth2/index.ts +0 -3
- package/src/wirings/rpc/addon-runner.ts +233 -0
- package/src/wirings/rpc/index.ts +17 -1
- package/src/wirings/rpc/remote-addon-auth.ts +69 -0
- package/src/wirings/rpc/rpc-runner.test.ts +319 -16
- package/src/wirings/rpc/rpc-runner.ts +155 -3
- package/src/wirings/rpc/rpc-types.ts +16 -5
- package/src/wirings/rpc/wire-addon.test.ts +6 -3
- package/src/wirings/rpc/wire-addon.ts +9 -0
- package/src/wirings/rpc/wire-remote-addon.ts +57 -0
- package/src/wirings/scope/index.ts +14 -0
- package/src/wirings/scope/scope.test.ts +135 -0
- package/src/wirings/scope/scope.types.ts +44 -0
- package/src/wirings/scope/validate-scope-definitions.ts +110 -0
- package/src/wirings/scope/wire-scope.ts +34 -0
- 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/index.ts +4 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +97 -6
- package/src/wirings/workflow/graph/graph-node.ts +2 -0
- package/src/wirings/workflow/graph/graph-runner.test.ts +58 -0
- package/src/wirings/workflow/graph/graph-runner.ts +3 -0
- package/src/wirings/workflow/graph/wire-workflow-graph.ts +7 -0
- package/src/wirings/workflow/graph/workflow-graph.types.ts +5 -0
- package/src/wirings/workflow/index.ts +5 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +341 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +272 -0
- package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
- package/src/wirings/workflow/workflow-step-session.test.ts +21 -5
- package/src/wirings/workflow/workflow.types.ts +4 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/src/wirings/oauth2/oauth2-client.test.ts +0 -930
- package/src/wirings/oauth2/oauth2-client.ts +0 -331
- package/src/wirings/oauth2/oauth2-routes.ts +0 -234
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* These types define the step-based workflow format extracted by the inspector
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
7
|
+
|
|
6
8
|
import type { WorkflowRun } from '../workflow.types.js'
|
|
7
9
|
import type { ScenarioActor } from '../../../services/scenario-actors-service.js'
|
|
8
10
|
|
|
@@ -16,6 +18,13 @@ export interface WorkflowStepOptions {
|
|
|
16
18
|
retries?: number
|
|
17
19
|
/** Delay between retry attempts (e.g., '1s', '2s', '2min') */
|
|
18
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
|
|
19
28
|
/**
|
|
20
29
|
* Run this step as an actor (scenarios). The RPC is sent through the
|
|
21
30
|
* actor's authenticated client over the REAL transport — never dispatched
|
|
@@ -87,6 +96,48 @@ export type WorkflowWireSleep = (
|
|
|
87
96
|
*/
|
|
88
97
|
export type WorkflowWireSuspend = (reason: string) => Promise<void>
|
|
89
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Options for workflow.approval().
|
|
101
|
+
*/
|
|
102
|
+
export interface WorkflowApprovalOptions<
|
|
103
|
+
TSchema extends StandardSchemaV1 = StandardSchemaV1,
|
|
104
|
+
> {
|
|
105
|
+
/**
|
|
106
|
+
* Schema the decision payload is validated against. This is a VALUE, not a
|
|
107
|
+
* type generic: the payload arrives from an untrusted caller over the approve
|
|
108
|
+
* wire, and a generic is erased at compile time — it would validate nothing.
|
|
109
|
+
* Any standard-schema library (zod, valibot, arktype) satisfies this.
|
|
110
|
+
*/
|
|
111
|
+
schema: TSchema
|
|
112
|
+
/**
|
|
113
|
+
* Give up waiting after this long (e.g. '3d'), yielding `{ status: 'expired' }`
|
|
114
|
+
* instead of waiting forever. Evaluated on replay from a recorded deadline, so
|
|
115
|
+
* the answer is correct even if the wake-up timer is never delivered.
|
|
116
|
+
*/
|
|
117
|
+
expiry?: string | number
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The result of an approval gate. A union rather than a throw so that callers
|
|
122
|
+
* must handle the deadline case, and so "skip it and carry on" stays trivial.
|
|
123
|
+
* `decided` means a human answered — whether that answer was yes or no is
|
|
124
|
+
* carried in `data` and is the application's business, not the framework's.
|
|
125
|
+
*/
|
|
126
|
+
export type ApprovalOutcome<T> =
|
|
127
|
+
| { status: 'decided'; data: T }
|
|
128
|
+
| { status: 'expired' }
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Type signature for workflow.approval() - used by inspector.
|
|
132
|
+
* Like {@link WorkflowWireSuspend}, `reason` is the approval point's stable
|
|
133
|
+
* durable identity. Unlike suspend, the gate stays closed until a decision is
|
|
134
|
+
* recorded against it, and the decision is handed back to the caller.
|
|
135
|
+
*/
|
|
136
|
+
export type WorkflowWireApproval = <TSchema extends StandardSchemaV1>(
|
|
137
|
+
reason: string,
|
|
138
|
+
options: WorkflowApprovalOptions<TSchema>
|
|
139
|
+
) => Promise<ApprovalOutcome<StandardSchemaV1.InferOutput<TSchema>>>
|
|
140
|
+
|
|
90
141
|
/**
|
|
91
142
|
* Input source for step arguments in DSL workflows
|
|
92
143
|
*/
|
|
@@ -180,16 +231,20 @@ export interface ParallelGroupStepMeta {
|
|
|
180
231
|
*/
|
|
181
232
|
export interface FanoutStepMeta {
|
|
182
233
|
type: 'fanout'
|
|
183
|
-
/**
|
|
184
|
-
|
|
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
|
|
185
240
|
/** Source array variable name */
|
|
186
241
|
sourceVar: string
|
|
187
242
|
/** Iterator variable name */
|
|
188
243
|
itemVar: string
|
|
189
244
|
/** Execution mode */
|
|
190
245
|
mode: 'parallel' | 'sequential'
|
|
191
|
-
/**
|
|
192
|
-
|
|
246
|
+
/** Steps to execute inline per iteration, in order */
|
|
247
|
+
body: Array<RpcStepMeta | SleepStepMeta | SuspendStepMeta>
|
|
193
248
|
/** Time between iterations (sequential mode only) */
|
|
194
249
|
timeBetween?: string
|
|
195
250
|
}
|
|
@@ -201,6 +256,12 @@ export interface ReturnStepMeta {
|
|
|
201
256
|
type: 'return'
|
|
202
257
|
/** Output bindings */
|
|
203
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[]
|
|
204
265
|
}
|
|
205
266
|
|
|
206
267
|
/**
|
|
@@ -227,6 +288,13 @@ export interface SleepStepMeta {
|
|
|
227
288
|
stepName: string
|
|
228
289
|
/** Sleep duration */
|
|
229
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
|
|
230
298
|
}
|
|
231
299
|
|
|
232
300
|
/**
|
|
@@ -247,8 +315,14 @@ export interface SetStepMeta {
|
|
|
247
315
|
type: 'set'
|
|
248
316
|
/** Variable name to set (must be in context) */
|
|
249
317
|
variable: string
|
|
250
|
-
/**
|
|
251
|
-
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
|
|
252
326
|
}
|
|
253
327
|
|
|
254
328
|
/**
|
|
@@ -285,6 +359,19 @@ export interface SuspendStepMeta {
|
|
|
285
359
|
reason: string
|
|
286
360
|
}
|
|
287
361
|
|
|
362
|
+
/**
|
|
363
|
+
* Approval step metadata (workflow.approval())
|
|
364
|
+
*/
|
|
365
|
+
export interface ApprovalStepMeta {
|
|
366
|
+
type: 'approval'
|
|
367
|
+
/** Reason string passed to workflow.approval() — becomes the durable step key */
|
|
368
|
+
reason: string
|
|
369
|
+
/** Output variable name (if assigned) */
|
|
370
|
+
outputVar?: string
|
|
371
|
+
/** Expiry duration, when one was given */
|
|
372
|
+
expiry?: string | number
|
|
373
|
+
}
|
|
374
|
+
|
|
288
375
|
/**
|
|
289
376
|
* Filter step metadata (array.filter)
|
|
290
377
|
*/
|
|
@@ -330,6 +417,7 @@ export type WorkflowStepMeta =
|
|
|
330
417
|
| SleepStepMeta
|
|
331
418
|
| CancelStepMeta
|
|
332
419
|
| SuspendStepMeta
|
|
420
|
+
| ApprovalStepMeta
|
|
333
421
|
| SwitchStepMeta
|
|
334
422
|
| FilterStepMeta
|
|
335
423
|
| ArrayPredicateStepMeta
|
|
@@ -388,6 +476,9 @@ export interface PikkuWorkflowWire {
|
|
|
388
476
|
|
|
389
477
|
/** Suspend workflow until explicitly resumed */
|
|
390
478
|
suspend: WorkflowWireSuspend
|
|
479
|
+
|
|
480
|
+
/** Suspend workflow until a human records a decision against this gate */
|
|
481
|
+
approval: WorkflowWireApproval
|
|
391
482
|
}
|
|
392
483
|
|
|
393
484
|
export interface PikkuScenarioWire extends PikkuWorkflowWire {
|
|
@@ -180,6 +180,8 @@ type GraphNodeConfigMap<
|
|
|
180
180
|
onError?: Extract<keyof FuncMap, string> | Extract<keyof FuncMap, string>[]
|
|
181
181
|
retries?: number
|
|
182
182
|
retryDelay?: string | number
|
|
183
|
+
/** Free-text node documentation. Non-semantic — excluded from graphHash. */
|
|
184
|
+
notes?: string
|
|
183
185
|
}
|
|
184
186
|
}
|
|
185
187
|
|
|
@@ -913,4 +913,62 @@ describe('graph-runner bugs', () => {
|
|
|
913
913
|
'default retries get exponential backoff so they ride out transient outages'
|
|
914
914
|
)
|
|
915
915
|
})
|
|
916
|
+
|
|
917
|
+
test('$item refs pass through resolveSerializedInput unresolved so a fanout node can bind them per-item', async () => {
|
|
918
|
+
const ws = new InMemoryWorkflowService()
|
|
919
|
+
|
|
920
|
+
let received: any = null
|
|
921
|
+
const mockRpcService = {
|
|
922
|
+
rpcWithWire: async (rpcName: string, data: any) => {
|
|
923
|
+
if (rpcName === 'mapFn') {
|
|
924
|
+
received = data
|
|
925
|
+
return { ok: true }
|
|
926
|
+
}
|
|
927
|
+
return {}
|
|
928
|
+
},
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const metaState = pikkuState(null, 'workflows', 'meta')
|
|
932
|
+
metaState['testItemPassthrough'] = {
|
|
933
|
+
name: 'testItemPassthrough',
|
|
934
|
+
pikkuFuncId: 'testItemPassthrough',
|
|
935
|
+
source: 'graph',
|
|
936
|
+
entryNodeIds: ['map'],
|
|
937
|
+
graphHash: 'inline-item-passthrough-hash',
|
|
938
|
+
nodes: {
|
|
939
|
+
map: {
|
|
940
|
+
nodeId: 'map',
|
|
941
|
+
rpcName: 'mapFn',
|
|
942
|
+
input: {
|
|
943
|
+
child: 'postVideo',
|
|
944
|
+
childInput: {
|
|
945
|
+
url: { $ref: '$item', path: 'URL VIDEO' },
|
|
946
|
+
title: { $ref: 'trigger', path: 'campaign' },
|
|
947
|
+
},
|
|
948
|
+
},
|
|
949
|
+
},
|
|
950
|
+
},
|
|
951
|
+
} as unknown as WorkflowRuntimeMeta
|
|
952
|
+
|
|
953
|
+
await runWorkflowGraph(
|
|
954
|
+
ws,
|
|
955
|
+
'testItemPassthrough',
|
|
956
|
+
{ campaign: 'launch' },
|
|
957
|
+
mockRpcService,
|
|
958
|
+
true
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
assert.deepEqual(
|
|
962
|
+
received?.childInput?.url,
|
|
963
|
+
{ $ref: '$item', path: 'URL VIDEO' },
|
|
964
|
+
'$item ref must survive verbatim so the fanout function can resolve it per element'
|
|
965
|
+
)
|
|
966
|
+
assert.equal(
|
|
967
|
+
received?.childInput?.title,
|
|
968
|
+
'launch',
|
|
969
|
+
'non-$item refs must still resolve normally (constant across items)'
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
delete metaState['testItemPassthrough']
|
|
973
|
+
})
|
|
916
974
|
})
|
|
@@ -308,6 +308,9 @@ function resolveTemplate(
|
|
|
308
308
|
|
|
309
309
|
function resolveValue(value: unknown, nodeResults: Record<string, any>): any {
|
|
310
310
|
if (isDataRef(value)) {
|
|
311
|
+
if (value.$ref === '$item') {
|
|
312
|
+
return value
|
|
313
|
+
}
|
|
311
314
|
const source = nodeResults[value.$ref]
|
|
312
315
|
return value.path ? getValueAtPath(source, value.path) : source
|
|
313
316
|
}
|
|
@@ -8,6 +8,11 @@ export interface PikkuWorkflowGraphConfig<
|
|
|
8
8
|
tags?: string[]
|
|
9
9
|
nodes: FuncMap
|
|
10
10
|
config?: T
|
|
11
|
+
/**
|
|
12
|
+
* Graph-level free-text notes (e.g. imported sticky notes). Non-semantic:
|
|
13
|
+
* excluded from the graph topology hash.
|
|
14
|
+
*/
|
|
15
|
+
notes?: string[]
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
export interface PikkuWorkflowGraphResult {
|
|
@@ -15,6 +20,7 @@ export interface PikkuWorkflowGraphResult {
|
|
|
15
20
|
name?: string
|
|
16
21
|
description?: string
|
|
17
22
|
tags?: string[]
|
|
23
|
+
notes?: string[]
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
export function pikkuWorkflowGraph<
|
|
@@ -25,5 +31,6 @@ export function pikkuWorkflowGraph<
|
|
|
25
31
|
name: config.name,
|
|
26
32
|
description: config.description,
|
|
27
33
|
tags: config.tags,
|
|
34
|
+
notes: config.notes,
|
|
28
35
|
}
|
|
29
36
|
}
|
|
@@ -69,6 +69,11 @@ export interface GraphNodeConfig<NodeIds extends string = string> {
|
|
|
69
69
|
retries?: number
|
|
70
70
|
/** Delay between retries — milliseconds, duration string, or 'exponential' */
|
|
71
71
|
retryDelay?: string | number
|
|
72
|
+
/**
|
|
73
|
+
* Free-text documentation for this node. Non-semantic: excluded from the
|
|
74
|
+
* graph topology hash, so editing a note never marks the workflow as changed.
|
|
75
|
+
*/
|
|
76
|
+
notes?: string
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
/**
|
|
@@ -8,6 +8,7 @@ export {
|
|
|
8
8
|
WorkflowDispatchException,
|
|
9
9
|
WorkflowNotFoundError,
|
|
10
10
|
WorkflowRunNotFoundError,
|
|
11
|
+
WorkflowApprovalResolvedError,
|
|
11
12
|
DEFAULT_STEP_RETRIES,
|
|
12
13
|
} from './pikku-workflow-service.js'
|
|
13
14
|
export { deriveInvocationId, uuidv5 } from './workflow-invocation-id.js'
|
|
@@ -88,6 +89,9 @@ export type {
|
|
|
88
89
|
WorkflowWireDoInline,
|
|
89
90
|
WorkflowWireSleep,
|
|
90
91
|
WorkflowWireSuspend,
|
|
92
|
+
WorkflowWireApproval,
|
|
93
|
+
WorkflowApprovalOptions,
|
|
94
|
+
ApprovalOutcome,
|
|
91
95
|
InputSource,
|
|
92
96
|
OutputBinding,
|
|
93
97
|
RpcStepMeta,
|
|
@@ -102,6 +106,7 @@ export type {
|
|
|
102
106
|
SleepStepMeta,
|
|
103
107
|
CancelStepMeta,
|
|
104
108
|
SuspendStepMeta,
|
|
109
|
+
ApprovalStepMeta,
|
|
105
110
|
SetStepMeta,
|
|
106
111
|
SwitchCaseMeta,
|
|
107
112
|
SwitchStepMeta,
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { describe, test } from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
3
4
|
|
|
4
5
|
import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
|
|
5
6
|
import { pikkuState, resetPikkuState } from '../../pikku-state.js'
|
|
6
7
|
import { addWorkflow } from './dsl/workflow-runner.js'
|
|
7
8
|
import {
|
|
9
|
+
WorkflowApprovalResolvedError,
|
|
8
10
|
WorkflowSuspendedException,
|
|
9
11
|
type PikkuWorkflowWire,
|
|
10
12
|
} from './pikku-workflow-service.js'
|
|
@@ -463,3 +465,342 @@ describe('pikku-workflow-service suspend', () => {
|
|
|
463
465
|
pikkuState(null, 'workflows', 'registrations').delete(workflowName)
|
|
464
466
|
})
|
|
465
467
|
})
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* A minimal StandardSchemaV1 — the same interface zod/valibot/arktype expose via
|
|
471
|
+
* `~standard`. Hand-rolled here because @pikku/core deliberately has no zod
|
|
472
|
+
* dependency: schemas reach it through the standard-schema spec only.
|
|
473
|
+
*/
|
|
474
|
+
const approvalDecisionSchema: StandardSchemaV1<
|
|
475
|
+
{ approved: boolean; comment?: string },
|
|
476
|
+
{ approved: boolean; comment?: string }
|
|
477
|
+
> = {
|
|
478
|
+
'~standard': {
|
|
479
|
+
version: 1,
|
|
480
|
+
vendor: 'test',
|
|
481
|
+
validate: (value: unknown) => {
|
|
482
|
+
if (typeof value !== 'object' || value === null) {
|
|
483
|
+
return { issues: [{ message: 'expected an object' }] }
|
|
484
|
+
}
|
|
485
|
+
const { approved, comment } = value as Record<string, unknown>
|
|
486
|
+
if (typeof approved !== 'boolean') {
|
|
487
|
+
return {
|
|
488
|
+
issues: [
|
|
489
|
+
{ message: 'approved must be a boolean', path: ['approved'] },
|
|
490
|
+
],
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (comment !== undefined && typeof comment !== 'string') {
|
|
494
|
+
return {
|
|
495
|
+
issues: [{ message: 'comment must be a string', path: ['comment'] }],
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return { value: { approved, ...(comment ? { comment } : {}) } }
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const registerApprovalWorkflow = (
|
|
504
|
+
workflowName: string,
|
|
505
|
+
graphHash: string,
|
|
506
|
+
func: (
|
|
507
|
+
services: any,
|
|
508
|
+
data: any,
|
|
509
|
+
wire: { workflow: PikkuWorkflowWire }
|
|
510
|
+
) => Promise<any>
|
|
511
|
+
) => {
|
|
512
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
513
|
+
queueService: {
|
|
514
|
+
add: async () => {},
|
|
515
|
+
},
|
|
516
|
+
} as any)
|
|
517
|
+
|
|
518
|
+
const metaState = pikkuState(null, 'workflows', 'meta')
|
|
519
|
+
metaState[workflowName] = {
|
|
520
|
+
name: workflowName,
|
|
521
|
+
pikkuFuncId: workflowName,
|
|
522
|
+
source: 'dsl',
|
|
523
|
+
graphHash,
|
|
524
|
+
}
|
|
525
|
+
const functionMetaState = pikkuState(null, 'function', 'meta')
|
|
526
|
+
functionMetaState[workflowName] = {
|
|
527
|
+
name: workflowName,
|
|
528
|
+
sessionless: true,
|
|
529
|
+
permissions: [],
|
|
530
|
+
} as any
|
|
531
|
+
|
|
532
|
+
addWorkflow(workflowName, { func })
|
|
533
|
+
|
|
534
|
+
return () => {
|
|
535
|
+
delete metaState[workflowName]
|
|
536
|
+
delete functionMetaState[workflowName]
|
|
537
|
+
pikkuState(null, 'workflows', 'registrations').delete(workflowName)
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
describe('pikku-workflow-service approval', () => {
|
|
542
|
+
test('resuming without a decision must NOT fall through the approval', async () => {
|
|
543
|
+
const ws = new InMemoryWorkflowService()
|
|
544
|
+
const workflowName = 'testApprovalNoFallthrough'
|
|
545
|
+
const graphHash = 'approval-no-fallthrough'
|
|
546
|
+
let bodyRuns = 0
|
|
547
|
+
|
|
548
|
+
const cleanup = registerApprovalWorkflow(
|
|
549
|
+
workflowName,
|
|
550
|
+
graphHash,
|
|
551
|
+
async (_services, _data, { workflow }) => {
|
|
552
|
+
bodyRuns++
|
|
553
|
+
const decision = await workflow.approval('Approve invoice', {
|
|
554
|
+
schema: approvalDecisionSchema,
|
|
555
|
+
})
|
|
556
|
+
return { decision }
|
|
557
|
+
}
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
const runId = await ws.createRun(workflowName, {}, false, graphHash, {
|
|
561
|
+
type: 'test',
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
await assert.rejects(
|
|
565
|
+
ws.runWorkflowJob(runId, {}),
|
|
566
|
+
(error: unknown) => error instanceof WorkflowSuspendedException
|
|
567
|
+
)
|
|
568
|
+
assert.equal((await ws.getRun(runId))?.status, 'suspended')
|
|
569
|
+
|
|
570
|
+
// THE point of an approval vs a suspend: a bare resume with no decision
|
|
571
|
+
// recorded must re-suspend, not walk past the gate.
|
|
572
|
+
await ws.resumeWorkflow(runId)
|
|
573
|
+
await assert.rejects(
|
|
574
|
+
ws.runWorkflowJob(runId, {}),
|
|
575
|
+
(error: unknown) => error instanceof WorkflowSuspendedException
|
|
576
|
+
)
|
|
577
|
+
assert.equal((await ws.getRun(runId))?.status, 'suspended')
|
|
578
|
+
assert.equal(bodyRuns, 2)
|
|
579
|
+
|
|
580
|
+
cleanup()
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
test('approveStep records a decision the workflow then returns', async () => {
|
|
584
|
+
const ws = new InMemoryWorkflowService()
|
|
585
|
+
const workflowName = 'testApprovalDecision'
|
|
586
|
+
const graphHash = 'approval-decision'
|
|
587
|
+
|
|
588
|
+
const cleanup = registerApprovalWorkflow(
|
|
589
|
+
workflowName,
|
|
590
|
+
graphHash,
|
|
591
|
+
async (_services, _data, { workflow }) => {
|
|
592
|
+
const decision = await workflow.approval('Approve invoice', {
|
|
593
|
+
schema: approvalDecisionSchema,
|
|
594
|
+
})
|
|
595
|
+
return { decision }
|
|
596
|
+
}
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
const runId = await ws.createRun(workflowName, {}, false, graphHash, {
|
|
600
|
+
type: 'test',
|
|
601
|
+
})
|
|
602
|
+
await assert.rejects(
|
|
603
|
+
ws.runWorkflowJob(runId, {}),
|
|
604
|
+
(error: unknown) => error instanceof WorkflowSuspendedException
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
await ws.approveStep(runId, 'Approve invoice', {
|
|
608
|
+
approved: true,
|
|
609
|
+
comment: 'lgtm',
|
|
610
|
+
})
|
|
611
|
+
await ws.runWorkflowJob(runId, {})
|
|
612
|
+
|
|
613
|
+
const run = await ws.getRun(runId)
|
|
614
|
+
assert.equal(run?.status, 'completed')
|
|
615
|
+
assert.deepEqual(run?.output, {
|
|
616
|
+
decision: {
|
|
617
|
+
status: 'decided',
|
|
618
|
+
data: { approved: true, comment: 'lgtm' },
|
|
619
|
+
},
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
cleanup()
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
test('approveStep rejects a payload that fails the schema', async () => {
|
|
626
|
+
const ws = new InMemoryWorkflowService()
|
|
627
|
+
const workflowName = 'testApprovalSchema'
|
|
628
|
+
const graphHash = 'approval-schema'
|
|
629
|
+
|
|
630
|
+
const cleanup = registerApprovalWorkflow(
|
|
631
|
+
workflowName,
|
|
632
|
+
graphHash,
|
|
633
|
+
async (_services, _data, { workflow }) => {
|
|
634
|
+
const decision = await workflow.approval('Approve invoice', {
|
|
635
|
+
schema: approvalDecisionSchema,
|
|
636
|
+
})
|
|
637
|
+
return { decision }
|
|
638
|
+
}
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
const runId = await ws.createRun(workflowName, {}, false, graphHash, {
|
|
642
|
+
type: 'test',
|
|
643
|
+
})
|
|
644
|
+
await assert.rejects(
|
|
645
|
+
ws.runWorkflowJob(runId, {}),
|
|
646
|
+
(error: unknown) => error instanceof WorkflowSuspendedException
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
// The resume payload crosses an HTTP boundary from an untrusted caller.
|
|
650
|
+
// approveStep only records it; validation happens on replay, inside the
|
|
651
|
+
// workflow body, which is the only place the schema value is in scope.
|
|
652
|
+
await ws.approveStep(runId, 'Approve invoice', { approved: 'yes-please' })
|
|
653
|
+
|
|
654
|
+
// An invalid decision must leave the gate closed rather than fail the run.
|
|
655
|
+
await assert.rejects(
|
|
656
|
+
ws.runWorkflowJob(runId, {}),
|
|
657
|
+
(error: unknown) => error instanceof WorkflowSuspendedException
|
|
658
|
+
)
|
|
659
|
+
assert.equal((await ws.getRun(runId))?.status, 'suspended')
|
|
660
|
+
|
|
661
|
+
// ...and the rejection is legible to whoever tries to approve next.
|
|
662
|
+
const state = await ws.getRunState(runId)
|
|
663
|
+
assert.match(
|
|
664
|
+
JSON.stringify(state),
|
|
665
|
+
/approved/,
|
|
666
|
+
'expected the validation failure to be recorded in run state'
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
// A subsequent valid decision still lands.
|
|
670
|
+
await ws.approveStep(runId, 'Approve invoice', { approved: false })
|
|
671
|
+
await ws.runWorkflowJob(runId, {})
|
|
672
|
+
const run = await ws.getRun(runId)
|
|
673
|
+
assert.equal(run?.status, 'completed')
|
|
674
|
+
assert.deepEqual(run?.output, {
|
|
675
|
+
decision: { status: 'decided', data: { approved: false } },
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
cleanup()
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
test('an expired approval returns { status: expired } instead of hanging', async () => {
|
|
682
|
+
const ws = new InMemoryWorkflowService()
|
|
683
|
+
const workflowName = 'testApprovalExpiry'
|
|
684
|
+
const graphHash = 'approval-expiry'
|
|
685
|
+
|
|
686
|
+
const cleanup = registerApprovalWorkflow(
|
|
687
|
+
workflowName,
|
|
688
|
+
graphHash,
|
|
689
|
+
async (_services, _data, { workflow }) => {
|
|
690
|
+
const decision = await workflow.approval('Approve invoice', {
|
|
691
|
+
schema: approvalDecisionSchema,
|
|
692
|
+
expiry: '10ms',
|
|
693
|
+
})
|
|
694
|
+
return { decision }
|
|
695
|
+
}
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
const runId = await ws.createRun(workflowName, {}, false, graphHash, {
|
|
699
|
+
type: 'test',
|
|
700
|
+
})
|
|
701
|
+
await assert.rejects(
|
|
702
|
+
ws.runWorkflowJob(runId, {}),
|
|
703
|
+
(error: unknown) => error instanceof WorkflowSuspendedException
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
707
|
+
|
|
708
|
+
await ws.resumeWorkflow(runId)
|
|
709
|
+
await ws.runWorkflowJob(runId, {})
|
|
710
|
+
|
|
711
|
+
const run = await ws.getRun(runId)
|
|
712
|
+
assert.equal(run?.status, 'completed')
|
|
713
|
+
assert.deepEqual(run?.output, { decision: { status: 'expired' } })
|
|
714
|
+
|
|
715
|
+
cleanup()
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
test('a decision arriving after the gate resolved is rejected, not dropped', async () => {
|
|
719
|
+
const ws = new InMemoryWorkflowService()
|
|
720
|
+
const workflowName = 'testApprovalAfterResolved'
|
|
721
|
+
const graphHash = 'approval-after-resolved'
|
|
722
|
+
|
|
723
|
+
const cleanup = registerApprovalWorkflow(
|
|
724
|
+
workflowName,
|
|
725
|
+
graphHash,
|
|
726
|
+
async (_services, _data, { workflow }) => {
|
|
727
|
+
const decision = await workflow.approval('Approve invoice', {
|
|
728
|
+
schema: approvalDecisionSchema,
|
|
729
|
+
expiry: '10ms',
|
|
730
|
+
})
|
|
731
|
+
return { decision }
|
|
732
|
+
}
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
const runId = await ws.createRun(workflowName, {}, false, graphHash, {
|
|
736
|
+
type: 'test',
|
|
737
|
+
})
|
|
738
|
+
await assert.rejects(
|
|
739
|
+
ws.runWorkflowJob(runId, {}),
|
|
740
|
+
(error: unknown) => error instanceof WorkflowSuspendedException
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
// Let the gate expire and resolve.
|
|
744
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
745
|
+
await ws.resumeWorkflow(runId)
|
|
746
|
+
await ws.runWorkflowJob(runId, {})
|
|
747
|
+
assert.equal((await ws.getRun(runId))?.status, 'completed')
|
|
748
|
+
|
|
749
|
+
// The gate caches its outcome and never re-reads state, so accepting this
|
|
750
|
+
// would discard it silently. The approver must be told it did not land.
|
|
751
|
+
await assert.rejects(
|
|
752
|
+
ws.approveStep(runId, 'Approve invoice', { approved: true }),
|
|
753
|
+
(error: unknown) =>
|
|
754
|
+
error instanceof WorkflowApprovalResolvedError &&
|
|
755
|
+
error.payload.outcome === 'expired'
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
// The resolved outcome stands.
|
|
759
|
+
const run = await ws.getRun(runId)
|
|
760
|
+
assert.deepEqual(run?.output, { decision: { status: 'expired' } })
|
|
761
|
+
|
|
762
|
+
cleanup()
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
test('a decision that landed before expiry wins over the expiry', async () => {
|
|
766
|
+
const ws = new InMemoryWorkflowService()
|
|
767
|
+
const workflowName = 'testApprovalExpiryRace'
|
|
768
|
+
const graphHash = 'approval-expiry-race'
|
|
769
|
+
|
|
770
|
+
const cleanup = registerApprovalWorkflow(
|
|
771
|
+
workflowName,
|
|
772
|
+
graphHash,
|
|
773
|
+
async (_services, _data, { workflow }) => {
|
|
774
|
+
const decision = await workflow.approval('Approve invoice', {
|
|
775
|
+
schema: approvalDecisionSchema,
|
|
776
|
+
expiry: '10ms',
|
|
777
|
+
})
|
|
778
|
+
return { decision }
|
|
779
|
+
}
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
const runId = await ws.createRun(workflowName, {}, false, graphHash, {
|
|
783
|
+
type: 'test',
|
|
784
|
+
})
|
|
785
|
+
await assert.rejects(
|
|
786
|
+
ws.runWorkflowJob(runId, {}),
|
|
787
|
+
(error: unknown) => error instanceof WorkflowSuspendedException
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
await ws.approveStep(runId, 'Approve invoice', { approved: true })
|
|
791
|
+
|
|
792
|
+
// Expiry fires unconditionally (an enqueued durable timer can't be
|
|
793
|
+
// retracted), so it must no-op once a decision has landed.
|
|
794
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
795
|
+
await ws.resumeWorkflow(runId)
|
|
796
|
+
await ws.runWorkflowJob(runId, {})
|
|
797
|
+
|
|
798
|
+
const run = await ws.getRun(runId)
|
|
799
|
+
assert.equal(run?.status, 'completed')
|
|
800
|
+
assert.deepEqual(run?.output, {
|
|
801
|
+
decision: { status: 'decided', data: { approved: true } },
|
|
802
|
+
})
|
|
803
|
+
|
|
804
|
+
cleanup()
|
|
805
|
+
})
|
|
806
|
+
})
|