okengine 0.13.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 (105) 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 +1 -1
  9. package/site/content/docs/get-started/installation.mdx +2 -2
  10. package/site/content/docs/reference/fx.mdx +27 -22
  11. package/src/cli/ai-setup/recommend.test.ts +25 -0
  12. package/src/cli/ai-setup/recommend.ts +8 -3
  13. package/src/compiler/effects-infer.ts +58 -3
  14. package/src/compiler/extract.test.ts +54 -1
  15. package/src/compiler/extract.ts +69 -2
  16. package/src/compiler/response.ts +45 -1
  17. package/src/console/server/ai.ts +5 -2
  18. package/src/console/server/flows.ts +1 -0
  19. package/src/console/server/serve.ts +17 -2
  20. package/src/console/ui-next/dist/assets/cache-glyph-BanhLsEY.js +1 -0
  21. package/src/console/ui-next/dist/assets/flows-page-DxDsOd4f.js +1 -0
  22. package/src/console/ui-next/dist/assets/http-method-CJCBYL2j.js +1 -0
  23. package/src/console/ui-next/dist/assets/{index-Bp-R7jtM.js → index-Ce6WKWKM.js} +3 -3
  24. package/src/console/ui-next/dist/assets/observability-page-BEZDzyYh.js +4 -0
  25. package/src/console/ui-next/dist/assets/trace-detail-sheet-DFLFfUUX.js +2 -0
  26. package/src/console/ui-next/dist/assets/units-page-C0gW6Kdo.js +1 -0
  27. package/src/console/ui-next/dist/assets/{vault-page-Ca-MvcmJ.js → vault-page-DuKzqwzW.js} +1 -1
  28. package/src/console/ui-next/dist/index.html +1 -1
  29. package/src/console/ui-next/seed-invoke-host.ts +2 -0
  30. package/src/console/ui-next/src/features/flows/graph/build-flow-graph.test.ts +18 -0
  31. package/src/console/ui-next/src/features/flows/graph/build-flow-graph.ts +14 -1
  32. package/src/console/ui-next/src/features/flows/graph/neighborhood.test.ts +17 -0
  33. package/src/console/ui-next/src/features/flows/graph/neighborhood.ts +16 -3
  34. package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +3 -1
  35. package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
  36. package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +9 -3
  37. package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +10 -1
  38. package/src/console/ui-next/src/features/observability/lib/ask-count.test.ts +25 -0
  39. package/src/console/ui-next/src/features/observability/lib/ask-count.ts +4 -1
  40. package/src/console/ui-next/src/features/units/detail/effects-summary.tsx +12 -4
  41. package/src/docker/docker.test.ts +1 -1
  42. package/src/docker/dockerfile.ts +1 -1
  43. package/src/drivers/ai-anthropic.ts +5 -0
  44. package/src/drivers/ai-ollama.ts +49 -30
  45. package/src/drivers/ai-openai-compatible.ts +57 -46
  46. package/src/drivers/ai-providers.test.ts +3 -0
  47. package/src/drivers/bun-native-completeness.test.ts +7 -9
  48. package/src/drivers/redis.ts +11 -4
  49. package/src/drivers/signal-redis.ts +24 -14
  50. package/src/drivers/signal-types.ts +2 -1
  51. package/src/elements/ai/declare.ts +109 -0
  52. package/src/elements/ai/errors.test.ts +5 -1
  53. package/src/elements/ai/errors.ts +30 -2
  54. package/src/elements/ai/eval.ts +4 -6
  55. package/src/elements/ai/mcp-client.test.ts +206 -0
  56. package/src/elements/ai/mcp-client.ts +362 -0
  57. package/src/elements/ai/mcp-http.ts +159 -0
  58. package/src/elements/ai/mcp-mock.ts +134 -0
  59. package/src/elements/ai/mcp-protocol.ts +234 -0
  60. package/src/elements/ai/mcp-stdio.test.ts +50 -0
  61. package/src/elements/ai/mcp-stdio.ts +212 -0
  62. package/src/elements/ai/mcp-transport.ts +70 -0
  63. package/src/elements/ai/runtime.ts +159 -29
  64. package/src/elements/ai.test.ts +139 -0
  65. package/src/elements/ai.ts +16 -0
  66. package/src/elements/clock/health.test.ts +43 -0
  67. package/src/elements/clock/runtime.ts +55 -2
  68. package/src/elements/clock/schedule.ts +71 -142
  69. package/src/elements/clock.test.ts +9 -40
  70. package/src/elements/clock.ts +6 -1
  71. package/src/elements/index.ts +2 -0
  72. package/src/elements/signal/declare.ts +2 -1
  73. package/src/elements/signal/runtime.ts +16 -1
  74. package/src/elements/signal.ts +1 -0
  75. package/src/elements/store/cache.test.ts +2 -0
  76. package/src/elements/store/cache.ts +3 -3
  77. package/src/elements/vault/builtin-adapter.ts +17 -0
  78. package/src/full.ts +2 -0
  79. package/src/index.ts +4 -0
  80. package/src/kernel/app.ts +97 -64
  81. package/src/kernel/auto-registry.test.ts +5 -0
  82. package/src/kernel/boot-bind/ai.ts +24 -0
  83. package/src/kernel/boot-bind/clock.ts +2 -0
  84. package/src/kernel/boot.ts +3 -1
  85. package/src/kernel/element-registries.ts +4 -1
  86. package/src/kernel/fx-dead-letters.test.ts +77 -0
  87. package/src/kernel/fx.test.ts +22 -0
  88. package/src/kernel/fx.ts +112 -6
  89. package/src/kernel/http-stream.test.ts +174 -0
  90. package/src/kernel/index.ts +2 -0
  91. package/src/manifest/mcp-ref.ts +88 -0
  92. package/src/manifest/types.ts +28 -2
  93. package/src/manifest/validate.test.ts +20 -0
  94. package/src/mcp/docs-server.ts +1 -1
  95. package/src/mcp/server.ts +1 -1
  96. package/src/plugins/compression.test.ts +21 -0
  97. package/src/plugins/compression.ts +1 -0
  98. package/src/runtime/bun.ts +41 -4
  99. package/src/test/reset-element-registries.ts +2 -0
  100. package/src/console/ui-next/dist/assets/cache-glyph-CLPBqZeb.js +0 -1
  101. package/src/console/ui-next/dist/assets/flows-page-Dg8CTE29.js +0 -1
  102. package/src/console/ui-next/dist/assets/http-method-BJ92Z_ke.js +0 -1
  103. package/src/console/ui-next/dist/assets/observability-page-DAnpEaq1.js +0 -4
  104. package/src/console/ui-next/dist/assets/trace-detail-sheet-D16lWQMt.js +0 -2
  105. package/src/console/ui-next/dist/assets/units-page-BtQ0bqMe.js +0 -1
@@ -176,6 +176,8 @@ export type {
176
176
  AiModelDecl,
177
177
  AiPromptDecl,
178
178
  AiAgentDecl,
179
+ AiMcpServerDecl,
180
+ AiMcpServerOptions,
179
181
  AiEmbedDecl,
180
182
  AiRuntime,
181
183
  AgentDenial,
@@ -34,7 +34,8 @@ export interface SignalOptions {
34
34
  }
35
35
 
36
36
  /**
37
- * Declared signal handle — usable as `on(signal, flow)` and `fx.emit(signal, …)`.
37
+ * Declared signal handle — usable as `on(signal, flow)`, `fx.emit(signal, …)`,
38
+ * and `fx.deadLetters(signal)`.
38
39
  */
39
40
  export interface SignalDecl<T = unknown> {
40
41
  /** Signal name (manifest key). */
@@ -3,7 +3,12 @@
3
3
  * emit / subscribe / live / drain for `fx` and tests.
4
4
  */
5
5
 
6
- import type { SignalBus, SignalDriver, SignalEmitOptions } from "../../drivers/signal-types.ts";
6
+ import type {
7
+ DeadLetter,
8
+ SignalBus,
9
+ SignalDriver,
10
+ SignalEmitOptions,
11
+ } from "../../drivers/signal-types.ts";
7
12
  import type { SignalDecl } from "./declare.ts";
8
13
 
9
14
  /** Options for {@link createSignalRuntime}. */
@@ -48,6 +53,12 @@ export interface SignalRuntime {
48
53
  * @param options - Optional emit options (`key` for per-key once ordering)
49
54
  */
50
55
  emit(name: string, payload?: unknown, options?: SignalEmitOptions): Promise<void>;
56
+ /**
57
+ * Dead-lettered messages for one signal (auto-starts).
58
+ *
59
+ * @param name - Signal name
60
+ */
61
+ deadLetters(name: string): Promise<readonly DeadLetter[]>;
51
62
  /** Close the bus. */
52
63
  close(): Promise<void>;
53
64
  }
@@ -87,6 +98,10 @@ export function createSignalRuntime(options: CreateSignalRuntimeOptions): Signal
87
98
  const b = await this.start();
88
99
  await b.emit(name, payload, options);
89
100
  },
101
+ async deadLetters(name) {
102
+ const b = await this.start();
103
+ return b.deadLetters(name);
104
+ },
90
105
  async close() {
91
106
  if (bus) {
92
107
  await bus.close();
@@ -8,6 +8,7 @@
8
8
 
9
9
  export { signal } from "./signal/declare.ts";
10
10
  export type { SignalDecl, SignalOptions } from "./signal/declare.ts";
11
+ export type { DeadLetter, SignalFailureReason } from "../drivers/signal-types.ts";
11
12
 
12
13
  export { createSignalRuntime } from "./signal/runtime.ts";
13
14
  export type { CreateSignalRuntimeOptions, SignalRuntime } from "./signal/runtime.ts";
@@ -92,6 +92,7 @@ describe("autoCacheEligible", () => {
92
92
  false,
93
93
  );
94
94
  expect(autoCacheEligible({ effects: { reads: ["runs"] } })).toBe(false);
95
+ expect(autoCacheEligible({ effects: { reads: ["signal:notify"] } })).toBe(false);
95
96
  expect(autoCacheEligible({ effects: {} })).toBe(false);
96
97
  });
97
98
 
@@ -112,6 +113,7 @@ describe("autoCacheEligible", () => {
112
113
  { kind: "read", resource: "sql:views" },
113
114
  { kind: "read", resource: "sql:views" },
114
115
  { kind: "read", resource: "runs" },
116
+ { kind: "read", resource: "signal:notify" },
115
117
  { kind: "write", resource: "sql:views" },
116
118
  { kind: "ask", resource: "summarize" },
117
119
  { kind: "emit", resource: "view-changed" },
@@ -53,7 +53,7 @@ export function tier1KeysForReads(
53
53
  effects: Effects,
54
54
  dimsByResource?: Readonly<Record<string, readonly string[]>>,
55
55
  ): string[] {
56
- const reads = (effects.reads ?? []).filter((r): r is ResourceRef => r !== "runs");
56
+ const reads = (effects.reads ?? []).filter((r): r is ResourceRef => isStoreResourceRef(r));
57
57
  return reads.map((resource) => computedCacheKey(resource, dimsByResource?.[resource]));
58
58
  }
59
59
 
@@ -223,7 +223,7 @@ export function resolveCacheEffects(
223
223
  declared: Effects | undefined,
224
224
  learnedReads: readonly ResourceRef[] | undefined,
225
225
  ): Effects {
226
- const declaredReads = (declared?.reads ?? []).filter((r) => r !== "runs");
226
+ const declaredReads = (declared?.reads ?? []).filter((r) => isStoreResourceRef(r));
227
227
  if (declaredReads.length > 0 || (declared?.writes?.length ?? 0) > 0) {
228
228
  return declared ?? {};
229
229
  }
@@ -275,7 +275,7 @@ export function autoCacheEligible(options: {
275
275
  const effects = options.effects ?? {};
276
276
  if ((effects.asks?.length ?? 0) > 0) return false;
277
277
  if ((effects.writes?.length ?? 0) > 0) return false;
278
- const reads = (effects.reads ?? []).filter((r) => r !== "runs");
278
+ const reads = (effects.reads ?? []).filter((r) => isStoreResourceRef(r));
279
279
  return reads.length > 0;
280
280
  }
281
281
 
@@ -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
  }
@@ -550,7 +550,7 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
550
550
  // 6. AI
551
551
  let ai = pre.ai;
552
552
  if (needs.ai && !ai) {
553
- ai = aiBind!.bindAi(options, gate, now, env, docker);
553
+ ai = aiBind!.bindAi(options, gate, now, env, docker, vault);
554
554
  }
555
555
 
556
556
  // 7. Runs
@@ -607,12 +607,14 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
607
607
  clearInterval(schedulerTimer);
608
608
  schedulerTimer = undefined;
609
609
  }
610
+ clock?.stopWakes();
610
611
  },
611
612
  async close() {
612
613
  if (schedulerTimer !== undefined) {
613
614
  clearInterval(schedulerTimer);
614
615
  schedulerTimer = undefined;
615
616
  }
617
+ clock?.stopWakes();
616
618
  await signal?.close();
617
619
  await vault?.close();
618
620
  await runs?.flush();
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Module-evaluation registries for `store.sql` / `store.files`, `vault.secret`,
3
3
  * `vault.env.required`, `signal()`, `channel.<medium>().template()`, and
4
- * `ai.model` / `.prompt` / `ai.embed` / `ai.agent` — the plain arrays behind
4
+ * `ai.model` / `.prompt` / `ai.embed` / `ai.agent` / `ai.mcpServer` — the plain arrays behind
5
5
  * each declare module's `listX()` / `resetX()` pair (mirrors `on.ts`'s
6
6
  * trigger-drain `bindings` array).
7
7
  *
@@ -16,6 +16,7 @@
16
16
  import type {
17
17
  AiAgentDecl,
18
18
  AiEmbedDecl,
19
+ AiMcpServerDecl,
19
20
  AiModelDecl,
20
21
  AiPromptDecl,
21
22
  } from "../elements/ai/declare.ts";
@@ -42,3 +43,5 @@ export const aiPromptRegistry: AiPromptDecl[] = [];
42
43
  export const aiEmbedRegistry: AiEmbedDecl[] = [];
43
44
  /** `ai.agent` declarations since the last reset. */
44
45
  export const aiAgentRegistry: AiAgentDecl[] = [];
46
+ /** `ai.mcpServer` declarations since the last reset. */
47
+ export const aiMcpServerRegistry: AiMcpServerDecl[] = [];
@@ -0,0 +1,77 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { memorySignalDriver } from "../drivers/index.ts";
3
+ import { createSignalRuntime, signal, type SignalDecl } from "../elements/signal.ts";
4
+ import { OkeError } from "./errors.ts";
5
+ import { createFx, createFxContext, signalReadRef } from "./fx.ts";
6
+
7
+ describe("fx.deadLetters", () => {
8
+ test("returns dead-lettered messages for a declared signal read", async () => {
9
+ const notify = signal("notify", { delivery: "once", retries: 0, deadLetter: true });
10
+ const runtime = openRuntime(notify);
11
+ const bus = await runtime.start();
12
+ await bus.subscribe("notify", "c1", async () => {
13
+ throw new Error("smtp-down");
14
+ });
15
+ await runtime.emit("notify", { orderId: "ord_1" });
16
+ await bus.drain();
17
+
18
+ const { fx, ledger } = createFxContext({
19
+ flow: "notifications.failed",
20
+ effects: { reads: [signalReadRef("notify")] },
21
+ signalRuntime: runtime,
22
+ });
23
+
24
+ const dead = await fx.deadLetters(notify);
25
+ expect(dead).toHaveLength(1);
26
+ expect(dead[0]!.payload).toEqual({ orderId: "ord_1" });
27
+ expect(dead[0]!.status).toBe("dead");
28
+ expect(dead[0]!.attempts).toBe(1);
29
+ expect(dead[0]!.failures[0]!.message).toBe("smtp-down");
30
+ expect(ledger.entries.map((e) => `${e.kind}:${e.resource}`)).toEqual(["read:signal:notify"]);
31
+
32
+ await runtime.close();
33
+ });
34
+
35
+ test("undeclared and cross-signal reads throw OKE1001", async () => {
36
+ const notify = signal("notify", { delivery: "once", retries: 0, deadLetter: true });
37
+ const other = signal("other", { delivery: "once", retries: 0, deadLetter: true });
38
+ const runtime = openRuntime(notify);
39
+ const fx = createFx({
40
+ flow: "notifications.failed",
41
+ effects: { reads: [signalReadRef("notify")] },
42
+ signalRuntime: runtime,
43
+ });
44
+
45
+ let err: unknown;
46
+ try {
47
+ await fx.deadLetters(other);
48
+ } catch (e) {
49
+ err = e;
50
+ }
51
+ expect(err).toBeInstanceOf(OkeError);
52
+ const oke = err as OkeError;
53
+ expect(oke.code).toBe(1001);
54
+ expect(oke.causeText).toBe(
55
+ 'Flow "notifications.failed" reads "signal:other" without declaring it.',
56
+ );
57
+ expect(oke.fix).toBe('Add "signal:other" to this flow\'s effects.reads.');
58
+
59
+ await runtime.close();
60
+ });
61
+
62
+ test("throws when no signal runtime is bound", async () => {
63
+ const fx = createFx({
64
+ flow: "notifications.failed",
65
+ effects: { reads: [signalReadRef("notify")] },
66
+ });
67
+ await expect(fx.deadLetters("notify")).rejects.toThrow(
68
+ "fx.deadLetters requires a bound signal runtime",
69
+ );
70
+ });
71
+ });
72
+
73
+ function openRuntime(decl: SignalDecl) {
74
+ const runtime = createSignalRuntime({ driver: memorySignalDriver });
75
+ runtime.register(decl);
76
+ return runtime;
77
+ }