okengine 0.21.1 → 0.23.0

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 (342) hide show
  1. package/AGENTS.md +11 -4
  2. package/LICENSE +202 -0
  3. package/NOTICE +2 -0
  4. package/README.md +15 -9
  5. package/manifest.v1.schema.json +54 -0
  6. package/package.json +53 -38
  7. package/site/content/docs/ai/mcp.mdx +13 -9
  8. package/site/content/docs/ai/skills.mdx +2 -2
  9. package/site/content/docs/client/calling.mdx +27 -19
  10. package/site/content/docs/client/index.mdx +1 -1
  11. package/site/content/docs/elements/ai/agents.mdx +61 -13
  12. package/site/content/docs/elements/ai/decide.mdx +536 -0
  13. package/site/content/docs/elements/ai/events.mdx +373 -0
  14. package/site/content/docs/elements/ai/index.mdx +9 -3
  15. package/site/content/docs/elements/ai/meta.json +1 -1
  16. package/site/content/docs/elements/ai/prompts.mdx +4 -2
  17. package/site/content/docs/elements/channel/email.mdx +1 -1
  18. package/site/content/docs/elements/channel/index.mdx +1 -1
  19. package/site/content/docs/elements/flow/http.mdx +4 -4
  20. package/site/content/docs/elements/flow/index.mdx +3 -3
  21. package/site/content/docs/elements/flow/meta.json +1 -1
  22. package/site/content/docs/elements/gate/auth.mdx +4 -1
  23. package/site/content/docs/elements/gate/tenancy.mdx +1 -1
  24. package/site/content/docs/elements/store/index.mdx +1 -1
  25. package/site/content/docs/elements/store/kv.mdx +5 -5
  26. package/site/content/docs/elements/vault/rotation.mdx +15 -0
  27. package/site/content/docs/plugins/otp.mdx +31 -31
  28. package/site/content/docs/recipes/dragonfly.mdx +5 -5
  29. package/site/content/docs/recipes/index.mdx +6 -6
  30. package/site/content/docs/recipes/mailpit.mdx +1 -1
  31. package/site/content/docs/recipes/meilisearch.mdx +1 -1
  32. package/site/content/docs/recipes/pgdog.mdx +1 -1
  33. package/site/content/docs/recipes/postgres.mdx +12 -8
  34. package/site/content/docs/recipes/redis.mdx +4 -4
  35. package/site/content/docs/reference/cli.mdx +2 -1
  36. package/site/content/docs/reference/configuration.mdx +11 -9
  37. package/site/content/docs/reference/errors.mdx +12 -2
  38. package/site/content/docs/reference/fx.mdx +35 -21
  39. package/site/content/docs/reference/idempotency.mdx +192 -0
  40. package/site/content/docs/reference/index.mdx +6 -1
  41. package/site/content/docs/reference/meta.json +2 -1
  42. package/site/content/docs/understand/meta.json +1 -1
  43. package/site/content/docs/{elements/flow → understand}/routing.mdx +11 -10
  44. package/site/content/docs/understand/the-architecture.mdx +14 -12
  45. package/site/content/docs/understand/try-it.mdx +3 -3
  46. package/src/auth/api-key-sql.test.ts +44 -0
  47. package/src/auth/api-key-sql.ts +1 -0
  48. package/src/auth/api-keys.ts +30 -12
  49. package/src/auth/config.ts +6 -2
  50. package/src/auth/gate-auth.test.ts +20 -0
  51. package/src/cli/agents-md.test.ts +25 -0
  52. package/src/cli/ask-vault-gaps.test.ts +38 -1
  53. package/src/cli/ask-vault-gaps.ts +35 -2
  54. package/src/cli/competitor-mention-removal.test.ts +3 -3
  55. package/src/cli/decide.ts +174 -0
  56. package/src/cli/decision-lock-watch.test.ts +45 -0
  57. package/src/cli/decision-lock-watch.ts +51 -0
  58. package/src/cli/dev-app-runner.ts +34 -8
  59. package/src/cli/dev-auth-secret.test.ts +49 -0
  60. package/src/cli/dev-auth-secret.ts +49 -0
  61. package/src/cli/dev-hot.test.ts +41 -0
  62. package/src/cli/dev-hot.ts +48 -0
  63. package/src/cli/dev.test.ts +1 -1
  64. package/src/cli/dev.ts +24 -6
  65. package/src/cli/docker-cli.test.ts +1 -1
  66. package/src/cli/eval.ts +142 -1
  67. package/src/cli/index.ts +14 -1
  68. package/src/cli/load-config.images.test.ts +12 -12
  69. package/src/cli/load-config.ts +2 -2
  70. package/src/cli/mcp-from-console.ts +48 -0
  71. package/src/cli/registry.ts +35 -1
  72. package/src/cli/vault-cmd.test.ts +23 -0
  73. package/src/cli/vault-cmd.ts +7 -0
  74. package/src/client/agent.ts +307 -0
  75. package/src/client/create.ts +18 -1
  76. package/src/client/transport.test.ts +245 -0
  77. package/src/client/transport.ts +133 -19
  78. package/src/client/types.ts +29 -3
  79. package/src/client-react/index.ts +7 -1
  80. package/src/client-react/use-agent-run.test.ts +271 -0
  81. package/src/client-react/use-agent-run.ts +193 -0
  82. package/src/compiler/ai-approval.test.ts +36 -0
  83. package/src/compiler/ai-repair.test.ts +22 -0
  84. package/src/compiler/decisions.extract.test.ts +164 -0
  85. package/src/compiler/effects-infer.ts +753 -17
  86. package/src/compiler/extract.test.ts +37 -0
  87. package/src/compiler/extract.ts +381 -24
  88. package/src/compiler/fixtures/skyport.expected.json +24 -0
  89. package/src/compiler/fx-follow.test.ts +164 -0
  90. package/src/compiler/fx-index.ts +399 -0
  91. package/src/compiler/response.ts +35 -2
  92. package/src/config/index.ts +21 -3
  93. package/src/console/server/ai-runs-flows.ts +269 -0
  94. package/src/console/server/ai-runs.test.ts +323 -0
  95. package/src/console/server/ai-runs.ts +562 -0
  96. package/src/console/server/ai.ts +12 -1
  97. package/src/console/server/app.ts +10 -1
  98. package/src/console/server/decisions-flows.ts +157 -0
  99. package/src/console/server/decisions.test.ts +66 -0
  100. package/src/console/server/decisions.ts +229 -0
  101. package/src/console/server/flows-invoke.test.ts +2 -2
  102. package/src/console/server/flows.ts +50 -2
  103. package/src/console/server/invoke-user-flow.ts +9 -0
  104. package/src/console/server/runs-ingest.ts +16 -0
  105. package/src/console/server/serve.ts +5 -0
  106. package/src/console/server/state.ts +25 -1
  107. package/src/console/server/store-stats.test.ts +47 -0
  108. package/src/console/server/store-stats.ts +92 -18
  109. package/src/console/ui-next/dist/assets/access-page-DDKkhT9v.js +4 -0
  110. package/src/console/ui-next/dist/assets/agent-disclosure-62Xts8Xi.js +1 -0
  111. package/src/console/ui-next/dist/assets/{cache-glyph-92uM5MO7.js → cache-glyph-DyeoKHKA.js} +1 -1
  112. package/src/console/ui-next/dist/assets/{call-pii-button-DtGVPtcs.js → call-pii-button-DvfpLXsU.js} +1 -1
  113. package/src/console/ui-next/dist/assets/{collapsible-DN7l6zmC.js → collapsible-BY03SeCg.js} +1 -1
  114. package/src/console/ui-next/dist/assets/confirm-sheet-DZfuJXP4.js +1 -0
  115. package/src/console/ui-next/dist/assets/copy-inline-button-BTjPVjRT.js +1 -0
  116. package/src/console/ui-next/dist/assets/decisions-page-CgkKlA56.js +1 -0
  117. package/src/console/ui-next/dist/assets/detail-header-Dk5LipF6.js +1 -0
  118. package/src/console/ui-next/dist/assets/dropdown-menu-CRGrj5JU.js +1 -0
  119. package/src/console/ui-next/dist/assets/duration-tone-Y6HLjyJ5.js +9 -0
  120. package/src/console/ui-next/dist/assets/element-icons-C6t8Vlml.js +1 -0
  121. package/src/console/ui-next/dist/assets/explorer-chrome-Dslh2yE8.js +1 -0
  122. package/src/console/ui-next/dist/assets/explorer-empty-CIVg7Jgb.js +1 -0
  123. package/src/console/ui-next/dist/assets/flows-page-DLPfNy-_.js +1 -0
  124. package/src/console/ui-next/dist/assets/{highlighted-json-BlAEVgNW.js → highlighted-json-D7nNzpgB.js} +1 -1
  125. package/src/console/ui-next/dist/assets/{http-method-BDf7OAHv.js → http-method-DdL19zzh.js} +1 -1
  126. package/src/console/ui-next/dist/assets/index-BlrN49Hs.css +2 -0
  127. package/src/console/ui-next/dist/assets/index-C0lc_s9d.js +58 -0
  128. package/src/console/ui-next/dist/assets/observability-page-CRlcyLCC.js +8 -0
  129. package/src/console/ui-next/dist/assets/{replica-lag-B3GLNVfF.js → replica-lag-Bb3FWUu9.js} +7 -7
  130. package/src/console/ui-next/dist/assets/request-meta-DYBMWhX8.js +1 -0
  131. package/src/console/ui-next/dist/assets/shortcut-keys-DTE1sjTC.js +1 -0
  132. package/src/console/ui-next/dist/assets/store-page-CcE-SXC8.js +41 -0
  133. package/src/console/ui-next/dist/assets/trace-detail-sheet-CKMSEQ4s.js +2 -0
  134. package/src/console/ui-next/dist/assets/tree-expand-toggle-QOfk0M0H.js +55 -0
  135. package/src/console/ui-next/dist/assets/units-page-DKC1CDDd.js +1 -0
  136. package/src/console/ui-next/dist/assets/use-vault-list-BAyQG8Re.js +1 -0
  137. package/src/console/ui-next/dist/assets/vault-page-B54v4Yit.js +2 -0
  138. package/src/console/ui-next/dist/index.html +4 -3
  139. package/src/console/ui-next/seed-parked-approval.ts +126 -0
  140. package/src/console/ui-next/src/client.ts +224 -1
  141. package/src/console/ui-next/src/components/explorer/detail-header.tsx +9 -0
  142. package/src/console/ui-next/src/components/explorer/explorer-start-toggle.tsx +5 -1
  143. package/src/console/ui-next/src/components/ui/sheet-form.tsx +21 -12
  144. package/src/console/ui-next/src/features/flows/decisions/decisions-page.tsx +216 -0
  145. package/src/console/ui-next/src/features/flows/decisions/resolve.test.ts +48 -0
  146. package/src/console/ui-next/src/features/flows/decisions/resolve.ts +111 -0
  147. package/src/console/ui-next/src/features/flows/flows-page.tsx +10 -0
  148. package/src/console/ui-next/src/features/flows/graph/element-map.ts +1 -0
  149. package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +6 -0
  150. package/src/console/ui-next/src/features/flows/traces/effect-summary.test.ts +24 -0
  151. package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
  152. package/src/console/ui-next/src/features/flows/traces/http-status.ts +58 -0
  153. package/src/console/ui-next/src/features/flows/traces/request-client-card.tsx +355 -0
  154. package/src/console/ui-next/src/features/flows/traces/request-client.test.ts +114 -0
  155. package/src/console/ui-next/src/features/flows/traces/request-client.ts +423 -0
  156. package/src/console/ui-next/src/features/flows/traces/request-input-view.test.ts +52 -6
  157. package/src/console/ui-next/src/features/flows/traces/request-input-view.ts +85 -19
  158. package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +505 -80
  159. package/src/console/ui-next/src/features/flows/traces/trace-lanes.test.ts +61 -0
  160. package/src/console/ui-next/src/features/flows/traces/trace-lanes.ts +78 -0
  161. package/src/console/ui-next/src/features/flows/traces/trace-nav.test.ts +48 -0
  162. package/src/console/ui-next/src/features/flows/traces/trace-nav.ts +57 -0
  163. package/src/console/ui-next/src/features/flows/traces/trace-request-section.tsx +275 -96
  164. package/src/console/ui-next/src/features/flows/traces/traces-pane.tsx +16 -2
  165. package/src/console/ui-next/src/features/observability/detail/ai-runs.tsx +369 -0
  166. package/src/console/ui-next/src/features/observability/observability-page.tsx +31 -1
  167. package/src/console/ui-next/src/features/observability/state/observability-selection.ts +18 -3
  168. package/src/console/ui-next/src/features/store/performance/kv-performance-panel.tsx +12 -1
  169. package/src/console/ui-next/src/features/store/performance/performance-panel.tsx +14 -1
  170. package/src/console/ui-next/src/features/units/call/call-api-panel.tsx +1 -1
  171. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +15 -2
  172. package/src/console/ui-next/src/features/units/explorer/units-tree.tsx +73 -18
  173. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +21 -0
  174. package/src/console/ui-next/src/features/units/lib/unit-tree.ts +82 -0
  175. package/src/console/ui-next/src/features/units/units-page.tsx +25 -7
  176. package/src/console/ui-next/src/router.tsx +17 -0
  177. package/src/console/ui-next/ui-next-seed-runs.ts +23 -0
  178. package/src/console/ui-next/vite-console-kernel-plugin.ts +43 -3
  179. package/src/console/ui-next/vite.config.ts +9 -0
  180. package/src/docker/docker.test.ts +13 -13
  181. package/src/docker/images-config.test.ts +12 -12
  182. package/src/docker/recipes/dragonfly.ts +3 -2
  183. package/src/docker/recipes/redis.ts +1 -1
  184. package/src/docker/stack-id.test.ts +1 -1
  185. package/src/drivers/ai-anthropic.ts +212 -21
  186. package/src/drivers/ai-mock.ts +34 -1
  187. package/src/drivers/ai-openai-compatible.ts +67 -26
  188. package/src/drivers/ai-preconnect.ts +18 -0
  189. package/src/drivers/ai-stream.test.ts +62 -0
  190. package/src/drivers/ai-types.ts +20 -0
  191. package/src/drivers/journal-postgres.ts +455 -3
  192. package/src/drivers/meilisearch.ts +2 -0
  193. package/src/drivers/memory-ddl.test.ts +18 -0
  194. package/src/drivers/memory.ts +21 -1
  195. package/src/drivers/pg-rls.test.ts +31 -0
  196. package/src/drivers/pg-rls.ts +15 -0
  197. package/src/drivers/postgres.test.ts +9 -0
  198. package/src/drivers/postgres.ts +49 -11
  199. package/src/drivers/vault-builtin.test.ts +10 -0
  200. package/src/drivers/vault-builtin.ts +13 -1
  201. package/src/drivers/vault-remote-bag.ts +5 -1
  202. package/src/drivers/vault-types.ts +6 -1
  203. package/src/elements/ai/agent-event-slot.ts +27 -0
  204. package/src/elements/ai/approval-http.ts +207 -0
  205. package/src/elements/ai/approval.test.ts +741 -0
  206. package/src/elements/ai/approval.ts +228 -0
  207. package/src/elements/ai/decisions/bind.ts +163 -0
  208. package/src/elements/ai/decisions/certificate.test.ts +291 -0
  209. package/src/elements/ai/decisions/certificate.ts +377 -0
  210. package/src/elements/ai/decisions/certify.ts +314 -0
  211. package/src/elements/ai/decisions/decide.test.ts +953 -0
  212. package/src/elements/ai/decisions/e2e.test.ts +399 -0
  213. package/src/elements/ai/decisions/export.test.ts +120 -0
  214. package/src/elements/ai/decisions/export.ts +146 -0
  215. package/src/elements/ai/decisions/fixtures/openrouter-request.json +24 -0
  216. package/src/elements/ai/decisions/fixtures/openrouter-response.json +22 -0
  217. package/src/elements/ai/decisions/fixtures/typesafe-request.json +19 -0
  218. package/src/elements/ai/decisions/fixtures/typesafe-response.json +13 -0
  219. package/src/elements/ai/decisions/http.test.ts +154 -0
  220. package/src/elements/ai/decisions/http.ts +240 -0
  221. package/src/elements/ai/decisions/labels.test.ts +282 -0
  222. package/src/elements/ai/decisions/labels.ts +257 -0
  223. package/src/elements/ai/decisions/openrouter.live.test.ts +136 -0
  224. package/src/elements/ai/decisions/openrouter.ts +36 -0
  225. package/src/elements/ai/decisions/provider.ts +102 -0
  226. package/src/elements/ai/decisions/typesafe.ts +36 -0
  227. package/src/elements/ai/declare.ts +252 -1
  228. package/src/elements/ai/events.test.ts +379 -0
  229. package/src/elements/ai/events.ts +138 -0
  230. package/src/elements/ai/run-events.test.ts +461 -0
  231. package/src/elements/ai/run-events.ts +522 -0
  232. package/src/elements/ai/runtime.ts +1186 -105
  233. package/src/elements/ai/stream-turn.ts +214 -0
  234. package/src/elements/ai/stream.live.test.ts +76 -0
  235. package/src/elements/ai/subagent.test.ts +136 -0
  236. package/src/elements/ai.test.ts +497 -3
  237. package/src/elements/ai.ts +16 -1
  238. package/src/elements/clock/durable.ts +27 -1
  239. package/src/elements/store/emit-drizzle.ts +2 -2
  240. package/src/elements/store/live-default.test.ts +2 -0
  241. package/src/elements/store/live-http.test.ts +1 -0
  242. package/src/elements/store/resource-list-docs.test.ts +2 -2
  243. package/src/elements/store/resource.test.ts +3 -3
  244. package/src/elements/store/schema-decl.test.ts +1 -0
  245. package/src/elements/store/sql-condition.ts +24 -7
  246. package/src/elements/store/sql-session.test.ts +51 -0
  247. package/src/elements/store/sql-session.ts +111 -1
  248. package/src/elements/store/upsert-app.test.ts +1 -1
  249. package/src/elements/vault/audit.test.ts +137 -0
  250. package/src/elements/vault/audit.ts +106 -13
  251. package/src/elements/vault/boot-chain.ts +9 -1
  252. package/src/elements/vault/builtin-adapter.ts +33 -3
  253. package/src/i18n/catalogs/ar.ts +9 -0
  254. package/src/i18n/catalogs/en.ts +9 -0
  255. package/src/index.ts +2 -0
  256. package/src/kernel/abort-scope.ts +33 -2
  257. package/src/kernel/agent-event-store.ts +448 -0
  258. package/src/kernel/app.ts +294 -19
  259. package/src/kernel/auto-cache.test.ts +6 -6
  260. package/src/kernel/boot-bind/ai.ts +3 -0
  261. package/src/kernel/boot-bind/store.test.ts +26 -0
  262. package/src/kernel/boot-bind/store.ts +82 -6
  263. package/src/kernel/boot-bind/vault.ts +1 -0
  264. package/src/kernel/boot.ts +34 -5
  265. package/src/kernel/builtin-errors.ts +10 -0
  266. package/src/kernel/capability.ts +3 -0
  267. package/src/kernel/client-descriptor.ts +1 -0
  268. package/src/kernel/concurrency.test.ts +33 -0
  269. package/src/kernel/decision-label-store.ts +441 -0
  270. package/src/kernel/effects-stamping.test.ts +2 -2
  271. package/src/kernel/effects.test.ts +2 -1
  272. package/src/kernel/effects.ts +13 -1
  273. package/src/kernel/element-registries.ts +3 -0
  274. package/src/kernel/errors-compiler.ts +20 -0
  275. package/src/kernel/errors-text.ts +4 -0
  276. package/src/kernel/errors.ts +2 -0
  277. package/src/kernel/external-effects.test.ts +36 -0
  278. package/src/kernel/flow.test.ts +2 -3
  279. package/src/kernel/flow.ts +24 -0
  280. package/src/kernel/fx-decide.ts +761 -0
  281. package/src/kernel/fx-fetch.ts +5 -1
  282. package/src/kernel/fx.test.ts +12 -5
  283. package/src/kernel/fx.ts +316 -29
  284. package/src/kernel/http-frame.test.ts +94 -0
  285. package/src/kernel/http-frame.ts +203 -0
  286. package/src/kernel/idempotency-store.ts +578 -0
  287. package/src/kernel/idempotency.test.ts +556 -0
  288. package/src/kernel/idempotency.ts +304 -0
  289. package/src/kernel/index.ts +1 -0
  290. package/src/kernel/journal.ts +160 -0
  291. package/src/kernel/json-result.ts +10 -0
  292. package/src/kernel/pipeline.test.ts +45 -0
  293. package/src/kernel/sse-id.ts +32 -0
  294. package/src/manifest/diff.ts +1 -0
  295. package/src/manifest/types.ts +37 -2
  296. package/src/mcp/ai-tools.test.ts +202 -0
  297. package/src/mcp/authorization.ts +66 -0
  298. package/src/mcp/session.ts +3 -0
  299. package/src/mcp/tools.ts +168 -0
  300. package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
  301. package/src/plugins/otp.test.ts +65 -1
  302. package/src/plugins/otp.ts +59 -7
  303. package/src/release/absolute-regression.test.ts +63 -0
  304. package/src/release/http-graph.test.ts +27 -0
  305. package/src/release/http-graph.ts +95 -0
  306. package/src/release/index.ts +3 -0
  307. package/src/release/limits.ts +17 -2
  308. package/src/release/measure.ts +144 -28
  309. package/src/runs/collect.ts +4 -1
  310. package/src/runs/parquet.test.ts +27 -1
  311. package/src/runs/parquet.ts +19 -0
  312. package/src/runs/types.ts +31 -0
  313. package/src/runtime/json-code-block.test.ts +106 -47
  314. package/src/runtime/json-code-block.ts +1209 -46
  315. package/src/term.test.ts +13 -0
  316. package/src/term.ts +81 -49
  317. package/src/test/create-test-app.test.ts +3 -1
  318. package/src/test/create-test-app.ts +40 -0
  319. package/src/test/live-signals.test.ts +1 -0
  320. package/src/test/provisions.integration.test.ts +1 -0
  321. package/src/test/tenant-isolation.test.ts +1 -0
  322. package/src/console/ui-next/dist/assets/PlusSignIcon-CwG3nxfu.js +0 -1
  323. package/src/console/ui-next/dist/assets/access-page-Bgt9bq2r.js +0 -4
  324. package/src/console/ui-next/dist/assets/agent-disclosure-B43CXZZR.js +0 -1
  325. package/src/console/ui-next/dist/assets/copy-inline-button-CAYD18cr.js +0 -1
  326. package/src/console/ui-next/dist/assets/detail-header-DVWNjWjg.js +0 -1
  327. package/src/console/ui-next/dist/assets/dropdown-menu-4h2LOVXM.js +0 -1
  328. package/src/console/ui-next/dist/assets/duration-tone-BmIR9FV8.js +0 -9
  329. package/src/console/ui-next/dist/assets/element-icons-BI8cJgdh.js +0 -1
  330. package/src/console/ui-next/dist/assets/explorer-empty-CJs5A-wm.js +0 -1
  331. package/src/console/ui-next/dist/assets/flows-page-BMHs-IzK.js +0 -1
  332. package/src/console/ui-next/dist/assets/index-BKpaes3n.js +0 -63
  333. package/src/console/ui-next/dist/assets/index-VxoEz295.css +0 -2
  334. package/src/console/ui-next/dist/assets/observability-page-WnVLI-0j.js +0 -4
  335. package/src/console/ui-next/dist/assets/request-meta-DMbnAe3f.js +0 -1
  336. package/src/console/ui-next/dist/assets/shortcut-keys-3ILd8oGn.js +0 -1
  337. package/src/console/ui-next/dist/assets/store-page-KvFDingJ.js +0 -41
  338. package/src/console/ui-next/dist/assets/trace-detail-sheet-Htk8Cm9t.js +0 -2
  339. package/src/console/ui-next/dist/assets/tree-expand-toggle-CFMPWX4f.js +0 -55
  340. package/src/console/ui-next/dist/assets/units-page-C4NdNuxP.js +0 -1
  341. package/src/console/ui-next/dist/assets/use-vault-list-CT4-gajj.js +0 -1
  342. package/src/console/ui-next/dist/assets/vault-page-CBYl0LW_.js +0 -2
@@ -0,0 +1,556 @@
1
+ /**
2
+ * HTTP idempotency — claim, replay, lease reclaim, and the client timeout path.
3
+ */
4
+
5
+ import { afterEach, describe, expect, test } from "bun:test";
6
+ import { z } from "zod";
7
+ import { createClient } from "../client/create.ts";
8
+ import type { AppOf } from "../client/types.ts";
9
+ import {
10
+ createPostgresJournalFake,
11
+ createPostgresJournalStore,
12
+ } from "../drivers/journal-postgres.ts";
13
+ import { gate } from "../elements/gate.ts";
14
+ import { store } from "../elements/store.ts";
15
+ import { oke, type OkeApp } from "./app.ts";
16
+ import { flow, resetFlowSeq, type AnyFlowDef } from "./flow.ts";
17
+ import { createMemoryJournalStore, type JournalStore } from "./journal.ts";
18
+ import type { JournalRuntime } from "./boot-bind/journal.ts";
19
+ import type { Binding } from "./on.ts";
20
+ import { resetBindings } from "./on.ts";
21
+ import { decideClaim, IDEMPOTENCY_LEASE_MS } from "./idempotency-store.ts";
22
+ import { http } from "./triggers.ts";
23
+
24
+ const KEY = "idem-key-0123456789";
25
+
26
+ const apps: OkeApp[] = [];
27
+
28
+ afterEach(async () => {
29
+ resetBindings();
30
+ resetFlowSeq();
31
+ for (const app of apps) await app.bootResult?.close();
32
+ apps.length = 0;
33
+ });
34
+
35
+ function journalOf(store: JournalStore, leaseMs = IDEMPOTENCY_LEASE_MS): JournalRuntime {
36
+ return { store, instanceId: "idem-test", leaseMs, driverId: "memory" };
37
+ }
38
+
39
+ async function start(
40
+ name: string,
41
+ bindings: readonly Binding[],
42
+ journal?: JournalRuntime,
43
+ stores?: ReturnType<typeof store.kv>[],
44
+ policies?: ReturnType<typeof gate.policy>[],
45
+ ): Promise<OkeApp> {
46
+ resetBindings();
47
+ resetFlowSeq();
48
+ const app = oke({
49
+ name,
50
+ env: "test",
51
+ registry: "ignore",
52
+ gate: {
53
+ unguardedHttp: "allow",
54
+ ...(policies !== undefined ? { policies } : {}),
55
+ },
56
+ bindings: [...bindings],
57
+ ...(stores !== undefined ? { stores } : {}),
58
+ ...(journal !== undefined ? { elements: { journal } } : {}),
59
+ });
60
+ await app.boot({ env: "test", ...(policies !== undefined ? { gates: policies } : {}) });
61
+ apps.push(app);
62
+ return app;
63
+ }
64
+
65
+ function chargeBinding(run: () => Promise<{ n: number }> | { n: number }): Binding {
66
+ return {
67
+ trigger: http.post("/charge", { in: z.object({ n: z.number() }) }),
68
+ flow: flow("pay.charge", {
69
+ effects: { writes: ["sql:payments"] },
70
+ do: async () => run(),
71
+ }) as AnyFlowDef,
72
+ };
73
+ }
74
+
75
+ function post(body: unknown, key: string | false = KEY): Request {
76
+ const headers = new Headers({ "content-type": "application/json" });
77
+ if (key !== false) headers.set("idempotency-key", key);
78
+ return new Request("http://localhost/charge", {
79
+ method: "POST",
80
+ headers,
81
+ body: JSON.stringify(body),
82
+ });
83
+ }
84
+
85
+ describe("idempotency store", () => {
86
+ test("a live same-key claim is busy; a different payload mismatches", () => {
87
+ const scope = { tenant: "", principal: "anon", flow: "pay.charge", key: KEY };
88
+ const row = {
89
+ ...scope,
90
+ fingerprint: "aaa",
91
+ status: "in_progress" as const,
92
+ claimToken: "t1",
93
+ leaseExpiresAt: 2_000,
94
+ createdAt: 0,
95
+ expiresAt: 9_000,
96
+ };
97
+ const input = {
98
+ scope,
99
+ fingerprint: "aaa",
100
+ claimToken: "t2",
101
+ now: 1_000,
102
+ leaseMs: 30_000,
103
+ ttlMs: 1,
104
+ };
105
+ expect(decideClaim(row, input, new Set(["t1"])).op).toBe("busy");
106
+ expect(decideClaim(row, { ...input, fingerprint: "bbb" }, new Set()).op).toBe("mismatch");
107
+ expect(decideClaim({ ...row, leaseExpiresAt: 0 }, input, new Set()).op).toBe("reclaim");
108
+ }, 30_000);
109
+
110
+ test("postgres fake replays one claim and rejects a second payload", async () => {
111
+ const journal = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
112
+ const idem = journal.idempotency!;
113
+ const scope = { tenant: "", principal: "anon", flow: "pay.charge", key: KEY };
114
+ const first = await idem.claim({
115
+ scope,
116
+ fingerprint: "aaa",
117
+ claimToken: "t1",
118
+ now: 1_000,
119
+ leaseMs: 30_000,
120
+ ttlMs: 5_000,
121
+ });
122
+ expect(first.kind).toBe("claimed");
123
+ await idem.complete(scope, "t1", { status: 200, body: '{"data":1}' });
124
+ const again = await idem.claim({
125
+ scope,
126
+ fingerprint: "aaa",
127
+ claimToken: "t2",
128
+ now: 1_100,
129
+ leaseMs: 30_000,
130
+ ttlMs: 5_000,
131
+ });
132
+ expect(again.kind).toBe("replay");
133
+ const reused = await idem.claim({
134
+ scope,
135
+ fingerprint: "bbb",
136
+ claimToken: "t3",
137
+ now: 1_100,
138
+ leaseMs: 30_000,
139
+ ttlMs: 5_000,
140
+ });
141
+ expect(reused.kind).toBe("mismatch");
142
+ }, 30_000);
143
+ });
144
+
145
+ describe("HTTP idempotency", () => {
146
+ test("the same key runs once and the second response is a replay", async () => {
147
+ let runs = 0;
148
+ const app = await start("idem-replay", [
149
+ chargeBinding(() => {
150
+ runs += 1;
151
+ return { n: runs };
152
+ }),
153
+ ]);
154
+ const first = await app.fetch(post({ n: 1 }));
155
+ const second = await app.fetch(post({ n: 1 }));
156
+ expect(first.status).toBe(200);
157
+ expect(second.status).toBe(200);
158
+ expect(second.headers.get("idempotent-replayed")).toBe("true");
159
+ expect(await second.json()).toEqual(await first.json());
160
+ expect(runs).toBe(1);
161
+ }, 30_000);
162
+
163
+ test("two concurrent same-key requests: one runs, the other is 409", async () => {
164
+ let runs = 0;
165
+ let release!: () => void;
166
+ const hold = new Promise<void>((resolve) => {
167
+ release = resolve;
168
+ });
169
+ const app = await start("idem-race", [
170
+ chargeBinding(async () => {
171
+ runs += 1;
172
+ await hold;
173
+ return { n: 1 };
174
+ }),
175
+ ]);
176
+ const pending = Promise.all([
177
+ app.fetch(post({ n: 1 })).then(async (res) => {
178
+ if (res.status === 409) release();
179
+ return res;
180
+ }),
181
+ app.fetch(post({ n: 1 })).then(async (res) => {
182
+ if (res.status === 409) release();
183
+ return res;
184
+ }),
185
+ ]);
186
+ const [a, b] = await pending;
187
+ const statuses = [a.status, b.status].sort();
188
+ expect(statuses).toEqual([200, 409]);
189
+ const conflict = a.status === 409 ? a : b;
190
+ expect(((await conflict.json()) as { error: { code: string } }).error.code).toBe(
191
+ "IdempotencyInProgress",
192
+ );
193
+ expect(runs).toBe(1);
194
+ }, 30_000);
195
+
196
+ test("the same key with a different payload is 422", async () => {
197
+ const app = await start("idem-reuse", [chargeBinding(() => ({ n: 1 }))]);
198
+ expect((await app.fetch(post({ n: 1 }))).status).toBe(200);
199
+ const reused = await app.fetch(post({ n: 2 }));
200
+ expect(reused.status).toBe(422);
201
+ expect(((await reused.json()) as { error: { code: string } }).error.code).toBe(
202
+ "IdempotencyKeyReused",
203
+ );
204
+ }, 30_000);
205
+
206
+ test("a validation or gate rejection stores nothing; the same key then succeeds", async () => {
207
+ let runs = 0;
208
+ const member = gate.policy("member", ({ auth }) => auth.verified === true);
209
+ const charge = flow("pay.charge", {
210
+ effects: { writes: ["sql:payments"] },
211
+ do: () => {
212
+ runs += 1;
213
+ return { n: 1 };
214
+ },
215
+ }) as AnyFlowDef;
216
+ charge.in = z.object({ n: z.number() });
217
+ const binding: Binding = {
218
+ trigger: http.post("/charge").gate(member),
219
+ flow: charge,
220
+ };
221
+ const app = await start("idem-gate", [binding], undefined, undefined, [member]);
222
+ const invalid = await app.fetch(post({ n: "nope" }));
223
+ expect(invalid.status).toBe(422);
224
+ const denied = await app.fetch(post({ n: 1 }));
225
+ expect(denied.status).toBe(401);
226
+ const allowed = await app.execute(binding.flow, { n: 1 }, binding.trigger, {
227
+ request: post({ n: 1 }),
228
+ validated: true,
229
+ principal: { plane: "user", userId: "u1", scopes: new Set(), verified: true },
230
+ });
231
+ expect(allowed.failure).toBeUndefined();
232
+ expect(allowed.output).toEqual({ n: 1 });
233
+ expect(runs).toBe(1);
234
+ }, 30_000);
235
+
236
+ test("a throw before any mutation deletes the row; a throw after a write replays 500", async () => {
237
+ let early = 0;
238
+ const earlyApp = await start("idem-early", [
239
+ chargeBinding(() => {
240
+ early += 1;
241
+ throw new Error("before");
242
+ }),
243
+ ]);
244
+ expect((await earlyApp.fetch(post({ n: 1 }))).status).toBe(500);
245
+ const earlyRetry = await earlyApp.fetch(post({ n: 1 }));
246
+ expect(earlyRetry.status).toBe(500);
247
+ expect(earlyRetry.headers.get("idempotent-replayed")).toBeNull();
248
+ expect(early).toBe(2);
249
+
250
+ const box = store.kv("box");
251
+ let writes = 0;
252
+ const lateApp = await start(
253
+ "idem-late",
254
+ [
255
+ {
256
+ trigger: http.post("/charge", { in: z.object({ n: z.number() }) }),
257
+ flow: flow("pay.charge", {
258
+ effects: { writes: ["kv:box"] },
259
+ do: async (_input, fx) => {
260
+ writes += 1;
261
+ await fx.store(box).set("k", "v");
262
+ throw new Error("after");
263
+ },
264
+ }) as AnyFlowDef,
265
+ },
266
+ ],
267
+ undefined,
268
+ [box],
269
+ );
270
+ const first = await lateApp.fetch(post({ n: 1 }));
271
+ const second = await lateApp.fetch(post({ n: 1 }));
272
+ expect(first.status).toBe(500);
273
+ expect(second.status).toBe(500);
274
+ expect(second.headers.get("idempotent-replayed")).toBe("true");
275
+ expect(writes).toBe(1);
276
+ }, 30_000);
277
+
278
+ test("a forfeited lease re-executes a non-durable flow and resumes a durable one", async () => {
279
+ const memory = createMemoryJournalStore();
280
+ let runs = 0;
281
+ let release!: () => void;
282
+ const hold = new Promise<void>((resolve) => {
283
+ release = resolve;
284
+ });
285
+ const plain = await start(
286
+ "idem-reclaim",
287
+ [
288
+ chargeBinding(async () => {
289
+ runs += 1;
290
+ await hold;
291
+ return { n: runs };
292
+ }),
293
+ ],
294
+ journalOf(memory, 80),
295
+ );
296
+ const first = plain.fetch(post({ n: 1 }));
297
+ expect(await waitFor(() => runs === 1)).toBe(true);
298
+ const row = await memory.idempotency!.read({
299
+ tenant: "",
300
+ principal: "anon",
301
+ flow: "pay.charge",
302
+ key: KEY,
303
+ });
304
+ expect(row?.status).toBe("in_progress");
305
+ expect(await memory.idempotency!.forfeit(row!, row!.claimToken)).toBe(true);
306
+ const second = plain.fetch(post({ n: 1 }));
307
+ expect(await waitFor(() => runs === 2)).toBe(true);
308
+ release();
309
+ expect((await first).status).toBe(200);
310
+ expect((await second).status).toBe(200);
311
+
312
+ const durableStore = createMemoryJournalStore();
313
+ const step1: string[] = [];
314
+ const blockers: Array<() => void> = [];
315
+ const durable = await start(
316
+ "idem-durable",
317
+ [
318
+ {
319
+ trigger: http.post("/charge", { in: z.object({ n: z.number() }) }),
320
+ flow: flow("pay.charge", {
321
+ durable: true,
322
+ effects: { writes: ["sql:payments"] },
323
+ do: async (_input, fx) => {
324
+ await fx.step("create-intent", () => {
325
+ step1.push("step1");
326
+ return { id: "pi_1" };
327
+ });
328
+ await fx.step("mid-flight", async () => {
329
+ await new Promise<void>((resolve) => {
330
+ blockers.push(resolve);
331
+ });
332
+ return "done";
333
+ });
334
+ return { n: 1 };
335
+ },
336
+ }) as AnyFlowDef,
337
+ },
338
+ ],
339
+ journalOf(durableStore, 80),
340
+ );
341
+ const hung = durable.fetch(post({ n: 1 }));
342
+ expect(await waitFor(() => blockers.length === 1)).toBe(true);
343
+ const held = await durableStore.idempotency!.read({
344
+ tenant: "",
345
+ principal: "anon",
346
+ flow: "pay.charge",
347
+ key: KEY,
348
+ });
349
+ expect(await durableStore.idempotency!.forfeit(held!, held!.claimToken)).toBe(true);
350
+ await Bun.sleep(120);
351
+ const resumed = durable.fetch(post({ n: 1 }));
352
+ expect(await waitFor(() => blockers.length === 2)).toBe(true);
353
+ expect(step1).toEqual(["step1"]);
354
+ blockers[0]!();
355
+ blockers[1]!();
356
+ expect((await hung).status).toBe(200);
357
+ expect((await resumed).status).toBe(200);
358
+ expect(step1).toEqual(["step1"]);
359
+ }, 30_000);
360
+
361
+ test("an expired ttl runs again; streams and reads ignore the key; required demands it", async () => {
362
+ let runs = 0;
363
+ const ttlApp = await start("idem-ttl", [
364
+ {
365
+ trigger: http.post("/charge", { in: z.object({ n: z.number() }) }),
366
+ flow: flow("pay.charge", {
367
+ effects: { writes: ["sql:payments"] },
368
+ idempotency: { ttl: "1ms" },
369
+ do: () => {
370
+ runs += 1;
371
+ return { n: runs };
372
+ },
373
+ }) as AnyFlowDef,
374
+ },
375
+ ]);
376
+ expect((await ttlApp.fetch(post({ n: 1 }))).status).toBe(200);
377
+ await Bun.sleep(20);
378
+ expect((await ttlApp.fetch(post({ n: 1 }))).status).toBe(200);
379
+ expect(runs).toBe(2);
380
+
381
+ let reads = 0;
382
+ const readApp = await start("idem-read", [
383
+ {
384
+ trigger: http.get("/charge"),
385
+ flow: flow("pay.read", {
386
+ effects: { reads: ["sql:payments"] },
387
+ cache: false,
388
+ do: () => {
389
+ reads += 1;
390
+ return { n: reads };
391
+ },
392
+ }) as AnyFlowDef,
393
+ },
394
+ ]);
395
+ const get = () =>
396
+ new Request("http://localhost/charge", { headers: { "idempotency-key": KEY } });
397
+ await readApp.fetch(get());
398
+ await readApp.fetch(get());
399
+ expect(reads).toBe(2);
400
+
401
+ let streamed = 0;
402
+ const streamApp = await start("idem-stream", [
403
+ {
404
+ trigger: http.post("/charge", { in: z.object({ n: z.number() }) }),
405
+ flow: flow("pay.charge", {
406
+ stream: true,
407
+ effects: { writes: ["sql:payments"] },
408
+ do: () => {
409
+ streamed += 1;
410
+ return { n: streamed };
411
+ },
412
+ }) as AnyFlowDef,
413
+ },
414
+ ]);
415
+ await streamApp.fetch(post({ n: 1 }));
416
+ await streamApp.fetch(post({ n: 1 }));
417
+ expect(streamed).toBe(2);
418
+
419
+ let requiredRuns = 0;
420
+ const requiredApp = await start("idem-required", [
421
+ {
422
+ trigger: http.post("/charge", { in: z.object({ n: z.number() }) }),
423
+ flow: flow("pay.charge", {
424
+ effects: { writes: ["sql:payments"] },
425
+ idempotency: "required",
426
+ do: () => {
427
+ requiredRuns += 1;
428
+ return { n: 1 };
429
+ },
430
+ }) as AnyFlowDef,
431
+ },
432
+ ]);
433
+ const missing = await requiredApp.fetch(post({ n: 1 }, false));
434
+ expect(missing.status).toBe(400);
435
+ expect(((await missing.json()) as { error: { code: string } }).error.code).toBe(
436
+ "IdempotencyKeyMissing",
437
+ );
438
+ expect(requiredRuns).toBe(0);
439
+ const bad = await requiredApp.fetch(post({ n: 1 }, "short"));
440
+ expect(bad.status).toBe(400);
441
+ expect(((await bad.json()) as { error: { code: string } }).error.code).toBe(
442
+ "IdempotencyKeyInvalid",
443
+ );
444
+ }, 30_000);
445
+
446
+ test("two principals with the same key are two records", async () => {
447
+ let runs = 0;
448
+ const binding = chargeBinding(() => {
449
+ runs += 1;
450
+ return { n: runs };
451
+ });
452
+ const memory = createMemoryJournalStore();
453
+ const app = await start("idem-principals", [binding], journalOf(memory));
454
+ const call = (userId: string) =>
455
+ app.execute(binding.flow, { n: 1 }, binding.trigger, {
456
+ request: post({ n: 1 }),
457
+ validated: true,
458
+ principal: { plane: "user", userId, scopes: new Set(), verified: true },
459
+ });
460
+ expect((await call("a")).output).toEqual({ n: 1 });
461
+ expect((await call("b")).output).toEqual({ n: 2 });
462
+ expect(runs).toBe(2);
463
+ expect(
464
+ await memory.idempotency!.read({
465
+ tenant: "",
466
+ principal: "user:a",
467
+ flow: "pay.charge",
468
+ key: KEY,
469
+ }),
470
+ ).toBeDefined();
471
+ expect(
472
+ await memory.idempotency!.read({
473
+ tenant: "",
474
+ principal: "user:b",
475
+ flow: "pay.charge",
476
+ key: KEY,
477
+ }),
478
+ ).toBeDefined();
479
+ }, 30_000);
480
+
481
+ test("a client timeout leaves do running, then a 409 waits and the replay arrives", async () => {
482
+ let runs = 0;
483
+ let aborted = 0;
484
+ let release!: () => void;
485
+ const hold = new Promise<void>((resolve) => {
486
+ release = resolve;
487
+ });
488
+ const memory = createMemoryJournalStore();
489
+ const app = await start(
490
+ "idem-timeout",
491
+ [
492
+ {
493
+ trigger: http.post("/charge", { in: z.object({ n: z.number() }) }),
494
+ flow: flow("pay.charge", {
495
+ effects: { writes: ["sql:payments"] },
496
+ do: async (_input, fx) => {
497
+ runs += 1;
498
+ if (fx.signal.aborted) aborted += 1;
499
+ fx.signal.addEventListener("abort", () => {
500
+ aborted += 1;
501
+ });
502
+ await hold;
503
+ return { ok: true as const };
504
+ },
505
+ }) as AnyFlowDef,
506
+ },
507
+ ],
508
+ journalOf(memory, 400),
509
+ );
510
+ type PayApp = AppOf<{
511
+ pay: {
512
+ charge: { in: { n: number }; out: { ok: true }; errors: Record<string, never> };
513
+ };
514
+ }>;
515
+ const api = createClient<PayApp>("http://localhost", {
516
+ retry: { retries: 2, delay: 1, backoff: 1 },
517
+ timeout: 300,
518
+ routes: { "pay.charge": { method: "POST", path: "/charge" } },
519
+ fetch: (input, init) => {
520
+ const href =
521
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
522
+ const pending = app.fetch(new Request(href, init));
523
+ const signal = init?.signal;
524
+ if (signal == null) return pending;
525
+ return new Promise((resolve, reject) => {
526
+ pending.then((res) => {
527
+ if (res.status === 409) release();
528
+ resolve(res);
529
+ }, reject);
530
+ if (signal.aborted) {
531
+ reject(new DOMException("Aborted", "AbortError"));
532
+ return;
533
+ }
534
+ signal.addEventListener("abort", () => {
535
+ reject(new DOMException("Aborted", "AbortError"));
536
+ });
537
+ });
538
+ },
539
+ });
540
+ const result = await api.pay.charge({ n: 1 });
541
+ expect(result.error).toBeNull();
542
+ expect(result.data).toEqual({ ok: true });
543
+ expect(result.meta?.idempotentReplayed).toBe(true);
544
+ expect(runs).toBe(1);
545
+ expect(aborted).toBe(0);
546
+ }, 30_000);
547
+ });
548
+
549
+ async function waitFor(cond: () => boolean, timeoutMs = 2_000): Promise<boolean> {
550
+ const deadline = Date.now() + timeoutMs;
551
+ while (Date.now() < deadline) {
552
+ if (cond()) return true;
553
+ await Bun.sleep(10);
554
+ }
555
+ return cond();
556
+ }