@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.
Files changed (198) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/dist/dev/hot-reload.js +13 -55
  3. package/dist/dev/module-runner.d.ts +14 -0
  4. package/dist/dev/module-runner.js +59 -0
  5. package/dist/dev/reload-meta.d.ts +13 -0
  6. package/dist/dev/reload-meta.js +22 -0
  7. package/dist/errors/errors.d.ts +12 -0
  8. package/dist/errors/errors.js +19 -0
  9. package/dist/function/function-runner.d.ts +5 -5
  10. package/dist/function/function-runner.js +39 -111
  11. package/dist/function/functions.types.d.ts +17 -7
  12. package/dist/index.d.ts +5 -1
  13. package/dist/index.js +3 -1
  14. package/dist/middleware/auth-apikey.d.ts +1 -0
  15. package/dist/middleware/auth-bearer.d.ts +1 -0
  16. package/dist/middleware/auth-cookie.d.ts +1 -0
  17. package/dist/middleware/cors.d.ts +1 -0
  18. package/dist/middleware/index.d.ts +1 -1
  19. package/dist/middleware/index.js +1 -1
  20. package/dist/middleware/remote-auth.d.ts +1 -0
  21. package/dist/middleware/telemetry.d.ts +2 -0
  22. package/dist/middleware/timeout.d.ts +1 -0
  23. package/dist/permissions.d.ts +32 -25
  24. package/dist/permissions.js +104 -185
  25. package/dist/pikku-state.js +0 -2
  26. package/dist/scopes.d.ts +17 -0
  27. package/dist/scopes.js +59 -0
  28. package/dist/services/ai-embedding-service.d.ts +31 -0
  29. package/dist/services/ai-embedding-service.js +1 -0
  30. package/dist/services/credential-wire-service.d.ts +3 -1
  31. package/dist/services/credential-wire-service.js +10 -4
  32. package/dist/services/index.d.ts +4 -0
  33. package/dist/services/index.js +2 -0
  34. package/dist/services/meta-service.d.ts +15 -2
  35. package/dist/services/meta-service.js +51 -1
  36. package/dist/services/queue-webhook-service.d.ts +55 -0
  37. package/dist/services/queue-webhook-service.js +136 -0
  38. package/dist/services/scope-service.d.ts +67 -0
  39. package/dist/services/scope-service.js +1 -0
  40. package/dist/services/typed-secret-service.d.ts +8 -0
  41. package/dist/services/typed-secret-service.js +39 -4
  42. package/dist/services/webhook-service.d.ts +140 -0
  43. package/dist/services/webhook-service.js +44 -0
  44. package/dist/services/workflow-service.d.ts +7 -0
  45. package/dist/types/core.types.d.ts +34 -6
  46. package/dist/types/state.types.d.ts +20 -2
  47. package/dist/utils/hmac.d.ts +16 -0
  48. package/dist/utils/hmac.js +26 -0
  49. package/dist/utils/safe-fetch.d.ts +51 -0
  50. package/dist/utils/safe-fetch.js +192 -0
  51. package/dist/wirings/ai-agent/ai-agent-agui.d.ts +84 -0
  52. package/dist/wirings/ai-agent/ai-agent-agui.js +305 -0
  53. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +41 -1
  54. package/dist/wirings/ai-agent/ai-agent-prepare.js +146 -6
  55. package/dist/wirings/ai-agent/ai-agent-runner.js +6 -1
  56. package/dist/wirings/ai-agent/ai-agent-stream.js +61 -10
  57. package/dist/wirings/ai-agent/ai-agent.types.d.ts +27 -1
  58. package/dist/wirings/ai-agent/index.d.ts +1 -0
  59. package/dist/wirings/ai-agent/index.js +1 -0
  60. package/dist/wirings/ai-agent/voice-input.d.ts +1 -0
  61. package/dist/wirings/ai-agent/voice-input.js +2 -38
  62. package/dist/wirings/ai-agent/voice-output.d.ts +1 -0
  63. package/dist/wirings/channel/channel-common.js +0 -1
  64. package/dist/wirings/channel/channel-handler.js +1 -4
  65. package/dist/wirings/channel/channel-middleware-runner.d.ts +12 -0
  66. package/dist/wirings/channel/channel-middleware-runner.js +34 -19
  67. package/dist/wirings/channel/channel.types.d.ts +2 -6
  68. package/dist/wirings/cli/cli-runner.js +0 -2
  69. package/dist/wirings/cli/cli.types.d.ts +1 -2
  70. package/dist/wirings/http/http-routes.js +0 -3
  71. package/dist/wirings/http/http-runner.d.ts +9 -29
  72. package/dist/wirings/http/http-runner.js +9 -34
  73. package/dist/wirings/http/http.types.d.ts +1 -9
  74. package/dist/wirings/mcp/mcp-runner.js +0 -2
  75. package/dist/wirings/mcp/mcp.types.d.ts +5 -11
  76. package/dist/wirings/oauth2/index.d.ts +0 -3
  77. package/dist/wirings/oauth2/index.js +1 -2
  78. package/dist/wirings/rpc/addon-runner.d.ts +28 -0
  79. package/dist/wirings/rpc/addon-runner.js +173 -0
  80. package/dist/wirings/rpc/index.d.ts +5 -1
  81. package/dist/wirings/rpc/index.js +3 -1
  82. package/dist/wirings/rpc/remote-addon-auth.d.ts +26 -0
  83. package/dist/wirings/rpc/remote-addon-auth.js +43 -0
  84. package/dist/wirings/rpc/rpc-runner.d.ts +19 -0
  85. package/dist/wirings/rpc/rpc-runner.js +109 -3
  86. package/dist/wirings/rpc/rpc-types.d.ts +15 -1
  87. package/dist/wirings/rpc/wire-addon.js +9 -0
  88. package/dist/wirings/rpc/wire-remote-addon.d.ts +47 -0
  89. package/dist/wirings/rpc/wire-remote-addon.js +19 -0
  90. package/dist/wirings/scope/index.d.ts +3 -0
  91. package/dist/wirings/scope/index.js +2 -0
  92. package/dist/wirings/scope/scope.types.d.ts +39 -0
  93. package/dist/wirings/scope/scope.types.js +1 -0
  94. package/dist/wirings/scope/validate-scope-definitions.d.ts +16 -0
  95. package/dist/wirings/scope/validate-scope-definitions.js +76 -0
  96. package/dist/wirings/scope/wire-scope.d.ts +33 -0
  97. package/dist/wirings/scope/wire-scope.js +32 -0
  98. package/dist/wirings/workflow/dsl/index.d.ts +1 -1
  99. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +53 -1
  100. package/dist/wirings/workflow/graph/graph-node.d.ts +2 -0
  101. package/dist/wirings/workflow/graph/graph-runner.js +3 -0
  102. package/dist/wirings/workflow/graph/wire-workflow-graph.d.ts +6 -0
  103. package/dist/wirings/workflow/graph/wire-workflow-graph.js +1 -0
  104. package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +5 -0
  105. package/dist/wirings/workflow/index.d.ts +2 -2
  106. package/dist/wirings/workflow/index.js +1 -1
  107. package/dist/wirings/workflow/pikku-workflow-service.d.ts +51 -1
  108. package/dist/wirings/workflow/pikku-workflow-service.js +171 -0
  109. package/dist/wirings/workflow/workflow.types.d.ts +1 -1
  110. package/package.json +5 -1
  111. package/run-tests.sh +1 -0
  112. package/src/dev/hot-reload.ts +13 -68
  113. package/src/dev/module-runner.test.ts +169 -0
  114. package/src/dev/module-runner.ts +103 -0
  115. package/src/dev/reload-meta.test.ts +31 -2
  116. package/src/dev/reload-meta.ts +26 -0
  117. package/src/errors/errors.ts +24 -0
  118. package/src/function/function-runner.test.ts +276 -142
  119. package/src/function/function-runner.ts +52 -145
  120. package/src/function/functions.types.ts +28 -38
  121. package/src/index.ts +8 -6
  122. package/src/middleware/index.ts +1 -5
  123. package/src/permissions.test.ts +153 -372
  124. package/src/permissions.ts +139 -259
  125. package/src/pikku-state.ts +0 -2
  126. package/src/scopes.test.ts +167 -0
  127. package/src/scopes.ts +69 -0
  128. package/src/services/ai-embedding-service.ts +31 -0
  129. package/src/services/credential-wire-service.ts +10 -4
  130. package/src/services/index.ts +20 -0
  131. package/src/services/meta-service.ts +68 -3
  132. package/src/services/queue-webhook-service.test.ts +408 -0
  133. package/src/services/queue-webhook-service.ts +182 -0
  134. package/src/services/scope-service.ts +75 -0
  135. package/src/services/typed-secret-service.test.ts +35 -0
  136. package/src/services/typed-secret-service.ts +39 -4
  137. package/src/services/webhook-service.ts +180 -0
  138. package/src/services/workflow-service.ts +7 -0
  139. package/src/types/core.types.ts +42 -14
  140. package/src/types/state.types.ts +23 -3
  141. package/src/utils/hmac.ts +27 -0
  142. package/src/utils/safe-fetch.test.ts +373 -0
  143. package/src/utils/safe-fetch.ts +213 -0
  144. package/src/wirings/ai-agent/ai-agent-agui.test.ts +1127 -0
  145. package/src/wirings/ai-agent/ai-agent-agui.ts +386 -0
  146. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +308 -0
  147. package/src/wirings/ai-agent/ai-agent-prepare.ts +192 -8
  148. package/src/wirings/ai-agent/ai-agent-runner.test.ts +48 -1
  149. package/src/wirings/ai-agent/ai-agent-runner.ts +21 -0
  150. package/src/wirings/ai-agent/ai-agent-stream.test.ts +198 -10
  151. package/src/wirings/ai-agent/ai-agent-stream.ts +84 -10
  152. package/src/wirings/ai-agent/ai-agent.types.ts +28 -0
  153. package/src/wirings/ai-agent/index.ts +1 -0
  154. package/src/wirings/ai-agent/voice-input.ts +6 -35
  155. package/src/wirings/channel/channel-common.ts +0 -1
  156. package/src/wirings/channel/channel-handler.ts +1 -9
  157. package/src/wirings/channel/channel-middleware-runner.test.ts +28 -2
  158. package/src/wirings/channel/channel-middleware-runner.ts +39 -27
  159. package/src/wirings/channel/channel.types.ts +0 -6
  160. package/src/wirings/cli/cli-runner.ts +0 -2
  161. package/src/wirings/cli/cli.types.ts +0 -2
  162. package/src/wirings/http/http-routes.ts +0 -3
  163. package/src/wirings/http/http-runner.test.ts +1 -11
  164. package/src/wirings/http/http-runner.ts +16 -39
  165. package/src/wirings/http/http.types.ts +1 -10
  166. package/src/wirings/mcp/mcp-runner.ts +0 -2
  167. package/src/wirings/mcp/mcp.types.ts +3 -11
  168. package/src/wirings/oauth2/index.ts +0 -3
  169. package/src/wirings/rpc/addon-runner.ts +233 -0
  170. package/src/wirings/rpc/index.ts +17 -1
  171. package/src/wirings/rpc/remote-addon-auth.ts +69 -0
  172. package/src/wirings/rpc/rpc-runner.test.ts +319 -16
  173. package/src/wirings/rpc/rpc-runner.ts +155 -3
  174. package/src/wirings/rpc/rpc-types.ts +16 -5
  175. package/src/wirings/rpc/wire-addon.test.ts +6 -3
  176. package/src/wirings/rpc/wire-addon.ts +9 -0
  177. package/src/wirings/rpc/wire-remote-addon.ts +57 -0
  178. package/src/wirings/scope/index.ts +14 -0
  179. package/src/wirings/scope/scope.test.ts +135 -0
  180. package/src/wirings/scope/scope.types.ts +44 -0
  181. package/src/wirings/scope/validate-scope-definitions.ts +110 -0
  182. package/src/wirings/scope/wire-scope.ts +34 -0
  183. package/src/wirings/workflow/dsl/index.ts +4 -0
  184. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +61 -0
  185. package/src/wirings/workflow/graph/graph-node.ts +2 -0
  186. package/src/wirings/workflow/graph/graph-runner.test.ts +58 -0
  187. package/src/wirings/workflow/graph/graph-runner.ts +3 -0
  188. package/src/wirings/workflow/graph/wire-workflow-graph.ts +7 -0
  189. package/src/wirings/workflow/graph/workflow-graph.types.ts +5 -0
  190. package/src/wirings/workflow/index.ts +5 -0
  191. package/src/wirings/workflow/pikku-workflow-service.test.ts +341 -0
  192. package/src/wirings/workflow/pikku-workflow-service.ts +236 -0
  193. package/src/wirings/workflow/workflow-step-session.test.ts +21 -5
  194. package/src/wirings/workflow/workflow.types.ts +4 -0
  195. package/tsconfig.tsbuildinfo +1 -1
  196. package/src/wirings/oauth2/oauth2-client.test.ts +0 -930
  197. package/src/wirings/oauth2/oauth2-client.ts +0 -331
  198. 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
+ })