okengine 0.23.0 → 0.23.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/site/content/docs/ai/mcp.mdx +4 -4
- package/site/content/docs/elements/channel/index.mdx +4 -4
- package/site/content/docs/elements/channel/receipts.mdx +7 -5
- package/site/content/docs/elements/channel/sms.mdx +2 -1
- package/site/content/docs/elements/flow/index.mdx +22 -18
- package/site/content/docs/elements/signal/broadcast.mdx +4 -2
- package/site/content/docs/elements/signal/index.mdx +15 -14
- package/site/content/docs/plugins/otp.mdx +3 -3
- package/site/content/docs/reference/configuration.mdx +16 -0
- package/site/content/docs/reference/errors.mdx +31 -19
- package/site/content/docs/reference/fx.mdx +12 -4
- package/src/auth/tenants.ts +17 -0
- package/src/cli/doctor-diff.test.ts +29 -1
- package/src/cli/doctor-diff.ts +35 -0
- package/src/cli/manifest-pr-diff.ts +16 -0
- package/src/compiler/aot.test.ts +3 -2
- package/src/compiler/aot.ts +45 -1
- package/src/compiler/dynamic.ts +1 -1
- package/src/compiler/effects-fetch.test.ts +77 -0
- package/src/compiler/effects-infer.ts +87 -16
- package/src/compiler/effects-join.test.ts +48 -0
- package/src/compiler/extract.test.ts +28 -0
- package/src/compiler/extract.ts +11 -1
- package/src/compiler/fx-index.ts +58 -4
- package/src/compiler/http-parse.test.ts +101 -0
- package/src/compiler/http-parse.ts +188 -9
- package/src/compiler/interpret.ts +25 -2
- package/src/console/index.ts +1 -1
- package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-zBMhZnFm.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-CBj9f7Ju.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-D3MY49h3.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-L9JxdBhp.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-BvFaX-Zt.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-DgB76m-X.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-l1DSJ2Vz.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-Bb9t1lu6.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DMvsi3Ti.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-BwhJBTcQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-RhV2jT_7.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-BRfUILBq.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-BsceAzIG.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-C6lTyCXp.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-YmE806bJ.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-Di4irS49.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-07agasRA.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-CHBck4n_.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/seed-parked-approval.ts +5 -1
- package/src/drivers/journal-postgres.test.ts +106 -2
- package/src/drivers/journal-postgres.ts +475 -14
- package/src/drivers/postgres.test.ts +60 -0
- package/src/drivers/postgres.ts +79 -5
- package/src/drivers/signal-compete.test.ts +238 -0
- package/src/drivers/signal-postgres.test.ts +170 -0
- package/src/drivers/signal-postgres.ts +486 -213
- package/src/drivers/signal-redis.test.ts +224 -0
- package/src/drivers/signal-redis.ts +934 -93
- package/src/drivers/signal-types.ts +12 -0
- package/src/elements/ai/approval.test.ts +15 -3
- package/src/elements/ai/approval.ts +15 -9
- package/src/elements/ai/mcp-protocol.ts +5 -6
- package/src/elements/ai/runtime.ts +20 -19
- package/src/elements/channel/runtime.ts +42 -4
- package/src/elements/channel/sql-ledger.test.ts +404 -0
- package/src/elements/channel/sql-ledger.ts +395 -0
- package/src/elements/clock/chaos-child.ts +12 -2
- package/src/elements/clock/durable.ts +11 -0
- package/src/elements/signal/runtime.ts +9 -0
- package/src/elements/signal.test.ts +1 -0
- package/src/elements/store/cache-bus.test.ts +121 -0
- package/src/elements/store/cache-bus.ts +278 -0
- package/src/elements/store/cache.test.ts +179 -15
- package/src/elements/store/cache.ts +357 -51
- package/src/elements/store/prepare-row.test.ts +6 -2
- package/src/elements/store/runtime.ts +44 -2
- package/src/elements/store/sql-errors.test.ts +131 -0
- package/src/elements/store/sql-errors.ts +39 -16
- package/src/elements/store/sql-nested-tx.test.ts +344 -0
- package/src/elements/store/sql-session.test.ts +2 -0
- package/src/elements/store/sql-session.ts +275 -9
- package/src/elements/store.ts +1 -0
- package/src/kernel/app.ts +207 -43
- package/src/kernel/auto-cache.test.ts +228 -0
- package/src/kernel/auto-registry.test.ts +5 -1
- package/src/kernel/boot-bind/channel.ts +63 -2
- package/src/kernel/boot-bind/honor-config.test.ts +40 -10
- package/src/kernel/boot-bind/signal.ts +72 -25
- package/src/kernel/boot-bind/store.ts +16 -1
- package/src/kernel/boot.test.ts +2 -2
- package/src/kernel/boot.ts +54 -10
- package/src/kernel/boundary-contract.ts +7 -0
- package/src/kernel/builtin-errors.ts +2 -0
- package/src/kernel/call.test.ts +1 -0
- package/src/kernel/errors-compiler.ts +11 -0
- package/src/kernel/errors-text.ts +16 -0
- package/src/kernel/errors.ts +8 -0
- package/src/kernel/flow.ts +33 -3
- package/src/kernel/fx-call-types.test.ts +32 -0
- package/src/kernel/fx-decide.ts +11 -7
- package/src/kernel/fx-sql-handle.ts +52 -7
- package/src/kernel/fx.test.ts +1 -3
- package/src/kernel/fx.ts +27 -16
- package/src/kernel/horizontal-child.ts +54 -1
- package/src/kernel/horizontal.integration.test.ts +131 -7
- package/src/kernel/idempotency.test.ts +4 -4
- package/src/kernel/journal-boot.test.ts +83 -6
- package/src/kernel/journal.test.ts +158 -0
- package/src/kernel/journal.ts +515 -54
- package/src/kernel/pipeline-tenant.ts +5 -1
- package/src/kernel/pipeline.ts +5 -0
- package/src/kernel/signal-tx.ts +28 -0
- package/src/kernel/store-transaction.test.ts +95 -0
- package/src/mcp/authorization.ts +5 -11
- package/src/mcp/confirmation.ts +203 -38
- package/src/mcp/docs-server.ts +26 -4
- package/src/mcp/index.ts +4 -0
- package/src/mcp/mcp.test.ts +404 -33
- package/src/mcp/protocol.ts +8 -2
- package/src/mcp/server.ts +31 -5
- package/src/mcp/session.ts +7 -0
- package/src/mcp/tools.ts +68 -19
- package/src/mcp/versions.ts +60 -0
- package/src/plugins/index.ts +1 -0
- package/src/plugins/mena.ts +56 -0
- package/src/runtime/bun.ts +5 -0
- package/src/runtime/types.ts +5 -0
package/src/kernel/boot.ts
CHANGED
|
@@ -182,6 +182,13 @@ export interface BootOptions {
|
|
|
182
182
|
readonly onDurableResume?: () => void | Promise<void>;
|
|
183
183
|
/** Durable-run lease duration ms (default 30_000 — matches Signal claims). */
|
|
184
184
|
readonly journalLeaseMs?: number;
|
|
185
|
+
/** Opt-in durable code version. Unset skips the stamp and the check. */
|
|
186
|
+
readonly codeVersion?: string;
|
|
187
|
+
/** Tier-1 cache bounds. Defaults: 10_000 entries, 60s TTL. */
|
|
188
|
+
readonly cache?: {
|
|
189
|
+
readonly maxEntries?: number;
|
|
190
|
+
readonly defaultTtlMs?: number;
|
|
191
|
+
};
|
|
185
192
|
/** Injectable clock for test / frozen harnesses. */
|
|
186
193
|
readonly now?: () => number;
|
|
187
194
|
/**
|
|
@@ -191,6 +198,8 @@ export interface BootOptions {
|
|
|
191
198
|
readonly clients?: {
|
|
192
199
|
readonly kv?: import("../drivers/types.ts").KvClientLike;
|
|
193
200
|
readonly signalRedis?: import("../drivers/signal-types.ts").SignalRedisClientLike;
|
|
201
|
+
/** Injected postgres signal client (tests). Production opens Bun.SQL. */
|
|
202
|
+
readonly signalSql?: import("../drivers/signal-postgres.ts").PostgresSignalSql;
|
|
194
203
|
};
|
|
195
204
|
/**
|
|
196
205
|
* Process instance id for Clock / Journal / fleet registry.
|
|
@@ -537,8 +546,21 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
|
|
|
537
546
|
|
|
538
547
|
// 4d. Scheduler — one timer drives clock ticks, durable-run resume, heartbeat.
|
|
539
548
|
let schedulerTimer: ReturnType<typeof setInterval> | undefined;
|
|
549
|
+
const inflight = new Map<string, Promise<void>>();
|
|
550
|
+
let stopping = false;
|
|
540
551
|
const startScheduler = options.startScheduler ?? env !== "test";
|
|
541
|
-
|
|
552
|
+
const signalBus = signal?.bus ?? undefined;
|
|
553
|
+
let refreshChannel: (() => Promise<void>) | undefined;
|
|
554
|
+
const pollCache = store?.cacheInvalidation === "postgres";
|
|
555
|
+
if (
|
|
556
|
+
startScheduler &&
|
|
557
|
+
(clock !== undefined ||
|
|
558
|
+
journal !== undefined ||
|
|
559
|
+
instances !== undefined ||
|
|
560
|
+
signalBus !== undefined ||
|
|
561
|
+
needs.channel ||
|
|
562
|
+
pollCache)
|
|
563
|
+
) {
|
|
542
564
|
const period = options.schedulerIntervalMs ?? 1000;
|
|
543
565
|
const clockRt = clock;
|
|
544
566
|
const durableResume = options.onDurableResume;
|
|
@@ -549,16 +571,30 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
|
|
|
549
571
|
// Unexpected scheduler failures stay visible.
|
|
550
572
|
console.error(err);
|
|
551
573
|
};
|
|
574
|
+
const spawn = (name: string, task: () => Promise<unknown>): void => {
|
|
575
|
+
if (stopping || inflight.has(name)) return;
|
|
576
|
+
const run = Promise.resolve()
|
|
577
|
+
.then(task)
|
|
578
|
+
.then(() => undefined)
|
|
579
|
+
.catch(ignoreBenignSql)
|
|
580
|
+
.finally(() => {
|
|
581
|
+
inflight.delete(name);
|
|
582
|
+
});
|
|
583
|
+
inflight.set(name, run);
|
|
584
|
+
};
|
|
552
585
|
schedulerTimer = setInterval(() => {
|
|
553
|
-
if (
|
|
554
|
-
if (
|
|
555
|
-
if (
|
|
586
|
+
if (signalBus) spawn("signal", () => signalBus.drain());
|
|
587
|
+
if (refreshChannel) spawn("channel", () => refreshChannel!());
|
|
588
|
+
if (clockRt) spawn("clock", () => Promise.resolve(clockRt.tick()));
|
|
589
|
+
if (journal && durableResume) spawn("durable", () => Promise.resolve(durableResume()));
|
|
590
|
+
if (fleet) spawn("fleet", () => fleet.maybeHeartbeat());
|
|
591
|
+
if (pollCache && store) spawn("cache", () => store.pollCacheInvalidations());
|
|
556
592
|
const stem = ["run", "events"].join("-");
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
mod.sweepInstalledAgentEvents(),
|
|
560
|
-
)
|
|
561
|
-
|
|
593
|
+
spawn("agents", () =>
|
|
594
|
+
import(`../elements/ai/${stem}.ts`).then(
|
|
595
|
+
(mod: typeof import("../elements/ai/run-events.ts")) => mod.sweepInstalledAgentEvents(),
|
|
596
|
+
),
|
|
597
|
+
);
|
|
562
598
|
}, period);
|
|
563
599
|
schedulerTimer.unref?.();
|
|
564
600
|
}
|
|
@@ -572,7 +608,8 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
|
|
|
572
608
|
// 5. Channel
|
|
573
609
|
let channel = pre.channel;
|
|
574
610
|
if (needs.channel && !channel) {
|
|
575
|
-
channel = channelBind!.bindChannel(options, env, now, docker);
|
|
611
|
+
channel = await channelBind!.bindChannel(options, env, now, docker);
|
|
612
|
+
refreshChannel = channel.refresh?.bind(channel);
|
|
576
613
|
}
|
|
577
614
|
|
|
578
615
|
// 6. AI
|
|
@@ -639,7 +676,12 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
|
|
|
639
676
|
instanceId,
|
|
640
677
|
instances,
|
|
641
678
|
capabilities,
|
|
679
|
+
/**
|
|
680
|
+
* Stop the scheduler interval and wake timers.
|
|
681
|
+
* Does not wait for a tick that has already started — {@link BootResult.close} does.
|
|
682
|
+
*/
|
|
642
683
|
stopScheduler() {
|
|
684
|
+
stopping = true;
|
|
643
685
|
if (schedulerTimer !== undefined) {
|
|
644
686
|
clearInterval(schedulerTimer);
|
|
645
687
|
schedulerTimer = undefined;
|
|
@@ -647,11 +689,13 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
|
|
|
647
689
|
clock?.stopWakes();
|
|
648
690
|
},
|
|
649
691
|
async close() {
|
|
692
|
+
stopping = true;
|
|
650
693
|
if (schedulerTimer !== undefined) {
|
|
651
694
|
clearInterval(schedulerTimer);
|
|
652
695
|
schedulerTimer = undefined;
|
|
653
696
|
}
|
|
654
697
|
clock?.stopWakes();
|
|
698
|
+
await Promise.all([...inflight.values()]);
|
|
655
699
|
await signal?.close();
|
|
656
700
|
await vault?.close();
|
|
657
701
|
await runs?.flush();
|
|
@@ -30,6 +30,13 @@ export interface BoundaryContract<
|
|
|
30
30
|
* (`oke doctor --diff` / CI gate).
|
|
31
31
|
*/
|
|
32
32
|
readonly breaking?: boolean;
|
|
33
|
+
/** Per-route body cap in bytes. Falls back to the app `maxRequestBodySize`. */
|
|
34
|
+
readonly maxBodySize?: number;
|
|
35
|
+
/**
|
|
36
|
+
* `required` (default) rejects a JSON-shaped body that is not
|
|
37
|
+
* `application/json` or `+json`. `any` keeps the previous parse-or-string behavior.
|
|
38
|
+
*/
|
|
39
|
+
readonly jsonContentType?: "required" | "any";
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
/** Infer validated input from a {@link BoundaryContract} bag. */
|
|
@@ -15,6 +15,7 @@ export const BUILTIN_ERROR_STATUS = {
|
|
|
15
15
|
Conflict: 409,
|
|
16
16
|
ForeignKey: 409,
|
|
17
17
|
UnsupportedMediaType: 415,
|
|
18
|
+
PayloadTooLarge: 413,
|
|
18
19
|
RateLimited: 429,
|
|
19
20
|
AuthRateLimited: 429,
|
|
20
21
|
InvalidQuery: 400,
|
|
@@ -78,6 +79,7 @@ export type BuiltinErrorMap = {
|
|
|
78
79
|
readonly Conflict: BuiltinErrorBag;
|
|
79
80
|
readonly ForeignKey: BuiltinErrorBag;
|
|
80
81
|
readonly UnsupportedMediaType: BuiltinErrorBag;
|
|
82
|
+
readonly PayloadTooLarge: BuiltinErrorBag;
|
|
81
83
|
readonly RateLimited: { readonly retryAfterMs?: number };
|
|
82
84
|
readonly AuthRateLimited: BuiltinErrorBag;
|
|
83
85
|
readonly InvalidQuery: BuiltinErrorBag;
|
package/src/kernel/call.test.ts
CHANGED
|
@@ -12,6 +12,17 @@ import type { OkeErrorDefinition } from "./errors.ts";
|
|
|
12
12
|
* A Flow hides `fx` from effect inference, or an explicit `effects` block
|
|
13
13
|
* omits an effect the walk can see.
|
|
14
14
|
*/
|
|
15
|
+
/**
|
|
16
|
+
* A durable Flow calls `fx.fetch` or `fx.send` outside `fx.step`.
|
|
17
|
+
* Those effects are irreversible and must be an explicit step so replay skips them.
|
|
18
|
+
*/
|
|
19
|
+
export const DURABLE_BARE_EFFECT: OkeErrorDefinition = {
|
|
20
|
+
code: 1901,
|
|
21
|
+
domain: "compiler",
|
|
22
|
+
cause: 'Durable flow "{flow}" calls fx.{effect} outside fx.step.',
|
|
23
|
+
fix: "Wrap fx.fetch and fx.send in fx.step so replay returns the journaled value instead of repeating the call.",
|
|
24
|
+
};
|
|
25
|
+
|
|
15
26
|
export const FX_INFERENCE_OPAQUE: OkeErrorDefinition = {
|
|
16
27
|
code: 1900,
|
|
17
28
|
domain: "compiler",
|
|
@@ -84,6 +84,22 @@ export const OKE_ERROR_TEXT: Readonly<Record<string, OkeErrorText>> = {
|
|
|
84
84
|
'Live signal "{signal}" is exposed twice with the same gates ({gates}) and match ({match}).',
|
|
85
85
|
fix: "Use a different gate or path-param filter, or drop the extra route.",
|
|
86
86
|
},
|
|
87
|
+
JOURNAL_STALE_LEASE: {
|
|
88
|
+
cause: 'Journal run "{runId}" rejected a write from an expired lease holder.',
|
|
89
|
+
fix: "Resume the run so the current holder acquires a new lease token. Do not write with a token from a previous holder.",
|
|
90
|
+
},
|
|
91
|
+
JOURNAL_REPLAY_DIVERGENCE: {
|
|
92
|
+
cause: 'Journal run "{runId}" expected {expected} but the code called {actual}.',
|
|
93
|
+
fix: "Keep step names, sleep labels, and effect order stable for in-flight runs. A code change that reorders journal calls cannot resume the old run.",
|
|
94
|
+
},
|
|
95
|
+
JOURNAL_CODE_VERSION: {
|
|
96
|
+
cause: 'Journal run "{runId}" was started on code {expected} and this process is {actual}.',
|
|
97
|
+
fix: "Finish or abandon in-flight runs before deploying a different code version. Do not replay them on the new build.",
|
|
98
|
+
},
|
|
99
|
+
JOURNAL_VALUE_NOT_JSON: {
|
|
100
|
+
cause: "A durable journal entry held a value that is not JSON ({detail}).",
|
|
101
|
+
fix: "Return plain JSON from fx.step. fx.fetch and fx.send must run inside fx.step so the journal can store a JSON snapshot.",
|
|
102
|
+
},
|
|
87
103
|
MCP_TOOL_DUPLICATE: {
|
|
88
104
|
cause: 'MCP tool "{tool}" is bound twice (flow "{flow}").',
|
|
89
105
|
fix: "Give each MCP tool exposure a unique tool name.",
|
package/src/kernel/errors.ts
CHANGED
|
@@ -294,6 +294,14 @@ const OKE_ERROR_ROWS = {
|
|
|
294
294
|
LIVE_EXPOSURE_DUPLICATE: { code: 1050, domain: "kernel" },
|
|
295
295
|
/** Two MCP tool bindings share the same exposed tool name. */
|
|
296
296
|
MCP_TOOL_DUPLICATE: { code: 1060, domain: "kernel" },
|
|
297
|
+
/** A journal write arrived from a holder whose lease token no longer matches. */
|
|
298
|
+
JOURNAL_STALE_LEASE: { code: 1074, domain: "kernel" },
|
|
299
|
+
/** Replay called a different step or effect than the next journal entry. */
|
|
300
|
+
JOURNAL_REPLAY_DIVERGENCE: { code: 1075, domain: "kernel" },
|
|
301
|
+
/** Resume of a run stamped with a different code version. */
|
|
302
|
+
JOURNAL_CODE_VERSION: { code: 1076, domain: "kernel" },
|
|
303
|
+
/** A journaled value could not be stored as JSON. */
|
|
304
|
+
JOURNAL_VALUE_NOT_JSON: { code: 1077, domain: "kernel" },
|
|
297
305
|
/**
|
|
298
306
|
* Emit target has no subscriber (unified-theory §21).
|
|
299
307
|
* Thrown at emit when `optional` is false and nobody is subscribed.
|
package/src/kernel/flow.ts
CHANGED
|
@@ -49,9 +49,9 @@ export interface FlowOptions<I = unknown, O = unknown> {
|
|
|
49
49
|
*/
|
|
50
50
|
readonly retry?: FxRetryOptions;
|
|
51
51
|
/**
|
|
52
|
-
* Cache policy.
|
|
53
|
-
*
|
|
54
|
-
*
|
|
52
|
+
* Cache policy. Omitted caches a pure store-read flow. `true` or a duration
|
|
53
|
+
* (`"30s"`) opts one flow in when the app set `cache: { auto: false }`.
|
|
54
|
+
* `false` always disables. The key includes tenant, locale, scopes, and roles.
|
|
55
55
|
*/
|
|
56
56
|
readonly cache?: boolean | string;
|
|
57
57
|
/**
|
|
@@ -124,6 +124,10 @@ export type InferFlowOut<Opts> = Opts extends { readonly do: FlowHandler<infer _
|
|
|
124
124
|
* `FlowDef<…, undefined>` remains assignable into `on()` before rebinding.
|
|
125
125
|
*/
|
|
126
126
|
declare const triggerPhantom: unique symbol;
|
|
127
|
+
/** Covariant input phantom so `fx.call` can read `I` without hitting `do`. */
|
|
128
|
+
declare const inputPhantom: unique symbol;
|
|
129
|
+
/** Covariant output phantom so `fx.call` can read `O` without hitting `do`. */
|
|
130
|
+
declare const outputPhantom: unique symbol;
|
|
127
131
|
|
|
128
132
|
/**
|
|
129
133
|
* A Flow definition — one species, trigger-agnostic until {@link on} binds.
|
|
@@ -151,6 +155,10 @@ export interface FlowDef<
|
|
|
151
155
|
* `$trigger` / `triggers[0]` for the value.
|
|
152
156
|
*/
|
|
153
157
|
readonly [triggerPhantom]?: T;
|
|
158
|
+
/** Type-level input. Not present at runtime. */
|
|
159
|
+
readonly [inputPhantom]?: I;
|
|
160
|
+
/** Type-level output. Not present at runtime. */
|
|
161
|
+
readonly [outputPhantom]?: O;
|
|
154
162
|
/** Stable name (auto-assigned when omitted). */
|
|
155
163
|
readonly name: string;
|
|
156
164
|
/** Unit scope — derived from `name`'s first dot segment (e.g. `"auth.refresh"` → `"auth"`). */
|
|
@@ -255,6 +263,28 @@ export interface FlowDef<
|
|
|
255
263
|
): FlowDef<I, O, E, D & (P extends PluginDef<infer PD> ? PD : Record<string, never>), T>;
|
|
256
264
|
}
|
|
257
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Input type of a Flow handle. `unknown` when `F` is a string name.
|
|
268
|
+
*
|
|
269
|
+
* @typeParam F - Flow handle or other call target
|
|
270
|
+
*/
|
|
271
|
+
export type FlowInput<F> = F extends { readonly [inputPhantom]?: infer I }
|
|
272
|
+
? [I] extends [undefined]
|
|
273
|
+
? unknown
|
|
274
|
+
: Exclude<I, undefined>
|
|
275
|
+
: unknown;
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Output type of a Flow handle. `unknown` when `F` is a string name.
|
|
279
|
+
*
|
|
280
|
+
* @typeParam F - Flow handle or other call target
|
|
281
|
+
*/
|
|
282
|
+
export type FlowOutput<F> = F extends { readonly [outputPhantom]?: infer O }
|
|
283
|
+
? [O] extends [undefined]
|
|
284
|
+
? unknown
|
|
285
|
+
: Exclude<O, undefined>
|
|
286
|
+
: unknown;
|
|
287
|
+
|
|
258
288
|
/** Unique brand symbol for {@link FlowDef}. */
|
|
259
289
|
export const flowBrand: unique symbol = Symbol("oke.flow");
|
|
260
290
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fx.call(FlowDef)` is typed; a string name stays `Promise<unknown>`.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { flow } from "./flow.ts";
|
|
7
|
+
import { createFxContext, type Fx } from "./fx.ts";
|
|
8
|
+
|
|
9
|
+
describe("fx.call — FlowDef inference", () => {
|
|
10
|
+
test("a flow handle types input and output; a string name does not", () => {
|
|
11
|
+
const load = flow("notes.load", {
|
|
12
|
+
do: async (input: { id: string }) => ({ id: input.id, title: "n" }),
|
|
13
|
+
});
|
|
14
|
+
const { fx } = createFxContext({
|
|
15
|
+
flow: "notes.copy",
|
|
16
|
+
effects: { calls: ["notes.load"] },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const typed: (id: string) => Promise<{ id: string; title: string }> = (id) =>
|
|
20
|
+
fx.call(load, { id });
|
|
21
|
+
const untyped: (name: string) => Promise<unknown> = (name) => fx.call(name, { id: "1" });
|
|
22
|
+
|
|
23
|
+
expect(typeof typed).toBe("function");
|
|
24
|
+
expect(typeof untyped).toBe("function");
|
|
25
|
+
|
|
26
|
+
function _wrongInput(fxArg: Fx): void {
|
|
27
|
+
// @ts-expect-error input must match the flow handler
|
|
28
|
+
void fxArg.call(load, { id: 1 });
|
|
29
|
+
}
|
|
30
|
+
expect(typeof _wrongInput).toBe("function");
|
|
31
|
+
});
|
|
32
|
+
});
|
package/src/kernel/fx-decide.ts
CHANGED
|
@@ -208,19 +208,23 @@ async function executeDecide(options: FxDecideInput, name: string): Promise<unkn
|
|
|
208
208
|
const stored = (await options.journal.step(step, () =>
|
|
209
209
|
pendingRecord(options, view, false, decl),
|
|
210
210
|
)) as DecisionReviewRecord;
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
211
|
+
// Always consume the sleep row. Resolve moves wakeAt to now.
|
|
212
|
+
await options.journal.sleep(step, "876000h", () => 876000 * 60 * 60 * 1000);
|
|
214
213
|
const resolved = readStepRecord(options.journal, step) ?? stored;
|
|
215
214
|
return reviewed(view, resolved.values ?? {});
|
|
216
215
|
}
|
|
217
216
|
|
|
218
|
-
/** Completed
|
|
217
|
+
/** Completed `decide-provider` rows for this name. The current call is not included. */
|
|
219
218
|
function decideOrdinal(journal: JournalSession | undefined, name: string): number {
|
|
220
219
|
if (!journal) return 0;
|
|
221
|
-
return journal
|
|
222
|
-
(
|
|
223
|
-
|
|
220
|
+
return journal
|
|
221
|
+
.recordedBeforeCursor()
|
|
222
|
+
.filter(
|
|
223
|
+
(entry) =>
|
|
224
|
+
entry.kind === "effect" &&
|
|
225
|
+
entry.effectKind === "decide-provider" &&
|
|
226
|
+
entry.resource.startsWith(`${name}#`),
|
|
227
|
+
).length;
|
|
224
228
|
}
|
|
225
229
|
|
|
226
230
|
function readStepRecord(journal: JournalSession, step: string): DecisionReviewRecord | undefined {
|
|
@@ -9,6 +9,7 @@ import type { SqlRow } from "../drivers/types.ts";
|
|
|
9
9
|
import type {
|
|
10
10
|
SelectFromBuilder,
|
|
11
11
|
SelectOrderBuilder,
|
|
12
|
+
SqlPredicate,
|
|
12
13
|
SqlStoreHandle,
|
|
13
14
|
StoreDecl,
|
|
14
15
|
} from "../elements/store.ts";
|
|
@@ -16,6 +17,7 @@ import { schemaTableName, sqlTableRef } from "../manifest/sql-resource.ts";
|
|
|
16
17
|
import type { CapabilityToken } from "./capability.ts";
|
|
17
18
|
import { DryRunWriteIsolationError, isDryRun } from "./dry-run.ts";
|
|
18
19
|
import type { EffectExternal } from "./effects.ts";
|
|
20
|
+
import { currentSignalTransaction, runInSignalTransaction } from "./signal-tx.ts";
|
|
19
21
|
|
|
20
22
|
/** Capability-gated effect runner from {@link createFx}. */
|
|
21
23
|
type Gated = <T>(
|
|
@@ -39,6 +41,13 @@ export interface GatedSqlHandleOptions {
|
|
|
39
41
|
readonly capability: CapabilityToken;
|
|
40
42
|
/** `fx.ask` — optional search rerank. */
|
|
41
43
|
readonly ask: (model: string, input: unknown) => Promise<unknown>;
|
|
44
|
+
/**
|
|
45
|
+
* Open the signal outbox for emits inside {@link SqlStoreHandle.transaction}.
|
|
46
|
+
* Absent when the app has no signal runtime.
|
|
47
|
+
*/
|
|
48
|
+
readonly beginSignal?: () => Promise<
|
|
49
|
+
import("../drivers/signal-types.ts").SignalTransaction | undefined
|
|
50
|
+
>;
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
/**
|
|
@@ -90,7 +99,7 @@ export function createGatedSqlHandle(options: GatedSqlHandleOptions): SqlStoreHa
|
|
|
90
99
|
return gated(kind, ref, body, externalOf);
|
|
91
100
|
};
|
|
92
101
|
|
|
93
|
-
|
|
102
|
+
const api = {
|
|
94
103
|
ref,
|
|
95
104
|
get routedRole() {
|
|
96
105
|
return cached?.routedRole ?? "primary";
|
|
@@ -102,7 +111,7 @@ export function createGatedSqlHandle(options: GatedSqlHandleOptions): SqlStoreHa
|
|
|
102
111
|
return {
|
|
103
112
|
from(table: unknown) {
|
|
104
113
|
const run = (plan: {
|
|
105
|
-
where?:
|
|
114
|
+
where?: SqlPredicate<SqlRow> | null;
|
|
106
115
|
orders?: readonly unknown[];
|
|
107
116
|
limit?: number;
|
|
108
117
|
offset?: number;
|
|
@@ -110,7 +119,8 @@ export function createGatedSqlHandle(options: GatedSqlHandleOptions): SqlStoreHa
|
|
|
110
119
|
gatedTable("read", table, async () => {
|
|
111
120
|
const h = await ensure();
|
|
112
121
|
const from = h.select(columns).from(table) as SelectFromBuilder;
|
|
113
|
-
const filtered =
|
|
122
|
+
const filtered =
|
|
123
|
+
plan.where === undefined || plan.where === null ? from : from.where(plan.where);
|
|
114
124
|
const ordered =
|
|
115
125
|
plan.orders === undefined ? filtered : filtered.orderBy(...plan.orders);
|
|
116
126
|
if (plan.offset !== undefined) return ordered.offset(plan.offset);
|
|
@@ -118,7 +128,7 @@ export function createGatedSqlHandle(options: GatedSqlHandleOptions): SqlStoreHa
|
|
|
118
128
|
});
|
|
119
129
|
|
|
120
130
|
const tail = (plan: {
|
|
121
|
-
where?:
|
|
131
|
+
where?: SqlPredicate<SqlRow> | null;
|
|
122
132
|
orders?: readonly unknown[];
|
|
123
133
|
}): SelectOrderBuilder => ({
|
|
124
134
|
limit(n) {
|
|
@@ -133,7 +143,7 @@ export function createGatedSqlHandle(options: GatedSqlHandleOptions): SqlStoreHa
|
|
|
133
143
|
});
|
|
134
144
|
|
|
135
145
|
return {
|
|
136
|
-
where(where:
|
|
146
|
+
where(where: SqlPredicate<SqlRow>) {
|
|
137
147
|
return {
|
|
138
148
|
...tail({ where }),
|
|
139
149
|
orderBy: (...orders: readonly unknown[]) => tail({ where, orders }),
|
|
@@ -185,7 +195,7 @@ export function createGatedSqlHandle(options: GatedSqlHandleOptions): SqlStoreHa
|
|
|
185
195
|
return {
|
|
186
196
|
set(row) {
|
|
187
197
|
return {
|
|
188
|
-
where(where) {
|
|
198
|
+
where(where: SqlPredicate<SqlRow>) {
|
|
189
199
|
return gatedTable("write", table, async () => {
|
|
190
200
|
refuseDryRunWrite();
|
|
191
201
|
const h = await ensure();
|
|
@@ -211,7 +221,7 @@ export function createGatedSqlHandle(options: GatedSqlHandleOptions): SqlStoreHa
|
|
|
211
221
|
});
|
|
212
222
|
}
|
|
213
223
|
return {
|
|
214
|
-
where(where:
|
|
224
|
+
where(where: SqlPredicate<SqlRow>) {
|
|
215
225
|
return gatedTable("write", table, async () => {
|
|
216
226
|
refuseDryRunWrite();
|
|
217
227
|
const h = await ensure();
|
|
@@ -240,6 +250,40 @@ export function createGatedSqlHandle(options: GatedSqlHandleOptions): SqlStoreHa
|
|
|
240
250
|
return h.increment(table, id, column, by);
|
|
241
251
|
});
|
|
242
252
|
},
|
|
253
|
+
async transaction(fn) {
|
|
254
|
+
refuseDryRunWrite();
|
|
255
|
+
const h = await ensure();
|
|
256
|
+
// Already inside a signal transaction: do not begin or commit another
|
|
257
|
+
// one. sql-session opens a child staging scope and merges it; only the
|
|
258
|
+
// outermost commit publishes.
|
|
259
|
+
if (currentSignalTransaction()) {
|
|
260
|
+
return h.transaction(() => fn(api));
|
|
261
|
+
}
|
|
262
|
+
const signalTx = options.beginSignal ? await options.beginSignal() : undefined;
|
|
263
|
+
try {
|
|
264
|
+
const result = await h.transaction(async () => {
|
|
265
|
+
const body = () => fn(api);
|
|
266
|
+
if (!signalTx) return body();
|
|
267
|
+
return runInSignalTransaction(signalTx, body);
|
|
268
|
+
});
|
|
269
|
+
if (signalTx) await signalTx.commit();
|
|
270
|
+
return result;
|
|
271
|
+
} catch (err) {
|
|
272
|
+
if (signalTx) await signalTx.rollback();
|
|
273
|
+
throw err;
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
run(builder) {
|
|
277
|
+
const text = builder.toSQL().sql;
|
|
278
|
+
const kind = /^(insert|update|delete|create|drop|alter|truncate|replace)\b/i.test(text.trim())
|
|
279
|
+
? "write"
|
|
280
|
+
: "read";
|
|
281
|
+
return gated(kind, ref, async () => {
|
|
282
|
+
if (kind === "write") refuseDryRunWrite();
|
|
283
|
+
const h = await ensure();
|
|
284
|
+
return h.run(builder);
|
|
285
|
+
});
|
|
286
|
+
},
|
|
243
287
|
raw(sql, params) {
|
|
244
288
|
return gated("read", ref, async () => {
|
|
245
289
|
const h = await ensure();
|
|
@@ -302,4 +346,5 @@ export function createGatedSqlHandle(options: GatedSqlHandleOptions): SqlStoreHa
|
|
|
302
346
|
});
|
|
303
347
|
},
|
|
304
348
|
} as SqlStoreHandle;
|
|
349
|
+
return api;
|
|
305
350
|
}
|
package/src/kernel/fx.test.ts
CHANGED
package/src/kernel/fx.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type JsonStreamResult,
|
|
18
18
|
} from "./json-result.ts";
|
|
19
19
|
import type { Effects, ResourceRef, SignalResourceRef } from "../manifest/types.ts";
|
|
20
|
+
import type { FlowInput, FlowOutput } from "./flow.ts";
|
|
20
21
|
import { isMcpToolRef } from "../manifest/mcp-ref.ts";
|
|
21
22
|
import type { QueryPageSpec } from "./list-page.ts";
|
|
22
23
|
import type {
|
|
@@ -36,6 +37,7 @@ import { rlsIdentityFromAuth } from "../elements/store.ts";
|
|
|
36
37
|
import type { RlsIdentity } from "../drivers/pg-rls.ts";
|
|
37
38
|
import type { SignalDecl, SignalRuntime } from "../elements/signal.ts";
|
|
38
39
|
import type { DeadLetter, SignalEmitOptions } from "../drivers/signal-types.ts";
|
|
40
|
+
import { currentSignalTransaction } from "./signal-tx.ts";
|
|
39
41
|
import type { VaultActor, VaultAdapter, VaultRuntime } from "../elements/vault.ts";
|
|
40
42
|
import type { ChannelRuntime } from "../elements/channel.ts";
|
|
41
43
|
import type { AgentApprovalResolveResult } from "../elements/ai/approval.ts";
|
|
@@ -172,6 +174,12 @@ export type { FxRetryOptions, FxThunk } from "./concurrency.ts";
|
|
|
172
174
|
/** Named ref: plain string or `{ name }` element handle (optional pin). */
|
|
173
175
|
export type NamedRef = string | { readonly name: string; readonly version?: number };
|
|
174
176
|
|
|
177
|
+
/** Input of {@link Fx.call}: the flow's input, or `unknown` for a name. */
|
|
178
|
+
type FxCallInput<F> = FlowInput<F>;
|
|
179
|
+
|
|
180
|
+
/** Result of {@link Fx.call}: the flow's output, or `unknown` for a name. */
|
|
181
|
+
type FxCallResult<F> = Promise<FlowOutput<F>>;
|
|
182
|
+
|
|
175
183
|
/** Resolve a {@link NamedRef} to its string id (`name@version` when pinned). */
|
|
176
184
|
export function resolveName(ref: NamedRef): string {
|
|
177
185
|
if (typeof ref === "string") return ref;
|
|
@@ -768,7 +776,7 @@ export interface Fx {
|
|
|
768
776
|
* @param flow - Flow name or handle
|
|
769
777
|
* @param input - Input payload
|
|
770
778
|
*/
|
|
771
|
-
call(flow:
|
|
779
|
+
call<F extends NamedRef>(flow: F, input?: FxCallInput<F>): FxCallResult<F>;
|
|
772
780
|
/**
|
|
773
781
|
* Query the Runs wide-event store (records `read` on `"runs"`).
|
|
774
782
|
* Requires a bound runs runtime and `effects.reads` including `"runs"`.
|
|
@@ -790,10 +798,6 @@ export interface Fx {
|
|
|
790
798
|
/**
|
|
791
799
|
* Send a provider-managed SMS OTP (records `send` on `sms-otp`).
|
|
792
800
|
*
|
|
793
|
-
* Vendor extra (Taqnyat Verify API) — requires a bound SMS driver that
|
|
794
|
-
* supports provider-managed OTP. Dry-run records would-have-fired without
|
|
795
|
-
* contacting the provider.
|
|
796
|
-
*
|
|
797
801
|
* @param opts - Recipient + requestId (+ lang / note / from)
|
|
798
802
|
*/
|
|
799
803
|
sendOtp(opts: FxSendOtpOptions): Promise<{ ok: true }>;
|
|
@@ -806,9 +810,6 @@ export interface Fx {
|
|
|
806
810
|
/**
|
|
807
811
|
* Deliver an app-owned OTP across declared channels (records `send` on `auth-otp`).
|
|
808
812
|
*
|
|
809
|
-
* Tier-2 only — uses Channel `deliverOtp` (sently FallbackTransport). Pass
|
|
810
|
-
* `only` for explicit user resend (single channel, no cross-medium failover).
|
|
811
|
-
*
|
|
812
813
|
* @param opts - Channels, templates, addresses, OTP data
|
|
813
814
|
*/
|
|
814
815
|
deliverOtp(opts: FxDeliverOtpOptions): Promise<{
|
|
@@ -1407,6 +1408,11 @@ export function createFxContext(options: CreateFxOptions): FxContext {
|
|
|
1407
1408
|
gated,
|
|
1408
1409
|
capability,
|
|
1409
1410
|
ask: (model, input) => fx.ask(model, input),
|
|
1411
|
+
beginSignal: async () => {
|
|
1412
|
+
const bus = options.signalRuntime?.bus;
|
|
1413
|
+
if (!bus) return undefined;
|
|
1414
|
+
return bus.begin();
|
|
1415
|
+
},
|
|
1410
1416
|
});
|
|
1411
1417
|
}
|
|
1412
1418
|
|
|
@@ -1783,13 +1789,18 @@ export function createFxContext(options: CreateFxOptions): FxContext {
|
|
|
1783
1789
|
emit(signal: NamedRef, payload?: unknown, emitOptions?: SignalEmitOptions) {
|
|
1784
1790
|
const name = resolveName(signal);
|
|
1785
1791
|
return gated("emit", name, async () => {
|
|
1792
|
+
const merged: SignalEmitOptions = {
|
|
1793
|
+
...emitOptions,
|
|
1794
|
+
...(options.runId !== undefined && emitOptions?.parentRunId === undefined
|
|
1795
|
+
? { parentRunId: options.runId }
|
|
1796
|
+
: {}),
|
|
1797
|
+
};
|
|
1798
|
+
const pending = currentSignalTransaction();
|
|
1799
|
+
if (pending) {
|
|
1800
|
+
await pending.emit(name, payload, merged);
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1786
1803
|
if (options.signalRuntime) {
|
|
1787
|
-
const merged: SignalEmitOptions = {
|
|
1788
|
-
...emitOptions,
|
|
1789
|
-
...(options.runId !== undefined && emitOptions?.parentRunId === undefined
|
|
1790
|
-
? { parentRunId: options.runId }
|
|
1791
|
-
: {}),
|
|
1792
|
-
};
|
|
1793
1804
|
await options.signalRuntime.emit(name, payload, merged);
|
|
1794
1805
|
}
|
|
1795
1806
|
});
|
|
@@ -1815,7 +1826,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
|
|
|
1815
1826
|
signalRuntime: options.signalRuntime,
|
|
1816
1827
|
}) as JsonStreamResult;
|
|
1817
1828
|
},
|
|
1818
|
-
call(flow, input) {
|
|
1829
|
+
call<F extends NamedRef>(flow: F, input?: FxCallInput<F>): FxCallResult<F> {
|
|
1819
1830
|
const name = resolveName(flow);
|
|
1820
1831
|
return gated("call", name, async () => {
|
|
1821
1832
|
if (isMcpToolRef(name)) {
|
|
@@ -1828,7 +1839,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
|
|
|
1828
1839
|
return options.callHandler(name, input);
|
|
1829
1840
|
}
|
|
1830
1841
|
return undefined;
|
|
1831
|
-
})
|
|
1842
|
+
}) as FxCallResult<F>;
|
|
1832
1843
|
},
|
|
1833
1844
|
clock,
|
|
1834
1845
|
vault: vaultSurface,
|