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
@@ -10,6 +10,7 @@
10
10
  import { mkdir } from "node:fs/promises";
11
11
  import { dirname } from "node:path";
12
12
  import { okid } from "../okid.ts";
13
+ import { OkeError, throwOke } from "./errors.ts";
13
14
  import type { DecisionLabelStore } from "./decision-label-store.ts";
14
15
  import type { IdempotencyStore } from "./idempotency-store.ts";
15
16
  import { JournalSuspend } from "./journal-suspend.ts";
@@ -106,17 +107,31 @@ export interface JournalRun {
106
107
  lockedBy?: string;
107
108
  /** Lease expiry epoch-ms; a crashed holder's run is reclaimable after this. */
108
109
  leaseExpiresAt?: number;
110
+ /**
111
+ * Fencing token. Bumped on each acquire that is not a live same-holder renew.
112
+ * Writes from a previous holder are rejected.
113
+ */
114
+ leaseToken?: number;
115
+ /** Code version stamped at start. Resume fails when the process version differs. */
116
+ codeVersion?: string;
109
117
  readonly createdAt: number;
110
118
  updatedAt: number;
111
119
  /** Isolation context for resume (`fx.tenant`). */
112
120
  tenant?: string | null;
113
121
  }
114
122
 
123
+ /** Fence presented with every leased journal write. */
124
+ export interface JournalWriteFence {
125
+ readonly lockedBy: string;
126
+ readonly leaseToken: number;
127
+ /** Epoch-ms used to reject an expired holder. */
128
+ readonly now: number;
129
+ }
130
+
115
131
  /**
116
132
  * Run-level lease coordination — same SKIP LOCKED + lazy-reclaim physics as
117
- * Signal's message claims and Clock's tick claims. No sweeper, no fencing
118
- * token: at-least-once after lease expiry, journal replay keeps completed
119
- * steps from re-running.
133
+ * Signal's message claims and Clock's tick claims. No sweeper. A fencing
134
+ * token rejects writes from a holder whose lease was reclaimed.
120
135
  */
121
136
  export interface JournalLeaseStore {
122
137
  /**
@@ -129,6 +144,15 @@ export interface JournalLeaseStore {
129
144
  * @param leaseMs - Lease duration
130
145
  */
131
146
  acquireLease(runId: string, instanceId: string, now: number, leaseMs: number): Promise<boolean>;
147
+ /**
148
+ * Extend `lease_expires_at` without changing the fencing token.
149
+ * Returns false when this holder no longer owns the run.
150
+ *
151
+ * @param runId - Run id
152
+ * @param expiresAt - New expiry, epoch-ms
153
+ * @param fence - Caller's lease fence
154
+ */
155
+ renewLeaseExpiry?(runId: string, expiresAt: number, fence: JournalWriteFence): Promise<boolean>;
132
156
  /**
133
157
  * Take the lease and write `update(run)` in that same hold.
134
158
  * `undefined` from `update` keeps the previous entries and still holds the lease.
@@ -218,11 +242,47 @@ export interface JournalStore extends Partial<JournalLeaseStore> {
218
242
  */
219
243
  get(runId: string): Promise<JournalRun | undefined>;
220
244
  /**
221
- * Persist a run (create or replace).
245
+ * Persist a run header (create or replace).
246
+ *
247
+ * When {@link JournalStore.appendEntry} is present, `entries` on this
248
+ * snapshot may be empty — the store keeps previously appended rows.
222
249
  *
223
250
  * @param run - Run snapshot
251
+ * @param fence - Required for a leased update. Omitted on the first insert
252
+ * and on uncoordinated stores.
224
253
  */
225
- put(run: JournalRun): Promise<void>;
254
+ put(run: JournalRun, fence?: JournalWriteFence): Promise<void>;
255
+ /**
256
+ * Append one journal entry. Built-in stores implement this so a step does
257
+ * not rewrite earlier entries.
258
+ *
259
+ * @param runId - Run id
260
+ * @param seq - Zero-based position
261
+ * @param entry - JSON-safe entry
262
+ * @param fence - Lease fence when the run is coordinated
263
+ */
264
+ appendEntry?(
265
+ runId: string,
266
+ seq: number,
267
+ entry: JournalEntry,
268
+ fence?: JournalWriteFence,
269
+ ): Promise<void>;
270
+ /**
271
+ * Replace one existing entry under the caller's fence. Built-in stores
272
+ * implement this so approval and decide can correct a step without
273
+ * rewriting the run. Absent on a custom store — callers keep `put`.
274
+ *
275
+ * @param runId - Run id
276
+ * @param seq - Zero-based position
277
+ * @param entry - Replacement entry
278
+ * @param fence - Lease fence
279
+ */
280
+ updateEntry?(
281
+ runId: string,
282
+ seq: number,
283
+ entry: JournalEntry,
284
+ fence: JournalWriteFence,
285
+ ): Promise<void>;
226
286
  /** List all runs (test / console helper). */
227
287
  list(): Promise<readonly JournalRun[]>;
228
288
  }
@@ -260,6 +320,27 @@ export function isJournalLeaseBusy(err: unknown): err is JournalLeaseBusy {
260
320
  return err instanceof JournalLeaseBusy;
261
321
  }
262
322
 
323
+ /**
324
+ * A fenced write lost the lease (OKE1074). The holder must not compensate
325
+ * or record the step — another instance may already own the run.
326
+ *
327
+ * @param err - Caught error
328
+ */
329
+ export function isLostJournalLease(err: unknown): boolean {
330
+ return err instanceof OkeError && err.code === 1074;
331
+ }
332
+
333
+ const heartbeatTimers = new Set<ReturnType<typeof setInterval>>();
334
+
335
+ /**
336
+ * Stop every session heartbeat. `stop` and `close` call this so a shut
337
+ * down process does not keep renewing leases it no longer owns.
338
+ */
339
+ export function clearJournalHeartbeats(): void {
340
+ for (const timer of heartbeatTimers) clearInterval(timer);
341
+ heartbeatTimers.clear();
342
+ }
343
+
263
344
  /** Live lease = a holder with an unexpired expiry. */
264
345
  function hasLiveLease(run: JournalRun, now: number): boolean {
265
346
  return run.lockedBy !== undefined && run.leaseExpiresAt !== undefined && run.leaseExpiresAt > now;
@@ -283,8 +364,7 @@ function leaseMethods(
283
364
  const map = await load();
284
365
  const run = map.get(runId);
285
366
  if (!run || !claimable(run, instanceId, now)) return false;
286
- run.lockedBy = instanceId;
287
- run.leaseExpiresAt = now + leaseMs;
367
+ holdLease(run, instanceId, now, leaseMs);
288
368
  await flush?.(map);
289
369
  return true;
290
370
  },
@@ -304,8 +384,10 @@ function leaseMethods(
304
384
  return { lease: true, leaseExpiresAt: run.leaseExpiresAt };
305
385
  }
306
386
  const next = update(cloneRun(run)) ?? run;
307
- next.lockedBy = instanceId;
308
- next.leaseExpiresAt = now + leaseMs;
387
+ holdLease(next, instanceId, now, leaseMs);
388
+ if (run.entries.length > 0 && next.entries.length === 0) {
389
+ (next as { entries: JournalEntry[] }).entries = run.entries;
390
+ }
309
391
  map.set(runId, next);
310
392
  await flush?.(map);
311
393
  return "ok";
@@ -313,6 +395,20 @@ function leaseMethods(
313
395
  release();
314
396
  }
315
397
  },
398
+ async renewLeaseExpiry(runId, expiresAt, fence) {
399
+ const map = await load();
400
+ const run = map.get(runId);
401
+ if (!run) return false;
402
+ const expired = run.leaseExpiresAt !== undefined && run.leaseExpiresAt <= fence.now;
403
+ if (run.lockedBy !== fence.lockedBy || run.leaseToken !== fence.leaseToken || expired) {
404
+ return false;
405
+ }
406
+ if (run.leaseExpiresAt === undefined || run.leaseExpiresAt < expiresAt) {
407
+ run.leaseExpiresAt = expiresAt;
408
+ }
409
+ await flush?.(map);
410
+ return true;
411
+ },
316
412
  async releaseLease(runId, instanceId) {
317
413
  const map = await load();
318
414
  const run = map.get(runId);
@@ -333,8 +429,7 @@ function leaseMethods(
333
429
  )
334
430
  .sort((a, b) => (a.wakeAt ?? 0) - (b.wakeAt ?? 0))[0];
335
431
  if (!due) return undefined;
336
- due.lockedBy = instanceId;
337
- due.leaseExpiresAt = now + leaseMs;
432
+ holdLease(due, instanceId, now, leaseMs);
338
433
  await flush?.(map);
339
434
  return cloneRun(due);
340
435
  },
@@ -363,8 +458,24 @@ export function createMemoryJournalStore(seed?: readonly JournalRun[]): JournalS
363
458
  const r = runs.get(runId);
364
459
  return r ? cloneRun(r) : undefined;
365
460
  },
366
- async put(run) {
367
- runs.set(run.id, cloneRun(run));
461
+ async put(run, fence) {
462
+ const existing = runs.get(run.id);
463
+ assertJournalFence(existing, fence);
464
+ runs.set(run.id, mergeJournalPut(existing, run));
465
+ },
466
+ async appendEntry(runId, seq, entry, fence) {
467
+ const existing = runs.get(runId);
468
+ if (!existing) throw new Error(`journal: run "${runId}" not found`);
469
+ assertJournalFence(existing, fence);
470
+ appendStoredEntry(existing, seq, entry);
471
+ },
472
+ async updateEntry(runId, seq, entry, fence) {
473
+ const existing = runs.get(runId);
474
+ if (!existing) throwOke("JOURNAL_STALE_LEASE", { runId });
475
+ assertJournalFence(existing, fence);
476
+ const entries = existing.entries as JournalEntry[];
477
+ if (!entries[seq]) throwOke("JOURNAL_STALE_LEASE", { runId });
478
+ entries[seq] = structuredClone(entry);
368
479
  },
369
480
  async list() {
370
481
  return [...runs.values()].map(cloneRun);
@@ -420,9 +531,29 @@ export function createFileJournalStore(path: string): JournalStore {
420
531
  const r = map.get(runId);
421
532
  return r ? cloneRun(r) : undefined;
422
533
  },
423
- async put(run) {
534
+ async put(run, fence) {
535
+ const map = await load();
536
+ const existing = map.get(run.id);
537
+ assertJournalFence(existing, fence);
538
+ map.set(run.id, mergeJournalPut(existing, run));
539
+ await flush(map);
540
+ },
541
+ async appendEntry(runId, seq, entry, fence) {
424
542
  const map = await load();
425
- map.set(run.id, cloneRun(run));
543
+ const existing = map.get(runId);
544
+ if (!existing) throw new Error(`journal: run "${runId}" not found`);
545
+ assertJournalFence(existing, fence);
546
+ appendStoredEntry(existing, seq, entry);
547
+ await flush(map);
548
+ },
549
+ async updateEntry(runId, seq, entry, fence) {
550
+ const map = await load();
551
+ const existing = map.get(runId);
552
+ if (!existing) throwOke("JOURNAL_STALE_LEASE", { runId });
553
+ assertJournalFence(existing, fence);
554
+ const entries = existing.entries as JournalEntry[];
555
+ if (!entries[seq]) throwOke("JOURNAL_STALE_LEASE", { runId });
556
+ entries[seq] = structuredClone(entry);
426
557
  await flush(map);
427
558
  },
428
559
  async list() {
@@ -478,6 +609,11 @@ export interface CreateJournalOptions {
478
609
  * sleeping/finished run never holds a 30s lock.
479
610
  */
480
611
  readonly lease?: JournalLeaseOptions;
612
+ /**
613
+ * Code version stamped on new runs. Resume of a different version fails.
614
+ * Defaults to the package version.
615
+ */
616
+ readonly codeVersion?: string;
481
617
  }
482
618
 
483
619
  /**
@@ -531,6 +667,11 @@ export interface JournalSession {
531
667
  */
532
668
  readonly epoch: number;
533
669
  rewind(): void;
670
+ /**
671
+ * Entries already replayed or appended. The row under the cursor is excluded
672
+ * so a replayed call does not count itself.
673
+ */
674
+ recordedBeforeCursor(): readonly JournalEntry[];
534
675
  /** Registered per-step undos in persist/replay order (LIFO compensate). */
535
676
  undoStack(): readonly JournalUndoFrame[];
536
677
  /**
@@ -610,6 +751,7 @@ export function createJournal(options: CreateJournalOptions): Journal {
610
751
  const now = options.now ?? (() => Date.now());
611
752
  const newId = options.id ?? (() => okid());
612
753
  const lease = options.lease;
754
+ const codeVersion = stampAppVersion(options.codeVersion);
613
755
  const coordinated = lease !== undefined && hasJournalLease(options.store);
614
756
 
615
757
  function openSession(run: JournalRun, leased: boolean): JournalSession {
@@ -619,21 +761,111 @@ export function createJournal(options: CreateJournalOptions): Journal {
619
761
  let leaseHeld = leased;
620
762
  let registrationPass = false;
621
763
  let undoExecution = false;
764
+ let stepDepth = 0;
765
+ /** Outer `effect` calls re-enter when the next row was written by a nested call. */
766
+ let effectDepth = 0;
622
767
  const undos: JournalUndoFrame[] = [];
623
768
 
624
- async function persist(): Promise<void> {
769
+ function fence(): JournalWriteFence | undefined {
770
+ if (!leaseHeld || !lease || run.leaseToken === undefined || run.lockedBy === undefined) {
771
+ return undefined;
772
+ }
773
+ return { lockedBy: run.lockedBy, leaseToken: run.leaseToken, now: now() };
774
+ }
775
+
776
+ async function persist(entry?: JournalEntry): Promise<void> {
777
+ if (lostLease) throwOke("JOURNAL_STALE_LEASE", { runId: run.id });
625
778
  run.updatedAt = now();
626
779
  // Natural heartbeat — a live holder renews on every journal write.
627
780
  if (leaseHeld && lease) {
628
781
  run.lockedBy = lease.instanceId;
629
782
  run.leaseExpiresAt = now() + (lease.leaseMs ?? JOURNAL_DEFAULT_LEASE_MS);
630
783
  }
631
- await options.store.put(cloneRun(run));
784
+ const writeFence = fence();
785
+ if (entry && options.store.appendEntry) {
786
+ try {
787
+ await options.store.appendEntry(run.id, run.entries.length - 1, entry, writeFence);
788
+ } catch (err) {
789
+ // The session list was edited in place (a test drops replayed rows).
790
+ // The stored rows no longer match, so replace them from the session.
791
+ if (!(err instanceof Error) || !err.message.includes("does not append")) throw err;
792
+ await options.store.put(cloneRun(run), writeFence);
793
+ return;
794
+ }
795
+ }
796
+ const header = cloneRun(run);
797
+ if (options.store.appendEntry) (header as { entries: JournalEntry[] }).entries = [];
798
+ await options.store.put(header, writeFence);
799
+ }
800
+
801
+ /**
802
+ * The next entry must match this call. Scanning forward would hide a
803
+ * reordered step.
804
+ */
805
+ function takeReplay(
806
+ match: (entry: JournalEntry) => boolean,
807
+ called: string,
808
+ ): JournalEntry | undefined {
809
+ if (cursor >= run.entries.length) return undefined;
810
+ const entry = run.entries[cursor];
811
+ if (entry === undefined || !match(entry)) {
812
+ throwOke("JOURNAL_REPLAY_DIVERGENCE", {
813
+ runId: run.id,
814
+ expected: entry === undefined ? "end" : entryLabel(entry),
815
+ actual: called,
816
+ });
817
+ }
818
+ cursor += 1;
819
+ return entry;
632
820
  }
633
821
 
634
822
  /** Parking / terminal states must not hold a short lease across days. */
823
+ let heartbeat: ReturnType<typeof setInterval> | undefined;
824
+ let lostLease = false;
825
+ let activeStep = "";
826
+
827
+ function stopHeartbeat(): void {
828
+ if (!heartbeat) return;
829
+ clearInterval(heartbeat);
830
+ heartbeatTimers.delete(heartbeat);
831
+ heartbeat = undefined;
832
+ }
833
+
834
+ function startHeartbeat(): void {
835
+ if (!leased || !lease || heartbeat) return;
836
+ const every = Math.max(1, Math.floor((lease.leaseMs ?? JOURNAL_DEFAULT_LEASE_MS) / 3));
837
+ heartbeat = setInterval(() => {
838
+ void (async () => {
839
+ if (!leaseHeld || lostLease || !options.store.renewLeaseExpiry) return;
840
+ const writeFence = fence();
841
+ if (!writeFence) return;
842
+ const expiresAt = now() + (lease.leaseMs ?? JOURNAL_DEFAULT_LEASE_MS);
843
+ const ok = await options.store.renewLeaseExpiry(run.id, expiresAt, writeFence);
844
+ if (!ok) {
845
+ lostLease = true;
846
+ console.warn(
847
+ JSON.stringify({
848
+ event: "journal.lease.lost",
849
+ runId: run.id,
850
+ step: activeStep,
851
+ instanceId: lease.instanceId,
852
+ }),
853
+ );
854
+ return;
855
+ }
856
+ if (run.leaseExpiresAt === undefined || run.leaseExpiresAt < expiresAt) {
857
+ run.leaseExpiresAt = expiresAt;
858
+ }
859
+ })();
860
+ }, every);
861
+ heartbeat.unref?.();
862
+ heartbeatTimers.add(heartbeat);
863
+ }
864
+
635
865
  function releaseLeaseLocally(): void {
636
866
  leaseHeld = false;
867
+ lostLease = false;
868
+ stopHeartbeat();
637
869
  delete run.lockedBy;
638
870
  delete run.leaseExpiresAt;
639
871
  }
@@ -662,6 +894,8 @@ export function createJournal(options: CreateJournalOptions): Journal {
662
894
  }
663
895
  }
664
896
 
897
+ if (leased) startHeartbeat();
898
+
665
899
  return {
666
900
  runId: run.id,
667
901
  run,
@@ -673,47 +907,59 @@ export function createJournal(options: CreateJournalOptions): Journal {
673
907
  if (name.startsWith(JOURNAL_UNDO_PREFIX) && opts?.undo) {
674
908
  throw new Error("journal: undo steps cannot register nested undo");
675
909
  }
676
- // Prefer name match among remaining entries (resume after crash).
677
- for (let i = cursor; i < run.entries.length; i++) {
678
- const e = run.entries[i]!;
679
- if (e.kind === "step" && e.name === name) {
680
- cursor = i + 1;
681
- if (!name.startsWith(JOURNAL_UNDO_PREFIX)) {
682
- registerUndo(name, e.value as T, opts);
683
- }
684
- return e.value as T;
910
+ const replayed = takeReplay((e) => e.kind === "step" && e.name === name, `step ${name}`);
911
+ if (replayed && replayed.kind === "step") {
912
+ const value = reviveJournalValue(replayed.value) as T;
913
+ if (!name.startsWith(JOURNAL_UNDO_PREFIX)) {
914
+ registerUndo(name, value, opts);
685
915
  }
916
+ return value;
686
917
  }
687
918
  assertCanAppendStep(name);
688
- const value = await fn();
919
+ activeStep = name;
920
+ stepDepth += 1;
921
+ let stored: unknown;
922
+ try {
923
+ stored = await journalValue(await fn());
924
+ } finally {
925
+ stepDepth -= 1;
926
+ }
927
+ if (lostLease) throwOke("JOURNAL_STALE_LEASE", { runId: run.id });
928
+ const value = reviveJournalValue(stored) as T;
689
929
  const entry: JournalStepEntry = {
690
930
  kind: "step",
691
931
  name,
692
- value,
932
+ value: stored,
693
933
  at: now(),
694
934
  };
695
935
  run.entries.push(entry);
696
936
  cursor = run.entries.length;
697
- await persist();
937
+ try {
938
+ await persist(entry);
939
+ } catch (err) {
940
+ run.entries.pop();
941
+ cursor = run.entries.length;
942
+ throw err;
943
+ }
698
944
  if (!name.startsWith(JOURNAL_UNDO_PREFIX)) {
699
945
  registerUndo(name, value, opts);
700
946
  }
701
947
  return value;
702
948
  },
703
949
  async sleep(label, duration, parseMs) {
704
- for (let i = cursor; i < run.entries.length; i++) {
705
- const e = run.entries[i]!;
706
- if (e.kind === "sleep" && e.label === label) {
707
- cursor = i + 1;
708
- if (now() < e.wakeAt) {
709
- run.status = "sleeping";
710
- run.wakeAt = e.wakeAt;
711
- releaseLeaseLocally();
712
- await persist();
713
- throw new JournalSuspend(label, e.wakeAt);
714
- }
715
- return;
950
+ const replayed = takeReplay(
951
+ (e) => e.kind === "sleep" && e.label === label,
952
+ `sleep ${label}`,
953
+ );
954
+ if (replayed && replayed.kind === "sleep") {
955
+ if (now() < replayed.wakeAt) {
956
+ run.status = "sleeping";
957
+ run.wakeAt = replayed.wakeAt;
958
+ releaseLeaseLocally();
959
+ await persist();
960
+ throw new JournalSuspend(label, replayed.wakeAt);
716
961
  }
962
+ return;
717
963
  }
718
964
  if (registrationPass) {
719
965
  throw new JournalRegistrationComplete();
@@ -732,38 +978,73 @@ export function createJournal(options: CreateJournalOptions): Journal {
732
978
  run.status = "sleeping";
733
979
  run.wakeAt = wakeAt;
734
980
  releaseLeaseLocally();
735
- await persist();
981
+ await persist(entry);
736
982
  throw new JournalSuspend(label, wakeAt);
737
983
  }
738
- await persist();
984
+ await persist(entry);
739
985
  },
740
986
  async effect<T>(
741
987
  effectKind: string,
742
988
  resource: string,
743
989
  execute: () => T | Promise<T>,
744
990
  ): Promise<T> {
745
- for (let i = cursor; i < run.entries.length; i++) {
746
- const e = run.entries[i]!;
747
- if (e.kind === "effect" && e.effectKind === effectKind && e.resource === resource) {
748
- cursor = i + 1;
749
- return e.value as T;
750
- }
991
+ // Inside fx.step the step value is the snapshot. A nested effect must
992
+ // not insert a row ahead of that step, or replay would diverge.
993
+ if (stepDepth > 0) return execute();
994
+ const called = `effect ${effectKind} ${resource}`;
995
+ const next = cursor < run.entries.length ? run.entries[cursor] : undefined;
996
+ const matches =
997
+ next?.kind === "effect" && next.effectKind === effectKind && next.resource === resource;
998
+ if (matches && next.kind === "effect") {
999
+ cursor += 1;
1000
+ return reviveJournalValue(next.value) as T;
1001
+ }
1002
+ // A nested call must hit its own row. An outer call re-enters: its row
1003
+ // is appended after the nested rows, so it is not next yet.
1004
+ if (next !== undefined && effectDepth > 0) {
1005
+ throwOke("JOURNAL_REPLAY_DIVERGENCE", {
1006
+ runId: run.id,
1007
+ expected: entryLabel(next),
1008
+ actual: called,
1009
+ });
751
1010
  }
752
1011
  if (registrationPass) {
753
1012
  throw new JournalRegistrationComplete();
754
1013
  }
755
- const value = await execute();
1014
+ effectDepth += 1;
1015
+ let stored: unknown;
1016
+ try {
1017
+ stored = await journalValue(await execute());
1018
+ } finally {
1019
+ effectDepth -= 1;
1020
+ }
1021
+ const after = cursor < run.entries.length ? run.entries[cursor] : undefined;
1022
+ if (
1023
+ after?.kind === "effect" &&
1024
+ after.effectKind === effectKind &&
1025
+ after.resource === resource
1026
+ ) {
1027
+ cursor += 1;
1028
+ return reviveJournalValue(after.value) as T;
1029
+ }
1030
+ if (after !== undefined) {
1031
+ throwOke("JOURNAL_REPLAY_DIVERGENCE", {
1032
+ runId: run.id,
1033
+ expected: entryLabel(after),
1034
+ actual: called,
1035
+ });
1036
+ }
756
1037
  const entry: JournalEffectEntry = {
757
1038
  kind: "effect",
758
1039
  effectKind,
759
1040
  resource,
760
- value,
1041
+ value: stored,
761
1042
  at: now(),
762
1043
  };
763
1044
  run.entries.push(entry);
764
1045
  cursor = run.entries.length;
765
- await persist();
766
- return value;
1046
+ await persist(entry);
1047
+ return reviveJournalValue(stored) as T;
767
1048
  },
768
1049
  get epoch() {
769
1050
  return epoch;
@@ -773,6 +1054,9 @@ export function createJournal(options: CreateJournalOptions): Journal {
773
1054
  undos.length = 0;
774
1055
  epoch += 1;
775
1056
  },
1057
+ recordedBeforeCursor() {
1058
+ return run.entries.slice(0, cursor);
1059
+ },
776
1060
  undoStack() {
777
1061
  return undos;
778
1062
  },
@@ -813,6 +1097,7 @@ export function createJournal(options: CreateJournalOptions): Journal {
813
1097
  input,
814
1098
  status: "running",
815
1099
  entries: [],
1100
+ codeVersion,
816
1101
  createdAt: t,
817
1102
  updatedAt: t,
818
1103
  };
@@ -820,6 +1105,7 @@ export function createJournal(options: CreateJournalOptions): Journal {
820
1105
  // Fresh id — insert already holding the lease (no claim race).
821
1106
  run.lockedBy = lease.instanceId;
822
1107
  run.leaseExpiresAt = t + (lease.leaseMs ?? JOURNAL_DEFAULT_LEASE_MS);
1108
+ run.leaseToken = 1;
823
1109
  }
824
1110
  await options.store.put(cloneRun(run));
825
1111
  return openSession(run, coordinated);
@@ -844,13 +1130,42 @@ export function createJournal(options: CreateJournalOptions): Journal {
844
1130
  }
845
1131
  throw new Error(`journal: run "${runId}" not found`);
846
1132
  }
1133
+ if (
1134
+ codeVersion !== undefined &&
1135
+ run.codeVersion !== undefined &&
1136
+ run.codeVersion !== codeVersion
1137
+ ) {
1138
+ if (run.codeVersion.startsWith("app:")) {
1139
+ run.status = "failed";
1140
+ run.error = "OKE1076";
1141
+ run.updatedAt = now();
1142
+ const skewFence =
1143
+ coordinated && lease && run.leaseToken !== undefined && run.lockedBy !== undefined
1144
+ ? { lockedBy: run.lockedBy, leaseToken: run.leaseToken, now: now() }
1145
+ : undefined;
1146
+ await options.store.put(cloneRun(run), skewFence);
1147
+ if (coordinated && lease) {
1148
+ await options.store.releaseLease!(runId, lease.instanceId);
1149
+ }
1150
+ throwOke("JOURNAL_CODE_VERSION", {
1151
+ runId,
1152
+ expected: run.codeVersion,
1153
+ actual: codeVersion,
1154
+ });
1155
+ }
1156
+ run.codeVersion = codeVersion;
1157
+ }
847
1158
  // Leave status intact — the durable runner parks or continues.
848
1159
  run.updatedAt = now();
849
1160
  if (coordinated && lease) {
850
1161
  run.lockedBy = lease.instanceId;
851
1162
  run.leaseExpiresAt = now() + (lease.leaseMs ?? JOURNAL_DEFAULT_LEASE_MS);
852
1163
  }
853
- await options.store.put(cloneRun(run));
1164
+ const resumeFence =
1165
+ coordinated && lease && run.leaseToken !== undefined && run.lockedBy !== undefined
1166
+ ? { lockedBy: run.lockedBy, leaseToken: run.leaseToken, now: now() }
1167
+ : undefined;
1168
+ await options.store.put(cloneRun(run), resumeFence);
854
1169
  return openSession(run, coordinated);
855
1170
  },
856
1171
  };
@@ -859,3 +1174,149 @@ export function createJournal(options: CreateJournalOptions): Journal {
859
1174
  function cloneRun(run: JournalRun): JournalRun {
860
1175
  return structuredClone(run);
861
1176
  }
1177
+
1178
+ /**
1179
+ * Bump the fencing token unless this instance already holds a live lease.
1180
+ *
1181
+ * @param run - Run being claimed
1182
+ * @param instanceId - Claimant
1183
+ * @param now - Epoch-ms
1184
+ * @param leaseMs - Lease duration
1185
+ */
1186
+ function holdLease(run: JournalRun, instanceId: string, now: number, leaseMs: number): void {
1187
+ const renew =
1188
+ run.lockedBy === instanceId && hasLiveLease(run, now) && run.leaseToken !== undefined;
1189
+ if (!renew) run.leaseToken = (run.leaseToken ?? 0) + 1;
1190
+ run.lockedBy = instanceId;
1191
+ const nextExpiry = now + leaseMs;
1192
+ if (run.leaseExpiresAt === undefined || run.leaseExpiresAt < nextExpiry) {
1193
+ run.leaseExpiresAt = nextExpiry;
1194
+ }
1195
+ }
1196
+
1197
+ /**
1198
+ * Reject a write whose lease token is no longer current.
1199
+ *
1200
+ * @param existing - Stored run, if any
1201
+ * @param fence - Caller fence. Omitted on insert and uncoordinated stores.
1202
+ */
1203
+ function assertJournalFence(
1204
+ existing: JournalRun | undefined,
1205
+ fence: JournalWriteFence | undefined,
1206
+ ): void {
1207
+ if (!fence || !existing) return;
1208
+ const expired = existing.leaseExpiresAt !== undefined && existing.leaseExpiresAt <= fence.now;
1209
+ if (existing.lockedBy !== fence.lockedBy || existing.leaseToken !== fence.leaseToken || expired) {
1210
+ throwOke("JOURNAL_STALE_LEASE", { runId: existing.id });
1211
+ }
1212
+ }
1213
+
1214
+ /**
1215
+ * Header replace that keeps appended entries when the snapshot omitted them.
1216
+ *
1217
+ * @param existing - Previous row
1218
+ * @param incoming - Header snapshot
1219
+ */
1220
+ function mergeJournalPut(existing: JournalRun | undefined, incoming: JournalRun): JournalRun {
1221
+ const next = cloneRun(incoming);
1222
+ if (existing && incoming.entries.length === 0 && existing.entries.length > 0) {
1223
+ (next as { entries: JournalEntry[] }).entries = existing.entries;
1224
+ }
1225
+ return next;
1226
+ }
1227
+
1228
+ /**
1229
+ * Append one entry at `seq`, or no-op when that seq is already stored.
1230
+ *
1231
+ * @param run - Stored run
1232
+ * @param seq - Zero-based position
1233
+ * @param entry - JSON-safe entry
1234
+ */
1235
+ function appendStoredEntry(run: JournalRun, seq: number, entry: JournalEntry): void {
1236
+ const entries = run.entries as JournalEntry[];
1237
+ if (entries.length === seq) {
1238
+ entries.push(structuredClone(entry));
1239
+ return;
1240
+ }
1241
+ if (entries.length === seq + 1) return;
1242
+ throw new Error(`journal: entry seq ${seq} does not append (have ${entries.length})`);
1243
+ }
1244
+
1245
+ /**
1246
+ * JSON snapshot of a journaled value. `Response` becomes a marked object.
1247
+ *
1248
+ * @param value - Step or effect result
1249
+ */
1250
+ async function journalValue(value: unknown): Promise<unknown> {
1251
+ if (typeof Response !== "undefined" && value instanceof Response) {
1252
+ const bytes = new Uint8Array(await value.arrayBuffer());
1253
+ let body = "";
1254
+ for (const byte of bytes) body += String.fromCharCode(byte);
1255
+ const headers: Record<string, string> = {};
1256
+ value.headers.forEach((header, name) => {
1257
+ headers[name] = header;
1258
+ });
1259
+ return {
1260
+ __oke: "response",
1261
+ status: value.status,
1262
+ statusText: value.statusText,
1263
+ headers,
1264
+ body: btoa(body),
1265
+ };
1266
+ }
1267
+ if (typeof value === "bigint" || typeof value === "function" || typeof value === "symbol") {
1268
+ throwOke("JOURNAL_VALUE_NOT_JSON", { detail: typeof value });
1269
+ }
1270
+ try {
1271
+ return JSON.parse(JSON.stringify(value ?? null)) as unknown;
1272
+ } catch {
1273
+ throwOke("JOURNAL_VALUE_NOT_JSON", { detail: "unserializable" });
1274
+ }
1275
+ }
1276
+
1277
+ /**
1278
+ * Rebuild a `Response` from a journal snapshot. Other values pass through.
1279
+ *
1280
+ * @param value - Stored entry value
1281
+ */
1282
+ function reviveJournalValue(value: unknown): unknown {
1283
+ if (!value || typeof value !== "object") return value;
1284
+ const record = value as {
1285
+ __oke?: string;
1286
+ status?: number;
1287
+ statusText?: string;
1288
+ headers?: Record<string, string>;
1289
+ body?: string;
1290
+ };
1291
+ if (record.__oke !== "response" || typeof record.body !== "string") return value;
1292
+ const binary = atob(record.body);
1293
+ const bytes = new Uint8Array(binary.length);
1294
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
1295
+ return new Response(bytes, {
1296
+ status: record.status ?? 200,
1297
+ statusText: record.statusText,
1298
+ headers: record.headers,
1299
+ });
1300
+ }
1301
+
1302
+ /**
1303
+ * Short label for a journal entry, used in replay errors.
1304
+ *
1305
+ * @param entry - Stored entry
1306
+ */
1307
+ function entryLabel(entry: JournalEntry): string {
1308
+ if (entry.kind === "step") return `step ${entry.name}`;
1309
+ if (entry.kind === "sleep") return `sleep ${entry.label}`;
1310
+ return `effect ${entry.effectKind} ${entry.resource}`;
1311
+ }
1312
+
1313
+ /**
1314
+ * Namespace an opt-in code version so an okengine package version stored by
1315
+ * 0.23.1 (`"0.23.1"`) is not compared with an app stamp (`"app:1"`).
1316
+ *
1317
+ * @param version - Caller version, or unset to skip the check
1318
+ */
1319
+ function stampAppVersion(version: string | undefined): string | undefined {
1320
+ if (version === undefined || version.length === 0) return undefined;
1321
+ return version.startsWith("app:") ? version : `app:${version}`;
1322
+ }