okengine 0.23.0 → 0.23.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/ai/mcp.mdx +4 -4
  3. package/site/content/docs/elements/channel/index.mdx +4 -4
  4. package/site/content/docs/elements/channel/receipts.mdx +7 -5
  5. package/site/content/docs/elements/channel/sms.mdx +2 -1
  6. package/site/content/docs/elements/flow/index.mdx +22 -18
  7. package/site/content/docs/elements/signal/broadcast.mdx +4 -2
  8. package/site/content/docs/elements/signal/index.mdx +15 -14
  9. package/site/content/docs/plugins/otp.mdx +3 -3
  10. package/site/content/docs/reference/configuration.mdx +16 -0
  11. package/site/content/docs/reference/errors.mdx +31 -19
  12. package/site/content/docs/reference/fx.mdx +12 -4
  13. package/src/auth/tenants.ts +17 -0
  14. package/src/cli/doctor-diff.test.ts +29 -1
  15. package/src/cli/doctor-diff.ts +35 -0
  16. package/src/cli/manifest-pr-diff.ts +16 -0
  17. package/src/compiler/aot.test.ts +3 -2
  18. package/src/compiler/aot.ts +45 -1
  19. package/src/compiler/dynamic.ts +1 -1
  20. package/src/compiler/effects-fetch.test.ts +77 -0
  21. package/src/compiler/effects-infer.ts +87 -16
  22. package/src/compiler/effects-join.test.ts +48 -0
  23. package/src/compiler/extract.test.ts +28 -0
  24. package/src/compiler/extract.ts +11 -1
  25. package/src/compiler/fx-index.ts +58 -4
  26. package/src/compiler/http-parse.test.ts +101 -0
  27. package/src/compiler/http-parse.ts +188 -9
  28. package/src/compiler/interpret.ts +25 -2
  29. package/src/console/index.ts +1 -1
  30. package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-zBMhZnFm.js} +1 -1
  31. package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-CBj9f7Ju.js} +1 -1
  32. package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-D3MY49h3.js} +1 -1
  33. package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-L9JxdBhp.js} +1 -1
  34. package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-BvFaX-Zt.js} +1 -1
  35. package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-DgB76m-X.js} +1 -1
  36. package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-l1DSJ2Vz.js} +1 -1
  37. package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-Bb9t1lu6.js} +1 -1
  38. package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DMvsi3Ti.js} +1 -1
  39. package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-BwhJBTcQ.js} +1 -1
  40. package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-RhV2jT_7.js} +3 -3
  41. package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-BRfUILBq.js} +1 -1
  42. package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-BsceAzIG.js} +1 -1
  43. package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-C6lTyCXp.js} +1 -1
  44. package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-YmE806bJ.js} +1 -1
  45. package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-Di4irS49.js} +1 -1
  46. package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
  47. package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-07agasRA.js} +1 -1
  48. package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-CHBck4n_.js} +1 -1
  49. package/src/console/ui-next/dist/index.html +1 -1
  50. package/src/console/ui-next/seed-parked-approval.ts +5 -1
  51. package/src/drivers/journal-postgres.test.ts +106 -2
  52. package/src/drivers/journal-postgres.ts +475 -14
  53. package/src/drivers/postgres.test.ts +60 -0
  54. package/src/drivers/postgres.ts +79 -5
  55. package/src/drivers/signal-compete.test.ts +238 -0
  56. package/src/drivers/signal-postgres.test.ts +170 -0
  57. package/src/drivers/signal-postgres.ts +486 -213
  58. package/src/drivers/signal-redis.test.ts +224 -0
  59. package/src/drivers/signal-redis.ts +934 -93
  60. package/src/drivers/signal-types.ts +12 -0
  61. package/src/elements/ai/approval.test.ts +15 -3
  62. package/src/elements/ai/approval.ts +15 -9
  63. package/src/elements/ai/mcp-protocol.ts +5 -6
  64. package/src/elements/ai/runtime.ts +20 -19
  65. package/src/elements/channel/runtime.ts +42 -4
  66. package/src/elements/channel/sql-ledger.test.ts +404 -0
  67. package/src/elements/channel/sql-ledger.ts +395 -0
  68. package/src/elements/clock/chaos-child.ts +12 -2
  69. package/src/elements/clock/durable.ts +11 -0
  70. package/src/elements/signal/runtime.ts +9 -0
  71. package/src/elements/signal.test.ts +1 -0
  72. package/src/elements/store/cache-bus.test.ts +121 -0
  73. package/src/elements/store/cache-bus.ts +278 -0
  74. package/src/elements/store/cache.test.ts +179 -15
  75. package/src/elements/store/cache.ts +357 -51
  76. package/src/elements/store/prepare-row.test.ts +6 -2
  77. package/src/elements/store/runtime.ts +44 -2
  78. package/src/elements/store/sql-errors.test.ts +131 -0
  79. package/src/elements/store/sql-errors.ts +39 -16
  80. package/src/elements/store/sql-nested-tx.test.ts +344 -0
  81. package/src/elements/store/sql-session.test.ts +2 -0
  82. package/src/elements/store/sql-session.ts +275 -9
  83. package/src/elements/store.ts +1 -0
  84. package/src/kernel/app.ts +207 -43
  85. package/src/kernel/auto-cache.test.ts +228 -0
  86. package/src/kernel/auto-registry.test.ts +5 -1
  87. package/src/kernel/boot-bind/channel.ts +63 -2
  88. package/src/kernel/boot-bind/honor-config.test.ts +40 -10
  89. package/src/kernel/boot-bind/signal.ts +72 -25
  90. package/src/kernel/boot-bind/store.ts +16 -1
  91. package/src/kernel/boot.test.ts +2 -2
  92. package/src/kernel/boot.ts +54 -10
  93. package/src/kernel/boundary-contract.ts +7 -0
  94. package/src/kernel/builtin-errors.ts +2 -0
  95. package/src/kernel/call.test.ts +1 -0
  96. package/src/kernel/errors-compiler.ts +11 -0
  97. package/src/kernel/errors-text.ts +16 -0
  98. package/src/kernel/errors.ts +8 -0
  99. package/src/kernel/flow.ts +33 -3
  100. package/src/kernel/fx-call-types.test.ts +32 -0
  101. package/src/kernel/fx-decide.ts +11 -7
  102. package/src/kernel/fx-sql-handle.ts +52 -7
  103. package/src/kernel/fx.test.ts +1 -3
  104. package/src/kernel/fx.ts +27 -16
  105. package/src/kernel/horizontal-child.ts +54 -1
  106. package/src/kernel/horizontal.integration.test.ts +131 -7
  107. package/src/kernel/idempotency.test.ts +4 -4
  108. package/src/kernel/journal-boot.test.ts +83 -6
  109. package/src/kernel/journal.test.ts +158 -0
  110. package/src/kernel/journal.ts +515 -54
  111. package/src/kernel/pipeline-tenant.ts +5 -1
  112. package/src/kernel/pipeline.ts +5 -0
  113. package/src/kernel/signal-tx.ts +28 -0
  114. package/src/kernel/store-transaction.test.ts +95 -0
  115. package/src/mcp/authorization.ts +5 -11
  116. package/src/mcp/confirmation.ts +203 -38
  117. package/src/mcp/docs-server.ts +26 -4
  118. package/src/mcp/index.ts +4 -0
  119. package/src/mcp/mcp.test.ts +404 -33
  120. package/src/mcp/protocol.ts +8 -2
  121. package/src/mcp/server.ts +31 -5
  122. package/src/mcp/session.ts +7 -0
  123. package/src/mcp/tools.ts +68 -19
  124. package/src/mcp/versions.ts +60 -0
  125. package/src/plugins/index.ts +1 -0
  126. package/src/plugins/mena.ts +56 -0
  127. package/src/runtime/bun.ts +5 -0
  128. package/src/runtime/types.ts +5 -0
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Same concurrency physics as Signal's `once` delivery and Clock's postgres
5
5
  * CronStore: claim with `FOR UPDATE SKIP LOCKED`; a crashed holder's lease is
6
- * reclaimed lazily on the next claim attempt (no sweeper, no fencing token —
7
- * completed journal steps replay instead of re-running).
6
+ * reclaimed lazily on the next claim attempt (no sweeper). Each acquire bumps
7
+ * a fencing token; writes from a previous holder are rejected. Entries are
8
+ * append-only rows, not a rewritten JSON blob.
8
9
  */
9
10
 
10
11
  import {
@@ -22,6 +23,7 @@ import {
22
23
  } from "../kernel/idempotency-store.ts";
23
24
  import type { DecisionLabelStore } from "../kernel/decision-label-store.ts";
24
25
  import type { AgentEventStore } from "../kernel/agent-event-store.ts";
26
+ import { throwOke } from "../kernel/errors.ts";
25
27
  import { lazyRequire } from "../kernel/lazy-require.ts";
26
28
  import {
27
29
  JOURNAL_DEFAULT_LEASE_MS,
@@ -29,6 +31,7 @@ import {
29
31
  type JournalLeaseStore,
30
32
  type JournalRun,
31
33
  type JournalStore,
34
+ type JournalWriteFence,
32
35
  } from "../kernel/journal.ts";
33
36
  import {
34
37
  resolvePostgresUrl,
@@ -53,6 +56,19 @@ interface JournalDbRow {
53
56
  created_at: number;
54
57
  updated_at: number;
55
58
  tenant: string | null;
59
+ lease_token: number | null;
60
+ code_version: string | null;
61
+ }
62
+
63
+ /** One append-only journal entry row. */
64
+ interface JournalEntryDbRow {
65
+ run_id: string;
66
+ seq: number;
67
+ kind: string;
68
+ name: string | null;
69
+ resource: string | null;
70
+ value: string | null;
71
+ at: number;
56
72
  }
57
73
 
58
74
  /** Minimal SQL + transaction surface for the postgres journal store. */
@@ -85,7 +101,45 @@ const CLAIM_DUE_SQL = `SELECT * FROM oke_journal_runs WHERE status='sleeping' AN
85
101
  /** Boot-time orphan discovery: running/sleeping runs with no live lease. */
86
102
  const ORPHANS_SQL = `SELECT * FROM oke_journal_runs WHERE (status='running' OR status='sleeping' OR status='compensating') AND ((locked_by IS NULL) OR (lease_expires_at IS NOT NULL AND lease_expires_at<=?))`;
87
103
 
88
- const UPDATE_LEASE_SQL = `UPDATE oke_journal_runs SET locked_by = ?, lease_expires_at = ? WHERE id = ?`;
104
+ const UPDATE_LEASE_SQL = `UPDATE oke_journal_runs SET locked_by = ?, lease_expires_at = ?, lease_token = COALESCE(lease_token, 0) + 1 WHERE id = ?`;
105
+
106
+ const HEARTBEAT_SQL = `UPDATE oke_journal_runs SET lease_expires_at = GREATEST(COALESCE(lease_expires_at, 0), ?) WHERE id = ? AND locked_by = ? AND lease_token = ? AND lease_expires_at > ?`;
107
+
108
+ const INSERT_ENTRY_SQL = `INSERT INTO oke_journal_entries (run_id, seq, kind, name, resource, value, at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (run_id, seq) DO NOTHING`;
109
+
110
+ /** One entry row, inserted only while the caller's fence still holds. */
111
+ const APPEND_ENTRY_FENCED_SQL = `INSERT INTO oke_journal_entries (run_id, seq, kind, name, resource, value, at) SELECT ?, ?, ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM oke_journal_runs WHERE id = ? AND locked_by = ? AND lease_token = ? AND lease_expires_at > ? FOR SHARE) ON CONFLICT (run_id, seq) DO NOTHING`;
112
+
113
+ const UPDATE_ENTRY_FENCED_SQL = `UPDATE oke_journal_entries SET kind = ?, name = ?, resource = ?, value = ?, at = ? WHERE run_id = ? AND seq = ? AND EXISTS (SELECT 1 FROM oke_journal_runs WHERE id = ? AND locked_by = ? AND lease_token = ? AND lease_expires_at > ? FOR SHARE)`;
114
+
115
+ const FENCED_HEADER_SQL = `UPDATE oke_journal_runs SET flow = ?, input = ?, status = ?, entries = ?, wake_at = ?, error = ?, output = ?, locked_by = ?, lease_expires_at = ?, updated_at = ?, tenant = ?, lease_token = GREATEST(COALESCE(lease_token, 0), ?), code_version = ? WHERE id = ? AND locked_by = ? AND lease_token = ? AND lease_expires_at > ?`;
116
+
117
+ /** Header write inside `cas`, which already holds the row `FOR UPDATE`. */
118
+ const CAS_HEADER_SQL = `UPDATE oke_journal_runs SET flow = ?, input = ?, status = ?, entries = ?, wake_at = ?, error = ?, output = ?, locked_by = ?, lease_expires_at = ?, updated_at = ?, tenant = ?, lease_token = GREATEST(COALESCE(lease_token, 0), ?), code_version = ? WHERE id = ? AND COALESCE(lease_token, 0) = ?`;
119
+
120
+ /**
121
+ * Every statement the journal driver sends. The in-memory fake must match
122
+ * each one; a new statement that misses every branch fails the driver tests.
123
+ */
124
+ export function postgresJournalStatements(): readonly string[] {
125
+ return [
126
+ CLAIM_LEASE_SQL,
127
+ CLAIM_DUE_SQL,
128
+ ORPHANS_SQL,
129
+ UPDATE_LEASE_SQL,
130
+ HEARTBEAT_SQL,
131
+ INSERT_ENTRY_SQL,
132
+ APPEND_ENTRY_FENCED_SQL,
133
+ UPDATE_ENTRY_FENCED_SQL,
134
+ FENCED_HEADER_SQL,
135
+ CAS_HEADER_SQL,
136
+ SELECT_ENTRIES_SQL,
137
+ RELEASE_LEASE_SQL,
138
+ UPSERT_SQL,
139
+ ];
140
+ }
141
+
142
+ const SELECT_ENTRIES_SQL = `SELECT * FROM oke_journal_entries WHERE run_id = ? ORDER BY seq ASC`;
89
143
 
90
144
  /** Guarded release — never clears another holder's lease. */
91
145
  const RELEASE_LEASE_SQL = `UPDATE oke_journal_runs SET locked_by = NULL, lease_expires_at = NULL WHERE id = ? AND locked_by = ?`;
@@ -171,9 +225,118 @@ function rowToRun(row: Record<string, unknown>): JournalRun {
171
225
  if (r.tenant !== null && r.tenant !== undefined && String(r.tenant).length > 0) {
172
226
  run.tenant = String(r.tenant);
173
227
  }
228
+ const leaseToken = numOrNull(r.lease_token);
229
+ if (leaseToken !== null) run.leaseToken = leaseToken;
230
+ if (
231
+ r.code_version !== null &&
232
+ r.code_version !== undefined &&
233
+ String(r.code_version).length > 0
234
+ ) {
235
+ run.codeVersion = String(r.code_version);
236
+ }
174
237
  return run;
175
238
  }
176
239
 
240
+ function sameEntry(a: JournalEntry, b: JournalEntry): boolean {
241
+ return JSON.stringify(a) === JSON.stringify(b);
242
+ }
243
+
244
+ function fencedHeaderParams(run: JournalRun, fence: JournalWriteFence): unknown[] {
245
+ const base = runToParams(run);
246
+ return [
247
+ base[1],
248
+ base[2],
249
+ base[3],
250
+ JSON.stringify([]),
251
+ base[5],
252
+ base[6],
253
+ base[7],
254
+ base[8],
255
+ base[9],
256
+ base[11],
257
+ base[12],
258
+ fence.leaseToken,
259
+ base[14],
260
+ run.id,
261
+ fence.lockedBy,
262
+ fence.leaseToken,
263
+ fence.now,
264
+ ];
265
+ }
266
+
267
+ function casHeaderParams(run: JournalRun, heldToken: number): unknown[] {
268
+ const base = runToParams(run);
269
+ return [
270
+ base[1],
271
+ base[2],
272
+ base[3],
273
+ JSON.stringify([]),
274
+ base[5],
275
+ base[6],
276
+ base[7],
277
+ base[8],
278
+ base[9],
279
+ base[11],
280
+ base[12],
281
+ run.leaseToken ?? heldToken,
282
+ base[14],
283
+ run.id,
284
+ heldToken,
285
+ ];
286
+ }
287
+
288
+ function entryToParams(runId: string, seq: number, entry: JournalEntry): unknown[] {
289
+ if (entry.kind === "step") {
290
+ return [runId, seq, "step", entry.name, null, JSON.stringify(entry.value ?? null), entry.at];
291
+ }
292
+ if (entry.kind === "sleep") {
293
+ return [
294
+ runId,
295
+ seq,
296
+ "sleep",
297
+ entry.label,
298
+ entry.duration,
299
+ JSON.stringify(entry.wakeAt),
300
+ entry.at,
301
+ ];
302
+ }
303
+ return [
304
+ runId,
305
+ seq,
306
+ "effect",
307
+ entry.effectKind,
308
+ entry.resource,
309
+ JSON.stringify(entry.value ?? null),
310
+ entry.at,
311
+ ];
312
+ }
313
+
314
+ function entryFromRow(row: Record<string, unknown>): JournalEntry {
315
+ const kind = String(row.kind);
316
+ const at = Number(row.at);
317
+ const value =
318
+ row.value === null || row.value === undefined ? null : JSON.parse(String(row.value));
319
+ if (kind === "step") {
320
+ return { kind: "step", name: String(row.name ?? ""), value, at };
321
+ }
322
+ if (kind === "sleep") {
323
+ return {
324
+ kind: "sleep",
325
+ label: String(row.name ?? ""),
326
+ duration: String(row.resource ?? ""),
327
+ wakeAt: Number(value),
328
+ at,
329
+ };
330
+ }
331
+ return {
332
+ kind: "effect",
333
+ effectKind: String(row.name ?? ""),
334
+ resource: String(row.resource ?? ""),
335
+ value,
336
+ at,
337
+ };
338
+ }
339
+
177
340
  function runToParams(run: JournalRun): unknown[] {
178
341
  return [
179
342
  run.id,
@@ -189,6 +352,8 @@ function runToParams(run: JournalRun): unknown[] {
189
352
  run.createdAt,
190
353
  run.updatedAt,
191
354
  run.tenant ?? null,
355
+ run.leaseToken ?? null,
356
+ run.codeVersion ?? null,
192
357
  ];
193
358
  }
194
359
 
@@ -209,6 +374,18 @@ async function ensureSchema(sql: PostgresJournalSql): Promise<void> {
209
374
  tenant TEXT
210
375
  )`);
211
376
  await sql.exec(`ALTER TABLE oke_journal_runs ADD COLUMN IF NOT EXISTS tenant TEXT`);
377
+ await sql.exec(`ALTER TABLE oke_journal_runs ADD COLUMN IF NOT EXISTS lease_token BIGINT`);
378
+ await sql.exec(`ALTER TABLE oke_journal_runs ADD COLUMN IF NOT EXISTS code_version TEXT`);
379
+ await sql.exec(`CREATE TABLE IF NOT EXISTS oke_journal_entries (
380
+ run_id TEXT NOT NULL,
381
+ seq BIGINT NOT NULL,
382
+ kind TEXT NOT NULL,
383
+ name TEXT,
384
+ resource TEXT,
385
+ value TEXT,
386
+ at BIGINT NOT NULL,
387
+ PRIMARY KEY (run_id, seq)
388
+ )`);
212
389
  await sql.exec(
213
390
  `CREATE INDEX IF NOT EXISTS oke_journal_runs_wake ON oke_journal_runs (status, wake_at)`,
214
391
  );
@@ -290,6 +467,7 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
290
467
  event_type: string | null;
291
468
  event_name: string | null;
292
469
  }[];
470
+ entries: JournalEntryDbRow[];
293
471
  };
294
472
 
295
473
  let committed: State = {
@@ -300,6 +478,7 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
300
478
  candidates: [],
301
479
  agentRuns: [],
302
480
  agentEvents: [],
481
+ entries: [],
303
482
  };
304
483
  let active: { state: State; locked: Set<string>; done: boolean } | null = null;
305
484
  /** Run ids held by other active transactions (SKIP LOCKED). */
@@ -320,6 +499,7 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
320
499
  candidates: s.candidates.map((r) => ({ ...r })),
321
500
  agentRuns: s.agentRuns.map((r) => ({ ...r })),
322
501
  agentEvents: s.agentEvents.map((r) => ({ ...r })),
502
+ entries: s.entries.map((r) => ({ ...r })),
323
503
  };
324
504
  }
325
505
 
@@ -342,6 +522,50 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
342
522
  return r.locked_by === null || (r.lease_expires_at !== null && r.lease_expires_at <= cutoff);
343
523
  }
344
524
 
525
+ function fenceHolds(
526
+ state: { rows: JournalDbRow[] },
527
+ id: string,
528
+ lockedBy: string,
529
+ token: number,
530
+ now: number,
531
+ ): boolean {
532
+ const row = state.rows.find((r) => r.id === id);
533
+ if (!row || row.locked_by !== lockedBy) return false;
534
+ if ((row.lease_token ?? 0) !== token) return false;
535
+ return row.lease_expires_at !== null && row.lease_expires_at > now;
536
+ }
537
+
538
+ function applyFencedHeader(
539
+ state: { rows: JournalDbRow[] },
540
+ text: string,
541
+ params: readonly unknown[],
542
+ ): { changes: number } {
543
+ const tokenOnly = /COALESCE\(lease_token,\s*0\)\s*=\s*\?/i.test(text);
544
+ const id = String(params[13]);
545
+ const row = state.rows.find((r) => r.id === id);
546
+ if (!row) return { changes: 0 };
547
+ if (tokenOnly) {
548
+ if ((row.lease_token ?? 0) !== Number(params[14])) return { changes: 0 };
549
+ } else if (!fenceHolds(state, id, String(params[14]), Number(params[15]), Number(params[16]))) {
550
+ return { changes: 0 };
551
+ }
552
+ row.flow = String(params[0]);
553
+ row.input = params[1] === null || params[1] === undefined ? null : String(params[1]);
554
+ row.status = String(params[2]);
555
+ row.entries = String(params[3] ?? "[]");
556
+ row.wake_at = params[4] === null || params[4] === undefined ? null : Number(params[4]);
557
+ row.error = params[5] === null || params[5] === undefined ? null : String(params[5]);
558
+ row.output = params[6] === null || params[6] === undefined ? null : String(params[6]);
559
+ row.locked_by = params[7] === null || params[7] === undefined ? null : String(params[7]);
560
+ row.lease_expires_at = params[8] === null || params[8] === undefined ? null : Number(params[8]);
561
+ row.updated_at = Number(params[9] ?? 0);
562
+ row.tenant = params[10] === null || params[10] === undefined ? null : String(params[10]);
563
+ row.lease_token = Math.max(row.lease_token ?? 0, Number(params[11] ?? 0));
564
+ row.code_version =
565
+ params[12] === null || params[12] === undefined ? row.code_version : String(params[12]);
566
+ return { changes: 1 };
567
+ }
568
+
345
569
  function tryLock(id: string): boolean {
346
570
  if (heldByTxn.has(id) && !(active?.locked.has(id) ?? false)) return false;
347
571
  if (active) {
@@ -481,6 +705,15 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
481
705
  .map((row) => ({ ...row }));
482
706
  }
483
707
 
708
+ if (/FROM\s+oke_journal_entries/i.test(text)) {
709
+ const runId = String(params[0] ?? "");
710
+ const seq = /seq\s*=\s*\?/i.test(text) ? Number(params[1]) : undefined;
711
+ return state.entries
712
+ .filter((row) => row.run_id === runId && (seq === undefined || row.seq === seq))
713
+ .sort((a, b) => a.seq - b.seq)
714
+ .map((row) => ({ ...row }));
715
+ }
716
+
484
717
  throw new Error(`postgres journal fake: unsupported query: ${sql}`);
485
718
  },
486
719
  async exec(sql, params = []) {
@@ -518,23 +751,110 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
518
751
  created_at: Number(record.created_at ?? 0),
519
752
  updated_at: Number(record.updated_at ?? 0),
520
753
  tenant: (record.tenant as string | null) ?? null,
754
+ lease_token: (() => {
755
+ const prev = state.rows.find((r) => r.id === String(record.id))?.lease_token ?? 0;
756
+ const incoming =
757
+ record.lease_token === undefined || record.lease_token === null
758
+ ? prev
759
+ : Number(record.lease_token);
760
+ return Math.max(prev, incoming);
761
+ })(),
762
+ code_version:
763
+ record.code_version === undefined || record.code_version === null
764
+ ? (state.rows.find((r) => r.id === String(record.id))?.code_version ?? null)
765
+ : String(record.code_version),
521
766
  };
522
767
  const idx = state.rows.findIndex((r) => r.id === next.id);
523
- if (idx >= 0) state.rows[idx] = next;
524
- else state.rows.push(next);
768
+ if (idx >= 0) {
769
+ const prev = state.rows[idx]!;
770
+ next.created_at = prev.created_at;
771
+ state.rows[idx] = next;
772
+ } else state.rows.push(next);
773
+ return { changes: 1 };
774
+ }
775
+
776
+ if (/^UPDATE\s+oke_journal_runs\s+SET\s+lease_expires_at\s*=\s*GREATEST/i.test(text)) {
777
+ const id = String(params[1]);
778
+ const row = state.rows.find((r) => r.id === id);
779
+ if (
780
+ !row ||
781
+ !fenceHolds(state, id, String(params[2]), Number(params[3]), Number(params[4]))
782
+ ) {
783
+ return { changes: 0 };
784
+ }
785
+ const next = Number(params[0]);
786
+ row.lease_expires_at = Math.max(row.lease_expires_at ?? 0, next);
525
787
  return { changes: 1 };
526
788
  }
527
789
 
528
790
  const updLease =
529
- /^UPDATE\s+oke_journal_runs\s+SET\s+locked_by\s*=\s*\?,\s*lease_expires_at\s*=\s*\?\s+WHERE\s+id\s*=\s*\?\s*$/i.exec(
791
+ /^UPDATE\s+oke_journal_runs\s+SET\s+locked_by\s*=\s*\?,\s*lease_expires_at\s*=\s*\?/i.exec(
530
792
  text,
531
793
  );
532
- if (updLease) {
533
- const row = state.rows.find((r) => r.id === params[2]);
794
+ if (updLease && /WHERE\s+id\s*=\s*\?/i.test(text) && !/locked_by\s*=\s*NULL/i.test(text)) {
795
+ const id = String(params[params.length - 1]);
796
+ const row = state.rows.find((r) => r.id === id);
534
797
  if (!row) return { changes: 0 };
535
798
  row.locked_by = params[0] === null ? null : String(params[0]);
536
799
  row.lease_expires_at =
537
800
  params[1] === null || params[1] === undefined ? null : Number(params[1]);
801
+ if (/lease_token/i.test(text)) row.lease_token = (row.lease_token ?? 0) + 1;
802
+ return { changes: 1 };
803
+ }
804
+
805
+ if (/^UPDATE\s+oke_journal_runs\s+SET\s+flow\s*=/i.test(text)) {
806
+ return applyFencedHeader(state, text, params);
807
+ }
808
+
809
+ if (/^UPDATE\s+oke_journal_entries\s+SET\s+kind\s*=/i.test(text)) {
810
+ const runId = String(params[5]);
811
+ const seq = Number(params[6]);
812
+ const fenceRun = String(params[7]);
813
+ const lockedBy = String(params[8]);
814
+ const token = Number(params[9]);
815
+ const at = Number(params[10]);
816
+ if (!fenceHolds(state, fenceRun, lockedBy, token, at)) return { changes: 0 };
817
+ const idx = state.entries.findIndex((e) => e.run_id === runId && e.seq === seq);
818
+ if (idx < 0) return { changes: 0 };
819
+ const row = state.entries[idx]!;
820
+ row.kind = String(params[0]);
821
+ row.name = params[1] === null || params[1] === undefined ? null : String(params[1]);
822
+ row.resource = params[2] === null || params[2] === undefined ? null : String(params[2]);
823
+ row.value = params[3] === null || params[3] === undefined ? null : String(params[3]);
824
+ row.at = Number(params[4]);
825
+ return { changes: 1 };
826
+ }
827
+
828
+ if (/^INSERT\s+INTO\s+oke_journal_entries\b/i.test(text)) {
829
+ const fenced = /WHERE\s+EXISTS/i.test(text);
830
+ const row: JournalEntryDbRow = {
831
+ run_id: String(params[0]),
832
+ seq: Number(params[1]),
833
+ kind: String(params[2]),
834
+ name: params[3] === null || params[3] === undefined ? null : String(params[3]),
835
+ resource: params[4] === null || params[4] === undefined ? null : String(params[4]),
836
+ value: params[5] === null || params[5] === undefined ? null : String(params[5]),
837
+ at: Number(params[6]),
838
+ };
839
+ if (
840
+ fenced &&
841
+ !fenceHolds(
842
+ state,
843
+ String(params[7]),
844
+ String(params[8]),
845
+ Number(params[9]),
846
+ Number(params[10]),
847
+ )
848
+ ) {
849
+ return { changes: 0 };
850
+ }
851
+ const idx = state.entries.findIndex((e) => e.run_id === row.run_id && e.seq === row.seq);
852
+ if (idx >= 0) {
853
+ if (/DO\s+NOTHING/i.test(text)) return { changes: 0 };
854
+ state.entries[idx] = row;
855
+ return { changes: 1 };
856
+ }
857
+ state.entries.push(row);
538
858
  return { changes: 1 };
539
859
  }
540
860
 
@@ -807,12 +1127,14 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
807
1127
  return api;
808
1128
  }
809
1129
 
810
- const UPSERT_SQL = `INSERT INTO oke_journal_runs (id, flow, input, status, entries, wake_at, error, output, locked_by, lease_expires_at, created_at, updated_at, tenant) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET flow = EXCLUDED.flow, input = EXCLUDED.input, status = EXCLUDED.status, entries = EXCLUDED.entries, wake_at = EXCLUDED.wake_at, error = EXCLUDED.error, output = EXCLUDED.output, locked_by = EXCLUDED.locked_by, lease_expires_at = EXCLUDED.lease_expires_at, created_at = EXCLUDED.created_at, updated_at = EXCLUDED.updated_at, tenant = EXCLUDED.tenant`;
1130
+ const UPSERT_SQL = `INSERT INTO oke_journal_runs (id, flow, input, status, entries, wake_at, error, output, locked_by, lease_expires_at, created_at, updated_at, tenant, lease_token, code_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET flow = EXCLUDED.flow, input = EXCLUDED.input, status = EXCLUDED.status, entries = EXCLUDED.entries, wake_at = EXCLUDED.wake_at, error = EXCLUDED.error, output = EXCLUDED.output, locked_by = EXCLUDED.locked_by, lease_expires_at = EXCLUDED.lease_expires_at, created_at = oke_journal_runs.created_at, updated_at = EXCLUDED.updated_at, tenant = EXCLUDED.tenant, lease_token = GREATEST(COALESCE(oke_journal_runs.lease_token, 0), COALESCE(EXCLUDED.lease_token, 0)), code_version = EXCLUDED.code_version`;
811
1131
 
812
1132
  /** Postgres journal store with run-level lease coordination. */
813
1133
  export type PostgresJournalStore = JournalStore &
814
1134
  JournalLeaseStore & {
815
1135
  readonly sql: PostgresJournalSql;
1136
+ /** Bytes written for entry rows plus header entry payloads. */
1137
+ readonly writeBytes: number;
816
1138
  close(): Promise<void>;
817
1139
  };
818
1140
 
@@ -888,22 +1210,100 @@ export async function createPostgresJournalStore(
888
1210
 
889
1211
  await ensureSchema(sql);
890
1212
 
1213
+ let writeBytes = 0;
1214
+
1215
+ async function hydrate(row: Record<string, unknown>): Promise<JournalRun> {
1216
+ const run = rowToRun(row);
1217
+ const entryRows = await sql.query(SELECT_ENTRIES_SQL, [run.id]);
1218
+ if (entryRows.length > 0) {
1219
+ (run as { entries: JournalEntry[] }).entries = entryRows.map((entry) => entryFromRow(entry));
1220
+ return run;
1221
+ }
1222
+ if (run.entries.length > 0) {
1223
+ for (let seq = 0; seq < run.entries.length; seq++) {
1224
+ const entry = run.entries[seq];
1225
+ if (!entry) continue;
1226
+ const params = entryToParams(run.id, seq, entry);
1227
+ writeBytes += JSON.stringify(entry).length;
1228
+ await sql.exec(INSERT_ENTRY_SQL, params);
1229
+ }
1230
+ }
1231
+ return run;
1232
+ }
1233
+
891
1234
  const store: PostgresJournalStore = {
892
1235
  sql,
1236
+ get writeBytes() {
1237
+ return writeBytes;
1238
+ },
893
1239
  idempotency: createPostgresIdempotencyStore(sql),
894
1240
  decisions: lazyDecisionLabels(sql),
895
1241
  agentEvents: lazyAgentEvents(sql),
896
1242
  async get(runId) {
897
1243
  const rows = await sql.query(`SELECT * FROM oke_journal_runs WHERE id = ?`, [runId]);
898
1244
  if (!rows[0]) return undefined;
899
- return rowToRun(rows[0]);
1245
+ return hydrate(rows[0]);
900
1246
  },
901
- async put(run) {
1247
+ async put(run, fence) {
1248
+ writeBytes += JSON.stringify(run.entries).length;
1249
+ if (fence) {
1250
+ const wrote = await sql.exec(FENCED_HEADER_SQL, fencedHeaderParams(run, fence));
1251
+ if (wrote.changes === 0) throwOke("JOURNAL_STALE_LEASE", { runId: run.id });
1252
+ return;
1253
+ }
902
1254
  await sql.exec(UPSERT_SQL, runToParams(run));
1255
+ for (let seq = 0; seq < run.entries.length; seq++) {
1256
+ const entry = run.entries[seq];
1257
+ if (!entry) continue;
1258
+ writeBytes += JSON.stringify(entry).length;
1259
+ await sql.exec(INSERT_ENTRY_SQL, entryToParams(run.id, seq, entry));
1260
+ }
1261
+ },
1262
+ async appendEntry(runId, seq, entry, fence) {
1263
+ writeBytes += JSON.stringify(entry).length;
1264
+ if (!fence) {
1265
+ await sql.exec(INSERT_ENTRY_SQL, entryToParams(runId, seq, entry));
1266
+ return;
1267
+ }
1268
+ const wrote = await sql.exec(APPEND_ENTRY_FENCED_SQL, [
1269
+ ...entryToParams(runId, seq, entry),
1270
+ runId,
1271
+ fence.lockedBy,
1272
+ fence.leaseToken,
1273
+ fence.now,
1274
+ ]);
1275
+ if (wrote.changes === 1) return;
1276
+ const stored = await sql.query(
1277
+ `SELECT * FROM oke_journal_entries WHERE run_id = ? AND seq = ?`,
1278
+ [runId, seq],
1279
+ );
1280
+ const row = stored[0];
1281
+ if (row && sameEntry(entryFromRow(row), entry)) return;
1282
+ throwOke("JOURNAL_STALE_LEASE", { runId });
1283
+ },
1284
+ async updateEntry(runId, seq, entry, fence) {
1285
+ writeBytes += JSON.stringify(entry).length;
1286
+ const params = entryToParams(runId, seq, entry);
1287
+ const wrote = await sql.exec(UPDATE_ENTRY_FENCED_SQL, [
1288
+ params[2],
1289
+ params[3],
1290
+ params[4],
1291
+ params[5],
1292
+ params[6],
1293
+ runId,
1294
+ seq,
1295
+ runId,
1296
+ fence.lockedBy,
1297
+ fence.leaseToken,
1298
+ fence.now,
1299
+ ]);
1300
+ if (wrote.changes === 0) throwOke("JOURNAL_STALE_LEASE", { runId });
903
1301
  },
904
1302
  async list() {
905
1303
  const rows = await sql.query(`SELECT * FROM oke_journal_runs`);
906
- return rows.map((r) => rowToRun(r));
1304
+ const runs: JournalRun[] = [];
1305
+ for (const row of rows) runs.push(await hydrate(row));
1306
+ return runs;
907
1307
  },
908
1308
  async acquireLease(runId, instanceId, now, leaseMs) {
909
1309
  return sql.begin(async (tx) => {
@@ -925,13 +1325,70 @@ export async function createPostgresJournalStore(
925
1325
  return { lease: true, leaseExpiresAt: current.leaseExpiresAt };
926
1326
  }
927
1327
  const current = rowToRun(row);
1328
+ const entryRows = await tx.query(SELECT_ENTRIES_SQL, [runId]);
1329
+ if (entryRows.length > 0) {
1330
+ (current as { entries: JournalEntry[] }).entries = entryRows.map((entry) =>
1331
+ entryFromRow(entry),
1332
+ );
1333
+ }
928
1334
  const next = update(current) ?? current;
1335
+ const heldToken = current.leaseToken ?? 0;
1336
+ const renew =
1337
+ current.lockedBy === instanceId &&
1338
+ current.leaseExpiresAt !== undefined &&
1339
+ current.leaseExpiresAt > now &&
1340
+ current.leaseToken !== undefined;
929
1341
  next.lockedBy = instanceId;
930
1342
  next.leaseExpiresAt = now + leaseMs;
931
- await tx.exec(UPSERT_SQL, runToParams(next));
1343
+ next.leaseToken = renew ? heldToken : heldToken + 1;
1344
+ const header = { ...next, entries: [] as JournalEntry[] };
1345
+ const wrote = await tx.exec(CAS_HEADER_SQL, casHeaderParams(header, heldToken));
1346
+ if (wrote.changes === 0) throwOke("JOURNAL_STALE_LEASE", { runId });
1347
+ const fence = { lockedBy: instanceId, leaseToken: next.leaseToken, now };
1348
+ for (let seq = 0; seq < next.entries.length; seq++) {
1349
+ const entry = next.entries[seq];
1350
+ if (!entry) continue;
1351
+ const prev = current.entries[seq];
1352
+ if (!prev) {
1353
+ const inserted = await tx.exec(APPEND_ENTRY_FENCED_SQL, [
1354
+ ...entryToParams(runId, seq, entry),
1355
+ runId,
1356
+ fence.lockedBy,
1357
+ fence.leaseToken,
1358
+ fence.now,
1359
+ ]);
1360
+ if (inserted.changes === 0) throwOke("JOURNAL_STALE_LEASE", { runId });
1361
+ } else if (!sameEntry(prev, entry)) {
1362
+ const params = entryToParams(runId, seq, entry);
1363
+ const updated = await tx.exec(UPDATE_ENTRY_FENCED_SQL, [
1364
+ params[2],
1365
+ params[3],
1366
+ params[4],
1367
+ params[5],
1368
+ params[6],
1369
+ runId,
1370
+ seq,
1371
+ runId,
1372
+ fence.lockedBy,
1373
+ fence.leaseToken,
1374
+ fence.now,
1375
+ ]);
1376
+ if (updated.changes === 0) throwOke("JOURNAL_STALE_LEASE", { runId });
1377
+ }
1378
+ }
932
1379
  return "ok";
933
1380
  });
934
1381
  },
1382
+ async renewLeaseExpiry(runId, expiresAt, fence) {
1383
+ const wrote = await sql.exec(HEARTBEAT_SQL, [
1384
+ expiresAt,
1385
+ runId,
1386
+ fence.lockedBy,
1387
+ fence.leaseToken,
1388
+ fence.now,
1389
+ ]);
1390
+ return wrote.changes === 1;
1391
+ },
935
1392
  async releaseLease(runId, instanceId) {
936
1393
  await sql.exec(RELEASE_LEASE_SQL, [runId, instanceId]);
937
1394
  },
@@ -941,7 +1398,11 @@ export async function createPostgresJournalStore(
941
1398
  const row = rows[0];
942
1399
  if (!row) return undefined;
943
1400
  await tx.exec(UPDATE_LEASE_SQL, [instanceId, now + leaseMs, String(row.id)]);
944
- return rowToRun(row);
1401
+ const run = rowToRun(row);
1402
+ run.lockedBy = instanceId;
1403
+ run.leaseExpiresAt = now + leaseMs;
1404
+ run.leaseToken = (run.leaseToken ?? 0) + 1;
1405
+ return run;
945
1406
  });
946
1407
  },
947
1408
  async listOrphans(now) {