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.
- package/package.json +1 -1
- package/site/content/docs/ai/mcp.mdx +4 -4
- 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/broadcast.mdx +4 -2
- package/site/content/docs/elements/signal/index.mdx +15 -14
- package/site/content/docs/plugins/otp.mdx +3 -3
- package/site/content/docs/reference/configuration.mdx +16 -0
- package/site/content/docs/reference/errors.mdx +31 -19
- 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/aot.test.ts +3 -2
- package/src/compiler/aot.ts +45 -1
- package/src/compiler/dynamic.ts +1 -1
- 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/compiler/http-parse.test.ts +101 -0
- package/src/compiler/http-parse.ts +188 -9
- package/src/compiler/interpret.ts +25 -2
- package/src/console/index.ts +1 -1
- package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-zBMhZnFm.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-CBj9f7Ju.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-D3MY49h3.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-L9JxdBhp.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-BvFaX-Zt.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-DgB76m-X.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-l1DSJ2Vz.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-Bb9t1lu6.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DMvsi3Ti.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-BwhJBTcQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-RhV2jT_7.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-BRfUILBq.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-BsceAzIG.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-C6lTyCXp.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-YmE806bJ.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-Di4irS49.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-07agasRA.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-CHBck4n_.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/seed-parked-approval.ts +5 -1
- package/src/drivers/journal-postgres.test.ts +106 -2
- package/src/drivers/journal-postgres.ts +475 -14
- package/src/drivers/postgres.test.ts +60 -0
- package/src/drivers/postgres.ts +79 -5
- package/src/drivers/signal-compete.test.ts +238 -0
- package/src/drivers/signal-postgres.test.ts +170 -0
- package/src/drivers/signal-postgres.ts +486 -213
- package/src/drivers/signal-redis.test.ts +224 -0
- package/src/drivers/signal-redis.ts +934 -93
- package/src/drivers/signal-types.ts +12 -0
- package/src/elements/ai/approval.test.ts +15 -3
- package/src/elements/ai/approval.ts +15 -9
- package/src/elements/ai/mcp-protocol.ts +5 -6
- package/src/elements/ai/runtime.ts +20 -19
- package/src/elements/channel/runtime.ts +42 -4
- package/src/elements/channel/sql-ledger.test.ts +404 -0
- package/src/elements/channel/sql-ledger.ts +395 -0
- package/src/elements/clock/chaos-child.ts +12 -2
- package/src/elements/clock/durable.ts +11 -0
- package/src/elements/signal/runtime.ts +9 -0
- package/src/elements/signal.test.ts +1 -0
- package/src/elements/store/cache-bus.test.ts +121 -0
- package/src/elements/store/cache-bus.ts +278 -0
- package/src/elements/store/cache.test.ts +179 -15
- package/src/elements/store/cache.ts +357 -51
- package/src/elements/store/prepare-row.test.ts +6 -2
- package/src/elements/store/runtime.ts +44 -2
- package/src/elements/store/sql-errors.test.ts +131 -0
- package/src/elements/store/sql-errors.ts +39 -16
- package/src/elements/store/sql-nested-tx.test.ts +344 -0
- package/src/elements/store/sql-session.test.ts +2 -0
- package/src/elements/store/sql-session.ts +275 -9
- package/src/elements/store.ts +1 -0
- package/src/kernel/app.ts +207 -43
- package/src/kernel/auto-cache.test.ts +228 -0
- package/src/kernel/auto-registry.test.ts +5 -1
- 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 +72 -25
- package/src/kernel/boot-bind/store.ts +16 -1
- package/src/kernel/boot.test.ts +2 -2
- package/src/kernel/boot.ts +54 -10
- package/src/kernel/boundary-contract.ts +7 -0
- package/src/kernel/builtin-errors.ts +2 -0
- package/src/kernel/call.test.ts +1 -0
- 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 +52 -7
- package/src/kernel/fx.test.ts +1 -3
- package/src/kernel/fx.ts +27 -16
- package/src/kernel/horizontal-child.ts +54 -1
- package/src/kernel/horizontal.integration.test.ts +131 -7
- package/src/kernel/idempotency.test.ts +4 -4
- package/src/kernel/journal-boot.test.ts +83 -6
- package/src/kernel/journal.test.ts +158 -0
- package/src/kernel/journal.ts +515 -54
- 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/authorization.ts +5 -11
- package/src/mcp/confirmation.ts +203 -38
- package/src/mcp/docs-server.ts +26 -4
- package/src/mcp/index.ts +4 -0
- package/src/mcp/mcp.test.ts +404 -33
- package/src/mcp/protocol.ts +8 -2
- package/src/mcp/server.ts +31 -5
- package/src/mcp/session.ts +7 -0
- package/src/mcp/tools.ts +68 -19
- package/src/mcp/versions.ts +60 -0
- package/src/plugins/index.ts +1 -0
- package/src/plugins/mena.ts +56 -0
- package/src/runtime/bun.ts +5 -0
- package/src/runtime/types.ts +5 -0
package/src/kernel/journal.ts
CHANGED
|
@@ -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
|
|
118
|
-
* token
|
|
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
|
|
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
|
|
308
|
-
next.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|