okengine 0.22.0 → 0.23.1

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 (312) hide show
  1. package/AGENTS.md +7 -0
  2. package/manifest.v1.schema.json +44 -0
  3. package/package.json +32 -19
  4. package/site/content/docs/ai/mcp.mdx +14 -10
  5. package/site/content/docs/ai/skills.mdx +1 -1
  6. package/site/content/docs/elements/ai/agents.mdx +61 -13
  7. package/site/content/docs/elements/ai/decide.mdx +536 -0
  8. package/site/content/docs/elements/ai/events.mdx +373 -0
  9. package/site/content/docs/elements/ai/index.mdx +9 -3
  10. package/site/content/docs/elements/ai/meta.json +1 -1
  11. package/site/content/docs/elements/ai/prompts.mdx +4 -2
  12. package/site/content/docs/elements/channel/index.mdx +4 -4
  13. package/site/content/docs/elements/channel/receipts.mdx +7 -5
  14. package/site/content/docs/elements/channel/sms.mdx +2 -1
  15. package/site/content/docs/elements/flow/index.mdx +22 -18
  16. package/site/content/docs/elements/gate/auth.mdx +4 -1
  17. package/site/content/docs/elements/signal/index.mdx +15 -14
  18. package/site/content/docs/elements/store/index.mdx +1 -1
  19. package/site/content/docs/elements/store/kv.mdx +5 -5
  20. package/site/content/docs/plugins/otp.mdx +3 -3
  21. package/site/content/docs/recipes/dragonfly.mdx +5 -5
  22. package/site/content/docs/recipes/index.mdx +6 -6
  23. package/site/content/docs/recipes/postgres.mdx +12 -8
  24. package/site/content/docs/recipes/redis.mdx +4 -4
  25. package/site/content/docs/reference/cli.mdx +2 -1
  26. package/site/content/docs/reference/configuration.mdx +9 -7
  27. package/site/content/docs/reference/fx.mdx +19 -10
  28. package/src/auth/api-key-sql.test.ts +44 -0
  29. package/src/auth/api-key-sql.ts +1 -0
  30. package/src/auth/api-keys.ts +30 -12
  31. package/src/auth/config.ts +6 -2
  32. package/src/auth/gate-auth.test.ts +20 -0
  33. package/src/auth/tenants.ts +17 -0
  34. package/src/cli/agents-md.test.ts +25 -0
  35. package/src/cli/ask-vault-gaps.test.ts +38 -1
  36. package/src/cli/ask-vault-gaps.ts +35 -2
  37. package/src/cli/decide.ts +174 -0
  38. package/src/cli/decision-lock-watch.test.ts +45 -0
  39. package/src/cli/decision-lock-watch.ts +51 -0
  40. package/src/cli/dev-app-runner.ts +34 -8
  41. package/src/cli/dev-auth-secret.test.ts +49 -0
  42. package/src/cli/dev-auth-secret.ts +49 -0
  43. package/src/cli/dev-hot.test.ts +41 -0
  44. package/src/cli/dev-hot.ts +48 -0
  45. package/src/cli/dev.test.ts +1 -1
  46. package/src/cli/dev.ts +24 -6
  47. package/src/cli/doctor-diff.test.ts +29 -1
  48. package/src/cli/doctor-diff.ts +35 -0
  49. package/src/cli/eval.ts +142 -1
  50. package/src/cli/index.ts +5 -0
  51. package/src/cli/load-config.images.test.ts +2 -2
  52. package/src/cli/load-config.ts +1 -1
  53. package/src/cli/manifest-pr-diff.ts +16 -0
  54. package/src/cli/mcp-from-console.ts +48 -0
  55. package/src/cli/registry.ts +35 -1
  56. package/src/client/agent.ts +307 -0
  57. package/src/client/transport.test.ts +20 -0
  58. package/src/client/transport.ts +2 -1
  59. package/src/client/types.ts +1 -1
  60. package/src/client-react/index.ts +7 -1
  61. package/src/client-react/use-agent-run.test.ts +271 -0
  62. package/src/client-react/use-agent-run.ts +193 -0
  63. package/src/compiler/ai-approval.test.ts +36 -0
  64. package/src/compiler/ai-repair.test.ts +22 -0
  65. package/src/compiler/decisions.extract.test.ts +164 -0
  66. package/src/compiler/effects-fetch.test.ts +77 -0
  67. package/src/compiler/effects-infer.ts +114 -17
  68. package/src/compiler/effects-join.test.ts +48 -0
  69. package/src/compiler/extract.test.ts +28 -0
  70. package/src/compiler/extract.ts +279 -13
  71. package/src/compiler/fx-index.ts +58 -4
  72. package/src/compiler/response.ts +35 -2
  73. package/src/config/index.ts +21 -3
  74. package/src/console/index.ts +1 -1
  75. package/src/console/server/ai-runs-flows.ts +269 -0
  76. package/src/console/server/ai-runs.test.ts +323 -0
  77. package/src/console/server/ai-runs.ts +562 -0
  78. package/src/console/server/ai.ts +12 -1
  79. package/src/console/server/app.ts +10 -1
  80. package/src/console/server/decisions-flows.ts +157 -0
  81. package/src/console/server/decisions.test.ts +66 -0
  82. package/src/console/server/decisions.ts +229 -0
  83. package/src/console/server/flows.ts +50 -2
  84. package/src/console/server/invoke-user-flow.ts +9 -0
  85. package/src/console/server/runs-ingest.ts +16 -0
  86. package/src/console/server/serve.ts +5 -0
  87. package/src/console/server/state.ts +25 -1
  88. package/src/console/server/store-stats.test.ts +47 -0
  89. package/src/console/server/store-stats.ts +92 -18
  90. package/src/console/ui-next/dist/assets/access-page-C_N4qvai.js +4 -0
  91. package/src/console/ui-next/dist/assets/{agent-disclosure-Bkohc_8X.js → agent-disclosure-Cq1TxniW.js} +1 -1
  92. package/src/console/ui-next/dist/assets/{cache-glyph-rL0lHxat.js → cache-glyph-BaI4uxZz.js} +1 -1
  93. package/src/console/ui-next/dist/assets/{call-pii-button-Deo4PP18.js → call-pii-button-DNyK7IIY.js} +1 -1
  94. package/src/console/ui-next/dist/assets/{collapsible-CoJ6amHf.js → collapsible-CRhBJMZR.js} +1 -1
  95. package/src/console/ui-next/dist/assets/confirm-sheet-DZfuJXP4.js +1 -0
  96. package/src/console/ui-next/dist/assets/copy-inline-button-BTjPVjRT.js +1 -0
  97. package/src/console/ui-next/dist/assets/decisions-page-D9twj8Xy.js +1 -0
  98. package/src/console/ui-next/dist/assets/detail-header-Dk5LipF6.js +1 -0
  99. package/src/console/ui-next/dist/assets/dropdown-menu-CRGrj5JU.js +1 -0
  100. package/src/console/ui-next/dist/assets/duration-tone-CsHWlVPU.js +9 -0
  101. package/src/console/ui-next/dist/assets/element-icons-C6t8Vlml.js +1 -0
  102. package/src/console/ui-next/dist/assets/explorer-chrome-Dslh2yE8.js +1 -0
  103. package/src/console/ui-next/dist/assets/explorer-empty-CIVg7Jgb.js +1 -0
  104. package/src/console/ui-next/dist/assets/flows-page-ZaTJAhR9.js +1 -0
  105. package/src/console/ui-next/dist/assets/{highlighted-json-CPYBzDh0.js → highlighted-json-DQ-EMAfz.js} +1 -1
  106. package/src/console/ui-next/dist/assets/{http-method-DTPevKmM.js → http-method-Cn_Fl79s.js} +1 -1
  107. package/src/console/ui-next/dist/assets/index-BlrN49Hs.css +2 -0
  108. package/src/console/ui-next/dist/assets/index-CmRFeWdT.js +58 -0
  109. package/src/console/ui-next/dist/assets/observability-page-Cn6R_dXz.js +8 -0
  110. package/src/console/ui-next/dist/assets/{replica-lag-BM27lXld.js → replica-lag-Bx6EZhSe.js} +7 -7
  111. package/src/console/ui-next/dist/assets/request-meta-DmdiUrfh.js +1 -0
  112. package/src/console/ui-next/dist/assets/shortcut-keys-DTE1sjTC.js +1 -0
  113. package/src/console/ui-next/dist/assets/store-page-8P6vGXnn.js +41 -0
  114. package/src/console/ui-next/dist/assets/trace-detail-sheet-BfGvpFSv.js +2 -0
  115. package/src/console/ui-next/dist/assets/tree-expand-toggle-CWdUHbc0.js +55 -0
  116. package/src/console/ui-next/dist/assets/units-page-CZOFZzay.js +1 -0
  117. package/src/console/ui-next/dist/assets/use-vault-list-BAyQG8Re.js +1 -0
  118. package/src/console/ui-next/dist/assets/vault-page-ki_ishmX.js +2 -0
  119. package/src/console/ui-next/dist/index.html +4 -3
  120. package/src/console/ui-next/seed-parked-approval.ts +126 -0
  121. package/src/console/ui-next/src/client.ts +224 -1
  122. package/src/console/ui-next/src/components/explorer/detail-header.tsx +9 -0
  123. package/src/console/ui-next/src/components/explorer/explorer-start-toggle.tsx +5 -1
  124. package/src/console/ui-next/src/components/ui/sheet-form.tsx +21 -12
  125. package/src/console/ui-next/src/features/flows/decisions/decisions-page.tsx +216 -0
  126. package/src/console/ui-next/src/features/flows/decisions/resolve.test.ts +48 -0
  127. package/src/console/ui-next/src/features/flows/decisions/resolve.ts +111 -0
  128. package/src/console/ui-next/src/features/flows/flows-page.tsx +10 -0
  129. package/src/console/ui-next/src/features/flows/graph/element-map.ts +1 -0
  130. package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +6 -0
  131. package/src/console/ui-next/src/features/flows/traces/effect-summary.test.ts +24 -0
  132. package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
  133. package/src/console/ui-next/src/features/flows/traces/http-status.ts +58 -0
  134. package/src/console/ui-next/src/features/flows/traces/request-client-card.tsx +355 -0
  135. package/src/console/ui-next/src/features/flows/traces/request-client.test.ts +114 -0
  136. package/src/console/ui-next/src/features/flows/traces/request-client.ts +423 -0
  137. package/src/console/ui-next/src/features/flows/traces/request-input-view.test.ts +52 -6
  138. package/src/console/ui-next/src/features/flows/traces/request-input-view.ts +85 -19
  139. package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +505 -80
  140. package/src/console/ui-next/src/features/flows/traces/trace-lanes.test.ts +61 -0
  141. package/src/console/ui-next/src/features/flows/traces/trace-lanes.ts +78 -0
  142. package/src/console/ui-next/src/features/flows/traces/trace-nav.test.ts +48 -0
  143. package/src/console/ui-next/src/features/flows/traces/trace-nav.ts +57 -0
  144. package/src/console/ui-next/src/features/flows/traces/trace-request-section.tsx +275 -96
  145. package/src/console/ui-next/src/features/flows/traces/traces-pane.tsx +16 -2
  146. package/src/console/ui-next/src/features/observability/detail/ai-runs.tsx +369 -0
  147. package/src/console/ui-next/src/features/observability/observability-page.tsx +31 -1
  148. package/src/console/ui-next/src/features/observability/state/observability-selection.ts +18 -3
  149. package/src/console/ui-next/src/features/store/performance/kv-performance-panel.tsx +12 -1
  150. package/src/console/ui-next/src/features/store/performance/performance-panel.tsx +14 -1
  151. package/src/console/ui-next/src/features/units/call/call-api-panel.tsx +1 -1
  152. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +6 -2
  153. package/src/console/ui-next/src/features/units/explorer/units-tree.tsx +73 -18
  154. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +21 -0
  155. package/src/console/ui-next/src/features/units/lib/unit-tree.ts +82 -0
  156. package/src/console/ui-next/src/features/units/units-page.tsx +25 -7
  157. package/src/console/ui-next/src/router.tsx +17 -0
  158. package/src/console/ui-next/ui-next-seed-runs.ts +23 -0
  159. package/src/console/ui-next/vite-console-kernel-plugin.ts +43 -3
  160. package/src/console/ui-next/vite.config.ts +9 -0
  161. package/src/docker/recipes/dragonfly.ts +3 -2
  162. package/src/docker/recipes/redis.ts +1 -1
  163. package/src/drivers/ai-anthropic.ts +212 -21
  164. package/src/drivers/ai-mock.ts +34 -1
  165. package/src/drivers/ai-openai-compatible.ts +67 -26
  166. package/src/drivers/ai-preconnect.ts +18 -0
  167. package/src/drivers/ai-stream.test.ts +62 -0
  168. package/src/drivers/ai-types.ts +20 -0
  169. package/src/drivers/journal-postgres.test.ts +22 -1
  170. package/src/drivers/journal-postgres.ts +511 -13
  171. package/src/drivers/memory-ddl.test.ts +18 -0
  172. package/src/drivers/memory.ts +21 -1
  173. package/src/drivers/pg-rls.test.ts +31 -0
  174. package/src/drivers/pg-rls.ts +15 -0
  175. package/src/drivers/postgres.test.ts +9 -0
  176. package/src/drivers/postgres.ts +49 -11
  177. package/src/drivers/signal-compete.test.ts +234 -0
  178. package/src/drivers/signal-postgres.ts +39 -19
  179. package/src/drivers/signal-redis.ts +167 -11
  180. package/src/drivers/signal-types.ts +12 -0
  181. package/src/elements/ai/agent-event-slot.ts +27 -0
  182. package/src/elements/ai/approval-http.ts +207 -0
  183. package/src/elements/ai/approval.test.ts +741 -0
  184. package/src/elements/ai/approval.ts +228 -0
  185. package/src/elements/ai/decisions/bind.ts +163 -0
  186. package/src/elements/ai/decisions/certificate.test.ts +291 -0
  187. package/src/elements/ai/decisions/certificate.ts +377 -0
  188. package/src/elements/ai/decisions/certify.ts +314 -0
  189. package/src/elements/ai/decisions/decide.test.ts +953 -0
  190. package/src/elements/ai/decisions/e2e.test.ts +399 -0
  191. package/src/elements/ai/decisions/export.test.ts +120 -0
  192. package/src/elements/ai/decisions/export.ts +146 -0
  193. package/src/elements/ai/decisions/fixtures/openrouter-request.json +24 -0
  194. package/src/elements/ai/decisions/fixtures/openrouter-response.json +22 -0
  195. package/src/elements/ai/decisions/fixtures/typesafe-request.json +19 -0
  196. package/src/elements/ai/decisions/fixtures/typesafe-response.json +13 -0
  197. package/src/elements/ai/decisions/http.test.ts +154 -0
  198. package/src/elements/ai/decisions/http.ts +240 -0
  199. package/src/elements/ai/decisions/labels.test.ts +282 -0
  200. package/src/elements/ai/decisions/labels.ts +257 -0
  201. package/src/elements/ai/decisions/openrouter.live.test.ts +136 -0
  202. package/src/elements/ai/decisions/openrouter.ts +36 -0
  203. package/src/elements/ai/decisions/provider.ts +102 -0
  204. package/src/elements/ai/decisions/typesafe.ts +36 -0
  205. package/src/elements/ai/declare.ts +252 -1
  206. package/src/elements/ai/events.test.ts +379 -0
  207. package/src/elements/ai/events.ts +138 -0
  208. package/src/elements/ai/mcp-protocol.ts +5 -6
  209. package/src/elements/ai/run-events.test.ts +461 -0
  210. package/src/elements/ai/run-events.ts +522 -0
  211. package/src/elements/ai/runtime.ts +1187 -105
  212. package/src/elements/ai/stream-turn.ts +214 -0
  213. package/src/elements/ai/stream.live.test.ts +76 -0
  214. package/src/elements/ai/subagent.test.ts +136 -0
  215. package/src/elements/ai.test.ts +497 -3
  216. package/src/elements/ai.ts +16 -1
  217. package/src/elements/channel/runtime.ts +11 -0
  218. package/src/elements/channel/sql-ledger.ts +251 -0
  219. package/src/elements/clock/durable.ts +27 -1
  220. package/src/elements/signal/runtime.ts +9 -0
  221. package/src/elements/signal.test.ts +1 -0
  222. package/src/elements/store/cache.test.ts +120 -15
  223. package/src/elements/store/cache.ts +200 -39
  224. package/src/elements/store/prepare-row.test.ts +6 -2
  225. package/src/elements/store/sql-session.test.ts +53 -0
  226. package/src/elements/store/sql-session.ts +206 -10
  227. package/src/elements/store.ts +1 -0
  228. package/src/i18n/catalogs/ar.ts +5 -0
  229. package/src/i18n/catalogs/en.ts +5 -0
  230. package/src/index.ts +2 -0
  231. package/src/kernel/agent-event-store.ts +448 -0
  232. package/src/kernel/app.ts +135 -17
  233. package/src/kernel/auto-cache.test.ts +228 -0
  234. package/src/kernel/boot-bind/ai.ts +3 -0
  235. package/src/kernel/boot-bind/channel.ts +63 -2
  236. package/src/kernel/boot-bind/honor-config.test.ts +40 -10
  237. package/src/kernel/boot-bind/signal.ts +61 -25
  238. package/src/kernel/boot-bind/store.test.ts +26 -0
  239. package/src/kernel/boot-bind/store.ts +82 -6
  240. package/src/kernel/boot.ts +31 -6
  241. package/src/kernel/builtin-errors.ts +2 -0
  242. package/src/kernel/capability.ts +3 -0
  243. package/src/kernel/decision-label-store.ts +441 -0
  244. package/src/kernel/effects.test.ts +2 -1
  245. package/src/kernel/effects.ts +13 -1
  246. package/src/kernel/element-registries.ts +3 -0
  247. package/src/kernel/errors-compiler.ts +11 -0
  248. package/src/kernel/errors-text.ts +20 -0
  249. package/src/kernel/errors.ts +10 -0
  250. package/src/kernel/flow.test.ts +2 -3
  251. package/src/kernel/flow.ts +33 -3
  252. package/src/kernel/fx-call-types.test.ts +32 -0
  253. package/src/kernel/fx-decide.ts +765 -0
  254. package/src/kernel/fx-sql-handle.ts +46 -7
  255. package/src/kernel/fx.test.ts +13 -8
  256. package/src/kernel/fx.ts +339 -45
  257. package/src/kernel/http-frame.test.ts +94 -0
  258. package/src/kernel/http-frame.ts +203 -0
  259. package/src/kernel/idempotency.test.ts +2 -1
  260. package/src/kernel/journal.test.ts +114 -0
  261. package/src/kernel/journal.ts +471 -51
  262. package/src/kernel/json-result.ts +10 -0
  263. package/src/kernel/pipeline-tenant.ts +5 -1
  264. package/src/kernel/pipeline.test.ts +45 -0
  265. package/src/kernel/pipeline.ts +5 -0
  266. package/src/kernel/signal-tx.ts +28 -0
  267. package/src/kernel/sse-id.ts +32 -0
  268. package/src/kernel/store-transaction.test.ts +95 -0
  269. package/src/manifest/diff.ts +1 -0
  270. package/src/manifest/types.ts +29 -2
  271. package/src/mcp/ai-tools.test.ts +202 -0
  272. package/src/mcp/authorization.ts +66 -0
  273. package/src/mcp/docs-server.ts +19 -2
  274. package/src/mcp/mcp.test.ts +83 -0
  275. package/src/mcp/protocol.ts +8 -2
  276. package/src/mcp/server.ts +23 -2
  277. package/src/mcp/session.ts +3 -0
  278. package/src/mcp/tools.ts +168 -0
  279. package/src/mcp/versions.ts +60 -0
  280. package/src/plugins/index.ts +1 -0
  281. package/src/plugins/mena.ts +56 -0
  282. package/src/release/http-graph.test.ts +2 -0
  283. package/src/release/http-graph.ts +5 -0
  284. package/src/release/measure.ts +39 -14
  285. package/src/runs/collect.ts +4 -1
  286. package/src/runs/parquet.test.ts +27 -1
  287. package/src/runs/parquet.ts +19 -0
  288. package/src/runs/types.ts +31 -0
  289. package/src/runtime/json-code-block.test.ts +106 -47
  290. package/src/runtime/json-code-block.ts +1209 -46
  291. package/src/term.test.ts +13 -0
  292. package/src/term.ts +81 -49
  293. package/src/console/ui-next/dist/assets/PlusSignIcon-CwG3nxfu.js +0 -1
  294. package/src/console/ui-next/dist/assets/access-page-Ze6ckjdd.js +0 -4
  295. package/src/console/ui-next/dist/assets/copy-inline-button-CWG5_ZOh.js +0 -1
  296. package/src/console/ui-next/dist/assets/detail-header-b_ZzIsmU.js +0 -1
  297. package/src/console/ui-next/dist/assets/dropdown-menu-nwXEO1Ac.js +0 -1
  298. package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +0 -9
  299. package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +0 -1
  300. package/src/console/ui-next/dist/assets/explorer-empty-C8sSCqYT.js +0 -1
  301. package/src/console/ui-next/dist/assets/flows-page-D2E7BtKK.js +0 -1
  302. package/src/console/ui-next/dist/assets/index-VxoEz295.css +0 -2
  303. package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +0 -58
  304. package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +0 -4
  305. package/src/console/ui-next/dist/assets/request-meta-BjFT7DNl.js +0 -1
  306. package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +0 -1
  307. package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +0 -41
  308. package/src/console/ui-next/dist/assets/trace-detail-sheet-Cmitq3da.js +0 -2
  309. package/src/console/ui-next/dist/assets/tree-expand-toggle-CbDIB-7x.js +0 -55
  310. package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +0 -1
  311. package/src/console/ui-next/dist/assets/use-vault-list-Cs45Czkt.js +0 -1
  312. package/src/console/ui-next/dist/assets/vault-page-BOMr0go9.js +0 -2
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Emit still enrols in a transactional outbox (semantics never regress).
5
5
  * After commit, a relay pushes to Redis Streams (`once`) or pub/sub
6
- * (`broadcast` / `live`). Consumer delivery is driven from the outbox so
7
- * exactly-once / fan-out physics stay correct under `drain()`.
6
+ * (`broadcast` / `live`). With `compete: true`, `once` delivery is
7
+ * `XREADGROUP` / `XACK` (pending reclaim on the next drain). Broadcast
8
+ * and live stay on the outbox plus pub/sub.
8
9
  *
9
10
  * Production bind: {@link createBunSignalRedisClient} (typed `publish` /
10
11
  * `subscribe` / `xadd` / `xreadgroup` / `xack` / `xgroup` on Bun ≥1.4).
@@ -12,8 +13,12 @@
12
13
 
13
14
  import { createSignalEngine } from "./signal-engine.ts";
14
15
  import type {
16
+ DeadLetter,
15
17
  SignalBus,
16
18
  SignalDriver,
19
+ SignalFailureReason,
20
+ SignalHandler,
21
+ SignalMessage,
17
22
  SignalOpenOptions,
18
23
  SignalRedisClientLike,
19
24
  } from "./signal-types.ts";
@@ -68,6 +73,32 @@ export function createBunSignalRedisClient(url?: string): SignalRedisClientLike
68
73
  }
69
74
  },
70
75
  async xreadgroup(group, consumer, key, count) {
76
+ try {
77
+ await redis.send("XAUTOCLAIM", [
78
+ key,
79
+ group,
80
+ consumer,
81
+ "5000",
82
+ "0-0",
83
+ "COUNT",
84
+ String(count),
85
+ ]);
86
+ } catch {
87
+ /* Idle reclaim is best-effort; same-consumer pending still returns below. */
88
+ }
89
+ const pending = parseXreadgroupReply(
90
+ await typedStreams(redis).xreadgroup(
91
+ "GROUP",
92
+ group,
93
+ consumer,
94
+ "COUNT",
95
+ count,
96
+ "STREAMS",
97
+ key,
98
+ "0",
99
+ ),
100
+ );
101
+ if (pending.length > 0) return pending;
71
102
  const reply = await typedStreams(redis).xreadgroup(
72
103
  "GROUP",
73
104
  group,
@@ -138,6 +169,10 @@ export function createSignalRedisFake(): SignalRedisClientLike & {
138
169
  const streams = new Map<string, Array<{ id: string; fields: Record<string, string> }>>();
139
170
  const groups = new Map<string, Set<string>>();
140
171
  const claimed = new Map<string, Set<string>>();
172
+ const pending = new Map<
173
+ string,
174
+ Array<{ id: string; fields: Record<string, string>; consumer: string }>
175
+ >();
141
176
  const subs = new Map<string, Set<(message: string) => void>>();
142
177
  const published: Array<{ channel: string; message: string }> = [];
143
178
  let seq = 0;
@@ -160,6 +195,7 @@ export function createSignalRedisFake(): SignalRedisClientLike & {
160
195
  const gkey = `${key}::${group}`;
161
196
  if (!groups.has(gkey)) groups.set(gkey, new Set());
162
197
  if (!claimed.has(gkey)) claimed.set(gkey, new Set());
198
+ if (!pending.has(gkey)) pending.set(gkey, []);
163
199
  },
164
200
  async xreadgroup(group, consumer, key, count) {
165
201
  const gkey = `${key}::${group}`;
@@ -167,21 +203,36 @@ export function createSignalRedisFake(): SignalRedisClientLike & {
167
203
  await this.xgroupCreate(key, group, "0", { mkstream: true });
168
204
  }
169
205
  const seen = claimed.get(gkey)!;
206
+ const held = pending.get(gkey)!;
170
207
  const list = streams.get(key) ?? [];
171
208
  const out: Array<{ id: string; fields: Record<string, string> }> = [];
209
+ for (const entry of held) {
210
+ if (entry.consumer !== consumer) continue;
211
+ out.push({ id: entry.id, fields: { ...entry.fields } });
212
+ if (out.length >= count) return out;
213
+ }
172
214
  for (const entry of list) {
173
215
  if (seen.has(entry.id)) continue;
174
216
  seen.add(entry.id);
175
- out.push({
176
- id: entry.id,
177
- fields: { ...entry.fields, _consumer: consumer },
178
- });
217
+ held.push({ id: entry.id, fields: { ...entry.fields }, consumer });
218
+ out.push({ id: entry.id, fields: { ...entry.fields } });
219
+ if (out.length >= count) return out;
220
+ }
221
+ for (const entry of held) {
222
+ if (entry.consumer === consumer) continue;
223
+ entry.consumer = consumer;
224
+ out.push({ id: entry.id, fields: { ...entry.fields } });
179
225
  if (out.length >= count) break;
180
226
  }
181
227
  return out;
182
228
  },
183
- async xack() {
184
- return 1;
229
+ async xack(key, group, id) {
230
+ const gkey = `${key}::${group}`;
231
+ const held = pending.get(gkey);
232
+ if (!held) return 0;
233
+ const next = held.filter((entry) => entry.id !== id);
234
+ pending.set(gkey, next);
235
+ return next.length === held.length ? 0 : 1;
185
236
  },
186
237
  async publish(channel, message) {
187
238
  published.push({ channel, message });
@@ -214,6 +265,93 @@ export async function openRedisSignal(options: SignalOpenOptions): Promise<Signa
214
265
  // binds Bun.redis (Streams via send until Bun ships typed xadd).
215
266
  const redis = options.redis ?? createBunSignalRedisClient();
216
267
  const outbox = await createSignalEngine("redis", options);
268
+ const compete = options.compete === true;
269
+ const onceHandlers = new Map<string, SignalHandler[]>();
270
+ const streamDead: DeadLetter[] = [];
271
+ const attemptById = new Map<string, number>();
272
+ const now = options.now ?? (() => Date.now());
273
+
274
+ if (compete) {
275
+ for (const [name, decl] of options.signals) {
276
+ if (decl.delivery !== "once") continue;
277
+ outbox.subscribe(name, "oke-relay-ack", async () => {});
278
+ onceHandlers.set(name, []);
279
+ }
280
+ }
281
+
282
+ function streamMessage(
283
+ name: string,
284
+ id: string,
285
+ payload: unknown,
286
+ attempt: number,
287
+ ): SignalMessage {
288
+ const at = now();
289
+ return {
290
+ id,
291
+ signal: name,
292
+ payload,
293
+ delivery: "once",
294
+ attempts: attempt,
295
+ failures: [],
296
+ createdAt: at,
297
+ availableAt: at,
298
+ status: "inflight",
299
+ };
300
+ }
301
+
302
+ async function drainOnceStreams(): Promise<void> {
303
+ const group = "oke";
304
+ const consumer = options.consumerId ?? "local";
305
+ for (const [name, decl] of options.signals) {
306
+ if (decl.delivery !== "once") continue;
307
+ const key = `oke:signal:${name}`;
308
+ await redis.xgroupCreate(key, group, "0", { mkstream: true });
309
+ const rows = await redis.xreadgroup(group, consumer, key, 32);
310
+ for (const row of rows) {
311
+ const handler = onceHandlers.get(name)?.[0];
312
+ let payload: unknown = null;
313
+ try {
314
+ payload = JSON.parse(row.fields.payload ?? "null");
315
+ } catch {
316
+ payload = null;
317
+ }
318
+ const attempt = (attemptById.get(row.id) ?? 0) + 1;
319
+ attemptById.set(row.id, attempt);
320
+ if (!handler) continue;
321
+ try {
322
+ await handler(streamMessage(name, row.id, payload, attempt));
323
+ await redis.xack(key, group, row.id);
324
+ attemptById.delete(row.id);
325
+ } catch (err) {
326
+ const retries = decl.retries ?? 3;
327
+ if (attempt > retries) {
328
+ const failure: SignalFailureReason = {
329
+ code: "handler_error",
330
+ message: err instanceof Error ? err.message : String(err),
331
+ at: now(),
332
+ attempt,
333
+ };
334
+ if (decl.deadLetter) {
335
+ const at = now();
336
+ streamDead.push({
337
+ id: row.id,
338
+ signal: name,
339
+ payload,
340
+ delivery: "once",
341
+ attempts: attempt,
342
+ failures: [failure],
343
+ createdAt: at,
344
+ availableAt: at,
345
+ status: "dead",
346
+ });
347
+ }
348
+ await redis.xack(key, group, row.id);
349
+ attemptById.delete(row.id);
350
+ }
351
+ }
352
+ }
353
+ }
354
+ }
217
355
 
218
356
  async function relayToRedis(signal: string, payload: unknown): Promise<void> {
219
357
  const decl = options.signals.get(signal);
@@ -252,11 +390,29 @@ export async function openRedisSignal(options: SignalOpenOptions): Promise<Signa
252
390
  rollback: () => tx.rollback(),
253
391
  };
254
392
  },
255
- subscribe: (signal, subscriberId, handler) => outbox.subscribe(signal, subscriberId, handler),
393
+ subscribe(signal, subscriberId, handler) {
394
+ if (compete && options.signals.get(signal)?.delivery === "once") {
395
+ const list = onceHandlers.get(signal) ?? [];
396
+ list.push(handler);
397
+ onceHandlers.set(signal, list);
398
+ return Promise.resolve(() => {
399
+ const next = (onceHandlers.get(signal) ?? []).filter((h) => h !== handler);
400
+ onceHandlers.set(signal, next);
401
+ });
402
+ }
403
+ return outbox.subscribe(signal, subscriberId, handler);
404
+ },
256
405
  live: (signal, opts) => outbox.live(signal, opts),
257
406
  checkLiveResume: (signal, afterId) => outbox.checkLiveResume(signal, afterId),
258
- drain: () => outbox.drain(),
259
- deadLetters: (s) => outbox.deadLetters(s),
407
+ async drain() {
408
+ if (compete) await drainOnceStreams();
409
+ await outbox.drain();
410
+ },
411
+ async deadLetters(s) {
412
+ const local = streamDead.filter((entry) => entry.signal === s);
413
+ const rest = await outbox.deadLetters(s);
414
+ return [...rest, ...local];
415
+ },
260
416
  inspect: (s) => outbox.inspect(s),
261
417
  replay: (opts) => outbox.replay(opts),
262
418
  discard: (opts) => outbox.discard(opts),
@@ -265,6 +265,18 @@ export interface SignalOpenOptions {
265
265
  readonly nats?: SignalNatsClientLike;
266
266
  /** Outbox SQL for redis/nats relay (defaults to in-process). */
267
267
  readonly outboxSql?: unknown;
268
+ /**
269
+ * When the SQL client has no `LISTEN`, poll `drain` on this interval (ms).
270
+ * `0` or omitted with a `listen` client uses notifications only.
271
+ */
272
+ readonly pollMs?: number;
273
+ /**
274
+ * Redis `once` consume via a consumer group (`xreadgroup` / `xack`)
275
+ * instead of the process-local outbox. Boot sets this for `drivers.signal: redis`.
276
+ */
277
+ readonly compete?: boolean;
278
+ /** Consumer name inside the redis group. Defaults to `"local"`. */
279
+ readonly consumerId?: string;
268
280
  }
269
281
 
270
282
  /**
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Installed agent event log.
3
+ *
4
+ * The follow route and the AI runtime must share one slot. The log
5
+ * implementation stays in `run-events.ts` and loads on first use; this file
6
+ * is the only static edge so a `lazyRequire` copy cannot hide the log.
7
+ */
8
+
9
+ import type { AgentEventLog } from "./run-events.ts";
10
+
11
+ let activeLog: AgentEventLog | undefined;
12
+
13
+ /**
14
+ * Install the log the follow route reads. Boot and tests call this.
15
+ *
16
+ * @param log - Active log
17
+ */
18
+ export function setAgentEventLog(log: AgentEventLog | undefined): void {
19
+ activeLog = log;
20
+ }
21
+
22
+ /**
23
+ * Log installed by the running AI runtime.
24
+ */
25
+ export function getAgentEventLog(): AgentEventLog | undefined {
26
+ return activeLog;
27
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Built-in HTTP approve / deny for a parked agent tool.
3
+ *
4
+ * The route is public so the request can arrive. The tool's own gate is
5
+ * checked inside `fx.agent`. `Idempotency-Key` applies because the flow writes.
6
+ */
7
+
8
+ import { flow } from "../../kernel/flow.ts";
9
+ import type { Fx } from "../../kernel/fx.ts";
10
+ import { lazyRequire } from "../../kernel/lazy-require.ts";
11
+ import type { Binding } from "../../kernel/on.ts";
12
+ import { http } from "../../kernel/triggers.ts";
13
+ import type { GateRuntime } from "../gate/runtime.ts";
14
+ import type { StandardSchemaV1 } from "../../validation/standard-schema.ts";
15
+ import { journalLeaseBusyResponse } from "./approval.ts";
16
+ import { getAgentEventLog } from "./agent-event-slot.ts";
17
+ import type { AgentEventLog } from "./run-events.ts";
18
+
19
+ /** SSE frame helpers live on `fx.ts`. Load them on the first follow frame. */
20
+ function loadSseFrames(): typeof import("../../kernel/fx.ts") {
21
+ return lazyRequire(`${import.meta.dir}/../../kernel`, ["f", "x"].join(""));
22
+ }
23
+
24
+ /** Body the approve route accepts. `args` replaces the tool input. */
25
+ const approveIn: StandardSchemaV1<{ id: string; args?: unknown }> = {
26
+ "~standard": {
27
+ version: 1,
28
+ vendor: "okengine",
29
+ validate(value) {
30
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
31
+ return { issues: [{ message: "expected object", path: [] }] };
32
+ }
33
+ const body = value as { id?: unknown; args?: unknown };
34
+ if (typeof body.id !== "string" || body.id.length === 0) {
35
+ return { issues: [{ message: "id is required", path: ["id"] }] };
36
+ }
37
+ return { value: { id: body.id, ...(body.args !== undefined ? { args: body.args } : {}) } };
38
+ },
39
+ },
40
+ };
41
+
42
+ /** Body the deny route accepts. */
43
+ const denyIn: StandardSchemaV1<{ id: string; reason?: string }> = {
44
+ "~standard": {
45
+ version: 1,
46
+ vendor: "okengine",
47
+ validate(value) {
48
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
49
+ return { issues: [{ message: "expected object", path: [] }] };
50
+ }
51
+ const body = value as { id?: unknown; reason?: unknown };
52
+ if (typeof body.id !== "string" || body.id.length === 0) {
53
+ return { issues: [{ message: "id is required", path: ["id"] }] };
54
+ }
55
+ if (body.reason !== undefined && typeof body.reason !== "string") {
56
+ return { issues: [{ message: "reason must be a string", path: ["reason"] }] };
57
+ }
58
+ return {
59
+ value: {
60
+ id: body.id,
61
+ ...(typeof body.reason === "string" ? { reason: body.reason } : {}),
62
+ },
63
+ };
64
+ },
65
+ },
66
+ };
67
+
68
+ /**
69
+ * Map a resolution to the HTTP failure the rest of the kernel already speaks.
70
+ *
71
+ * @param fx - Flow context
72
+ * @param result - Approval resolution
73
+ */
74
+ function approvalHttpResult(
75
+ fx: Fx,
76
+ result:
77
+ | { readonly ok: true }
78
+ | { readonly ok: false; readonly status: 403 | 404 }
79
+ | { readonly ok: false; readonly status: 409; readonly reason: "resolved" }
80
+ | {
81
+ readonly ok: false;
82
+ readonly status: 409;
83
+ readonly reason: "lease";
84
+ readonly retryAfterSeconds: number;
85
+ },
86
+ ): unknown {
87
+ if (result.ok) return { ok: true };
88
+ if (result.status === 409 && result.reason === "lease") {
89
+ return journalLeaseBusyResponse(result.retryAfterSeconds);
90
+ }
91
+ if (result.status === 409) return fx.fail.conflict();
92
+ if (result.status === 403) return fx.fail.forbidden();
93
+ return fx.fail.notFound();
94
+ }
95
+
96
+ /**
97
+ * Register the approve and deny routes on an app.
98
+ *
99
+ * @param adopt - App binding adopter
100
+ */
101
+ export function bindAgentApprovalFlows(adopt: (binding: Binding) => void): void {
102
+ adopt({
103
+ trigger: http.post("/agent/approvals/approve", { in: approveIn }).public(),
104
+ flow: flow("oke.agent.approve", {
105
+ effects: { writes: ["journal:runs"] },
106
+ do: async (input, fx) => {
107
+ const body = input as { id: string; args?: unknown };
108
+ const result = await fx.agent.approve(body.id, { args: body.args });
109
+ return approvalHttpResult(fx, result);
110
+ },
111
+ }),
112
+ });
113
+ adopt({
114
+ trigger: http.post("/agent/approvals/deny", { in: denyIn }).public(),
115
+ flow: flow("oke.agent.deny", {
116
+ effects: { writes: ["journal:runs"] },
117
+ do: async (input, fx) => {
118
+ const body = input as { id: string; reason?: string };
119
+ const result = await fx.agent.deny(body.id, { reason: body.reason });
120
+ return approvalHttpResult(fx, result);
121
+ },
122
+ }),
123
+ });
124
+ adopt({
125
+ trigger: http.get("/agent/runs/:runId/events").public(),
126
+ flow: flow("oke.agent.follow", {
127
+ do: async (input, fx) => followAgentRun(input, fx),
128
+ }),
129
+ });
130
+ }
131
+
132
+ let followGates: GateRuntime | undefined;
133
+
134
+ /**
135
+ * Gate runtime the follow route uses. Boot installs the app's gates.
136
+ *
137
+ * @param gates - Runtime that checked the calling Flow
138
+ */
139
+ export function setAgentFollowGates(gates: GateRuntime | undefined): void {
140
+ followGates = gates;
141
+ }
142
+
143
+ async function followAgentRun(input: unknown, fx: Fx): Promise<unknown> {
144
+ const body = (input ?? {}) as { runId?: unknown };
145
+ const runId = typeof body.runId === "string" ? body.runId : "";
146
+ const log = getAgentEventLog();
147
+ if (!log || !runId) return fx.fail.notFound();
148
+ const header = await log.header(runId);
149
+ if (!header) return fx.fail.notFound();
150
+ if ((header.tenant ?? null) !== (fx.tenant.id ?? null)) return fx.fail.notFound();
151
+ const followerIsOperator = fx.operator.id !== null;
152
+ const sameUser = (header.userId ?? null) === (fx.auth.userId ?? null);
153
+ const sameOperator = (header.operatorId ?? null) === (fx.operator.id ?? null);
154
+ const samePrincipal =
155
+ (header.userId === null && header.operatorId === null && sameUser && sameOperator) ||
156
+ (header.userId !== null && sameUser) ||
157
+ (header.operatorId !== null && sameOperator);
158
+ if (!followerIsOperator && !samePrincipal) return fx.fail.forbidden();
159
+ if (header.gates.length > 0) {
160
+ if (!followGates) return fx.fail.forbidden();
161
+ const checks = await followGates.check(header.gates, {
162
+ auth: fx.auth,
163
+ operator: fx.operator,
164
+ meta: {},
165
+ });
166
+ if (checks.some((check) => check.allowed === false)) return fx.fail.forbidden();
167
+ }
168
+ const after = Number(fx.lastEventId ?? "0");
169
+ const afterSeq = Number.isFinite(after) ? after : 0;
170
+ return fx.json.stream(frames(log, runId, afterSeq, fx.signal));
171
+ }
172
+
173
+ async function* frames(
174
+ log: AgentEventLog,
175
+ runId: string,
176
+ afterSeq: number,
177
+ signal: AbortSignal,
178
+ ): AsyncIterable<unknown> {
179
+ const iter = log.subscribe(runId, afterSeq, signal)[Symbol.asyncIterator]();
180
+ let pending = iter.next();
181
+ try {
182
+ for (;;) {
183
+ let timer: ReturnType<typeof setTimeout> | undefined;
184
+ const ping = new Promise<"ping">((resolve) => {
185
+ timer = setTimeout(() => resolve("ping"), 15_000);
186
+ });
187
+ const winner = await Promise.race([
188
+ pending.then((row) => ({ kind: "row" as const, row })),
189
+ ping.then((kind) => ({ kind })),
190
+ ]);
191
+ if (timer) clearTimeout(timer);
192
+ if (winner.kind === "ping") {
193
+ if (signal.aborted) return;
194
+ yield loadSseFrames().sseComment("keepalive");
195
+ continue;
196
+ }
197
+ pending = iter.next();
198
+ if (winner.row.done || winner.row.value === undefined) return;
199
+ const row = winner.row.value;
200
+ yield loadSseFrames().sseFrame(row.event, String(row.seq));
201
+ if (row.event.type === "RUN_ERROR") return;
202
+ if (row.event.type === "RUN_FINISHED" && row.event.outcome?.type !== "interrupt") return;
203
+ }
204
+ } finally {
205
+ await iter.return?.();
206
+ }
207
+ }