okengine 0.23.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 (87) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/ai/mcp.mdx +1 -1
  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/index.mdx +15 -14
  8. package/site/content/docs/plugins/otp.mdx +3 -3
  9. package/site/content/docs/reference/fx.mdx +12 -4
  10. package/src/auth/tenants.ts +17 -0
  11. package/src/cli/doctor-diff.test.ts +29 -1
  12. package/src/cli/doctor-diff.ts +35 -0
  13. package/src/cli/manifest-pr-diff.ts +16 -0
  14. package/src/compiler/effects-fetch.test.ts +77 -0
  15. package/src/compiler/effects-infer.ts +87 -16
  16. package/src/compiler/effects-join.test.ts +48 -0
  17. package/src/compiler/extract.test.ts +28 -0
  18. package/src/compiler/extract.ts +11 -1
  19. package/src/compiler/fx-index.ts +58 -4
  20. package/src/console/index.ts +1 -1
  21. package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-C_N4qvai.js} +1 -1
  22. package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-Cq1TxniW.js} +1 -1
  23. package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-BaI4uxZz.js} +1 -1
  24. package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-DNyK7IIY.js} +1 -1
  25. package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-CRhBJMZR.js} +1 -1
  26. package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-D9twj8Xy.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-CsHWlVPU.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-ZaTJAhR9.js} +1 -1
  29. package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DQ-EMAfz.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-Cn_Fl79s.js} +1 -1
  31. package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-CmRFeWdT.js} +3 -3
  32. package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-Cn6R_dXz.js} +1 -1
  33. package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-Bx6EZhSe.js} +1 -1
  34. package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-DmdiUrfh.js} +1 -1
  35. package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-8P6vGXnn.js} +1 -1
  36. package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-BfGvpFSv.js} +1 -1
  37. package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-CWdUHbc0.js} +1 -1
  38. package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-CZOFZzay.js} +1 -1
  39. package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-ki_ishmX.js} +1 -1
  40. package/src/console/ui-next/dist/index.html +1 -1
  41. package/src/drivers/journal-postgres.test.ts +22 -1
  42. package/src/drivers/journal-postgres.ts +223 -12
  43. package/src/drivers/signal-compete.test.ts +234 -0
  44. package/src/drivers/signal-postgres.ts +39 -19
  45. package/src/drivers/signal-redis.ts +167 -11
  46. package/src/drivers/signal-types.ts +12 -0
  47. package/src/elements/ai/mcp-protocol.ts +5 -6
  48. package/src/elements/ai/runtime.ts +20 -19
  49. package/src/elements/channel/runtime.ts +11 -0
  50. package/src/elements/channel/sql-ledger.ts +251 -0
  51. package/src/elements/signal/runtime.ts +9 -0
  52. package/src/elements/signal.test.ts +1 -0
  53. package/src/elements/store/cache.test.ts +120 -15
  54. package/src/elements/store/cache.ts +200 -39
  55. package/src/elements/store/prepare-row.test.ts +6 -2
  56. package/src/elements/store/sql-session.test.ts +2 -0
  57. package/src/elements/store/sql-session.ts +95 -9
  58. package/src/elements/store.ts +1 -0
  59. package/src/kernel/app.ts +32 -16
  60. package/src/kernel/auto-cache.test.ts +228 -0
  61. package/src/kernel/boot-bind/channel.ts +63 -2
  62. package/src/kernel/boot-bind/honor-config.test.ts +40 -10
  63. package/src/kernel/boot-bind/signal.ts +61 -25
  64. package/src/kernel/boot.ts +16 -2
  65. package/src/kernel/errors-compiler.ts +11 -0
  66. package/src/kernel/errors-text.ts +16 -0
  67. package/src/kernel/errors.ts +8 -0
  68. package/src/kernel/flow.ts +33 -3
  69. package/src/kernel/fx-call-types.test.ts +32 -0
  70. package/src/kernel/fx-decide.ts +11 -7
  71. package/src/kernel/fx-sql-handle.ts +46 -7
  72. package/src/kernel/fx.test.ts +1 -3
  73. package/src/kernel/fx.ts +27 -16
  74. package/src/kernel/idempotency.test.ts +2 -1
  75. package/src/kernel/journal.test.ts +114 -0
  76. package/src/kernel/journal.ts +369 -53
  77. package/src/kernel/pipeline-tenant.ts +5 -1
  78. package/src/kernel/pipeline.ts +5 -0
  79. package/src/kernel/signal-tx.ts +28 -0
  80. package/src/kernel/store-transaction.test.ts +95 -0
  81. package/src/mcp/docs-server.ts +19 -2
  82. package/src/mcp/mcp.test.ts +83 -0
  83. package/src/mcp/protocol.ts +8 -2
  84. package/src/mcp/server.ts +23 -2
  85. package/src/mcp/versions.ts +60 -0
  86. package/src/plugins/index.ts +1 -0
  87. package/src/plugins/mena.ts +56 -0
@@ -7,9 +7,11 @@
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";
13
15
  import type { DecisionLabelStore } from "./decision-label-store.ts";
14
16
  import type { IdempotencyStore } from "./idempotency-store.ts";
15
17
  import { JournalSuspend } from "./journal-suspend.ts";
@@ -106,17 +108,31 @@ export interface JournalRun {
106
108
  lockedBy?: string;
107
109
  /** Lease expiry epoch-ms; a crashed holder's run is reclaimable after this. */
108
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;
109
118
  readonly createdAt: number;
110
119
  updatedAt: number;
111
120
  /** Isolation context for resume (`fx.tenant`). */
112
121
  tenant?: string | null;
113
122
  }
114
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
+
115
132
  /**
116
133
  * 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.
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.
120
136
  */
121
137
  export interface JournalLeaseStore {
122
138
  /**
@@ -218,11 +234,31 @@ export interface JournalStore extends Partial<JournalLeaseStore> {
218
234
  */
219
235
  get(runId: string): Promise<JournalRun | undefined>;
220
236
  /**
221
- * 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.
222
241
  *
223
242
  * @param run - Run snapshot
243
+ * @param fence - Required for a leased update. Omitted on the first insert
244
+ * and on uncoordinated stores.
224
245
  */
225
- 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>;
226
262
  /** List all runs (test / console helper). */
227
263
  list(): Promise<readonly JournalRun[]>;
228
264
  }
@@ -283,8 +319,7 @@ function leaseMethods(
283
319
  const map = await load();
284
320
  const run = map.get(runId);
285
321
  if (!run || !claimable(run, instanceId, now)) return false;
286
- run.lockedBy = instanceId;
287
- run.leaseExpiresAt = now + leaseMs;
322
+ holdLease(run, instanceId, now, leaseMs);
288
323
  await flush?.(map);
289
324
  return true;
290
325
  },
@@ -304,8 +339,10 @@ function leaseMethods(
304
339
  return { lease: true, leaseExpiresAt: run.leaseExpiresAt };
305
340
  }
306
341
  const next = update(cloneRun(run)) ?? run;
307
- next.lockedBy = instanceId;
308
- next.leaseExpiresAt = now + leaseMs;
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
+ }
309
346
  map.set(runId, next);
310
347
  await flush?.(map);
311
348
  return "ok";
@@ -333,8 +370,7 @@ function leaseMethods(
333
370
  )
334
371
  .sort((a, b) => (a.wakeAt ?? 0) - (b.wakeAt ?? 0))[0];
335
372
  if (!due) return undefined;
336
- due.lockedBy = instanceId;
337
- due.leaseExpiresAt = now + leaseMs;
373
+ holdLease(due, instanceId, now, leaseMs);
338
374
  await flush?.(map);
339
375
  return cloneRun(due);
340
376
  },
@@ -363,8 +399,16 @@ export function createMemoryJournalStore(seed?: readonly JournalRun[]): JournalS
363
399
  const r = runs.get(runId);
364
400
  return r ? cloneRun(r) : undefined;
365
401
  },
366
- async put(run) {
367
- 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);
368
412
  },
369
413
  async list() {
370
414
  return [...runs.values()].map(cloneRun);
@@ -420,9 +464,19 @@ export function createFileJournalStore(path: string): JournalStore {
420
464
  const r = map.get(runId);
421
465
  return r ? cloneRun(r) : undefined;
422
466
  },
423
- async put(run) {
467
+ async put(run, fence) {
424
468
  const map = await load();
425
- 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);
426
480
  await flush(map);
427
481
  },
428
482
  async list() {
@@ -478,6 +532,11 @@ export interface CreateJournalOptions {
478
532
  * sleeping/finished run never holds a 30s lock.
479
533
  */
480
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;
481
540
  }
482
541
 
483
542
  /**
@@ -531,6 +590,11 @@ export interface JournalSession {
531
590
  */
532
591
  readonly epoch: number;
533
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[];
534
598
  /** Registered per-step undos in persist/replay order (LIFO compensate). */
535
599
  undoStack(): readonly JournalUndoFrame[];
536
600
  /**
@@ -610,6 +674,7 @@ export function createJournal(options: CreateJournalOptions): Journal {
610
674
  const now = options.now ?? (() => Date.now());
611
675
  const newId = options.id ?? (() => okid());
612
676
  const lease = options.lease;
677
+ const codeVersion = options.codeVersion ?? packageVersion();
613
678
  const coordinated = lease !== undefined && hasJournalLease(options.store);
614
679
 
615
680
  function openSession(run: JournalRun, leased: boolean): JournalSession {
@@ -619,16 +684,61 @@ export function createJournal(options: CreateJournalOptions): Journal {
619
684
  let leaseHeld = leased;
620
685
  let registrationPass = false;
621
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;
622
690
  const undos: JournalUndoFrame[] = [];
623
691
 
624
- 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> {
625
700
  run.updatedAt = now();
626
701
  // Natural heartbeat — a live holder renews on every journal write.
627
702
  if (leaseHeld && lease) {
628
703
  run.lockedBy = lease.instanceId;
629
704
  run.leaseExpiresAt = now() + (lease.leaseMs ?? JOURNAL_DEFAULT_LEASE_MS);
630
705
  }
631
- 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;
632
742
  }
633
743
 
634
744
  /** Parking / terminal states must not hold a short lease across days. */
@@ -673,47 +783,51 @@ export function createJournal(options: CreateJournalOptions): Journal {
673
783
  if (name.startsWith(JOURNAL_UNDO_PREFIX) && opts?.undo) {
674
784
  throw new Error("journal: undo steps cannot register nested undo");
675
785
  }
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;
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);
685
791
  }
792
+ return value;
686
793
  }
687
794
  assertCanAppendStep(name);
688
- 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;
689
803
  const entry: JournalStepEntry = {
690
804
  kind: "step",
691
805
  name,
692
- value,
806
+ value: stored,
693
807
  at: now(),
694
808
  };
695
809
  run.entries.push(entry);
696
810
  cursor = run.entries.length;
697
- await persist();
811
+ await persist(entry);
698
812
  if (!name.startsWith(JOURNAL_UNDO_PREFIX)) {
699
813
  registerUndo(name, value, opts);
700
814
  }
701
815
  return value;
702
816
  },
703
817
  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;
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);
716
829
  }
830
+ return;
717
831
  }
718
832
  if (registrationPass) {
719
833
  throw new JournalRegistrationComplete();
@@ -732,38 +846,73 @@ export function createJournal(options: CreateJournalOptions): Journal {
732
846
  run.status = "sleeping";
733
847
  run.wakeAt = wakeAt;
734
848
  releaseLeaseLocally();
735
- await persist();
849
+ await persist(entry);
736
850
  throw new JournalSuspend(label, wakeAt);
737
851
  }
738
- await persist();
852
+ await persist(entry);
739
853
  },
740
854
  async effect<T>(
741
855
  effectKind: string,
742
856
  resource: string,
743
857
  execute: () => T | Promise<T>,
744
858
  ): 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
- }
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
+ });
751
878
  }
752
879
  if (registrationPass) {
753
880
  throw new JournalRegistrationComplete();
754
881
  }
755
- 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
+ }
756
905
  const entry: JournalEffectEntry = {
757
906
  kind: "effect",
758
907
  effectKind,
759
908
  resource,
760
- value,
909
+ value: stored,
761
910
  at: now(),
762
911
  };
763
912
  run.entries.push(entry);
764
913
  cursor = run.entries.length;
765
- await persist();
766
- return value;
914
+ await persist(entry);
915
+ return reviveJournalValue(stored) as T;
767
916
  },
768
917
  get epoch() {
769
918
  return epoch;
@@ -773,6 +922,9 @@ export function createJournal(options: CreateJournalOptions): Journal {
773
922
  undos.length = 0;
774
923
  epoch += 1;
775
924
  },
925
+ recordedBeforeCursor() {
926
+ return run.entries.slice(0, cursor);
927
+ },
776
928
  undoStack() {
777
929
  return undos;
778
930
  },
@@ -813,6 +965,7 @@ export function createJournal(options: CreateJournalOptions): Journal {
813
965
  input,
814
966
  status: "running",
815
967
  entries: [],
968
+ codeVersion,
816
969
  createdAt: t,
817
970
  updatedAt: t,
818
971
  };
@@ -820,6 +973,7 @@ export function createJournal(options: CreateJournalOptions): Journal {
820
973
  // Fresh id — insert already holding the lease (no claim race).
821
974
  run.lockedBy = lease.instanceId;
822
975
  run.leaseExpiresAt = t + (lease.leaseMs ?? JOURNAL_DEFAULT_LEASE_MS);
976
+ run.leaseToken = 1;
823
977
  }
824
978
  await options.store.put(cloneRun(run));
825
979
  return openSession(run, coordinated);
@@ -844,6 +998,16 @@ export function createJournal(options: CreateJournalOptions): Journal {
844
998
  }
845
999
  throw new Error(`journal: run "${runId}" not found`);
846
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
+ }
847
1011
  // Leave status intact — the durable runner parks or continues.
848
1012
  run.updatedAt = now();
849
1013
  if (coordinated && lease) {
@@ -859,3 +1023,155 @@ export function createJournal(options: CreateJournalOptions): Journal {
859
1023
  function cloneRun(run: JournalRun): JournalRun {
860
1024
  return structuredClone(run);
861
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
+ }
@@ -6,7 +6,7 @@
6
6
  * including Store-only apps that never enable `gate.auth.tenant`.
7
7
  */
8
8
 
9
- import { tenantScopesForMember } from "../auth/tenants.ts";
9
+ import { tenantRoleForMember, tenantScopesForMember } from "../auth/tenants.ts";
10
10
  import type { FlowFailure } from "./errors.ts";
11
11
  import type { PipelineDeps } from "./pipeline.ts";
12
12
  import type { InvocationContext } from "./hooks.ts";
@@ -41,6 +41,10 @@ export function applyPipelineTenant(
41
41
  for (const scope of tenantScopesForMember(tenant.store, result.id, userId)) {
42
42
  deps.principals.auth.scopes.add(scope);
43
43
  }
44
+ const role = tenantRoleForMember(tenant.store, result.id, userId);
45
+ deps.principals.cacheRoles = role ? [role] : [];
46
+ } else {
47
+ deps.principals.cacheRoles = [];
44
48
  }
45
49
  return undefined;
46
50
  }
@@ -37,6 +37,11 @@ export interface PrincipalBag {
37
37
  readonly tenant: {
38
38
  id: string | null;
39
39
  };
40
+ /**
41
+ * Membership role names for the tier-1 cache key.
42
+ * Gates do not read this field.
43
+ */
44
+ cacheRoles?: string[];
40
45
  }
41
46
 
42
47
  /** Resolved identity from auth middleware / test harness. */
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Ambient signal transaction for `fx.store().transaction`.
3
+ *
4
+ * Emits inside the callback stage on {@link SignalTransaction} and publish
5
+ * only after the SQL transaction commits.
6
+ */
7
+
8
+ import { AsyncLocalStorage } from "node:async_hooks";
9
+ import type { SignalTransaction } from "../drivers/signal-types.ts";
10
+
11
+ const storage = new AsyncLocalStorage<SignalTransaction>();
12
+
13
+ /**
14
+ * The signal transaction wrapping the current async frame, if any.
15
+ */
16
+ export function currentSignalTransaction(): SignalTransaction | undefined {
17
+ return storage.getStore();
18
+ }
19
+
20
+ /**
21
+ * Run `fn` with `tx` as the ambient signal transaction.
22
+ *
23
+ * @param tx - Open signal transaction
24
+ * @param fn - Body
25
+ */
26
+ export function runInSignalTransaction<T>(tx: SignalTransaction, fn: () => Promise<T>): Promise<T> {
27
+ return storage.run(tx, fn);
28
+ }