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
@@ -63,6 +63,7 @@ describe("extractManifest — five trigger types", () => {
63
63
  http: { method: "POST", path: "/orders" },
64
64
  });
65
65
  expect(manifest.flows?.["triggers.http"]?.effects?.writes).toEqual(["sql:orders"]);
66
+ expect(manifest.flows?.["triggers.http"]?.idempotency).toEqual({ mode: "auto", ttl: "24h" });
66
67
 
67
68
  expect(manifest.flows?.["triggers.every"]?.trigger).toEqual({
68
69
  every: "10m",
@@ -86,6 +87,7 @@ describe("extractManifest — five trigger types", () => {
86
87
  true,
87
88
  );
88
89
  expect(internal?.effects?.reads).toEqual(["sql:links"]);
90
+ expect(internal?.idempotency).toEqual({ mode: "off", ttl: "24h" });
89
91
  });
90
92
  });
91
93
 
@@ -99,9 +101,44 @@ describe("extractManifest — fx.raw", () => {
99
101
  });
100
102
 
101
103
  expect(manifest.flows?.["raw.unannotated"]?.cache).toBe(false);
104
+ expect(manifest.flows?.["raw.unannotated"]?.usesRaw).toBe(true);
105
+ expect(manifest.flows?.["raw.unannotated"]?.idempotency).toEqual({ mode: "off", ttl: "24h" });
102
106
  expect(manifest.flows?.["raw.annotated"]?.cache).toBeUndefined();
107
+ expect(manifest.flows?.["raw.annotated"]?.usesRaw).toBe(true);
103
108
  expect(manifest.flows?.["raw.annotated"]?.effects?.reads).toEqual(["sql:orders"]);
104
109
  });
110
+
111
+ test("required on a GET flow is an extract error; a bad ttl is an extract error", async () => {
112
+ const requiredGet = `
113
+ import { on, flow, http } from "okengine";
114
+ export const read = on(
115
+ http.get("/read"),
116
+ flow("pay.read", { idempotency: "required", do: async () => ({ ok: true }) }),
117
+ );
118
+ `;
119
+ await expect(extractFromSources({ "src/flows/pay.ts": requiredGet })).rejects.toThrow(
120
+ /header would be ignored/,
121
+ );
122
+
123
+ const badTtl = `
124
+ import { on, flow, http, store, field } from "okengine";
125
+ export const db = store.sql("db");
126
+ export const payments = store.schema.table("payments", { id: field.text().primaryKey() });
127
+ export const charge = on(
128
+ http.post("/charge"),
129
+ flow("pay.charge", {
130
+ idempotency: { ttl: "nope" },
131
+ do: async (input, fx) => {
132
+ await fx.store(db).insert(payments).values(input);
133
+ return { ok: true };
134
+ },
135
+ }),
136
+ );
137
+ `;
138
+ await expect(extractFromSources({ "src/flows/pay.ts": badTtl })).rejects.toThrow(
139
+ /not a duration/,
140
+ );
141
+ });
105
142
  });
106
143
 
107
144
  describe("extractManifest — performance", () => {
@@ -6,6 +6,7 @@
6
6
  * from source to `manifest.oke.json`.
7
7
  */
8
8
 
9
+ import { readdir } from "node:fs/promises";
9
10
  import { parseSync } from "oxc-parser";
10
11
  import { join } from "node:path";
11
12
 
@@ -15,11 +16,19 @@ import {
15
16
  getAiProviderEntry,
16
17
  } from "../elements/ai/providers.ts";
17
18
  import { buildCronExpression, type CronField } from "../elements/clock/cron-fields.ts";
19
+ import {
20
+ hasMutatingEffects,
21
+ idempotencyTtlMs,
22
+ resolveIdempotencyMode,
23
+ ttlOf,
24
+ type FlowIdempotencyOption,
25
+ } from "../kernel/idempotency.ts";
18
26
  import { SearchConfigError } from "../elements/store/search-errors.ts";
19
27
 
20
28
  import type {
21
29
  Ai,
22
30
  AiAgent,
31
+ AiDecision,
23
32
  AiMcpServer,
24
33
  AiModel,
25
34
  AiPrompt,
@@ -45,6 +54,7 @@ import type {
45
54
  } from "../manifest/types.ts";
46
55
  import { sqlTableRef } from "../manifest/sql-resource.ts";
47
56
  import {
57
+ effectsMissingFromDeclaration,
48
58
  identifierName,
49
59
  inferEffects,
50
60
  resolveCallTarget,
@@ -56,6 +66,7 @@ import {
56
66
  type InferBinding,
57
67
  type Literal,
58
68
  } from "./effects-infer.ts";
69
+ import { buildFxIndex, readTsconfigPaths, type FxIndex } from "./fx-index.ts";
59
70
  import {
60
71
  isFlowsTreeFile,
61
72
  isSkippedExtractSource,
@@ -131,6 +142,8 @@ interface ProjectScope {
131
142
  /** Store-wide live defaulting — `oke({ store: { live: true } })`. */
132
143
  store?: Manifest["store"];
133
144
  flows: Record<string, Flow>;
145
+ /** Project functions that can receive `fx`, built once per extract. */
146
+ fxIndex: FxIndex;
134
147
  /** Export name → flow id (for agent tools). */
135
148
  flowExports: Map<string, string>;
136
149
  /** Local binding name → store.resource declaration (for on(http.resource)). */
@@ -168,6 +181,7 @@ export async function extractManifest(options: ExtractManifestOptions = {}): Pro
168
181
 
169
182
  const scope: ProjectScope = {
170
183
  app: options.app ?? "app",
184
+ fxIndex: buildFxIndex([]),
171
185
  bindings: new Map(),
172
186
  signals: {},
173
187
  stores: {},
@@ -198,6 +212,12 @@ export async function extractManifest(options: ExtractManifestOptions = {}): Pro
198
212
  return { file, program: result.program as unknown as AstNode };
199
213
  });
200
214
 
215
+ const tsPaths = options.rootDir ? await readTsconfigPaths(options.rootDir) : [];
216
+ scope.fxIndex = buildFxIndex(
217
+ parsed.map(({ file, program }) => ({ path: file.path, program })),
218
+ tsPaths,
219
+ );
220
+
201
221
  // Pass 1 — declarations (elements, config, named bindings).
202
222
  for (const { file, program } of parsed) {
203
223
  collectDeclarations(file.path, program, scope);
@@ -238,11 +258,18 @@ export async function extractManifest(options: ExtractManifestOptions = {}): Pro
238
258
  if (Object.keys(gates).length > 0) manifest.gates = gates;
239
259
  if (Object.keys(vault).length > 0) manifest.vault = vault;
240
260
  if (Object.keys(channels).length > 0) manifest.channels = channels;
241
- if (scope.ai.models || scope.ai.prompts || scope.ai.agents || scope.ai.mcpServers) {
261
+ if (
262
+ scope.ai.models ||
263
+ scope.ai.prompts ||
264
+ scope.ai.agents ||
265
+ scope.ai.decisions ||
266
+ scope.ai.mcpServers
267
+ ) {
242
268
  manifest.ai = {
243
269
  ...(scope.ai.models ? { models: sortRecord(scope.ai.models) } : {}),
244
270
  ...(scope.ai.prompts ? { prompts: sortRecord(scope.ai.prompts) } : {}),
245
271
  ...(scope.ai.agents ? { agents: sortRecord(scope.ai.agents) } : {}),
272
+ ...(scope.ai.decisions ? { decisions: sortRecord(scope.ai.decisions) } : {}),
246
273
  ...(scope.ai.mcpServers ? { mcpServers: sortRecord(scope.ai.mcpServers) } : {}),
247
274
  };
248
275
  }
@@ -295,21 +322,50 @@ export async function extractFromSources(
295
322
  }
296
323
 
297
324
  async function readSources(rootDir: string, pattern: string): Promise<SourceFile[]> {
298
- // Bun.Glob (global) — bare `import … from "bun"` is rejected by JSR.
299
- const glob = new Bun.Glob(pattern);
300
325
  const files: SourceFile[] = [];
301
- for await (const path of glob.scan({
302
- cwd: rootDir,
303
- onlyFiles: true,
304
- })) {
305
- if (isSkippedExtractSource(path)) continue;
306
- const abs = join(rootDir, path);
307
- files.push({ path: toPosixPath(path), source: await Bun.file(abs).text() });
326
+ if (pattern === "**/*.{ts,tsx}") {
327
+ await walkSources(rootDir, rootDir, files);
328
+ } else {
329
+ // Bun.Glob (global) — bare `import … from "bun"` is rejected by JSR.
330
+ const glob = new Bun.Glob(pattern);
331
+ for await (const path of glob.scan({ cwd: rootDir, onlyFiles: true })) {
332
+ if (isSkippedExtractSource(path)) continue;
333
+ const abs = join(rootDir, path);
334
+ files.push({ path: toPosixPath(path), source: await Bun.file(abs).text() });
335
+ }
308
336
  }
309
337
  files.sort((a, b) => a.path.localeCompare(b.path));
310
338
  return files;
311
339
  }
312
340
 
341
+ /**
342
+ * Walk an app tree without entering `node_modules` or directory symlinks.
343
+ *
344
+ * A template's `node_modules/okengine` link points back at this repo, which
345
+ * contains the template again. Globbing through that link never terminates.
346
+ */
347
+ async function walkSources(rootDir: string, dir: string, files: SourceFile[]): Promise<void> {
348
+ let entries;
349
+ try {
350
+ entries = await readdir(dir, { withFileTypes: true });
351
+ } catch {
352
+ return;
353
+ }
354
+ for (const entry of entries) {
355
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.isSymbolicLink()) continue;
356
+ const abs = join(dir, entry.name);
357
+ if (entry.isDirectory()) {
358
+ await walkSources(rootDir, abs, files);
359
+ continue;
360
+ }
361
+ if (!entry.isFile()) continue;
362
+ if (!entry.name.endsWith(".ts") && !entry.name.endsWith(".tsx")) continue;
363
+ const rel = toPosixPath(abs.slice(rootDir.length + 1));
364
+ if (isSkippedExtractSource(rel)) continue;
365
+ files.push({ path: rel, source: await Bun.file(abs).text() });
366
+ }
367
+ }
368
+
313
369
  function collectDeclarations(filePath: string, program: AstNode, scope: ProjectScope): void {
314
370
  walk(program, (node) => {
315
371
  if (node.type === "CallExpression") {
@@ -1378,7 +1434,11 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
1378
1434
  }
1379
1435
 
1380
1436
  if (obj === "ai" && prop === "agent") {
1381
- collectAgent(call, scope);
1437
+ collectAgent(call, program, scope);
1438
+ }
1439
+
1440
+ if (obj === "ai" && prop === "decision") {
1441
+ collectDecision(call, program, scope);
1382
1442
  }
1383
1443
 
1384
1444
  if (obj === "ai" && prop === "mcpServer") {
@@ -1446,6 +1506,7 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
1446
1506
  : {}),
1447
1507
  ...(via && via.length > 0 ? { via } : {}),
1448
1508
  ...(timeout !== undefined ? { timeout } : {}),
1509
+ ...(numberProp(opts, "repair") === 1 ? { repair: 1 as const } : {}),
1449
1510
  };
1450
1511
  scope.ai.prompts = scope.ai.prompts ?? {};
1451
1512
  scope.ai.prompts[promptName] = prompt;
@@ -1600,27 +1661,267 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
1600
1661
  }
1601
1662
  }
1602
1663
 
1603
- function collectAgent(call: CallExpression, scope: ProjectScope): void {
1664
+ function agentToolEntry(
1665
+ el: AstNode,
1666
+ scope: ProjectScope,
1667
+ ): { name: string; approval: boolean } | undefined {
1668
+ if (el.type === "ObjectExpression") {
1669
+ const nameNode = objectProp(el, "name");
1670
+ const raw =
1671
+ (nameNode ? resolveCallTarget(nameNode, scope.bindings) : undefined) ??
1672
+ stringArg(nameNode) ??
1673
+ identifierName(nameNode);
1674
+ if (!raw) return undefined;
1675
+ const name = scope.flowExports.get(raw) ?? scope.bindings.get(raw)?.ref ?? raw;
1676
+ const approvalNode = objectProp(el, "approval");
1677
+ return { name, approval: approvalNode !== undefined && boolProp(el, "approval") !== false };
1678
+ }
1679
+ const resolved = resolveCallTarget(el, scope.bindings);
1680
+ const raw = resolved ?? identifierName(el) ?? stringArg(el);
1681
+ if (!raw) return undefined;
1682
+ const name = scope.flowExports.get(raw) ?? scope.bindings.get(raw)?.ref ?? raw;
1683
+ return { name, approval: false };
1684
+ }
1685
+
1686
+ function assertDecisionPlacement(flowName: string, flow: Flow, scope: ProjectScope): void {
1687
+ const names = flow.effects?.decides ?? [];
1688
+ if (names.length === 0) return;
1689
+ const http = flow.trigger !== undefined && "http" in flow.trigger;
1690
+ for (const decisionName of names) {
1691
+ const decision = scope.ai.decisions?.[decisionName];
1692
+ if (!decision || decision.mode !== "review") continue;
1693
+ if (http) {
1694
+ throw new Error(
1695
+ `ai.decision("${decisionName}") review cannot run in HTTP flow "${flowName}" — a park answers 204. fx.emit a signal and decide in a consumer.`,
1696
+ );
1697
+ }
1698
+ if (flow.durable !== true) {
1699
+ throw new Error(
1700
+ `ai.decision("${decisionName}"): flow "${flowName}" must set durable: true to review`,
1701
+ );
1702
+ }
1703
+ }
1704
+ }
1705
+
1706
+ function decisionReviewName(node: AstNode | undefined, scope: ProjectScope): string {
1707
+ const literal = stringArg(node);
1708
+ if (literal) return literal;
1709
+ if (node?.type === "Identifier") {
1710
+ const name = (node as Identifier).name;
1711
+ return scope.gateIds.get(name) ?? name;
1712
+ }
1713
+ if (node?.type === "CallExpression") {
1714
+ const arg = stringArg((node as CallExpression).arguments[0]);
1715
+ if (arg) return arg;
1716
+ }
1717
+ if (node?.type === "MemberExpression") {
1718
+ const member = node as AstNode & { property: AstNode; computed?: boolean };
1719
+ if (member.property.type === "Identifier" && member.computed !== true) {
1720
+ return (member.property as Identifier).name;
1721
+ }
1722
+ }
1723
+ throw new Error("ai.decision: review gate name could not be resolved");
1724
+ }
1725
+
1726
+ function assertDecisionQuestion(
1727
+ decisionName: string,
1728
+ key: string,
1729
+ value: AstNode | undefined,
1730
+ program: AstNode,
1731
+ ): void {
1732
+ const resolved = resolveDecisionExpr(value, program, decisionName, key);
1733
+ if (!resolved || resolved.type !== "CallExpression") {
1734
+ throw new Error(
1735
+ `ai.decision("${decisionName}"): question "${key}" must be ai.choice, ai.score, or ai.boolean`,
1736
+ );
1737
+ }
1738
+ const callee = (resolved as CallExpression).callee;
1739
+ const member = callee as AstNode & { property?: AstNode };
1740
+ const fn =
1741
+ callee.type === "MemberExpression" && member.property?.type === "Identifier"
1742
+ ? (member.property as Identifier).name
1743
+ : undefined;
1744
+ const args = (resolved as CallExpression).arguments;
1745
+ if (fn === "choice") {
1746
+ const options = args[1];
1747
+ if (!options || options.type !== "ObjectExpression") {
1748
+ throw new Error(
1749
+ `ai.decision("${decisionName}"): choice "${key}" options must be an object literal`,
1750
+ );
1751
+ }
1752
+ const keys = objectProperties(options)
1753
+ .map((prop) => propKey(prop))
1754
+ .filter((name): name is string => typeof name === "string");
1755
+ if (keys.includes("none_of_these")) {
1756
+ throw new Error(`ai.decision("${decisionName}"): choice "${key}" must not set none_of_these`);
1757
+ }
1758
+ if (keys.length > 254) {
1759
+ throw new Error(
1760
+ `ai.decision("${decisionName}"): choice "${key}" has ${keys.length} options; max 254`,
1761
+ );
1762
+ }
1763
+ }
1764
+ if (fn === "score") {
1765
+ const levels = resolveScoreLevels(args[1], program, decisionName, key);
1766
+ const count = ((levels as AstNode & { elements?: unknown[] }).elements ?? []).length;
1767
+ if (count < 2 || count > 10) {
1768
+ throw new Error(`ai.decision("${decisionName}"): score "${key}" needs 2–10 levels`);
1769
+ }
1770
+ }
1771
+ }
1772
+
1773
+ function resolveDecisionExpr(
1774
+ value: AstNode | undefined,
1775
+ program: AstNode,
1776
+ decisionName: string,
1777
+ key: string,
1778
+ ): AstNode | undefined {
1779
+ if (!value) return undefined;
1780
+ if (value.type !== "Identifier") return value;
1781
+ const name = (value as Identifier).name;
1782
+ const init = sameFileConstInit(name, program);
1783
+ if (!init) {
1784
+ throw new Error(
1785
+ `ai.decision("${decisionName}"): question "${key}" is "${name}", which is not a same-file const`,
1786
+ );
1787
+ }
1788
+ return init;
1789
+ }
1790
+
1791
+ function resolveScoreLevels(
1792
+ levels: AstNode | undefined,
1793
+ program: AstNode,
1794
+ decisionName: string,
1795
+ key: string,
1796
+ ): AstNode {
1797
+ const resolved =
1798
+ levels?.type === "Identifier"
1799
+ ? sameFileConstInit((levels as Identifier).name, program)
1800
+ : levels;
1801
+ if (!resolved || resolved.type !== "ArrayExpression") {
1802
+ const label = levels?.type === "Identifier" ? (levels as Identifier).name : "the levels";
1803
+ throw new Error(
1804
+ `ai.decision("${decisionName}"): score "${key}" levels (${label}) must be an array literal or a same-file const`,
1805
+ );
1806
+ }
1807
+ return resolved;
1808
+ }
1809
+
1810
+ function sameFileConstInit(name: string, program: AstNode): AstNode | undefined {
1811
+ let found: AstNode | undefined;
1812
+ walk(program, (node) => {
1813
+ if (found || node.type !== "VariableDeclarator") return;
1814
+ const decl = node as AstNode & { id?: AstNode; init?: AstNode };
1815
+ if (decl.id?.type === "Identifier" && (decl.id as Identifier).name === name) {
1816
+ found = decl.init;
1817
+ }
1818
+ });
1819
+ return found;
1820
+ }
1821
+
1822
+ function collectDecision(call: CallExpression, program: AstNode, scope: ProjectScope): void {
1823
+ const decisionName = stringArg(call.arguments[0]);
1824
+ const opts = objectArg(call.arguments[1]);
1825
+ if (!decisionName || !opts) return;
1826
+ const ask = objectProp(opts, "ask");
1827
+ const questionProps = ask?.type === "ObjectExpression" ? objectProperties(ask) : [];
1828
+ const questionNames = questionProps
1829
+ .map((prop) => propKey(prop))
1830
+ .filter((name): name is string => typeof name === "string" && name.length > 0);
1831
+ if (questionNames.length === 0) {
1832
+ throw new Error(`ai.decision("${decisionName}"): ask is empty`);
1833
+ }
1834
+ for (const prop of questionProps) {
1835
+ const key = propKey(prop);
1836
+ if (!key) continue;
1837
+ if (key === "meta" || key === "$") {
1838
+ throw new Error(`ai.decision("${decisionName}"): question id "${key}" is reserved`);
1839
+ }
1840
+ assertDecisionQuestion(
1841
+ decisionName,
1842
+ key,
1843
+ (prop as AstNode & { value?: AstNode }).value,
1844
+ program,
1845
+ );
1846
+ }
1847
+ if (scope.ai.decisions?.[decisionName]) {
1848
+ throw new Error(`ai.decision("${decisionName}"): duplicate decision name`);
1849
+ }
1850
+ const hasReview = objectProp(opts, "review") !== undefined;
1851
+ const uncertain = stringProp(opts, "onUncertain");
1852
+ const abstain = uncertain === "abstain";
1853
+ if (hasReview === abstain) {
1854
+ throw new Error(
1855
+ `ai.decision("${decisionName}"): declare exactly one of review or onUncertain: "abstain"`,
1856
+ );
1857
+ }
1858
+ const autonomyNode = objectProp(opts, "autonomy");
1859
+ let autonomy: AiDecision["autonomy"];
1860
+ if (autonomyNode?.type === "ObjectExpression") {
1861
+ if (objectProp(autonomyNode, "audit") === undefined) {
1862
+ throw new Error(`ai.decision("${decisionName}"): autonomy requires audit`);
1863
+ }
1864
+ const maxError = numberProp(autonomyNode, "maxError");
1865
+ const audit = numberProp(autonomyNode, "audit");
1866
+ const risk = numberProp(autonomyNode, "risk");
1867
+ if (maxError === undefined || audit === undefined) {
1868
+ throw new Error(`ai.decision("${decisionName}"): autonomy requires maxError and audit`);
1869
+ }
1870
+ autonomy = { maxError, audit, ...(risk !== undefined ? { risk } : {}) };
1871
+ }
1872
+ const evals = stringProp(opts, "evals");
1873
+ const decision: AiDecision = {
1874
+ mode: abstain ? "abstain" : "review",
1875
+ questions: questionNames,
1876
+ ...(hasReview ? { review: decisionReviewName(objectProp(opts, "review"), scope) } : {}),
1877
+ ...(stringProp(opts, "model") ? { model: stringProp(opts, "model") } : {}),
1878
+ driverId: stringProp(opts, "driverId") === "typesafe" ? "typesafe" : "openrouter",
1879
+ ...(evals !== undefined ? { evals } : {}),
1880
+ ...(autonomy !== undefined ? { autonomy } : {}),
1881
+ };
1882
+ scope.ai.decisions = scope.ai.decisions ?? {};
1883
+ scope.ai.decisions[decisionName] = decision;
1884
+ const bindingName = enclosingConstName(call, program);
1885
+ if (bindingName) {
1886
+ scope.bindings.set(bindingName, { kind: "decision", ref: decisionName });
1887
+ }
1888
+ }
1889
+
1890
+ function collectAgent(call: CallExpression, program: AstNode, scope: ProjectScope): void {
1604
1891
  const agentName = stringArg(call.arguments[0]);
1605
1892
  const opts = objectArg(call.arguments[1]);
1606
1893
  if (!agentName || !opts) return;
1607
1894
  const toolsArr = arrayProp(opts, "tools");
1608
- const tools = toolsArr
1609
- ?.map((el) => {
1610
- const resolved = resolveCallTarget(el, scope.bindings);
1611
- if (resolved) return resolved;
1612
- return identifierName(el) ?? stringArg(el);
1613
- })
1614
- .filter((x): x is string => typeof x === "string")
1615
- .map((id) => scope.flowExports.get(id) ?? scope.bindings.get(id)?.ref ?? id);
1895
+ const entries = (toolsArr ?? [])
1896
+ .map((el) => agentToolEntry(el, scope))
1897
+ .filter((entry): entry is { name: string; approval: boolean } => entry !== undefined);
1898
+ const tools = entries.map((entry) => entry.name);
1899
+ const agentCalls = tools.filter(
1900
+ (name) => scope.bindings.get(name)?.kind === "agent" || scope.ai.agents?.[name],
1901
+ );
1902
+ const approvals = Object.fromEntries(
1903
+ entries.filter((entry) => entry.approval).map((entry) => [entry.name, true as const]),
1904
+ );
1616
1905
  const agent: AiAgent = {
1617
- ...(tools && tools.length > 0 ? { tools } : {}),
1906
+ ...(tools.length > 0 ? { tools } : {}),
1907
+ ...(Object.keys(approvals).length > 0 ? { approvals } : {}),
1618
1908
  ...(numberProp(opts, "maxSteps") !== undefined
1619
1909
  ? { maxSteps: numberProp(opts, "maxSteps") }
1620
1910
  : {}),
1911
+ ...(numberProp(opts, "maxDepth") !== undefined
1912
+ ? { maxDepth: numberProp(opts, "maxDepth") }
1913
+ : {}),
1621
1914
  };
1622
1915
  scope.ai.agents = scope.ai.agents ?? {};
1623
1916
  scope.ai.agents[agentName] = agent;
1917
+ const bindingName = enclosingConstName(call, program);
1918
+ if (bindingName) {
1919
+ scope.bindings.set(bindingName, {
1920
+ kind: "agent",
1921
+ ref: agentName,
1922
+ ...(agentCalls.length > 0 ? { agentCalls } : {}),
1923
+ });
1924
+ }
1624
1925
  }
1625
1926
 
1626
1927
  function collectMcpServer(call: CallExpression, program: AstNode, scope: ProjectScope): void {
@@ -1882,13 +2183,17 @@ function registerFlow(args: {
1882
2183
  }
1883
2184
  args.scope.bindings.set(name, { kind: "flow", ref: name });
1884
2185
 
1885
- const hasExplicitEffects = objectProp(opts, "effects") !== undefined;
2186
+ const effectsNode = objectProp(opts, "effects");
2187
+ const hasExplicitEffects = effectsNode !== undefined;
1886
2188
  const doNode = objectProp(opts, "do");
1887
2189
  const inferred = doNode
1888
2190
  ? inferEffects({
1889
2191
  doNode,
1890
2192
  bindings: args.scope.bindings,
1891
2193
  hasExplicitEffects,
2194
+ flowName: name,
2195
+ file: args.file.path,
2196
+ resolveCallee: (callee, file) => args.scope.fxIndex.resolve(file, callee),
1892
2197
  })
1893
2198
  : {
1894
2199
  effects: {} as Effects,
@@ -1900,8 +2205,16 @@ function registerFlow(args: {
1900
2205
  };
1901
2206
 
1902
2207
  let effects: Effects | undefined;
1903
- if (hasExplicitEffects) {
1904
- effects = parseEffectsObject(objectProp(opts, "effects"));
2208
+ if (hasExplicitEffects && effectsNode?.type === "ObjectExpression") {
2209
+ effects = parseEffectsObject(effectsNode);
2210
+ const missing = effectsMissingFromDeclaration(effects ?? {}, inferred.effects);
2211
+ if (missing.length > 0) {
2212
+ throw new Error(
2213
+ `OKE1900: flow "${name}" declares effects that omit ${missing.join(", ")}. An effects block must include every effect inference can see.`,
2214
+ );
2215
+ }
2216
+ } else if (hasExplicitEffects) {
2217
+ effects = parseEffectsObject(effectsNode);
1905
2218
  } else {
1906
2219
  effects = inferred.effects;
1907
2220
  }
@@ -1950,6 +2263,7 @@ function registerFlow(args: {
1950
2263
  flow.source = `${args.file.path}:${line}`;
1951
2264
 
1952
2265
  if (boolProp(opts, "durable")) flow.durable = true;
2266
+ assertDecisionPlacement(name, flow, args.scope);
1953
2267
  if (typeof liveFromTrigger === "string") {
1954
2268
  flow.live = liveFromTrigger;
1955
2269
  // Manual live surface — `.live(tableBinding)` on the trigger. Synthesize
@@ -2039,6 +2353,24 @@ function registerFlow(args: {
2039
2353
  flow.cacheKeys = `computed:${effects.reads[0]}/userId`;
2040
2354
  }
2041
2355
 
2356
+ const idempotencyOption = parseIdempotencyOption(opts);
2357
+ const httpMethod = manifestTrigger?.http?.method;
2358
+ const stream = boolProp(opts, "stream") === true;
2359
+ const live = typeof liveFromTrigger === "string";
2360
+ const canHonor =
2361
+ httpMethod !== "GET" && !stream && !live && (inferred.usesRaw || hasMutatingEffects(effects));
2362
+ if (idempotencyRequired(idempotencyOption) && !canHonor) {
2363
+ throw new Error(
2364
+ `flow "${name}" sets idempotency required, but the header would be ignored (read-only, GET, stream, or live).`,
2365
+ );
2366
+ }
2367
+ const ttl = ttlOf(idempotencyOption);
2368
+ if (idempotencyTtlMs(ttl) <= 0) {
2369
+ throw new Error(`flow "${name}" has an idempotency ttl that is not a duration ("${ttl}").`);
2370
+ }
2371
+ flow.idempotency = resolveIdempotencyMode(idempotencyOption, canHonor);
2372
+ if (inferred.usesRaw) flow.usesRaw = true;
2373
+
2042
2374
  putFlow(args.scope, name, flow);
2043
2375
  }
2044
2376
 
@@ -2830,6 +3162,7 @@ function parseEffectsObject(node: AstNode | undefined): Effects | undefined {
2830
3162
  const secrets = stringArrayProp(node, "secrets");
2831
3163
  const calls = stringArrayProp(node, "calls");
2832
3164
  const fetches = stringArrayProp(node, "fetches");
3165
+ const decides = stringArrayProp(node, "decides");
2833
3166
  if (reads) effects.reads = reads as Effects["reads"];
2834
3167
  if (writes) effects.writes = writes as Effects["writes"];
2835
3168
  if (emits) effects.emits = emits;
@@ -2839,6 +3172,7 @@ function parseEffectsObject(node: AstNode | undefined): Effects | undefined {
2839
3172
  if (secrets) effects.secrets = secrets;
2840
3173
  if (calls) effects.calls = calls;
2841
3174
  if (fetches) effects.fetches = fetches;
3175
+ if (decides) effects.decides = decides;
2842
3176
  return effects;
2843
3177
  }
2844
3178
 
@@ -3279,6 +3613,29 @@ function numberProp(obj: AstNode | undefined, key: string): number | undefined {
3279
3613
  return typeof v === "number" ? v : undefined;
3280
3614
  }
3281
3615
 
3616
+ function parseIdempotencyOption(opts: AstNode | undefined): FlowIdempotencyOption | undefined {
3617
+ const node = objectProp(opts, "idempotency");
3618
+ if (!node) return undefined;
3619
+ if (node.type === "Literal") {
3620
+ const value = (node as Literal).value;
3621
+ if (value === false) return false;
3622
+ if (value === "required") return "required";
3623
+ }
3624
+ if (node.type === "ObjectExpression") {
3625
+ const required = boolProp(node, "required");
3626
+ const ttl = stringProp(node, "ttl");
3627
+ return {
3628
+ ...(required !== undefined ? { required } : {}),
3629
+ ...(ttl !== undefined ? { ttl } : {}),
3630
+ };
3631
+ }
3632
+ return undefined;
3633
+ }
3634
+
3635
+ function idempotencyRequired(option: FlowIdempotencyOption | undefined): boolean {
3636
+ return option === "required" || (typeof option === "object" && option.required === true);
3637
+ }
3638
+
3282
3639
  function boolProp(obj: AstNode | undefined, key: string): boolean | undefined {
3283
3640
  const node = objectProp(obj, key);
3284
3641
  if (!node || node.type !== "Literal") return undefined;
@@ -3,6 +3,10 @@
3
3
  "app": "skyport",
4
4
  "flows": {
5
5
  "bookings.create": {
6
+ "idempotency": {
7
+ "mode": "auto",
8
+ "ttl": "24h"
9
+ },
6
10
  "trigger": {
7
11
  "http": {
8
12
  "method": "POST",
@@ -29,12 +33,20 @@
29
33
  "plane": "user"
30
34
  },
31
35
  "bookings.getBooking": {
36
+ "idempotency": {
37
+ "mode": "off",
38
+ "ttl": "24h"
39
+ },
32
40
  "effects": {
33
41
  "reads": ["sql:bookings"]
34
42
  },
35
43
  "source": "src/flows/bookings/index.ts:50"
36
44
  },
37
45
  "bookings.mine": {
46
+ "idempotency": {
47
+ "mode": "off",
48
+ "ttl": "24h"
49
+ },
38
50
  "trigger": {
39
51
  "http": {
40
52
  "method": "GET",
@@ -49,6 +61,10 @@
49
61
  "cacheKeys": "computed:sql:bookings/userId"
50
62
  },
51
63
  "bookings.refundBooking": {
64
+ "idempotency": {
65
+ "mode": "auto",
66
+ "ttl": "24h"
67
+ },
52
68
  "effects": {
53
69
  "reads": ["sql:bookings"],
54
70
  "writes": ["sql:bookings"]
@@ -56,6 +72,10 @@
56
72
  "source": "src/flows/bookings/index.ts:54"
57
73
  },
58
74
  "payments.chargeBooking": {
75
+ "idempotency": {
76
+ "mode": "off",
77
+ "ttl": "24h"
78
+ },
59
79
  "effects": {
60
80
  "secrets": ["STRIPE_KEY"]
61
81
  },
@@ -64,6 +84,10 @@
64
84
  "steps": ["create-intent", "confirm"]
65
85
  },
66
86
  "support.createTicket": {
87
+ "idempotency": {
88
+ "mode": "auto",
89
+ "ttl": "24h"
90
+ },
67
91
  "trigger": {
68
92
  "http": {
69
93
  "method": "POST",