@pikku/core 0.12.79 → 0.12.80

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 (330) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/dist/dev/hot-reload.js +1 -6
  3. package/dist/ecosystem.d.ts +27 -0
  4. package/dist/ecosystem.js +26 -0
  5. package/dist/errors/error-handler.js +4 -2
  6. package/dist/function/function-runner.js +20 -42
  7. package/dist/function/functions.types.d.ts +9 -5
  8. package/dist/index.d.ts +4 -9
  9. package/dist/index.js +3 -8
  10. package/dist/middleware/auth-cookie.d.ts +0 -4
  11. package/dist/middleware/auth-cookie.js +38 -2
  12. package/dist/middleware/cors.js +1 -0
  13. package/dist/middleware/index.d.ts +1 -1
  14. package/dist/middleware/index.js +1 -1
  15. package/dist/middleware/remote-auth.js +14 -3
  16. package/dist/permissions.d.ts +8 -10
  17. package/dist/permissions.js +0 -11
  18. package/dist/pikku-state.js +12 -5
  19. package/dist/schema.js +35 -1
  20. package/dist/services/ai-run-state-service.d.ts +7 -1
  21. package/dist/services/in-memory-ai-run-state-service.d.ts +1 -1
  22. package/dist/services/in-memory-ai-run-state-service.js +2 -1
  23. package/dist/services/in-memory-workflow-service.js +2 -0
  24. package/dist/services/local-content-request-handler.d.ts +21 -0
  25. package/dist/services/local-content-request-handler.js +72 -53
  26. package/dist/services/local-content.d.ts +6 -0
  27. package/dist/services/local-content.js +14 -1
  28. package/dist/services/workflow-service.d.ts +4 -2
  29. package/dist/testing/service-tests/agent-run-service-tests.d.ts +10 -0
  30. package/dist/testing/service-tests/agent-run-service-tests.js +72 -0
  31. package/dist/testing/service-tests/ai-storage-service-tests.d.ts +3 -0
  32. package/dist/testing/service-tests/ai-storage-service-tests.js +226 -0
  33. package/dist/testing/service-tests/channel-store-tests.d.ts +3 -0
  34. package/dist/testing/service-tests/channel-store-tests.js +72 -0
  35. package/dist/testing/service-tests/credential-service-tests.d.ts +3 -0
  36. package/dist/testing/service-tests/credential-service-tests.js +109 -0
  37. package/dist/testing/service-tests/deployment-service-tests.d.ts +3 -0
  38. package/dist/testing/service-tests/deployment-service-tests.js +21 -0
  39. package/dist/testing/service-tests/event-hub-store-tests.d.ts +3 -0
  40. package/dist/testing/service-tests/event-hub-store-tests.js +34 -0
  41. package/dist/testing/service-tests/secret-service-tests.d.ts +3 -0
  42. package/dist/testing/service-tests/secret-service-tests.js +80 -0
  43. package/dist/testing/service-tests/session-store-tests.d.ts +3 -0
  44. package/dist/testing/service-tests/session-store-tests.js +43 -0
  45. package/dist/testing/service-tests/workflow-run-service-tests.d.ts +3 -0
  46. package/dist/testing/service-tests/workflow-run-service-tests.js +42 -0
  47. package/dist/testing/service-tests/workflow-service-tests.d.ts +3 -0
  48. package/dist/testing/service-tests/workflow-service-tests.js +150 -0
  49. package/dist/testing/service-tests.d.ts +6 -0
  50. package/dist/testing/service-tests.js +26 -791
  51. package/dist/types/core.types.d.ts +11 -1
  52. package/dist/types/state.types.d.ts +1 -1
  53. package/dist/wirings/actor-flow/run-conversation.js +1 -6
  54. package/dist/wirings/ai-agent/agent-rpc.d.ts +15 -0
  55. package/dist/wirings/ai-agent/agent-rpc.js +53 -0
  56. package/dist/wirings/ai-agent/ai-agent-agui.js +1 -5
  57. package/dist/wirings/ai-agent/ai-agent-memory.js +3 -1
  58. package/dist/wirings/ai-agent/ai-agent-prepare.js +3 -9
  59. package/dist/wirings/ai-agent/ai-agent-runner.js +28 -107
  60. package/dist/wirings/ai-agent/ai-agent-stream.js +20 -61
  61. package/dist/wirings/ai-agent/ai-agent-turn.d.ts +56 -0
  62. package/dist/wirings/ai-agent/ai-agent-turn.js +81 -0
  63. package/dist/wirings/ai-agent/ai-agent.types.d.ts +1 -1
  64. package/dist/wirings/ai-agent/voice-input.js +1 -6
  65. package/dist/wirings/ai-agent/voice-output.js +2 -12
  66. package/dist/wirings/channel/channel-common.js +1 -0
  67. package/dist/wirings/channel/channel-handler.js +3 -5
  68. package/dist/wirings/channel/channel-rpc-service.d.ts +0 -6
  69. package/dist/wirings/channel/channel-rpc-service.js +0 -8
  70. package/dist/wirings/channel/channel-rpc.types.d.ts +6 -0
  71. package/dist/wirings/channel/channel-rpc.types.js +8 -0
  72. package/dist/wirings/channel/channel-runner.d.ts +1 -3
  73. package/dist/wirings/channel/channel-runner.js +16 -8
  74. package/dist/wirings/channel/channel.types.d.ts +2 -0
  75. package/dist/wirings/channel/pikku-abstract-channel-handler.js +1 -0
  76. package/dist/wirings/channel/serverless/serverless-channel-runner.js +3 -0
  77. package/dist/wirings/cli/channel/cli-channel-runner.js +2 -0
  78. package/dist/wirings/cli/cli-runner.js +4 -2
  79. package/dist/wirings/cli/cli.types.d.ts +0 -8
  80. package/dist/wirings/cli/command-parser.js +13 -0
  81. package/dist/wirings/gateway/gateway-runner.js +9 -2
  82. package/dist/wirings/http/http-routes.js +2 -0
  83. package/dist/wirings/http/http-runner.d.ts +0 -10
  84. package/dist/wirings/http/http-runner.js +6 -13
  85. package/dist/wirings/http/http.types.d.ts +0 -10
  86. package/dist/wirings/http/index.d.ts +1 -1
  87. package/dist/wirings/http/index.js +1 -1
  88. package/dist/wirings/mcp/mcp-runner.d.ts +0 -7
  89. package/dist/wirings/mcp/mcp-runner.js +0 -6
  90. package/dist/wirings/rpc/rpc-runner.d.ts +8 -51
  91. package/dist/wirings/rpc/rpc-runner.js +53 -86
  92. package/dist/wirings/rpc/rpc-types.d.ts +3 -0
  93. package/dist/wirings/secret/validate-secret-definitions.js +2 -2
  94. package/dist/wirings/trigger/pikku-trigger-service.d.ts +0 -4
  95. package/dist/wirings/trigger/trigger-runner.js +1 -0
  96. package/dist/wirings/virtual-user/run-virtual-user.js +11 -11
  97. package/dist/wirings/virtual-user/virtual-user-derive.js +4 -25
  98. package/dist/wirings/virtual-user/virtual-user-dispositions.js +1 -4
  99. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +29 -2
  100. package/dist/wirings/workflow/feature.js +7 -4
  101. package/dist/wirings/workflow/graph/graph-runner.d.ts +1 -2
  102. package/dist/wirings/workflow/graph/graph-runner.js +8 -7
  103. package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +0 -4
  104. package/dist/wirings/workflow/index.d.ts +7 -2
  105. package/dist/wirings/workflow/index.js +5 -1
  106. package/dist/wirings/workflow/pikku-scenario-service.d.ts +17 -3
  107. package/dist/wirings/workflow/pikku-scenario-service.js +38 -10
  108. package/dist/wirings/workflow/pikku-workflow-service.d.ts +28 -147
  109. package/dist/wirings/workflow/pikku-workflow-service.js +71 -493
  110. package/dist/wirings/workflow/workflow-approval.d.ts +39 -0
  111. package/dist/wirings/workflow/workflow-approval.js +114 -0
  112. package/dist/wirings/workflow/workflow-constants.d.ts +26 -0
  113. package/dist/wirings/workflow/workflow-constants.js +35 -0
  114. package/dist/wirings/workflow/workflow-errors.d.ts +58 -0
  115. package/dist/wirings/workflow/workflow-errors.js +112 -0
  116. package/dist/wirings/workflow/workflow-meta-resolver.d.ts +11 -0
  117. package/dist/wirings/workflow/workflow-meta-resolver.js +31 -0
  118. package/dist/wirings/workflow/workflow-queue-routing.d.ts +8 -0
  119. package/dist/wirings/workflow/workflow-queue-routing.js +38 -0
  120. package/dist/wirings/workflow/workflow-queue-wiring.d.ts +20 -0
  121. package/dist/wirings/workflow/workflow-queue-wiring.js +79 -0
  122. package/dist/wirings/workflow/workflow-recovery.d.ts +68 -0
  123. package/dist/wirings/workflow/workflow-recovery.js +101 -0
  124. package/dist/wirings/workflow/workflow-run-engine.types.d.ts +54 -0
  125. package/dist/wirings/workflow/workflow-run-engine.types.js +1 -0
  126. package/dist/wirings/workflow/workflow-run-ownership.d.ts +16 -0
  127. package/dist/wirings/workflow/workflow-run-ownership.js +29 -0
  128. package/dist/wirings/workflow/workflow-suspend.d.ts +12 -0
  129. package/dist/wirings/workflow/workflow-suspend.js +33 -0
  130. package/dist/wirings/workflow/workflow.types.d.ts +7 -0
  131. package/knowledge/decisions/internals/a-non-streaming-agent-run-registers-with-airunstate-too.md +22 -0
  132. package/knowledge/decisions/internals/a-resumed-agent-turn-is-as-interruptible-as-the-first.md +20 -0
  133. package/knowledge/decisions/internals/a-scenario-step-template-is-offered-unfilled.md +21 -0
  134. package/knowledge/decisions/internals/a-virtual-user-decides-whether-to-trust-memory-once-per-turn.md +21 -0
  135. package/knowledge/decisions/internals/a-wall-clock-threshold-is-a-load-test-in-disguise.md +46 -0
  136. package/knowledge/decisions/internals/a-workflow-wire-is-built-from-the-run-not-from-the-rpc-service.md +45 -0
  137. package/knowledge/decisions/internals/agent-context-waits-for-a-tool-result-still-being-written.md +21 -0
  138. package/knowledge/decisions/internals/agent-speech-travels-as-a-custom-agui-event.md +22 -0
  139. package/knowledge/decisions/internals/an-agent-interrupt-is-not-a-failure.md +23 -0
  140. package/knowledge/decisions/internals/an-agent-run-owned-by-another-instance-says-so.md +25 -0
  141. package/knowledge/decisions/internals/an-agent-stream-send-must-return-the-inner-sends-promise.md +22 -0
  142. package/knowledge/decisions/internals/an-empty-text-part-is-omitted-from-an-agent-message.md +20 -0
  143. package/knowledge/decisions/internals/an-empty-transcript-is-not-recorded.md +22 -0
  144. package/knowledge/decisions/internals/an-unref-d-timer-cannot-be-awaited-under-node-test.md +66 -0
  145. package/knowledge/decisions/internals/channel-state-accessors-are-unsound-generics-that-every-implementation-asserts.md +33 -0
  146. package/knowledge/decisions/internals/gateway-listener-middleware-runs-without-an-rpc-on-the-wire.md +43 -0
  147. package/knowledge/decisions/internals/hot-reload-writes-into-the-function-map-captured-at-startup.md +26 -0
  148. package/knowledge/decisions/internals/index.md +38 -26
  149. package/knowledge/decisions/internals/only-exposed-functions-enter-a-virtual-user-catalogue.md +21 -0
  150. package/knowledge/decisions/internals/scenario-given-and-when-are-sugar-but-then-is-not.md +24 -0
  151. package/knowledge/decisions/internals/side-effects-are-an-allowlist-not-a-boolean.md +34 -0
  152. package/knowledge/decisions/internals/speech-synthesis-picks-a-voice-per-sentence-and-warns-once.md +24 -0
  153. package/knowledge/decisions/internals/the-actor-prompt-says-json-because-of-json-object-mode.md +22 -0
  154. package/knowledge/decisions/internals/the-agent-done-event-goes-through-the-middleware-and-is-awaited.md +26 -0
  155. package/knowledge/decisions/internals/the-api-report-pins-members-not-just-names.md +43 -0
  156. package/knowledge/decisions/internals/the-ecosystem-entry-point-carries-the-adapter-surface.md +58 -0
  157. package/knowledge/decisions/internals/the-middleware-resolution-cache-is-deliberately-unbounded.md +40 -0
  158. package/knowledge/decisions/internals/the-per-invocation-rpc-view-is-a-class.md +40 -0
  159. package/knowledge/decisions/internals/the-persona-runtime-is-exported-from-the-persona-entry-point.md +28 -0
  160. package/knowledge/decisions/internals/the-transcript-event-is-sent-ahead-of-the-run.md +25 -0
  161. package/knowledge/decisions/internals/the-virtual-user-catalogue-is-the-only-gate-on-what-may-be-called.md +21 -0
  162. package/knowledge/decisions/internals/the-worker-disposition-is-the-one-that-is-not-testing.md +22 -0
  163. package/knowledge/decisions/internals/thread-history-records-the-transcript-not-the-audio.md +25 -0
  164. package/knowledge/decisions/internals/virtual-user-step-order-comes-from-insertion-order.md +21 -0
  165. package/knowledge/decisions/internals/voice-output-speaks-unless-voice-input-explicitly-says-otherwise.md +24 -0
  166. package/knowledge/decisions/internals/wiring-registries-erase-the-generics-their-wire-functions-capture.md +35 -0
  167. package/knowledge/decisions/security/a-dropped-audit-write-is-always-logged.md +4 -2
  168. package/knowledge/decisions/security/a-graph-run-starts-at-an-entry-node-the-graph-declared.md +33 -0
  169. package/knowledge/decisions/security/a-permission-gets-a-wire-it-cannot-reply-on.md +33 -0
  170. package/knowledge/decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md +37 -0
  171. package/knowledge/decisions/security/a-virtual-user-is-never-offered-a-step-that-would-forge-its-own-oracle.md +28 -0
  172. package/knowledge/decisions/security/a-workflow-run-is-read-and-approved-by-its-owner.md +34 -0
  173. package/knowledge/decisions/security/an-agent-approval-is-claimed-before-the-tool-runs.md +33 -0
  174. package/knowledge/decisions/security/an-upload-is-counted-as-it-arrives-not-buffered-then-measured.md +24 -0
  175. package/knowledge/decisions/security/index.md +7 -0
  176. package/knowledge/questions/channel-middleware-accepts-bare-factories-that-nothing-resolves.md +35 -0
  177. package/knowledge/questions/index.md +2 -1
  178. package/knowledge/questions/unauthorized-channel-replies-escape-the-declared-out-type.md +44 -0
  179. package/package.json +13 -3
  180. package/scripts/generate-api-report.d.mts +1 -0
  181. package/scripts/generate-api-report.mjs +89 -0
  182. package/scripts/generate-api-report.mts +218 -0
  183. package/src/api-report.test.ts +69 -0
  184. package/src/column-form.ts +1 -4
  185. package/src/crypto-utils.test.ts +15 -2
  186. package/src/dev/hot-reload.ts +2 -7
  187. package/src/ecosystem.ts +40 -0
  188. package/src/errors/error-handler.ts +5 -3
  189. package/src/errors/error.test.ts +4 -1
  190. package/src/function/function-runner.test.ts +1 -1
  191. package/src/function/function-runner.ts +28 -39
  192. package/src/function/functions.types.ts +15 -9
  193. package/src/handle-error.ts +1 -1
  194. package/src/index.ts +3 -22
  195. package/src/middleware/auth-cookie.test.ts +50 -0
  196. package/src/middleware/auth-cookie.ts +38 -2
  197. package/src/middleware/cors.ts +2 -1
  198. package/src/middleware/index.ts +1 -1
  199. package/src/middleware/remote-auth.test.ts +43 -0
  200. package/src/middleware/remote-auth.ts +17 -3
  201. package/src/middleware-runner.ts +4 -2
  202. package/src/no-any-casts.test.ts +57 -0
  203. package/src/permissions.test.ts +3 -1
  204. package/src/permissions.ts +22 -24
  205. package/src/pikku-state.ts +17 -6
  206. package/src/public-surface.json +578 -0
  207. package/src/public-surface.json.README +25 -0
  208. package/src/public-surface.test.ts +105 -0
  209. package/src/removed-legacy-exports.test.ts +62 -0
  210. package/src/schema.test.ts +78 -0
  211. package/src/schema.ts +36 -1
  212. package/src/services/ai-run-state-service.ts +7 -1
  213. package/src/services/audit-service.ts +2 -2
  214. package/src/services/in-memory-ai-run-state-service.ts +3 -2
  215. package/src/services/in-memory-workflow-service.ts +2 -0
  216. package/src/services/index.ts +1 -4
  217. package/src/services/local-content-request-handler.test.ts +43 -5
  218. package/src/services/local-content-request-handler.ts +103 -74
  219. package/src/services/local-content.ts +15 -1
  220. package/src/services/local-email-service.ts +5 -1
  221. package/src/services/system-role-guard.test.ts +4 -1
  222. package/src/services/workflow-service.ts +9 -2
  223. package/src/side-effects-are-declared.test.ts +84 -0
  224. package/src/source-files-stay-composable.test.ts +41 -0
  225. package/src/testing/service-tests/agent-run-service-tests.ts +98 -0
  226. package/src/testing/service-tests/ai-storage-service-tests.ts +286 -0
  227. package/src/testing/service-tests/channel-store-tests.ts +98 -0
  228. package/src/testing/service-tests/credential-service-tests.ts +143 -0
  229. package/src/testing/service-tests/deployment-service-tests.ts +33 -0
  230. package/src/testing/service-tests/event-hub-store-tests.ts +48 -0
  231. package/src/testing/service-tests/secret-service-tests.ts +105 -0
  232. package/src/testing/service-tests/session-store-tests.ts +62 -0
  233. package/src/testing/service-tests/workflow-run-service-tests.ts +59 -0
  234. package/src/testing/service-tests/workflow-service-tests.ts +308 -0
  235. package/src/testing/service-tests.ts +31 -1111
  236. package/src/types/core.types.ts +15 -1
  237. package/src/types/state.types.ts +1 -1
  238. package/src/wirings/actor-flow/run-conversation.ts +1 -6
  239. package/src/wirings/ai-agent/agent-rpc.ts +120 -0
  240. package/src/wirings/ai-agent/ai-agent-agui.ts +1 -5
  241. package/src/wirings/ai-agent/ai-agent-interrupt.test.ts +2 -1
  242. package/src/wirings/ai-agent/ai-agent-memory.ts +8 -1
  243. package/src/wirings/ai-agent/ai-agent-prepare.ts +10 -12
  244. package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +1 -0
  245. package/src/wirings/ai-agent/ai-agent-runner.test.ts +82 -2
  246. package/src/wirings/ai-agent/ai-agent-runner.ts +49 -120
  247. package/src/wirings/ai-agent/ai-agent-stream.test.ts +2 -0
  248. package/src/wirings/ai-agent/ai-agent-stream.ts +36 -63
  249. package/src/wirings/ai-agent/ai-agent-thread-ownership.test.ts +1 -1
  250. package/src/wirings/ai-agent/ai-agent-turn.ts +121 -0
  251. package/src/wirings/ai-agent/ai-agent.types.ts +1 -1
  252. package/src/wirings/ai-agent/voice-input.ts +1 -6
  253. package/src/wirings/ai-agent/voice-output.ts +2 -12
  254. package/src/wirings/channel/channel-common.ts +5 -2
  255. package/src/wirings/channel/channel-handler-shapes.test.ts +72 -0
  256. package/src/wirings/channel/channel-handler.ts +7 -7
  257. package/src/wirings/channel/channel-rpc-service.ts +0 -14
  258. package/src/wirings/channel/channel-rpc.test.ts +25 -4
  259. package/src/wirings/channel/channel-rpc.types.ts +14 -0
  260. package/src/wirings/channel/channel-runner.ts +33 -20
  261. package/src/wirings/channel/channel.types.ts +2 -0
  262. package/src/wirings/channel/local/local-channel-runner.ts +1 -1
  263. package/src/wirings/channel/pikku-abstract-channel-handler.ts +2 -1
  264. package/src/wirings/channel/serverless/serverless-channel-runner.ts +6 -3
  265. package/src/wirings/cli/channel/cli-channel-runner.ts +3 -1
  266. package/src/wirings/cli/channel/cli-raw-client-runner.ts +4 -1
  267. package/src/wirings/cli/cli-runner.ts +7 -4
  268. package/src/wirings/cli/cli.types.ts +0 -39
  269. package/src/wirings/cli/command-parser.test.ts +19 -0
  270. package/src/wirings/cli/command-parser.ts +17 -1
  271. package/src/wirings/gateway/gateway-channel-meta.test.ts +44 -0
  272. package/src/wirings/gateway/gateway-runner.ts +27 -19
  273. package/src/wirings/http/http-routes.ts +4 -1
  274. package/src/wirings/http/http-runner.ts +11 -26
  275. package/src/wirings/http/http.types.ts +0 -15
  276. package/src/wirings/http/index.ts +1 -7
  277. package/src/wirings/http/pikku-fetch-http-request.ts +2 -2
  278. package/src/wirings/http/web-request.ts +1 -1
  279. package/src/wirings/mcp/mcp-runner.ts +2 -16
  280. package/src/wirings/persona/persona-environments.test.ts +14 -3
  281. package/src/wirings/persona/persona.test.ts +13 -3
  282. package/src/wirings/persona/validate-personas.ts +5 -1
  283. package/src/wirings/queue/queue-runner.ts +1 -1
  284. package/src/wirings/rpc/addon-auth-tags.test.ts +1 -5
  285. package/src/wirings/rpc/rpc-runner.ts +58 -136
  286. package/src/wirings/rpc/rpc-types.ts +7 -0
  287. package/src/wirings/rpc/wire-addon.ts +3 -1
  288. package/src/wirings/secret/validate-secret-definitions.test.ts +22 -0
  289. package/src/wirings/secret/validate-secret-definitions.ts +2 -2
  290. package/src/wirings/trigger/pikku-trigger-service.ts +0 -5
  291. package/src/wirings/trigger/trigger-runner.ts +7 -5
  292. package/src/wirings/virtual-user/run-virtual-user.test.ts +18 -9
  293. package/src/wirings/virtual-user/run-virtual-user.ts +28 -15
  294. package/src/wirings/virtual-user/virtual-user-agents.test.ts +4 -1
  295. package/src/wirings/virtual-user/virtual-user-derive.ts +4 -25
  296. package/src/wirings/virtual-user/virtual-user-dispositions.test.ts +7 -2
  297. package/src/wirings/virtual-user/virtual-user-dispositions.ts +1 -4
  298. package/src/wirings/virtual-user/virtual-user-intents.test.ts +13 -3
  299. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +35 -2
  300. package/src/wirings/workflow/feature.ts +7 -4
  301. package/src/wirings/workflow/graph/graph-node.ts +1 -1
  302. package/src/wirings/workflow/graph/graph-runner.test.ts +4 -2
  303. package/src/wirings/workflow/graph/graph-runner.ts +13 -16
  304. package/src/wirings/workflow/graph/workflow-graph.types.ts +0 -5
  305. package/src/wirings/workflow/index.ts +12 -4
  306. package/src/wirings/workflow/pikku-scenario-service.ts +54 -25
  307. package/src/wirings/workflow/pikku-workflow-service.test.ts +2 -2
  308. package/src/wirings/workflow/pikku-workflow-service.ts +190 -743
  309. package/src/wirings/workflow/scenario-hooks.test.ts +51 -0
  310. package/src/wirings/workflow/workflow-approval.ts +185 -0
  311. package/src/wirings/workflow/workflow-child-run-session.test.ts +89 -0
  312. package/src/wirings/workflow/workflow-constants.ts +47 -0
  313. package/src/wirings/workflow/workflow-dispatch-durability.test.ts +1 -1
  314. package/src/wirings/workflow/workflow-errors.ts +128 -0
  315. package/src/wirings/workflow/workflow-inline-authority.test.ts +12 -6
  316. package/src/wirings/workflow/workflow-meta-resolver.ts +45 -0
  317. package/src/wirings/workflow/workflow-missing-meta.test.ts +92 -0
  318. package/src/wirings/workflow/workflow-queue-routing.ts +85 -0
  319. package/src/wirings/workflow/workflow-queue-wiring.ts +149 -0
  320. package/src/wirings/workflow/workflow-recovery.ts +162 -0
  321. package/src/wirings/workflow/workflow-retry-policy.test.ts +1 -1
  322. package/src/wirings/workflow/workflow-run-authority.test.ts +215 -0
  323. package/src/wirings/workflow/workflow-run-engine.types.ts +91 -0
  324. package/src/wirings/workflow/workflow-run-ownership.ts +36 -0
  325. package/src/wirings/workflow/workflow-suspend.ts +61 -0
  326. package/src/wirings/workflow/workflow.types.ts +7 -0
  327. package/src/wirings-stay-decoupled.test.ts +123 -0
  328. package/tsconfig.json +1 -1
  329. package/tsconfig.tsbuildinfo +1 -1
  330. package/src/internal.ts +0 -10
@@ -0,0 +1,85 @@
1
+ import { getSingletonServices, pikkuState } from '../../pikku-state.js'
2
+ import { getDurationInMilliseconds } from '../../time-utils.js'
3
+ import type { JobGroup, JobOptions } from '../queue/queue.types.js'
4
+ import type {
5
+ WorkflowServiceConfig,
6
+ WorkflowStepOptions,
7
+ } from './workflow.types.js'
8
+ import { DEFAULT_STEP_RETRIES } from './workflow-constants.js'
9
+
10
+ export type WorkflowQueueStrategy = 'per-workflow' | 'shared-groups'
11
+
12
+ const toKebab = (s: string) =>
13
+ s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
14
+
15
+ export const resolveWorkflowConfig = (): WorkflowServiceConfig => {
16
+ const workflow = getSingletonServices().config?.workflow
17
+ return {
18
+ retries: workflow?.retries ?? DEFAULT_STEP_RETRIES,
19
+ retryDelay: workflow?.retryDelay ?? 0,
20
+ orchestratorQueueName:
21
+ workflow?.orchestratorQueueName ?? 'pikku-workflow-orchestrator',
22
+ stepWorkerQueueName:
23
+ workflow?.stepWorkerQueueName ?? 'pikku-workflow-step-worker',
24
+ sleeperRPCName: workflow?.sleeperRPCName ?? 'pikkuWorkflowSleeper',
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Falls back to the shared queue whenever the dedicated one was not generated
30
+ * into queue meta, so a workflow added without a rebuild still runs.
31
+ */
32
+ const dedicatedQueueName = (
33
+ prefix: string,
34
+ name: string | undefined,
35
+ strategy: WorkflowQueueStrategy,
36
+ fallback: string
37
+ ): string => {
38
+ if (!name || strategy === 'shared-groups') {
39
+ return fallback
40
+ }
41
+ const dedicated = `${prefix}${toKebab(name)}`
42
+ return pikkuState(null, 'queue', 'meta')[dedicated] ? dedicated : fallback
43
+ }
44
+
45
+ export const orchestratorQueueName = (
46
+ strategy: WorkflowQueueStrategy,
47
+ workflowName?: string
48
+ ): string =>
49
+ dedicatedQueueName(
50
+ 'wf-orchestrator-',
51
+ workflowName,
52
+ strategy,
53
+ resolveWorkflowConfig().orchestratorQueueName
54
+ )
55
+
56
+ export const stepWorkerQueueName = (
57
+ strategy: WorkflowQueueStrategy,
58
+ rpcName?: string
59
+ ): string =>
60
+ dedicatedQueueName(
61
+ 'wf-step-',
62
+ rpcName,
63
+ strategy,
64
+ resolveWorkflowConfig().stepWorkerQueueName
65
+ )
66
+
67
+ export const jobGroupFor = (
68
+ strategy: WorkflowQueueStrategy,
69
+ id?: string
70
+ ): JobGroup | undefined =>
71
+ id && strategy === 'shared-groups' ? { id, tier: id } : undefined
72
+
73
+ export const stepJobOptions = (
74
+ stepOptions?: WorkflowStepOptions
75
+ ): JobOptions => {
76
+ const retries = stepOptions?.retries ?? DEFAULT_STEP_RETRIES
77
+ const retryDelay = stepOptions?.retryDelay
78
+ const backoff =
79
+ retryDelay !== undefined && retryDelay !== 'exponential'
80
+ ? { type: 'fixed', delay: getDurationInMilliseconds(retryDelay) }
81
+ : retries > 0 || retryDelay === 'exponential'
82
+ ? 'exponential'
83
+ : undefined
84
+ return { attempts: retries + 1, ...(backoff ? { backoff } : {}) }
85
+ }
@@ -0,0 +1,149 @@
1
+ import { addFunction } from '../../function/function-runner.js'
2
+ import { pikkuState } from '../../pikku-state.js'
3
+ import { wireQueueWorker } from '../queue/queue-runner.js'
4
+ import type {
5
+ GroupConcurrencyConfig,
6
+ PikkuWorkerConfig,
7
+ } from '../queue/queue.types.js'
8
+ import type { Logger } from '../../services/logger.js'
9
+ import {
10
+ pikkuWorkflowOrchestratorFunc,
11
+ pikkuWorkflowSleeperFunc,
12
+ pikkuWorkflowWorkerFunc,
13
+ } from './workflow-queue-workers.js'
14
+ import type { WorkflowQueueStrategy } from './workflow-queue-routing.js'
15
+
16
+ const ORCHESTRATOR_QUEUE_PREFIX = 'wf-orchestrator-'
17
+ const STEP_QUEUE_PREFIX = 'wf-step-'
18
+
19
+ const helperFunctionMeta = (funcId: string) => ({
20
+ pikkuFuncId: funcId,
21
+ sessionless: true,
22
+ functionType: 'helper' as const,
23
+ inputSchemaName: null,
24
+ outputSchemaName: null,
25
+ })
26
+
27
+ export type WorkflowQueueWiringOptions = {
28
+ strategy: WorkflowQueueStrategy
29
+ concurrency: number
30
+ groupConcurrency: number | GroupConcurrencyConfig
31
+ /**
32
+ * Resolved only when there is something to warn about: reading the logger
33
+ * throws while singleton services are uninitialized, and wiring queues before
34
+ * that point is legitimate.
35
+ */
36
+ getLogger: () => Logger | undefined
37
+ }
38
+
39
+ /**
40
+ * Registers the orchestrator, step-worker and sleeper functions and binds them
41
+ * to their queues. Idempotent: a queue already carrying a registered function
42
+ * is left alone.
43
+ */
44
+ export const wireWorkflowQueueWorkers = ({
45
+ strategy,
46
+ concurrency,
47
+ groupConcurrency,
48
+ getLogger,
49
+ }: WorkflowQueueWiringOptions): void => {
50
+ const functions = pikkuState(null, 'function', 'functions')
51
+ const functionsMeta = pikkuState(null, 'function', 'meta')
52
+ const queueMeta = pikkuState(null, 'queue', 'meta')
53
+
54
+ const registerWorkflowFunc = (
55
+ funcId: string,
56
+ func: { func: unknown },
57
+ queueName: string,
58
+ config?: PikkuWorkerConfig
59
+ ) => {
60
+ if (functions.has(funcId)) return
61
+ addFunction(funcId, func as never)
62
+ if (!queueMeta[queueName]) {
63
+ queueMeta[queueName] = { pikkuFuncId: funcId, name: queueName }
64
+ }
65
+ wireQueueWorker({ name: queueName, func, config } as never)
66
+ if (!functionsMeta[funcId]) {
67
+ functionsMeta[funcId] = helperFunctionMeta(funcId)
68
+ }
69
+ }
70
+
71
+ const sharedGroups = strategy === 'shared-groups'
72
+ const sharedQueueConfig: PikkuWorkerConfig | undefined = sharedGroups
73
+ ? { batchSize: concurrency, groupConcurrency }
74
+ : undefined
75
+
76
+ registerWorkflowFunc(
77
+ 'pikkuWorkflowOrchestrator',
78
+ { func: pikkuWorkflowOrchestratorFunc },
79
+ 'pikku-workflow-orchestrator',
80
+ sharedQueueConfig
81
+ )
82
+ registerWorkflowFunc(
83
+ 'pikkuWorkflowStepWorker',
84
+ { func: pikkuWorkflowWorkerFunc },
85
+ 'pikku-workflow-step-worker',
86
+ sharedQueueConfig
87
+ )
88
+
89
+ const registerDedicatedQueues = (meta: Record<string, any>) => {
90
+ for (const [queueName, entry] of Object.entries(meta)) {
91
+ if (functions.has(entry.pikkuFuncId)) continue
92
+ if (queueName.startsWith(ORCHESTRATOR_QUEUE_PREFIX)) {
93
+ registerWorkflowFunc(
94
+ entry.pikkuFuncId,
95
+ { func: pikkuWorkflowOrchestratorFunc },
96
+ queueName
97
+ )
98
+ } else if (queueName.startsWith(STEP_QUEUE_PREFIX)) {
99
+ registerWorkflowFunc(
100
+ entry.pikkuFuncId,
101
+ { func: pikkuWorkflowWorkerFunc },
102
+ queueName
103
+ )
104
+ }
105
+ }
106
+ }
107
+
108
+ if (!sharedGroups) {
109
+ registerDedicatedQueues(pikkuState(null, 'queue', 'meta'))
110
+
111
+ const addons = pikkuState(null, 'addons', 'packages')
112
+ for (const [, addon] of addons ?? []) {
113
+ const addonQueueMeta = pikkuState(addon.package, 'queue', 'meta')
114
+ if (addonQueueMeta) {
115
+ registerDedicatedQueues(addonQueueMeta)
116
+ }
117
+ }
118
+ }
119
+
120
+ warnOnMissingDedicatedQueues(queueMeta, getLogger)
121
+
122
+ if (!functions.has('pikkuWorkflowSleeper')) {
123
+ addFunction('pikkuWorkflowSleeper', { func: pikkuWorkflowSleeperFunc })
124
+ }
125
+ if (!functionsMeta.pikkuWorkflowSleeper) {
126
+ functionsMeta.pikkuWorkflowSleeper = helperFunctionMeta(
127
+ 'pikkuWorkflowSleeper'
128
+ )
129
+ }
130
+ }
131
+
132
+ const warnOnMissingDedicatedQueues = (
133
+ queueMeta: Record<string, unknown>,
134
+ getLogger: () => Logger | undefined
135
+ ) => {
136
+ const workflowCount = Object.keys(
137
+ pikkuState(null, 'workflows', 'meta') ?? {}
138
+ ).length
139
+ const dedicatedQueues = Object.keys(queueMeta).filter((name) =>
140
+ name.startsWith(ORCHESTRATOR_QUEUE_PREFIX)
141
+ ).length
142
+ if (workflowCount > 0 && dedicatedQueues === 0) {
143
+ getLogger()?.warn?.(
144
+ `[pikku] ${workflowCount} workflow(s) registered but no per-workflow orchestrator queues were found in queue meta. ` +
145
+ `All workflows will share a single orchestrator queue, where one slow step blocks every other workflow behind it. ` +
146
+ `Check that the generated bootstrap imports the queue-workers meta (pikku-queue-workers-wirings-meta.gen.js).`
147
+ )
148
+ }
149
+ }
@@ -0,0 +1,162 @@
1
+ import type { Logger } from '../../services/logger.js'
2
+ import {
3
+ DEFAULT_STALLED_RUN_LIMIT,
4
+ DEFAULT_STALLED_RUN_MS,
5
+ DEFAULT_UNDISPATCHED_STEP_LIMIT,
6
+ DEFAULT_UNDISPATCHED_STEP_MS,
7
+ REDISPATCH_BACKOFF_MAX_ENTRIES,
8
+ REDISPATCH_BACKOFF_MAX_MS,
9
+ REDISPATCH_BACKOFF_MS,
10
+ } from './workflow-constants.js'
11
+
12
+ /**
13
+ * Per-process, advisory record of when a run may next be re-dispatched.
14
+ *
15
+ * Keyed by run rather than by step because the run is the unit of re-drive:
16
+ * `resumeWorkflow` replays the whole run and re-dispatches every step still
17
+ * owed a job, so holding off a single step while resuming its run would
18
+ * suppress nothing.
19
+ *
20
+ * Losing this on restart costs extra dispatches, never correctness.
21
+ */
22
+ export class RedispatchBackoff {
23
+ private readonly eligibleAt = new Map<string, number>()
24
+ private readonly delays = new Map<string, number>()
25
+
26
+ public isEligible(runId: string, now: number): boolean {
27
+ const at = this.eligibleAt.get(runId)
28
+ return at === undefined || at <= now
29
+ }
30
+
31
+ public note(runId: string, now: number): void {
32
+ const previous = this.delays.get(runId)
33
+ const delay = Math.min(
34
+ previous === undefined ? REDISPATCH_BACKOFF_MS : previous * 2,
35
+ REDISPATCH_BACKOFF_MAX_MS
36
+ )
37
+ // A run that settles is never returned again, so entries are only evicted
38
+ // by this bound — oldest first, which is also least recently re-dispatched.
39
+ if (this.eligibleAt.size >= REDISPATCH_BACKOFF_MAX_ENTRIES) {
40
+ const oldest = this.eligibleAt.keys().next()
41
+ if (!oldest.done) {
42
+ this.eligibleAt.delete(oldest.value)
43
+ this.delays.delete(oldest.value)
44
+ }
45
+ }
46
+ this.delays.set(runId, delay)
47
+ this.eligibleAt.set(runId, now + delay)
48
+ }
49
+ }
50
+
51
+ type SweepDeps = {
52
+ resume: (runId: string) => Promise<void>
53
+ logger?: Logger
54
+ }
55
+
56
+ const resumeEach = async (
57
+ runIds: Iterable<string>,
58
+ { resume, logger }: SweepDeps,
59
+ failure: (runId: string, detail: string) => string
60
+ ): Promise<string[]> => {
61
+ const succeeded: string[] = []
62
+ for (const runId of runIds) {
63
+ try {
64
+ await resume(runId)
65
+ succeeded.push(runId)
66
+ } catch (err) {
67
+ // One unresumable run must not stop the sweep from recovering the rest.
68
+ logger?.error(
69
+ failure(runId, err instanceof Error ? err.message : String(err))
70
+ )
71
+ }
72
+ }
73
+ return succeeded
74
+ }
75
+
76
+ /**
77
+ * Re-drive runs whose next move was lost, and report which were resumed.
78
+ *
79
+ * Arming a step is two writes to two systems — the step row, then the queue or
80
+ * scheduler job — so a process that dies between them leaves a run that is
81
+ * `running` with nothing in flight. Nothing notices: the run parks on a step
82
+ * that will never complete and never error, so it neither finishes nor fails.
83
+ * (Seen on a `workflow.sleep()`: a deploy restart landed between the sleep
84
+ * step's insert and its timer, parking the run permanently.)
85
+ *
86
+ * Replay is the recovery — `resumeWorkflow` re-orchestrates from persisted step
87
+ * state, and every settled step is memoized, so resuming a run that was not
88
+ * actually stuck costs an orchestration pass and changes nothing. That
89
+ * idempotence is what makes an idle-time heuristic safe here; a run that is
90
+ * legitimately mid-sleep is excluded anyway, since its step is `scheduled`.
91
+ */
92
+ export const sweepStalledRuns = async (
93
+ findStalledRunIds: (before: Date, limit: number) => Promise<string[]>,
94
+ options: { stalledAfterMs?: number; limit?: number } | undefined,
95
+ deps: SweepDeps
96
+ ): Promise<{ resumed: string[] }> => {
97
+ const before = new Date(
98
+ Date.now() - (options?.stalledAfterMs ?? DEFAULT_STALLED_RUN_MS)
99
+ )
100
+ const runIds = await findStalledRunIds(
101
+ before,
102
+ options?.limit ?? DEFAULT_STALLED_RUN_LIMIT
103
+ )
104
+ return {
105
+ resumed: await resumeEach(
106
+ runIds,
107
+ deps,
108
+ (runId, detail) =>
109
+ `Failed to resume stalled workflow run ${runId}: ${detail}`
110
+ ),
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Re-drive steps whose dispatch was lost, and report which runs were nudged.
116
+ *
117
+ * The step row is the outbox record and this is the relay. Age is the only
118
+ * signal available — a step `pending` because its dispatch was lost is
119
+ * indistinguishable from one whose job is merely still queued — so a step past
120
+ * `undispatchedAfterMs` is re-dispatched regardless, and correctness rests on
121
+ * the claim in `executeWorkflowStepInner` rather than on the guess being right.
122
+ * A redundant dispatch costs one queue message: the loser reads `running` and
123
+ * returns without invoking anything.
124
+ *
125
+ * Re-dispatches back off per run (doubling from 30s, capped at 10m) so a
126
+ * genuine queue backlog is not amplified by a tick that keeps firing at the
127
+ * steps the backlog is already delaying.
128
+ */
129
+ export const sweepUndispatchedSteps = async (
130
+ findUndispatchedSteps: (
131
+ before: Date,
132
+ limit: number
133
+ ) => Promise<Array<{ runId: string; stepId: string }>>,
134
+ backoff: RedispatchBackoff,
135
+ options: { undispatchedAfterMs?: number; limit?: number } | undefined,
136
+ deps: SweepDeps
137
+ ): Promise<{ redispatched: string[] }> => {
138
+ const before = new Date(
139
+ Date.now() - (options?.undispatchedAfterMs ?? DEFAULT_UNDISPATCHED_STEP_MS)
140
+ )
141
+ const steps = await findUndispatchedSteps(
142
+ before,
143
+ options?.limit ?? DEFAULT_UNDISPATCHED_STEP_LIMIT
144
+ )
145
+
146
+ const now = Date.now()
147
+ const runIds = new Set<string>()
148
+ for (const { runId } of steps) {
149
+ if (!backoff.isEligible(runId, now)) continue
150
+ backoff.note(runId, now)
151
+ runIds.add(runId)
152
+ }
153
+
154
+ return {
155
+ redispatched: await resumeEach(
156
+ runIds,
157
+ deps,
158
+ (runId, detail) =>
159
+ `Failed to re-dispatch workflow run ${runId}: ${detail}`
160
+ ),
161
+ }
162
+ }
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
3
3
 
4
4
  import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
5
5
  import { pikkuState } from '../../pikku-state.js'
6
- import { DEFAULT_STEP_RETRIES } from './pikku-workflow-service.js'
6
+ import { DEFAULT_STEP_RETRIES } from './workflow-constants.js'
7
7
  import type { PikkuWorkflowService } from './pikku-workflow-service.js'
8
8
  import { deriveInvocationId } from './workflow-invocation-id.js'
9
9
  import type { WorkflowStepOptions } from './workflow.types.js'
@@ -0,0 +1,215 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
5
+ import { pikkuState } from '../../pikku-state.js'
6
+ import { runWorkflowGraph } from './graph/graph-runner.js'
7
+ import { assertWorkflowRunOwner } from './workflow-run-ownership.js'
8
+ import { WorkflowStepFunctionMismatchError } from './workflow-errors.js'
9
+ import { ForbiddenError } from '../../errors/errors.js'
10
+
11
+ const silentLogger = { error() {}, info() {}, warn() {}, debug() {} }
12
+
13
+ const seedSingletons = () =>
14
+ pikkuState(null, 'package', 'singletonServices', {
15
+ logger: silentLogger,
16
+ // `resumeWorkflow` hands the run back to the orchestrator queue; the tests
17
+ // here assert on what happened before that hand-off.
18
+ queueService: { add: async () => 'job-1' },
19
+ } as any)
20
+
21
+ describe('a graph run starts at an entry node the graph declared', () => {
22
+ const meta = {
23
+ name: 'payout',
24
+ pikkuFuncId: 'payout',
25
+ source: 'graph' as const,
26
+ entryNodeIds: ['validate'],
27
+ graphHash: 'payout-hash',
28
+ nodes: {
29
+ validate: { nodeId: 'validate', rpcName: 'validatePayout' },
30
+ // Reachable only after validation, but its input is a literal — so
31
+ // `areDependenciesSatisfied` alone would happily start the run here.
32
+ transfer: {
33
+ nodeId: 'transfer',
34
+ rpcName: 'transferFunds',
35
+ input: { amount: 1000 },
36
+ },
37
+ },
38
+ }
39
+
40
+ test('a startNode outside entryNodeIds is rejected', async () => {
41
+ seedSingletons()
42
+ const ws = new InMemoryWorkflowService()
43
+
44
+ await assert.rejects(
45
+ runWorkflowGraph(
46
+ ws,
47
+ 'payout',
48
+ {},
49
+ { rpcWithWire: async () => ({}) },
50
+ true,
51
+ 'transfer',
52
+ undefined,
53
+ meta
54
+ ),
55
+ /'transfer' is not an entry node/
56
+ )
57
+ })
58
+
59
+ test('a declared entry node still starts the run', async () => {
60
+ seedSingletons()
61
+ const ws = new InMemoryWorkflowService()
62
+
63
+ const { runId } = await runWorkflowGraph(
64
+ ws,
65
+ 'payout',
66
+ {},
67
+ { rpcWithWire: async () => ({}) },
68
+ true,
69
+ 'validate',
70
+ undefined,
71
+ meta
72
+ )
73
+
74
+ assert.ok(runId, 'the graph ran from the entry node it declared')
75
+ })
76
+ })
77
+
78
+ describe('a step runs the function the workflow dispatched it with', () => {
79
+ test('a queue message naming another function is rejected', async () => {
80
+ seedSingletons()
81
+ const ws = new InMemoryWorkflowService()
82
+ const runId = await ws.createRun('flow', {}, false, 'hash', {
83
+ type: 'queue',
84
+ })
85
+ await ws.insertStepState(runId, 'notify', 'sendReceipt', {})
86
+
87
+ const invoked: string[] = []
88
+ await assert.rejects(
89
+ ws.executeWorkflowStep(runId, 'notify', 'deleteAllUsers', {}, {
90
+ rpcWithWire: async (rpcName: string) => {
91
+ invoked.push(rpcName)
92
+ return {}
93
+ },
94
+ } as any),
95
+ WorkflowStepFunctionMismatchError
96
+ )
97
+ assert.deepEqual(invoked, [], 'nothing was invoked')
98
+
99
+ const stepState = await ws.getStepState(runId, 'notify')
100
+ assert.equal(
101
+ stepState.status,
102
+ 'pending',
103
+ 'the forged message left the step untouched'
104
+ )
105
+ })
106
+
107
+ test('the dispatched function still runs', async () => {
108
+ seedSingletons()
109
+ const ws = new InMemoryWorkflowService()
110
+ const runId = await ws.createRun('flow', {}, false, 'hash', {
111
+ type: 'queue',
112
+ })
113
+ await ws.insertStepState(runId, 'notify', 'sendReceipt', {})
114
+
115
+ const invoked: string[] = []
116
+ await ws.executeWorkflowStep(runId, 'notify', 'sendReceipt', {}, {
117
+ rpcWithWire: async (rpcName: string) => {
118
+ invoked.push(rpcName)
119
+ return {}
120
+ },
121
+ } as any)
122
+
123
+ assert.deepEqual(invoked, ['sendReceipt'])
124
+ })
125
+ })
126
+
127
+ describe('workflow run ownership', () => {
128
+ test('a run started by a session is readable only by that session', () => {
129
+ assert.throws(
130
+ () =>
131
+ assertWorkflowRunOwner(
132
+ { type: 'http', pikkuUserId: 'user-a' },
133
+ {
134
+ userId: 'user-b',
135
+ }
136
+ ),
137
+ ForbiddenError
138
+ )
139
+ assert.throws(
140
+ () =>
141
+ assertWorkflowRunOwner(
142
+ { type: 'http', pikkuUserId: 'user-a' },
143
+ undefined
144
+ ),
145
+ ForbiddenError
146
+ )
147
+ assert.doesNotThrow(() =>
148
+ assertWorkflowRunOwner(
149
+ { type: 'http', pikkuUserId: 'user-a' },
150
+ {
151
+ userId: 'user-a',
152
+ }
153
+ )
154
+ )
155
+ })
156
+
157
+ test('a run with no recorded owner has no ownership to enforce', () => {
158
+ assert.doesNotThrow(() =>
159
+ assertWorkflowRunOwner({ type: 'trigger' }, { userId: 'user-b' })
160
+ )
161
+ })
162
+
163
+ test('approveStep refuses a caller who does not own the run', async () => {
164
+ seedSingletons()
165
+ const ws = new InMemoryWorkflowService()
166
+ const runId = await ws.createRun('flow', {}, false, 'hash', {
167
+ type: 'http',
168
+ pikkuUserId: 'owner',
169
+ })
170
+
171
+ await assert.rejects(
172
+ ws.approveStep(
173
+ runId,
174
+ 'release-funds',
175
+ { ok: true },
176
+ {
177
+ userId: 'attacker',
178
+ }
179
+ ),
180
+ ForbiddenError
181
+ )
182
+
183
+ const state = await ws.getRunState(runId)
184
+ assert.deepEqual(
185
+ Object.keys(state),
186
+ [],
187
+ 'no decision was recorded for the run'
188
+ )
189
+ })
190
+
191
+ test('approveStep accepts the run owner', async () => {
192
+ seedSingletons()
193
+ const ws = new InMemoryWorkflowService()
194
+ const runId = await ws.createRun('flow', {}, false, 'hash', {
195
+ type: 'http',
196
+ pikkuUserId: 'owner',
197
+ })
198
+
199
+ await ws.approveStep(
200
+ runId,
201
+ 'release-funds',
202
+ { ok: true },
203
+ {
204
+ userId: 'owner',
205
+ }
206
+ )
207
+
208
+ const state = await ws.getRunState(runId)
209
+ assert.equal(
210
+ Object.keys(state).length,
211
+ 1,
212
+ 'the owner’s decision was recorded'
213
+ )
214
+ })
215
+ })