okengine 0.12.0 → 0.14.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 (127) hide show
  1. package/manifest.v1.schema.json +7 -1
  2. package/package.json +2 -2
  3. package/site/content/docs/ai/mcp.mdx +27 -2
  4. package/site/content/docs/elements/ai.mdx +58 -11
  5. package/site/content/docs/elements/clock.mdx +27 -12
  6. package/site/content/docs/elements/flow.mdx +6 -3
  7. package/site/content/docs/elements/signal.mdx +71 -25
  8. package/site/content/docs/elements/store.mdx +21 -1
  9. package/site/content/docs/get-started/installation.mdx +2 -2
  10. package/site/content/docs/providers/index.mdx +1 -0
  11. package/site/content/docs/recipes/dragonfly.mdx +4 -3
  12. package/site/content/docs/recipes/redis.mdx +4 -5
  13. package/site/content/docs/recipes/valkey.mdx +5 -3
  14. package/site/content/docs/reference/configuration.mdx +3 -1
  15. package/site/content/docs/reference/environment-variables.mdx +5 -5
  16. package/site/content/docs/reference/fx.mdx +27 -22
  17. package/src/cli/ai-setup/recommend.test.ts +25 -0
  18. package/src/cli/ai-setup/recommend.ts +8 -3
  19. package/src/cli/load-config.images.test.ts +1 -0
  20. package/src/compiler/effects-infer.ts +58 -3
  21. package/src/compiler/extract.test.ts +68 -1
  22. package/src/compiler/extract.ts +70 -2
  23. package/src/compiler/response.ts +45 -1
  24. package/src/console/server/ai.ts +5 -2
  25. package/src/console/server/flows.ts +1 -0
  26. package/src/console/server/serve.ts +17 -2
  27. package/src/console/ui-next/dist/assets/cache-glyph-BanhLsEY.js +1 -0
  28. package/src/console/ui-next/dist/assets/flows-page-DxDsOd4f.js +1 -0
  29. package/src/console/ui-next/dist/assets/http-method-CJCBYL2j.js +1 -0
  30. package/src/console/ui-next/dist/assets/{index-DX248G39.js → index-Ce6WKWKM.js} +3 -3
  31. package/src/console/ui-next/dist/assets/observability-page-BEZDzyYh.js +4 -0
  32. package/src/console/ui-next/dist/assets/trace-detail-sheet-DFLFfUUX.js +2 -0
  33. package/src/console/ui-next/dist/assets/units-page-C0gW6Kdo.js +1 -0
  34. package/src/console/ui-next/dist/assets/{vault-page-CSOYMHhO.js → vault-page-DuKzqwzW.js} +1 -1
  35. package/src/console/ui-next/dist/index.html +1 -1
  36. package/src/console/ui-next/seed-invoke-host.ts +2 -0
  37. package/src/console/ui-next/src/features/flows/graph/build-flow-graph.test.ts +18 -0
  38. package/src/console/ui-next/src/features/flows/graph/build-flow-graph.ts +14 -1
  39. package/src/console/ui-next/src/features/flows/graph/neighborhood.test.ts +17 -0
  40. package/src/console/ui-next/src/features/flows/graph/neighborhood.ts +16 -3
  41. package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +3 -1
  42. package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
  43. package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +9 -3
  44. package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +10 -1
  45. package/src/console/ui-next/src/features/observability/lib/ask-count.test.ts +25 -0
  46. package/src/console/ui-next/src/features/observability/lib/ask-count.ts +4 -1
  47. package/src/console/ui-next/src/features/units/detail/effects-summary.tsx +12 -4
  48. package/src/docker/docker.test.ts +1 -1
  49. package/src/docker/dockerfile.ts +1 -1
  50. package/src/docker/helpers.ts +28 -0
  51. package/src/docker/recipes/dragonfly.ts +3 -6
  52. package/src/docker/recipes/redis.ts +2 -6
  53. package/src/docker/recipes/valkey.ts +2 -6
  54. package/src/drivers/ai-anthropic.ts +5 -0
  55. package/src/drivers/ai-ollama.ts +49 -30
  56. package/src/drivers/ai-openai-compatible.ts +57 -46
  57. package/src/drivers/ai-providers.test.ts +3 -0
  58. package/src/drivers/bun-native-completeness.test.ts +7 -9
  59. package/src/drivers/redis.ts +11 -4
  60. package/src/drivers/signal-redis.ts +24 -14
  61. package/src/drivers/signal-types.ts +2 -1
  62. package/src/drivers/types.ts +1 -1
  63. package/src/elements/ai/declare.ts +109 -0
  64. package/src/elements/ai/errors.test.ts +5 -1
  65. package/src/elements/ai/errors.ts +30 -2
  66. package/src/elements/ai/eval.ts +4 -6
  67. package/src/elements/ai/mcp-client.test.ts +206 -0
  68. package/src/elements/ai/mcp-client.ts +362 -0
  69. package/src/elements/ai/mcp-http.ts +159 -0
  70. package/src/elements/ai/mcp-mock.ts +134 -0
  71. package/src/elements/ai/mcp-protocol.ts +234 -0
  72. package/src/elements/ai/mcp-stdio.test.ts +50 -0
  73. package/src/elements/ai/mcp-stdio.ts +212 -0
  74. package/src/elements/ai/mcp-transport.ts +70 -0
  75. package/src/elements/ai/runtime.ts +159 -29
  76. package/src/elements/ai.test.ts +139 -0
  77. package/src/elements/ai.ts +16 -0
  78. package/src/elements/clock/health.test.ts +43 -0
  79. package/src/elements/clock/runtime.ts +55 -2
  80. package/src/elements/clock/schedule.ts +71 -142
  81. package/src/elements/clock.test.ts +9 -40
  82. package/src/elements/clock.ts +6 -1
  83. package/src/elements/gate/runtime.ts +3 -3
  84. package/src/elements/index.ts +2 -0
  85. package/src/elements/signal/declare.ts +2 -1
  86. package/src/elements/signal/runtime.ts +16 -1
  87. package/src/elements/signal.ts +1 -0
  88. package/src/elements/store/cache.test.ts +2 -0
  89. package/src/elements/store/cache.ts +3 -3
  90. package/src/elements/store/declare.ts +7 -0
  91. package/src/elements/store/kv-sql.test.ts +86 -0
  92. package/src/elements/store/kv-sql.ts +178 -0
  93. package/src/elements/store/runtime.ts +35 -9
  94. package/src/elements/store.test.ts +2 -0
  95. package/src/elements/vault/builtin-adapter.ts +17 -0
  96. package/src/full.ts +2 -0
  97. package/src/index.ts +4 -0
  98. package/src/kernel/app.ts +97 -64
  99. package/src/kernel/auto-registry.test.ts +5 -0
  100. package/src/kernel/boot-bind/ai.ts +24 -0
  101. package/src/kernel/boot-bind/clock.ts +2 -0
  102. package/src/kernel/boot-bind/store.test.ts +150 -1
  103. package/src/kernel/boot-bind/store.ts +66 -1
  104. package/src/kernel/boot.ts +3 -1
  105. package/src/kernel/element-registries.ts +4 -1
  106. package/src/kernel/fx-dead-letters.test.ts +77 -0
  107. package/src/kernel/fx.test.ts +22 -0
  108. package/src/kernel/fx.ts +112 -6
  109. package/src/kernel/http-stream.test.ts +174 -0
  110. package/src/kernel/index.ts +2 -0
  111. package/src/manifest/diff.test.ts +13 -0
  112. package/src/manifest/diff.ts +15 -0
  113. package/src/manifest/mcp-ref.ts +88 -0
  114. package/src/manifest/types.ts +33 -2
  115. package/src/manifest/validate.test.ts +20 -0
  116. package/src/mcp/docs-server.ts +1 -1
  117. package/src/mcp/server.ts +1 -1
  118. package/src/plugins/compression.test.ts +21 -0
  119. package/src/plugins/compression.ts +1 -0
  120. package/src/runtime/bun.ts +41 -4
  121. package/src/test/reset-element-registries.ts +2 -0
  122. package/src/console/ui-next/dist/assets/cache-glyph-CLPBqZeb.js +0 -1
  123. package/src/console/ui-next/dist/assets/flows-page-C_Tas1E1.js +0 -1
  124. package/src/console/ui-next/dist/assets/http-method-BJ92Z_ke.js +0 -1
  125. package/src/console/ui-next/dist/assets/observability-page-BwVS5Jvm.js +0 -4
  126. package/src/console/ui-next/dist/assets/trace-detail-sheet-D16lWQMt.js +0 -2
  127. package/src/console/ui-next/dist/assets/units-page-C5KOI7qG.js +0 -1
@@ -0,0 +1,178 @@
1
+ /**
2
+ * SQL-backed durable KV — `oke_kv` JSONB on the shared store.sql connection.
3
+ *
4
+ * Not a catalog `drivers.store.kv` id. Cache namespaces stay redis/memory.
5
+ */
6
+
7
+ import type { KvNamespace, SqlConnection } from "../../drivers/types.ts";
8
+ import { parseTtlMs } from "./cache.ts";
9
+
10
+ /** Engine-owned durable KV table — boot DDL, excluded from `oke db push`. */
11
+ export const OKE_KV_TABLE = "oke_kv";
12
+
13
+ /** Options for {@link openSqlKvNamespace}. */
14
+ export interface OpenSqlKvOptions {
15
+ /** Shared SQL connection (`sharedSqlConn`). */
16
+ readonly conn: SqlConnection;
17
+ /** `store.kv` namespace name. */
18
+ readonly namespace: string;
19
+ /** Injectable clock (epoch ms). */
20
+ readonly now: () => number;
21
+ }
22
+
23
+ /** SQL KV handle — {@link KvNamespace} plus {@link SqlKvNamespace.purgeExpired}. */
24
+ export interface SqlKvNamespace extends KvNamespace {
25
+ readonly driverId: "postgres" | "pglite" | "memory";
26
+ /**
27
+ * Delete rows whose `expires_at` has passed.
28
+ *
29
+ * @returns Rows removed
30
+ */
31
+ purgeExpired(): Promise<number>;
32
+ }
33
+
34
+ const ensured = new WeakSet<SqlConnection>();
35
+
36
+ /**
37
+ * Create `oke_kv` + the expires partial index (idempotent).
38
+ *
39
+ * @param conn - Shared SQL connection
40
+ */
41
+ export async function ensureOkeKvSchema(conn: SqlConnection): Promise<void> {
42
+ if (ensured.has(conn)) return;
43
+ await conn.exec(`CREATE TABLE IF NOT EXISTS ${OKE_KV_TABLE} (
44
+ namespace TEXT NOT NULL,
45
+ key TEXT NOT NULL,
46
+ value JSONB NOT NULL,
47
+ expires_at BIGINT,
48
+ updated_at BIGINT NOT NULL,
49
+ PRIMARY KEY (namespace, key)
50
+ )`);
51
+ await conn.exec(
52
+ `CREATE INDEX IF NOT EXISTS oke_kv_expires ON ${OKE_KV_TABLE} (expires_at) WHERE expires_at IS NOT NULL`,
53
+ );
54
+ ensured.add(conn);
55
+ }
56
+
57
+ /**
58
+ * Open a durable KV namespace on an already-open SQL connection.
59
+ *
60
+ * @param options - Connection / namespace / clock
61
+ */
62
+ export async function openSqlKvNamespace(options: OpenSqlKvOptions): Promise<SqlKvNamespace> {
63
+ const { conn, namespace, now } = options;
64
+ await ensureOkeKvSchema(conn);
65
+ await purgeExpiredOn(conn, now());
66
+
67
+ return {
68
+ driverId: conn.driverId,
69
+ async get(key) {
70
+ const rows = await conn.query(
71
+ `SELECT value FROM ${OKE_KV_TABLE}
72
+ WHERE namespace = ? AND key = ?
73
+ AND (expires_at IS NULL OR expires_at > ?)`,
74
+ [namespace, key, now()],
75
+ );
76
+ const raw = rows[0]?.value;
77
+ if (raw === undefined) return undefined;
78
+ return decodeJsonb(raw);
79
+ },
80
+ async set(key, value, ttl) {
81
+ await purgeExpiredOn(conn, now());
82
+ const t = now();
83
+ const expiresAt = ttlExpiresAt(ttl, t);
84
+ await conn.exec(
85
+ `INSERT INTO ${OKE_KV_TABLE} (namespace, key, value, expires_at, updated_at)
86
+ VALUES (?, ?, CAST(? AS JSONB), ?, ?)
87
+ ON CONFLICT (namespace, key) DO UPDATE SET
88
+ value = EXCLUDED.value,
89
+ expires_at = EXCLUDED.expires_at,
90
+ updated_at = EXCLUDED.updated_at`,
91
+ [namespace, key, JSON.stringify(value), expiresAt, t],
92
+ );
93
+ },
94
+ async delete(key) {
95
+ const result = await conn.exec(
96
+ `DELETE FROM ${OKE_KV_TABLE} WHERE namespace = ? AND key = ?`,
97
+ [namespace, key],
98
+ );
99
+ return result.changes > 0;
100
+ },
101
+ async list(prefix = "") {
102
+ const rows = await conn.query(
103
+ `SELECT key FROM ${OKE_KV_TABLE}
104
+ WHERE namespace = ?
105
+ AND (expires_at IS NULL OR expires_at > ?)
106
+ AND key LIKE ?
107
+ ORDER BY key`,
108
+ [namespace, now(), `${prefix}%`],
109
+ );
110
+ return rows.map((row) => String(row.key ?? ""));
111
+ },
112
+ async ttlMs(key) {
113
+ const rows = await conn.query(
114
+ `SELECT expires_at FROM ${OKE_KV_TABLE}
115
+ WHERE namespace = ? AND key = ?
116
+ AND (expires_at IS NULL OR expires_at > ?)`,
117
+ [namespace, key, now()],
118
+ );
119
+ const raw = rows[0]?.expires_at;
120
+ if (raw === undefined || raw === null) return null;
121
+ const at = Number(raw);
122
+ if (!Number.isFinite(at)) return null;
123
+ return Math.max(0, at - now());
124
+ },
125
+ async eval() {
126
+ throw new Error("oke store: durable store.kv does not support eval");
127
+ },
128
+ async close() {
129
+ /* Shared connection — StoreRuntime.close owns it. */
130
+ },
131
+ async purgeExpired() {
132
+ return purgeExpiredOn(conn, now());
133
+ },
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Decode a JSONB cell — PGlite/Postgres may return an object or a string.
139
+ *
140
+ * @param raw - Driver cell
141
+ */
142
+ function decodeJsonb(raw: unknown): unknown {
143
+ if (typeof raw === "string") {
144
+ try {
145
+ return JSON.parse(raw) as unknown;
146
+ } catch {
147
+ return raw;
148
+ }
149
+ }
150
+ return raw;
151
+ }
152
+
153
+ /**
154
+ * Absolute expiry from a TTL string, or `null` when omitted / unparsed.
155
+ *
156
+ * @param ttl - Duration like `"7d"`
157
+ * @param t - Now (epoch ms)
158
+ */
159
+ function ttlExpiresAt(ttl: string | undefined, t: number): number | null {
160
+ if (ttl === undefined) return null;
161
+ const ms = parseTtlMs(ttl);
162
+ if (ms <= 0) return null;
163
+ return t + ms;
164
+ }
165
+
166
+ /**
167
+ * Delete expired rows across every namespace on this connection.
168
+ *
169
+ * @param conn - Shared SQL connection
170
+ * @param t - Now (epoch ms)
171
+ */
172
+ async function purgeExpiredOn(conn: SqlConnection, t: number): Promise<number> {
173
+ const result = await conn.exec(
174
+ `DELETE FROM ${OKE_KV_TABLE} WHERE expires_at IS NOT NULL AND expires_at <= ?`,
175
+ [t],
176
+ );
177
+ return result.changes;
178
+ }
@@ -42,6 +42,7 @@ import type {
42
42
  SqlStoreDecl,
43
43
  StoreDecl,
44
44
  } from "./declare.ts";
45
+ import { openSqlKvNamespace } from "./kv-sql.ts";
45
46
  import { createGatedFilesStoreHandle, type GatedFilesFxBridge } from "./files-fx.ts";
46
47
  import {
47
48
  createFilesImagePipeline,
@@ -98,6 +99,11 @@ export interface CreateStoreRuntimeOptions {
98
99
  * Boot sets `off` for docker/prod and local+autoPush.
99
100
  */
100
101
  readonly domainDdl?: DomainDdlMode;
102
+ /**
103
+ * Shared SQL URL for durable KV (and apps with no `store.sql()` decl).
104
+ * Durable namespaces open via `sharedSqlConn`, never a second Redis.
105
+ */
106
+ readonly sqlUrl?: string;
101
107
  }
102
108
 
103
109
  /** Per-invocation context when opening a handle through the runtime. */
@@ -118,7 +124,7 @@ export type StoreHandle =
118
124
  /** KV handle on `fx.store`. */
119
125
  export interface KvStoreFxHandle {
120
126
  readonly ref: `kv:${string}`;
121
- readonly driverId: "memory" | "redis";
127
+ readonly driverId: "memory" | "redis" | "postgres" | "pglite";
122
128
  get(key: string): Promise<unknown>;
123
129
  set(key: string, value: unknown, ttl?: string): Promise<void>;
124
130
  delete(key: string): Promise<boolean>;
@@ -383,17 +389,37 @@ export function createStoreRuntime(options: CreateStoreRuntimeOptions): StoreRun
383
389
  return sharedSqlConn(sqlDriver, "primary", { url });
384
390
  }
385
391
 
392
+ /**
393
+ * Durable KV — JSONB table on the shared SQL connection.
394
+ *
395
+ * @param name - Namespace
396
+ */
397
+ async function openDurableKv(name: string): Promise<KvNamespace> {
398
+ const sqlDriver = options.drivers.sql;
399
+ if (!sqlDriver) {
400
+ throw new Error("oke store: durable store.kv needs a configured sql driver");
401
+ }
402
+ const firstSql = Object.values(options.sql ?? [])[0];
403
+ const url = options.sqlUrl ?? firstSql?.primary.url;
404
+ const conn = await sharedSqlConn(sqlDriver, "primary", { url });
405
+ return openSqlKvNamespace({ conn, namespace: name, now });
406
+ }
407
+
386
408
  async function openKv(decl: KvStoreDecl): Promise<KvStoreFxHandle> {
387
- const driver = options.drivers.kv;
388
- if (!driver) throw new Error("No kv driver configured");
389
409
  let ns = kvNs.get(decl.name);
390
410
  if (!ns) {
391
- const binding = options.kv?.[decl.name] ?? {};
392
- ns = await driver.open({
393
- name: decl.name,
394
- url: binding.url,
395
- client: binding.client as never,
396
- });
411
+ if (decl.durable === true) {
412
+ ns = await openDurableKv(decl.name);
413
+ } else {
414
+ const driver = options.drivers.kv;
415
+ if (!driver) throw new Error("No kv driver configured");
416
+ const binding = options.kv?.[decl.name] ?? {};
417
+ ns = await driver.open({
418
+ name: decl.name,
419
+ url: binding.url,
420
+ client: binding.client as never,
421
+ });
422
+ }
397
423
  kvNs.set(decl.name, ns);
398
424
  }
399
425
  const driverId = ns.driverId;
@@ -63,6 +63,8 @@ describe("store facets", () => {
63
63
  test("declares sql · kv · files · index with resource refs", () => {
64
64
  expect(store.sql("notes").ref).toBe("sql:notes");
65
65
  expect(store.kv("sessions").ref).toBe("kv:sessions");
66
+ expect(store.kv("ledger", { durable: true }).durable).toBe(true);
67
+ expect(store.kv("sessions").durable).toBeUndefined();
66
68
  expect(store.files("uploads").ref).toBe("files:uploads");
67
69
  expect(store.index("kb", { dims: 8 }).ref).toBe("index:kb");
68
70
  });
@@ -450,6 +450,23 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
450
450
  * @param keyRow - That version's live wrapped DEK
451
451
  */
452
452
  async function decryptRow(row: SecretRow, keyRow: KeyRow): Promise<string> {
453
+ try {
454
+ return await decryptRowOnce(row, keyRow);
455
+ } catch (err) {
456
+ // Stale key snapshot: rotation committed and dropped `pending` after
457
+ // this read. Reload the wrap and retry once — never return torn bytes.
458
+ if (!(err instanceof VaultError) || err.code !== "INVALID_KEY") throw err;
459
+ return await decryptRowOnce(row, await readKeyRow(row.id));
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Unwrap the DEK and decrypt the value ciphertext.
465
+ *
466
+ * @param row - Stored secret version
467
+ * @param keyRow - That version's wrapped DEK
468
+ */
469
+ async function decryptRowOnce(row: SecretRow, keyRow: KeyRow): Promise<string> {
453
470
  const path = row.path;
454
471
  const version = toInt(row.version);
455
472
  const algorithm = row.algorithm as VaultAlgorithm;
package/src/full.ts CHANGED
@@ -88,6 +88,8 @@ export {
88
88
  type SignalDecl,
89
89
  type SignalOptions,
90
90
  type SignalRuntime,
91
+ type DeadLetter,
92
+ type SignalFailureReason,
91
93
  } from "./elements/signal.ts";
92
94
 
93
95
  export {
package/src/index.ts CHANGED
@@ -71,6 +71,8 @@ export {
71
71
  type SignalDecl,
72
72
  type SignalOptions,
73
73
  type SignalRuntime,
74
+ type DeadLetter,
75
+ type SignalFailureReason,
74
76
  } from "./elements/signal.ts";
75
77
 
76
78
  export {
@@ -143,6 +145,8 @@ export {
143
145
  type AiModelDecl,
144
146
  type AiPromptDecl,
145
147
  type AiAgentDecl,
148
+ type AiMcpServerDecl,
149
+ type AiMcpServerOptions,
146
150
  type AiRuntime,
147
151
  } from "./elements/ai.ts";
148
152
 
package/src/kernel/app.ts CHANGED
@@ -37,10 +37,12 @@ import { runWithLocale } from "../i18n/locale-context.ts";
37
37
  import { runDurable } from "../elements/clock/durable.ts";
38
38
  import { isFlow, type AnyFlowDef } from "./flow.ts";
39
39
  import { failureFromUnknown, runCompensationPhase } from "./compensate.ts";
40
+ import { withAbortSignal } from "./abort-scope.ts";
40
41
  import { fxRetry } from "./concurrency.ts";
41
42
  import {
42
43
  createFxContext,
43
44
  freezePrincipal,
45
+ isJsonStreamResult,
44
46
  resolveName,
45
47
  type CreateFxOptions,
46
48
  type Fx,
@@ -85,6 +87,7 @@ import { listBindings, resetBindings, type Binding } from "./on.ts";
85
87
  import {
86
88
  aiAgentRegistry,
87
89
  aiEmbedRegistry,
90
+ aiMcpServerRegistry,
88
91
  aiModelRegistry,
89
92
  aiPromptRegistry,
90
93
  channelTemplateRegistry,
@@ -531,18 +534,21 @@ function mergeAiOptions(
531
534
  readonly prompts: NonNullable<BootOptions["ai"]>["prompts"];
532
535
  readonly embeds: NonNullable<BootOptions["ai"]>["embeds"];
533
536
  readonly agents: NonNullable<BootOptions["ai"]>["agents"];
537
+ readonly mcpServers: NonNullable<BootOptions["ai"]>["mcpServers"];
534
538
  },
535
539
  ): BootOptions["ai"] | undefined {
536
540
  const models = mergeUnique(explicit?.models, fromRegistry.models ?? []);
537
541
  const prompts = mergeUnique(explicit?.prompts, fromRegistry.prompts ?? []);
538
542
  const embeds = mergeUnique(explicit?.embeds, fromRegistry.embeds ?? []);
539
543
  const agents = mergeUnique(explicit?.agents, fromRegistry.agents ?? []);
544
+ const mcpServers = mergeUnique(explicit?.mcpServers, fromRegistry.mcpServers ?? []);
540
545
  if (
541
546
  explicit === undefined &&
542
547
  models.length === 0 &&
543
548
  prompts.length === 0 &&
544
549
  embeds.length === 0 &&
545
- agents.length === 0
550
+ agents.length === 0 &&
551
+ mcpServers.length === 0
546
552
  ) {
547
553
  return undefined;
548
554
  }
@@ -552,6 +558,7 @@ function mergeAiOptions(
552
558
  ...(prompts.length > 0 ? { prompts } : {}),
553
559
  ...(embeds.length > 0 ? { embeds } : {}),
554
560
  ...(agents.length > 0 ? { agents } : {}),
561
+ ...(mcpServers.length > 0 ? { mcpServers } : {}),
555
562
  };
556
563
  }
557
564
 
@@ -570,7 +577,7 @@ export function oke(options: OkeOptions): OkeApp {
570
577
  if (registry === "consume") resetBindings();
571
578
 
572
579
  // Same registry mode drains store.sql/store.files, vault.secret, signal(),
573
- // channel.<medium>().template(), and ai.model/prompt/embed/agent —
580
+ // channel.<medium>().template(), and ai.model/prompt/embed/agent/mcpServer
574
581
  // module-evaluation registries that mirror `on`'s trigger drain
575
582
  // (`listBindings`/`resetBindings` above). Explicit `options.stores` /
576
583
  // `secrets` / `signals` / `channel.templates` / `ai` are additive, never
@@ -588,6 +595,7 @@ export function oke(options: OkeOptions): OkeApp {
588
595
  aiPrompts: [],
589
596
  aiEmbeds: [],
590
597
  aiAgents: [],
598
+ aiMcpServers: [],
591
599
  }
592
600
  : {
593
601
  stores: storeRegistry.slice(),
@@ -599,6 +607,7 @@ export function oke(options: OkeOptions): OkeApp {
599
607
  aiPrompts: aiPromptRegistry.slice(),
600
608
  aiEmbeds: aiEmbedRegistry.slice(),
601
609
  aiAgents: aiAgentRegistry.slice(),
610
+ aiMcpServers: aiMcpServerRegistry.slice(),
602
611
  };
603
612
  if (registry === "consume") {
604
613
  storeRegistry.length = 0;
@@ -610,6 +619,7 @@ export function oke(options: OkeOptions): OkeApp {
610
619
  aiPromptRegistry.length = 0;
611
620
  aiEmbedRegistry.length = 0;
612
621
  aiAgentRegistry.length = 0;
622
+ aiMcpServerRegistry.length = 0;
613
623
  }
614
624
  const effectiveStores = mergeUnique(options.stores, registrySnapshot.stores);
615
625
  const effectiveSecrets = mergeUnique(options.secrets, registrySnapshot.secrets);
@@ -643,6 +653,7 @@ export function oke(options: OkeOptions): OkeApp {
643
653
  prompts: registrySnapshot.aiPrompts,
644
654
  embeds: registrySnapshot.aiEmbeds,
645
655
  agents: registrySnapshot.aiAgents,
656
+ mcpServers: registrySnapshot.aiMcpServers,
646
657
  });
647
658
 
648
659
  // Resolve Gate bag early so auth HTTP Bindings join `adopted` + the router
@@ -1149,16 +1160,19 @@ export function oke(options: OkeOptions): OkeApp {
1149
1160
  defaultLocale,
1150
1161
  );
1151
1162
 
1152
- return runWithLocale({ locale: resolvedLocale, defaultLocale }, () =>
1153
- executeInLocale({
1154
- flowDef,
1155
- input,
1156
- trigger,
1157
- extras,
1158
- resolvedLocale,
1159
- defaultLocale,
1160
- }),
1161
- );
1163
+ return runWithLocale({ locale: resolvedLocale, defaultLocale }, () => {
1164
+ const run = () =>
1165
+ executeInLocale({
1166
+ flowDef,
1167
+ input,
1168
+ trigger,
1169
+ extras,
1170
+ resolvedLocale,
1171
+ defaultLocale,
1172
+ });
1173
+ const signal = extras?.request?.signal;
1174
+ return signal ? withAbortSignal(signal, run) : run();
1175
+ });
1162
1176
  }
1163
1177
 
1164
1178
  async function executeInLocale(args: {
@@ -1451,7 +1465,7 @@ export function oke(options: OkeOptions): OkeApp {
1451
1465
  durable: flowDef.durable,
1452
1466
  effects: putEffects,
1453
1467
  });
1454
- if (storeAfter && output !== undefined) {
1468
+ if (storeAfter && output !== undefined && !isJsonStreamResult(output)) {
1455
1469
  const ttlMs =
1456
1470
  typeof flowDef.cache === "string" ? cache.parseTtlMs(flowDef.cache) : undefined;
1457
1471
  storeRt.putTier1(
@@ -1499,60 +1513,79 @@ export function oke(options: OkeOptions): OkeApp {
1499
1513
 
1500
1514
  const endedAt = now();
1501
1515
  const durationMs = resolveDurationMs(endedAt - startedAt, performance.now() - startedHr);
1516
+ const streamOut = isJsonStreamResult(result.output) ? result.output : undefined;
1517
+
1518
+ const finalizeRun = async (): Promise<void> => {
1519
+ // Stream rows land in Traces only after close. Measure wall time then
1520
+ // so duration / waterfall cover the open stream, not just TTFB.
1521
+ const recordEndedAt = streamOut ? now() : endedAt;
1522
+ const recordDurationMs = streamOut
1523
+ ? resolveDurationMs(recordEndedAt - startedAt, performance.now() - startedHr)
1524
+ : durationMs;
1525
+ if (journalSession) {
1526
+ inflightRuns.delete(journalSession.runId);
1527
+ const sleeping = ctx.state.sleeping as
1528
+ | { readonly wakeAt: number; readonly label: string; readonly runId: string }
1529
+ | undefined;
1530
+ // Lease-capable stores make the shared row the wake schedule; the
1531
+ // in-process map is only for custom stores without the lease surface.
1532
+ if (sleeping && !hasJournalLease(activeJournal().store)) {
1533
+ sleepingRuns.set(sleeping.runId, {
1534
+ flow: flowDef,
1535
+ input: ctx.input,
1536
+ wakeAt: sleeping.wakeAt,
1537
+ });
1538
+ } else if (!sleeping && isTerminalFailure(result)) {
1539
+ const terminalErr = result.failure ?? result.ctx.error;
1540
+ await runCompensationPhase({
1541
+ flow: flowDef,
1542
+ input: ctx.input,
1543
+ session: journalSession,
1544
+ fx,
1545
+ error: terminalErr,
1546
+ });
1547
+ } else if (!sleeping) {
1548
+ await journalSession.commit("completed", {
1549
+ output: streamOut ? { streamed: true } : result.output,
1550
+ });
1551
+ }
1552
+ }
1502
1553
 
1503
- if (journalSession) {
1504
- inflightRuns.delete(journalSession.runId);
1505
- const sleeping = ctx.state.sleeping as
1506
- | { readonly wakeAt: number; readonly label: string; readonly runId: string }
1507
- | undefined;
1508
- // Lease-capable stores make the shared row the wake schedule; the
1509
- // in-process map is only for custom stores without the lease surface.
1510
- if (sleeping && !hasJournalLease(activeJournal().store)) {
1511
- sleepingRuns.set(sleeping.runId, {
1512
- flow: flowDef,
1513
- input: ctx.input,
1514
- wakeAt: sleeping.wakeAt,
1515
- });
1516
- } else if (!sleeping && isTerminalFailure(result)) {
1517
- const terminalErr = result.failure ?? result.ctx.error;
1518
- await runCompensationPhase({
1519
- flow: flowDef,
1520
- input: ctx.input,
1521
- session: journalSession,
1522
- fx,
1523
- error: terminalErr,
1524
- });
1525
- } else if (!sleeping) {
1526
- await journalSession.commit("completed", { output: result.output });
1554
+ const runsRuntime =
1555
+ booted?.runs ?? (isRunsRuntimeLike(options.runs) ? options.runs : undefined);
1556
+ if (runsRuntime) {
1557
+ const archiveCleartext = archiveFromInput(ctx.input, options.archiveInputFields);
1558
+ const replayInput = redactArchivedFields(ctx.input, options.archiveInputFields);
1559
+ const recordFailure =
1560
+ result.failure ??
1561
+ (isTerminalFailure(result) ? failureFromUnknown(result.ctx.error) : undefined);
1562
+ await runsRuntime.record(
1563
+ {
1564
+ flow: flowDef,
1565
+ trigger,
1566
+ fx,
1567
+ ledger,
1568
+ telemetry,
1569
+ startedAt,
1570
+ endedAt: recordEndedAt,
1571
+ durationMs: recordDurationMs,
1572
+ failure: recordFailure,
1573
+ id: runId,
1574
+ input: replayInput,
1575
+ ...(result.output !== undefined && !recordFailure
1576
+ ? { output: streamOut ? { streamed: true } : result.output }
1577
+ : {}),
1578
+ ...(parentId !== undefined ? { parentId } : {}),
1579
+ },
1580
+ archiveCleartext,
1581
+ );
1527
1582
  }
1528
- }
1583
+ };
1529
1584
 
1530
- const runsRuntime =
1531
- booted?.runs ?? (isRunsRuntimeLike(options.runs) ? options.runs : undefined);
1532
- if (runsRuntime) {
1533
- const archiveCleartext = archiveFromInput(ctx.input, options.archiveInputFields);
1534
- const replayInput = redactArchivedFields(ctx.input, options.archiveInputFields);
1535
- const recordFailure =
1536
- result.failure ??
1537
- (isTerminalFailure(result) ? failureFromUnknown(result.ctx.error) : undefined);
1538
- await runsRuntime.record(
1539
- {
1540
- flow: flowDef,
1541
- trigger,
1542
- fx,
1543
- ledger,
1544
- telemetry,
1545
- startedAt,
1546
- endedAt,
1547
- durationMs,
1548
- failure: recordFailure,
1549
- id: runId,
1550
- input: replayInput,
1551
- ...(result.output !== undefined && !recordFailure ? { output: result.output } : {}),
1552
- ...(parentId !== undefined ? { parentId } : {}),
1553
- },
1554
- archiveCleartext,
1555
- );
1585
+ if (streamOut && !isTerminalFailure(result)) {
1586
+ streamOut.finalize = finalizeRun;
1587
+ } else {
1588
+ await finalizeRun();
1556
1589
  }
1557
1590
 
1558
1591
  return {
@@ -205,6 +205,10 @@ describe("oke() auto-registry — stores/secrets/signals/channel.templates/ai",
205
205
  const summarizeNote = smart.prompt("summarize-note", {
206
206
  version: 1,
207
207
  });
208
+ ai.mcpServer("github", {
209
+ url: "https://mcp.example/github",
210
+ tools: ["create_issue"],
211
+ });
208
212
 
209
213
  const ping = flow("ai.ping", {
210
214
  effects: { asks: ["summarize-note"] },
@@ -221,6 +225,7 @@ describe("oke() auto-registry — stores/secrets/signals/channel.templates/ai",
221
225
 
222
226
  expect(app.$options.ai?.models?.some((m) => m.name === "smart")).toBe(true);
223
227
  expect(app.$options.ai?.prompts?.some((p) => p.name === "summarize-note")).toBe(true);
228
+ expect(app.$options.ai?.mcpServers?.some((s) => s.name === "github")).toBe(true);
224
229
 
225
230
  const runtime = createAiRuntime({
226
231
  models: [smart],
@@ -18,6 +18,7 @@ import { openaiCompatibleAiDriver } from "../../drivers/ai-openai-compatible.ts"
18
18
  import type { AiDriver, AiOpenOptions } from "../../drivers/ai-types.ts";
19
19
  import { createAiRuntime, type AiRuntime } from "../../elements/ai.ts";
20
20
  import type { GateRuntime } from "../../elements/gate.ts";
21
+ import type { VaultRuntime } from "../../elements/vault.ts";
21
22
  import type { BootOptions } from "../boot.ts";
22
23
 
23
24
  /**
@@ -28,6 +29,7 @@ import type { BootOptions } from "../boot.ts";
28
29
  * @param now - Clock
29
30
  * @param env - Active environment
30
31
  * @param docker - Prefer compose AI URL when active
32
+ * @param vault - Optional vault for MCP bearer secrets
31
33
  */
32
34
  export function bindAi(
33
35
  options: BootOptions,
@@ -35,13 +37,21 @@ export function bindAi(
35
37
  now: () => number,
36
38
  env: ConfigEnv = "test",
37
39
  docker = false,
40
+ vault?: VaultRuntime,
38
41
  ): AiRuntime {
39
42
  const id = resolveAiDriverId(options, env, docker);
40
43
  const driver =
41
44
  options.ai?.defaultDriver ?? withOpenDefaults(aiDriverFor(id), openDefaultsFor(id, docker));
42
45
  return createAiRuntime({
43
46
  ...(options.ai ?? {}),
47
+ ...(vault ? { resolveSecret: (name: string) => vault.read(name) } : undefined),
44
48
  defaultDriver: driver,
49
+ drivers: {
50
+ mock: lazyDriver("mock", docker),
51
+ anthropic: lazyDriver("anthropic", docker),
52
+ "openai-compatible": lazyDriver("openai-compatible", docker),
53
+ ollama: lazyDriver("ollama", docker),
54
+ },
45
55
  gates: options.ai?.gates ?? gate,
46
56
  now,
47
57
  });
@@ -156,6 +166,20 @@ export function aiUrlFor(docker = false): string {
156
166
  * @param driver - Base driver
157
167
  * @param defaults - Env-derived open options
158
168
  */
169
+ /**
170
+ * Open a protocol driver with env defaults only when a model actually uses it.
171
+ *
172
+ * @param id - Driver id
173
+ * @param docker - Docker mode
174
+ */
175
+ function lazyDriver(id: string, docker: boolean): AiDriver {
176
+ const base = aiDriverFor(id);
177
+ return {
178
+ id: base.id,
179
+ open: (opts = {}) => base.open({ ...openDefaultsFor(id, docker), ...opts }),
180
+ };
181
+ }
182
+
159
183
  function withOpenDefaults(driver: AiDriver, defaults: AiOpenOptions): AiDriver {
160
184
  if (Object.keys(defaults).length === 0) return driver;
161
185
  return {
@@ -124,5 +124,7 @@ export async function bindClock(
124
124
  }
125
125
  }
126
126
 
127
+ clock.startWakes();
128
+
127
129
  return { clock, clockDecls };
128
130
  }