@pikku/core 0.12.63 → 0.12.64
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 +147 -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 +32 -25
- package/dist/permissions.js +104 -185
- 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/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 +41 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +146 -6
- package/dist/wirings/ai-agent/ai-agent-runner.js +6 -1
- package/dist/wirings/ai-agent/ai-agent-stream.js +61 -10
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +27 -1
- package/dist/wirings/ai-agent/index.d.ts +1 -0
- package/dist/wirings/ai-agent/index.js +1 -0
- package/dist/wirings/ai-agent/voice-input.d.ts +1 -0
- package/dist/wirings/ai-agent/voice-input.js +2 -38
- 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 +0 -2
- package/dist/wirings/cli/cli.types.d.ts +1 -2
- 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 +1 -9
- 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/workflow/dsl/index.d.ts +1 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +53 -1
- 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 +51 -1
- package/dist/wirings/workflow/pikku-workflow-service.js +171 -0
- package/dist/wirings/workflow/workflow.types.d.ts +1 -1
- package/package.json +5 -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 +153 -372
- package/src/permissions.ts +139 -259
- 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/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-prepare.test.ts +308 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +192 -8
- package/src/wirings/ai-agent/ai-agent-runner.test.ts +48 -1
- package/src/wirings/ai-agent/ai-agent-runner.ts +21 -0
- package/src/wirings/ai-agent/ai-agent-stream.test.ts +198 -10
- package/src/wirings/ai-agent/ai-agent-stream.ts +84 -10
- package/src/wirings/ai-agent/ai-agent.types.ts +28 -0
- package/src/wirings/ai-agent/index.ts +1 -0
- package/src/wirings/ai-agent/voice-input.ts +6 -35
- 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 +0 -2
- package/src/wirings/cli/cli.types.ts +0 -2
- 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 +1 -10
- 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/workflow/dsl/index.ts +4 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +61 -0
- 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 +236 -0
- package/src/wirings/workflow/workflow-step-session.test.ts +21 -5
- package/src/wirings/workflow/workflow.types.ts +4 -0
- 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
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { CoreScopes } from './scope.types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* No-op function for declaring scopes.
|
|
5
|
+
* This exists purely for TypeScript type checking and will be tree-shaken.
|
|
6
|
+
* The CLI extracts metadata via AST parsing and generates a `ScopeId` union,
|
|
7
|
+
* so a function referencing an undeclared scope fails the build.
|
|
8
|
+
*
|
|
9
|
+
* Scopes are keyed by segment at every level: a scope is named by its key, and
|
|
10
|
+
* its value describes it. Every node is grantable — the declaration below
|
|
11
|
+
* yields `admin`, `admin:invoices`, `admin:invoices:create`,
|
|
12
|
+
* `admin:invoices:void` and `billing`.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* wireScope({
|
|
17
|
+
* admin: {
|
|
18
|
+
* displayName: 'Administration',
|
|
19
|
+
* description: 'Administrative access',
|
|
20
|
+
* scopes: {
|
|
21
|
+
* invoices: {
|
|
22
|
+
* description: 'Invoice management',
|
|
23
|
+
* scopes: {
|
|
24
|
+
* create: { description: 'Create invoices' },
|
|
25
|
+
* void: { description: 'Void invoices' },
|
|
26
|
+
* },
|
|
27
|
+
* },
|
|
28
|
+
* },
|
|
29
|
+
* },
|
|
30
|
+
* billing: {},
|
|
31
|
+
* })
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export const wireScope = (_config: CoreScopes): void => {}
|
|
@@ -9,6 +9,9 @@ export type {
|
|
|
9
9
|
WorkflowWireDoInline,
|
|
10
10
|
WorkflowWireSleep,
|
|
11
11
|
WorkflowWireSuspend,
|
|
12
|
+
WorkflowWireApproval,
|
|
13
|
+
WorkflowApprovalOptions,
|
|
14
|
+
ApprovalOutcome,
|
|
12
15
|
InputSource,
|
|
13
16
|
OutputBinding,
|
|
14
17
|
RpcStepMeta,
|
|
@@ -22,6 +25,7 @@ export type {
|
|
|
22
25
|
SleepStepMeta,
|
|
23
26
|
CancelStepMeta,
|
|
24
27
|
SuspendStepMeta,
|
|
28
|
+
ApprovalStepMeta,
|
|
25
29
|
SwitchCaseMeta,
|
|
26
30
|
SwitchStepMeta,
|
|
27
31
|
FilterStepMeta,
|
|
@@ -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
|
|
|
@@ -87,6 +89,48 @@ export type WorkflowWireSleep = (
|
|
|
87
89
|
*/
|
|
88
90
|
export type WorkflowWireSuspend = (reason: string) => Promise<void>
|
|
89
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Options for workflow.approval().
|
|
94
|
+
*/
|
|
95
|
+
export interface WorkflowApprovalOptions<
|
|
96
|
+
TSchema extends StandardSchemaV1 = StandardSchemaV1,
|
|
97
|
+
> {
|
|
98
|
+
/**
|
|
99
|
+
* Schema the decision payload is validated against. This is a VALUE, not a
|
|
100
|
+
* type generic: the payload arrives from an untrusted caller over the approve
|
|
101
|
+
* wire, and a generic is erased at compile time — it would validate nothing.
|
|
102
|
+
* Any standard-schema library (zod, valibot, arktype) satisfies this.
|
|
103
|
+
*/
|
|
104
|
+
schema: TSchema
|
|
105
|
+
/**
|
|
106
|
+
* Give up waiting after this long (e.g. '3d'), yielding `{ status: 'expired' }`
|
|
107
|
+
* instead of waiting forever. Evaluated on replay from a recorded deadline, so
|
|
108
|
+
* the answer is correct even if the wake-up timer is never delivered.
|
|
109
|
+
*/
|
|
110
|
+
expiry?: string | number
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The result of an approval gate. A union rather than a throw so that callers
|
|
115
|
+
* must handle the deadline case, and so "skip it and carry on" stays trivial.
|
|
116
|
+
* `decided` means a human answered — whether that answer was yes or no is
|
|
117
|
+
* carried in `data` and is the application's business, not the framework's.
|
|
118
|
+
*/
|
|
119
|
+
export type ApprovalOutcome<T> =
|
|
120
|
+
| { status: 'decided'; data: T }
|
|
121
|
+
| { status: 'expired' }
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Type signature for workflow.approval() - used by inspector.
|
|
125
|
+
* Like {@link WorkflowWireSuspend}, `reason` is the approval point's stable
|
|
126
|
+
* durable identity. Unlike suspend, the gate stays closed until a decision is
|
|
127
|
+
* recorded against it, and the decision is handed back to the caller.
|
|
128
|
+
*/
|
|
129
|
+
export type WorkflowWireApproval = <TSchema extends StandardSchemaV1>(
|
|
130
|
+
reason: string,
|
|
131
|
+
options: WorkflowApprovalOptions<TSchema>
|
|
132
|
+
) => Promise<ApprovalOutcome<StandardSchemaV1.InferOutput<TSchema>>>
|
|
133
|
+
|
|
90
134
|
/**
|
|
91
135
|
* Input source for step arguments in DSL workflows
|
|
92
136
|
*/
|
|
@@ -285,6 +329,19 @@ export interface SuspendStepMeta {
|
|
|
285
329
|
reason: string
|
|
286
330
|
}
|
|
287
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Approval step metadata (workflow.approval())
|
|
334
|
+
*/
|
|
335
|
+
export interface ApprovalStepMeta {
|
|
336
|
+
type: 'approval'
|
|
337
|
+
/** Reason string passed to workflow.approval() — becomes the durable step key */
|
|
338
|
+
reason: string
|
|
339
|
+
/** Output variable name (if assigned) */
|
|
340
|
+
outputVar?: string
|
|
341
|
+
/** Expiry duration, when one was given */
|
|
342
|
+
expiry?: string | number
|
|
343
|
+
}
|
|
344
|
+
|
|
288
345
|
/**
|
|
289
346
|
* Filter step metadata (array.filter)
|
|
290
347
|
*/
|
|
@@ -330,6 +387,7 @@ export type WorkflowStepMeta =
|
|
|
330
387
|
| SleepStepMeta
|
|
331
388
|
| CancelStepMeta
|
|
332
389
|
| SuspendStepMeta
|
|
390
|
+
| ApprovalStepMeta
|
|
333
391
|
| SwitchStepMeta
|
|
334
392
|
| FilterStepMeta
|
|
335
393
|
| ArrayPredicateStepMeta
|
|
@@ -388,6 +446,9 @@ export interface PikkuWorkflowWire {
|
|
|
388
446
|
|
|
389
447
|
/** Suspend workflow until explicitly resumed */
|
|
390
448
|
suspend: WorkflowWireSuspend
|
|
449
|
+
|
|
450
|
+
/** Suspend workflow until a human records a decision against this gate */
|
|
451
|
+
approval: WorkflowWireApproval
|
|
391
452
|
}
|
|
392
453
|
|
|
393
454
|
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
|
+
})
|