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,164 @@
1
+ /**
2
+ * Decision extraction: exclusive modes, and review cannot sit on HTTP.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { extractFromSources } from "./extract.ts";
7
+
8
+ const header = `const triage = ai.decision("triage", {\n`;
9
+
10
+ describe("ai.decision extract", () => {
11
+ test("review and abstain together fail to compile", async () => {
12
+ await expect(
13
+ extractFromSources({
14
+ "src/flows/run.ts": `${header} review: "ops",\n onUncertain: "abstain",\n ask: { team: ai.choice("which", { a: "A" }) },\n});\n`,
15
+ }),
16
+ ).rejects.toThrow(/exactly one of review/);
17
+ });
18
+
19
+ test("an HTTP trigger that reviews fails to compile", async () => {
20
+ await expect(
21
+ extractFromSources({
22
+ "src/flows/run.ts": `
23
+ const triage = ai.decision("triage", {
24
+ review: "ops",
25
+ ask: { team: ai.choice("which", { a: "A" }) },
26
+ });
27
+ on(http.post("/t"), flow("run", {
28
+ durable: true,
29
+ do: async (input, fx) => fx.decide(triage, input),
30
+ }));
31
+ `,
32
+ }),
33
+ ).rejects.toThrow(/fx\.emit/);
34
+ });
35
+
36
+ test("choice, score, and duplicate names fail at compile time", async () => {
37
+ const many = Array.from({ length: 255 }, (_, i) => `o${i}: "x"`).join(", ");
38
+ await expect(
39
+ extractFromSources({
40
+ "src/flows/run.ts": `${header} onUncertain: "abstain",\n ask: { team: ai.choice("which", { ${many} }) },\n});\n`,
41
+ }),
42
+ ).rejects.toThrow(/max 254/);
43
+ await expect(
44
+ extractFromSources({
45
+ "src/flows/run.ts": `${header} onUncertain: "abstain",\n ask: { team: ai.choice("which", { none_of_these: null }) },\n});\n`,
46
+ }),
47
+ ).rejects.toThrow(/none_of_these/);
48
+ await expect(
49
+ extractFromSources({
50
+ "src/flows/run.ts": `${header} onUncertain: "abstain",\n ask: { rank: ai.score("rank", ["only"]) },\n});\n`,
51
+ }),
52
+ ).rejects.toThrow(/2–10 levels/);
53
+ await expect(
54
+ extractFromSources({
55
+ "src/flows/a.ts": `${header} onUncertain: "abstain",\n ask: { team: ai.choice("which", { a: "A", b: "B" }) },\n});\n`,
56
+ "src/flows/b.ts": `${header} onUncertain: "abstain",\n ask: { team: ai.choice("which", { a: "A", b: "B" }) },\n});\n`,
57
+ }),
58
+ ).rejects.toThrow(/duplicate decision name/);
59
+ });
60
+
61
+ test("review records the gate name", async () => {
62
+ const manifest = await extractFromSources({
63
+ "src/flows/run.ts": `
64
+ const ops = gate.policy("ops", () => true);
65
+ const triage = ai.decision("triage", {
66
+ review: ops,
67
+ ask: { team: ai.choice("which", { a: "A", b: "B" }) },
68
+ });
69
+ on(signal.once("job"), flow("run", {
70
+ durable: true,
71
+ do: async (input, fx) => fx.decide(triage, input),
72
+ }));
73
+ `,
74
+ });
75
+ expect(manifest.ai?.decisions?.triage?.review).toBe("ops");
76
+ });
77
+
78
+ test("review resolves a variable to the gate's declared name", async () => {
79
+ const manifest = await extractFromSources({
80
+ "src/flows/run.ts": `
81
+ const someVar = gate.policy("ops", () => true);
82
+ const triage = ai.decision("triage", {
83
+ review: someVar,
84
+ ask: { team: ai.choice("which", { a: "A", b: "B" }) },
85
+ });
86
+ on(signal.once("job"), flow("run", {
87
+ durable: true,
88
+ do: async (input, fx) => fx.decide(triage, input),
89
+ }));
90
+ `,
91
+ });
92
+ expect(manifest.ai?.decisions?.triage?.review).toBe("ops");
93
+ });
94
+
95
+ test("autonomy and evals survive extraction", async () => {
96
+ const manifest = await extractFromSources({
97
+ "src/flows/run.ts": `
98
+ const triage = ai.decision("triage", {
99
+ onUncertain: "abstain",
100
+ evals: "evals/triage.jsonl",
101
+ autonomy: { maxError: 0.05, audit: 0.1, risk: 0.1 },
102
+ ask: { team: ai.choice("which", { a: "A", b: "B" }) },
103
+ });
104
+ `,
105
+ });
106
+ expect(manifest.ai?.decisions?.triage?.evals).toBe("evals/triage.jsonl");
107
+ expect(manifest.ai?.decisions?.triage?.autonomy).toEqual({
108
+ maxError: 0.05,
109
+ audit: 0.1,
110
+ risk: 0.1,
111
+ });
112
+ });
113
+
114
+ test("choice options passed through a variable fail to compile", async () => {
115
+ await expect(
116
+ extractFromSources({
117
+ "src/flows/run.ts": `
118
+ const options = { a: "A", b: "B" };
119
+ ai.decision("triage", {
120
+ onUncertain: "abstain",
121
+ ask: { team: ai.choice("which", options) },
122
+ });
123
+ `,
124
+ }),
125
+ ).rejects.toThrow(/object literal/);
126
+ });
127
+
128
+ test("score levels and a whole question resolve a same-file const", async () => {
129
+ const manifest = await extractFromSources({
130
+ "src/flows/run.ts": `
131
+ const levels = ["low", "high"];
132
+ const team = ai.choice("which", { a: "A", b: "B" });
133
+ ai.decision("triage", {
134
+ onUncertain: "abstain",
135
+ ask: { team, rank: ai.score("rank", levels) },
136
+ });
137
+ `,
138
+ });
139
+ expect(manifest.ai?.decisions?.triage?.questions).toEqual(["team", "rank"]);
140
+ });
141
+
142
+ test("an unresolved score or question fails to compile", async () => {
143
+ await expect(
144
+ extractFromSources({
145
+ "src/flows/run.ts": `
146
+ ai.decision("triage", {
147
+ onUncertain: "abstain",
148
+ ask: { rank: ai.score("rank", missing) },
149
+ });
150
+ `,
151
+ }),
152
+ ).rejects.toThrow(/same-file const/);
153
+ await expect(
154
+ extractFromSources({
155
+ "src/flows/run.ts": `
156
+ ai.decision("triage", {
157
+ onUncertain: "abstain",
158
+ ask: { team: missingQuestion },
159
+ });
160
+ `,
161
+ }),
162
+ ).rejects.toThrow(/not a same-file const/);
163
+ });
164
+ });
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
6
+ import { extractFromSources } from "./extract.ts";
6
7
  import { inferEffects, type AstNode, type InferBinding } from "./effects-infer.ts";
7
8
 
8
9
  function parseDo(src: string): AstNode {
@@ -39,5 +40,81 @@ describe("inferEffects — fx.fetch → effects.fetches", () => {
39
40
  expect(inferred.effects.fetches).toEqual(["api.stripe.com"]);
40
41
  expect(inferred.effects.calls).toBeUndefined();
41
42
  expect(inferred.effects.sends).toBeUndefined();
43
+ expect(inferred.bareIrreversible).toEqual(["fetch"]);
44
+ });
45
+
46
+ test("fx.fetch inside fx.step is not a bare irreversible call", () => {
47
+ const stepBody = {
48
+ type: "ArrowFunctionExpression",
49
+ params: [],
50
+ body: {
51
+ type: "BlockStatement",
52
+ body: [
53
+ {
54
+ type: "ExpressionStatement",
55
+ expression: {
56
+ type: "CallExpression",
57
+ callee: {
58
+ type: "MemberExpression",
59
+ object: { type: "Identifier", name: "fx" },
60
+ property: { type: "Identifier", name: "fetch" },
61
+ computed: false,
62
+ },
63
+ arguments: [{ type: "Literal", value: "https://example.com/data" }],
64
+ },
65
+ },
66
+ ],
67
+ },
68
+ };
69
+ const inferred = inferEffects({
70
+ doNode: {
71
+ type: "ArrowFunctionExpression",
72
+ params: [
73
+ { type: "Identifier", name: "_input" },
74
+ { type: "Identifier", name: "fx" },
75
+ ],
76
+ body: {
77
+ type: "BlockStatement",
78
+ body: [
79
+ {
80
+ type: "ExpressionStatement",
81
+ expression: {
82
+ type: "CallExpression",
83
+ callee: {
84
+ type: "MemberExpression",
85
+ object: { type: "Identifier", name: "fx" },
86
+ property: { type: "Identifier", name: "step" },
87
+ computed: false,
88
+ },
89
+ arguments: [{ type: "Literal", value: "load" }, stepBody],
90
+ },
91
+ },
92
+ ],
93
+ },
94
+ } as AstNode,
95
+ bindings: new Map<string, InferBinding>(),
96
+ hasExplicitEffects: false,
97
+ });
98
+ expect(inferred.bareIrreversible).toEqual([]);
99
+ expect(inferred.steps).toEqual(["load"]);
100
+ expect(inferred.effects.fetches).toEqual(["example.com"]);
101
+ });
102
+ });
103
+
104
+ describe("extract — durable fx.fetch outside fx.step", () => {
105
+ test("OKE1901 when a durable flow calls fx.fetch directly", async () => {
106
+ await expect(
107
+ extractFromSources({
108
+ "src/flows/load.ts": `
109
+ import { flow, on, http } from "okengine";
110
+ export const load = on(http.get("/load"), flow("load", {
111
+ durable: true,
112
+ do: async (_input, fx) => {
113
+ return fx.fetch("https://example.com/data");
114
+ },
115
+ }));
116
+ `,
117
+ }),
118
+ ).rejects.toThrow(/OKE1901/);
42
119
  });
43
120
  });
@@ -57,10 +57,12 @@ export interface InferBinding {
57
57
  | "signal"
58
58
  | "clock"
59
59
  | "prompt"
60
+ | "agent"
60
61
  | "secret"
61
62
  | "template"
62
63
  | "flow"
63
64
  | "embed"
65
+ | "decision"
64
66
  | "table"
65
67
  | "mcp-server"
66
68
  | "mcp-tool"
@@ -71,6 +73,8 @@ export interface InferBinding {
71
73
  readonly facet?: "sql" | "kv" | "files" | "index";
72
74
  /** Prompt version when kind is `prompt`. */
73
75
  readonly version?: number;
76
+ /** Nested agent tools when kind is `agent`. */
77
+ readonly agentCalls?: readonly string[];
74
78
  }
75
79
 
76
80
  /** What a callee that is passed `fx` resolves to. */
@@ -106,6 +110,11 @@ export interface InferEffectsOptions {
106
110
  * never pass `fx` into another function.
107
111
  */
108
112
  readonly resolveCallee?: (callee: AstNode, file: string) => FxCalleeResolution;
113
+ /**
114
+ * Flow name of an imported handle in `file`. Absent when inference does
115
+ * not follow imports (unit tests).
116
+ */
117
+ readonly resolveFlowRef?: (localName: string, file: string) => string | undefined;
109
118
  }
110
119
 
111
120
  /** Result of effect inference for one flow body. */
@@ -125,6 +134,11 @@ export interface InferredEffects {
125
134
  readonly nondeterministic: boolean;
126
135
  /** True when the body references `fx.auth.userId`. */
127
136
  readonly readsUserId: boolean;
137
+ /**
138
+ * `fx.fetch` / `fx.send` calls that are not inside an `fx.step` callback.
139
+ * A `durable: true` flow must not call these directly.
140
+ */
141
+ readonly bareIrreversible: readonly ("fetch" | "send")[];
128
142
  }
129
143
 
130
144
  const READ_METHODS = new Set([
@@ -141,6 +155,10 @@ const READ_METHODS = new Set([
141
155
  "page",
142
156
  "search",
143
157
  "raw",
158
+ "innerJoin",
159
+ "leftJoin",
160
+ "rightJoin",
161
+ "fullJoin",
144
162
  ]);
145
163
 
146
164
  const WRITE_METHODS = new Set([
@@ -171,6 +189,10 @@ const TABLE_ARG_METHODS = new Set([
171
189
  "count",
172
190
  "page",
173
191
  "search",
192
+ "innerJoin",
193
+ "leftJoin",
194
+ "rightJoin",
195
+ "fullJoin",
174
196
  ]);
175
197
 
176
198
  /**
@@ -190,6 +212,7 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
190
212
  const secrets = new Set<SecretRef>();
191
213
  const calls = new Set<FlowRef>();
192
214
  const fetches = new Set<FetchHostRef>();
215
+ const decides = new Set<string>();
193
216
  const steps: string[] = [];
194
217
  let usesRaw = false;
195
218
 
@@ -199,7 +222,8 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
199
222
  name: "liveQuery" | "applySearchEmbedCdc";
200
223
  call: CallExpression;
201
224
  }> = [];
202
- const chains = collectFxChains(options, opaque, userIdRoots, intrinsics);
225
+ const bareIrreversible = new Set<"fetch" | "send">();
226
+ const chains = collectFxChains(options, opaque, userIdRoots, intrinsics, bareIrreversible);
203
227
 
204
228
  for (const { call, chain } of chains) {
205
229
  if (chain.rootMethod === "raw") {
@@ -237,6 +261,17 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
237
261
  continue;
238
262
  }
239
263
 
264
+ if (chain.rootMethod === "run" && call === chain.rootCall) {
265
+ const binding = resolveBinding(call.arguments[0], options.bindings);
266
+ const name =
267
+ stringArg(call.arguments[0]) ?? (binding?.kind === "agent" ? binding.ref : undefined);
268
+ if (name) asks.add(name);
269
+ if (binding?.kind === "agent") {
270
+ for (const child of binding.agentCalls ?? []) calls.add(child);
271
+ }
272
+ continue;
273
+ }
274
+
240
275
  if (chain.rootMethod === "ask" && call === chain.rootCall) {
241
276
  const ref = resolvePrompt(call.arguments[0], options.bindings);
242
277
  if (ref) asks.add(ref);
@@ -268,6 +303,14 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
268
303
  continue;
269
304
  }
270
305
 
306
+ if (chain.rootMethod === "decide" && call === chain.rootCall) {
307
+ const binding = resolveBinding(call.arguments[0], options.bindings);
308
+ const name =
309
+ stringArg(call.arguments[0]) ?? (binding?.kind === "decision" ? binding.ref : undefined);
310
+ if (name) decides.add(name);
311
+ continue;
312
+ }
313
+
271
314
  if (chain.rootMethod === "fetch" && call === chain.rootCall) {
272
315
  // fx.fetch(url) — host becomes effects.fetches entry.
273
316
  const lit = stringArg(call.arguments[0]);
@@ -289,7 +332,13 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
289
332
  }
290
333
 
291
334
  if (chain.rootMethod === "call" && call === chain.rootCall) {
292
- const ref = resolveCallTarget(call.arguments[0], options.bindings);
335
+ const arg = unwrapValueNode(call.arguments[0]);
336
+ const local = identifierName(arg);
337
+ const imported =
338
+ local && options.file && options.resolveFlowRef
339
+ ? options.resolveFlowRef(local, options.file)
340
+ : undefined;
341
+ const ref = imported ?? resolveCallTarget(call.arguments[0], options.bindings);
293
342
  if (ref) calls.add(ref);
294
343
  continue;
295
344
  }
@@ -335,18 +384,28 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
335
384
  // Skip incomplete chains (`select` before `.from`, `insert` before table) —
336
385
  // the sibling call that carries the table arg records the real resource.
337
386
  const leaf = resolved.methods[resolved.methods.length - 1]!;
338
- const hasTable = tableFromStoreChain(call, options.bindings, chain) !== undefined;
387
+ const tables = tablesFromStoreChain(call, options.bindings, chain);
388
+ const hasTable = tables.length > 0;
339
389
  if (!hasTable && (leaf === "select" || leaf === "insert" || leaf === "update")) {
340
390
  continue;
341
391
  }
342
392
  // Intermediate chain links that only forward (where/values/returning)
343
393
  // still carry read/write from earlier methods — record once we have a table
344
394
  // or a terminal key-based op.
345
- if (!hasTable && (leaf === "where" || leaf === "values" || leaf === "returning")) {
395
+ if (
396
+ !hasTable &&
397
+ (leaf === "where" || leaf === "values" || leaf === "returning" || leaf === "groupBy")
398
+ ) {
346
399
  continue;
347
400
  }
348
- if (op === "read" || op === "both") reads.add(resolved.resource);
349
- if (op === "write" || op === "both") writes.add(resolved.resource);
401
+ const resources =
402
+ tables.length > 0
403
+ ? tables.map((table) => sqlTableRef(table) as ResourceRef)
404
+ : [resolved.resource];
405
+ for (const resource of resources) {
406
+ if (op === "read" || op === "both") reads.add(resource);
407
+ if (op === "write" || op === "both") writes.add(resource);
408
+ }
350
409
  }
351
410
  }
352
411
 
@@ -374,14 +433,16 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
374
433
  if (secrets.size > 0) effects.secrets = sortUnique([...secrets]);
375
434
  if (calls.size > 0) effects.calls = sortUnique([...calls]);
376
435
  if (fetches.size > 0) effects.fetches = sortUnique([...fetches]);
436
+ if (decides.size > 0) effects.decides = sortUnique([...decides]);
377
437
 
378
438
  return {
379
439
  effects,
380
440
  steps,
381
441
  usesRaw,
382
442
  cacheIneligible: usesRaw && !options.hasExplicitEffects,
383
- nondeterministic: asks.size > 0 || embeds.size > 0,
443
+ nondeterministic: asks.size > 0 || embeds.size > 0 || decides.size > 0,
384
444
  readsUserId: userIdRoots.some((node) => containsAuthUserId(node)),
445
+ bareIrreversible: [...bareIrreversible],
385
446
  };
386
447
  }
387
448
 
@@ -395,6 +456,7 @@ const EFFECT_KEYS = [
395
456
  "secrets",
396
457
  "calls",
397
458
  "fetches",
459
+ "decides",
398
460
  ] as const satisfies readonly (keyof Effects)[];
399
461
 
400
462
  /**
@@ -584,21 +646,37 @@ function tableFromStoreChain(
584
646
  bindings: ReadonlyMap<string, InferBinding>,
585
647
  chain?: FxChain,
586
648
  ): string | undefined {
649
+ return tablesFromStoreChain(call, bindings, chain)[0];
650
+ }
651
+
652
+ /**
653
+ * Every table argument on a store chain, including joins.
654
+ *
655
+ * @param call - Any call in the chain
656
+ * @param bindings - Scope bindings
657
+ * @param chain - Precomputed chain when already known
658
+ */
659
+ function tablesFromStoreChain(
660
+ call: CallExpression,
661
+ bindings: ReadonlyMap<string, InferBinding>,
662
+ chain?: FxChain,
663
+ ): string[] {
587
664
  const found = tableFromStoreWalk(call, bindings, chain);
588
- if (found) return found;
665
+ if (found.length > 0) return found;
589
666
  if (chain?.aliasLeaf && chain.aliasLeaf !== call) {
590
667
  return tableFromStoreWalk(chain.aliasLeaf, bindings);
591
668
  }
592
- return undefined;
669
+ return [];
593
670
  }
594
671
 
595
672
  function tableFromStoreWalk(
596
673
  call: CallExpression,
597
674
  bindings: ReadonlyMap<string, InferBinding>,
598
675
  chain?: FxChain,
599
- ): string | undefined {
676
+ ): string[] {
600
677
  let current: AstNode | undefined = call;
601
678
  const seen = new Set<AstNode>();
679
+ const names: string[] = [];
602
680
  while (current && current.type === "CallExpression" && !seen.has(current)) {
603
681
  seen.add(current);
604
682
  const c = current as CallExpression;
@@ -618,7 +696,8 @@ function tableFromStoreWalk(
618
696
  const id = identifierName(c.arguments[0]);
619
697
  if (id) {
620
698
  const binding = bindings.get(id);
621
- return binding?.kind === "table" ? binding.ref : id;
699
+ const tableName = binding?.kind === "table" ? binding.ref : id;
700
+ if (!names.includes(tableName)) names.push(tableName);
622
701
  }
623
702
  }
624
703
  }
@@ -632,7 +711,7 @@ function tableFromStoreWalk(
632
711
  }
633
712
  break;
634
713
  }
635
- return undefined;
714
+ return names;
636
715
  }
637
716
 
638
717
  function methodNameOfCall(call: CallExpression): string | undefined {
@@ -882,7 +961,9 @@ function collectFxChains(
882
961
  opaque: string[],
883
962
  userIdRoots: AstNode[],
884
963
  intrinsics: Array<{ name: "liveQuery" | "applySearchEmbedCdc"; call: CallExpression }>,
964
+ bareIrreversible: Set<"fetch" | "send">,
885
965
  ): ChainHit[] {
966
+ const stepBodies = new Set<AstNode>();
886
967
  const hits: ChainHit[] = [];
887
968
  const seen = new Set<AstNode>();
888
969
 
@@ -892,7 +973,7 @@ function collectFxChains(
892
973
  aliases: ReadonlyMap<string, ChainAlias>,
893
974
  locals: ReadonlyMap<string, AstNode>,
894
975
  ): void => {
895
- analyzeFunction(fn, "fx", aliases, locals, file);
976
+ analyzeFunction(fn, "fx", aliases, locals, file, stepBodies.has(fn));
896
977
  };
897
978
 
898
979
  const analyzeFunction = (
@@ -901,13 +982,14 @@ function collectFxChains(
901
982
  outerAliases: ReadonlyMap<string, ChainAlias>,
902
983
  outerLocals: ReadonlyMap<string, AstNode>,
903
984
  file: string,
985
+ insideStep: boolean,
904
986
  ): void => {
905
987
  if (seen.has(fn)) return;
906
988
  seen.add(fn);
907
989
  userIdRoots.push(fn);
908
990
  const body = (fn as AstNode & { body?: AstNode }).body;
909
991
  if (!body) return;
910
- analyzeBody(body, door, outerAliases, outerLocals, file);
992
+ analyzeBody(body, door, outerAliases, outerLocals, file, insideStep);
911
993
  };
912
994
 
913
995
  const analyzeBody = (
@@ -916,6 +998,7 @@ function collectFxChains(
916
998
  outerAliases: ReadonlyMap<string, ChainAlias>,
917
999
  outerLocals: ReadonlyMap<string, AstNode>,
918
1000
  file: string,
1001
+ insideStep: boolean,
919
1002
  ): void => {
920
1003
  const aliases = new Map(outerAliases);
921
1004
  const locals = new Map(outerLocals);
@@ -925,6 +1008,20 @@ function collectFxChains(
925
1008
  });
926
1009
  for (const call of callsSkippingNested(body)) {
927
1010
  const chain = chainFromCall(call, aliases);
1011
+ if (chain?.rootMethod === "step") {
1012
+ for (const arg of call.arguments) {
1013
+ const fn = unwrapValue(arg);
1014
+ if (fn && isFunctionNode(fn)) stepBodies.add(fn);
1015
+ }
1016
+ }
1017
+ if (
1018
+ !insideStep &&
1019
+ chain &&
1020
+ call === chain.rootCall &&
1021
+ (chain.rootMethod === "fetch" || chain.rootMethod === "send")
1022
+ ) {
1023
+ bareIrreversible.add(chain.rootMethod);
1024
+ }
928
1025
  if (chain) {
929
1026
  hits.push({ call, chain });
930
1027
  continue;
@@ -935,7 +1032,7 @@ function collectFxChains(
935
1032
  if (seen.has(nested)) continue;
936
1033
  const shadows = functionParams(nested).some((param) => paramIdentifierName(param) === door);
937
1034
  if (shadows) continue;
938
- analyzeFunction(nested, door, aliases, locals, file);
1035
+ analyzeFunction(nested, door, aliases, locals, file, insideStep || stepBodies.has(nested));
939
1036
  }
940
1037
  };
941
1038
 
@@ -946,9 +1043,9 @@ function collectFxChains(
946
1043
  if (second && paramIdentifierName(second) !== "fx") {
947
1044
  opaque.push("the do callback's second parameter is not named fx");
948
1045
  }
949
- analyzeFunction(options.doNode, "fx", new Map(), new Map(), startFile);
1046
+ analyzeFunction(options.doNode, "fx", new Map(), new Map(), startFile, false);
950
1047
  } else {
951
- analyzeBody(options.doNode, "fx", new Map(), new Map(), startFile);
1048
+ analyzeBody(options.doNode, "fx", new Map(), new Map(), startFile, false);
952
1049
  }
953
1050
 
954
1051
  return hits;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Joins and groupBy list every source table under the flow's reads.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { extractFromSources } from "./extract.ts";
7
+
8
+ describe("extractManifest — joins", () => {
9
+ test("innerJoin and groupBy record every table", async () => {
10
+ const manifest = await extractFromSources({
11
+ "src/app.ts": `
12
+ import { oke } from "okengine";
13
+ export const app = oke({ name: "joins" });
14
+ `,
15
+ "src/flows/orders.ts": `
16
+ import { on, flow, http, store, field } from "okengine";
17
+ import { eq, count } from "drizzle-orm";
18
+
19
+ export const db = store.sql("db");
20
+ export const orders = store.schema.table("orders", {
21
+ id: field.text().primaryKey(),
22
+ customerId: field.text(),
23
+ });
24
+ export const customers = store.schema.table("customers", {
25
+ id: field.text().primaryKey(),
26
+ });
27
+
28
+ export const totals = on(
29
+ http.get("/totals"),
30
+ flow("orders.totals", {
31
+ do: async (_input, fx) => {
32
+ return fx
33
+ .store(db)
34
+ .select({ n: count() })
35
+ .from(orders)
36
+ .innerJoin(customers, eq(orders.customerId, customers.id))
37
+ .groupBy(customers.id);
38
+ },
39
+ }),
40
+ );
41
+ `,
42
+ });
43
+ expect(manifest.flows?.["orders.totals"]?.effects?.reads?.slice().sort()).toEqual([
44
+ "sql:customers",
45
+ "sql:orders",
46
+ ]);
47
+ });
48
+ });
@@ -1577,6 +1577,34 @@ export const ping = flow({ do: () => ({ ok: true }) });
1577
1577
  expect(manifest.flows?.["notes.ping"]).toBeDefined();
1578
1578
  });
1579
1579
 
1580
+ test("fx.call of an imported flow handle records that flow's name", async () => {
1581
+ const tasks = `
1582
+ import { on, flow, http } from "okengine";
1583
+ export const create = on(
1584
+ http.post("/tasks"),
1585
+ flow("tasks.create", { do: async () => ({ id: "1" }) }),
1586
+ );
1587
+ `;
1588
+ const forms = `
1589
+ import { on, flow, http } from "okengine";
1590
+ import { create as createTask } from "../tasks/index.ts";
1591
+ export const submit = on(
1592
+ http.post("/forms/:id/submit"),
1593
+ flow("forms.submit", {
1594
+ do: async (_input, fx) => {
1595
+ await fx.call(createTask, { title: "SSO" });
1596
+ return { ok: true };
1597
+ },
1598
+ }),
1599
+ );
1600
+ `;
1601
+ const manifest = await extractFromSources({
1602
+ "src/flows/tasks/index.ts": tasks,
1603
+ "src/flows/forms/index.ts": forms,
1604
+ });
1605
+ expect(manifest.flows?.["forms.submit"]?.effects?.calls).toEqual(["tasks.create"]);
1606
+ });
1607
+
1580
1608
  test("effects are identical for the same do body in a tree file vs a barrel", async () => {
1581
1609
  const doBody = `
1582
1610
  const rows = await fx.store(db).select().from(notes);