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.
- package/package.json +1 -1
- package/site/content/docs/ai/mcp.mdx +1 -1
- package/site/content/docs/elements/channel/index.mdx +4 -4
- package/site/content/docs/elements/channel/receipts.mdx +7 -5
- package/site/content/docs/elements/channel/sms.mdx +2 -1
- package/site/content/docs/elements/flow/index.mdx +22 -18
- package/site/content/docs/elements/signal/index.mdx +15 -14
- package/site/content/docs/plugins/otp.mdx +3 -3
- package/site/content/docs/reference/fx.mdx +12 -4
- package/src/auth/tenants.ts +17 -0
- package/src/cli/doctor-diff.test.ts +29 -1
- package/src/cli/doctor-diff.ts +35 -0
- package/src/cli/manifest-pr-diff.ts +16 -0
- package/src/compiler/effects-fetch.test.ts +77 -0
- package/src/compiler/effects-infer.ts +87 -16
- package/src/compiler/effects-join.test.ts +48 -0
- package/src/compiler/extract.test.ts +28 -0
- package/src/compiler/extract.ts +11 -1
- package/src/compiler/fx-index.ts +58 -4
- package/src/console/index.ts +1 -1
- package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-C_N4qvai.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-Cq1TxniW.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-BaI4uxZz.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-DNyK7IIY.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-CRhBJMZR.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-D9twj8Xy.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-CsHWlVPU.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-ZaTJAhR9.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DQ-EMAfz.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-Cn_Fl79s.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-CmRFeWdT.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-Cn6R_dXz.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-Bx6EZhSe.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-DmdiUrfh.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-8P6vGXnn.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-BfGvpFSv.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-CWdUHbc0.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-CZOFZzay.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-ki_ishmX.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/drivers/journal-postgres.test.ts +22 -1
- package/src/drivers/journal-postgres.ts +223 -12
- package/src/drivers/signal-compete.test.ts +234 -0
- package/src/drivers/signal-postgres.ts +39 -19
- package/src/drivers/signal-redis.ts +167 -11
- package/src/drivers/signal-types.ts +12 -0
- package/src/elements/ai/mcp-protocol.ts +5 -6
- package/src/elements/ai/runtime.ts +20 -19
- package/src/elements/channel/runtime.ts +11 -0
- package/src/elements/channel/sql-ledger.ts +251 -0
- package/src/elements/signal/runtime.ts +9 -0
- package/src/elements/signal.test.ts +1 -0
- package/src/elements/store/cache.test.ts +120 -15
- package/src/elements/store/cache.ts +200 -39
- package/src/elements/store/prepare-row.test.ts +6 -2
- package/src/elements/store/sql-session.test.ts +2 -0
- package/src/elements/store/sql-session.ts +95 -9
- package/src/elements/store.ts +1 -0
- package/src/kernel/app.ts +32 -16
- package/src/kernel/auto-cache.test.ts +228 -0
- package/src/kernel/boot-bind/channel.ts +63 -2
- package/src/kernel/boot-bind/honor-config.test.ts +40 -10
- package/src/kernel/boot-bind/signal.ts +61 -25
- package/src/kernel/boot.ts +16 -2
- package/src/kernel/errors-compiler.ts +11 -0
- package/src/kernel/errors-text.ts +16 -0
- package/src/kernel/errors.ts +8 -0
- package/src/kernel/flow.ts +33 -3
- package/src/kernel/fx-call-types.test.ts +32 -0
- package/src/kernel/fx-decide.ts +11 -7
- package/src/kernel/fx-sql-handle.ts +46 -7
- package/src/kernel/fx.test.ts +1 -3
- package/src/kernel/fx.ts +27 -16
- package/src/kernel/idempotency.test.ts +2 -1
- package/src/kernel/journal.test.ts +114 -0
- package/src/kernel/journal.ts +369 -53
- package/src/kernel/pipeline-tenant.ts +5 -1
- package/src/kernel/pipeline.ts +5 -0
- package/src/kernel/signal-tx.ts +28 -0
- package/src/kernel/store-transaction.test.ts +95 -0
- package/src/mcp/docs-server.ts +19 -2
- package/src/mcp/mcp.test.ts +83 -0
- package/src/mcp/protocol.ts +8 -2
- package/src/mcp/server.ts +23 -2
- package/src/mcp/versions.ts +60 -0
- package/src/plugins/index.ts +1 -0
- package/src/plugins/mena.ts +56 -0
package/src/kernel/journal.ts
CHANGED
|
@@ -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
|
|
118
|
-
* token
|
|
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
|
|
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
|
|
308
|
-
next.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
const
|
|
679
|
-
if (
|
|
680
|
-
|
|
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
|
-
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/kernel/pipeline.ts
CHANGED
|
@@ -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
|
+
}
|