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
@@ -7,9 +7,12 @@
7
7
  * (four-applications · Provisions).
8
8
  */
9
9
 
10
+ import { readFileSync } from "node:fs";
10
11
  import { mkdir } from "node:fs/promises";
11
12
  import { dirname } from "node:path";
12
13
  import { okid } from "../okid.ts";
14
+ import { throwOke } from "./errors.ts";
15
+ import type { DecisionLabelStore } from "./decision-label-store.ts";
13
16
  import type { IdempotencyStore } from "./idempotency-store.ts";
14
17
  import { JournalSuspend } from "./journal-suspend.ts";
15
18
  import { lazyRequire } from "./lazy-require.ts";
@@ -105,17 +108,31 @@ export interface JournalRun {
105
108
  lockedBy?: string;
106
109
  /** Lease expiry epoch-ms; a crashed holder's run is reclaimable after this. */
107
110
  leaseExpiresAt?: number;
111
+ /**
112
+ * Fencing token. Bumped on each acquire that is not a live same-holder renew.
113
+ * Writes from a previous holder are rejected.
114
+ */
115
+ leaseToken?: number;
116
+ /** Code version stamped at start. Resume fails when the process version differs. */
117
+ codeVersion?: string;
108
118
  readonly createdAt: number;
109
119
  updatedAt: number;
110
120
  /** Isolation context for resume (`fx.tenant`). */
111
121
  tenant?: string | null;
112
122
  }
113
123
 
124
+ /** Fence presented with every leased journal write. */
125
+ export interface JournalWriteFence {
126
+ readonly lockedBy: string;
127
+ readonly leaseToken: number;
128
+ /** Epoch-ms used to reject an expired holder. */
129
+ readonly now: number;
130
+ }
131
+
114
132
  /**
115
133
  * Run-level lease coordination — same SKIP LOCKED + lazy-reclaim physics as
116
- * Signal's message claims and Clock's tick claims. No sweeper, no fencing
117
- * token: at-least-once after lease expiry, journal replay keeps completed
118
- * steps from re-running.
134
+ * Signal's message claims and Clock's tick claims. No sweeper. A fencing
135
+ * token rejects writes from a holder whose lease was reclaimed.
119
136
  */
120
137
  export interface JournalLeaseStore {
121
138
  /**
@@ -128,6 +145,24 @@ export interface JournalLeaseStore {
128
145
  * @param leaseMs - Lease duration
129
146
  */
130
147
  acquireLease(runId: string, instanceId: string, now: number, leaseMs: number): Promise<boolean>;
148
+ /**
149
+ * Take the lease and write `update(run)` in that same hold.
150
+ * `undefined` from `update` keeps the previous entries and still holds the lease.
151
+ * `"lease"` means another holder won. `"missing"` means no run.
152
+ *
153
+ * @param runId - Run id
154
+ * @param instanceId - Claimant instance
155
+ * @param now - Epoch-ms
156
+ * @param leaseMs - Lease duration
157
+ * @param update - Next snapshot, or undefined to leave entries
158
+ */
159
+ cas(
160
+ runId: string,
161
+ instanceId: string,
162
+ now: number,
163
+ leaseMs: number,
164
+ update: (run: JournalRun) => JournalRun | undefined,
165
+ ): Promise<"ok" | "missing" | { readonly lease: true; readonly leaseExpiresAt?: number }>;
131
166
  /**
132
167
  * Release a lease held by `instanceId` (no-op for other holders).
133
168
  *
@@ -161,6 +196,20 @@ function loadIdempotencyStore(): typeof import("./idempotency-store.ts") {
161
196
  return lazyRequire(import.meta.dir, ["idempotency", "store"].join("-"));
162
197
  }
163
198
 
199
+ /**
200
+ * Load the decision-label table without a static import.
201
+ */
202
+ function loadDecisionLabelStore(): typeof import("./decision-label-store.ts") {
203
+ return lazyRequire(import.meta.dir, ["decision", "label", "store"].join("-"));
204
+ }
205
+
206
+ /**
207
+ * Load the agent-event table without a static import.
208
+ */
209
+ function loadAgentEventStore(): typeof import("./agent-event-store.ts") {
210
+ return lazyRequire(import.meta.dir, ["agent", "event", "store"].join("-"));
211
+ }
212
+
164
213
  /** Persistence backend for journal runs. */
165
214
  export interface JournalStore extends Partial<JournalLeaseStore> {
166
215
  /**
@@ -168,6 +217,16 @@ export interface JournalStore extends Partial<JournalLeaseStore> {
168
217
  * only implements run `get` / `put`.
169
218
  */
170
219
  readonly idempotency?: IdempotencyStore;
220
+ /**
221
+ * Decision labels and the drift flag on this same driver. Opened only when
222
+ * the app declares decisions.
223
+ */
224
+ readonly decisions?: DecisionLabelStore;
225
+ /**
226
+ * Agent-run event log on this same driver. Followers resume from these rows
227
+ * after a restart and from another instance.
228
+ */
229
+ readonly agentEvents?: import("./agent-event-store.ts").AgentEventStore;
171
230
  /**
172
231
  * Load a run by id.
173
232
  *
@@ -175,11 +234,31 @@ export interface JournalStore extends Partial<JournalLeaseStore> {
175
234
  */
176
235
  get(runId: string): Promise<JournalRun | undefined>;
177
236
  /**
178
- * Persist a run (create or replace).
237
+ * Persist a run header (create or replace).
238
+ *
239
+ * When {@link JournalStore.appendEntry} is present, `entries` on this
240
+ * snapshot may be empty — the store keeps previously appended rows.
179
241
  *
180
242
  * @param run - Run snapshot
243
+ * @param fence - Required for a leased update. Omitted on the first insert
244
+ * and on uncoordinated stores.
181
245
  */
182
- put(run: JournalRun): Promise<void>;
246
+ put(run: JournalRun, fence?: JournalWriteFence): Promise<void>;
247
+ /**
248
+ * Append one journal entry. Built-in stores implement this so a step does
249
+ * not rewrite earlier entries.
250
+ *
251
+ * @param runId - Run id
252
+ * @param seq - Zero-based position
253
+ * @param entry - JSON-safe entry
254
+ * @param fence - Lease fence when the run is coordinated
255
+ */
256
+ appendEntry?(
257
+ runId: string,
258
+ seq: number,
259
+ entry: JournalEntry,
260
+ fence?: JournalWriteFence,
261
+ ): Promise<void>;
183
262
  /** List all runs (test / console helper). */
184
263
  list(): Promise<readonly JournalRun[]>;
185
264
  }
@@ -234,16 +313,43 @@ function leaseMethods(
234
313
  load: () => Promise<Map<string, JournalRun>>,
235
314
  flush?: (map: Map<string, JournalRun>) => Promise<void>,
236
315
  ): JournalLeaseStore {
316
+ let gate: Promise<void> = Promise.resolve();
237
317
  return {
238
318
  async acquireLease(runId, instanceId, now, leaseMs) {
239
319
  const map = await load();
240
320
  const run = map.get(runId);
241
321
  if (!run || !claimable(run, instanceId, now)) return false;
242
- run.lockedBy = instanceId;
243
- run.leaseExpiresAt = now + leaseMs;
322
+ holdLease(run, instanceId, now, leaseMs);
244
323
  await flush?.(map);
245
324
  return true;
246
325
  },
326
+ async cas(runId, instanceId, now, leaseMs, update) {
327
+ let release!: () => void;
328
+ const slot = new Promise<void>((resolve) => {
329
+ release = resolve;
330
+ });
331
+ const prev = gate;
332
+ gate = slot;
333
+ await prev;
334
+ try {
335
+ const map = await load();
336
+ const run = map.get(runId);
337
+ if (!run) return "missing";
338
+ if (!claimable(run, instanceId, now)) {
339
+ return { lease: true, leaseExpiresAt: run.leaseExpiresAt };
340
+ }
341
+ const next = update(cloneRun(run)) ?? run;
342
+ holdLease(next, instanceId, now, leaseMs);
343
+ if (run.entries.length > 0 && next.entries.length === 0) {
344
+ (next as { entries: JournalEntry[] }).entries = run.entries;
345
+ }
346
+ map.set(runId, next);
347
+ await flush?.(map);
348
+ return "ok";
349
+ } finally {
350
+ release();
351
+ }
352
+ },
247
353
  async releaseLease(runId, instanceId) {
248
354
  const map = await load();
249
355
  const run = map.get(runId);
@@ -264,8 +370,7 @@ function leaseMethods(
264
370
  )
265
371
  .sort((a, b) => (a.wakeAt ?? 0) - (b.wakeAt ?? 0))[0];
266
372
  if (!due) return undefined;
267
- due.lockedBy = instanceId;
268
- due.leaseExpiresAt = now + leaseMs;
373
+ holdLease(due, instanceId, now, leaseMs);
269
374
  await flush?.(map);
270
375
  return cloneRun(due);
271
376
  },
@@ -294,8 +399,16 @@ export function createMemoryJournalStore(seed?: readonly JournalRun[]): JournalS
294
399
  const r = runs.get(runId);
295
400
  return r ? cloneRun(r) : undefined;
296
401
  },
297
- async put(run) {
298
- runs.set(run.id, cloneRun(run));
402
+ async put(run, fence) {
403
+ const existing = runs.get(run.id);
404
+ assertJournalFence(existing, fence);
405
+ runs.set(run.id, mergeJournalPut(existing, run));
406
+ },
407
+ async appendEntry(runId, seq, entry, fence) {
408
+ const existing = runs.get(runId);
409
+ if (!existing) throw new Error(`journal: run "${runId}" not found`);
410
+ assertJournalFence(existing, fence);
411
+ appendStoredEntry(existing, seq, entry);
299
412
  },
300
413
  async list() {
301
414
  return [...runs.values()].map(cloneRun);
@@ -306,6 +419,16 @@ export function createMemoryJournalStore(seed?: readonly JournalRun[]): JournalS
306
419
  Object.defineProperty(this, "idempotency", { value: created });
307
420
  return created;
308
421
  },
422
+ get decisions(): DecisionLabelStore {
423
+ const created = loadDecisionLabelStore().createMemoryDecisionLabelStore();
424
+ Object.defineProperty(this, "decisions", { value: created });
425
+ return created;
426
+ },
427
+ get agentEvents(): import("./agent-event-store.ts").AgentEventStore {
428
+ const created = loadAgentEventStore().createMemoryAgentEventStore();
429
+ Object.defineProperty(this, "agentEvents", { value: created });
430
+ return created;
431
+ },
309
432
  };
310
433
  }
311
434
 
@@ -341,9 +464,19 @@ export function createFileJournalStore(path: string): JournalStore {
341
464
  const r = map.get(runId);
342
465
  return r ? cloneRun(r) : undefined;
343
466
  },
344
- async put(run) {
467
+ async put(run, fence) {
345
468
  const map = await load();
346
- map.set(run.id, cloneRun(run));
469
+ const existing = map.get(run.id);
470
+ assertJournalFence(existing, fence);
471
+ map.set(run.id, mergeJournalPut(existing, run));
472
+ await flush(map);
473
+ },
474
+ async appendEntry(runId, seq, entry, fence) {
475
+ const map = await load();
476
+ const existing = map.get(runId);
477
+ if (!existing) throw new Error(`journal: run "${runId}" not found`);
478
+ assertJournalFence(existing, fence);
479
+ appendStoredEntry(existing, seq, entry);
347
480
  await flush(map);
348
481
  },
349
482
  async list() {
@@ -359,6 +492,20 @@ export function createFileJournalStore(path: string): JournalStore {
359
492
  Object.defineProperty(this, "idempotency", { value: created });
360
493
  return created;
361
494
  },
495
+ get decisions(): DecisionLabelStore {
496
+ const created = loadDecisionLabelStore().createFileDecisionLabelStore(
497
+ `${dirname(path)}/decision-labels.json`,
498
+ );
499
+ Object.defineProperty(this, "decisions", { value: created });
500
+ return created;
501
+ },
502
+ get agentEvents(): import("./agent-event-store.ts").AgentEventStore {
503
+ const created = loadAgentEventStore().createFileAgentEventStore(
504
+ `${dirname(path)}/agent-events`,
505
+ );
506
+ Object.defineProperty(this, "agentEvents", { value: created });
507
+ return created;
508
+ },
362
509
  };
363
510
  }
364
511
 
@@ -385,6 +532,11 @@ export interface CreateJournalOptions {
385
532
  * sleeping/finished run never holds a 30s lock.
386
533
  */
387
534
  readonly lease?: JournalLeaseOptions;
535
+ /**
536
+ * Code version stamped on new runs. Resume of a different version fails.
537
+ * Defaults to the package version.
538
+ */
539
+ readonly codeVersion?: string;
388
540
  }
389
541
 
390
542
  /**
@@ -432,7 +584,17 @@ export interface JournalSession {
432
584
  * instead of treating the cursor as past them. Clears the in-memory undo stack
433
585
  * so frames re-bind on the next walk.
434
586
  */
587
+ /**
588
+ * Increments on {@link JournalSession.rewind}. Agent run ids key off this
589
+ * so `flow.retry` replays the same id.
590
+ */
591
+ readonly epoch: number;
435
592
  rewind(): void;
593
+ /**
594
+ * Entries already replayed or appended. The row under the cursor is excluded
595
+ * so a replayed call does not count itself.
596
+ */
597
+ recordedBeforeCursor(): readonly JournalEntry[];
436
598
  /** Registered per-step undos in persist/replay order (LIFO compensate). */
437
599
  undoStack(): readonly JournalUndoFrame[];
438
600
  /**
@@ -471,6 +633,7 @@ export function createJournalSlot(): {
471
633
  const session = slot.session;
472
634
  if (!session) {
473
635
  if (prop === "runId") return "";
636
+ if (prop === "epoch") return 0;
474
637
  if (prop === "stampTenant") return async () => undefined;
475
638
  if (prop === "rewind") return () => undefined;
476
639
  return undefined;
@@ -511,24 +674,71 @@ export function createJournal(options: CreateJournalOptions): Journal {
511
674
  const now = options.now ?? (() => Date.now());
512
675
  const newId = options.id ?? (() => okid());
513
676
  const lease = options.lease;
677
+ const codeVersion = options.codeVersion ?? packageVersion();
514
678
  const coordinated = lease !== undefined && hasJournalLease(options.store);
515
679
 
516
680
  function openSession(run: JournalRun, leased: boolean): JournalSession {
517
681
  /** Next entry index to consume on replay. */
518
682
  let cursor = 0;
683
+ let epoch = 0;
519
684
  let leaseHeld = leased;
520
685
  let registrationPass = false;
521
686
  let undoExecution = false;
687
+ let stepDepth = 0;
688
+ /** Outer `effect` calls re-enter when the next row was written by a nested call. */
689
+ let effectDepth = 0;
522
690
  const undos: JournalUndoFrame[] = [];
523
691
 
524
- async function persist(): Promise<void> {
692
+ function fence(): JournalWriteFence | undefined {
693
+ if (!leaseHeld || !lease || run.leaseToken === undefined || run.lockedBy === undefined) {
694
+ return undefined;
695
+ }
696
+ return { lockedBy: run.lockedBy, leaseToken: run.leaseToken, now: now() };
697
+ }
698
+
699
+ async function persist(entry?: JournalEntry): Promise<void> {
525
700
  run.updatedAt = now();
526
701
  // Natural heartbeat — a live holder renews on every journal write.
527
702
  if (leaseHeld && lease) {
528
703
  run.lockedBy = lease.instanceId;
529
704
  run.leaseExpiresAt = now() + (lease.leaseMs ?? JOURNAL_DEFAULT_LEASE_MS);
530
705
  }
531
- await options.store.put(cloneRun(run));
706
+ const writeFence = fence();
707
+ if (entry && options.store.appendEntry) {
708
+ try {
709
+ await options.store.appendEntry(run.id, run.entries.length - 1, entry, writeFence);
710
+ } catch (err) {
711
+ // The session list was edited in place (a test drops replayed rows).
712
+ // The stored rows no longer match, so replace them from the session.
713
+ if (!(err instanceof Error) || !err.message.includes("does not append")) throw err;
714
+ await options.store.put(cloneRun(run), writeFence);
715
+ return;
716
+ }
717
+ }
718
+ const header = cloneRun(run);
719
+ if (options.store.appendEntry) (header as { entries: JournalEntry[] }).entries = [];
720
+ await options.store.put(header, writeFence);
721
+ }
722
+
723
+ /**
724
+ * The next entry must match this call. Scanning forward would hide a
725
+ * reordered step.
726
+ */
727
+ function takeReplay(
728
+ match: (entry: JournalEntry) => boolean,
729
+ called: string,
730
+ ): JournalEntry | undefined {
731
+ if (cursor >= run.entries.length) return undefined;
732
+ const entry = run.entries[cursor];
733
+ if (entry === undefined || !match(entry)) {
734
+ throwOke("JOURNAL_REPLAY_DIVERGENCE", {
735
+ runId: run.id,
736
+ expected: entry === undefined ? "end" : entryLabel(entry),
737
+ actual: called,
738
+ });
739
+ }
740
+ cursor += 1;
741
+ return entry;
532
742
  }
533
743
 
534
744
  /** Parking / terminal states must not hold a short lease across days. */
@@ -573,47 +783,51 @@ export function createJournal(options: CreateJournalOptions): Journal {
573
783
  if (name.startsWith(JOURNAL_UNDO_PREFIX) && opts?.undo) {
574
784
  throw new Error("journal: undo steps cannot register nested undo");
575
785
  }
576
- // Prefer name match among remaining entries (resume after crash).
577
- for (let i = cursor; i < run.entries.length; i++) {
578
- const e = run.entries[i]!;
579
- if (e.kind === "step" && e.name === name) {
580
- cursor = i + 1;
581
- if (!name.startsWith(JOURNAL_UNDO_PREFIX)) {
582
- registerUndo(name, e.value as T, opts);
583
- }
584
- return e.value as T;
786
+ const replayed = takeReplay((e) => e.kind === "step" && e.name === name, `step ${name}`);
787
+ if (replayed && replayed.kind === "step") {
788
+ const value = reviveJournalValue(replayed.value) as T;
789
+ if (!name.startsWith(JOURNAL_UNDO_PREFIX)) {
790
+ registerUndo(name, value, opts);
585
791
  }
792
+ return value;
586
793
  }
587
794
  assertCanAppendStep(name);
588
- const value = await fn();
795
+ stepDepth += 1;
796
+ let stored: unknown;
797
+ try {
798
+ stored = await journalValue(await fn());
799
+ } finally {
800
+ stepDepth -= 1;
801
+ }
802
+ const value = reviveJournalValue(stored) as T;
589
803
  const entry: JournalStepEntry = {
590
804
  kind: "step",
591
805
  name,
592
- value,
806
+ value: stored,
593
807
  at: now(),
594
808
  };
595
809
  run.entries.push(entry);
596
810
  cursor = run.entries.length;
597
- await persist();
811
+ await persist(entry);
598
812
  if (!name.startsWith(JOURNAL_UNDO_PREFIX)) {
599
813
  registerUndo(name, value, opts);
600
814
  }
601
815
  return value;
602
816
  },
603
817
  async sleep(label, duration, parseMs) {
604
- for (let i = cursor; i < run.entries.length; i++) {
605
- const e = run.entries[i]!;
606
- if (e.kind === "sleep" && e.label === label) {
607
- cursor = i + 1;
608
- if (now() < e.wakeAt) {
609
- run.status = "sleeping";
610
- run.wakeAt = e.wakeAt;
611
- releaseLeaseLocally();
612
- await persist();
613
- throw new JournalSuspend(label, e.wakeAt);
614
- }
615
- return;
818
+ const replayed = takeReplay(
819
+ (e) => e.kind === "sleep" && e.label === label,
820
+ `sleep ${label}`,
821
+ );
822
+ if (replayed && replayed.kind === "sleep") {
823
+ if (now() < replayed.wakeAt) {
824
+ run.status = "sleeping";
825
+ run.wakeAt = replayed.wakeAt;
826
+ releaseLeaseLocally();
827
+ await persist();
828
+ throw new JournalSuspend(label, replayed.wakeAt);
616
829
  }
830
+ return;
617
831
  }
618
832
  if (registrationPass) {
619
833
  throw new JournalRegistrationComplete();
@@ -632,42 +846,84 @@ export function createJournal(options: CreateJournalOptions): Journal {
632
846
  run.status = "sleeping";
633
847
  run.wakeAt = wakeAt;
634
848
  releaseLeaseLocally();
635
- await persist();
849
+ await persist(entry);
636
850
  throw new JournalSuspend(label, wakeAt);
637
851
  }
638
- await persist();
852
+ await persist(entry);
639
853
  },
640
854
  async effect<T>(
641
855
  effectKind: string,
642
856
  resource: string,
643
857
  execute: () => T | Promise<T>,
644
858
  ): Promise<T> {
645
- for (let i = cursor; i < run.entries.length; i++) {
646
- const e = run.entries[i]!;
647
- if (e.kind === "effect" && e.effectKind === effectKind && e.resource === resource) {
648
- cursor = i + 1;
649
- return e.value as T;
650
- }
859
+ // Inside fx.step the step value is the snapshot. A nested effect must
860
+ // not insert a row ahead of that step, or replay would diverge.
861
+ if (stepDepth > 0) return execute();
862
+ const called = `effect ${effectKind} ${resource}`;
863
+ const next = cursor < run.entries.length ? run.entries[cursor] : undefined;
864
+ const matches =
865
+ next?.kind === "effect" && next.effectKind === effectKind && next.resource === resource;
866
+ if (matches && next.kind === "effect") {
867
+ cursor += 1;
868
+ return reviveJournalValue(next.value) as T;
869
+ }
870
+ // A nested call must hit its own row. An outer call re-enters: its row
871
+ // is appended after the nested rows, so it is not next yet.
872
+ if (next !== undefined && effectDepth > 0) {
873
+ throwOke("JOURNAL_REPLAY_DIVERGENCE", {
874
+ runId: run.id,
875
+ expected: entryLabel(next),
876
+ actual: called,
877
+ });
651
878
  }
652
879
  if (registrationPass) {
653
880
  throw new JournalRegistrationComplete();
654
881
  }
655
- const value = await execute();
882
+ effectDepth += 1;
883
+ let stored: unknown;
884
+ try {
885
+ stored = await journalValue(await execute());
886
+ } finally {
887
+ effectDepth -= 1;
888
+ }
889
+ const after = cursor < run.entries.length ? run.entries[cursor] : undefined;
890
+ if (
891
+ after?.kind === "effect" &&
892
+ after.effectKind === effectKind &&
893
+ after.resource === resource
894
+ ) {
895
+ cursor += 1;
896
+ return reviveJournalValue(after.value) as T;
897
+ }
898
+ if (after !== undefined) {
899
+ throwOke("JOURNAL_REPLAY_DIVERGENCE", {
900
+ runId: run.id,
901
+ expected: entryLabel(after),
902
+ actual: called,
903
+ });
904
+ }
656
905
  const entry: JournalEffectEntry = {
657
906
  kind: "effect",
658
907
  effectKind,
659
908
  resource,
660
- value,
909
+ value: stored,
661
910
  at: now(),
662
911
  };
663
912
  run.entries.push(entry);
664
913
  cursor = run.entries.length;
665
- await persist();
666
- return value;
914
+ await persist(entry);
915
+ return reviveJournalValue(stored) as T;
916
+ },
917
+ get epoch() {
918
+ return epoch;
667
919
  },
668
920
  rewind() {
669
921
  cursor = 0;
670
922
  undos.length = 0;
923
+ epoch += 1;
924
+ },
925
+ recordedBeforeCursor() {
926
+ return run.entries.slice(0, cursor);
671
927
  },
672
928
  undoStack() {
673
929
  return undos;
@@ -709,6 +965,7 @@ export function createJournal(options: CreateJournalOptions): Journal {
709
965
  input,
710
966
  status: "running",
711
967
  entries: [],
968
+ codeVersion,
712
969
  createdAt: t,
713
970
  updatedAt: t,
714
971
  };
@@ -716,6 +973,7 @@ export function createJournal(options: CreateJournalOptions): Journal {
716
973
  // Fresh id — insert already holding the lease (no claim race).
717
974
  run.lockedBy = lease.instanceId;
718
975
  run.leaseExpiresAt = t + (lease.leaseMs ?? JOURNAL_DEFAULT_LEASE_MS);
976
+ run.leaseToken = 1;
719
977
  }
720
978
  await options.store.put(cloneRun(run));
721
979
  return openSession(run, coordinated);
@@ -740,6 +998,16 @@ export function createJournal(options: CreateJournalOptions): Journal {
740
998
  }
741
999
  throw new Error(`journal: run "${runId}" not found`);
742
1000
  }
1001
+ if (run.codeVersion !== undefined && run.codeVersion !== codeVersion) {
1002
+ if (coordinated && lease) {
1003
+ await options.store.releaseLease!(runId, lease.instanceId);
1004
+ }
1005
+ throwOke("JOURNAL_CODE_VERSION", {
1006
+ runId,
1007
+ expected: run.codeVersion,
1008
+ actual: codeVersion,
1009
+ });
1010
+ }
743
1011
  // Leave status intact — the durable runner parks or continues.
744
1012
  run.updatedAt = now();
745
1013
  if (coordinated && lease) {
@@ -755,3 +1023,155 @@ export function createJournal(options: CreateJournalOptions): Journal {
755
1023
  function cloneRun(run: JournalRun): JournalRun {
756
1024
  return structuredClone(run);
757
1025
  }
1026
+
1027
+ /**
1028
+ * Bump the fencing token unless this instance already holds a live lease.
1029
+ *
1030
+ * @param run - Run being claimed
1031
+ * @param instanceId - Claimant
1032
+ * @param now - Epoch-ms
1033
+ * @param leaseMs - Lease duration
1034
+ */
1035
+ function holdLease(run: JournalRun, instanceId: string, now: number, leaseMs: number): void {
1036
+ const renew =
1037
+ run.lockedBy === instanceId && hasLiveLease(run, now) && run.leaseToken !== undefined;
1038
+ if (!renew) run.leaseToken = (run.leaseToken ?? 0) + 1;
1039
+ run.lockedBy = instanceId;
1040
+ const nextExpiry = now + leaseMs;
1041
+ if (run.leaseExpiresAt === undefined || run.leaseExpiresAt < nextExpiry) {
1042
+ run.leaseExpiresAt = nextExpiry;
1043
+ }
1044
+ }
1045
+
1046
+ /**
1047
+ * Reject a write whose lease token is no longer current.
1048
+ *
1049
+ * @param existing - Stored run, if any
1050
+ * @param fence - Caller fence. Omitted on insert and uncoordinated stores.
1051
+ */
1052
+ function assertJournalFence(
1053
+ existing: JournalRun | undefined,
1054
+ fence: JournalWriteFence | undefined,
1055
+ ): void {
1056
+ if (!fence || !existing) return;
1057
+ const expired = existing.leaseExpiresAt !== undefined && existing.leaseExpiresAt <= fence.now;
1058
+ if (existing.lockedBy !== fence.lockedBy || existing.leaseToken !== fence.leaseToken || expired) {
1059
+ throwOke("JOURNAL_STALE_LEASE", { runId: existing.id });
1060
+ }
1061
+ }
1062
+
1063
+ /**
1064
+ * Header replace that keeps appended entries when the snapshot omitted them.
1065
+ *
1066
+ * @param existing - Previous row
1067
+ * @param incoming - Header snapshot
1068
+ */
1069
+ function mergeJournalPut(existing: JournalRun | undefined, incoming: JournalRun): JournalRun {
1070
+ const next = cloneRun(incoming);
1071
+ if (existing && incoming.entries.length === 0 && existing.entries.length > 0) {
1072
+ (next as { entries: JournalEntry[] }).entries = existing.entries;
1073
+ }
1074
+ return next;
1075
+ }
1076
+
1077
+ /**
1078
+ * Append one entry at `seq`, or no-op when that seq is already stored.
1079
+ *
1080
+ * @param run - Stored run
1081
+ * @param seq - Zero-based position
1082
+ * @param entry - JSON-safe entry
1083
+ */
1084
+ function appendStoredEntry(run: JournalRun, seq: number, entry: JournalEntry): void {
1085
+ const entries = run.entries as JournalEntry[];
1086
+ if (entries.length === seq) {
1087
+ entries.push(structuredClone(entry));
1088
+ return;
1089
+ }
1090
+ if (entries.length === seq + 1) return;
1091
+ throw new Error(`journal: entry seq ${seq} does not append (have ${entries.length})`);
1092
+ }
1093
+
1094
+ /**
1095
+ * JSON snapshot of a journaled value. `Response` becomes a marked object.
1096
+ *
1097
+ * @param value - Step or effect result
1098
+ */
1099
+ async function journalValue(value: unknown): Promise<unknown> {
1100
+ if (typeof Response !== "undefined" && value instanceof Response) {
1101
+ const bytes = new Uint8Array(await value.arrayBuffer());
1102
+ let body = "";
1103
+ for (const byte of bytes) body += String.fromCharCode(byte);
1104
+ const headers: Record<string, string> = {};
1105
+ value.headers.forEach((header, name) => {
1106
+ headers[name] = header;
1107
+ });
1108
+ return {
1109
+ __oke: "response",
1110
+ status: value.status,
1111
+ statusText: value.statusText,
1112
+ headers,
1113
+ body: btoa(body),
1114
+ };
1115
+ }
1116
+ if (typeof value === "bigint" || typeof value === "function" || typeof value === "symbol") {
1117
+ throwOke("JOURNAL_VALUE_NOT_JSON", { detail: typeof value });
1118
+ }
1119
+ try {
1120
+ return JSON.parse(JSON.stringify(value ?? null)) as unknown;
1121
+ } catch {
1122
+ throwOke("JOURNAL_VALUE_NOT_JSON", { detail: "unserializable" });
1123
+ }
1124
+ }
1125
+
1126
+ /**
1127
+ * Rebuild a `Response` from a journal snapshot. Other values pass through.
1128
+ *
1129
+ * @param value - Stored entry value
1130
+ */
1131
+ function reviveJournalValue(value: unknown): unknown {
1132
+ if (!value || typeof value !== "object") return value;
1133
+ const record = value as {
1134
+ __oke?: string;
1135
+ status?: number;
1136
+ statusText?: string;
1137
+ headers?: Record<string, string>;
1138
+ body?: string;
1139
+ };
1140
+ if (record.__oke !== "response" || typeof record.body !== "string") return value;
1141
+ const binary = atob(record.body);
1142
+ const bytes = new Uint8Array(binary.length);
1143
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
1144
+ return new Response(bytes, {
1145
+ status: record.status ?? 200,
1146
+ statusText: record.statusText,
1147
+ headers: record.headers,
1148
+ });
1149
+ }
1150
+
1151
+ /**
1152
+ * Short label for a journal entry, used in replay errors.
1153
+ *
1154
+ * @param entry - Stored entry
1155
+ */
1156
+ function entryLabel(entry: JournalEntry): string {
1157
+ if (entry.kind === "step") return `step ${entry.name}`;
1158
+ if (entry.kind === "sleep") return `sleep ${entry.label}`;
1159
+ return `effect ${entry.effectKind} ${entry.resource}`;
1160
+ }
1161
+
1162
+ let cachedPackageVersion: string | undefined;
1163
+
1164
+ /**
1165
+ * Package version used when a journal is not given an explicit code version.
1166
+ */
1167
+ function packageVersion(): string {
1168
+ if (cachedPackageVersion !== undefined) return cachedPackageVersion;
1169
+ try {
1170
+ const raw = readFileSync(new URL("../../package.json", import.meta.url), "utf8");
1171
+ const parsed = JSON.parse(raw) as { version?: string };
1172
+ cachedPackageVersion = parsed.version && parsed.version.length > 0 ? parsed.version : "0";
1173
+ } catch {
1174
+ cachedPackageVersion = "0";
1175
+ }
1176
+ return cachedPackageVersion;
1177
+ }