@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.
Files changed (225) hide show
  1. package/CHANGELOG.md +368 -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 +42 -27
  24. package/dist/permissions.js +101 -203
  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/testing/service-tests.js +37 -0
  46. package/dist/types/core.types.d.ts +34 -6
  47. package/dist/types/state.types.d.ts +20 -2
  48. package/dist/utils/hmac.d.ts +16 -0
  49. package/dist/utils/hmac.js +26 -0
  50. package/dist/utils/safe-fetch.d.ts +51 -0
  51. package/dist/utils/safe-fetch.js +192 -0
  52. package/dist/wirings/ai-agent/ai-agent-agui.d.ts +84 -0
  53. package/dist/wirings/ai-agent/ai-agent-agui.js +305 -0
  54. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +105 -1
  55. package/dist/wirings/ai-agent/ai-agent-prepare.js +249 -11
  56. package/dist/wirings/ai-agent/ai-agent-runner.js +10 -1
  57. package/dist/wirings/ai-agent/ai-agent-stream.js +88 -16
  58. package/dist/wirings/ai-agent/ai-agent.types.d.ts +56 -2
  59. package/dist/wirings/ai-agent/index.d.ts +2 -1
  60. package/dist/wirings/ai-agent/index.js +2 -1
  61. package/dist/wirings/ai-agent/voice-input.d.ts +1 -0
  62. package/dist/wirings/ai-agent/voice-input.js +5 -41
  63. package/dist/wirings/ai-agent/voice-output.d.ts +1 -0
  64. package/dist/wirings/channel/channel-common.js +0 -1
  65. package/dist/wirings/channel/channel-handler.js +1 -4
  66. package/dist/wirings/channel/channel-middleware-runner.d.ts +12 -0
  67. package/dist/wirings/channel/channel-middleware-runner.js +34 -19
  68. package/dist/wirings/channel/channel.types.d.ts +2 -6
  69. package/dist/wirings/cli/cli-runner.js +3 -2
  70. package/dist/wirings/cli/cli.types.d.ts +1 -2
  71. package/dist/wirings/cli/command-parser.d.ts +2 -0
  72. package/dist/wirings/cli/command-parser.js +59 -2
  73. package/dist/wirings/credential/credential.types.d.ts +14 -0
  74. package/dist/wirings/credential/validate-credential-definitions.js +1 -0
  75. package/dist/wirings/gateway/gateway-runner.js +100 -50
  76. package/dist/wirings/gateway/gateway.types.d.ts +8 -5
  77. package/dist/wirings/http/http-routes.js +0 -3
  78. package/dist/wirings/http/http-runner.d.ts +9 -29
  79. package/dist/wirings/http/http-runner.js +9 -34
  80. package/dist/wirings/http/http.types.d.ts +4 -12
  81. package/dist/wirings/mcp/mcp-runner.js +0 -2
  82. package/dist/wirings/mcp/mcp.types.d.ts +5 -11
  83. package/dist/wirings/oauth2/index.d.ts +0 -3
  84. package/dist/wirings/oauth2/index.js +1 -2
  85. package/dist/wirings/rpc/addon-runner.d.ts +28 -0
  86. package/dist/wirings/rpc/addon-runner.js +173 -0
  87. package/dist/wirings/rpc/index.d.ts +5 -1
  88. package/dist/wirings/rpc/index.js +3 -1
  89. package/dist/wirings/rpc/remote-addon-auth.d.ts +26 -0
  90. package/dist/wirings/rpc/remote-addon-auth.js +43 -0
  91. package/dist/wirings/rpc/rpc-runner.d.ts +19 -0
  92. package/dist/wirings/rpc/rpc-runner.js +109 -3
  93. package/dist/wirings/rpc/rpc-types.d.ts +15 -1
  94. package/dist/wirings/rpc/wire-addon.js +9 -0
  95. package/dist/wirings/rpc/wire-remote-addon.d.ts +47 -0
  96. package/dist/wirings/rpc/wire-remote-addon.js +19 -0
  97. package/dist/wirings/scope/index.d.ts +3 -0
  98. package/dist/wirings/scope/index.js +2 -0
  99. package/dist/wirings/scope/scope.types.d.ts +39 -0
  100. package/dist/wirings/scope/scope.types.js +1 -0
  101. package/dist/wirings/scope/validate-scope-definitions.d.ts +16 -0
  102. package/dist/wirings/scope/validate-scope-definitions.js +76 -0
  103. package/dist/wirings/scope/wire-scope.d.ts +33 -0
  104. package/dist/wirings/scope/wire-scope.js +32 -0
  105. package/dist/wirings/secret/secret.types.d.ts +14 -0
  106. package/dist/wirings/secret/validate-secret-definitions.js +2 -0
  107. package/dist/wirings/variable/validate-variable-definitions.js +2 -0
  108. package/dist/wirings/variable/variable.types.d.ts +14 -0
  109. package/dist/wirings/workflow/dsl/index.d.ts +1 -1
  110. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +89 -7
  111. package/dist/wirings/workflow/graph/graph-node.d.ts +2 -0
  112. package/dist/wirings/workflow/graph/graph-runner.js +3 -0
  113. package/dist/wirings/workflow/graph/wire-workflow-graph.d.ts +6 -0
  114. package/dist/wirings/workflow/graph/wire-workflow-graph.js +1 -0
  115. package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +5 -0
  116. package/dist/wirings/workflow/index.d.ts +2 -2
  117. package/dist/wirings/workflow/index.js +1 -1
  118. package/dist/wirings/workflow/pikku-workflow-service.d.ts +59 -1
  119. package/dist/wirings/workflow/pikku-workflow-service.js +187 -0
  120. package/dist/wirings/workflow/workflow.types.d.ts +1 -3
  121. package/package.json +6 -1
  122. package/run-tests.sh +1 -0
  123. package/src/dev/hot-reload.ts +13 -68
  124. package/src/dev/module-runner.test.ts +169 -0
  125. package/src/dev/module-runner.ts +103 -0
  126. package/src/dev/reload-meta.test.ts +31 -2
  127. package/src/dev/reload-meta.ts +26 -0
  128. package/src/errors/errors.ts +24 -0
  129. package/src/function/function-runner.test.ts +276 -142
  130. package/src/function/function-runner.ts +52 -145
  131. package/src/function/functions.types.ts +28 -38
  132. package/src/index.ts +8 -6
  133. package/src/middleware/index.ts +1 -5
  134. package/src/permissions.test.ts +160 -373
  135. package/src/permissions.ts +137 -279
  136. package/src/pikku-state.ts +0 -2
  137. package/src/scopes.test.ts +167 -0
  138. package/src/scopes.ts +69 -0
  139. package/src/services/ai-embedding-service.ts +31 -0
  140. package/src/services/credential-wire-service.ts +10 -4
  141. package/src/services/index.ts +20 -0
  142. package/src/services/meta-service.ts +68 -3
  143. package/src/services/queue-webhook-service.test.ts +408 -0
  144. package/src/services/queue-webhook-service.ts +182 -0
  145. package/src/services/scope-service.ts +75 -0
  146. package/src/services/typed-secret-service.test.ts +35 -0
  147. package/src/services/typed-secret-service.ts +39 -4
  148. package/src/services/webhook-service.ts +180 -0
  149. package/src/services/workflow-service.ts +7 -0
  150. package/src/testing/service-tests.ts +49 -0
  151. package/src/types/core.types.ts +42 -14
  152. package/src/types/state.types.ts +23 -3
  153. package/src/utils/hmac.ts +27 -0
  154. package/src/utils/safe-fetch.test.ts +373 -0
  155. package/src/utils/safe-fetch.ts +213 -0
  156. package/src/wirings/ai-agent/ai-agent-agui.test.ts +1127 -0
  157. package/src/wirings/ai-agent/ai-agent-agui.ts +386 -0
  158. package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
  159. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +483 -0
  160. package/src/wirings/ai-agent/ai-agent-prepare.ts +324 -13
  161. package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
  162. package/src/wirings/ai-agent/ai-agent-runner.test.ts +48 -1
  163. package/src/wirings/ai-agent/ai-agent-runner.ts +28 -0
  164. package/src/wirings/ai-agent/ai-agent-stream.test.ts +301 -10
  165. package/src/wirings/ai-agent/ai-agent-stream.ts +122 -16
  166. package/src/wirings/ai-agent/ai-agent.types.ts +57 -0
  167. package/src/wirings/ai-agent/index.ts +5 -0
  168. package/src/wirings/ai-agent/voice-input.test.ts +90 -0
  169. package/src/wirings/ai-agent/voice-input.ts +9 -40
  170. package/src/wirings/channel/channel-common.ts +0 -1
  171. package/src/wirings/channel/channel-handler.ts +1 -9
  172. package/src/wirings/channel/channel-middleware-runner.test.ts +28 -2
  173. package/src/wirings/channel/channel-middleware-runner.ts +39 -27
  174. package/src/wirings/channel/channel.types.ts +0 -6
  175. package/src/wirings/cli/cli-runner.ts +4 -2
  176. package/src/wirings/cli/cli.types.ts +0 -2
  177. package/src/wirings/cli/command-parser.test.ts +130 -0
  178. package/src/wirings/cli/command-parser.ts +80 -2
  179. package/src/wirings/credential/credential.types.ts +14 -0
  180. package/src/wirings/credential/validate-credential-definitions.ts +1 -0
  181. package/src/wirings/gateway/gateway-authorization.test.ts +444 -0
  182. package/src/wirings/gateway/gateway-runner.ts +114 -68
  183. package/src/wirings/gateway/gateway.types.ts +7 -9
  184. package/src/wirings/http/http-routes.ts +0 -3
  185. package/src/wirings/http/http-runner.test.ts +1 -11
  186. package/src/wirings/http/http-runner.ts +16 -39
  187. package/src/wirings/http/http.types.ts +7 -14
  188. package/src/wirings/mcp/mcp-runner.ts +0 -2
  189. package/src/wirings/mcp/mcp.types.ts +3 -11
  190. package/src/wirings/oauth2/index.ts +0 -3
  191. package/src/wirings/rpc/addon-runner.ts +233 -0
  192. package/src/wirings/rpc/index.ts +17 -1
  193. package/src/wirings/rpc/remote-addon-auth.ts +69 -0
  194. package/src/wirings/rpc/rpc-runner.test.ts +319 -16
  195. package/src/wirings/rpc/rpc-runner.ts +155 -3
  196. package/src/wirings/rpc/rpc-types.ts +16 -5
  197. package/src/wirings/rpc/wire-addon.test.ts +6 -3
  198. package/src/wirings/rpc/wire-addon.ts +9 -0
  199. package/src/wirings/rpc/wire-remote-addon.ts +57 -0
  200. package/src/wirings/scope/index.ts +14 -0
  201. package/src/wirings/scope/scope.test.ts +135 -0
  202. package/src/wirings/scope/scope.types.ts +44 -0
  203. package/src/wirings/scope/validate-scope-definitions.ts +110 -0
  204. package/src/wirings/scope/wire-scope.ts +34 -0
  205. package/src/wirings/secret/secret.types.ts +14 -0
  206. package/src/wirings/secret/validate-secret-definitions.ts +2 -0
  207. package/src/wirings/variable/validate-variable-definitions.ts +2 -0
  208. package/src/wirings/variable/variable.types.ts +14 -0
  209. package/src/wirings/workflow/dsl/index.ts +4 -0
  210. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +97 -6
  211. package/src/wirings/workflow/graph/graph-node.ts +2 -0
  212. package/src/wirings/workflow/graph/graph-runner.test.ts +58 -0
  213. package/src/wirings/workflow/graph/graph-runner.ts +3 -0
  214. package/src/wirings/workflow/graph/wire-workflow-graph.ts +7 -0
  215. package/src/wirings/workflow/graph/workflow-graph.types.ts +5 -0
  216. package/src/wirings/workflow/index.ts +5 -0
  217. package/src/wirings/workflow/pikku-workflow-service.test.ts +341 -0
  218. package/src/wirings/workflow/pikku-workflow-service.ts +272 -0
  219. package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
  220. package/src/wirings/workflow/workflow-step-session.test.ts +21 -5
  221. package/src/wirings/workflow/workflow.types.ts +4 -2
  222. package/tsconfig.tsbuildinfo +1 -1
  223. package/src/wirings/oauth2/oauth2-client.test.ts +0 -930
  224. package/src/wirings/oauth2/oauth2-client.ts +0 -331
  225. 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
- /** Step name for this fanout */
184
- stepName: string
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
- /** Child step to execute per iteration */
192
- child: RpcStepMeta
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
- /** Value to assign (literal or expression) */
251
- value: unknown
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
+ })