@pikku/core 0.12.78 → 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 (341) hide show
  1. package/CHANGELOG.md +234 -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.d.ts +10 -0
  24. package/dist/services/in-memory-workflow-service.js +25 -0
  25. package/dist/services/local-content-request-handler.d.ts +21 -0
  26. package/dist/services/local-content-request-handler.js +72 -53
  27. package/dist/services/local-content.d.ts +6 -0
  28. package/dist/services/local-content.js +14 -1
  29. package/dist/services/workflow-service.d.ts +4 -2
  30. package/dist/testing/service-tests/agent-run-service-tests.d.ts +10 -0
  31. package/dist/testing/service-tests/agent-run-service-tests.js +72 -0
  32. package/dist/testing/service-tests/ai-storage-service-tests.d.ts +3 -0
  33. package/dist/testing/service-tests/ai-storage-service-tests.js +226 -0
  34. package/dist/testing/service-tests/channel-store-tests.d.ts +3 -0
  35. package/dist/testing/service-tests/channel-store-tests.js +72 -0
  36. package/dist/testing/service-tests/credential-service-tests.d.ts +3 -0
  37. package/dist/testing/service-tests/credential-service-tests.js +109 -0
  38. package/dist/testing/service-tests/deployment-service-tests.d.ts +3 -0
  39. package/dist/testing/service-tests/deployment-service-tests.js +21 -0
  40. package/dist/testing/service-tests/event-hub-store-tests.d.ts +3 -0
  41. package/dist/testing/service-tests/event-hub-store-tests.js +34 -0
  42. package/dist/testing/service-tests/secret-service-tests.d.ts +3 -0
  43. package/dist/testing/service-tests/secret-service-tests.js +80 -0
  44. package/dist/testing/service-tests/session-store-tests.d.ts +3 -0
  45. package/dist/testing/service-tests/session-store-tests.js +43 -0
  46. package/dist/testing/service-tests/workflow-run-service-tests.d.ts +3 -0
  47. package/dist/testing/service-tests/workflow-run-service-tests.js +42 -0
  48. package/dist/testing/service-tests/workflow-service-tests.d.ts +3 -0
  49. package/dist/testing/service-tests/workflow-service-tests.js +150 -0
  50. package/dist/testing/service-tests.d.ts +6 -0
  51. package/dist/testing/service-tests.js +26 -791
  52. package/dist/types/core.types.d.ts +11 -1
  53. package/dist/types/state.types.d.ts +1 -1
  54. package/dist/utils/node-host-resolver.d.ts +12 -0
  55. package/dist/utils/node-host-resolver.js +16 -0
  56. package/dist/utils/safe-fetch.d.ts +18 -0
  57. package/dist/utils/safe-fetch.js +167 -29
  58. package/dist/wirings/actor-flow/run-conversation.js +1 -6
  59. package/dist/wirings/ai-agent/agent-rpc.d.ts +15 -0
  60. package/dist/wirings/ai-agent/agent-rpc.js +53 -0
  61. package/dist/wirings/ai-agent/ai-agent-agui.js +1 -5
  62. package/dist/wirings/ai-agent/ai-agent-memory.js +3 -1
  63. package/dist/wirings/ai-agent/ai-agent-prepare.js +3 -9
  64. package/dist/wirings/ai-agent/ai-agent-runner.js +28 -107
  65. package/dist/wirings/ai-agent/ai-agent-stream.js +20 -61
  66. package/dist/wirings/ai-agent/ai-agent-turn.d.ts +56 -0
  67. package/dist/wirings/ai-agent/ai-agent-turn.js +81 -0
  68. package/dist/wirings/ai-agent/ai-agent.types.d.ts +1 -1
  69. package/dist/wirings/ai-agent/voice-input.js +1 -6
  70. package/dist/wirings/ai-agent/voice-output.js +2 -12
  71. package/dist/wirings/channel/channel-common.js +1 -0
  72. package/dist/wirings/channel/channel-handler.js +3 -5
  73. package/dist/wirings/channel/channel-rpc-service.d.ts +0 -6
  74. package/dist/wirings/channel/channel-rpc-service.js +0 -8
  75. package/dist/wirings/channel/channel-rpc.types.d.ts +6 -0
  76. package/dist/wirings/channel/channel-rpc.types.js +8 -0
  77. package/dist/wirings/channel/channel-runner.d.ts +1 -3
  78. package/dist/wirings/channel/channel-runner.js +16 -8
  79. package/dist/wirings/channel/channel.types.d.ts +2 -0
  80. package/dist/wirings/channel/pikku-abstract-channel-handler.js +1 -0
  81. package/dist/wirings/channel/serverless/serverless-channel-runner.js +3 -0
  82. package/dist/wirings/cli/channel/cli-channel-runner.js +2 -0
  83. package/dist/wirings/cli/cli-runner.js +4 -2
  84. package/dist/wirings/cli/cli.types.d.ts +0 -8
  85. package/dist/wirings/cli/command-parser.js +13 -0
  86. package/dist/wirings/gateway/gateway-runner.js +41 -5
  87. package/dist/wirings/http/http-routes.js +2 -0
  88. package/dist/wirings/http/http-runner.d.ts +0 -10
  89. package/dist/wirings/http/http-runner.js +6 -13
  90. package/dist/wirings/http/http.types.d.ts +0 -10
  91. package/dist/wirings/http/index.d.ts +1 -1
  92. package/dist/wirings/http/index.js +1 -1
  93. package/dist/wirings/mcp/mcp-runner.d.ts +0 -7
  94. package/dist/wirings/mcp/mcp-runner.js +0 -6
  95. package/dist/wirings/rpc/rpc-runner.d.ts +8 -51
  96. package/dist/wirings/rpc/rpc-runner.js +53 -86
  97. package/dist/wirings/rpc/rpc-types.d.ts +3 -0
  98. package/dist/wirings/secret/validate-secret-definitions.js +2 -0
  99. package/dist/wirings/trigger/pikku-trigger-service.d.ts +0 -4
  100. package/dist/wirings/trigger/trigger-runner.js +1 -0
  101. package/dist/wirings/virtual-user/run-virtual-user.js +11 -11
  102. package/dist/wirings/virtual-user/virtual-user-derive.js +4 -25
  103. package/dist/wirings/virtual-user/virtual-user-dispositions.js +1 -4
  104. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +29 -2
  105. package/dist/wirings/workflow/feature.js +7 -4
  106. package/dist/wirings/workflow/graph/graph-runner.d.ts +1 -2
  107. package/dist/wirings/workflow/graph/graph-runner.js +8 -7
  108. package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +0 -4
  109. package/dist/wirings/workflow/index.d.ts +7 -2
  110. package/dist/wirings/workflow/index.js +5 -1
  111. package/dist/wirings/workflow/pikku-scenario-service.d.ts +17 -3
  112. package/dist/wirings/workflow/pikku-scenario-service.js +38 -10
  113. package/dist/wirings/workflow/pikku-workflow-service.d.ts +44 -111
  114. package/dist/wirings/workflow/pikku-workflow-service.js +86 -404
  115. package/dist/wirings/workflow/workflow-approval.d.ts +39 -0
  116. package/dist/wirings/workflow/workflow-approval.js +114 -0
  117. package/dist/wirings/workflow/workflow-constants.d.ts +26 -0
  118. package/dist/wirings/workflow/workflow-constants.js +35 -0
  119. package/dist/wirings/workflow/workflow-errors.d.ts +58 -0
  120. package/dist/wirings/workflow/workflow-errors.js +112 -0
  121. package/dist/wirings/workflow/workflow-meta-resolver.d.ts +11 -0
  122. package/dist/wirings/workflow/workflow-meta-resolver.js +31 -0
  123. package/dist/wirings/workflow/workflow-queue-routing.d.ts +8 -0
  124. package/dist/wirings/workflow/workflow-queue-routing.js +38 -0
  125. package/dist/wirings/workflow/workflow-queue-wiring.d.ts +20 -0
  126. package/dist/wirings/workflow/workflow-queue-wiring.js +79 -0
  127. package/dist/wirings/workflow/workflow-recovery.d.ts +68 -0
  128. package/dist/wirings/workflow/workflow-recovery.js +101 -0
  129. package/dist/wirings/workflow/workflow-run-engine.types.d.ts +54 -0
  130. package/dist/wirings/workflow/workflow-run-engine.types.js +1 -0
  131. package/dist/wirings/workflow/workflow-run-ownership.d.ts +16 -0
  132. package/dist/wirings/workflow/workflow-run-ownership.js +29 -0
  133. package/dist/wirings/workflow/workflow-suspend.d.ts +12 -0
  134. package/dist/wirings/workflow/workflow-suspend.js +33 -0
  135. package/dist/wirings/workflow/workflow.types.d.ts +7 -0
  136. package/knowledge/decisions/internals/a-non-streaming-agent-run-registers-with-airunstate-too.md +22 -0
  137. package/knowledge/decisions/internals/a-resumed-agent-turn-is-as-interruptible-as-the-first.md +20 -0
  138. package/knowledge/decisions/internals/a-scenario-step-template-is-offered-unfilled.md +21 -0
  139. package/knowledge/decisions/internals/a-virtual-user-decides-whether-to-trust-memory-once-per-turn.md +21 -0
  140. package/knowledge/decisions/internals/a-wall-clock-threshold-is-a-load-test-in-disguise.md +46 -0
  141. package/knowledge/decisions/internals/a-workflow-wire-is-built-from-the-run-not-from-the-rpc-service.md +45 -0
  142. package/knowledge/decisions/internals/agent-context-waits-for-a-tool-result-still-being-written.md +21 -0
  143. package/knowledge/decisions/internals/agent-speech-travels-as-a-custom-agui-event.md +22 -0
  144. package/knowledge/decisions/internals/an-agent-interrupt-is-not-a-failure.md +23 -0
  145. package/knowledge/decisions/internals/an-agent-run-owned-by-another-instance-says-so.md +25 -0
  146. package/knowledge/decisions/internals/an-agent-stream-send-must-return-the-inner-sends-promise.md +22 -0
  147. package/knowledge/decisions/internals/an-empty-text-part-is-omitted-from-an-agent-message.md +20 -0
  148. package/knowledge/decisions/internals/an-empty-transcript-is-not-recorded.md +22 -0
  149. package/knowledge/decisions/internals/an-unref-d-timer-cannot-be-awaited-under-node-test.md +66 -0
  150. package/knowledge/decisions/internals/channel-state-accessors-are-unsound-generics-that-every-implementation-asserts.md +33 -0
  151. package/knowledge/decisions/internals/gateway-listener-middleware-runs-without-an-rpc-on-the-wire.md +43 -0
  152. package/knowledge/decisions/internals/hot-reload-writes-into-the-function-map-captured-at-startup.md +26 -0
  153. package/knowledge/decisions/internals/index.md +38 -26
  154. package/knowledge/decisions/internals/only-exposed-functions-enter-a-virtual-user-catalogue.md +21 -0
  155. package/knowledge/decisions/internals/scenario-given-and-when-are-sugar-but-then-is-not.md +24 -0
  156. package/knowledge/decisions/internals/side-effects-are-an-allowlist-not-a-boolean.md +34 -0
  157. package/knowledge/decisions/internals/speech-synthesis-picks-a-voice-per-sentence-and-warns-once.md +24 -0
  158. package/knowledge/decisions/internals/the-actor-prompt-says-json-because-of-json-object-mode.md +22 -0
  159. package/knowledge/decisions/internals/the-agent-done-event-goes-through-the-middleware-and-is-awaited.md +26 -0
  160. package/knowledge/decisions/internals/the-api-report-pins-members-not-just-names.md +43 -0
  161. package/knowledge/decisions/internals/the-ecosystem-entry-point-carries-the-adapter-surface.md +58 -0
  162. package/knowledge/decisions/internals/the-middleware-resolution-cache-is-deliberately-unbounded.md +40 -0
  163. package/knowledge/decisions/internals/the-per-invocation-rpc-view-is-a-class.md +40 -0
  164. package/knowledge/decisions/internals/the-persona-runtime-is-exported-from-the-persona-entry-point.md +28 -0
  165. package/knowledge/decisions/internals/the-transcript-event-is-sent-ahead-of-the-run.md +25 -0
  166. package/knowledge/decisions/internals/the-virtual-user-catalogue-is-the-only-gate-on-what-may-be-called.md +21 -0
  167. package/knowledge/decisions/internals/the-worker-disposition-is-the-one-that-is-not-testing.md +22 -0
  168. package/knowledge/decisions/internals/thread-history-records-the-transcript-not-the-audio.md +25 -0
  169. package/knowledge/decisions/internals/virtual-user-step-order-comes-from-insertion-order.md +21 -0
  170. package/knowledge/decisions/internals/voice-output-speaks-unless-voice-input-explicitly-says-otherwise.md +24 -0
  171. package/knowledge/decisions/internals/wiring-registries-erase-the-generics-their-wire-functions-capture.md +35 -0
  172. package/knowledge/decisions/security/a-dropped-audit-write-is-always-logged.md +4 -2
  173. package/knowledge/decisions/security/a-graph-run-starts-at-an-entry-node-the-graph-declared.md +33 -0
  174. package/knowledge/decisions/security/a-permission-gets-a-wire-it-cannot-reply-on.md +33 -0
  175. package/knowledge/decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md +37 -0
  176. package/knowledge/decisions/security/a-virtual-user-is-never-offered-a-step-that-would-forge-its-own-oracle.md +28 -0
  177. package/knowledge/decisions/security/a-workflow-run-is-read-and-approved-by-its-owner.md +34 -0
  178. package/knowledge/decisions/security/an-agent-approval-is-claimed-before-the-tool-runs.md +33 -0
  179. package/knowledge/decisions/security/an-upload-is-counted-as-it-arrives-not-buffered-then-measured.md +24 -0
  180. package/knowledge/decisions/security/gateway-handlers-run-through-the-function-runner-gate.md +13 -3
  181. package/knowledge/decisions/security/index.md +7 -0
  182. package/knowledge/questions/channel-middleware-accepts-bare-factories-that-nothing-resolves.md +35 -0
  183. package/knowledge/questions/index.md +2 -1
  184. package/knowledge/questions/unauthorized-channel-replies-escape-the-declared-out-type.md +44 -0
  185. package/package.json +14 -3
  186. package/scripts/generate-api-report.d.mts +1 -0
  187. package/scripts/generate-api-report.mjs +89 -0
  188. package/scripts/generate-api-report.mts +218 -0
  189. package/src/api-report.test.ts +69 -0
  190. package/src/column-form.ts +1 -4
  191. package/src/crypto-utils.test.ts +15 -2
  192. package/src/dev/hot-reload.ts +2 -7
  193. package/src/ecosystem.ts +40 -0
  194. package/src/errors/error-handler.ts +5 -3
  195. package/src/errors/error.test.ts +4 -1
  196. package/src/function/function-runner.test.ts +1 -1
  197. package/src/function/function-runner.ts +28 -39
  198. package/src/function/functions.types.ts +15 -9
  199. package/src/handle-error.ts +1 -1
  200. package/src/index.ts +3 -22
  201. package/src/middleware/auth-cookie.test.ts +50 -0
  202. package/src/middleware/auth-cookie.ts +38 -2
  203. package/src/middleware/cors.ts +2 -1
  204. package/src/middleware/index.ts +1 -1
  205. package/src/middleware/remote-auth.test.ts +43 -0
  206. package/src/middleware/remote-auth.ts +17 -3
  207. package/src/middleware-runner.ts +4 -2
  208. package/src/no-any-casts.test.ts +57 -0
  209. package/src/permissions.test.ts +3 -1
  210. package/src/permissions.ts +22 -24
  211. package/src/pikku-state.ts +17 -6
  212. package/src/public-surface.json +578 -0
  213. package/src/public-surface.json.README +25 -0
  214. package/src/public-surface.test.ts +105 -0
  215. package/src/removed-legacy-exports.test.ts +62 -0
  216. package/src/schema.test.ts +78 -0
  217. package/src/schema.ts +36 -1
  218. package/src/services/ai-run-state-service.ts +7 -1
  219. package/src/services/audit-service.ts +2 -2
  220. package/src/services/in-memory-ai-run-state-service.ts +3 -2
  221. package/src/services/in-memory-workflow-service.ts +25 -0
  222. package/src/services/index.ts +1 -4
  223. package/src/services/local-content-request-handler.test.ts +43 -5
  224. package/src/services/local-content-request-handler.ts +103 -74
  225. package/src/services/local-content.ts +15 -1
  226. package/src/services/local-email-service.ts +5 -1
  227. package/src/services/system-role-guard.test.ts +4 -1
  228. package/src/services/workflow-service.ts +9 -2
  229. package/src/side-effects-are-declared.test.ts +84 -0
  230. package/src/source-files-stay-composable.test.ts +41 -0
  231. package/src/testing/service-tests/agent-run-service-tests.ts +98 -0
  232. package/src/testing/service-tests/ai-storage-service-tests.ts +286 -0
  233. package/src/testing/service-tests/channel-store-tests.ts +98 -0
  234. package/src/testing/service-tests/credential-service-tests.ts +143 -0
  235. package/src/testing/service-tests/deployment-service-tests.ts +33 -0
  236. package/src/testing/service-tests/event-hub-store-tests.ts +48 -0
  237. package/src/testing/service-tests/secret-service-tests.ts +105 -0
  238. package/src/testing/service-tests/session-store-tests.ts +62 -0
  239. package/src/testing/service-tests/workflow-run-service-tests.ts +59 -0
  240. package/src/testing/service-tests/workflow-service-tests.ts +308 -0
  241. package/src/testing/service-tests.ts +31 -1111
  242. package/src/types/core.types.ts +15 -1
  243. package/src/types/state.types.ts +1 -1
  244. package/src/utils/node-host-resolver.ts +20 -0
  245. package/src/utils/safe-fetch.test.ts +143 -1
  246. package/src/utils/safe-fetch.ts +200 -25
  247. package/src/wirings/actor-flow/run-conversation.ts +1 -6
  248. package/src/wirings/ai-agent/agent-rpc.ts +120 -0
  249. package/src/wirings/ai-agent/ai-agent-agui.ts +1 -5
  250. package/src/wirings/ai-agent/ai-agent-interrupt.test.ts +2 -1
  251. package/src/wirings/ai-agent/ai-agent-memory.ts +8 -1
  252. package/src/wirings/ai-agent/ai-agent-prepare.ts +10 -12
  253. package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +1 -0
  254. package/src/wirings/ai-agent/ai-agent-runner.test.ts +82 -2
  255. package/src/wirings/ai-agent/ai-agent-runner.ts +49 -120
  256. package/src/wirings/ai-agent/ai-agent-stream.test.ts +2 -0
  257. package/src/wirings/ai-agent/ai-agent-stream.ts +36 -63
  258. package/src/wirings/ai-agent/ai-agent-thread-ownership.test.ts +1 -1
  259. package/src/wirings/ai-agent/ai-agent-turn.ts +121 -0
  260. package/src/wirings/ai-agent/ai-agent.types.ts +1 -1
  261. package/src/wirings/ai-agent/voice-input.ts +1 -6
  262. package/src/wirings/ai-agent/voice-output.ts +2 -12
  263. package/src/wirings/channel/channel-common.ts +5 -2
  264. package/src/wirings/channel/channel-handler-shapes.test.ts +72 -0
  265. package/src/wirings/channel/channel-handler.ts +7 -7
  266. package/src/wirings/channel/channel-rpc-service.ts +0 -14
  267. package/src/wirings/channel/channel-rpc.test.ts +25 -4
  268. package/src/wirings/channel/channel-rpc.types.ts +14 -0
  269. package/src/wirings/channel/channel-runner.ts +33 -20
  270. package/src/wirings/channel/channel.types.ts +2 -0
  271. package/src/wirings/channel/local/local-channel-runner.ts +1 -1
  272. package/src/wirings/channel/pikku-abstract-channel-handler.ts +2 -1
  273. package/src/wirings/channel/serverless/serverless-channel-runner.ts +6 -3
  274. package/src/wirings/cli/channel/cli-channel-runner.ts +3 -1
  275. package/src/wirings/cli/channel/cli-raw-client-runner.ts +4 -1
  276. package/src/wirings/cli/cli-runner.ts +7 -4
  277. package/src/wirings/cli/cli.types.ts +0 -39
  278. package/src/wirings/cli/command-parser.test.ts +19 -0
  279. package/src/wirings/cli/command-parser.ts +17 -1
  280. package/src/wirings/gateway/gateway-authorization.test.ts +131 -0
  281. package/src/wirings/gateway/gateway-channel-meta.test.ts +44 -0
  282. package/src/wirings/gateway/gateway-runner.ts +62 -22
  283. package/src/wirings/http/http-routes.ts +4 -1
  284. package/src/wirings/http/http-runner.ts +11 -26
  285. package/src/wirings/http/http.types.ts +0 -15
  286. package/src/wirings/http/index.ts +1 -7
  287. package/src/wirings/http/pikku-fetch-http-request.ts +2 -2
  288. package/src/wirings/http/web-request.ts +1 -1
  289. package/src/wirings/mcp/mcp-runner.ts +2 -16
  290. package/src/wirings/persona/persona-environments.test.ts +14 -3
  291. package/src/wirings/persona/persona.test.ts +13 -3
  292. package/src/wirings/persona/validate-personas.ts +5 -1
  293. package/src/wirings/queue/queue-runner.ts +1 -1
  294. package/src/wirings/rpc/addon-auth-tags.test.ts +1 -5
  295. package/src/wirings/rpc/rpc-runner.ts +58 -136
  296. package/src/wirings/rpc/rpc-types.ts +7 -0
  297. package/src/wirings/rpc/wire-addon.ts +3 -1
  298. package/src/wirings/secret/validate-secret-definitions.test.ts +69 -0
  299. package/src/wirings/secret/validate-secret-definitions.ts +2 -0
  300. package/src/wirings/trigger/pikku-trigger-service.ts +0 -5
  301. package/src/wirings/trigger/trigger-runner.ts +7 -5
  302. package/src/wirings/virtual-user/run-virtual-user.test.ts +18 -9
  303. package/src/wirings/virtual-user/run-virtual-user.ts +28 -15
  304. package/src/wirings/virtual-user/virtual-user-agents.test.ts +4 -1
  305. package/src/wirings/virtual-user/virtual-user-derive.ts +4 -25
  306. package/src/wirings/virtual-user/virtual-user-dispositions.test.ts +7 -2
  307. package/src/wirings/virtual-user/virtual-user-dispositions.ts +1 -4
  308. package/src/wirings/virtual-user/virtual-user-intents.test.ts +13 -3
  309. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +35 -2
  310. package/src/wirings/workflow/feature.ts +7 -4
  311. package/src/wirings/workflow/graph/graph-node.ts +1 -1
  312. package/src/wirings/workflow/graph/graph-runner.test.ts +4 -2
  313. package/src/wirings/workflow/graph/graph-runner.ts +13 -16
  314. package/src/wirings/workflow/graph/workflow-graph.types.ts +0 -5
  315. package/src/wirings/workflow/index.ts +12 -4
  316. package/src/wirings/workflow/pikku-scenario-service.ts +54 -25
  317. package/src/wirings/workflow/pikku-workflow-service.test.ts +2 -2
  318. package/src/wirings/workflow/pikku-workflow-service.ts +208 -630
  319. package/src/wirings/workflow/scenario-hooks.test.ts +51 -0
  320. package/src/wirings/workflow/workflow-approval.ts +185 -0
  321. package/src/wirings/workflow/workflow-child-run-session.test.ts +89 -0
  322. package/src/wirings/workflow/workflow-constants.ts +47 -0
  323. package/src/wirings/workflow/workflow-dispatch-durability.test.ts +1 -1
  324. package/src/wirings/workflow/workflow-dispatch-relay.test.ts +128 -0
  325. package/src/wirings/workflow/workflow-errors.ts +128 -0
  326. package/src/wirings/workflow/workflow-inline-authority.test.ts +12 -6
  327. package/src/wirings/workflow/workflow-meta-resolver.ts +45 -0
  328. package/src/wirings/workflow/workflow-missing-meta.test.ts +92 -0
  329. package/src/wirings/workflow/workflow-queue-routing.ts +85 -0
  330. package/src/wirings/workflow/workflow-queue-wiring.ts +149 -0
  331. package/src/wirings/workflow/workflow-recovery.ts +162 -0
  332. package/src/wirings/workflow/workflow-retry-policy.test.ts +1 -1
  333. package/src/wirings/workflow/workflow-run-authority.test.ts +215 -0
  334. package/src/wirings/workflow/workflow-run-engine.types.ts +91 -0
  335. package/src/wirings/workflow/workflow-run-ownership.ts +36 -0
  336. package/src/wirings/workflow/workflow-suspend.ts +61 -0
  337. package/src/wirings/workflow/workflow.types.ts +7 -0
  338. package/src/wirings-stay-decoupled.test.ts +123 -0
  339. package/tsconfig.json +1 -1
  340. package/tsconfig.tsbuildinfo +1 -1
  341. package/src/internal.ts +0 -10
@@ -0,0 +1,101 @@
1
+ import { DEFAULT_STALLED_RUN_LIMIT, DEFAULT_STALLED_RUN_MS, DEFAULT_UNDISPATCHED_STEP_LIMIT, DEFAULT_UNDISPATCHED_STEP_MS, REDISPATCH_BACKOFF_MAX_ENTRIES, REDISPATCH_BACKOFF_MAX_MS, REDISPATCH_BACKOFF_MS, } from './workflow-constants.js';
2
+ /**
3
+ * Per-process, advisory record of when a run may next be re-dispatched.
4
+ *
5
+ * Keyed by run rather than by step because the run is the unit of re-drive:
6
+ * `resumeWorkflow` replays the whole run and re-dispatches every step still
7
+ * owed a job, so holding off a single step while resuming its run would
8
+ * suppress nothing.
9
+ *
10
+ * Losing this on restart costs extra dispatches, never correctness.
11
+ */
12
+ export class RedispatchBackoff {
13
+ eligibleAt = new Map();
14
+ delays = new Map();
15
+ isEligible(runId, now) {
16
+ const at = this.eligibleAt.get(runId);
17
+ return at === undefined || at <= now;
18
+ }
19
+ note(runId, now) {
20
+ const previous = this.delays.get(runId);
21
+ const delay = Math.min(previous === undefined ? REDISPATCH_BACKOFF_MS : previous * 2, REDISPATCH_BACKOFF_MAX_MS);
22
+ // A run that settles is never returned again, so entries are only evicted
23
+ // by this bound — oldest first, which is also least recently re-dispatched.
24
+ if (this.eligibleAt.size >= REDISPATCH_BACKOFF_MAX_ENTRIES) {
25
+ const oldest = this.eligibleAt.keys().next();
26
+ if (!oldest.done) {
27
+ this.eligibleAt.delete(oldest.value);
28
+ this.delays.delete(oldest.value);
29
+ }
30
+ }
31
+ this.delays.set(runId, delay);
32
+ this.eligibleAt.set(runId, now + delay);
33
+ }
34
+ }
35
+ const resumeEach = async (runIds, { resume, logger }, failure) => {
36
+ const succeeded = [];
37
+ for (const runId of runIds) {
38
+ try {
39
+ await resume(runId);
40
+ succeeded.push(runId);
41
+ }
42
+ catch (err) {
43
+ // One unresumable run must not stop the sweep from recovering the rest.
44
+ logger?.error(failure(runId, err instanceof Error ? err.message : String(err)));
45
+ }
46
+ }
47
+ return succeeded;
48
+ };
49
+ /**
50
+ * Re-drive runs whose next move was lost, and report which were resumed.
51
+ *
52
+ * Arming a step is two writes to two systems — the step row, then the queue or
53
+ * scheduler job — so a process that dies between them leaves a run that is
54
+ * `running` with nothing in flight. Nothing notices: the run parks on a step
55
+ * that will never complete and never error, so it neither finishes nor fails.
56
+ * (Seen on a `workflow.sleep()`: a deploy restart landed between the sleep
57
+ * step's insert and its timer, parking the run permanently.)
58
+ *
59
+ * Replay is the recovery — `resumeWorkflow` re-orchestrates from persisted step
60
+ * state, and every settled step is memoized, so resuming a run that was not
61
+ * actually stuck costs an orchestration pass and changes nothing. That
62
+ * idempotence is what makes an idle-time heuristic safe here; a run that is
63
+ * legitimately mid-sleep is excluded anyway, since its step is `scheduled`.
64
+ */
65
+ export const sweepStalledRuns = async (findStalledRunIds, options, deps) => {
66
+ const before = new Date(Date.now() - (options?.stalledAfterMs ?? DEFAULT_STALLED_RUN_MS));
67
+ const runIds = await findStalledRunIds(before, options?.limit ?? DEFAULT_STALLED_RUN_LIMIT);
68
+ return {
69
+ resumed: await resumeEach(runIds, deps, (runId, detail) => `Failed to resume stalled workflow run ${runId}: ${detail}`),
70
+ };
71
+ };
72
+ /**
73
+ * Re-drive steps whose dispatch was lost, and report which runs were nudged.
74
+ *
75
+ * The step row is the outbox record and this is the relay. Age is the only
76
+ * signal available — a step `pending` because its dispatch was lost is
77
+ * indistinguishable from one whose job is merely still queued — so a step past
78
+ * `undispatchedAfterMs` is re-dispatched regardless, and correctness rests on
79
+ * the claim in `executeWorkflowStepInner` rather than on the guess being right.
80
+ * A redundant dispatch costs one queue message: the loser reads `running` and
81
+ * returns without invoking anything.
82
+ *
83
+ * Re-dispatches back off per run (doubling from 30s, capped at 10m) so a
84
+ * genuine queue backlog is not amplified by a tick that keeps firing at the
85
+ * steps the backlog is already delaying.
86
+ */
87
+ export const sweepUndispatchedSteps = async (findUndispatchedSteps, backoff, options, deps) => {
88
+ const before = new Date(Date.now() - (options?.undispatchedAfterMs ?? DEFAULT_UNDISPATCHED_STEP_MS));
89
+ const steps = await findUndispatchedSteps(before, options?.limit ?? DEFAULT_UNDISPATCHED_STEP_LIMIT);
90
+ const now = Date.now();
91
+ const runIds = new Set();
92
+ for (const { runId } of steps) {
93
+ if (!backoff.isEligible(runId, now))
94
+ continue;
95
+ backoff.note(runId, now);
96
+ runIds.add(runId);
97
+ }
98
+ return {
99
+ redispatched: await resumeEach(runIds, deps, (runId, detail) => `Failed to re-dispatch workflow run ${runId}: ${detail}`),
100
+ };
101
+ };
@@ -0,0 +1,54 @@
1
+ import type { PikkuRawWire, SerializedError } from '../../types/core.types.js';
2
+ import type { CoreWorkflow, PikkuWorkflowWire, StepState, WorkflowRun, WorkflowStatus, WorkflowStepOptions } from './workflow.types.js';
3
+ export interface RunLifecycleContext {
4
+ runId: string;
5
+ run: WorkflowRun;
6
+ workflowMeta: any;
7
+ workflow: CoreWorkflow;
8
+ wire: PikkuRawWire;
9
+ packageName: string | null;
10
+ }
11
+ /**
12
+ * The subset of the workflow service a step executor is allowed to call back
13
+ * into.
14
+ */
15
+ export interface WorkflowRunEngine {
16
+ inlineStep(runId: string, logicalStepName: string, fn: Function, stepOptions?: WorkflowStepOptions, data?: any, funcName?: string): Promise<any>;
17
+ updateRunStatus(runId: string, status: WorkflowStatus, output?: any, error?: SerializedError): Promise<void>;
18
+ onChildWorkflowFailed(run: WorkflowRun, error: unknown): Promise<void>;
19
+ verifyStepName(stepName: unknown): void;
20
+ }
21
+ /**
22
+ * Hooks a host may install to decorate runs it did not start. Used by the
23
+ * scenario service to attach its own per-run state.
24
+ */
25
+ export interface WorkflowRunExtension {
26
+ attachRunContext(runId: string, workflowMeta: any, options?: Record<string, any>): Promise<void>;
27
+ detachRunContext(runId: string): void;
28
+ decorateRunWire(wire: PikkuRawWire, context: {
29
+ runId: string;
30
+ workflowMeta: any;
31
+ workflowWire: PikkuWorkflowWire;
32
+ }): void;
33
+ decorateWorkflowWire(workflowWire: PikkuWorkflowWire, context: {
34
+ name: string;
35
+ runId: string;
36
+ rpcService: any;
37
+ addonNamespace?: string | null;
38
+ }): void;
39
+ onBeforeRunFunc(context: RunLifecycleContext): Promise<void>;
40
+ onAfterRunFunc(context: RunLifecycleContext, outcome: 'completed' | 'failed' | 'interrupted', failure: unknown): Promise<void>;
41
+ }
42
+ /**
43
+ * Per-run bookkeeping held only for the lifetime of an in-process execution.
44
+ */
45
+ export type RunContext = {
46
+ activeExecutions: number;
47
+ inline?: boolean;
48
+ ordinals: Map<string, number>;
49
+ lastStep?: string;
50
+ replay?: {
51
+ steps?: Map<string, StepState>;
52
+ run?: WorkflowRun;
53
+ };
54
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,16 @@
1
+ import { ForbiddenError } from '../../errors/errors.js';
2
+ import type { CoreUserSession } from '../../types/core.types.js';
3
+ import type { WorkflowRunWire } from './workflow.types.js';
4
+ export declare class WorkflowRunForbiddenError extends ForbiddenError {
5
+ constructor();
6
+ }
7
+ /**
8
+ * A run started through a session records that session's user as its owner, and
9
+ * only that user may read it or answer its approval gates.
10
+ *
11
+ * A run with no recorded owner — started by a trigger, a scheduler, or a route
12
+ * wired without auth — has nobody to compare a caller against, so ownership is
13
+ * not a control that exists for it. Gate those with `auth` or `permissions` on
14
+ * the entrypoint instead.
15
+ */
16
+ export declare const assertWorkflowRunOwner: (wire: WorkflowRunWire | undefined, session: CoreUserSession | undefined) => void;
@@ -0,0 +1,29 @@
1
+ import { ForbiddenError } from '../../errors/errors.js';
2
+ import { addError } from '../../errors/error-handler.js';
3
+ export class WorkflowRunForbiddenError extends ForbiddenError {
4
+ constructor() {
5
+ super('Not authorized to access this workflow run');
6
+ }
7
+ }
8
+ addError(WorkflowRunForbiddenError, {
9
+ status: 403,
10
+ message: 'Not authorized to access this workflow run.',
11
+ });
12
+ /**
13
+ * A run started through a session records that session's user as its owner, and
14
+ * only that user may read it or answer its approval gates.
15
+ *
16
+ * A run with no recorded owner — started by a trigger, a scheduler, or a route
17
+ * wired without auth — has nobody to compare a caller against, so ownership is
18
+ * not a control that exists for it. Gate those with `auth` or `permissions` on
19
+ * the entrypoint instead.
20
+ */
21
+ export const assertWorkflowRunOwner = (wire, session) => {
22
+ const owner = wire?.pikkuUserId;
23
+ if (!owner) {
24
+ return;
25
+ }
26
+ if (!session?.userId || session.userId !== owner) {
27
+ throw new WorkflowRunForbiddenError();
28
+ }
29
+ };
@@ -0,0 +1,12 @@
1
+ import type { ApprovalStore } from './workflow-approval.js';
2
+ /** The durable step name a suspension point is recorded under. */
3
+ export declare const suspendStepNameFor: (reason: string) => string;
4
+ /** What the suspend gate needs from the workflow service. */
5
+ export type SuspendStore = Pick<ApprovalStore, 'getStepState' | 'insertStepState' | 'setStepRunning' | 'setStepResult'>;
6
+ /**
7
+ * Record a suspension point and unwind the run.
8
+ *
9
+ * A suspension that has already succeeded returns instead of throwing, so a
10
+ * replay walks past a gate the run has already passed through.
11
+ */
12
+ export declare const recordSuspension: (store: SuspendStore, runId: string, reason: string, stepName: string, fromStepName: string | undefined) => Promise<void>;
@@ -0,0 +1,33 @@
1
+ import { WorkflowSuspendedException } from './workflow-errors.js';
2
+ /** The durable step name a suspension point is recorded under. */
3
+ export const suspendStepNameFor = (reason) => `__workflow_suspend:${reason}`;
4
+ /**
5
+ * Record a suspension point and unwind the run.
6
+ *
7
+ * A suspension that has already succeeded returns instead of throwing, so a
8
+ * replay walks past a gate the run has already passed through.
9
+ */
10
+ export const recordSuspension = async (store, runId, reason, stepName, fromStepName) => {
11
+ const insert = () => store.insertStepState(runId, stepName, 'pikkuWorkflowSuspend', { reason }, undefined, fromStepName);
12
+ let stepState;
13
+ try {
14
+ stepState = await store.getStepState(runId, stepName);
15
+ }
16
+ catch {
17
+ stepState = await insert();
18
+ }
19
+ if (!stepState.stepId) {
20
+ stepState = await insert();
21
+ }
22
+ if (stepState.status === 'succeeded') {
23
+ return;
24
+ }
25
+ if (stepState.status === 'pending') {
26
+ await store.setStepRunning(stepState.stepId);
27
+ }
28
+ await store.setStepResult(stepState.stepId, {
29
+ reason,
30
+ suspendedAt: new Date().toISOString(),
31
+ });
32
+ throw new WorkflowSuspendedException(runId, reason);
33
+ };
@@ -49,6 +49,13 @@ export interface WorkflowRun {
49
49
  export interface StepState {
50
50
  stepId: string;
51
51
  status: StepStatus;
52
+ /**
53
+ * The function the workflow dispatched this step with, recorded so a worker
54
+ * can reject a queue message naming anything else. `null` is a step with no
55
+ * function of its own (inline work); `undefined` is a store that never
56
+ * recorded one, and cannot be compared against.
57
+ */
58
+ rpcName?: string | null;
52
59
  result?: any;
53
60
  error?: SerializedError;
54
61
  attemptCount: number;
@@ -0,0 +1,22 @@
1
+ ---
2
+ type: decision
3
+ title: A non-streaming agent run registers with aiRunState on the same terms as a streaming one
4
+ description: Otherwise interruptAIAgent finds the run, passes the ownership check, then cannot stop it — and reports that as if the run were on another host
5
+ tags: core, ai-agent
6
+ ---
7
+
8
+ # A non-streaming agent run registers with `aiRunState` too
9
+
10
+ `runAIAgent` registers its run with `aiRunState` exactly as `streamAIAgent`
11
+ does, even though nothing is streaming and there is no channel to interrupt.
12
+
13
+ `interruptAIAgent` resolves a run through `aiRunState` first, checks ownership,
14
+ then looks for a local abort handle. A run that skipped registration is invisible
15
+ at the first step. A run that registered but has no handle is visible, passes the
16
+ ownership check, and then cannot be stopped — which the interrupt path reports as
17
+ "running on another instance". That message would be wrong and actively
18
+ misleading: the run is right here, and the deployment is single-instance.
19
+
20
+ **What this rules out:** treating registration as a streaming concern. It is an
21
+ addressability concern, and the two paths have to be addressable the same way for
22
+ the interrupt path's diagnosis to mean anything.
@@ -0,0 +1,20 @@
1
+ ---
2
+ type: decision
3
+ title: A resumed turn is as interruptible as the first one
4
+ description: It is the same person listening to the same voice, and after an approval it is where most of the reply actually gets spoken
5
+ tags: core, ai-agent
6
+ ---
7
+
8
+ # A resumed turn is as interruptible as the first one
9
+
10
+ Resuming after an approval registers for interruption on the same terms as the
11
+ original turn.
12
+
13
+ It is the same person listening to the same voice, so the reason to allow
14
+ interruption has not changed. And after an approval is precisely where most of
15
+ the reply gets spoken: an approved delete is followed by the agent describing
16
+ what it did, which is a normal thing for a listener to talk over.
17
+
18
+ **What this rules out:** treating the resume as a short continuation not worth
19
+ wiring for interruption. By volume of speech it is usually the larger half of
20
+ the exchange.
@@ -0,0 +1,21 @@
1
+ ---
2
+ type: decision
3
+ title: A scenario step's prose template is offered to a virtual user unfilled
4
+ description: A reporter fills placeholders from a run that happened; there is no run yet, and the filled form would answer the question the user is there to answer
5
+ tags: core, virtual-user
6
+ ---
7
+
8
+ # A scenario step's prose template is offered unfilled
9
+
10
+ When a scenario step becomes a catalogue entry, its prose template goes in with
11
+ its placeholders intact — braces and all.
12
+
13
+ A reporter fills those placeholders from a run that already happened. There is
14
+ no run yet at derivation time, so there is nothing to fill them from. More
15
+ importantly, filling them would answer the wrong question: "invites {email}"
16
+ tells the user to choose someone, where "invites ada@example.com" tells it whom
17
+ — and *whom* is the scenario author's answer, not something the virtual user
18
+ worked out.
19
+
20
+ **What this rules out:** substituting example or fixture values to make the
21
+ catalogue read more naturally. It reads better and tests less.
@@ -0,0 +1,21 @@
1
+ ---
2
+ type: decision
3
+ title: A virtual user decides whether to trust its notes once per turn, by one roll
4
+ description: The difference between the stale, newcomer and auditor dispositions is expressed as a single probability rather than as prose in each prompt
5
+ tags: core, virtual-user
6
+ ---
7
+
8
+ # A virtual user decides whether to trust its notes once per turn
9
+
10
+ Each turn the run makes one weighted decision: does this user act on what it
11
+ already recorded, or go and look again.
12
+
13
+ That single roll is where the dispositions differ. `stale` almost always trusts
14
+ its notes — that is what makes it stale. A `newcomer` has none to trust. An
15
+ `auditor` re-checks nearly everything, which is the entire point of an auditor.
16
+ Expressing it as one probability keeps the difference between those runs in one
17
+ readable place instead of spread through three prompts as English that drifts.
18
+
19
+ **What this rules out:** encoding "you are suspicious of your own notes" into
20
+ each disposition's prompt text, where the behaviour becomes a property of how
21
+ the model reads prose rather than something the run controls and can report.
@@ -0,0 +1,46 @@
1
+ ---
2
+ type: decision
3
+ title: A wall-clock threshold is a load test in disguise
4
+ description: The KEK derivation test asserted a fixed 50ms budget for work that took 10ms, which went red about one run in five once the suite was large enough to compete for the machine
5
+ tags: core, testing, crypto
6
+ ---
7
+
8
+ # A wall-clock threshold is a load test in disguise
9
+
10
+ `crypto-utils.test.ts` guards a real property: unwrapping N secrets must not
11
+ cost N KEK derivations. Deriving a KEK is a deliberately slow KDF, so doing it
12
+ per secret turns a 10ms operation into a 4-second one.
13
+
14
+ It guarded it with `assert.ok(elapsed < 50)`. Measured on this machine:
15
+
16
+ | | |
17
+ | --- | --- |
18
+ | one `deriveKEK` | ~86ms |
19
+ | 50 `envelopeDecrypt` | 10.8ms |
20
+ | the old 50ms threshold | 4.6x headroom |
21
+ | the failure it guards against | ~4281ms — a **396x** separation |
22
+
23
+ So the assertion had 4.6x of margin to detect a 396x regression. Everything
24
+ between those two numbers was noise, and once core's suite reached ~2000 tests
25
+ competing for the same cores, a 10ms window drifting past 50ms became routine:
26
+ roughly one run in five went red, always on a machine where nothing was wrong.
27
+
28
+ **Calibrate against a measurement taken in the same run.** The test already
29
+ derives a KEK, so timing that call is free, and the assertion becomes
30
+ `elapsed < oneDerivation` — unwrapping all fifty must cost less than deriving
31
+ once. Under load both sides slow down together, so the ratio holds.
32
+
33
+ **What this rules out:** raising the constant. 100ms or 200ms buys a smaller
34
+ flake rate and the same class of bug, and it drifts again the next time the
35
+ suite grows or CI moves to a noisier runner. Any assertion of the form
36
+ "operation X takes less than N milliseconds" has this problem; express it as a
37
+ ratio against something measured alongside it.
38
+
39
+ Worth knowing: `envelopeDecrypt(kek: CryptoKey, …)` takes the derived key as a
40
+ parameter, so it *cannot* derive one — the property is already enforced by the
41
+ signature, and [[the-api-report-pins-members-not-just-names]] would catch a
42
+ change to it. The test is defence in depth, which is a reason to make it cheap
43
+ and quiet rather than to delete it.
44
+
45
+ Related: [[an-unref-d-timer-cannot-be-awaited-under-node-test]], the other
46
+ source of nondeterminism found in the same pass.
@@ -0,0 +1,45 @@
1
+ ---
2
+ type: decision
3
+ title: A workflow's wire is built from the run record, not from the RPC service
4
+ description: The RPC service exposes no wire, so every rpcService.wire read was undefined; the run record is the only thing that carries the caller across a step boundary
5
+ tags: core, workflow
6
+ ---
7
+
8
+ # A workflow's wire is built from the run record, not from the RPC service
9
+
10
+ `PikkuWorkflowService` builds a fresh wire each time it runs a workflow body or
11
+ starts a child. For a while it filled parts of that wire from the RPC service it
12
+ had been handed:
13
+
14
+ ```ts
15
+ session: rpcService?.wire?.session,
16
+ rpc: rpcService?.wire?.rpc,
17
+ pikkuUserId: rpcService.wire?.pikkuUserId,
18
+ ```
19
+
20
+ `PikkuRPC` has no `wire`. Neither does the object `getContextRPCService`
21
+ actually returns — `ContextAwareRPCService` holds its wire *privately* and
22
+ exposes `invoke`, `remote`, `exposed`, `startWorkflow`, `agent` and
23
+ `rpcWithWire`, and nothing else. Every one of those reads was `undefined`, and
24
+ the `rpcService: any` parameter type is what kept the compiler quiet about it.
25
+
26
+ The consequence was silent and one-directional: a child workflow started from a
27
+ step never inherited the `pikkuUserId` its parent was running as, so a queued
28
+ child ran as nobody. Nothing failed — the field was simply absent, and the run
29
+ proceeded.
30
+
31
+ **The run record is the carrier.** `WorkflowRun.wire` is durable, is written
32
+ when the run is created, and survives the process boundary a queued step
33
+ crosses, which is exactly what a live service reference cannot do. Both the
34
+ run-body wire and the child-run wire now read `run.wire?.pikkuUserId`.
35
+
36
+ `session` and `rpc` are not copied at all. `runPikkuFunc` attaches `rpc` lazily
37
+ for the duration of an invocation and restores the previous descriptor
38
+ afterwards, and it resolves the session from the session store using
39
+ `pikkuUserId` — so both were being overwritten moments later anyway. The wire
40
+ these paths construct is a `PikkuRawWire` for that reason: it genuinely has no
41
+ `rpc` yet, and saying so is what let the dead reads be found.
42
+
43
+ **What this rules out:** reaching for the RPC service to answer "who is this
44
+ running as". It cannot answer, and the shape of the question hides that.
45
+ Anything a step needs to know about its caller has to be on the run.
@@ -0,0 +1,21 @@
1
+ ---
2
+ type: decision
3
+ title: Loading agent context waits for a tool result that may still be landing
4
+ description: An interrupted run's tool can still be writing to the thread, and in voice the next turn arrives within seconds — soon enough to load context missing what it is about to be asked about
5
+ tags: core, ai-agent
6
+ ---
7
+
8
+ # Loading agent context waits for a tool result that may still be landing
9
+
10
+ Before a turn loads its thread, it settles any tool whose run was interrupted
11
+ but whose result is still being written to that thread.
12
+
13
+ The window is small and, on a typed interface, usually irrelevant — a person
14
+ takes seconds to write the next message. In voice it is not: the next turn lands
15
+ within a second or two of the last one. Without the wait, the model loads a
16
+ thread that is missing the very result the user is about to ask about, and
17
+ answers as though the tool never ran.
18
+
19
+ **What this rules out:** treating the settle as belt-and-braces and dropping it
20
+ to save a round trip. The failure it prevents is a confidently wrong answer, not
21
+ an error, and it only appears on the fastest transport.
@@ -0,0 +1,22 @@
1
+ ---
2
+ type: decision
3
+ title: Agent speech travels as a CUSTOM AG-UI event rather than being dropped
4
+ description: AG-UI has no speech event, and dropping it makes a voice agent reached over HTTP silently inaudible while the provider still bills for the audio
5
+ tags: core, ai-agent
6
+ ---
7
+
8
+ # Agent speech travels as a CUSTOM AG-UI event
9
+
10
+ The AG-UI protocol has no event type for synthesized speech, so the bridge
11
+ forwards it as `CUSTOM`, the same way it forwards the other pikku-specific
12
+ events.
13
+
14
+ The alternative was tried: the mapper dropped the event, on the reasoning that a
15
+ protocol without a speech event has no way to carry one. The result is a voice
16
+ agent reached over HTTP that is completely silent — `voiceOutput` synthesizes
17
+ every sentence, the provider bills for every one of them, and none of it gets
18
+ past the mapper. Nothing errors, so there is nothing to find.
19
+
20
+ **What this rules out:** filtering unknown event types at the AG-UI boundary as
21
+ a tidiness measure. `CUSTOM` exists precisely so a protocol gap degrades to
22
+ "the client ignores it" rather than "the server threw the work away".
@@ -0,0 +1,23 @@
1
+ ---
2
+ type: decision
3
+ title: An interrupt is not a failure, and the non-streaming path throws rather than returning
4
+ description: It skips the onError hooks and never becomes an errorMessage; with no partial reply to hand back, a typed throw is what distinguishes it from a provider outage
5
+ tags: core, ai-agent
6
+ ---
7
+
8
+ # An interrupt is not a failure
9
+
10
+ Interrupting a run skips the `onError` hooks entirely and never becomes an
11
+ `errorMessage`. Someone asked the agent to stop and it stopped; that is the
12
+ feature working, and running failure handlers over it would report an incident
13
+ that did not happen.
14
+
15
+ The streaming and non-streaming paths then diverge, because what they can hand
16
+ back differs. A stream has already delivered part of the reply, so it returns
17
+ that fragment. `runAIAgent` has delivered nothing — there is no partial answer to
18
+ return — so it throws a typed error instead. A caller can tell that apart from a
19
+ provider outage, which returning an empty result would not allow.
20
+
21
+ **What this rules out:** unifying the two paths on "return whatever you have".
22
+ For the non-streaming path that is an empty string, and an empty string is
23
+ indistinguishable from a model that answered with nothing.
@@ -0,0 +1,25 @@
1
+ ---
2
+ type: decision
3
+ title: An interrupt for a run owned by another instance says so, rather than returning false
4
+ description: A bare false is indistinguishable from "already finished", which is the one deployment shape the in-process registry cannot cover
5
+ tags: core, ai-agent
6
+ ---
7
+
8
+ # An interrupt for a run owned by another instance says so
9
+
10
+ `interruptAIAgent` resolves a run through `aiRunState`, then tries to abort it
11
+ via the in-process registry. A run still marked `running` that this process has
12
+ no abort handle for is executing on another instance.
13
+
14
+ Returning a bare `false` there is indistinguishable from "the run already
15
+ finished" — the ordinary, uninteresting outcome. So the single deployment shape
16
+ the in-process registry does not cover, multi-instance, fails silently: an agent
17
+ that will not stop talking, and nothing in the logs to say why. The call reports
18
+ the condition instead.
19
+
20
+ **What this rules out:** collapsing the two outcomes into one boolean because
21
+ the caller "only cares whether it stopped". The caller cares a great deal about
22
+ the difference between *stopped* and *cannot be stopped from here*.
23
+
24
+ The fix for the underlying gap is `signalRunInterrupt`, which fans the interrupt
25
+ out over `eventHub` so every instance tries locally.
@@ -0,0 +1,22 @@
1
+ ---
2
+ type: decision
3
+ title: A wrapped agent-stream send must return the inner send's promise
4
+ description: Middleware runs asynchronously, so dropping the returned promise turns every awaited channel.send upstream into a no-op
5
+ tags: core, ai-agent
6
+ ---
7
+
8
+ # A wrapped agent-stream send must return the inner send's promise
9
+
10
+ `streamAIAgent` wraps the caller's channel so every event passes through the
11
+ stream middleware. That wrapper's `send` returns whatever the inner `send`
12
+ returns, and it must: the middleware chain is asynchronous, so a wrapper that
13
+ calls the inner `send` and returns `undefined` resolves immediately while the
14
+ event is still in flight.
15
+
16
+ Every `await channel.send(...)` upstream then becomes a no-op that resolves
17
+ before the thing it is waiting for has happened. The one that matters is the
18
+ final flush — a buffering hook such as `voiceOutput` is still synthesizing audio
19
+ when the awaited send resolves, and the `close()` that follows discards it.
20
+
21
+ **What this rules out:** writing the wrapper as a fire-and-forget `(msg) => {
22
+ inner.send(msg) }`, which reads as equivalent and is not.
@@ -0,0 +1,20 @@
1
+ ---
2
+ type: decision
3
+ title: A message with nothing to say carries no text part at all
4
+ description: An attachment on its own is a real turn, and providers are entitled to reject an empty text part sitting beside it
5
+ tags: core, ai-agent
6
+ ---
7
+
8
+ # A message with nothing to say carries no text part
9
+
10
+ When a turn has no text, the text part is omitted rather than included as an
11
+ empty string.
12
+
13
+ An attachment on its own is a legitimate turn — a spoken one carries audio and
14
+ no text whatsoever — so "no text" is a normal state, not a degenerate one.
15
+ Providers are entitled to reject a message part with empty content, and that
16
+ rejection would land on a caller who never wrote any text to begin with.
17
+
18
+ **What this rules out:** normalising the text to `''` for a uniform message
19
+ shape. Uniformity here buys nothing and costs a provider error on the one turn
20
+ type that most needs to work.
@@ -0,0 +1,22 @@
1
+ ---
2
+ type: decision
3
+ title: A transcript is recorded only when something was actually heard
4
+ description: Recording an empty string sends a transcript event saying the user said nothing, which renders as an empty bubble rather than a pending one
5
+ tags: core, ai-agent
6
+ ---
7
+
8
+ # A transcript is recorded only when something was actually heard
9
+
10
+ A turn can carry audio that reads entirely as non-speech and still have content
11
+ — an image with a silent caption clip — so there is nothing above to throw.
12
+ `voiceInput` records the transcript only when speech was found.
13
+
14
+ Writing `''` instead would emit a transcript event asserting that the user said
15
+ nothing. A client that distinguishes "not transcribed yet" from "transcribed"
16
+ by whether the key is present would then render that turn as a permanently
17
+ empty bubble rather than a pending one — a worse outcome than showing nothing,
18
+ because it looks settled.
19
+
20
+ **What this rules out:** defaulting the transcript to an empty string for a
21
+ uniform event shape. Absence and emptiness mean different things to the client,
22
+ and only absence is recoverable.