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
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Labels and the drift flag live on the journal driver.
3
+ */
4
+
5
+ import { afterEach, describe, expect, test } from "bun:test";
6
+ import { mkdtemp } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { createFileJournalStore, createMemoryJournalStore } from "../../../kernel/journal.ts";
10
+ import {
11
+ DecisionLabelStoreError,
12
+ createFileDecisionLabelStore,
13
+ createPostgresDecisionLabelStore,
14
+ setDeclaredDriftDecisions,
15
+ } from "../../../kernel/decision-label-store.ts";
16
+ import {
17
+ decisionDriftNames,
18
+ decisionDriftSuspended,
19
+ setDecisionDrift,
20
+ setDecisionLock,
21
+ } from "./certificate.ts";
22
+ import {
23
+ auditDriftExceeded,
24
+ closeDecisionLabelStore,
25
+ flushDecisionLabels,
26
+ loadDecisionLabels,
27
+ openDecisionLabelStore,
28
+ persistDecisionDrift,
29
+ decisionLabelWriteFailures,
30
+ persistDecisionLabel,
31
+ } from "./labels.ts";
32
+
33
+ afterEach(() => {
34
+ closeDecisionLabelStore();
35
+ for (const name of decisionDriftNames()) setDecisionDrift(name, false);
36
+ setDecisionLock(undefined);
37
+ });
38
+
39
+ describe("decision labels", () => {
40
+ test("labels and the suspension flag survive a reopened journal", async () => {
41
+ const root = await mkdtemp(join(tmpdir(), "oke-labels-"));
42
+ const path = join(root, "journal.json");
43
+ const first = createFileJournalStore(path);
44
+ await openDecisionLabelStore(first, setDecisionDrift);
45
+ persistDecisionLabel({
46
+ decision: "triage",
47
+ question: "team",
48
+ value: "billing",
49
+ propensity: 1,
50
+ reviewer: "a",
51
+ tenant: "acme",
52
+ model: "jev",
53
+ score: 0.4,
54
+ loss: 1,
55
+ at: 1,
56
+ });
57
+ persistDecisionDrift("triage", true);
58
+ await flushDecisionLabels();
59
+ closeDecisionLabelStore();
60
+ setDecisionDrift("triage", false);
61
+ const second = createFileJournalStore(path);
62
+ await openDecisionLabelStore(second, setDecisionDrift);
63
+ const saved = await loadDecisionLabels("triage", "acme");
64
+ expect(saved).toHaveLength(1);
65
+ expect(saved[0]?.at).toBe(1);
66
+ expect(await loadDecisionLabels("triage", "other")).toHaveLength(0);
67
+ expect(await loadDecisionLabels("triage")).toHaveLength(1);
68
+ expect(decisionDriftSuspended("triage")).toBe(true);
69
+ });
70
+
71
+ test("one correct audit label never suspends", async () => {
72
+ const journal = createMemoryJournalStore();
73
+ await openDecisionLabelStore(journal, setDecisionDrift);
74
+ persistDecisionLabel({
75
+ decision: "triage",
76
+ question: "team",
77
+ value: "technical",
78
+ propensity: 0.1,
79
+ reviewer: "audit",
80
+ model: "typesafe/jev-1.13.0",
81
+ loss: 0,
82
+ at: Date.now(),
83
+ });
84
+ await flushDecisionLabels();
85
+ expect(
86
+ auditDriftExceeded({
87
+ maxError: 0.05,
88
+ model: "typesafe/jev-1.13.0",
89
+ since: 0,
90
+ labels: await loadDecisionLabels("triage"),
91
+ }),
92
+ ).toBe(false);
93
+ });
94
+
95
+ test("audit drift is a one-sided binomial on the pinned model since the certificate", async () => {
96
+ const journal = createMemoryJournalStore();
97
+ await openDecisionLabelStore(journal, setDecisionDrift);
98
+ const now = Date.now();
99
+ for (let i = 0; i < 30; i++) {
100
+ persistDecisionLabel({
101
+ decision: "triage",
102
+ question: "team",
103
+ value: "billing",
104
+ propensity: 0.1,
105
+ reviewer: "audit",
106
+ model: "typesafe/jev-1.13.0",
107
+ loss: 1,
108
+ at: now,
109
+ });
110
+ }
111
+ persistDecisionLabel({
112
+ decision: "triage",
113
+ question: "team",
114
+ value: "billing",
115
+ propensity: 0.1,
116
+ reviewer: "audit",
117
+ model: "other-model",
118
+ loss: 1,
119
+ at: now,
120
+ });
121
+ await flushDecisionLabels();
122
+ expect(
123
+ auditDriftExceeded({
124
+ maxError: 0.05,
125
+ model: "typesafe/jev-1.13.0",
126
+ since: now - 1000,
127
+ now,
128
+ labels: await loadDecisionLabels("triage"),
129
+ }),
130
+ ).toBe(true);
131
+ expect(
132
+ auditDriftExceeded({
133
+ maxError: 0.05,
134
+ model: "typesafe/jev-1.13.0",
135
+ since: now + 1,
136
+ now,
137
+ labels: await loadDecisionLabels("triage"),
138
+ }),
139
+ ).toBe(false);
140
+ });
141
+
142
+ test("a newer certificate ignores a drift flag raised against the previous one", async () => {
143
+ const journal = createMemoryJournalStore();
144
+ await openDecisionLabelStore(journal, setDecisionDrift);
145
+ persistDecisionDrift("triage", true, 100);
146
+ await flushDecisionLabels();
147
+ closeDecisionLabelStore();
148
+ setDecisionDrift("triage", false);
149
+ setDecisionLock({
150
+ decisions: {
151
+ triage: { model: "typesafe/jev-1.13.0", certifiedAt: 200, questions: {} },
152
+ },
153
+ });
154
+ await openDecisionLabelStore(journal, setDecisionDrift);
155
+ expect(decisionDriftSuspended("triage")).toBe(false);
156
+ });
157
+
158
+ test("promoting one decision clears only that decision", async () => {
159
+ const journal = createMemoryJournalStore();
160
+ await openDecisionLabelStore(journal, setDecisionDrift);
161
+ persistDecisionDrift("ship", true, 1);
162
+ persistDecisionDrift("triage", true, 1);
163
+ await flushDecisionLabels();
164
+ persistDecisionDrift("ship", false);
165
+ await flushDecisionLabels();
166
+ closeDecisionLabelStore();
167
+ for (const name of decisionDriftNames()) setDecisionDrift(name, false);
168
+ await openDecisionLabelStore(journal, setDecisionDrift);
169
+ expect(decisionDriftSuspended("ship")).toBe(false);
170
+ expect(decisionDriftSuspended("triage")).toBe(true);
171
+ });
172
+
173
+ test("one failed insert does not drop the next write", async () => {
174
+ const journal = createMemoryJournalStore();
175
+ const decisions = journal.decisions;
176
+ if (!decisions) throw new Error("expected a label store");
177
+ let calls = 0;
178
+ const original = decisions.insert.bind(decisions);
179
+ decisions.insert = async (label, at) => {
180
+ calls += 1;
181
+ if (calls === 1) throw new Error("insert failed");
182
+ await original(label, at);
183
+ };
184
+ await openDecisionLabelStore(journal, setDecisionDrift);
185
+ persistDecisionLabel({
186
+ decision: "triage",
187
+ question: "team",
188
+ value: "a",
189
+ propensity: 1,
190
+ reviewer: "a",
191
+ });
192
+ persistDecisionLabel({
193
+ decision: "triage",
194
+ question: "team",
195
+ value: "b",
196
+ propensity: 1,
197
+ reviewer: "a",
198
+ });
199
+ await flushDecisionLabels();
200
+ const rows = await loadDecisionLabels("triage");
201
+ expect(rows.map((row) => row.value)).toEqual(["b"]);
202
+ expect(decisionLabelWriteFailures().map((event) => event.message)).toEqual(["insert failed"]);
203
+ });
204
+
205
+ test("a postgres label store throws when init fails", async () => {
206
+ const sql = {
207
+ async query(): Promise<Record<string, unknown>[]> {
208
+ throw new Error("query failed");
209
+ },
210
+ async exec(): Promise<{ changes: number }> {
211
+ throw new Error("exec failed");
212
+ },
213
+ };
214
+ await expect(createPostgresDecisionLabelStore(sql)).rejects.toBeInstanceOf(
215
+ DecisionLabelStoreError,
216
+ );
217
+ });
218
+
219
+ test("postgres null tenant matches, and an older drift table gains certified_at", async () => {
220
+ const { connectPglite } = await import("../../../drivers/pglite.ts");
221
+ const db = await connectPglite({ url: "memory://decision-labels" });
222
+ try {
223
+ await db.exec(`CREATE TABLE oke_decision_drift (
224
+ id INTEGER PRIMARY KEY,
225
+ suspended INTEGER NOT NULL
226
+ )`);
227
+ await db.exec(`INSERT INTO oke_decision_drift (id, suspended) VALUES (1, 1)`);
228
+ setDeclaredDriftDecisions(["ship", "triage"]);
229
+ const store = await createPostgresDecisionLabelStore(db);
230
+ await store.insert(
231
+ {
232
+ decision: "triage",
233
+ question: "team",
234
+ value: "billing",
235
+ propensity: 1,
236
+ reviewer: "a",
237
+ },
238
+ 1,
239
+ );
240
+ await store.insert(
241
+ {
242
+ decision: "triage",
243
+ question: "team",
244
+ value: "technical",
245
+ propensity: 1,
246
+ reviewer: "a",
247
+ tenant: "acme",
248
+ },
249
+ 2,
250
+ );
251
+ const unlabeled = await store.list("triage", null);
252
+ const acme = await store.list("triage", "acme");
253
+ expect(unlabeled.map((row) => row.value)).toEqual(["billing"]);
254
+ expect(acme.map((row) => row.value)).toEqual(["technical"]);
255
+ expect(await store.drift()).toEqual({
256
+ ship: { suspended: true, certifiedAt: 0 },
257
+ triage: { suspended: true, certifiedAt: 0 },
258
+ });
259
+ } finally {
260
+ await db.close();
261
+ }
262
+ });
263
+
264
+ test("a file store copies a legacy star flag onto every declared decision", async () => {
265
+ const root = await mkdtemp(join(tmpdir(), "oke-drift-"));
266
+ const path = join(root, "decision-labels.json");
267
+ await Bun.write(
268
+ path,
269
+ JSON.stringify({
270
+ rows: [],
271
+ drifts: { "*": { suspended: true, certifiedAt: 4 } },
272
+ }),
273
+ );
274
+ setDeclaredDriftDecisions(["triage", "ship"]);
275
+ const store = createFileDecisionLabelStore(path);
276
+ expect(await store.drift()).toEqual({
277
+ triage: { suspended: true, certifiedAt: 4 },
278
+ ship: { suspended: true, certifiedAt: 4 },
279
+ });
280
+ setDeclaredDriftDecisions([]);
281
+ });
282
+ });
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Decision labels and the app drift flag on the journal driver.
3
+ * Boot opens the store only when the app declares decisions.
4
+ */
5
+
6
+ import { AsyncLocalStorage } from "node:async_hooks";
7
+ import type { JournalStore } from "../../../kernel/journal.ts";
8
+ import type { RunTelemetry } from "../../../kernel/run-telemetry.ts";
9
+ import type {
10
+ DecisionDriftRecord,
11
+ DecisionLabelStore,
12
+ } from "../../../kernel/decision-label-store.ts";
13
+ import {
14
+ binomialCdf,
15
+ decisionLabels,
16
+ getDecisionLock,
17
+ type DecisionCandidate,
18
+ type DecisionLabel,
19
+ } from "./certificate.ts";
20
+
21
+ /** Rolling window for audit drift. Labels older than this are ignored. */
22
+ export const DECISION_DRIFT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
23
+
24
+ let store: DecisionLabelStore | undefined;
25
+ let writes: Promise<void> = Promise.resolve();
26
+
27
+ /** Trace log attached to the run that queued the write. */
28
+ const telemetryScope = new AsyncLocalStorage<RunTelemetry>();
29
+
30
+ /** A failed label write. The chain continues; the error is not dropped. */
31
+ export interface DecisionLabelWriteFailure {
32
+ readonly decision: string;
33
+ readonly question: string;
34
+ readonly message: string;
35
+ readonly at: number;
36
+ }
37
+
38
+ const writeFailures: DecisionLabelWriteFailure[] = [];
39
+
40
+ /**
41
+ * Recent label-write failures. Console reads this for the decisions page.
42
+ */
43
+ export function decisionLabelWriteFailures(): readonly DecisionLabelWriteFailure[] {
44
+ return writeFailures;
45
+ }
46
+
47
+ /**
48
+ * Attach this run's telemetry so a failed label write lands on its trace.
49
+ *
50
+ * @param telemetry - Collector for the current invocation
51
+ */
52
+ export function bindDecisionLabelTelemetry(telemetry: RunTelemetry): void {
53
+ telemetryScope.enterWith(telemetry);
54
+ }
55
+
56
+ /**
57
+ * Bind the journal's decision store and load the drift flag.
58
+ *
59
+ * @param journal - Journal that owns the label table
60
+ * @param setDrift - Install the loaded flag
61
+ */
62
+ export async function openDecisionLabelStore(
63
+ journal: JournalStore,
64
+ setDrift: (name: string, suspended: boolean) => void,
65
+ ): Promise<void> {
66
+ await writes;
67
+ store = journal.decisions;
68
+ if (!store) return;
69
+ const flags = await store.drift();
70
+ for (const [name, flag] of Object.entries(flags)) {
71
+ const pinned = pinnedDecision(name);
72
+ const suspended = flag.suspended && (pinned?.since ?? 0) <= flag.certifiedAt;
73
+ setDrift(name, suspended);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Wait until label and drift writes have reached the journal driver.
79
+ */
80
+ export function flushDecisionLabels(): Promise<void> {
81
+ return writes;
82
+ }
83
+
84
+ /**
85
+ * Drop the bound store. Tests use this.
86
+ */
87
+ export function closeDecisionLabelStore(): void {
88
+ store = undefined;
89
+ writeFailures.length = 0;
90
+ }
91
+
92
+ /**
93
+ * Write one label. Tenant scopes the row. The candidate job reads every tenant.
94
+ *
95
+ * @param label - Resolved label. `label.at` is the stored time when set.
96
+ * @param at - Epoch ms used only when `label.at` is absent
97
+ */
98
+ export function persistDecisionLabel(label: DecisionLabel, at?: number): void {
99
+ const stamped = label.at ?? at ?? Date.now();
100
+ const row = { ...label, at: stamped };
101
+ if (!store) return;
102
+ const target = store;
103
+ const write = (): Promise<void> =>
104
+ target.insert(row, stamped).then(
105
+ () => undefined,
106
+ (error: unknown) => {
107
+ noteLabelWriteFailure(row, error);
108
+ },
109
+ );
110
+ writes = writes.then(write, write);
111
+ }
112
+
113
+ /**
114
+ * Write a candidate onto the journal driver.
115
+ *
116
+ * @param decision - Decision name
117
+ * @param entry - Fitted lock entry
118
+ */
119
+ export async function persistDecisionCandidate(
120
+ decision: string,
121
+ entry: DecisionCandidate,
122
+ ): Promise<void> {
123
+ await store?.putCandidate(decision, entry);
124
+ }
125
+
126
+ /**
127
+ * Read one candidate from the journal driver.
128
+ *
129
+ * @param decision - Decision name
130
+ */
131
+ export async function loadDecisionCandidate(
132
+ decision: string,
133
+ ): Promise<DecisionCandidate | undefined> {
134
+ return store?.getCandidate(decision);
135
+ }
136
+
137
+ /**
138
+ * Decision names that have a candidate on the journal driver.
139
+ */
140
+ export async function listDecisionCandidates(): Promise<readonly string[]> {
141
+ return (await store?.listCandidates()) ?? [];
142
+ }
143
+
144
+ /**
145
+ * Record a failed label write on the run trace and for the decisions page.
146
+ * The returned promise still fulfills so the next write runs.
147
+ *
148
+ * @param label - Row that did not persist
149
+ * @param error - Driver error
150
+ */
151
+ function noteLabelWriteFailure(label: DecisionLabel, error: unknown): void {
152
+ const message = error instanceof Error ? error.message : "decision label write failed";
153
+ const at = Date.now();
154
+ writeFailures.push({
155
+ decision: label.decision,
156
+ question: label.question,
157
+ message,
158
+ at,
159
+ });
160
+ const telemetry = telemetryScope.getStore();
161
+ if (!telemetry) return;
162
+ telemetry.logs.push({
163
+ level: "error",
164
+ message: "decision label write failed",
165
+ data: { decision: label.decision, question: label.question, error: message },
166
+ at,
167
+ });
168
+ }
169
+
170
+ /**
171
+ * Labels for one decision. Omit `tenant` to read every tenant.
172
+ * Reads the journal mirror when one is open, otherwise in-memory review labels.
173
+ *
174
+ * @param decision - Decision name
175
+ * @param tenant - Tenant filter
176
+ */
177
+ export async function loadDecisionLabels(
178
+ decision?: string,
179
+ tenant?: string | null,
180
+ ): Promise<DecisionLabel[]> {
181
+ if (store) return store.list(decision, tenant);
182
+ return decisionLabels().filter((label) => {
183
+ if (decision !== undefined && label.decision !== decision) return false;
184
+ if (tenant !== undefined && (label.tenant ?? null) !== tenant) return false;
185
+ return true;
186
+ });
187
+ }
188
+
189
+ /**
190
+ * Compare audit labels for the pinned model to the certified error cap.
191
+ * Only labels since the certificate, inside the rolling window, count.
192
+ * One-sided exact binomial. A single correct label does not suspend.
193
+ *
194
+ * @param options - Cap, pinned model, and certificate time
195
+ */
196
+ export function auditDriftExceeded(options: {
197
+ readonly maxError: number;
198
+ readonly delta?: number;
199
+ readonly model: string;
200
+ readonly since: number;
201
+ readonly now?: number;
202
+ readonly windowMs?: number;
203
+ readonly labels: readonly DecisionLabel[];
204
+ }): boolean {
205
+ const delta = options.delta ?? 0.1;
206
+ const now = options.now ?? Date.now();
207
+ const windowMs = options.windowMs ?? DECISION_DRIFT_WINDOW_MS;
208
+ const floor = Math.max(options.since, now - windowMs);
209
+ const audits = options.labels.filter((label) => {
210
+ if (!(label.propensity < 1) || label.loss === undefined) return false;
211
+ if (label.model !== options.model) return false;
212
+ const at = label.at ?? 0;
213
+ return at >= floor;
214
+ });
215
+ if (audits.length === 0) return false;
216
+ const errors = audits.filter((label) => (label.loss ?? 0) > 0).length;
217
+ const tail = 1 - binomialCdf(errors - 1, audits.length, options.maxError);
218
+ return tail <= delta;
219
+ }
220
+
221
+ /**
222
+ * Persist one decision's suspension flag.
223
+ *
224
+ * @param name - Decision name
225
+ * @param suspended - Drift detected for that decision
226
+ * @param certifiedAt - Certificate time the flag was raised against
227
+ */
228
+ export function persistDecisionDrift(name: string, suspended: boolean, certifiedAt = 0): void {
229
+ if (!store) return;
230
+ const target = store;
231
+ const record: DecisionDriftRecord = { suspended, certifiedAt };
232
+ const write = async (): Promise<void> => {
233
+ try {
234
+ await target.setDrift(name, record);
235
+ } catch (error) {
236
+ noteLabelWriteFailure(
237
+ { decision: name, question: "drift", value: null, propensity: 0, reviewer: "drift" },
238
+ error,
239
+ );
240
+ throw error;
241
+ }
242
+ };
243
+ writes = writes.then(write, write);
244
+ }
245
+
246
+ /**
247
+ * Pinned model and certificate time for one decision, when a lock exists.
248
+ *
249
+ * @param name - Decision name
250
+ */
251
+ export function pinnedDecision(
252
+ name: string,
253
+ ): { readonly model: string; readonly since: number } | undefined {
254
+ const entry = getDecisionLock()?.decisions[name];
255
+ if (!entry) return undefined;
256
+ return { model: entry.model, since: entry.certifiedAt ?? 0 };
257
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Opt-in OpenRouter decision smoke check.
3
+ *
4
+ * Skipped unless `OPENROUTER_API_KEY` is set. `bun run test` (CI) ignores
5
+ * `*.live.test.ts`. The key is never printed, logged, or written.
6
+ */
7
+
8
+ import { afterEach, describe, expect, test } from "bun:test";
9
+ import { readFileSync } from "node:fs";
10
+ import { resolve } from "node:path";
11
+ import { ai, resetAiDecls } from "../../ai.ts";
12
+ import { createFx } from "../../../kernel/fx.ts";
13
+ import { resetDecisionCertificates } from "./certificate.ts";
14
+ import { OPENROUTER_DECISION_URL } from "./openrouter.ts";
15
+ import { resetDecisionProvider } from "../../../kernel/fx-decide.ts";
16
+
17
+ function openRouterKey(): string | undefined {
18
+ const fromEnv = process.env["OPENROUTER_API_KEY"];
19
+ if (fromEnv && fromEnv.length > 0) return fromEnv;
20
+ try {
21
+ const text = readFileSync(resolve(import.meta.dir, "../../../../.env.local"), "utf8");
22
+ for (const line of text.split("\n")) {
23
+ const trimmed = line.trim();
24
+ if (!trimmed.startsWith("OPENROUTER_API_KEY=")) continue;
25
+ const value = trimmed.slice("OPENROUTER_API_KEY=".length).replace(/^["']|["']$/g, "");
26
+ if (value.length > 0) return value;
27
+ }
28
+ } catch {
29
+ return undefined;
30
+ }
31
+ return undefined;
32
+ }
33
+
34
+ const apiKey = process.env["CI"] === "true" ? undefined : openRouterKey();
35
+
36
+ afterEach(() => {
37
+ resetAiDecls();
38
+ resetDecisionCertificates();
39
+ resetDecisionProvider();
40
+ });
41
+
42
+ function decision() {
43
+ return ai.decision("triage-live", {
44
+ onUncertain: "abstain",
45
+ ask: {
46
+ team: ai.choice("Which team should own this ticket?", {
47
+ billing: "Payments and refunds",
48
+ technical: "Bugs and outages",
49
+ }),
50
+ severity: ai.score("How severe is this?", ["low", "medium", "high"]),
51
+ urgent: ai.boolean("Does this need a person today?"),
52
+ },
53
+ });
54
+ }
55
+
56
+ function fx() {
57
+ return createFx({
58
+ flow: "triage.live",
59
+ effects: { decides: ["triage-live"], secrets: ["OPENROUTER_API_KEY"] },
60
+ secrets: { OPENROUTER_API_KEY: apiKey ?? "" },
61
+ });
62
+ }
63
+
64
+ describe.skipIf(apiKey === undefined)("openrouter decisions live", () => {
65
+ test("fx.decide hits /api/alpha/decisions and the body matches the fixture fields", async () => {
66
+ const original = globalThis.fetch;
67
+ let seenUrl = "";
68
+ let raw: unknown;
69
+ globalThis.fetch = (async (input, init) => {
70
+ seenUrl = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
71
+ const res = await original(input, init);
72
+ const text = await res.clone().text();
73
+ raw = JSON.parse(text) as unknown;
74
+ return res;
75
+ }) as typeof fetch;
76
+ try {
77
+ const decl = decision();
78
+ const result = (await fx().decide(decl, {
79
+ ticket: "Checkout is blank after Pay.",
80
+ })) as {
81
+ $: {
82
+ team: { raw: unknown };
83
+ severity: { raw: unknown };
84
+ urgent: { raw: unknown };
85
+ };
86
+ };
87
+ expect(seenUrl).toBe(OPENROUTER_DECISION_URL);
88
+ expect(raw && typeof raw === "object").toBe(true);
89
+ const body = raw as {
90
+ model?: unknown;
91
+ answers?: Record<string, Record<string, unknown>>;
92
+ usage?: Record<string, unknown>;
93
+ };
94
+ expect(typeof body.model).toBe("string");
95
+ expect(String(body.model)).toMatch(/\d+\.\d+/);
96
+ const team = body.answers?.team;
97
+ const severity = body.answers?.severity;
98
+ const urgent = body.answers?.urgent;
99
+ expect(team?.type).toBe("choice");
100
+ expect(team?.probabilities && typeof team.probabilities).toBe("object");
101
+ expect(typeof team?.confidence).toBe("number");
102
+ expect(severity?.type).toBe("score");
103
+ expect(severity?.probabilities && typeof severity.probabilities).toBe("object");
104
+ expect(severity?.legend && typeof severity.legend).toBe("object");
105
+ expect(typeof severity?.confidence).toBe("number");
106
+ expect(urgent?.type).toBe("noul");
107
+ expect(typeof urgent?.noul).toBe("number");
108
+ expect(body.usage && typeof body.usage).toBe("object");
109
+ expect(result.$.team.raw).toBeDefined();
110
+
111
+ const english = (await fx().decide(decl, {
112
+ ticket: "Checkout is blank after Pay.",
113
+ })) as typeof result;
114
+ const arabic = (await fx().decide(decl, {
115
+ ticket: "صفحة الدفع فاضية بعد ما دفعت.",
116
+ })) as typeof result;
117
+ console.log(
118
+ "openrouter probabilities",
119
+ JSON.stringify({
120
+ en: {
121
+ team: english.$.team.raw,
122
+ severity: english.$.severity.raw,
123
+ urgent: english.$.urgent.raw,
124
+ },
125
+ ar: {
126
+ team: arabic.$.team.raw,
127
+ severity: arabic.$.severity.raw,
128
+ urgent: arabic.$.urgent.raw,
129
+ },
130
+ }),
131
+ );
132
+ } finally {
133
+ globalThis.fetch = original;
134
+ }
135
+ }, 120_000);
136
+ });