okengine 0.23.1 → 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 +3 -3
- package/site/content/docs/elements/signal/broadcast.mdx +4 -2
- package/site/content/docs/reference/configuration.mdx +16 -0
- package/site/content/docs/reference/errors.mdx +31 -19
- 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/http-parse.test.ts +101 -0
- package/src/compiler/http-parse.ts +188 -9
- package/src/compiler/interpret.ts +25 -2
- package/src/console/ui-next/dist/assets/{access-page-C_N4qvai.js → access-page-zBMhZnFm.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-Cq1TxniW.js → agent-disclosure-CBj9f7Ju.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-BaI4uxZz.js → cache-glyph-D3MY49h3.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DNyK7IIY.js → call-pii-button-L9JxdBhp.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-CRhBJMZR.js → collapsible-BvFaX-Zt.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-D9twj8Xy.js → decisions-page-DgB76m-X.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-CsHWlVPU.js → duration-tone-l1DSJ2Vz.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-ZaTJAhR9.js → flows-page-Bb9t1lu6.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-DQ-EMAfz.js → highlighted-json-DMvsi3Ti.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-Cn_Fl79s.js → http-method-BwhJBTcQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-CmRFeWdT.js → index-RhV2jT_7.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-Cn6R_dXz.js → observability-page-BRfUILBq.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-Bx6EZhSe.js → replica-lag-BsceAzIG.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DmdiUrfh.js → request-meta-C6lTyCXp.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-8P6vGXnn.js → store-page-YmE806bJ.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-BfGvpFSv.js → trace-detail-sheet-Di4irS49.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-CWdUHbc0.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-CZOFZzay.js → units-page-07agasRA.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-ki_ishmX.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 +84 -1
- package/src/drivers/journal-postgres.ts +282 -32
- package/src/drivers/postgres.test.ts +60 -0
- package/src/drivers/postgres.ts +79 -5
- package/src/drivers/signal-compete.test.ts +6 -2
- package/src/drivers/signal-postgres.test.ts +170 -0
- package/src/drivers/signal-postgres.ts +449 -196
- package/src/drivers/signal-redis.test.ts +224 -0
- package/src/drivers/signal-redis.ts +835 -150
- package/src/elements/ai/approval.test.ts +15 -3
- package/src/elements/ai/approval.ts +15 -9
- package/src/elements/channel/runtime.ts +31 -4
- package/src/elements/channel/sql-ledger.test.ts +404 -0
- package/src/elements/channel/sql-ledger.ts +177 -33
- package/src/elements/clock/chaos-child.ts +12 -2
- package/src/elements/clock/durable.ts +11 -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 +59 -0
- package/src/elements/store/cache.ts +157 -12
- 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.ts +183 -3
- package/src/kernel/app.ts +176 -28
- package/src/kernel/auto-registry.test.ts +5 -1
- package/src/kernel/boot-bind/signal.ts +13 -2
- package/src/kernel/boot-bind/store.ts +16 -1
- package/src/kernel/boot.test.ts +2 -2
- package/src/kernel/boot.ts +41 -11
- 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/fx-sql-handle.ts +7 -1
- package/src/kernel/horizontal-child.ts +54 -1
- package/src/kernel/horizontal.integration.test.ts +131 -7
- package/src/kernel/idempotency.test.ts +5 -6
- package/src/kernel/journal-boot.test.ts +83 -6
- package/src/kernel/journal.test.ts +44 -0
- package/src/kernel/journal.ts +171 -26
- package/src/mcp/authorization.ts +5 -11
- package/src/mcp/confirmation.ts +203 -38
- package/src/mcp/docs-server.ts +7 -2
- package/src/mcp/index.ts +4 -0
- package/src/mcp/mcp.test.ts +321 -33
- package/src/mcp/server.ts +8 -3
- package/src/mcp/session.ts +7 -0
- package/src/mcp/tools.ts +68 -19
- package/src/runtime/bun.ts +5 -0
- package/src/runtime/types.ts +5 -0
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { DomainDdlMode } from "../../config/index.ts";
|
|
9
|
+
import type { SignalEmitOptions, SignalTransaction } from "../../drivers/signal-types.ts";
|
|
9
10
|
import type { ClassificationMap, SqlConnection, SqlRow } from "../../drivers/types.ts";
|
|
10
11
|
import {
|
|
11
12
|
buildRlsIdentityPreludeSql,
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
import { throwOke } from "../../kernel/errors.ts";
|
|
17
18
|
import { isFlowFailure } from "../../kernel/hooks.ts";
|
|
18
19
|
import { lazyRequire } from "../../kernel/lazy-require.ts";
|
|
20
|
+
import { currentSignalTransaction, runInSignalTransaction } from "../../kernel/signal-tx.ts";
|
|
19
21
|
import { isRetryableSqlError, sqlErrorToFailure } from "./sql-errors.ts";
|
|
20
22
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
21
23
|
import { maskRows, tableFromSql } from "./classify.ts";
|
|
@@ -137,8 +139,28 @@ function notifySqlCdc(event: {
|
|
|
137
139
|
*/
|
|
138
140
|
const cdcMutationStorage = new AsyncLocalStorage<{ readonly mutationId: string }>();
|
|
139
141
|
|
|
142
|
+
/**
|
|
143
|
+
* One frame of {@link SqlStoreHandle.transaction}.
|
|
144
|
+
* Depth 0 is the driver `BEGIN`. Deeper frames are savepoints on that pin.
|
|
145
|
+
*/
|
|
146
|
+
interface SqlTxFrame {
|
|
147
|
+
readonly connection: SqlConnection;
|
|
148
|
+
/** 0 for the driver transaction; 1+ for `SAVEPOINT`. */
|
|
149
|
+
readonly depth: number;
|
|
150
|
+
/**
|
|
151
|
+
* Promise chain for direct child transactions on this pin.
|
|
152
|
+
* Siblings append here so their statements cannot interleave. A child
|
|
153
|
+
* waits on this chain, not on a connection-wide tail, so a nested call
|
|
154
|
+
* does not deadlock behind its own parent.
|
|
155
|
+
*/
|
|
156
|
+
childTail: Promise<void>;
|
|
157
|
+
}
|
|
158
|
+
|
|
140
159
|
/** Pinned connection for {@link SqlStoreHandle.transaction}. */
|
|
141
|
-
const sqlTxStorage = new AsyncLocalStorage<
|
|
160
|
+
const sqlTxStorage = new AsyncLocalStorage<SqlTxFrame>();
|
|
161
|
+
|
|
162
|
+
/** Monotonic savepoint counter, keyed by the pinned connection. */
|
|
163
|
+
const savepointSeq = new WeakMap<SqlConnection, number>();
|
|
142
164
|
|
|
143
165
|
/**
|
|
144
166
|
* Read the ambient mutation id, or `undefined` outside a stamped request.
|
|
@@ -214,6 +236,110 @@ async function withRlsStampLock<T>(connection: SqlConnection, fn: () => Promise<
|
|
|
214
236
|
}
|
|
215
237
|
}
|
|
216
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Next `oke_sp_<depth>_<n>` name. `n` is per pinned connection so siblings
|
|
241
|
+
* and nested savepoints never reuse a name, even after `ROLLBACK TO`.
|
|
242
|
+
*
|
|
243
|
+
* @param connection - Pinned connection
|
|
244
|
+
* @param depth - Savepoint depth (1 for the first nested call)
|
|
245
|
+
*/
|
|
246
|
+
function nextSavepointId(connection: SqlConnection, depth: number): string {
|
|
247
|
+
const n = (savepointSeq.get(connection) ?? 0) + 1;
|
|
248
|
+
savepointSeq.set(connection, n);
|
|
249
|
+
return `oke_sp_${String(depth)}_${String(n)}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Run `fn` after earlier direct children of `frame` finish.
|
|
254
|
+
*
|
|
255
|
+
* @param frame - Parent transaction frame
|
|
256
|
+
* @param fn - Child body, including savepoint control
|
|
257
|
+
*/
|
|
258
|
+
async function withSqlChildLock<T>(frame: SqlTxFrame, fn: () => Promise<T>): Promise<T> {
|
|
259
|
+
const prev = frame.childTail;
|
|
260
|
+
let release!: () => void;
|
|
261
|
+
const gate = new Promise<void>((resolve) => {
|
|
262
|
+
release = resolve;
|
|
263
|
+
});
|
|
264
|
+
frame.childTail = prev.then(
|
|
265
|
+
() => gate,
|
|
266
|
+
() => gate,
|
|
267
|
+
);
|
|
268
|
+
await prev.catch(() => undefined);
|
|
269
|
+
try {
|
|
270
|
+
return await fn();
|
|
271
|
+
} finally {
|
|
272
|
+
release();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** One staged signal op inside a nested transaction. */
|
|
277
|
+
type StagedSignalOp =
|
|
278
|
+
| { readonly kind: "write"; readonly key: string; readonly value: unknown }
|
|
279
|
+
| {
|
|
280
|
+
readonly kind: "emit";
|
|
281
|
+
readonly signal: string;
|
|
282
|
+
readonly payload: unknown;
|
|
283
|
+
readonly options?: SignalEmitOptions;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Child staging scope. Emits buffer here and merge into `parent` on success.
|
|
288
|
+
* Discard drops them. Neither path publishes — the outermost commit does.
|
|
289
|
+
*
|
|
290
|
+
* @param parent - Ambient signal transaction
|
|
291
|
+
*/
|
|
292
|
+
function openChildSignalScope(parent: SignalTransaction): {
|
|
293
|
+
readonly transaction: SignalTransaction;
|
|
294
|
+
readonly merge: () => Promise<void>;
|
|
295
|
+
readonly discard: () => void;
|
|
296
|
+
} {
|
|
297
|
+
const staged: StagedSignalOp[] = [];
|
|
298
|
+
let open = true;
|
|
299
|
+
|
|
300
|
+
function assertOpen(): void {
|
|
301
|
+
if (!open) throw new Error("transaction finished");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const transaction: SignalTransaction = {
|
|
305
|
+
async write(key, value) {
|
|
306
|
+
assertOpen();
|
|
307
|
+
staged.push({ kind: "write", key, value });
|
|
308
|
+
},
|
|
309
|
+
async emit(signal, payload, options) {
|
|
310
|
+
assertOpen();
|
|
311
|
+
staged.push(
|
|
312
|
+
options === undefined
|
|
313
|
+
? { kind: "emit", signal, payload }
|
|
314
|
+
: { kind: "emit", signal, payload, options },
|
|
315
|
+
);
|
|
316
|
+
},
|
|
317
|
+
async commit() {
|
|
318
|
+
throw new Error("nested signal scope publishes on the outermost commit");
|
|
319
|
+
},
|
|
320
|
+
async rollback() {
|
|
321
|
+
open = false;
|
|
322
|
+
staged.length = 0;
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
transaction,
|
|
328
|
+
async merge() {
|
|
329
|
+
if (!open) return;
|
|
330
|
+
open = false;
|
|
331
|
+
for (const op of staged) {
|
|
332
|
+
if (op.kind === "write") await parent.write(op.key, op.value);
|
|
333
|
+
else await parent.emit(op.signal, op.payload, op.options);
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
discard() {
|
|
337
|
+
open = false;
|
|
338
|
+
staged.length = 0;
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
217
343
|
/** Options for a SQL session. */
|
|
218
344
|
export interface SqlSessionOptions {
|
|
219
345
|
/** Open connection (already role-routed). */
|
|
@@ -443,6 +569,9 @@ export interface SqlStoreHandle {
|
|
|
443
569
|
/**
|
|
444
570
|
* Run `fn` on one pinned connection. `fx.emit` inside `fn` stages on the
|
|
445
571
|
* signal outbox and publishes only after this SQL transaction commits.
|
|
572
|
+
* A call that already has a pinned connection opens `SAVEPOINT` on that
|
|
573
|
+
* connection. Nested emits stage on a child scope and merge into the
|
|
574
|
+
* parent on success; only the outermost commit publishes.
|
|
446
575
|
*
|
|
447
576
|
* @param fn - Transaction body. `tx` is this handle, bound to the pin.
|
|
448
577
|
*/
|
|
@@ -606,7 +735,7 @@ export function createSqlStoreHandle(
|
|
|
606
735
|
}
|
|
607
736
|
|
|
608
737
|
function activeConnection(): SqlConnection {
|
|
609
|
-
return sqlTxStorage.getStore() ?? connection;
|
|
738
|
+
return sqlTxStorage.getStore()?.connection ?? connection;
|
|
610
739
|
}
|
|
611
740
|
|
|
612
741
|
function query(sql: string, params: readonly unknown[] = []): Promise<SqlRow[]> {
|
|
@@ -975,6 +1104,53 @@ export function createSqlStoreHandle(
|
|
|
975
1104
|
};
|
|
976
1105
|
}
|
|
977
1106
|
|
|
1107
|
+
/**
|
|
1108
|
+
* Nested `transaction()` on the pinned connection: `SAVEPOINT`, then
|
|
1109
|
+
* `RELEASE` or `ROLLBACK TO`. Sibling calls share `parent.childTail`.
|
|
1110
|
+
*
|
|
1111
|
+
* @param parent - Frame already on the async store
|
|
1112
|
+
* @param fn - Nested body
|
|
1113
|
+
*/
|
|
1114
|
+
async function runNestedTransaction<T>(
|
|
1115
|
+
parent: SqlTxFrame,
|
|
1116
|
+
fn: (tx: SqlStoreHandle) => Promise<T>,
|
|
1117
|
+
): Promise<T> {
|
|
1118
|
+
return withSqlChildLock(parent, async () => {
|
|
1119
|
+
const depth = parent.depth + 1;
|
|
1120
|
+
const name = quoteIdent(nextSavepointId(parent.connection, depth));
|
|
1121
|
+
const conn = parent.connection;
|
|
1122
|
+
await conn.exec(`SAVEPOINT ${name}`);
|
|
1123
|
+
const child: SqlTxFrame = {
|
|
1124
|
+
connection: conn,
|
|
1125
|
+
depth,
|
|
1126
|
+
childTail: Promise.resolve(),
|
|
1127
|
+
};
|
|
1128
|
+
const parentSignal = currentSignalTransaction();
|
|
1129
|
+
const staging = parentSignal ? openChildSignalScope(parentSignal) : undefined;
|
|
1130
|
+
let released = false;
|
|
1131
|
+
try {
|
|
1132
|
+
const result = await sqlTxStorage.run(child, () => {
|
|
1133
|
+
const body = () => fn(handle as SqlStoreHandle);
|
|
1134
|
+
return staging ? runInSignalTransaction(staging.transaction, body) : body();
|
|
1135
|
+
});
|
|
1136
|
+
await conn.exec(`RELEASE SAVEPOINT ${name}`);
|
|
1137
|
+
released = true;
|
|
1138
|
+
if (staging) await staging.merge();
|
|
1139
|
+
return result;
|
|
1140
|
+
} catch (err) {
|
|
1141
|
+
if (!released) {
|
|
1142
|
+
try {
|
|
1143
|
+
await conn.exec(`ROLLBACK TO SAVEPOINT ${name}`);
|
|
1144
|
+
} catch {
|
|
1145
|
+
// The pin may already be aborted; the original error still wins.
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
staging?.discard();
|
|
1149
|
+
throw err;
|
|
1150
|
+
}
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
|
|
978
1154
|
const handle = {
|
|
979
1155
|
ref,
|
|
980
1156
|
routedRole: options.routedRole,
|
|
@@ -1398,11 +1574,15 @@ export function createSqlStoreHandle(
|
|
|
1398
1574
|
},
|
|
1399
1575
|
|
|
1400
1576
|
async transaction<T>(fn: (tx: SqlStoreHandle) => Promise<T>): Promise<T> {
|
|
1577
|
+
const current = sqlTxStorage.getStore();
|
|
1578
|
+
if (current) return runNestedTransaction(current, fn);
|
|
1401
1579
|
if (!connection.transaction) {
|
|
1402
1580
|
throw new Error("fx.store().transaction needs SqlConnection.transaction");
|
|
1403
1581
|
}
|
|
1404
1582
|
return connection.transaction(async (txConn) =>
|
|
1405
|
-
sqlTxStorage.run(txConn, ()
|
|
1583
|
+
sqlTxStorage.run({ connection: txConn, depth: 0, childTail: Promise.resolve() }, () =>
|
|
1584
|
+
fn(handle as SqlStoreHandle),
|
|
1585
|
+
),
|
|
1406
1586
|
);
|
|
1407
1587
|
},
|
|
1408
1588
|
|
package/src/kernel/app.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { bindAgentApprovalFlows } from "../elements/ai/approval-http.ts";
|
|
10
10
|
import { compileRoute } from "../compiler/dynamic.ts";
|
|
11
|
+
import { HttpBodyRejected, parseBody, type ParseBodyOptions } from "../compiler/http-parse.ts";
|
|
11
12
|
import type { CompiledRoute } from "../compiler/aot.ts";
|
|
12
13
|
import { encodeExecuteResult, encodeFailure } from "../compiler/response.ts";
|
|
13
14
|
import { validate } from "../validation/standard-schema.ts";
|
|
@@ -87,9 +88,12 @@ import {
|
|
|
87
88
|
createJournal,
|
|
88
89
|
createJournalSlot,
|
|
89
90
|
createMemoryJournalStore,
|
|
91
|
+
clearJournalHeartbeats,
|
|
90
92
|
hasJournalLease,
|
|
91
93
|
isJournalLeaseBusy,
|
|
92
94
|
isJournalSuspend,
|
|
95
|
+
isLostJournalLease,
|
|
96
|
+
JournalLeaseBusy,
|
|
93
97
|
JOURNAL_DEFAULT_LEASE_MS,
|
|
94
98
|
type JournalSession,
|
|
95
99
|
type JournalStore,
|
|
@@ -376,6 +380,10 @@ export interface OkeOptions {
|
|
|
376
380
|
*/
|
|
377
381
|
readonly cache?: {
|
|
378
382
|
readonly auto?: boolean;
|
|
383
|
+
/** Auto-cache entry cap. Default 10_000. */
|
|
384
|
+
readonly maxEntries?: number;
|
|
385
|
+
/** Auto-cache TTL when a flow sets none. Default 60_000. */
|
|
386
|
+
readonly defaultTtlMs?: number;
|
|
379
387
|
};
|
|
380
388
|
/** Channel runtime options. */
|
|
381
389
|
readonly channel?: BootOptions["channel"];
|
|
@@ -398,6 +406,15 @@ export interface OkeOptions {
|
|
|
398
406
|
readonly schedulerIntervalMs?: number;
|
|
399
407
|
/** Durable-run lease duration ms (default 30_000). Forwarded to boot. */
|
|
400
408
|
readonly journalLeaseMs?: number;
|
|
409
|
+
/** Request body cap in bytes. Default 1 MiB. A route `maxBodySize` overrides it. */
|
|
410
|
+
readonly maxRequestBodySize?: number;
|
|
411
|
+
/**
|
|
412
|
+
* Opt-in durable code version. Unset stamps nothing and checks nothing.
|
|
413
|
+
* When set, new runs are stamped `app:<version>`. A stored stamp without
|
|
414
|
+
* the `app:` prefix (0.23.1 and earlier) is treated as compatible and
|
|
415
|
+
* restamped. An `app:` stamp that differs fails the run with OKE1076.
|
|
416
|
+
*/
|
|
417
|
+
readonly codeVersion?: string;
|
|
401
418
|
}
|
|
402
419
|
|
|
403
420
|
/** Readiness probe state — see `GET /_/ready`. */
|
|
@@ -803,6 +820,7 @@ function registerHttpRoute(
|
|
|
803
820
|
smart: { add(method: string, path: string, value: Binding): void },
|
|
804
821
|
compiled: WeakMap<Binding, CompiledRoute>,
|
|
805
822
|
aot: boolean,
|
|
823
|
+
maxRequestBodySize?: number,
|
|
806
824
|
): void {
|
|
807
825
|
const trigger = binding.trigger;
|
|
808
826
|
if (trigger.kind !== "http") return;
|
|
@@ -834,7 +852,7 @@ function registerHttpRoute(
|
|
|
834
852
|
seenLiveExposures.set(exposure, binding.flow.name);
|
|
835
853
|
}
|
|
836
854
|
smart.add(trigger.method, trigger.path, binding);
|
|
837
|
-
compiled.set(binding, compileHttpBinding(binding, aot));
|
|
855
|
+
compiled.set(binding, compileHttpBinding(binding, aot, maxRequestBodySize));
|
|
838
856
|
}
|
|
839
857
|
|
|
840
858
|
/**
|
|
@@ -1125,7 +1143,15 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1125
1143
|
for (const b of adopted) {
|
|
1126
1144
|
if (b.trigger.kind === "http") {
|
|
1127
1145
|
assertHttpBindingReady(b);
|
|
1128
|
-
registerHttpRoute(
|
|
1146
|
+
registerHttpRoute(
|
|
1147
|
+
b,
|
|
1148
|
+
seenHttpRoutes,
|
|
1149
|
+
seenLiveExposures,
|
|
1150
|
+
smart,
|
|
1151
|
+
compiled,
|
|
1152
|
+
aot,
|
|
1153
|
+
options.maxRequestBodySize,
|
|
1154
|
+
);
|
|
1129
1155
|
}
|
|
1130
1156
|
if (b.trigger.kind === "mcp") {
|
|
1131
1157
|
registerMcpTool(b, seenMcpTools, mcpToolsByName);
|
|
@@ -1148,7 +1174,15 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1148
1174
|
registerFlow(b.flow);
|
|
1149
1175
|
if (b.trigger.kind === "http") {
|
|
1150
1176
|
assertHttpBindingReady(b);
|
|
1151
|
-
registerHttpRoute(
|
|
1177
|
+
registerHttpRoute(
|
|
1178
|
+
b,
|
|
1179
|
+
seenHttpRoutes,
|
|
1180
|
+
seenLiveExposures,
|
|
1181
|
+
smart,
|
|
1182
|
+
compiled,
|
|
1183
|
+
aot,
|
|
1184
|
+
options.maxRequestBodySize,
|
|
1185
|
+
);
|
|
1152
1186
|
}
|
|
1153
1187
|
if (b.trigger.kind === "mcp") {
|
|
1154
1188
|
registerMcpTool(b, seenMcpTools, mcpToolsByName);
|
|
@@ -1400,6 +1434,7 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1400
1434
|
journalStore: store,
|
|
1401
1435
|
runId,
|
|
1402
1436
|
...(hasJournalLease(store) ? { lease: { instanceId, leaseMs } } : {}),
|
|
1437
|
+
...(options.codeVersion ? { codeVersion: options.codeVersion } : {}),
|
|
1403
1438
|
now,
|
|
1404
1439
|
fx: {
|
|
1405
1440
|
...durableResumeFx(),
|
|
@@ -1422,7 +1457,13 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1422
1457
|
});
|
|
1423
1458
|
} catch (err) {
|
|
1424
1459
|
if (isJournalLeaseBusy(err)) return;
|
|
1425
|
-
|
|
1460
|
+
console.warn(
|
|
1461
|
+
JSON.stringify({
|
|
1462
|
+
event: "journal.resume.failed",
|
|
1463
|
+
runId,
|
|
1464
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1465
|
+
}),
|
|
1466
|
+
);
|
|
1426
1467
|
}
|
|
1427
1468
|
} finally {
|
|
1428
1469
|
inflightRuns.delete(runId);
|
|
@@ -1598,6 +1639,19 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1598
1639
|
startScheduler: overrides?.startScheduler ?? options.startScheduler,
|
|
1599
1640
|
schedulerIntervalMs: overrides?.schedulerIntervalMs ?? options.schedulerIntervalMs,
|
|
1600
1641
|
journalLeaseMs: overrides?.journalLeaseMs ?? options.journalLeaseMs,
|
|
1642
|
+
codeVersion: overrides?.codeVersion ?? options.codeVersion,
|
|
1643
|
+
...(options.cache
|
|
1644
|
+
? {
|
|
1645
|
+
cache: {
|
|
1646
|
+
...(options.cache.maxEntries !== undefined
|
|
1647
|
+
? { maxEntries: options.cache.maxEntries }
|
|
1648
|
+
: {}),
|
|
1649
|
+
...(options.cache.defaultTtlMs !== undefined
|
|
1650
|
+
? { defaultTtlMs: options.cache.defaultTtlMs }
|
|
1651
|
+
: {}),
|
|
1652
|
+
},
|
|
1653
|
+
}
|
|
1654
|
+
: {}),
|
|
1601
1655
|
bindings: adopted,
|
|
1602
1656
|
flows: [...flowsByName.values()],
|
|
1603
1657
|
onCronFire: overrides?.onCronFire ?? handleCronFire,
|
|
@@ -1981,6 +2035,7 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1981
2035
|
// Hold the run lease for the request's lifetime — a crash mid-run
|
|
1982
2036
|
// leaves an expired lease another instance can reclaim and resume.
|
|
1983
2037
|
...(hasJournalLease(store) ? { lease: { instanceId, leaseMs } } : {}),
|
|
2038
|
+
...(options.codeVersion ? { codeVersion: options.codeVersion } : {}),
|
|
1984
2039
|
});
|
|
1985
2040
|
};
|
|
1986
2041
|
|
|
@@ -2103,6 +2158,8 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
2103
2158
|
locale: fx.locale,
|
|
2104
2159
|
scopes: fx.auth.scopes,
|
|
2105
2160
|
roles: principals?.cacheRoles ?? [],
|
|
2161
|
+
verified: fx.auth.verified === true,
|
|
2162
|
+
operatorId: fx.operator.id,
|
|
2106
2163
|
};
|
|
2107
2164
|
const cacheOk =
|
|
2108
2165
|
cache !== undefined &&
|
|
@@ -2148,17 +2205,52 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
2148
2205
|
journalSession?.rewind();
|
|
2149
2206
|
return flowDef.do(input as never, fx);
|
|
2150
2207
|
};
|
|
2151
|
-
const
|
|
2208
|
+
const flightKey =
|
|
2209
|
+
cacheOk && cache && dims && cacheEffects
|
|
2210
|
+
? cache.tier1KeysForReads(cacheEffects, dims)[0]
|
|
2211
|
+
: undefined;
|
|
2212
|
+
const flightResources = (cacheEffects?.reads ?? []).filter(
|
|
2213
|
+
cache?.isStoreResourceRef ?? (() => false),
|
|
2214
|
+
);
|
|
2215
|
+
const generation =
|
|
2216
|
+
storeRt && flightKey
|
|
2217
|
+
? Object.fromEntries(
|
|
2218
|
+
flightResources.map((resource) => [
|
|
2219
|
+
resource,
|
|
2220
|
+
storeRt.cache.generationOf(resource),
|
|
2221
|
+
]),
|
|
2222
|
+
)
|
|
2223
|
+
: undefined;
|
|
2224
|
+
let raw: unknown;
|
|
2225
|
+
let cacheable = true;
|
|
2226
|
+
try {
|
|
2227
|
+
const load = () => (flowDef.retry ? fxRetry(run, flowDef.retry) : run());
|
|
2228
|
+
if (flightKey && storeRt) {
|
|
2229
|
+
const flight = await storeRt.cache.coalesce(flightKey, flightResources, load);
|
|
2230
|
+
raw = flight.value;
|
|
2231
|
+
cacheable =
|
|
2232
|
+
flight.cacheable &&
|
|
2233
|
+
(generation === undefined || storeRt.cache.sameGeneration(generation));
|
|
2234
|
+
} else {
|
|
2235
|
+
raw = await load();
|
|
2236
|
+
}
|
|
2237
|
+
} finally {
|
|
2238
|
+
if (cache && storeRt && cacheEffects) {
|
|
2239
|
+
const ledgerFx = cache.effectsFromLedger(ledger.entries);
|
|
2240
|
+
const writeEffects: Effects = {
|
|
2241
|
+
writes: [
|
|
2242
|
+
...new Set([...(cacheEffects.writes ?? []), ...(ledgerFx.writes ?? [])]),
|
|
2243
|
+
].filter(cache.isStoreResourceRef),
|
|
2244
|
+
};
|
|
2245
|
+
if ((writeEffects.writes?.length ?? 0) > 0) {
|
|
2246
|
+
storeRt.onWriteEffects(writeEffects);
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2152
2250
|
const output = isFlowFailure(raw) ? raw : await projectFlowOut(flowDef.out, raw);
|
|
2153
2251
|
if (!isFlowFailure(output) && cache && storeRt && cacheEffects) {
|
|
2154
2252
|
const ledgerFx = cache.effectsFromLedger(ledger.entries);
|
|
2155
2253
|
const observed = cache.mergeEffects(cacheEffects, ledgerFx);
|
|
2156
|
-
const writeEffects: Effects = {
|
|
2157
|
-
writes: (observed.writes ?? []).filter(cache.isStoreResourceRef),
|
|
2158
|
-
};
|
|
2159
|
-
if ((writeEffects.writes?.length ?? 0) > 0) {
|
|
2160
|
-
storeRt.onWriteEffects(writeEffects);
|
|
2161
|
-
}
|
|
2162
2254
|
const storeReads = (observed.reads ?? []).filter(cache.isStoreResourceRef);
|
|
2163
2255
|
if (cache.autoCachePure(observed) && storeReads.length > 0) {
|
|
2164
2256
|
learnedTier1Reads.set(flowDef.name, storeReads);
|
|
@@ -2173,7 +2265,12 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
2173
2265
|
durable: flowDef.durable,
|
|
2174
2266
|
effects: observed,
|
|
2175
2267
|
});
|
|
2176
|
-
if (
|
|
2268
|
+
if (
|
|
2269
|
+
cacheable &&
|
|
2270
|
+
storeAfter &&
|
|
2271
|
+
output !== undefined &&
|
|
2272
|
+
!loadFx().isJsonStreamResult(output)
|
|
2273
|
+
) {
|
|
2177
2274
|
const ttlMs =
|
|
2178
2275
|
typeof flowDef.cache === "string" ? cache.parseTtlMs(flowDef.cache) : undefined;
|
|
2179
2276
|
const putEffects: Effects = { reads: storeReads };
|
|
@@ -2231,6 +2328,9 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
2231
2328
|
const journal = openJournal();
|
|
2232
2329
|
const resumeId = claimed.kind === "reclaimed" ? claimed.row.runId : undefined;
|
|
2233
2330
|
try {
|
|
2331
|
+
if (resumeId && inflightRuns.has(resumeId)) {
|
|
2332
|
+
throw new JournalLeaseBusy(resumeId);
|
|
2333
|
+
}
|
|
2234
2334
|
const session = resumeId
|
|
2235
2335
|
? await journal.resume(resumeId)
|
|
2236
2336
|
: await journal.start(flowDef.name, validated);
|
|
@@ -2335,6 +2435,9 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
2335
2435
|
};
|
|
2336
2436
|
return undefined;
|
|
2337
2437
|
}
|
|
2438
|
+
if (flowDef.durable && journalSession && isLostJournalLease(err)) {
|
|
2439
|
+
return fail("JournalLeaseBusy", {});
|
|
2440
|
+
}
|
|
2338
2441
|
throw err;
|
|
2339
2442
|
}
|
|
2340
2443
|
},
|
|
@@ -2383,13 +2486,22 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
2383
2486
|
});
|
|
2384
2487
|
} else if (!sleeping && isTerminalFailure(result)) {
|
|
2385
2488
|
const terminalErr = result.failure ?? result.ctx.error;
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2489
|
+
const lost =
|
|
2490
|
+
isLostJournalLease(terminalErr) ||
|
|
2491
|
+
isJournalLeaseBusy(terminalErr) ||
|
|
2492
|
+
(terminalErr != null &&
|
|
2493
|
+
typeof terminalErr === "object" &&
|
|
2494
|
+
"error" in terminalErr &&
|
|
2495
|
+
(terminalErr as { error?: { code?: string } }).error?.code === "JournalLeaseBusy");
|
|
2496
|
+
if (!lost) {
|
|
2497
|
+
await runCompensationPhase({
|
|
2498
|
+
flow: flowDef,
|
|
2499
|
+
input: ctx.input,
|
|
2500
|
+
session: journalSession,
|
|
2501
|
+
fx,
|
|
2502
|
+
error: terminalErr,
|
|
2503
|
+
});
|
|
2504
|
+
}
|
|
2393
2505
|
} else if (parked && sleeping) {
|
|
2394
2506
|
await journalSession.commit("sleeping", { wakeAt: sleeping.wakeAt });
|
|
2395
2507
|
} else if (!sleeping) {
|
|
@@ -2503,6 +2615,7 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
2503
2615
|
},
|
|
2504
2616
|
async stop() {
|
|
2505
2617
|
if (!bootResult) return;
|
|
2618
|
+
clearJournalHeartbeats();
|
|
2506
2619
|
bootResult.stopScheduler();
|
|
2507
2620
|
// Realtime bridge down before connections close.
|
|
2508
2621
|
{
|
|
@@ -2530,7 +2643,11 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
2530
2643
|
/** Shared identity store when `gate.auth` is enabled. */
|
|
2531
2644
|
identities: gateConfig.auth?.identities,
|
|
2532
2645
|
async resumeDurable(now) {
|
|
2533
|
-
|
|
2646
|
+
// Due selection uses one sample. The run itself must not: a frozen
|
|
2647
|
+
// closure wrote leases that were already expired by the time persist ran.
|
|
2648
|
+
const sample = (): number => bootResult?.clock?.now() ?? options.fx?.now?.() ?? Date.now();
|
|
2649
|
+
const t = now ?? sample();
|
|
2650
|
+
const live = typeof now === "number" ? (): number => now : sample;
|
|
2534
2651
|
const { store, instanceId, leaseMs } = activeJournal();
|
|
2535
2652
|
if (hasJournalLease(store)) {
|
|
2536
2653
|
// Shared store is the wake schedule — claim due sleeps across all
|
|
@@ -2539,14 +2656,14 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
2539
2656
|
const due = await store.claimDueSleep(instanceId, t, leaseMs);
|
|
2540
2657
|
if (!due) break;
|
|
2541
2658
|
sleepingRuns.delete(due.id);
|
|
2542
|
-
await resumeDurableRun(due.id, due.flow, due.input,
|
|
2659
|
+
await resumeDurableRun(due.id, due.flow, due.input, live);
|
|
2543
2660
|
}
|
|
2544
2661
|
// Crash sweep: the boot orphan scan is once-only, so each tick also
|
|
2545
2662
|
// reclaims `running` runs whose holder's lease has expired (same
|
|
2546
2663
|
// takeover physics as Clock: lease expiry + next tick).
|
|
2547
2664
|
for (const orphan of await store.listOrphans(t)) {
|
|
2548
2665
|
if (orphan.status !== "running") continue;
|
|
2549
|
-
await resumeDurableRun(orphan.id, orphan.flow, orphan.input,
|
|
2666
|
+
await resumeDurableRun(orphan.id, orphan.flow, orphan.input, live);
|
|
2550
2667
|
}
|
|
2551
2668
|
return;
|
|
2552
2669
|
}
|
|
@@ -2559,7 +2676,7 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
2559
2676
|
input: sleeper.input,
|
|
2560
2677
|
journalStore: store,
|
|
2561
2678
|
runId,
|
|
2562
|
-
now:
|
|
2679
|
+
now: live,
|
|
2563
2680
|
fx: durableResumeFx(),
|
|
2564
2681
|
});
|
|
2565
2682
|
if (result.status === "sleeping") {
|
|
@@ -2700,9 +2817,15 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
2700
2817
|
flowLabel = target.name;
|
|
2701
2818
|
let internalInput: unknown;
|
|
2702
2819
|
try {
|
|
2703
|
-
internalInput = await request
|
|
2704
|
-
|
|
2705
|
-
|
|
2820
|
+
internalInput = await parseBody(request, {
|
|
2821
|
+
maxBytes: options.maxRequestBodySize,
|
|
2822
|
+
jsonContentType: "required",
|
|
2823
|
+
});
|
|
2824
|
+
} catch (err) {
|
|
2825
|
+
if (err instanceof HttpBodyRejected) {
|
|
2826
|
+
return respond(encodeFailure(bodyFailure(err, request)));
|
|
2827
|
+
}
|
|
2828
|
+
throw err;
|
|
2706
2829
|
}
|
|
2707
2830
|
const internalResult = await execute(
|
|
2708
2831
|
target,
|
|
@@ -2769,7 +2892,7 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
2769
2892
|
|
|
2770
2893
|
let route = compiled.get(binding);
|
|
2771
2894
|
if (!route && binding.trigger.kind === "http") {
|
|
2772
|
-
route = compileHttpBinding(binding, aot);
|
|
2895
|
+
route = compileHttpBinding(binding, aot, options.maxRequestBodySize);
|
|
2773
2896
|
compiled.set(binding, route);
|
|
2774
2897
|
}
|
|
2775
2898
|
|
|
@@ -2875,7 +2998,31 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
2875
2998
|
return app;
|
|
2876
2999
|
}
|
|
2877
3000
|
|
|
2878
|
-
function
|
|
3001
|
+
function httpBodyOptions(trigger: HttpTrigger, maxRequestBodySize?: number): ParseBodyOptions {
|
|
3002
|
+
const contract = trigger.contract as
|
|
3003
|
+
| { readonly maxBodySize?: number; readonly jsonContentType?: "required" | "any" }
|
|
3004
|
+
| undefined;
|
|
3005
|
+
return {
|
|
3006
|
+
maxBytes: contract?.maxBodySize ?? maxRequestBodySize,
|
|
3007
|
+
...(contract?.jsonContentType ? { jsonContentType: contract.jsonContentType } : {}),
|
|
3008
|
+
};
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
function bodyFailure(err: HttpBodyRejected, request: Request): ReturnType<typeof fail> {
|
|
3012
|
+
if (err.code === "InvalidQuery") {
|
|
3013
|
+
return fail("InvalidQuery", { reason: err.reason ?? "malformed_body" });
|
|
3014
|
+
}
|
|
3015
|
+
if (err.code === "UnsupportedMediaType") {
|
|
3016
|
+
return fail("UnsupportedMediaType", { contentType: request.headers.get("content-type") ?? "" });
|
|
3017
|
+
}
|
|
3018
|
+
return fail("PayloadTooLarge", {});
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
function compileHttpBinding(
|
|
3022
|
+
binding: Binding,
|
|
3023
|
+
aot: boolean,
|
|
3024
|
+
maxRequestBodySize?: number,
|
|
3025
|
+
): CompiledRoute {
|
|
2879
3026
|
const trigger = binding.trigger as HttpTrigger;
|
|
2880
3027
|
const hookFns: Array<(...args: never[]) => unknown> = [];
|
|
2881
3028
|
for (const list of Object.values(binding.flow.hooks)) {
|
|
@@ -2891,6 +3038,7 @@ function compileHttpBinding(binding: Binding, aot: boolean): CompiledRoute {
|
|
|
2891
3038
|
handler: binding.flow.do as (...args: never[]) => unknown,
|
|
2892
3039
|
hooks: hookFns,
|
|
2893
3040
|
schema: binding.flow.in,
|
|
3041
|
+
body: httpBodyOptions(trigger, maxRequestBodySize),
|
|
2894
3042
|
},
|
|
2895
3043
|
aot,
|
|
2896
3044
|
);
|
|
@@ -285,7 +285,11 @@ describe("oke() auto-registry — stores/secrets/signals/clocks/gates/channel.te
|
|
|
285
285
|
});
|
|
286
286
|
|
|
287
287
|
const res = await app.fetch(
|
|
288
|
-
new Request("http://127.0.0.1/ai-ping", {
|
|
288
|
+
new Request("http://127.0.0.1/ai-ping", {
|
|
289
|
+
method: "POST",
|
|
290
|
+
headers: { "content-type": "application/json" },
|
|
291
|
+
body: "{}",
|
|
292
|
+
}),
|
|
289
293
|
);
|
|
290
294
|
expect(res.status).toBe(200);
|
|
291
295
|
const json = (await res.json()) as { data: { summary: string } };
|
|
@@ -12,7 +12,11 @@ import {
|
|
|
12
12
|
import type { PostgresClientLike } from "../../drivers/postgres.ts";
|
|
13
13
|
import { memorySignalDriver } from "../../drivers/signal-memory.ts";
|
|
14
14
|
import type { PostgresSignalSql } from "../../drivers/signal-postgres.ts";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
POSTGRES_SIGNAL_DEFAULT_LAG_MS,
|
|
17
|
+
openPostgresSignal,
|
|
18
|
+
postgresSignalDriver,
|
|
19
|
+
} from "../../drivers/signal-postgres.ts";
|
|
16
20
|
import { createBunSignalRedisClient, redisSignalDriver } from "../../drivers/signal-redis.ts";
|
|
17
21
|
import type { SignalRedisClientLike } from "../../drivers/signal-types.ts";
|
|
18
22
|
import { createSignalRuntime, type SignalRuntime } from "../../elements/signal.ts";
|
|
@@ -123,7 +127,14 @@ export async function bindSignal(
|
|
|
123
127
|
);
|
|
124
128
|
}
|
|
125
129
|
signal = createSignalRuntime({
|
|
126
|
-
driver:
|
|
130
|
+
driver: {
|
|
131
|
+
id: postgresSignalDriver.id,
|
|
132
|
+
open: (openOptions) =>
|
|
133
|
+
openPostgresSignal({
|
|
134
|
+
...openOptions,
|
|
135
|
+
lagMs: injected ? undefined : POSTGRES_SIGNAL_DEFAULT_LAG_MS,
|
|
136
|
+
}),
|
|
137
|
+
},
|
|
127
138
|
now,
|
|
128
139
|
sql: injected ?? asSignalSql(sharedPostgresClient(url)),
|
|
129
140
|
pollMs: injected ? undefined : 1_000,
|