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
@@ -3,11 +3,12 @@
3
3
  */
4
4
 
5
5
  import { afterEach, describe, expect, test } from "bun:test";
6
- import { files } from "../../elements/store.ts";
6
+ import { files, store } from "../../elements/store.ts";
7
7
  import {
8
8
  bindStore,
9
9
  indexDriverFor,
10
10
  resetFilesFsWarnForTests,
11
+ resetKvDurableWarnsForTests,
11
12
  resolveFilesDriverId,
12
13
  resolveIndexDriverId,
13
14
  resolveKvDriverId,
@@ -98,6 +99,154 @@ describe("bindStore driver resolution", () => {
98
99
  });
99
100
  });
100
101
 
102
+ describe("bindStore durable KV routing", () => {
103
+ const prev = {
104
+ redis: process.env.REDIS_URL,
105
+ kv: process.env.OKE_STORE_KV_URL,
106
+ database: process.env.DATABASE_URL,
107
+ sql: process.env.OKE_STORE_SQL_URL,
108
+ };
109
+
110
+ afterEach(() => {
111
+ resetKvDurableWarnsForTests();
112
+ if (prev.redis === undefined) delete process.env.REDIS_URL;
113
+ else process.env.REDIS_URL = prev.redis;
114
+ if (prev.kv === undefined) delete process.env.OKE_STORE_KV_URL;
115
+ else process.env.OKE_STORE_KV_URL = prev.kv;
116
+ if (prev.database === undefined) delete process.env.DATABASE_URL;
117
+ else process.env.DATABASE_URL = prev.database;
118
+ if (prev.sql === undefined) delete process.env.OKE_STORE_SQL_URL;
119
+ else process.env.OKE_STORE_SQL_URL = prev.sql;
120
+ });
121
+
122
+ test("pglite + durable in test does not throw or warn", () => {
123
+ const warnings: string[] = [];
124
+ const prevWarn = console.warn;
125
+ console.warn = (...args: unknown[]) => {
126
+ warnings.push(args.map(String).join(" "));
127
+ };
128
+ try {
129
+ bindStore(
130
+ { stores: [store.kv("ledger", { durable: true })] },
131
+ "test",
132
+ () => Date.now(),
133
+ false,
134
+ );
135
+ expect(warnings.some((w) => w.includes("durable"))).toBe(false);
136
+ } finally {
137
+ console.warn = prevWarn;
138
+ }
139
+ });
140
+
141
+ test("postgres + durable without DATABASE_URL throws outside test", () => {
142
+ delete process.env.DATABASE_URL;
143
+ delete process.env.OKE_STORE_SQL_URL;
144
+ process.env.REDIS_URL = "redis://cache";
145
+ expect(() =>
146
+ bindStore(
147
+ {
148
+ stores: [store.kv("ledger", { durable: true })],
149
+ config: {
150
+ drivers: {
151
+ store: {
152
+ sql: { dev: "postgres", test: "pglite", prod: "postgres" },
153
+ kv: { dev: "redis", test: "memory", prod: "redis" },
154
+ },
155
+ },
156
+ },
157
+ },
158
+ "dev",
159
+ () => Date.now(),
160
+ false,
161
+ ),
162
+ ).toThrow(/DATABASE_URL/);
163
+ });
164
+
165
+ test("redis + cache-shaped KV warns once outside test", () => {
166
+ process.env.REDIS_URL = "redis://cache";
167
+ const warnings: string[] = [];
168
+ const prevWarn = console.warn;
169
+ console.warn = (...args: unknown[]) => {
170
+ warnings.push(args.map(String).join(" "));
171
+ };
172
+ try {
173
+ const options = {
174
+ stores: [store.kv("sessions")],
175
+ config: {
176
+ drivers: {
177
+ store: {
178
+ sql: { dev: "memory", test: "memory", prod: "memory" },
179
+ kv: { dev: "redis", test: "memory", prod: "redis" },
180
+ },
181
+ },
182
+ },
183
+ };
184
+ bindStore(options, "dev", () => Date.now(), false);
185
+ bindStore(options, "dev", () => Date.now(), false);
186
+ expect(warnings.filter((w) => w.includes("cache-shaped"))).toHaveLength(1);
187
+ } finally {
188
+ console.warn = prevWarn;
189
+ }
190
+ });
191
+
192
+ test("non-durable KV keeps REDIS_URL; durable does not", async () => {
193
+ process.env.REDIS_URL = "redis://cache";
194
+ const sessions = store.kv("sessions");
195
+ const ledger = store.kv("ledger", { durable: true });
196
+ const runtime = bindStore(
197
+ {
198
+ stores: [sessions, ledger],
199
+ config: {
200
+ drivers: {
201
+ store: {
202
+ sql: { test: "pglite" },
203
+ kv: { test: "memory" },
204
+ },
205
+ },
206
+ },
207
+ },
208
+ "test",
209
+ () => Date.now(),
210
+ false,
211
+ );
212
+ const ctx = { effects: {} };
213
+ const cache = await runtime.open(sessions, ctx);
214
+ const durable = await runtime.open(ledger, ctx);
215
+ expect(cache.driverId).toBe("memory");
216
+ expect(durable.driverId).toBe("pglite");
217
+ await runtime.close();
218
+ });
219
+
220
+ test("memory SQL + durable in dev warns that memory cannot honor durability", () => {
221
+ const warnings: string[] = [];
222
+ const prevWarn = console.warn;
223
+ console.warn = (...args: unknown[]) => {
224
+ warnings.push(args.map(String).join(" "));
225
+ };
226
+ try {
227
+ bindStore(
228
+ {
229
+ stores: [store.kv("ledger", { durable: true })],
230
+ config: {
231
+ drivers: {
232
+ store: {
233
+ sql: { dev: "memory", test: "memory", prod: "memory" },
234
+ kv: { dev: "memory", test: "memory", prod: "redis" },
235
+ },
236
+ },
237
+ },
238
+ },
239
+ "dev",
240
+ () => Date.now(),
241
+ false,
242
+ );
243
+ expect(warnings.some((w) => w.includes("memory") && w.includes("durable"))).toBe(true);
244
+ } finally {
245
+ console.warn = prevWarn;
246
+ }
247
+ });
248
+ });
249
+
101
250
  describe("bindStore files fs multi-instance warn", () => {
102
251
  afterEach(() => {
103
252
  resetFilesFsWarnForTests();
@@ -34,12 +34,20 @@ import type { BootOptions } from "../boot.ts"; // type-only — no cycle at runt
34
34
  * @param docker - Prefer compose URLs when opening postgres/redis
35
35
  */
36
36
  let filesFsWarned = false;
37
+ let kvCacheShapedWarned = false;
38
+ let kvMemoryDurableWarned = false;
37
39
 
38
40
  /** Test helper — reset the one-shot `fs` multi-instance warn. */
39
41
  export function resetFilesFsWarnForTests(): void {
40
42
  filesFsWarned = false;
41
43
  }
42
44
 
45
+ /** Test helper — reset durable-KV boot warns. */
46
+ export function resetKvDurableWarnsForTests(): void {
47
+ kvCacheShapedWarned = false;
48
+ kvMemoryDurableWarned = false;
49
+ }
50
+
43
51
  /**
44
52
  * Warn once when `drivers.store.files` is `fs` — per-process (or per-pod)
45
53
  * filesystem visibility under horizontal scale.
@@ -54,6 +62,39 @@ function warnFilesFsMultiInstance(): void {
54
62
  );
55
63
  }
56
64
 
65
+ /**
66
+ * Warn once: default KV namespaces are cache-shaped (no AOF) unless
67
+ * `{ durable: true }` persists them in SQL (`oke_kv`).
68
+ */
69
+ function warnKvCacheShaped(names: readonly string[]): void {
70
+ if (kvCacheShapedWarned) return;
71
+ kvCacheShapedWarned = true;
72
+ const listed = names
73
+ .slice(0, 4)
74
+ .map((n) => `"${n}"`)
75
+ .join(", ");
76
+ const extra = names.length > 4 ? ` (and ${String(names.length - 4)} more)` : "";
77
+ emitBootWarn(
78
+ `oke boot: store.kv ${listed}${extra} is cache-shaped — a Redis recreate drops keys ` +
79
+ "(no AOF). Declare `{ durable: true }` on a separate namespace to persist in " +
80
+ "your SQL database (`oke_kv`).",
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Warn once: in-process memory SQL cannot honor `{ durable: true }`.
86
+ */
87
+ function warnKvMemoryDurable(names: readonly string[]): void {
88
+ if (kvMemoryDurableWarned) return;
89
+ kvMemoryDurableWarned = true;
90
+ const listed = names.map((n) => `"${n}"`).join(", ");
91
+ emitBootWarn(
92
+ `oke boot: store.kv ${listed} declared { durable: true } but drivers.store.sql is ` +
93
+ '"memory" — keys are process-local and vanish on restart. Use drivers.store.sql ' +
94
+ "postgres with DATABASE_URL.",
95
+ );
96
+ }
97
+
57
98
  export function bindStore(
58
99
  options: BootOptions,
59
100
  env: ConfigEnv,
@@ -65,9 +106,28 @@ export function bindStore(
65
106
  const filesId = resolveFilesDriverId(options, env, docker);
66
107
  const indexId = resolveIndexDriverId(options, env, docker);
67
108
  if (filesId === "fs") warnFilesFsMultiInstance();
109
+ const kvDecls = (options.stores ?? []).filter(isKvDecl);
110
+ const durableKvDecls = kvDecls.filter((d) => d.durable === true);
111
+ const cacheKvDecls = kvDecls.filter((d) => d.durable !== true);
112
+ if (durableKvDecls.length > 0 && sqlId === "postgres") {
113
+ const url = process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL;
114
+ if (!url) {
115
+ throw new Error(
116
+ docker
117
+ ? "oke boot: durable store.kv needs DATABASE_URL (did `oke dev` write .env.local?)"
118
+ : "oke boot: durable store.kv needs DATABASE_URL",
119
+ );
120
+ }
121
+ }
68
122
  const sqlUrl = sqlUrlFor(sqlId, docker, env);
69
123
  const kvUrl = kvUrlFor(kvId, docker);
70
124
  const filesRoot = filesRootFor(filesId);
125
+ if (kvId === "redis" && env !== "test" && cacheKvDecls.length > 0) {
126
+ warnKvCacheShaped(cacheKvDecls.map((d) => d.name));
127
+ }
128
+ if (sqlId === "memory" && env !== "test" && durableKvDecls.length > 0) {
129
+ warnKvMemoryDurable(durableKvDecls.map((d) => d.name));
130
+ }
71
131
 
72
132
  const sqlBindings: Record<string, { name: string; primary: { url: string } }> = {};
73
133
  const kvBindings: Record<string, { url?: string }> = {};
@@ -81,7 +141,11 @@ export function bindStore(
81
141
  primary: { url: sqlUrl },
82
142
  };
83
143
  } else if (isKvDecl(decl)) {
84
- kvBindings[decl.name] = kvUrl !== undefined ? { url: kvUrl } : {};
144
+ if (decl.durable === true) {
145
+ kvBindings[decl.name] = {};
146
+ } else {
147
+ kvBindings[decl.name] = kvUrl !== undefined ? { url: kvUrl } : {};
148
+ }
85
149
  } else if (isFilesDecl(decl)) {
86
150
  filesBindings[decl.name] = filesRoot !== undefined ? { root: filesRoot } : {};
87
151
  } else if (isIndexDecl(decl)) {
@@ -114,6 +178,7 @@ export function bindStore(
114
178
  index: indexBindings,
115
179
  now,
116
180
  domainDdl,
181
+ sqlUrl,
117
182
  });
118
183
  for (const decl of options.stores ?? []) {
119
184
  store.register?.(decl);
@@ -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
+ }
@@ -361,11 +361,17 @@ describe("fx — wholesale swap", () => {
361
361
  async emit(signal) {
362
362
  calls.push(`emit:${typeof signal === "string" ? signal : signal.name}`);
363
363
  },
364
+ async deadLetters() {
365
+ return [];
366
+ },
364
367
  async call() {
365
368
  return null;
366
369
  },
367
370
  clock: {
368
371
  now: () => 0,
372
+ ago: () => 0,
373
+ fromNow: () => 0,
374
+ duration: () => 0,
369
375
  sleep: async () => undefined,
370
376
  },
371
377
  vault: {
@@ -451,6 +457,9 @@ describe("fx — wholesale swap", () => {
451
457
  withQuery(rows) {
452
458
  return { [jsonResultBrand]: true, status: 200, value: [...rows], meta: {} };
453
459
  },
460
+ stream(chunks) {
461
+ return { [jsonResultBrand]: true, kind: "stream" as const, status: 200 as const, chunks };
462
+ },
454
463
  },
455
464
  async step(_name, fn) {
456
465
  return fn();
@@ -514,6 +523,19 @@ describe("fx.t — catalogs", () => {
514
523
  });
515
524
  });
516
525
 
526
+ describe("fx.clock — relative instants", () => {
527
+ test("ago / fromNow / duration use the injected now and duration strings", () => {
528
+ const fx = createFx({ flow: "sessions.sweepExpired", effects: {}, now: () => 1_000_000 });
529
+ expect(fx.clock.now()).toBe(1_000_000);
530
+ expect(fx.clock.duration("30d")).toBe(30 * 86_400_000);
531
+ expect(fx.clock.ago("30d")).toBe(1_000_000 - 30 * 86_400_000);
532
+ expect(fx.clock.fromNow("14d")).toBe(1_000_000 + 14 * 86_400_000);
533
+ expect(fx.clock.duration("nope")).toBe(0);
534
+ expect(fx.clock.ago("nope")).toBe(1_000_000);
535
+ expect(fx.clock.fromNow("200ms")).toBe(1_000_200);
536
+ });
537
+ });
538
+
517
539
  describe("fx.json", () => {
518
540
  test("with accepts a page or (data, meta)", () => {
519
541
  const fx = createFx({ flow: "x", effects: {} });
package/src/kernel/fx.ts CHANGED
@@ -9,7 +9,8 @@
9
9
  * for deterministic tests (§7.6).
10
10
  */
11
11
 
12
- import type { Effects, ResourceRef } from "../manifest/types.ts";
12
+ import type { Effects, ResourceRef, SignalResourceRef } from "../manifest/types.ts";
13
+ import { isMcpToolRef } from "../manifest/mcp-ref.ts";
13
14
  import type { QueryPageSpec } from "./list-page.ts";
14
15
  import { schemaTableName, sqlTableRef } from "../manifest/sql-resource.ts";
15
16
  import type {
@@ -27,8 +28,8 @@ import type {
27
28
  SqlStoreHandle,
28
29
  } from "../elements/store.ts";
29
30
  import type { SqlRow } from "../drivers/types.ts";
30
- import type { SignalRuntime } from "../elements/signal.ts";
31
- import type { SignalEmitOptions } from "../drivers/signal-types.ts";
31
+ import type { SignalDecl, SignalRuntime } from "../elements/signal.ts";
32
+ import type { DeadLetter, SignalEmitOptions } from "../drivers/signal-types.ts";
32
33
  import type { VaultActor, VaultAdapter, VaultRuntime } from "../elements/vault.ts";
33
34
  import type { ChannelRuntime } from "../elements/channel.ts";
34
35
  import type { AiRuntime } from "../elements/ai.ts";
@@ -73,6 +74,15 @@ async function loadRunsWindow(): Promise<typeof import("../runs/window.ts")> {
73
74
  /** Resource ref Flows declare to read the Runs store via {@link Fx.runs}. */
74
75
  export const RUNS_RESOURCE = "runs";
75
76
 
77
+ /**
78
+ * Capability ref for {@link Fx.deadLetters} — `signal:<name>`, never a store facet.
79
+ *
80
+ * @param name - Signal name
81
+ */
82
+ export function signalReadRef(name: string): SignalResourceRef {
83
+ return `signal:${name}`;
84
+ }
85
+
76
86
  export type { FxRetryOptions, FxThunk } from "./concurrency.ts";
77
87
 
78
88
  /** Named ref: plain string or `{ name }` element handle (optional pin). */
@@ -140,6 +150,27 @@ export interface FxTenant {
140
150
  export interface FxClock {
141
151
  /** Current epoch-ms (injectable via {@link CreateFxOptions.now}). */
142
152
  now(): number;
153
+ /**
154
+ * Instant `duration` before {@link FxClock.now} (`"30d"` → now − 30 days).
155
+ * Uses the same duration strings as {@link FxClock.sleep} / `every()`.
156
+ *
157
+ * @param duration - Duration string (e.g. `"30d"`, `"2h"`)
158
+ */
159
+ ago(duration: string): number;
160
+ /**
161
+ * Instant `duration` after {@link FxClock.now} (`"14d"` → now + 14 days).
162
+ * Uses the same duration strings as {@link FxClock.sleep} / `every()`.
163
+ *
164
+ * @param duration - Duration string (e.g. `"14d"`, `"15m"`)
165
+ */
166
+ fromNow(duration: string): number;
167
+ /**
168
+ * Span in milliseconds (`"30d"` → `2_592_000_000`). Compose with a stored
169
+ * instant: `createdAt + fx.clock.duration("7d")`. Unknown strings are `0`.
170
+ *
171
+ * @param duration - Duration string (e.g. `"30d"`, `"200ms"`)
172
+ */
173
+ duration(duration: string): number;
143
174
  /**
144
175
  * Durable sleep — when the flow is `durable`, journals the wake time and
145
176
  * survives restart / deploy. Without a journal, resolves immediately
@@ -323,12 +354,36 @@ export interface JsonResult<T = unknown> {
323
354
  readonly status: number;
324
355
  readonly value?: T;
325
356
  readonly meta?: Record<string, unknown>;
357
+ readonly kind?: undefined;
358
+ }
359
+
360
+ /** SSE carrier from {@link FxJson.stream}. */
361
+ export interface JsonStreamResult {
362
+ readonly [jsonResultBrand]: true;
363
+ readonly kind: "stream";
364
+ readonly status: 200;
365
+ readonly chunks: AsyncIterable<string>;
366
+ /** Set by the kernel to commit journal / Runs after the stream settles. */
367
+ finalize?: () => Promise<void>;
326
368
  }
327
369
 
328
- /** True when `value` is an {@link FxJson} carrier. */
370
+ /** True when `value` is an {@link FxJson} JSON-envelope carrier. */
329
371
  export function isJsonResult(value: unknown): value is JsonResult {
330
372
  return (
331
- typeof value === "object" && value !== null && (value as JsonResult)[jsonResultBrand] === true
373
+ typeof value === "object" &&
374
+ value !== null &&
375
+ (value as JsonResult)[jsonResultBrand] === true &&
376
+ (value as JsonStreamResult).kind !== "stream"
377
+ );
378
+ }
379
+
380
+ /** True when `value` is an SSE stream carrier from {@link FxJson.stream}. */
381
+ export function isJsonStreamResult(value: unknown): value is JsonStreamResult {
382
+ return (
383
+ typeof value === "object" &&
384
+ value !== null &&
385
+ (value as JsonStreamResult)[jsonResultBrand] === true &&
386
+ (value as JsonStreamResult).kind === "stream"
332
387
  );
333
388
  }
334
389
 
@@ -363,6 +418,11 @@ export interface FxJson {
363
418
  * every string field; extra keys auto-eq except `id`.
364
419
  */
365
420
  withQuery<T>(rows: readonly T[], input: unknown, spec?: QueryPageSpec<T>): JsonResult<T[]>;
421
+ /**
422
+ * 200 — `text/event-stream` of JSON `data:` frames, then `data: [DONE]`.
423
+ * Pass {@link Fx.stream} or any async iterable of token strings.
424
+ */
425
+ stream(chunks: AsyncIterable<string>): JsonStreamResult;
366
426
  }
367
427
 
368
428
  /**
@@ -567,6 +627,15 @@ export interface Fx {
567
627
  * @param options - Optional emit options (`key` for per-key once ordering)
568
628
  */
569
629
  emit(signal: NamedRef, payload?: unknown, options?: SignalEmitOptions): Promise<void>;
630
+ /**
631
+ * Query dead-lettered messages for one signal (records `read` on `signal:<name>`).
632
+ *
633
+ * Requires a bound signal runtime and `effects.reads` including `signal:<name>`.
634
+ *
635
+ * @param signal - Signal name or handle
636
+ */
637
+ deadLetters<T>(signal: SignalDecl<T>): Promise<readonly DeadLetter<T>[]>;
638
+ deadLetters(signal: NamedRef): Promise<readonly DeadLetter[]>;
570
639
  /**
571
640
  * Call another flow (records `call`). Stub returns `undefined`.
572
641
  *
@@ -651,7 +720,11 @@ export interface Fx {
651
720
  */
652
721
  stream(
653
722
  model: NamedRef,
654
- opts?: { readonly prompt?: string; readonly data?: unknown },
723
+ opts?: {
724
+ readonly prompt?: string;
725
+ readonly data?: unknown;
726
+ readonly via?: readonly NamedRef[];
727
+ },
655
728
  ): AsyncIterable<string>;
656
729
  /** Logger. */
657
730
  readonly log: FxLog;
@@ -1336,6 +1409,15 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1336
1409
 
1337
1410
  const clock: FxClock = {
1338
1411
  now,
1412
+ ago(duration: string): number {
1413
+ return now() - parseDurationMs(duration);
1414
+ },
1415
+ fromNow(duration: string): number {
1416
+ return now() + parseDurationMs(duration);
1417
+ },
1418
+ duration(duration: string): number {
1419
+ return parseDurationMs(duration);
1420
+ },
1339
1421
  async sleep(label: string, duration: string): Promise<void> {
1340
1422
  if (journal) {
1341
1423
  await journal.sleep(label, duration, parseDurationMs);
@@ -1591,9 +1673,24 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1591
1673
  }
1592
1674
  });
1593
1675
  },
1676
+ deadLetters(signal: NamedRef) {
1677
+ const name = resolveName(signal);
1678
+ return gated("read", signalReadRef(name), async () => {
1679
+ if (!options.signalRuntime) {
1680
+ throw new Error("fx.deadLetters requires a bound signal runtime");
1681
+ }
1682
+ return options.signalRuntime.deadLetters(name);
1683
+ });
1684
+ },
1594
1685
  call(flow, input) {
1595
1686
  const name = resolveName(flow);
1596
1687
  return gated("call", name, async () => {
1688
+ if (isMcpToolRef(name)) {
1689
+ if (!options.aiRuntime) {
1690
+ throw new Error(`fx.call: AI runtime is not configured for MCP tool "${name}"`);
1691
+ }
1692
+ return options.aiRuntime.callMcp(name, input, currentAbortSignal());
1693
+ }
1597
1694
  if (options.callHandler) {
1598
1695
  return options.callHandler(name, input);
1599
1696
  }
@@ -1785,6 +1882,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1785
1882
  prompt: opts?.prompt,
1786
1883
  data: opts?.data,
1787
1884
  signal: local.signal,
1885
+ via: opts?.via?.map(resolveName),
1788
1886
  })) {
1789
1887
  if (local.signal.aborted) break;
1790
1888
  yield text;
@@ -1832,6 +1930,14 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1832
1930
  },
1833
1931
  with: jsonWith,
1834
1932
  withQuery: jsonWithQuery,
1933
+ stream(chunks) {
1934
+ return {
1935
+ [jsonResultBrand]: true,
1936
+ kind: "stream" as const,
1937
+ status: 200 as const,
1938
+ chunks,
1939
+ };
1940
+ },
1835
1941
  },
1836
1942
  async step<T>(name: string, fn: () => T | Promise<T>, opts?: StepOptions<T>): Promise<T> {
1837
1943
  if (journal) {