okengine 0.23.0 → 0.23.2

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 (128) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/ai/mcp.mdx +4 -4
  3. package/site/content/docs/elements/channel/index.mdx +4 -4
  4. package/site/content/docs/elements/channel/receipts.mdx +7 -5
  5. package/site/content/docs/elements/channel/sms.mdx +2 -1
  6. package/site/content/docs/elements/flow/index.mdx +22 -18
  7. package/site/content/docs/elements/signal/broadcast.mdx +4 -2
  8. package/site/content/docs/elements/signal/index.mdx +15 -14
  9. package/site/content/docs/plugins/otp.mdx +3 -3
  10. package/site/content/docs/reference/configuration.mdx +16 -0
  11. package/site/content/docs/reference/errors.mdx +31 -19
  12. package/site/content/docs/reference/fx.mdx +12 -4
  13. package/src/auth/tenants.ts +17 -0
  14. package/src/cli/doctor-diff.test.ts +29 -1
  15. package/src/cli/doctor-diff.ts +35 -0
  16. package/src/cli/manifest-pr-diff.ts +16 -0
  17. package/src/compiler/aot.test.ts +3 -2
  18. package/src/compiler/aot.ts +45 -1
  19. package/src/compiler/dynamic.ts +1 -1
  20. package/src/compiler/effects-fetch.test.ts +77 -0
  21. package/src/compiler/effects-infer.ts +87 -16
  22. package/src/compiler/effects-join.test.ts +48 -0
  23. package/src/compiler/extract.test.ts +28 -0
  24. package/src/compiler/extract.ts +11 -1
  25. package/src/compiler/fx-index.ts +58 -4
  26. package/src/compiler/http-parse.test.ts +101 -0
  27. package/src/compiler/http-parse.ts +188 -9
  28. package/src/compiler/interpret.ts +25 -2
  29. package/src/console/index.ts +1 -1
  30. package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-zBMhZnFm.js} +1 -1
  31. package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-CBj9f7Ju.js} +1 -1
  32. package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-D3MY49h3.js} +1 -1
  33. package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-L9JxdBhp.js} +1 -1
  34. package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-BvFaX-Zt.js} +1 -1
  35. package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-DgB76m-X.js} +1 -1
  36. package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-l1DSJ2Vz.js} +1 -1
  37. package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-Bb9t1lu6.js} +1 -1
  38. package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DMvsi3Ti.js} +1 -1
  39. package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-BwhJBTcQ.js} +1 -1
  40. package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-RhV2jT_7.js} +3 -3
  41. package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-BRfUILBq.js} +1 -1
  42. package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-BsceAzIG.js} +1 -1
  43. package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-C6lTyCXp.js} +1 -1
  44. package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-YmE806bJ.js} +1 -1
  45. package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-Di4irS49.js} +1 -1
  46. package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
  47. package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-07agasRA.js} +1 -1
  48. package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-CHBck4n_.js} +1 -1
  49. package/src/console/ui-next/dist/index.html +1 -1
  50. package/src/console/ui-next/seed-parked-approval.ts +5 -1
  51. package/src/drivers/journal-postgres.test.ts +106 -2
  52. package/src/drivers/journal-postgres.ts +475 -14
  53. package/src/drivers/postgres.test.ts +60 -0
  54. package/src/drivers/postgres.ts +79 -5
  55. package/src/drivers/signal-compete.test.ts +238 -0
  56. package/src/drivers/signal-postgres.test.ts +170 -0
  57. package/src/drivers/signal-postgres.ts +486 -213
  58. package/src/drivers/signal-redis.test.ts +224 -0
  59. package/src/drivers/signal-redis.ts +934 -93
  60. package/src/drivers/signal-types.ts +12 -0
  61. package/src/elements/ai/approval.test.ts +15 -3
  62. package/src/elements/ai/approval.ts +15 -9
  63. package/src/elements/ai/mcp-protocol.ts +5 -6
  64. package/src/elements/ai/runtime.ts +20 -19
  65. package/src/elements/channel/runtime.ts +42 -4
  66. package/src/elements/channel/sql-ledger.test.ts +404 -0
  67. package/src/elements/channel/sql-ledger.ts +395 -0
  68. package/src/elements/clock/chaos-child.ts +12 -2
  69. package/src/elements/clock/durable.ts +11 -0
  70. package/src/elements/signal/runtime.ts +9 -0
  71. package/src/elements/signal.test.ts +1 -0
  72. package/src/elements/store/cache-bus.test.ts +121 -0
  73. package/src/elements/store/cache-bus.ts +278 -0
  74. package/src/elements/store/cache.test.ts +179 -15
  75. package/src/elements/store/cache.ts +357 -51
  76. package/src/elements/store/prepare-row.test.ts +6 -2
  77. package/src/elements/store/runtime.ts +44 -2
  78. package/src/elements/store/sql-errors.test.ts +131 -0
  79. package/src/elements/store/sql-errors.ts +39 -16
  80. package/src/elements/store/sql-nested-tx.test.ts +344 -0
  81. package/src/elements/store/sql-session.test.ts +2 -0
  82. package/src/elements/store/sql-session.ts +275 -9
  83. package/src/elements/store.ts +1 -0
  84. package/src/kernel/app.ts +207 -43
  85. package/src/kernel/auto-cache.test.ts +228 -0
  86. package/src/kernel/auto-registry.test.ts +5 -1
  87. package/src/kernel/boot-bind/channel.ts +63 -2
  88. package/src/kernel/boot-bind/honor-config.test.ts +40 -10
  89. package/src/kernel/boot-bind/signal.ts +72 -25
  90. package/src/kernel/boot-bind/store.ts +16 -1
  91. package/src/kernel/boot.test.ts +2 -2
  92. package/src/kernel/boot.ts +54 -10
  93. package/src/kernel/boundary-contract.ts +7 -0
  94. package/src/kernel/builtin-errors.ts +2 -0
  95. package/src/kernel/call.test.ts +1 -0
  96. package/src/kernel/errors-compiler.ts +11 -0
  97. package/src/kernel/errors-text.ts +16 -0
  98. package/src/kernel/errors.ts +8 -0
  99. package/src/kernel/flow.ts +33 -3
  100. package/src/kernel/fx-call-types.test.ts +32 -0
  101. package/src/kernel/fx-decide.ts +11 -7
  102. package/src/kernel/fx-sql-handle.ts +52 -7
  103. package/src/kernel/fx.test.ts +1 -3
  104. package/src/kernel/fx.ts +27 -16
  105. package/src/kernel/horizontal-child.ts +54 -1
  106. package/src/kernel/horizontal.integration.test.ts +131 -7
  107. package/src/kernel/idempotency.test.ts +4 -4
  108. package/src/kernel/journal-boot.test.ts +83 -6
  109. package/src/kernel/journal.test.ts +158 -0
  110. package/src/kernel/journal.ts +515 -54
  111. package/src/kernel/pipeline-tenant.ts +5 -1
  112. package/src/kernel/pipeline.ts +5 -0
  113. package/src/kernel/signal-tx.ts +28 -0
  114. package/src/kernel/store-transaction.test.ts +95 -0
  115. package/src/mcp/authorization.ts +5 -11
  116. package/src/mcp/confirmation.ts +203 -38
  117. package/src/mcp/docs-server.ts +26 -4
  118. package/src/mcp/index.ts +4 -0
  119. package/src/mcp/mcp.test.ts +404 -33
  120. package/src/mcp/protocol.ts +8 -2
  121. package/src/mcp/server.ts +31 -5
  122. package/src/mcp/session.ts +7 -0
  123. package/src/mcp/tools.ts +68 -19
  124. package/src/mcp/versions.ts +60 -0
  125. package/src/plugins/index.ts +1 -0
  126. package/src/plugins/mena.ts +56 -0
  127. package/src/runtime/bun.ts +5 -0
  128. package/src/runtime/types.ts +5 -0
package/src/kernel/app.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { bindAgentApprovalFlows } from "../elements/ai/approval-http.ts";
10
10
  import { compileRoute } from "../compiler/dynamic.ts";
11
+ import { HttpBodyRejected, parseBody, type ParseBodyOptions } from "../compiler/http-parse.ts";
11
12
  import type { CompiledRoute } from "../compiler/aot.ts";
12
13
  import { encodeExecuteResult, encodeFailure } from "../compiler/response.ts";
13
14
  import { validate } from "../validation/standard-schema.ts";
@@ -87,9 +88,12 @@ import {
87
88
  createJournal,
88
89
  createJournalSlot,
89
90
  createMemoryJournalStore,
91
+ clearJournalHeartbeats,
90
92
  hasJournalLease,
91
93
  isJournalLeaseBusy,
92
94
  isJournalSuspend,
95
+ isLostJournalLease,
96
+ JournalLeaseBusy,
93
97
  JOURNAL_DEFAULT_LEASE_MS,
94
98
  type JournalSession,
95
99
  type JournalStore,
@@ -368,6 +372,19 @@ export interface OkeOptions {
368
372
  };
369
373
  };
370
374
  };
375
+ /**
376
+ * Tier-1 read cache. Omitted caches pure store-read flows. `{ auto: false }`
377
+ * turns that off. A flow can still opt in with `cache: true` or a duration,
378
+ * and `cache: false` always disables. The key includes tenant, locale,
379
+ * scopes, and membership roles.
380
+ */
381
+ readonly cache?: {
382
+ readonly auto?: boolean;
383
+ /** Auto-cache entry cap. Default 10_000. */
384
+ readonly maxEntries?: number;
385
+ /** Auto-cache TTL when a flow sets none. Default 60_000. */
386
+ readonly defaultTtlMs?: number;
387
+ };
371
388
  /** Channel runtime options. */
372
389
  readonly channel?: BootOptions["channel"];
373
390
  /** AI runtime options. */
@@ -389,6 +406,15 @@ export interface OkeOptions {
389
406
  readonly schedulerIntervalMs?: number;
390
407
  /** Durable-run lease duration ms (default 30_000). Forwarded to boot. */
391
408
  readonly journalLeaseMs?: number;
409
+ /** Request body cap in bytes. Default 1 MiB. A route `maxBodySize` overrides it. */
410
+ readonly maxRequestBodySize?: number;
411
+ /**
412
+ * Opt-in durable code version. Unset stamps nothing and checks nothing.
413
+ * When set, new runs are stamped `app:<version>`. A stored stamp without
414
+ * the `app:` prefix (0.23.1 and earlier) is treated as compatible and
415
+ * restamped. An `app:` stamp that differs fails the run with OKE1076.
416
+ */
417
+ readonly codeVersion?: string;
392
418
  }
393
419
 
394
420
  /** Readiness probe state — see `GET /_/ready`. */
@@ -598,6 +624,8 @@ export interface OkeApp<D extends Record<string, unknown> = {}, R extends AppRou
598
624
  readonly runId?: string;
599
625
  /** Tenant identity for cron / `fx.call` (propagated, unlike auth). */
600
626
  readonly tenant?: { readonly id: string | null };
627
+ /** Explicit locale override for {@link Fx.t} / channel sends. */
628
+ readonly locale?: string;
601
629
  },
602
630
  ): Promise<ExecuteResult>;
603
631
  /**
@@ -792,6 +820,7 @@ function registerHttpRoute(
792
820
  smart: { add(method: string, path: string, value: Binding): void },
793
821
  compiled: WeakMap<Binding, CompiledRoute>,
794
822
  aot: boolean,
823
+ maxRequestBodySize?: number,
795
824
  ): void {
796
825
  const trigger = binding.trigger;
797
826
  if (trigger.kind !== "http") return;
@@ -823,7 +852,7 @@ function registerHttpRoute(
823
852
  seenLiveExposures.set(exposure, binding.flow.name);
824
853
  }
825
854
  smart.add(trigger.method, trigger.path, binding);
826
- compiled.set(binding, compileHttpBinding(binding, aot));
855
+ compiled.set(binding, compileHttpBinding(binding, aot, maxRequestBodySize));
827
856
  }
828
857
 
829
858
  /**
@@ -1114,7 +1143,15 @@ export function oke(options: OkeOptions): OkeApp {
1114
1143
  for (const b of adopted) {
1115
1144
  if (b.trigger.kind === "http") {
1116
1145
  assertHttpBindingReady(b);
1117
- registerHttpRoute(b, seenHttpRoutes, seenLiveExposures, smart, compiled, aot);
1146
+ registerHttpRoute(
1147
+ b,
1148
+ seenHttpRoutes,
1149
+ seenLiveExposures,
1150
+ smart,
1151
+ compiled,
1152
+ aot,
1153
+ options.maxRequestBodySize,
1154
+ );
1118
1155
  }
1119
1156
  if (b.trigger.kind === "mcp") {
1120
1157
  registerMcpTool(b, seenMcpTools, mcpToolsByName);
@@ -1137,7 +1174,15 @@ export function oke(options: OkeOptions): OkeApp {
1137
1174
  registerFlow(b.flow);
1138
1175
  if (b.trigger.kind === "http") {
1139
1176
  assertHttpBindingReady(b);
1140
- registerHttpRoute(b, seenHttpRoutes, seenLiveExposures, smart, compiled, aot);
1177
+ registerHttpRoute(
1178
+ b,
1179
+ seenHttpRoutes,
1180
+ seenLiveExposures,
1181
+ smart,
1182
+ compiled,
1183
+ aot,
1184
+ options.maxRequestBodySize,
1185
+ );
1141
1186
  }
1142
1187
  if (b.trigger.kind === "mcp") {
1143
1188
  registerMcpTool(b, seenMcpTools, mcpToolsByName);
@@ -1389,6 +1434,7 @@ export function oke(options: OkeOptions): OkeApp {
1389
1434
  journalStore: store,
1390
1435
  runId,
1391
1436
  ...(hasJournalLease(store) ? { lease: { instanceId, leaseMs } } : {}),
1437
+ ...(options.codeVersion ? { codeVersion: options.codeVersion } : {}),
1392
1438
  now,
1393
1439
  fx: {
1394
1440
  ...durableResumeFx(),
@@ -1411,7 +1457,13 @@ export function oke(options: OkeOptions): OkeApp {
1411
1457
  });
1412
1458
  } catch (err) {
1413
1459
  if (isJournalLeaseBusy(err)) return;
1414
- throw err;
1460
+ console.warn(
1461
+ JSON.stringify({
1462
+ event: "journal.resume.failed",
1463
+ runId,
1464
+ message: err instanceof Error ? err.message : String(err),
1465
+ }),
1466
+ );
1415
1467
  }
1416
1468
  } finally {
1417
1469
  inflightRuns.delete(runId);
@@ -1587,6 +1639,19 @@ export function oke(options: OkeOptions): OkeApp {
1587
1639
  startScheduler: overrides?.startScheduler ?? options.startScheduler,
1588
1640
  schedulerIntervalMs: overrides?.schedulerIntervalMs ?? options.schedulerIntervalMs,
1589
1641
  journalLeaseMs: overrides?.journalLeaseMs ?? options.journalLeaseMs,
1642
+ codeVersion: overrides?.codeVersion ?? options.codeVersion,
1643
+ ...(options.cache
1644
+ ? {
1645
+ cache: {
1646
+ ...(options.cache.maxEntries !== undefined
1647
+ ? { maxEntries: options.cache.maxEntries }
1648
+ : {}),
1649
+ ...(options.cache.defaultTtlMs !== undefined
1650
+ ? { defaultTtlMs: options.cache.defaultTtlMs }
1651
+ : {}),
1652
+ },
1653
+ }
1654
+ : {}),
1590
1655
  bindings: adopted,
1591
1656
  flows: [...flowsByName.values()],
1592
1657
  onCronFire: overrides?.onCronFire ?? handleCronFire,
@@ -1970,6 +2035,7 @@ export function oke(options: OkeOptions): OkeApp {
1970
2035
  // Hold the run lease for the request's lifetime — a crash mid-run
1971
2036
  // leaves an expired lease another instance can reclaim and resume.
1972
2037
  ...(hasJournalLease(store) ? { lease: { instanceId, leaseMs } } : {}),
2038
+ ...(options.codeVersion ? { codeVersion: options.codeVersion } : {}),
1973
2039
  });
1974
2040
  };
1975
2041
 
@@ -2085,6 +2151,16 @@ export function oke(options: OkeOptions): OkeApp {
2085
2151
  : undefined;
2086
2152
  // Revealed PII must not hit a prior masked entry or land in cache.
2087
2153
  const reveal = extras?.trustedInvoke === true && extras.revealPii === true;
2154
+ const cacheAuto = options.cache?.auto !== false;
2155
+ const cacheCaller = {
2156
+ userId: fx.auth.userId,
2157
+ tenantId: fx.tenant.id,
2158
+ locale: fx.locale,
2159
+ scopes: fx.auth.scopes,
2160
+ roles: principals?.cacheRoles ?? [],
2161
+ verified: fx.auth.verified === true,
2162
+ operatorId: fx.operator.id,
2163
+ };
2088
2164
  const cacheOk =
2089
2165
  cache !== undefined &&
2090
2166
  storeRt !== undefined &&
@@ -2092,12 +2168,13 @@ export function oke(options: OkeOptions): OkeApp {
2092
2168
  !reveal &&
2093
2169
  cache.autoCacheEligible({
2094
2170
  cache: flowDef.cache,
2171
+ auto: cacheAuto,
2095
2172
  durable: flowDef.durable,
2096
2173
  effects: cacheEffects,
2097
2174
  });
2098
2175
  const dims =
2099
2176
  cacheOk && cache && cacheEffects
2100
- ? cache.tier1DimsByResource(cacheEffects, flowDef.name, input, fx.auth.userId)
2177
+ ? cache.tier1DimsByResource(cacheEffects, flowDef.name, input, cacheCaller)
2101
2178
  : undefined;
2102
2179
  if (cacheOk && cache && dims && storeRt) {
2103
2180
  const keys = cache.tier1KeysForReads(cacheEffects, dims);
@@ -2128,43 +2205,79 @@ export function oke(options: OkeOptions): OkeApp {
2128
2205
  journalSession?.rewind();
2129
2206
  return flowDef.do(input as never, fx);
2130
2207
  };
2131
- const raw = await (flowDef.retry ? fxRetry(run, flowDef.retry) : run());
2208
+ const flightKey =
2209
+ cacheOk && cache && dims && cacheEffects
2210
+ ? cache.tier1KeysForReads(cacheEffects, dims)[0]
2211
+ : undefined;
2212
+ const flightResources = (cacheEffects?.reads ?? []).filter(
2213
+ cache?.isStoreResourceRef ?? (() => false),
2214
+ );
2215
+ const generation =
2216
+ storeRt && flightKey
2217
+ ? Object.fromEntries(
2218
+ flightResources.map((resource) => [
2219
+ resource,
2220
+ storeRt.cache.generationOf(resource),
2221
+ ]),
2222
+ )
2223
+ : undefined;
2224
+ let raw: unknown;
2225
+ let cacheable = true;
2226
+ try {
2227
+ const load = () => (flowDef.retry ? fxRetry(run, flowDef.retry) : run());
2228
+ if (flightKey && storeRt) {
2229
+ const flight = await storeRt.cache.coalesce(flightKey, flightResources, load);
2230
+ raw = flight.value;
2231
+ cacheable =
2232
+ flight.cacheable &&
2233
+ (generation === undefined || storeRt.cache.sameGeneration(generation));
2234
+ } else {
2235
+ raw = await load();
2236
+ }
2237
+ } finally {
2238
+ if (cache && storeRt && cacheEffects) {
2239
+ const ledgerFx = cache.effectsFromLedger(ledger.entries);
2240
+ const writeEffects: Effects = {
2241
+ writes: [
2242
+ ...new Set([...(cacheEffects.writes ?? []), ...(ledgerFx.writes ?? [])]),
2243
+ ].filter(cache.isStoreResourceRef),
2244
+ };
2245
+ if ((writeEffects.writes?.length ?? 0) > 0) {
2246
+ storeRt.onWriteEffects(writeEffects);
2247
+ }
2248
+ }
2249
+ }
2132
2250
  const output = isFlowFailure(raw) ? raw : await projectFlowOut(flowDef.out, raw);
2133
2251
  if (!isFlowFailure(output) && cache && storeRt && cacheEffects) {
2134
2252
  const ledgerFx = cache.effectsFromLedger(ledger.entries);
2135
- const writeEffects: Effects = {
2136
- writes: [
2137
- ...new Set([...(cacheEffects.writes ?? []), ...(ledgerFx.writes ?? [])]),
2138
- ].filter(cache.isStoreResourceRef),
2139
- };
2140
- if ((writeEffects.writes?.length ?? 0) > 0) {
2141
- storeRt.onWriteEffects(writeEffects);
2142
- }
2143
- const mergedReads = [
2144
- ...new Set([...(cacheEffects.reads ?? []), ...(ledgerFx.reads ?? [])]),
2145
- ].filter(cache.isStoreResourceRef);
2146
- if (mergedReads.length > 0) {
2147
- learnedTier1Reads.set(flowDef.name, mergedReads);
2253
+ const observed = cache.mergeEffects(cacheEffects, ledgerFx);
2254
+ const storeReads = (observed.reads ?? []).filter(cache.isStoreResourceRef);
2255
+ if (cache.autoCachePure(observed) && storeReads.length > 0) {
2256
+ learnedTier1Reads.set(flowDef.name, storeReads);
2257
+ } else {
2258
+ learnedTier1Reads.delete(flowDef.name);
2148
2259
  }
2149
- const putEffects: Effects = {
2150
- reads: mergedReads,
2151
- ...(ledgerFx.writes ? { writes: ledgerFx.writes } : {}),
2152
- ...(ledgerFx.asks ? { asks: ledgerFx.asks } : {}),
2153
- };
2154
2260
  const storeAfter =
2155
2261
  !reveal &&
2156
2262
  cache.autoCacheEligible({
2157
2263
  cache: flowDef.cache,
2264
+ auto: cacheAuto,
2158
2265
  durable: flowDef.durable,
2159
- effects: putEffects,
2266
+ effects: observed,
2160
2267
  });
2161
- if (storeAfter && output !== undefined && !loadFx().isJsonStreamResult(output)) {
2268
+ if (
2269
+ cacheable &&
2270
+ storeAfter &&
2271
+ output !== undefined &&
2272
+ !loadFx().isJsonStreamResult(output)
2273
+ ) {
2162
2274
  const ttlMs =
2163
2275
  typeof flowDef.cache === "string" ? cache.parseTtlMs(flowDef.cache) : undefined;
2276
+ const putEffects: Effects = { reads: storeReads };
2164
2277
  storeRt.putTier1(
2165
2278
  putEffects,
2166
2279
  output,
2167
- cache.tier1DimsByResource(putEffects, flowDef.name, input, fx.auth.userId),
2280
+ cache.tier1DimsByResource(putEffects, flowDef.name, input, cacheCaller),
2168
2281
  ttlMs,
2169
2282
  );
2170
2283
  if (!cacheOk) telemetry.cacheMisses += 1;
@@ -2215,6 +2328,9 @@ export function oke(options: OkeOptions): OkeApp {
2215
2328
  const journal = openJournal();
2216
2329
  const resumeId = claimed.kind === "reclaimed" ? claimed.row.runId : undefined;
2217
2330
  try {
2331
+ if (resumeId && inflightRuns.has(resumeId)) {
2332
+ throw new JournalLeaseBusy(resumeId);
2333
+ }
2218
2334
  const session = resumeId
2219
2335
  ? await journal.resume(resumeId)
2220
2336
  : await journal.start(flowDef.name, validated);
@@ -2319,6 +2435,9 @@ export function oke(options: OkeOptions): OkeApp {
2319
2435
  };
2320
2436
  return undefined;
2321
2437
  }
2438
+ if (flowDef.durable && journalSession && isLostJournalLease(err)) {
2439
+ return fail("JournalLeaseBusy", {});
2440
+ }
2322
2441
  throw err;
2323
2442
  }
2324
2443
  },
@@ -2367,13 +2486,22 @@ export function oke(options: OkeOptions): OkeApp {
2367
2486
  });
2368
2487
  } else if (!sleeping && isTerminalFailure(result)) {
2369
2488
  const terminalErr = result.failure ?? result.ctx.error;
2370
- await runCompensationPhase({
2371
- flow: flowDef,
2372
- input: ctx.input,
2373
- session: journalSession,
2374
- fx,
2375
- error: terminalErr,
2376
- });
2489
+ const lost =
2490
+ isLostJournalLease(terminalErr) ||
2491
+ isJournalLeaseBusy(terminalErr) ||
2492
+ (terminalErr != null &&
2493
+ typeof terminalErr === "object" &&
2494
+ "error" in terminalErr &&
2495
+ (terminalErr as { error?: { code?: string } }).error?.code === "JournalLeaseBusy");
2496
+ if (!lost) {
2497
+ await runCompensationPhase({
2498
+ flow: flowDef,
2499
+ input: ctx.input,
2500
+ session: journalSession,
2501
+ fx,
2502
+ error: terminalErr,
2503
+ });
2504
+ }
2377
2505
  } else if (parked && sleeping) {
2378
2506
  await journalSession.commit("sleeping", { wakeAt: sleeping.wakeAt });
2379
2507
  } else if (!sleeping) {
@@ -2487,6 +2615,7 @@ export function oke(options: OkeOptions): OkeApp {
2487
2615
  },
2488
2616
  async stop() {
2489
2617
  if (!bootResult) return;
2618
+ clearJournalHeartbeats();
2490
2619
  bootResult.stopScheduler();
2491
2620
  // Realtime bridge down before connections close.
2492
2621
  {
@@ -2514,7 +2643,11 @@ export function oke(options: OkeOptions): OkeApp {
2514
2643
  /** Shared identity store when `gate.auth` is enabled. */
2515
2644
  identities: gateConfig.auth?.identities,
2516
2645
  async resumeDurable(now) {
2517
- const t = now ?? bootResult?.clock?.now() ?? options.fx?.now?.() ?? Date.now();
2646
+ // Due selection uses one sample. The run itself must not: a frozen
2647
+ // closure wrote leases that were already expired by the time persist ran.
2648
+ const sample = (): number => bootResult?.clock?.now() ?? options.fx?.now?.() ?? Date.now();
2649
+ const t = now ?? sample();
2650
+ const live = typeof now === "number" ? (): number => now : sample;
2518
2651
  const { store, instanceId, leaseMs } = activeJournal();
2519
2652
  if (hasJournalLease(store)) {
2520
2653
  // Shared store is the wake schedule — claim due sleeps across all
@@ -2523,14 +2656,14 @@ export function oke(options: OkeOptions): OkeApp {
2523
2656
  const due = await store.claimDueSleep(instanceId, t, leaseMs);
2524
2657
  if (!due) break;
2525
2658
  sleepingRuns.delete(due.id);
2526
- await resumeDurableRun(due.id, due.flow, due.input, () => t);
2659
+ await resumeDurableRun(due.id, due.flow, due.input, live);
2527
2660
  }
2528
2661
  // Crash sweep: the boot orphan scan is once-only, so each tick also
2529
2662
  // reclaims `running` runs whose holder's lease has expired (same
2530
2663
  // takeover physics as Clock: lease expiry + next tick).
2531
2664
  for (const orphan of await store.listOrphans(t)) {
2532
2665
  if (orphan.status !== "running") continue;
2533
- await resumeDurableRun(orphan.id, orphan.flow, orphan.input, () => t);
2666
+ await resumeDurableRun(orphan.id, orphan.flow, orphan.input, live);
2534
2667
  }
2535
2668
  return;
2536
2669
  }
@@ -2543,7 +2676,7 @@ export function oke(options: OkeOptions): OkeApp {
2543
2676
  input: sleeper.input,
2544
2677
  journalStore: store,
2545
2678
  runId,
2546
- now: () => t,
2679
+ now: live,
2547
2680
  fx: durableResumeFx(),
2548
2681
  });
2549
2682
  if (result.status === "sleeping") {
@@ -2684,9 +2817,15 @@ export function oke(options: OkeOptions): OkeApp {
2684
2817
  flowLabel = target.name;
2685
2818
  let internalInput: unknown;
2686
2819
  try {
2687
- internalInput = await request.json();
2688
- } catch {
2689
- internalInput = undefined;
2820
+ internalInput = await parseBody(request, {
2821
+ maxBytes: options.maxRequestBodySize,
2822
+ jsonContentType: "required",
2823
+ });
2824
+ } catch (err) {
2825
+ if (err instanceof HttpBodyRejected) {
2826
+ return respond(encodeFailure(bodyFailure(err, request)));
2827
+ }
2828
+ throw err;
2690
2829
  }
2691
2830
  const internalResult = await execute(
2692
2831
  target,
@@ -2753,7 +2892,7 @@ export function oke(options: OkeOptions): OkeApp {
2753
2892
 
2754
2893
  let route = compiled.get(binding);
2755
2894
  if (!route && binding.trigger.kind === "http") {
2756
- route = compileHttpBinding(binding, aot);
2895
+ route = compileHttpBinding(binding, aot, options.maxRequestBodySize);
2757
2896
  compiled.set(binding, route);
2758
2897
  }
2759
2898
 
@@ -2859,7 +2998,31 @@ export function oke(options: OkeOptions): OkeApp {
2859
2998
  return app;
2860
2999
  }
2861
3000
 
2862
- function compileHttpBinding(binding: Binding, aot: boolean): CompiledRoute {
3001
+ function httpBodyOptions(trigger: HttpTrigger, maxRequestBodySize?: number): ParseBodyOptions {
3002
+ const contract = trigger.contract as
3003
+ | { readonly maxBodySize?: number; readonly jsonContentType?: "required" | "any" }
3004
+ | undefined;
3005
+ return {
3006
+ maxBytes: contract?.maxBodySize ?? maxRequestBodySize,
3007
+ ...(contract?.jsonContentType ? { jsonContentType: contract.jsonContentType } : {}),
3008
+ };
3009
+ }
3010
+
3011
+ function bodyFailure(err: HttpBodyRejected, request: Request): ReturnType<typeof fail> {
3012
+ if (err.code === "InvalidQuery") {
3013
+ return fail("InvalidQuery", { reason: err.reason ?? "malformed_body" });
3014
+ }
3015
+ if (err.code === "UnsupportedMediaType") {
3016
+ return fail("UnsupportedMediaType", { contentType: request.headers.get("content-type") ?? "" });
3017
+ }
3018
+ return fail("PayloadTooLarge", {});
3019
+ }
3020
+
3021
+ function compileHttpBinding(
3022
+ binding: Binding,
3023
+ aot: boolean,
3024
+ maxRequestBodySize?: number,
3025
+ ): CompiledRoute {
2863
3026
  const trigger = binding.trigger as HttpTrigger;
2864
3027
  const hookFns: Array<(...args: never[]) => unknown> = [];
2865
3028
  for (const list of Object.values(binding.flow.hooks)) {
@@ -2875,6 +3038,7 @@ function compileHttpBinding(binding: Binding, aot: boolean): CompiledRoute {
2875
3038
  handler: binding.flow.do as (...args: never[]) => unknown,
2876
3039
  hooks: hookFns,
2877
3040
  schema: binding.flow.in,
3041
+ body: httpBodyOptions(trigger, maxRequestBodySize),
2878
3042
  },
2879
3043
  aot,
2880
3044
  );