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
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 { 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,34 @@ 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
|
+
const signalTx = options.beginSignal ? await options.beginSignal() : undefined;
|
|
257
|
+
try {
|
|
258
|
+
const result = await h.transaction(async () => {
|
|
259
|
+
const body = () => fn(api);
|
|
260
|
+
if (!signalTx) return body();
|
|
261
|
+
return runInSignalTransaction(signalTx, body);
|
|
262
|
+
});
|
|
263
|
+
if (signalTx) await signalTx.commit();
|
|
264
|
+
return result;
|
|
265
|
+
} catch (err) {
|
|
266
|
+
if (signalTx) await signalTx.rollback();
|
|
267
|
+
throw err;
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
run(builder) {
|
|
271
|
+
const text = builder.toSQL().sql;
|
|
272
|
+
const kind = /^(insert|update|delete|create|drop|alter|truncate|replace)\b/i.test(text.trim())
|
|
273
|
+
? "write"
|
|
274
|
+
: "read";
|
|
275
|
+
return gated(kind, ref, async () => {
|
|
276
|
+
if (kind === "write") refuseDryRunWrite();
|
|
277
|
+
const h = await ensure();
|
|
278
|
+
return h.run(builder);
|
|
279
|
+
});
|
|
280
|
+
},
|
|
243
281
|
raw(sql, params) {
|
|
244
282
|
return gated("read", ref, async () => {
|
|
245
283
|
const h = await ensure();
|
|
@@ -302,4 +340,5 @@ export function createGatedSqlHandle(options: GatedSqlHandleOptions): SqlStoreHa
|
|
|
302
340
|
});
|
|
303
341
|
},
|
|
304
342
|
} as SqlStoreHandle;
|
|
343
|
+
return api;
|
|
305
344
|
}
|
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,
|
|
@@ -353,7 +353,8 @@ describe("HTTP idempotency", () => {
|
|
|
353
353
|
expect(step1).toEqual(["step1"]);
|
|
354
354
|
blockers[0]!();
|
|
355
355
|
blockers[1]!();
|
|
356
|
-
|
|
356
|
+
// The forfeited holder kept the old lease token. Its later write is rejected.
|
|
357
|
+
await expect(hung).rejects.toThrow(/OKE1074/);
|
|
357
358
|
expect((await resumed).status).toBe(200);
|
|
358
359
|
expect(step1).toEqual(["step1"]);
|
|
359
360
|
}, 30_000);
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
import { describe, expect, test } from "bun:test";
|
|
7
7
|
|
|
8
|
+
import { runDurable } from "../elements/clock/durable.ts";
|
|
9
|
+
import { flow } from "./flow.ts";
|
|
8
10
|
import {
|
|
9
11
|
createJournal,
|
|
10
12
|
createMemoryJournalStore,
|
|
@@ -13,6 +15,7 @@ import {
|
|
|
13
15
|
isJournalSuspend,
|
|
14
16
|
JOURNAL_DEFAULT_LEASE_MS,
|
|
15
17
|
} from "./journal.ts";
|
|
18
|
+
import { OkeError } from "./errors.ts";
|
|
16
19
|
|
|
17
20
|
describe("journal lease (memory store)", () => {
|
|
18
21
|
test("built-in stores expose the lease surface", () => {
|
|
@@ -139,4 +142,115 @@ describe("journal lease (memory store)", () => {
|
|
|
139
142
|
const again = await journal.resume(session.runId);
|
|
140
143
|
expect(await again.step("s", () => 2)).toBe(1);
|
|
141
144
|
});
|
|
145
|
+
|
|
146
|
+
test("a stale lease holder cannot append", async () => {
|
|
147
|
+
let t = 1_000;
|
|
148
|
+
const store = createMemoryJournalStore();
|
|
149
|
+
const a = createJournal({
|
|
150
|
+
store,
|
|
151
|
+
now: () => t,
|
|
152
|
+
lease: { instanceId: "a", leaseMs: 100 },
|
|
153
|
+
codeVersion: "v1",
|
|
154
|
+
});
|
|
155
|
+
const session = await a.start("charge");
|
|
156
|
+
await session.step("create-intent", () => ({ id: "pi_1" }));
|
|
157
|
+
t = 1_200;
|
|
158
|
+
const b = createJournal({
|
|
159
|
+
store,
|
|
160
|
+
now: () => t,
|
|
161
|
+
lease: { instanceId: "b", leaseMs: 100 },
|
|
162
|
+
codeVersion: "v1",
|
|
163
|
+
});
|
|
164
|
+
await b.resume(session.runId);
|
|
165
|
+
try {
|
|
166
|
+
await session.step("capture", () => ({ ok: true }));
|
|
167
|
+
expect.unreachable("stale holder must be rejected");
|
|
168
|
+
} catch (err) {
|
|
169
|
+
expect(err).toBeInstanceOf(OkeError);
|
|
170
|
+
expect((err as OkeError).code).toBe(1074);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("replay throws when the next call does not match the journal", async () => {
|
|
175
|
+
const store = createMemoryJournalStore();
|
|
176
|
+
const journal = createJournal({ store, now: () => 1, codeVersion: "v1" });
|
|
177
|
+
const session = await journal.start("charge");
|
|
178
|
+
await session.step("a", () => 1);
|
|
179
|
+
const resumed = await journal.resume(session.runId);
|
|
180
|
+
try {
|
|
181
|
+
await resumed.step("b", () => 2);
|
|
182
|
+
expect.unreachable("divergent replay must throw");
|
|
183
|
+
} catch (err) {
|
|
184
|
+
expect(err).toBeInstanceOf(OkeError);
|
|
185
|
+
expect((err as OkeError).code).toBe(1075);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("a non-JSON journal value is OKE1077", async () => {
|
|
190
|
+
const store = createMemoryJournalStore();
|
|
191
|
+
const session = await createJournal({ store, now: () => 1 }).start("charge");
|
|
192
|
+
try {
|
|
193
|
+
await session.effect("call", "charge", () => 1n);
|
|
194
|
+
expect.unreachable("bigint must not journal");
|
|
195
|
+
} catch (err) {
|
|
196
|
+
expect(err).toBeInstanceOf(OkeError);
|
|
197
|
+
expect((err as OkeError).code).toBe(1077);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("resume rejects a run stamped with another code version", async () => {
|
|
202
|
+
const store = createMemoryJournalStore();
|
|
203
|
+
const started = await createJournal({ store, now: () => 1, codeVersion: "v1" }).start("charge");
|
|
204
|
+
try {
|
|
205
|
+
await createJournal({ store, now: () => 2, codeVersion: "v2" }).resume(started.runId);
|
|
206
|
+
expect.unreachable("version skew must throw");
|
|
207
|
+
} catch (err) {
|
|
208
|
+
expect(err).toBeInstanceOf(OkeError);
|
|
209
|
+
expect((err as OkeError).code).toBe(1076);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("journal fetch snapshot", () => {
|
|
215
|
+
test("a durable flow replays fx.fetch without calling the network again", async () => {
|
|
216
|
+
const store = createMemoryJournalStore();
|
|
217
|
+
let calls = 0;
|
|
218
|
+
const original = globalThis.fetch;
|
|
219
|
+
globalThis.fetch = (async () => {
|
|
220
|
+
calls += 1;
|
|
221
|
+
return new Response("hello", { status: 201 });
|
|
222
|
+
}) as unknown as typeof fetch;
|
|
223
|
+
const load = flow("load", {
|
|
224
|
+
durable: true,
|
|
225
|
+
do: async (_input, fx) => {
|
|
226
|
+
const res = await fx.step("load", () => fx.fetch("https://example.com/data"));
|
|
227
|
+
return { status: res.status, text: await res.text() };
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
try {
|
|
231
|
+
const first = await runDurable({
|
|
232
|
+
flow: load,
|
|
233
|
+
input: {},
|
|
234
|
+
journalStore: store,
|
|
235
|
+
now: () => 1,
|
|
236
|
+
});
|
|
237
|
+
expect(first).toMatchObject({ status: "completed", output: { status: 201, text: "hello" } });
|
|
238
|
+
expect(calls).toBe(1);
|
|
239
|
+
const row = await store.get(first.runId);
|
|
240
|
+
expect(row?.entries.some((entry) => entry.kind === "step" && entry.name === "load")).toBe(
|
|
241
|
+
true,
|
|
242
|
+
);
|
|
243
|
+
const replay = await createJournal({ store, now: () => 2 }).resume(first.runId);
|
|
244
|
+
const again = await replay.step("load", () => {
|
|
245
|
+
calls += 1;
|
|
246
|
+
return new Response("nope", { status: 500 });
|
|
247
|
+
});
|
|
248
|
+
expect(again).toBeInstanceOf(Response);
|
|
249
|
+
expect(again.status).toBe(201);
|
|
250
|
+
expect(await again.text()).toBe("hello");
|
|
251
|
+
expect(calls).toBe(1);
|
|
252
|
+
} finally {
|
|
253
|
+
globalThis.fetch = original;
|
|
254
|
+
}
|
|
255
|
+
});
|
|
142
256
|
});
|