okengine 0.23.0 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/ai/mcp.mdx +1 -1
  3. package/site/content/docs/elements/channel/index.mdx +4 -4
  4. package/site/content/docs/elements/channel/receipts.mdx +7 -5
  5. package/site/content/docs/elements/channel/sms.mdx +2 -1
  6. package/site/content/docs/elements/flow/index.mdx +22 -18
  7. package/site/content/docs/elements/signal/index.mdx +15 -14
  8. package/site/content/docs/plugins/otp.mdx +3 -3
  9. package/site/content/docs/reference/fx.mdx +12 -4
  10. package/src/auth/tenants.ts +17 -0
  11. package/src/cli/doctor-diff.test.ts +29 -1
  12. package/src/cli/doctor-diff.ts +35 -0
  13. package/src/cli/manifest-pr-diff.ts +16 -0
  14. package/src/compiler/effects-fetch.test.ts +77 -0
  15. package/src/compiler/effects-infer.ts +87 -16
  16. package/src/compiler/effects-join.test.ts +48 -0
  17. package/src/compiler/extract.test.ts +28 -0
  18. package/src/compiler/extract.ts +11 -1
  19. package/src/compiler/fx-index.ts +58 -4
  20. package/src/console/index.ts +1 -1
  21. package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-C_N4qvai.js} +1 -1
  22. package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-Cq1TxniW.js} +1 -1
  23. package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-BaI4uxZz.js} +1 -1
  24. package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-DNyK7IIY.js} +1 -1
  25. package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-CRhBJMZR.js} +1 -1
  26. package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-D9twj8Xy.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-CsHWlVPU.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-ZaTJAhR9.js} +1 -1
  29. package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DQ-EMAfz.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-Cn_Fl79s.js} +1 -1
  31. package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-CmRFeWdT.js} +3 -3
  32. package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-Cn6R_dXz.js} +1 -1
  33. package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-Bx6EZhSe.js} +1 -1
  34. package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-DmdiUrfh.js} +1 -1
  35. package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-8P6vGXnn.js} +1 -1
  36. package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-BfGvpFSv.js} +1 -1
  37. package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-CWdUHbc0.js} +1 -1
  38. package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-CZOFZzay.js} +1 -1
  39. package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-ki_ishmX.js} +1 -1
  40. package/src/console/ui-next/dist/index.html +1 -1
  41. package/src/drivers/journal-postgres.test.ts +22 -1
  42. package/src/drivers/journal-postgres.ts +223 -12
  43. package/src/drivers/signal-compete.test.ts +234 -0
  44. package/src/drivers/signal-postgres.ts +39 -19
  45. package/src/drivers/signal-redis.ts +167 -11
  46. package/src/drivers/signal-types.ts +12 -0
  47. package/src/elements/ai/mcp-protocol.ts +5 -6
  48. package/src/elements/ai/runtime.ts +20 -19
  49. package/src/elements/channel/runtime.ts +11 -0
  50. package/src/elements/channel/sql-ledger.ts +251 -0
  51. package/src/elements/signal/runtime.ts +9 -0
  52. package/src/elements/signal.test.ts +1 -0
  53. package/src/elements/store/cache.test.ts +120 -15
  54. package/src/elements/store/cache.ts +200 -39
  55. package/src/elements/store/prepare-row.test.ts +6 -2
  56. package/src/elements/store/sql-session.test.ts +2 -0
  57. package/src/elements/store/sql-session.ts +95 -9
  58. package/src/elements/store.ts +1 -0
  59. package/src/kernel/app.ts +32 -16
  60. package/src/kernel/auto-cache.test.ts +228 -0
  61. package/src/kernel/boot-bind/channel.ts +63 -2
  62. package/src/kernel/boot-bind/honor-config.test.ts +40 -10
  63. package/src/kernel/boot-bind/signal.ts +61 -25
  64. package/src/kernel/boot.ts +16 -2
  65. package/src/kernel/errors-compiler.ts +11 -0
  66. package/src/kernel/errors-text.ts +16 -0
  67. package/src/kernel/errors.ts +8 -0
  68. package/src/kernel/flow.ts +33 -3
  69. package/src/kernel/fx-call-types.test.ts +32 -0
  70. package/src/kernel/fx-decide.ts +11 -7
  71. package/src/kernel/fx-sql-handle.ts +46 -7
  72. package/src/kernel/fx.test.ts +1 -3
  73. package/src/kernel/fx.ts +27 -16
  74. package/src/kernel/idempotency.test.ts +2 -1
  75. package/src/kernel/journal.test.ts +114 -0
  76. package/src/kernel/journal.ts +369 -53
  77. package/src/kernel/pipeline-tenant.ts +5 -1
  78. package/src/kernel/pipeline.ts +5 -0
  79. package/src/kernel/signal-tx.ts +28 -0
  80. package/src/kernel/store-transaction.test.ts +95 -0
  81. package/src/mcp/docs-server.ts +19 -2
  82. package/src/mcp/mcp.test.ts +83 -0
  83. package/src/mcp/protocol.ts +8 -2
  84. package/src/mcp/server.ts +23 -2
  85. package/src/mcp/versions.ts +60 -0
  86. package/src/plugins/index.ts +1 -0
  87. package/src/plugins/mena.ts +56 -0
@@ -2,7 +2,8 @@
2
2
  * `postgres` signal driver — transactional emit (dual-write fix).
3
3
  *
4
4
  * `fx.emit` inserts into the outbox on the caller's connection. Consumers
5
- * claim with `FOR UPDATE SKIP LOCKED`; wakeups use `LISTEN` / `NOTIFY`.
5
+ * claim with `FOR UPDATE SKIP LOCKED`. Wakeups use `LISTEN` / `NOTIFY` when
6
+ * the client has them; otherwise the boot scheduler polls `drain`.
6
7
  */
7
8
 
8
9
  import type { SignalDecl } from "../elements/signal/declare.ts";
@@ -65,18 +66,19 @@ export interface PostgresSignalSql {
65
66
  begin<T>(fn: (sql: PostgresSignalSql) => Promise<T>): Promise<T>;
66
67
  /**
67
68
  * LISTEN channel; returns unsubscribe.
69
+ * Optional — Bun.SQL has no listen API. Omit it and poll {@link SignalBus.drain}.
68
70
  *
69
71
  * @param channel - Channel name
70
72
  * @param onNotify - Payload callback
71
73
  */
72
- listen(channel: string, onNotify: (payload: string) => void): Promise<() => void>;
74
+ listen?(channel: string, onNotify: (payload: string) => void): Promise<() => void>;
73
75
  /**
74
- * NOTIFY channel.
76
+ * NOTIFY channel. Optional when the client cannot notify.
75
77
  *
76
78
  * @param channel - Channel name
77
79
  * @param payload - Payload
78
80
  */
79
- notify(channel: string, payload: string): Promise<void>;
81
+ notify?(channel: string, payload: string): Promise<void>;
80
82
  close(): Promise<void>;
81
83
  }
82
84
 
@@ -205,6 +207,9 @@ export function createPostgresSignalFake(options?: {
205
207
  return true;
206
208
  });
207
209
  if (!row) return [];
210
+ // Hold the row before yielding so a concurrent claim skips it.
211
+ row.status = "inflight";
212
+ if (row.locked_by === null) row.locked_by = "claimed";
208
213
  return [{ ...row }];
209
214
  }
210
215
 
@@ -565,9 +570,18 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
565
570
  return n;
566
571
  }
567
572
 
568
- unlisten = await sql.listen(CHANNEL, () => {
569
- void drainQuiet();
570
- });
573
+ if (typeof sql.listen === "function") {
574
+ unlisten = await sql.listen(CHANNEL, () => {
575
+ void drainQuiet();
576
+ });
577
+ } else if ((options.pollMs ?? 0) > 0) {
578
+ const timer = setInterval(() => {
579
+ void drainQuiet();
580
+ }, options.pollMs);
581
+ unlisten = () => {
582
+ clearInterval(timer);
583
+ };
584
+ }
571
585
 
572
586
  function requireDecl(name: string): SignalDecl {
573
587
  const decl = signals.get(name);
@@ -663,7 +677,9 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
663
677
  await insertEmit(tx, e.signal, e.payload, e.options);
664
678
  }
665
679
  });
666
- await sql.notify(CHANNEL, "commit");
680
+ if (typeof sql.notify === "function") {
681
+ await sql.notify(CHANNEL, "commit");
682
+ }
667
683
  },
668
684
  async rollback() {
669
685
  if (finished) throw new Error("transaction finished");
@@ -827,20 +843,24 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
827
843
  for (const consumer of consumers) {
828
844
  const decl = signals.get(consumer.signal);
829
845
  if (decl?.delivery !== "once") continue;
830
- const claimed = await sql.query(CLAIM_ONCE_SQL, [consumer.signal, t, t, t]);
831
- if (claimed[0]) {
832
- progress = true;
833
- const row = claimed[0];
834
- const attempts = Number(row.attempts) + 1;
846
+ const row = await sql.begin(async (tx) => {
847
+ const claimed = await tx.query(CLAIM_ONCE_SQL, [consumer.signal, t, t, t]);
848
+ const hit = claimed[0];
849
+ if (!hit) return undefined;
850
+ const attempts = Number(hit.attempts) + 1;
835
851
  const leaseExpiresAt = t + leaseMs;
836
- await sql.exec(
852
+ await tx.exec(
837
853
  `UPDATE oke_signal_messages SET status = 'inflight', locked_by = ?, attempts = ?, lease_expires_at = ? WHERE id = ?`,
838
- [consumer.subscriberId, attempts, leaseExpiresAt, row.id],
854
+ [consumer.subscriberId, attempts, leaseExpiresAt, hit.id],
839
855
  );
840
- row.locked_by = consumer.subscriberId;
841
- row.attempts = attempts;
842
- row.status = "inflight";
843
- row.lease_expires_at = leaseExpiresAt;
856
+ hit.locked_by = consumer.subscriberId;
857
+ hit.attempts = attempts;
858
+ hit.status = "inflight";
859
+ hit.lease_expires_at = leaseExpiresAt;
860
+ return hit;
861
+ });
862
+ if (row) {
863
+ progress = true;
844
864
  await deliverOnce(row, consumer);
845
865
  }
846
866
  }
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Emit still enrols in a transactional outbox (semantics never regress).
5
5
  * After commit, a relay pushes to Redis Streams (`once`) or pub/sub
6
- * (`broadcast` / `live`). Consumer delivery is driven from the outbox so
7
- * exactly-once / fan-out physics stay correct under `drain()`.
6
+ * (`broadcast` / `live`). With `compete: true`, `once` delivery is
7
+ * `XREADGROUP` / `XACK` (pending reclaim on the next drain). Broadcast
8
+ * and live stay on the outbox plus pub/sub.
8
9
  *
9
10
  * Production bind: {@link createBunSignalRedisClient} (typed `publish` /
10
11
  * `subscribe` / `xadd` / `xreadgroup` / `xack` / `xgroup` on Bun ≥1.4).
@@ -12,8 +13,12 @@
12
13
 
13
14
  import { createSignalEngine } from "./signal-engine.ts";
14
15
  import type {
16
+ DeadLetter,
15
17
  SignalBus,
16
18
  SignalDriver,
19
+ SignalFailureReason,
20
+ SignalHandler,
21
+ SignalMessage,
17
22
  SignalOpenOptions,
18
23
  SignalRedisClientLike,
19
24
  } from "./signal-types.ts";
@@ -68,6 +73,32 @@ export function createBunSignalRedisClient(url?: string): SignalRedisClientLike
68
73
  }
69
74
  },
70
75
  async xreadgroup(group, consumer, key, count) {
76
+ try {
77
+ await redis.send("XAUTOCLAIM", [
78
+ key,
79
+ group,
80
+ consumer,
81
+ "5000",
82
+ "0-0",
83
+ "COUNT",
84
+ String(count),
85
+ ]);
86
+ } catch {
87
+ /* Idle reclaim is best-effort; same-consumer pending still returns below. */
88
+ }
89
+ const pending = parseXreadgroupReply(
90
+ await typedStreams(redis).xreadgroup(
91
+ "GROUP",
92
+ group,
93
+ consumer,
94
+ "COUNT",
95
+ count,
96
+ "STREAMS",
97
+ key,
98
+ "0",
99
+ ),
100
+ );
101
+ if (pending.length > 0) return pending;
71
102
  const reply = await typedStreams(redis).xreadgroup(
72
103
  "GROUP",
73
104
  group,
@@ -138,6 +169,10 @@ export function createSignalRedisFake(): SignalRedisClientLike & {
138
169
  const streams = new Map<string, Array<{ id: string; fields: Record<string, string> }>>();
139
170
  const groups = new Map<string, Set<string>>();
140
171
  const claimed = new Map<string, Set<string>>();
172
+ const pending = new Map<
173
+ string,
174
+ Array<{ id: string; fields: Record<string, string>; consumer: string }>
175
+ >();
141
176
  const subs = new Map<string, Set<(message: string) => void>>();
142
177
  const published: Array<{ channel: string; message: string }> = [];
143
178
  let seq = 0;
@@ -160,6 +195,7 @@ export function createSignalRedisFake(): SignalRedisClientLike & {
160
195
  const gkey = `${key}::${group}`;
161
196
  if (!groups.has(gkey)) groups.set(gkey, new Set());
162
197
  if (!claimed.has(gkey)) claimed.set(gkey, new Set());
198
+ if (!pending.has(gkey)) pending.set(gkey, []);
163
199
  },
164
200
  async xreadgroup(group, consumer, key, count) {
165
201
  const gkey = `${key}::${group}`;
@@ -167,21 +203,36 @@ export function createSignalRedisFake(): SignalRedisClientLike & {
167
203
  await this.xgroupCreate(key, group, "0", { mkstream: true });
168
204
  }
169
205
  const seen = claimed.get(gkey)!;
206
+ const held = pending.get(gkey)!;
170
207
  const list = streams.get(key) ?? [];
171
208
  const out: Array<{ id: string; fields: Record<string, string> }> = [];
209
+ for (const entry of held) {
210
+ if (entry.consumer !== consumer) continue;
211
+ out.push({ id: entry.id, fields: { ...entry.fields } });
212
+ if (out.length >= count) return out;
213
+ }
172
214
  for (const entry of list) {
173
215
  if (seen.has(entry.id)) continue;
174
216
  seen.add(entry.id);
175
- out.push({
176
- id: entry.id,
177
- fields: { ...entry.fields, _consumer: consumer },
178
- });
217
+ held.push({ id: entry.id, fields: { ...entry.fields }, consumer });
218
+ out.push({ id: entry.id, fields: { ...entry.fields } });
219
+ if (out.length >= count) return out;
220
+ }
221
+ for (const entry of held) {
222
+ if (entry.consumer === consumer) continue;
223
+ entry.consumer = consumer;
224
+ out.push({ id: entry.id, fields: { ...entry.fields } });
179
225
  if (out.length >= count) break;
180
226
  }
181
227
  return out;
182
228
  },
183
- async xack() {
184
- return 1;
229
+ async xack(key, group, id) {
230
+ const gkey = `${key}::${group}`;
231
+ const held = pending.get(gkey);
232
+ if (!held) return 0;
233
+ const next = held.filter((entry) => entry.id !== id);
234
+ pending.set(gkey, next);
235
+ return next.length === held.length ? 0 : 1;
185
236
  },
186
237
  async publish(channel, message) {
187
238
  published.push({ channel, message });
@@ -214,6 +265,93 @@ export async function openRedisSignal(options: SignalOpenOptions): Promise<Signa
214
265
  // binds Bun.redis (Streams via send until Bun ships typed xadd).
215
266
  const redis = options.redis ?? createBunSignalRedisClient();
216
267
  const outbox = await createSignalEngine("redis", options);
268
+ const compete = options.compete === true;
269
+ const onceHandlers = new Map<string, SignalHandler[]>();
270
+ const streamDead: DeadLetter[] = [];
271
+ const attemptById = new Map<string, number>();
272
+ const now = options.now ?? (() => Date.now());
273
+
274
+ if (compete) {
275
+ for (const [name, decl] of options.signals) {
276
+ if (decl.delivery !== "once") continue;
277
+ outbox.subscribe(name, "oke-relay-ack", async () => {});
278
+ onceHandlers.set(name, []);
279
+ }
280
+ }
281
+
282
+ function streamMessage(
283
+ name: string,
284
+ id: string,
285
+ payload: unknown,
286
+ attempt: number,
287
+ ): SignalMessage {
288
+ const at = now();
289
+ return {
290
+ id,
291
+ signal: name,
292
+ payload,
293
+ delivery: "once",
294
+ attempts: attempt,
295
+ failures: [],
296
+ createdAt: at,
297
+ availableAt: at,
298
+ status: "inflight",
299
+ };
300
+ }
301
+
302
+ async function drainOnceStreams(): Promise<void> {
303
+ const group = "oke";
304
+ const consumer = options.consumerId ?? "local";
305
+ for (const [name, decl] of options.signals) {
306
+ if (decl.delivery !== "once") continue;
307
+ const key = `oke:signal:${name}`;
308
+ await redis.xgroupCreate(key, group, "0", { mkstream: true });
309
+ const rows = await redis.xreadgroup(group, consumer, key, 32);
310
+ for (const row of rows) {
311
+ const handler = onceHandlers.get(name)?.[0];
312
+ let payload: unknown = null;
313
+ try {
314
+ payload = JSON.parse(row.fields.payload ?? "null");
315
+ } catch {
316
+ payload = null;
317
+ }
318
+ const attempt = (attemptById.get(row.id) ?? 0) + 1;
319
+ attemptById.set(row.id, attempt);
320
+ if (!handler) continue;
321
+ try {
322
+ await handler(streamMessage(name, row.id, payload, attempt));
323
+ await redis.xack(key, group, row.id);
324
+ attemptById.delete(row.id);
325
+ } catch (err) {
326
+ const retries = decl.retries ?? 3;
327
+ if (attempt > retries) {
328
+ const failure: SignalFailureReason = {
329
+ code: "handler_error",
330
+ message: err instanceof Error ? err.message : String(err),
331
+ at: now(),
332
+ attempt,
333
+ };
334
+ if (decl.deadLetter) {
335
+ const at = now();
336
+ streamDead.push({
337
+ id: row.id,
338
+ signal: name,
339
+ payload,
340
+ delivery: "once",
341
+ attempts: attempt,
342
+ failures: [failure],
343
+ createdAt: at,
344
+ availableAt: at,
345
+ status: "dead",
346
+ });
347
+ }
348
+ await redis.xack(key, group, row.id);
349
+ attemptById.delete(row.id);
350
+ }
351
+ }
352
+ }
353
+ }
354
+ }
217
355
 
218
356
  async function relayToRedis(signal: string, payload: unknown): Promise<void> {
219
357
  const decl = options.signals.get(signal);
@@ -252,11 +390,29 @@ export async function openRedisSignal(options: SignalOpenOptions): Promise<Signa
252
390
  rollback: () => tx.rollback(),
253
391
  };
254
392
  },
255
- subscribe: (signal, subscriberId, handler) => outbox.subscribe(signal, subscriberId, handler),
393
+ subscribe(signal, subscriberId, handler) {
394
+ if (compete && options.signals.get(signal)?.delivery === "once") {
395
+ const list = onceHandlers.get(signal) ?? [];
396
+ list.push(handler);
397
+ onceHandlers.set(signal, list);
398
+ return Promise.resolve(() => {
399
+ const next = (onceHandlers.get(signal) ?? []).filter((h) => h !== handler);
400
+ onceHandlers.set(signal, next);
401
+ });
402
+ }
403
+ return outbox.subscribe(signal, subscriberId, handler);
404
+ },
256
405
  live: (signal, opts) => outbox.live(signal, opts),
257
406
  checkLiveResume: (signal, afterId) => outbox.checkLiveResume(signal, afterId),
258
- drain: () => outbox.drain(),
259
- deadLetters: (s) => outbox.deadLetters(s),
407
+ async drain() {
408
+ if (compete) await drainOnceStreams();
409
+ await outbox.drain();
410
+ },
411
+ async deadLetters(s) {
412
+ const local = streamDead.filter((entry) => entry.signal === s);
413
+ const rest = await outbox.deadLetters(s);
414
+ return [...rest, ...local];
415
+ },
260
416
  inspect: (s) => outbox.inspect(s),
261
417
  replay: (opts) => outbox.replay(opts),
262
418
  discard: (opts) => outbox.discard(opts),
@@ -265,6 +265,18 @@ export interface SignalOpenOptions {
265
265
  readonly nats?: SignalNatsClientLike;
266
266
  /** Outbox SQL for redis/nats relay (defaults to in-process). */
267
267
  readonly outboxSql?: unknown;
268
+ /**
269
+ * When the SQL client has no `LISTEN`, poll `drain` on this interval (ms).
270
+ * `0` or omitted with a `listen` client uses notifications only.
271
+ */
272
+ readonly pollMs?: number;
273
+ /**
274
+ * Redis `once` consume via a consumer group (`xreadgroup` / `xack`)
275
+ * instead of the process-local outbox. Boot sets this for `drivers.signal: redis`.
276
+ */
277
+ readonly compete?: boolean;
278
+ /** Consumer name inside the redis group. Defaults to `"local"`. */
279
+ readonly consumerId?: string;
268
280
  }
269
281
 
270
282
  /**
@@ -1,15 +1,14 @@
1
1
  /**
2
2
  * MCP client protocol — 2026-07-28 happy path + dual-era fallback types.
3
3
  *
4
- * Reuses only the JSON-RPC envelope. Does not import the app MCP *server*
5
- * dialect (`2024-11-05` initialize) as the client version.
4
+ * Reuses only the JSON-RPC envelope. The client version is
5
+ * {@link MCP_CLIENT_PROTOCOL_VERSION} from the shared module, not the
6
+ * server's initialize advertisement.
6
7
  */
7
8
 
8
- /** Current MCP protocol version (stateless core). */
9
- export const MCP_CLIENT_PROTOCOL_VERSION = "2026-07-28";
9
+ import { MCP_CLIENT_PROTOCOL_VERSION, MCP_LEGACY_PROTOCOL_VERSION } from "../../mcp/versions.ts";
10
10
 
11
- /** Legacy initialize dialect still in production. */
12
- export const MCP_LEGACY_PROTOCOL_VERSION = "2024-11-05";
11
+ export { MCP_CLIENT_PROTOCOL_VERSION, MCP_LEGACY_PROTOCOL_VERSION };
13
12
 
14
13
  /** JSON-RPC request id. */
15
14
  export type JsonRpcId = string | number;
@@ -879,8 +879,7 @@ export function createAiRuntime(options: CreateAiRuntimeOptions = {}): AiRuntime
879
879
  approvalStepName(id),
880
880
  () => record,
881
881
  )) as AgentApprovalRecord;
882
- let decision = stored;
883
- if (decision.status === "pending") {
882
+ if (stored.status === "pending") {
884
883
  opts.emit?.({
885
884
  type: "RUN_FINISHED",
886
885
  threadId: opts.threadId ?? opts.runId ?? opts.journal.runId,
@@ -890,24 +889,26 @@ export function createAiRuntime(options: CreateAiRuntimeOptions = {}): AiRuntime
890
889
  interrupts: [{ id, reason: "approval", payload: { tool: capability, args } }],
891
890
  },
892
891
  });
893
- await opts.journal.sleep(approvalStepName(id), approval.timeout, () =>
894
- approvalTimeoutMs(approval.timeout),
892
+ }
893
+ // Always consume the sleep row. Resolve moves wakeAt to now, so a
894
+ // resolved replay returns here instead of parking again.
895
+ await opts.journal.sleep(approvalStepName(id), approval.timeout, () =>
896
+ approvalTimeoutMs(approval.timeout),
897
+ );
898
+ const store = options.journalStore;
899
+ if (!store) throw new Error("ai: approval resume requires a journal store");
900
+ let decision = (await readAgentApproval(store, id)) ?? stored;
901
+ if (decision.status === "pending") {
902
+ const wrote = await resolveAgentApproval(
903
+ store,
904
+ id,
905
+ { decision: "deny", reason: "timeout", tenant: decision.tenant },
906
+ now,
907
+ opts.journal.run.lockedBy,
895
908
  );
896
- const store = options.journalStore;
897
- if (!store) throw new Error("ai: approval resume requires a journal store");
898
- decision = (await readAgentApproval(store, id)) ?? decision;
899
- if (decision.status === "pending") {
900
- const wrote = await resolveAgentApproval(
901
- store,
902
- id,
903
- { decision: "deny", reason: "timeout", tenant: decision.tenant },
904
- now,
905
- opts.journal.run.lockedBy,
906
- );
907
- decision = wrote.ok
908
- ? { ...decision, status: "denied", reason: "timeout" }
909
- : ((await readAgentApproval(store, id)) ?? decision);
910
- }
909
+ decision = wrote.ok
910
+ ? { ...decision, status: "denied", reason: "timeout" }
911
+ : ((await readAgentApproval(store, id)) ?? decision);
911
912
  }
912
913
  if (decision.status === "denied") {
913
914
  const denial: AgentDenial = {
@@ -105,6 +105,11 @@ export interface CreateChannelRuntimeOptions {
105
105
  readonly retry?: boolean;
106
106
  /** Injectable clock. */
107
107
  readonly now?: () => number;
108
+ /**
109
+ * Reload durable consent / suppression / receipts.
110
+ * Boot calls this from the scheduler when a SQL ledger is bound.
111
+ */
112
+ readonly refresh?: () => Promise<void>;
108
113
  }
109
114
 
110
115
  /** Send options for {@link ChannelRuntime.send}. */
@@ -127,6 +132,11 @@ export interface ChannelRuntime {
127
132
  readonly costs: MediumCosts;
128
133
  /** Bound driver chain (email + SMS + …). */
129
134
  readonly drivers: readonly ChannelDriver[];
135
+ /**
136
+ * Reload a shared consent / suppression / receipt ledger.
137
+ * Memory stores leave this unset.
138
+ */
139
+ refresh?(): Promise<void>;
130
140
  /**
131
141
  * Send a template through the driver chain.
132
142
  *
@@ -728,6 +738,7 @@ export function createChannelRuntime(options: CreateChannelRuntimeOptions = {}):
728
738
  receipts,
729
739
  costs,
730
740
  drivers,
741
+ ...(options.refresh ? { refresh: options.refresh } : {}),
731
742
  async sendOtp(opts) {
732
743
  const { otp } = otpSmsDriver();
733
744
  return otp.sendOtp(opts);