okengine 0.23.0 → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/site/content/docs/ai/mcp.mdx +1 -1
- package/site/content/docs/elements/channel/index.mdx +4 -4
- package/site/content/docs/elements/channel/receipts.mdx +7 -5
- package/site/content/docs/elements/channel/sms.mdx +2 -1
- package/site/content/docs/elements/flow/index.mdx +22 -18
- package/site/content/docs/elements/signal/index.mdx +15 -14
- package/site/content/docs/plugins/otp.mdx +3 -3
- package/site/content/docs/reference/fx.mdx +12 -4
- package/src/auth/tenants.ts +17 -0
- package/src/cli/doctor-diff.test.ts +29 -1
- package/src/cli/doctor-diff.ts +35 -0
- package/src/cli/manifest-pr-diff.ts +16 -0
- package/src/compiler/effects-fetch.test.ts +77 -0
- package/src/compiler/effects-infer.ts +87 -16
- package/src/compiler/effects-join.test.ts +48 -0
- package/src/compiler/extract.test.ts +28 -0
- package/src/compiler/extract.ts +11 -1
- package/src/compiler/fx-index.ts +58 -4
- package/src/console/index.ts +1 -1
- package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-C_N4qvai.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-Cq1TxniW.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-BaI4uxZz.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-DNyK7IIY.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-CRhBJMZR.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-D9twj8Xy.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-CsHWlVPU.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-ZaTJAhR9.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DQ-EMAfz.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-Cn_Fl79s.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-CmRFeWdT.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-Cn6R_dXz.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-Bx6EZhSe.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-DmdiUrfh.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-8P6vGXnn.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-BfGvpFSv.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-CWdUHbc0.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-CZOFZzay.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-ki_ishmX.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/drivers/journal-postgres.test.ts +22 -1
- package/src/drivers/journal-postgres.ts +223 -12
- package/src/drivers/signal-compete.test.ts +234 -0
- package/src/drivers/signal-postgres.ts +39 -19
- package/src/drivers/signal-redis.ts +167 -11
- package/src/drivers/signal-types.ts +12 -0
- package/src/elements/ai/mcp-protocol.ts +5 -6
- package/src/elements/ai/runtime.ts +20 -19
- package/src/elements/channel/runtime.ts +11 -0
- package/src/elements/channel/sql-ledger.ts +251 -0
- package/src/elements/signal/runtime.ts +9 -0
- package/src/elements/signal.test.ts +1 -0
- package/src/elements/store/cache.test.ts +120 -15
- package/src/elements/store/cache.ts +200 -39
- package/src/elements/store/prepare-row.test.ts +6 -2
- package/src/elements/store/sql-session.test.ts +2 -0
- package/src/elements/store/sql-session.ts +95 -9
- package/src/elements/store.ts +1 -0
- package/src/kernel/app.ts +32 -16
- package/src/kernel/auto-cache.test.ts +228 -0
- package/src/kernel/boot-bind/channel.ts +63 -2
- package/src/kernel/boot-bind/honor-config.test.ts +40 -10
- package/src/kernel/boot-bind/signal.ts +61 -25
- package/src/kernel/boot.ts +16 -2
- package/src/kernel/errors-compiler.ts +11 -0
- package/src/kernel/errors-text.ts +16 -0
- package/src/kernel/errors.ts +8 -0
- package/src/kernel/flow.ts +33 -3
- package/src/kernel/fx-call-types.test.ts +32 -0
- package/src/kernel/fx-decide.ts +11 -7
- package/src/kernel/fx-sql-handle.ts +46 -7
- package/src/kernel/fx.test.ts +1 -3
- package/src/kernel/fx.ts +27 -16
- package/src/kernel/idempotency.test.ts +2 -1
- package/src/kernel/journal.test.ts +114 -0
- package/src/kernel/journal.ts +369 -53
- package/src/kernel/pipeline-tenant.ts +5 -1
- package/src/kernel/pipeline.ts +5 -0
- package/src/kernel/signal-tx.ts +28 -0
- package/src/kernel/store-transaction.test.ts +95 -0
- package/src/mcp/docs-server.ts +19 -2
- package/src/mcp/mcp.test.ts +83 -0
- package/src/mcp/protocol.ts +8 -2
- package/src/mcp/server.ts +23 -2
- package/src/mcp/versions.ts +60 -0
- package/src/plugins/index.ts +1 -0
- package/src/plugins/mena.ts +56 -0
|
@@ -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
|
|
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
|
-
|
|
569
|
-
|
|
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
|
-
|
|
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
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
const attempts = Number(
|
|
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
|
|
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,
|
|
854
|
+
[consumer.subscriberId, attempts, leaseExpiresAt, hit.id],
|
|
839
855
|
);
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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`).
|
|
7
|
-
*
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
259
|
-
|
|
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.
|
|
5
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
894
|
-
|
|
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
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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);
|