okengine 0.23.0 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/ai/mcp.mdx +1 -1
  3. package/site/content/docs/elements/channel/index.mdx +4 -4
  4. package/site/content/docs/elements/channel/receipts.mdx +7 -5
  5. package/site/content/docs/elements/channel/sms.mdx +2 -1
  6. package/site/content/docs/elements/flow/index.mdx +22 -18
  7. package/site/content/docs/elements/signal/index.mdx +15 -14
  8. package/site/content/docs/plugins/otp.mdx +3 -3
  9. package/site/content/docs/reference/fx.mdx +12 -4
  10. package/src/auth/tenants.ts +17 -0
  11. package/src/cli/doctor-diff.test.ts +29 -1
  12. package/src/cli/doctor-diff.ts +35 -0
  13. package/src/cli/manifest-pr-diff.ts +16 -0
  14. package/src/compiler/effects-fetch.test.ts +77 -0
  15. package/src/compiler/effects-infer.ts +87 -16
  16. package/src/compiler/effects-join.test.ts +48 -0
  17. package/src/compiler/extract.test.ts +28 -0
  18. package/src/compiler/extract.ts +11 -1
  19. package/src/compiler/fx-index.ts +58 -4
  20. package/src/console/index.ts +1 -1
  21. package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-C_N4qvai.js} +1 -1
  22. package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-Cq1TxniW.js} +1 -1
  23. package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-BaI4uxZz.js} +1 -1
  24. package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-DNyK7IIY.js} +1 -1
  25. package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-CRhBJMZR.js} +1 -1
  26. package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-D9twj8Xy.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-CsHWlVPU.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-ZaTJAhR9.js} +1 -1
  29. package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DQ-EMAfz.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-Cn_Fl79s.js} +1 -1
  31. package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-CmRFeWdT.js} +3 -3
  32. package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-Cn6R_dXz.js} +1 -1
  33. package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-Bx6EZhSe.js} +1 -1
  34. package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-DmdiUrfh.js} +1 -1
  35. package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-8P6vGXnn.js} +1 -1
  36. package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-BfGvpFSv.js} +1 -1
  37. package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-CWdUHbc0.js} +1 -1
  38. package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-CZOFZzay.js} +1 -1
  39. package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-ki_ishmX.js} +1 -1
  40. package/src/console/ui-next/dist/index.html +1 -1
  41. package/src/drivers/journal-postgres.test.ts +22 -1
  42. package/src/drivers/journal-postgres.ts +223 -12
  43. package/src/drivers/signal-compete.test.ts +234 -0
  44. package/src/drivers/signal-postgres.ts +39 -19
  45. package/src/drivers/signal-redis.ts +167 -11
  46. package/src/drivers/signal-types.ts +12 -0
  47. package/src/elements/ai/mcp-protocol.ts +5 -6
  48. package/src/elements/ai/runtime.ts +20 -19
  49. package/src/elements/channel/runtime.ts +11 -0
  50. package/src/elements/channel/sql-ledger.ts +251 -0
  51. package/src/elements/signal/runtime.ts +9 -0
  52. package/src/elements/signal.test.ts +1 -0
  53. package/src/elements/store/cache.test.ts +120 -15
  54. package/src/elements/store/cache.ts +200 -39
  55. package/src/elements/store/prepare-row.test.ts +6 -2
  56. package/src/elements/store/sql-session.test.ts +2 -0
  57. package/src/elements/store/sql-session.ts +95 -9
  58. package/src/elements/store.ts +1 -0
  59. package/src/kernel/app.ts +32 -16
  60. package/src/kernel/auto-cache.test.ts +228 -0
  61. package/src/kernel/boot-bind/channel.ts +63 -2
  62. package/src/kernel/boot-bind/honor-config.test.ts +40 -10
  63. package/src/kernel/boot-bind/signal.ts +61 -25
  64. package/src/kernel/boot.ts +16 -2
  65. package/src/kernel/errors-compiler.ts +11 -0
  66. package/src/kernel/errors-text.ts +16 -0
  67. package/src/kernel/errors.ts +8 -0
  68. package/src/kernel/flow.ts +33 -3
  69. package/src/kernel/fx-call-types.test.ts +32 -0
  70. package/src/kernel/fx-decide.ts +11 -7
  71. package/src/kernel/fx-sql-handle.ts +46 -7
  72. package/src/kernel/fx.test.ts +1 -3
  73. package/src/kernel/fx.ts +27 -16
  74. package/src/kernel/idempotency.test.ts +2 -1
  75. package/src/kernel/journal.test.ts +114 -0
  76. package/src/kernel/journal.ts +369 -53
  77. package/src/kernel/pipeline-tenant.ts +5 -1
  78. package/src/kernel/pipeline.ts +5 -0
  79. package/src/kernel/signal-tx.ts +28 -0
  80. package/src/kernel/store-transaction.test.ts +95 -0
  81. package/src/mcp/docs-server.ts +19 -2
  82. package/src/mcp/mcp.test.ts +83 -0
  83. package/src/mcp/protocol.ts +8 -2
  84. package/src/mcp/server.ts +23 -2
  85. package/src/mcp/versions.ts +60 -0
  86. package/src/plugins/index.ts +1 -0
  87. package/src/plugins/mena.ts +56 -0
@@ -49,9 +49,9 @@ export interface FlowOptions<I = unknown, O = unknown> {
49
49
  */
50
50
  readonly retry?: FxRetryOptions;
51
51
  /**
52
- * Cache policy. Read-only flows cache automatically from inferred
53
- * effects (`true` / omitted). `false` opts out; a duration string
54
- * (`"30s"`) adds a TTL on top of write invalidation.
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
+ });
@@ -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
- if (stored.status === "pending") {
212
- await options.journal.sleep(step, "876000h", () => 876000 * 60 * 60 * 1000);
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 outer `decide` effects for this name. The current call is not stored yet. */
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.run.entries.filter(
222
- (entry) => entry.kind === "effect" && entry.effectKind === "decide" && entry.resource === name,
223
- ).length;
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
- return {
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?: unknown;
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 = plan.where === undefined ? from : from.where(plan.where);
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?: unknown;
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: unknown) {
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: unknown) {
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
  }
@@ -436,9 +436,7 @@ describe("fx — wholesale swap", () => {
436
436
  live() {
437
437
  throw new Error("live must not be used");
438
438
  },
439
- async call() {
440
- return null;
441
- },
439
+ call: (async () => null) as Fx["call"],
442
440
  clock: {
443
441
  now: () => 0,
444
442
  ago: () => 0,
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: NamedRef, input?: unknown): Promise<unknown>;
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
- expect((await hung).status).toBe(200);
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
  });