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.
- package/manifest.v1.schema.json +7 -1
- package/package.json +2 -2
- package/site/content/docs/ai/mcp.mdx +27 -2
- package/site/content/docs/elements/ai.mdx +58 -11
- package/site/content/docs/elements/clock.mdx +27 -12
- package/site/content/docs/elements/flow.mdx +6 -3
- package/site/content/docs/elements/signal.mdx +71 -25
- package/site/content/docs/elements/store.mdx +21 -1
- package/site/content/docs/get-started/installation.mdx +2 -2
- package/site/content/docs/providers/index.mdx +1 -0
- package/site/content/docs/recipes/dragonfly.mdx +4 -3
- package/site/content/docs/recipes/redis.mdx +4 -5
- package/site/content/docs/recipes/valkey.mdx +5 -3
- package/site/content/docs/reference/configuration.mdx +3 -1
- package/site/content/docs/reference/environment-variables.mdx +5 -5
- package/site/content/docs/reference/fx.mdx +27 -22
- package/src/cli/ai-setup/recommend.test.ts +25 -0
- package/src/cli/ai-setup/recommend.ts +8 -3
- package/src/cli/load-config.images.test.ts +1 -0
- package/src/compiler/effects-infer.ts +58 -3
- package/src/compiler/extract.test.ts +68 -1
- package/src/compiler/extract.ts +70 -2
- package/src/compiler/response.ts +45 -1
- package/src/console/server/ai.ts +5 -2
- package/src/console/server/flows.ts +1 -0
- package/src/console/server/serve.ts +17 -2
- package/src/console/ui-next/dist/assets/cache-glyph-BanhLsEY.js +1 -0
- package/src/console/ui-next/dist/assets/flows-page-DxDsOd4f.js +1 -0
- package/src/console/ui-next/dist/assets/http-method-CJCBYL2j.js +1 -0
- package/src/console/ui-next/dist/assets/{index-DX248G39.js → index-Ce6WKWKM.js} +3 -3
- package/src/console/ui-next/dist/assets/observability-page-BEZDzyYh.js +4 -0
- package/src/console/ui-next/dist/assets/trace-detail-sheet-DFLFfUUX.js +2 -0
- package/src/console/ui-next/dist/assets/units-page-C0gW6Kdo.js +1 -0
- package/src/console/ui-next/dist/assets/{vault-page-CSOYMHhO.js → vault-page-DuKzqwzW.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/seed-invoke-host.ts +2 -0
- package/src/console/ui-next/src/features/flows/graph/build-flow-graph.test.ts +18 -0
- package/src/console/ui-next/src/features/flows/graph/build-flow-graph.ts +14 -1
- package/src/console/ui-next/src/features/flows/graph/neighborhood.test.ts +17 -0
- package/src/console/ui-next/src/features/flows/graph/neighborhood.ts +16 -3
- package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +3 -1
- package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
- package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +9 -3
- package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +10 -1
- package/src/console/ui-next/src/features/observability/lib/ask-count.test.ts +25 -0
- package/src/console/ui-next/src/features/observability/lib/ask-count.ts +4 -1
- package/src/console/ui-next/src/features/units/detail/effects-summary.tsx +12 -4
- package/src/docker/docker.test.ts +1 -1
- package/src/docker/dockerfile.ts +1 -1
- package/src/docker/helpers.ts +28 -0
- package/src/docker/recipes/dragonfly.ts +3 -6
- package/src/docker/recipes/redis.ts +2 -6
- package/src/docker/recipes/valkey.ts +2 -6
- package/src/drivers/ai-anthropic.ts +5 -0
- package/src/drivers/ai-ollama.ts +49 -30
- package/src/drivers/ai-openai-compatible.ts +57 -46
- package/src/drivers/ai-providers.test.ts +3 -0
- package/src/drivers/bun-native-completeness.test.ts +7 -9
- package/src/drivers/redis.ts +11 -4
- package/src/drivers/signal-redis.ts +24 -14
- package/src/drivers/signal-types.ts +2 -1
- package/src/drivers/types.ts +1 -1
- package/src/elements/ai/declare.ts +109 -0
- package/src/elements/ai/errors.test.ts +5 -1
- package/src/elements/ai/errors.ts +30 -2
- package/src/elements/ai/eval.ts +4 -6
- package/src/elements/ai/mcp-client.test.ts +206 -0
- package/src/elements/ai/mcp-client.ts +362 -0
- package/src/elements/ai/mcp-http.ts +159 -0
- package/src/elements/ai/mcp-mock.ts +134 -0
- package/src/elements/ai/mcp-protocol.ts +234 -0
- package/src/elements/ai/mcp-stdio.test.ts +50 -0
- package/src/elements/ai/mcp-stdio.ts +212 -0
- package/src/elements/ai/mcp-transport.ts +70 -0
- package/src/elements/ai/runtime.ts +159 -29
- package/src/elements/ai.test.ts +139 -0
- package/src/elements/ai.ts +16 -0
- package/src/elements/clock/health.test.ts +43 -0
- package/src/elements/clock/runtime.ts +55 -2
- package/src/elements/clock/schedule.ts +71 -142
- package/src/elements/clock.test.ts +9 -40
- package/src/elements/clock.ts +6 -1
- package/src/elements/gate/runtime.ts +3 -3
- package/src/elements/index.ts +2 -0
- package/src/elements/signal/declare.ts +2 -1
- package/src/elements/signal/runtime.ts +16 -1
- package/src/elements/signal.ts +1 -0
- package/src/elements/store/cache.test.ts +2 -0
- package/src/elements/store/cache.ts +3 -3
- package/src/elements/store/declare.ts +7 -0
- package/src/elements/store/kv-sql.test.ts +86 -0
- package/src/elements/store/kv-sql.ts +178 -0
- package/src/elements/store/runtime.ts +35 -9
- package/src/elements/store.test.ts +2 -0
- package/src/elements/vault/builtin-adapter.ts +17 -0
- package/src/full.ts +2 -0
- package/src/index.ts +4 -0
- package/src/kernel/app.ts +97 -64
- package/src/kernel/auto-registry.test.ts +5 -0
- package/src/kernel/boot-bind/ai.ts +24 -0
- package/src/kernel/boot-bind/clock.ts +2 -0
- package/src/kernel/boot-bind/store.test.ts +150 -1
- package/src/kernel/boot-bind/store.ts +66 -1
- package/src/kernel/boot.ts +3 -1
- package/src/kernel/element-registries.ts +4 -1
- package/src/kernel/fx-dead-letters.test.ts +77 -0
- package/src/kernel/fx.test.ts +22 -0
- package/src/kernel/fx.ts +112 -6
- package/src/kernel/http-stream.test.ts +174 -0
- package/src/kernel/index.ts +2 -0
- package/src/manifest/diff.test.ts +13 -0
- package/src/manifest/diff.ts +15 -0
- package/src/manifest/mcp-ref.ts +88 -0
- package/src/manifest/types.ts +33 -2
- package/src/manifest/validate.test.ts +20 -0
- package/src/mcp/docs-server.ts +1 -1
- package/src/mcp/server.ts +1 -1
- package/src/plugins/compression.test.ts +21 -0
- package/src/plugins/compression.ts +1 -0
- package/src/runtime/bun.ts +41 -4
- package/src/test/reset-element-registries.ts +2 -0
- package/src/console/ui-next/dist/assets/cache-glyph-CLPBqZeb.js +0 -1
- package/src/console/ui-next/dist/assets/flows-page-C_Tas1E1.js +0 -1
- package/src/console/ui-next/dist/assets/http-method-BJ92Z_ke.js +0 -1
- package/src/console/ui-next/dist/assets/observability-page-BwVS5Jvm.js +0 -4
- package/src/console/ui-next/dist/assets/trace-detail-sheet-D16lWQMt.js +0 -2
- 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
|
-
|
|
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);
|
package/src/kernel/boot.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/kernel/fx.test.ts
CHANGED
|
@@ -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" &&
|
|
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?: {
|
|
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) {
|