okengine 0.12.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/manifest.v1.schema.json +7 -1
  2. package/package.json +2 -2
  3. package/site/content/docs/ai/mcp.mdx +27 -2
  4. package/site/content/docs/elements/ai.mdx +58 -11
  5. package/site/content/docs/elements/clock.mdx +27 -12
  6. package/site/content/docs/elements/flow.mdx +6 -3
  7. package/site/content/docs/elements/signal.mdx +71 -25
  8. package/site/content/docs/elements/store.mdx +21 -1
  9. package/site/content/docs/get-started/installation.mdx +2 -2
  10. package/site/content/docs/providers/index.mdx +1 -0
  11. package/site/content/docs/recipes/dragonfly.mdx +4 -3
  12. package/site/content/docs/recipes/redis.mdx +4 -5
  13. package/site/content/docs/recipes/valkey.mdx +5 -3
  14. package/site/content/docs/reference/configuration.mdx +3 -1
  15. package/site/content/docs/reference/environment-variables.mdx +5 -5
  16. package/site/content/docs/reference/fx.mdx +27 -22
  17. package/src/cli/ai-setup/recommend.test.ts +25 -0
  18. package/src/cli/ai-setup/recommend.ts +8 -3
  19. package/src/cli/load-config.images.test.ts +1 -0
  20. package/src/compiler/effects-infer.ts +58 -3
  21. package/src/compiler/extract.test.ts +68 -1
  22. package/src/compiler/extract.ts +70 -2
  23. package/src/compiler/response.ts +45 -1
  24. package/src/console/server/ai.ts +5 -2
  25. package/src/console/server/flows.ts +1 -0
  26. package/src/console/server/serve.ts +17 -2
  27. package/src/console/ui-next/dist/assets/cache-glyph-BanhLsEY.js +1 -0
  28. package/src/console/ui-next/dist/assets/flows-page-DxDsOd4f.js +1 -0
  29. package/src/console/ui-next/dist/assets/http-method-CJCBYL2j.js +1 -0
  30. package/src/console/ui-next/dist/assets/{index-DX248G39.js → index-Ce6WKWKM.js} +3 -3
  31. package/src/console/ui-next/dist/assets/observability-page-BEZDzyYh.js +4 -0
  32. package/src/console/ui-next/dist/assets/trace-detail-sheet-DFLFfUUX.js +2 -0
  33. package/src/console/ui-next/dist/assets/units-page-C0gW6Kdo.js +1 -0
  34. package/src/console/ui-next/dist/assets/{vault-page-CSOYMHhO.js → vault-page-DuKzqwzW.js} +1 -1
  35. package/src/console/ui-next/dist/index.html +1 -1
  36. package/src/console/ui-next/seed-invoke-host.ts +2 -0
  37. package/src/console/ui-next/src/features/flows/graph/build-flow-graph.test.ts +18 -0
  38. package/src/console/ui-next/src/features/flows/graph/build-flow-graph.ts +14 -1
  39. package/src/console/ui-next/src/features/flows/graph/neighborhood.test.ts +17 -0
  40. package/src/console/ui-next/src/features/flows/graph/neighborhood.ts +16 -3
  41. package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +3 -1
  42. package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
  43. package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +9 -3
  44. package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +10 -1
  45. package/src/console/ui-next/src/features/observability/lib/ask-count.test.ts +25 -0
  46. package/src/console/ui-next/src/features/observability/lib/ask-count.ts +4 -1
  47. package/src/console/ui-next/src/features/units/detail/effects-summary.tsx +12 -4
  48. package/src/docker/docker.test.ts +1 -1
  49. package/src/docker/dockerfile.ts +1 -1
  50. package/src/docker/helpers.ts +28 -0
  51. package/src/docker/recipes/dragonfly.ts +3 -6
  52. package/src/docker/recipes/redis.ts +2 -6
  53. package/src/docker/recipes/valkey.ts +2 -6
  54. package/src/drivers/ai-anthropic.ts +5 -0
  55. package/src/drivers/ai-ollama.ts +49 -30
  56. package/src/drivers/ai-openai-compatible.ts +57 -46
  57. package/src/drivers/ai-providers.test.ts +3 -0
  58. package/src/drivers/bun-native-completeness.test.ts +7 -9
  59. package/src/drivers/redis.ts +11 -4
  60. package/src/drivers/signal-redis.ts +24 -14
  61. package/src/drivers/signal-types.ts +2 -1
  62. package/src/drivers/types.ts +1 -1
  63. package/src/elements/ai/declare.ts +109 -0
  64. package/src/elements/ai/errors.test.ts +5 -1
  65. package/src/elements/ai/errors.ts +30 -2
  66. package/src/elements/ai/eval.ts +4 -6
  67. package/src/elements/ai/mcp-client.test.ts +206 -0
  68. package/src/elements/ai/mcp-client.ts +362 -0
  69. package/src/elements/ai/mcp-http.ts +159 -0
  70. package/src/elements/ai/mcp-mock.ts +134 -0
  71. package/src/elements/ai/mcp-protocol.ts +234 -0
  72. package/src/elements/ai/mcp-stdio.test.ts +50 -0
  73. package/src/elements/ai/mcp-stdio.ts +212 -0
  74. package/src/elements/ai/mcp-transport.ts +70 -0
  75. package/src/elements/ai/runtime.ts +159 -29
  76. package/src/elements/ai.test.ts +139 -0
  77. package/src/elements/ai.ts +16 -0
  78. package/src/elements/clock/health.test.ts +43 -0
  79. package/src/elements/clock/runtime.ts +55 -2
  80. package/src/elements/clock/schedule.ts +71 -142
  81. package/src/elements/clock.test.ts +9 -40
  82. package/src/elements/clock.ts +6 -1
  83. package/src/elements/gate/runtime.ts +3 -3
  84. package/src/elements/index.ts +2 -0
  85. package/src/elements/signal/declare.ts +2 -1
  86. package/src/elements/signal/runtime.ts +16 -1
  87. package/src/elements/signal.ts +1 -0
  88. package/src/elements/store/cache.test.ts +2 -0
  89. package/src/elements/store/cache.ts +3 -3
  90. package/src/elements/store/declare.ts +7 -0
  91. package/src/elements/store/kv-sql.test.ts +86 -0
  92. package/src/elements/store/kv-sql.ts +178 -0
  93. package/src/elements/store/runtime.ts +35 -9
  94. package/src/elements/store.test.ts +2 -0
  95. package/src/elements/vault/builtin-adapter.ts +17 -0
  96. package/src/full.ts +2 -0
  97. package/src/index.ts +4 -0
  98. package/src/kernel/app.ts +97 -64
  99. package/src/kernel/auto-registry.test.ts +5 -0
  100. package/src/kernel/boot-bind/ai.ts +24 -0
  101. package/src/kernel/boot-bind/clock.ts +2 -0
  102. package/src/kernel/boot-bind/store.test.ts +150 -1
  103. package/src/kernel/boot-bind/store.ts +66 -1
  104. package/src/kernel/boot.ts +3 -1
  105. package/src/kernel/element-registries.ts +4 -1
  106. package/src/kernel/fx-dead-letters.test.ts +77 -0
  107. package/src/kernel/fx.test.ts +22 -0
  108. package/src/kernel/fx.ts +112 -6
  109. package/src/kernel/http-stream.test.ts +174 -0
  110. package/src/kernel/index.ts +2 -0
  111. package/src/manifest/diff.test.ts +13 -0
  112. package/src/manifest/diff.ts +15 -0
  113. package/src/manifest/mcp-ref.ts +88 -0
  114. package/src/manifest/types.ts +33 -2
  115. package/src/manifest/validate.test.ts +20 -0
  116. package/src/mcp/docs-server.ts +1 -1
  117. package/src/mcp/server.ts +1 -1
  118. package/src/plugins/compression.test.ts +21 -0
  119. package/src/plugins/compression.ts +1 -0
  120. package/src/runtime/bun.ts +41 -4
  121. package/src/test/reset-element-registries.ts +2 -0
  122. package/src/console/ui-next/dist/assets/cache-glyph-CLPBqZeb.js +0 -1
  123. package/src/console/ui-next/dist/assets/flows-page-C_Tas1E1.js +0 -1
  124. package/src/console/ui-next/dist/assets/http-method-BJ92Z_ke.js +0 -1
  125. package/src/console/ui-next/dist/assets/observability-page-BwVS5Jvm.js +0 -4
  126. package/src/console/ui-next/dist/assets/trace-detail-sheet-D16lWQMt.js +0 -2
  127. package/src/console/ui-next/dist/assets/units-page-C5KOI7qG.js +0 -1
@@ -0,0 +1,174 @@
1
+ /**
2
+ * HTTP SSE via `fx.json.stream` + client-disconnect abort of the provider fetch.
3
+ */
4
+
5
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
6
+ import { ai } from "../elements/ai.ts";
7
+ import type { AiDriver } from "../drivers/ai-types.ts";
8
+ import { createRunsRuntime } from "../runs/runtime.ts";
9
+ import { createBunRuntime } from "../runtime/bun.ts";
10
+ import type { ServerHandle } from "../runtime/types.ts";
11
+ import { oke } from "./app.ts";
12
+ import { flow, resetFlowSeq } from "./flow.ts";
13
+ import { on, resetBindings } from "./on.ts";
14
+ import { http } from "./triggers.ts";
15
+
16
+ beforeEach(() => {
17
+ resetBindings();
18
+ resetFlowSeq();
19
+ });
20
+
21
+ function tokenDriver(chunks: readonly string[], delayMs = 0): AiDriver {
22
+ return {
23
+ id: "mock",
24
+ async open() {
25
+ return {
26
+ driverId: "mock" as const,
27
+ model: "smart",
28
+ async complete() {
29
+ return { text: "", model: "smart", driverId: "mock" as const };
30
+ },
31
+ async *stream() {
32
+ for (const text of chunks) {
33
+ if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
34
+ yield { text };
35
+ }
36
+ },
37
+ };
38
+ },
39
+ };
40
+ }
41
+
42
+ function hangingDriver(onAbort: () => void): AiDriver {
43
+ return {
44
+ id: "mock",
45
+ async open() {
46
+ return {
47
+ driverId: "mock" as const,
48
+ model: "smart",
49
+ async complete() {
50
+ return { text: "", model: "smart", driverId: "mock" as const };
51
+ },
52
+ async *stream(opts) {
53
+ yield { text: "start" };
54
+ await new Promise<void>((resolve, reject) => {
55
+ const t = setTimeout(resolve, 8_000);
56
+ opts.signal?.addEventListener(
57
+ "abort",
58
+ () => {
59
+ onAbort();
60
+ clearTimeout(t);
61
+ const err = new Error("aborted");
62
+ err.name = "AbortError";
63
+ reject(err);
64
+ },
65
+ { once: true },
66
+ );
67
+ });
68
+ yield { text: "never" };
69
+ },
70
+ };
71
+ },
72
+ };
73
+ }
74
+
75
+ describe("fx.json.stream HTTP SSE", () => {
76
+ let handle: ServerHandle | undefined;
77
+
78
+ afterEach(() => {
79
+ handle?.stop(true);
80
+ handle = undefined;
81
+ });
82
+
83
+ test("encodes token chunks as SSE and leaves JSON flows buffered", async () => {
84
+ const smart = ai.model("smart");
85
+ on(
86
+ http.get("/chat").gate.public,
87
+ flow("chat.stream", {
88
+ do: (_input, fx) => fx.json.stream(fx.stream(smart, { prompt: "hi" })),
89
+ }),
90
+ );
91
+ on(http.get("/ping").gate.public, flow("ping", { do: () => ({ ok: true as const }) }));
92
+
93
+ const app = oke({
94
+ name: "http-stream",
95
+ ai: { models: [smart], defaultDriver: tokenDriver(["Hel", "lo"]) },
96
+ });
97
+
98
+ const streamed = await app.fetch(new Request("http://localhost/chat"));
99
+ expect(streamed.headers.get("content-type")).toMatch(/text\/event-stream/);
100
+ const body = await streamed.text();
101
+ expect(body).toContain('data: "Hel"');
102
+ expect(body).toContain('data: "lo"');
103
+ expect(body).toContain("data: [DONE]");
104
+
105
+ const json = await app.fetch(new Request("http://localhost/ping"));
106
+ expect(json.headers.get("content-type")).toMatch(/application\/json/);
107
+ expect(await json.json()).toEqual({ data: { ok: true }, error: null });
108
+ });
109
+
110
+ test("client abort mid-stream aborts the upstream provider fetch", async () => {
111
+ let providerAborted = false;
112
+ const smart = ai.model("smart");
113
+ on(
114
+ http.get("/slow").gate.public,
115
+ flow("chat.slow", {
116
+ do: (_input, fx) => fx.json.stream(fx.stream(smart, { prompt: "long" })),
117
+ }),
118
+ );
119
+
120
+ const app = oke({
121
+ name: "http-stream-abort",
122
+ ai: { models: [smart], defaultDriver: hangingDriver(() => (providerAborted = true)) },
123
+ });
124
+
125
+ handle = createBunRuntime().serve(app, { port: 0, hostname: "127.0.0.1" });
126
+ const ac = new AbortController();
127
+ const res = await fetch(`http://127.0.0.1:${handle.port}/slow`, { signal: ac.signal });
128
+ expect(res.headers.get("content-type")).toMatch(/text\/event-stream/);
129
+ const reader = res.body!.getReader();
130
+ const first = await reader.read();
131
+ expect(first.done).toBe(false);
132
+ ac.abort();
133
+ await new Promise((r) => setTimeout(r, 80));
134
+ expect(providerAborted).toBe(true);
135
+ });
136
+
137
+ test("Traces stay empty until stream close; duration covers the open stream", async () => {
138
+ const runs = createRunsRuntime({ driver: "memory" });
139
+ await runs.open();
140
+ const smart = ai.model("smart");
141
+ on(
142
+ http.get("/chat").gate.public,
143
+ flow("chat.stream", {
144
+ do: (_input, fx) => fx.json.stream(fx.stream(smart, { prompt: "hi" })),
145
+ }),
146
+ );
147
+
148
+ const app = oke({
149
+ name: "http-stream-traces",
150
+ ai: { models: [smart], defaultDriver: tokenDriver(["Hel", "lo"], 25) },
151
+ runs,
152
+ env: "test",
153
+ });
154
+ await app.boot({ env: "test", runs, startScheduler: false });
155
+
156
+ const streamed = await app.fetch(new Request("http://localhost/chat"));
157
+ expect(streamed.headers.get("content-type")).toMatch(/text\/event-stream/);
158
+ await runs.flush();
159
+ expect(await runs.all()).toHaveLength(0);
160
+
161
+ const body = await streamed.text();
162
+ expect(body).toContain("data: [DONE]");
163
+
164
+ await runs.flush();
165
+ const events = await runs.all();
166
+ expect(events).toHaveLength(1);
167
+ expect(events[0]!.error ?? null).toBeNull();
168
+ expect(events[0]!.output).toEqual({ streamed: true });
169
+ expect(events[0]!.durationMs).toBeGreaterThanOrEqual(40);
170
+
171
+ await app.stop();
172
+ await runs.close();
173
+ });
174
+ });
@@ -137,6 +137,7 @@ export {
137
137
  createFxContext,
138
138
  freezePrincipal,
139
139
  isJsonResult,
140
+ isJsonStreamResult,
140
141
  jsonResultBrand,
141
142
  resolveName,
142
143
  resolveStoreRef,
@@ -160,6 +161,7 @@ export {
160
161
  type FxThunk,
161
162
  type JsonPage,
162
163
  type JsonResult,
164
+ type JsonStreamResult,
163
165
  type NamedRef,
164
166
  type StepOptions,
165
167
  } from "./fx.ts";
@@ -309,6 +309,19 @@ describe("diffManifest — additional branches", () => {
309
309
  expect(hasCategory(result.changes, "contract-breaking", "facet")).toBe(true);
310
310
  });
311
311
 
312
+ test("store.kv durable on is effect-widening; off is contract-breaking", async () => {
313
+ const before = await loadBase();
314
+ before.stores!.db!.facet = "kv";
315
+ before.stores!.db!.namespaces = ["cache"];
316
+ delete before.stores!.db!.tables;
317
+ const on = clone(before);
318
+ on.stores!.db!.durable = true;
319
+ expect(hasCategory(diffManifest(before, on).changes, "effect-widening", "durable")).toBe(true);
320
+ const off = clone(on);
321
+ off.stores!.db!.durable = false;
322
+ expect(hasCategory(diffManifest(on, off).changes, "contract-breaking", "durable")).toBe(true);
323
+ });
324
+
312
325
  test("clocks, vault, channels, journeys, drivers, i18n, topology, images", async () => {
313
326
  const before = await loadBase();
314
327
  const after = clone(before);
@@ -779,6 +779,21 @@ function diffStore(before: Store, after: Store, path: string, out: ManifestChang
779
779
  diffStringSet(before.namespaces, after.namespaces, `${path}/namespaces`, out);
780
780
  diffStringSet(before.buckets, after.buckets, `${path}/buckets`, out);
781
781
  diffStringSet(before.indexes, after.indexes, `${path}/indexes`, out);
782
+ if (
783
+ before.durable !== after.durable &&
784
+ (before.durable !== undefined || after.durable !== undefined)
785
+ ) {
786
+ out.push(
787
+ change(
788
+ `${path}/durable`,
789
+ after.durable === true ? "effect-widening" : "contract-breaking",
790
+ "changed",
791
+ before.durable,
792
+ after.durable,
793
+ `store durable ${String(before.durable)} → ${String(after.durable)}`,
794
+ ),
795
+ );
796
+ }
782
797
  if (!deepEqual(before.classifications, after.classifications)) {
783
798
  out.push(
784
799
  change(
@@ -0,0 +1,88 @@
1
+ /**
2
+ * MCP tool resource refs — `mcp:<server>/<tool>` (capability / Manifest)
3
+ * and `<server>__<tool>` (provider-safe model tool name).
4
+ */
5
+
6
+ /** Capability / Manifest ref for one declared MCP tool. */
7
+ export type McpToolRef = `mcp:${string}/${string}`;
8
+
9
+ /** Parsed `mcp:<server>/<tool>` ref. */
10
+ export interface ParsedMcpToolRef {
11
+ readonly server: string;
12
+ readonly tool: string;
13
+ }
14
+
15
+ /**
16
+ * Build a capability ref.
17
+ *
18
+ * @param server - Declared MCP server name
19
+ * @param tool - Allowlisted tool name on that server
20
+ */
21
+ export function mcpToolRef(server: string, tool: string): McpToolRef {
22
+ return `mcp:${server}/${tool}`;
23
+ }
24
+
25
+ /**
26
+ * Parse `mcp:<server>/<tool>`. Null when the prefix or parts are missing.
27
+ *
28
+ * @param ref - Effect / tool resource string
29
+ */
30
+ export function parseMcpToolRef(ref: string): ParsedMcpToolRef | null {
31
+ if (!ref.startsWith("mcp:")) return null;
32
+ const rest = ref.slice(4);
33
+ const slash = rest.indexOf("/");
34
+ if (slash <= 0 || slash === rest.length - 1) return null;
35
+ return { server: rest.slice(0, slash), tool: rest.slice(slash + 1) };
36
+ }
37
+
38
+ /**
39
+ * True when `ref` is a well-formed MCP capability ref.
40
+ *
41
+ * @param ref - Candidate
42
+ */
43
+ export function isMcpToolRef(ref: string): boolean {
44
+ return parseMcpToolRef(ref) !== null;
45
+ }
46
+
47
+ /**
48
+ * Graph node id for one MCP server (`mcp:github`).
49
+ *
50
+ * @param server - Declared server name
51
+ */
52
+ export function mcpServerNodeId(server: string): string {
53
+ return `mcp:${server}`;
54
+ }
55
+
56
+ /**
57
+ * Provider-safe function name (`github__create_issue`).
58
+ *
59
+ * @param server - Declared server name
60
+ * @param tool - MCP tool name
61
+ */
62
+ export function mcpModelToolName(server: string, tool: string): string {
63
+ return `${server}__${tool}`;
64
+ }
65
+
66
+ /**
67
+ * Inverse of {@link mcpModelToolName}.
68
+ *
69
+ * @param name - Model-facing tool name
70
+ */
71
+ export function parseMcpModelToolName(name: string): ParsedMcpToolRef | null {
72
+ const sep = name.indexOf("__");
73
+ if (sep <= 0 || sep === name.length - 2) return null;
74
+ return { server: name.slice(0, sep), tool: name.slice(sep + 2) };
75
+ }
76
+
77
+ /**
78
+ * Capability ref from either `mcp:server/tool` or `server__tool`.
79
+ *
80
+ * @param name - Model-facing or capability name
81
+ */
82
+ export function mcpCapabilityRefFromName(name: string): McpToolRef | null {
83
+ const fromRef = parseMcpToolRef(name);
84
+ if (fromRef) return mcpToolRef(fromRef.server, fromRef.tool);
85
+ const fromModel = parseMcpModelToolName(name);
86
+ if (fromModel) return mcpToolRef(fromModel.server, fromModel.tool);
87
+ return null;
88
+ }
@@ -46,6 +46,12 @@ export type ResourceRef = `${StoreFacet}:${string}`;
46
46
  */
47
47
  export type RunsResourceRef = "runs";
48
48
 
49
+ /**
50
+ * Dead-letter read capability for `fx.deadLetters` — not a store facet.
51
+ * Declare on `effects.reads` (never `writes`).
52
+ */
53
+ export type SignalResourceRef = `signal:${string}`;
54
+
49
55
  /** Signal name reference. */
50
56
  export type SignalRef = string;
51
57
 
@@ -74,8 +80,8 @@ export type JsonSchema = string | Record<string, unknown>;
74
80
  * `sends` / `asks` are irreversible (asks also nondeterministic + cost).
75
81
  */
76
82
  export interface Effects {
77
- /** Store reads, plus optional `"runs"` for `fx.runs`. */
78
- reads?: Array<ResourceRef | RunsResourceRef>;
83
+ /** Store reads, plus `"runs"` for `fx.runs` and `signal:name` for `fx.deadLetters`. */
84
+ reads?: Array<ResourceRef | RunsResourceRef | SignalResourceRef>;
79
85
  /** Store writes. */
80
86
  writes?: ResourceRef[];
81
87
  /** Emitted signals. */
@@ -234,6 +240,11 @@ export interface Store {
234
240
  buckets?: string[];
235
241
  indexes?: string[];
236
242
  classifications?: Record<string, ClassificationValue>;
243
+ /**
244
+ * KV only — namespace persists in SQL (`oke_kv`), not the cache Redis.
245
+ * Distinct from Flow `durable` (journaled steps).
246
+ */
247
+ durable?: boolean;
237
248
  }
238
249
 
239
250
  /** Named clock / schedule. */
@@ -292,6 +303,8 @@ export interface AiModel {
292
303
  provider?: string;
293
304
  tier?: string;
294
305
  model?: string;
306
+ /** Protocol driver override for this logical binding. */
307
+ driverId?: string;
295
308
  }
296
309
 
297
310
  /** Prompt / agent budget. */
@@ -322,11 +335,29 @@ export interface AiAgent {
322
335
  budget?: AiBudget;
323
336
  }
324
337
 
338
+ /**
339
+ * External MCP server the app consumes — tools join the existing `fx.call`
340
+ * / `toolLoop` path as `mcp:<server>/<tool>` capability refs.
341
+ */
342
+ export interface AiMcpServer {
343
+ /** Streamable HTTP endpoint. */
344
+ url?: string;
345
+ /** stdio executable (no shell string). */
346
+ command?: string;
347
+ /** Arguments for {@link command}. */
348
+ args?: string[];
349
+ /** Bearer secret contract name (never the value). */
350
+ auth?: SecretRef;
351
+ /** Required allowlist — never trust whatever `tools/list` exposes. */
352
+ tools: string[];
353
+ }
354
+
325
355
  /** AI element catalogue. */
326
356
  export interface Ai {
327
357
  models?: Record<string, AiModel>;
328
358
  prompts?: Record<string, AiPrompt>;
329
359
  agents?: Record<string, AiAgent>;
360
+ mcpServers?: Record<string, AiMcpServer>;
330
361
  }
331
362
 
332
363
  /** Per-table metadata on a Manifest {@link Plugin}. */
@@ -75,6 +75,26 @@ describe("validateManifest", () => {
75
75
  expect(result.ok).toBe(false);
76
76
  });
77
77
 
78
+ test("accepts signal resource refs on reads only", async () => {
79
+ const reads = await validateManifest({
80
+ oke: "1.0",
81
+ app: "x",
82
+ flows: {
83
+ f: { effects: { reads: ["signal:notify"] } },
84
+ },
85
+ });
86
+ expect(reads.ok).toBe(true);
87
+
88
+ const writes = await validateManifest({
89
+ oke: "1.0",
90
+ app: "x",
91
+ flows: {
92
+ f: { effects: { writes: ["signal:notify"] } },
93
+ },
94
+ });
95
+ expect(writes.ok).toBe(false);
96
+ });
97
+
78
98
  test("parseManifest throws ManifestValidationError on invalid JSON", async () => {
79
99
  expect(parseManifest("{")).rejects.toBeInstanceOf(ManifestValidationError);
80
100
  });
@@ -197,7 +197,7 @@ export async function serveDocsMcp(
197
197
  hostname: boundHost,
198
198
  fetch: mcp.fetch,
199
199
  stop(closeActive = false) {
200
- server.stop(closeActive);
200
+ return server.stop(closeActive);
201
201
  },
202
202
  };
203
203
  }
package/src/mcp/server.ts CHANGED
@@ -268,7 +268,7 @@ export async function serveMcp(options: ServeMcpOptions): Promise<McpServerHandl
268
268
  hostname: boundHost,
269
269
  fetch: mcp.fetch,
270
270
  stop(closeActive = false) {
271
- server.stop(closeActive);
271
+ return server.stop(closeActive);
272
272
  },
273
273
  };
274
274
  }
@@ -122,4 +122,25 @@ describe("compression plugin", () => {
122
122
  );
123
123
  expect(nt.headers.get("content-encoding")).toBeNull();
124
124
  });
125
+
126
+ test("skips text/event-stream even when Accept-Encoding is gzip", async () => {
127
+ on(
128
+ http.get("/sse"),
129
+ flow("sse.get", {
130
+ do: (_input, fx) =>
131
+ fx.json.stream(
132
+ (async function* () {
133
+ yield "hello";
134
+ })(),
135
+ ),
136
+ }),
137
+ );
138
+ const app = oke({ autoBoot: false, name: "zip-sse" }).plug(compression({ minSize: 0 }));
139
+ const res = await app.fetch(
140
+ new Request("http://localhost/sse", { headers: { "accept-encoding": "gzip" } }),
141
+ );
142
+ expect(res.headers.get("content-type")).toMatch(/text\/event-stream/);
143
+ expect(res.headers.get("content-encoding")).toBeNull();
144
+ expect(await res.text()).toContain('data: "hello"');
145
+ });
125
146
  });
@@ -73,6 +73,7 @@ export function compression(
73
73
  if (cacheControl !== null && /\bno-transform\b/i.test(cacheControl)) return;
74
74
 
75
75
  const contentType = headers.get("content-type") ?? "";
76
+ if (/^text\/event-stream\b/i.test(contentType.split(";")[0]?.trim() ?? "")) return;
76
77
  if (!match.test(contentType)) return;
77
78
 
78
79
  const body = await ctx.response.arrayBuffer();
@@ -81,7 +81,29 @@ export function isBunNativeMethod(method: string): boolean {
81
81
  );
82
82
  }
83
83
 
84
- type MethodHandlers = Partial<Record<string, (req: Request) => Response | Promise<Response>>>;
84
+ /** Bun.serve `fetch` / route handler second arg is the live server. */
85
+ type ServeFetch = (
86
+ req: Request,
87
+ server: { timeout(request: Request, seconds: number): void },
88
+ ) => Response | Promise<Response>;
89
+
90
+ type MethodHandlers = Partial<Record<string, ServeFetch>>;
91
+
92
+ /**
93
+ * Disable the 10s idle timeout for SSE so a quiet token stream is not reset.
94
+ *
95
+ * @param inner - App fetch
96
+ */
97
+ function holdSseIdle(inner: (request: Request) => Promise<Response>): ServeFetch {
98
+ return async (req, server) => {
99
+ const res = await inner(req);
100
+ const ct = (res.headers.get("content-type") ?? "").split(";")[0]?.trim() ?? "";
101
+ if (/^text\/event-stream$/i.test(ct)) {
102
+ server.timeout(req, 0);
103
+ }
104
+ return res;
105
+ };
106
+ }
85
107
 
86
108
  /**
87
109
  * Build Bun.serve `routes` from app HTTP bindings.
@@ -103,7 +125,7 @@ export function buildBunRoutes(
103
125
  if (typeof path !== "string" || typeof method !== "string") continue;
104
126
  if (!isBunNativePath(path) || !isBunNativeMethod(method)) continue;
105
127
  const methods = routes[path] ?? (routes[path] = {});
106
- methods[method] = (req) => fetchHandler(req);
128
+ methods[method] = holdSseIdle(fetchHandler);
107
129
  }
108
130
  return routes;
109
131
  }
@@ -128,10 +150,15 @@ export function serveBunHttp(options: {
128
150
  readonly routes?: Record<string, MethodHandlers>;
129
151
  readonly id?: string;
130
152
  }): ReturnType<typeof Bun.serve> {
153
+ const fetch = holdSseIdle(
154
+ typeof options.fetch === "function"
155
+ ? (req: Request) => Promise.resolve(options.fetch(req))
156
+ : options.fetch,
157
+ );
131
158
  const base = {
132
159
  port: options.port,
133
160
  hostname: options.hostname,
134
- fetch: options.fetch,
161
+ fetch,
135
162
  ...(options.id !== undefined ? { id: options.id } : {}),
136
163
  };
137
164
  const table = options.routes;
@@ -160,6 +187,15 @@ function listenBun(app: FetchApp, options?: ServeOptions): ServerHandle {
160
187
  ...(options?.id !== undefined ? { id: options.id } : {}),
161
188
  });
162
189
 
190
+ const closeIdle = (
191
+ server as typeof server & { closeIdleConnections(): void }
192
+ ).closeIdleConnections.bind(server);
193
+ const onPressure = (): void => {
194
+ closeIdle();
195
+ };
196
+ const proc = process as NodeJS.EventEmitter;
197
+ proc.on("memoryPressure", onPressure);
198
+
163
199
  const boundPort = server.port ?? port;
164
200
  const boundHost = server.hostname ?? hostname;
165
201
  const url = new URL(`http://${formatHostForUrl(boundHost)}:${boundPort}/`);
@@ -170,7 +206,8 @@ function listenBun(app: FetchApp, options?: ServeOptions): ServerHandle {
170
206
  hostname: boundHost,
171
207
  fetch: fetchHandler,
172
208
  stop(closeActiveConnections = false) {
173
- server.stop(closeActiveConnections);
209
+ proc.off("memoryPressure", onPressure);
210
+ return server.stop(closeActiveConnections);
174
211
  },
175
212
  };
176
213
  }
@@ -19,6 +19,7 @@ import { afterEach } from "bun:test";
19
19
  import {
20
20
  aiAgentRegistry,
21
21
  aiEmbedRegistry,
22
+ aiMcpServerRegistry,
22
23
  aiModelRegistry,
23
24
  aiPromptRegistry,
24
25
  channelTemplateRegistry,
@@ -38,4 +39,5 @@ afterEach(() => {
38
39
  aiPromptRegistry.length = 0;
39
40
  aiEmbedRegistry.length = 0;
40
41
  aiAgentRegistry.length = 0;
42
+ aiMcpServerRegistry.length = 0;
41
43
  });
@@ -1 +0,0 @@
1
- import{Cr as e,F as t,L as n,P as r,wr as i}from"./shortcut-keys-DO4IsVqv.js";import{A as a,f as o}from"./xyflow-D7n4g6go.js";import{t as s}from"./dagre.esm-ZwcdTuZZ.js";import{C as c,S as l,_ as u,o as d,v as f}from"./http-method-BJ92Z_ke.js";function p(e){let t=e.indexOf(`.`);return t===-1?e:e.slice(0,t)}function m(e){let t=e.indexOf(`.`);return t===-1?e:e.slice(t+1)}function h(e){let t=e?.flows??{},n=new Map;for(let e of Object.keys(t)){let r=t[e]?.effects?.calls??[];for(let t of r){let r=n.get(t)??[];r.push(e),n.set(t,r)}}for(let[e,t]of n)t.sort((e,t)=>e.localeCompare(t)),n.set(e,t);return n}function g(e,t){return h(e).get(t)??[]}function _(e){return e.startsWith(`sql:`)?{id:e,kind:`store`,label:e.slice(4),facet:`sql`}:e.startsWith(`kv:`)?{id:e,kind:`store`,label:e.slice(3),facet:`kv`}:e.startsWith(`files:`)?{id:e,kind:`store`,label:e.slice(6),facet:`files`}:e.startsWith(`index:`)?{id:e,kind:`store`,label:e.slice(6),facet:`index`}:e.startsWith(`signal:`)?{id:e,kind:`signal`,label:e.slice(7)}:e.startsWith(`ai:`)?{id:e,kind:`ai`,label:e.slice(3)}:e.startsWith(`vault:`)?{id:e,kind:`vault`,label:e.slice(6)}:e.startsWith(`channel:`)?{id:e,kind:`channel`,label:e.slice(8)}:e.startsWith(`gate:`)?{id:e,kind:`gate`,label:e.slice(5)}:e.startsWith(`clock:`)?{id:e,kind:`clock`,label:e.slice(6)}:null}function v(e,t,n,r,i=!1){let a=u[r];return{id:e,source:t,target:n,type:`smoothstep`,animated:i,data:{kind:r},style:{stroke:a,strokeWidth:r===`calls`?2.5:2.25},pathOptions:{borderRadius:28},zIndex:f.edge,markerEnd:{type:o.ArrowClosed,width:14,height:14,color:a}}}function y(e){let t=e?.flows??{},n=new Set(Object.keys(t)),r=[],i=new Map;for(let e of[...n].sort()){let t=p(e),n=i.get(t)??[];n.push(e),i.set(t,n)}let a=new Map,o=new Map;for(let e of n){let n=t[e];if(!n)continue;if(n.trigger?.signal){let t=`signal:${n.trigger.signal}`,i=_(t);i&&a.set(t,i),r.push(v(`e:${t}->flow:${e}`,t,`flow:${e}`,`trigger`))}if(n.trigger?.cron||n.trigger?.every){let t=`clock:${e}`,i=n.trigger.cron??n.trigger.every??e;a.set(t,{id:t,kind:`clock`,label:i,facet:n.trigger.cron?`cron`:`every`}),r.push(v(`e:${t}->flow:${e}`,t,`flow:${e}`,`trigger`))}for(let t of n.gates??[]){let n=`gate:${t}`;a.set(n,{id:n,kind:`gate`,label:t}),o.set(n,(o.get(n)??0)+1),r.push(v(`e:flow:${e}-gates->${n}`,`flow:${e}`,n,`gates`))}let i=n.effects;if(i){for(let t of i.reads??[]){let n=_(t);n&&(a.set(t,n),o.set(t,(o.get(t)??0)+1),r.push(v(`e:flow:${e}-reads->${t}`,`flow:${e}`,t,`reads`)))}for(let t of i.writes??[]){let n=_(t);n&&(a.set(t,n),o.set(t,(o.get(t)??0)+1),r.push(v(`e:flow:${e}-writes->${t}`,`flow:${e}`,t,`writes`)))}for(let t of i.emits??[]){let n=`signal:${t}`,i=_(n);i&&(a.set(n,i),o.set(n,(o.get(n)??0)+1),r.push(v(`e:flow:${e}->${n}`,`flow:${e}`,n,`emits`)))}for(let t of i.asks??[]){let n=`ai:${t}`,i=_(n);i&&(a.set(n,i),o.set(n,(o.get(n)??0)+1),r.push(v(`e:flow:${e}->${n}`,`flow:${e}`,n,`asks`)))}for(let t of i.sends??[]){let n=`channel:${t}`,i=_(n);i&&(a.set(n,i),o.set(n,(o.get(n)??0)+1),r.push(v(`e:flow:${e}->${n}`,`flow:${e}`,n,`sends`)))}for(let t of i.secrets??[]){let n=`vault:${t}`,i=_(n);i&&(a.set(n,i),o.set(n,(o.get(n)??0)+1),r.push(v(`e:flow:${e}->${n}`,`flow:${e}`,n,`secrets`)))}for(let t of i.calls??[])r.push(v(`e:flow:${e}->flow:${t}`,`flow:${e}`,`flow:${t}`,`calls`,!0))}}return{nodes:b(i,t,a,o),edges:r,flowIds:n}}function b(e,t,n,r){let i=new s.graphlib.Graph({compound:!0});i.setGraph({rankdir:`LR`,align:`UL`,nodesep:16,ranksep:88,edgesep:12,marginx:8,marginy:8}),i.setDefaultEdgeLabel(()=>({}));for(let[t,n]of[...e.entries()].sort()){i.setNode(`unit:${t}`,{});for(let e of n)i.setNode(`flow:${e}`,{...l.flow}),i.setParent(`flow:${e}`,`unit:${t}`)}for(let e of n.values())i.setNode(e.id,{...l[e.kind]});let a=new Set;for(let n of e.values())for(let e of n){let n=t[e];if(!n)continue;let r=(e,t)=>{let n=`${e}->${t}`;a.has(n)||!i.hasNode(e)||!i.hasNode(t)||(a.add(n),i.setEdge(e,t))};n.trigger?.signal&&r(`signal:${n.trigger.signal}`,`flow:${e}`),(n.trigger?.cron||n.trigger?.every)&&r(`clock:${e}`,`flow:${e}`);for(let t of n.gates??[])r(`flow:${e}`,`gate:${t}`);let o=n.effects;if(o){for(let t of o.reads??[])r(`flow:${e}`,t);for(let t of o.writes??[])r(`flow:${e}`,t);for(let t of o.emits??[])r(`flow:${e}`,`signal:${t}`);for(let t of o.asks??[])r(`flow:${e}`,`ai:${t}`);for(let t of o.sends??[])r(`flow:${e}`,`channel:${t}`);for(let t of o.secrets??[])r(`flow:${e}`,`vault:${t}`);for(let t of o.calls??[])r(`flow:${e}`,`flow:${t}`)}}s.layout(i);let o=[],{headerH:u,padX:d,padBottom:p}=c;for(let[n,r]of[...e.entries()].sort()){let e=`unit:${n}`,a=i.node(e);if(!a)continue;let s=a.x??0,c=a.y??0,h=r.length*l.flow.height+Math.max(0,r.length-1)*10,g=l.flow.width+d*2,_=u+h+p,v=s-g/2,y=c-_/2;o.push({id:e,type:`unit`,position:{x:v,y},data:{kind:`unit`,label:n,refId:e,badge:String(r.length)},selectable:!1,draggable:!1,zIndex:f.unit,width:g,height:_,style:{width:g,height:_}}),r.forEach((r,i)=>{let a=t[r];a&&o.push({id:`flow:${r}`,type:`flow`,position:{x:d,y:u+i*(l.flow.height+10)},parentId:e,extent:`parent`,zIndex:f.leaf,data:{kind:`flow`,label:m(r),refId:r,unit:n,plane:a.plane??`user`,badge:a.plane??`user`},draggable:!1,width:l.flow.width,height:l.flow.height,style:{width:l.flow.width,height:l.flow.height}})})}let h=[...n.values()].sort((e,t)=>e.kind.localeCompare(t.kind)||e.label.localeCompare(t.label));for(let e of h){let t=i.node(e.id),n=l[e.kind],a=r.get(e.id)??0;o.push({id:e.id,type:e.kind,position:{x:(t?.x??0)-n.width/2,y:(t?.y??0)-n.height/2},data:{kind:e.kind,label:e.label,refId:e.id,...e.facet===void 0?{}:{facet:e.facet},badge:e.facet??(a>0?String(a):e.kind)},draggable:!1,zIndex:f.leaf,width:n.width,height:n.height,style:{width:n.width,height:n.height}})}return o}function x(e,t,n,r={}){let i=t.size>0||n.size>0;return e.map(e=>{if(e.data.kind===`unit`||e.data.kind===`law`)return e;let a=e.data.kind===`flow`?t.has(e.data.refId):n.has(e.id);return{...e,data:{...e.data,highlighted:a,dimmed:i&&!a,active:r.activeNodeId!=null&&e.id===r.activeNodeId,focused:r.focusedNodeId!=null&&e.id===r.focusedNodeId}}})}function S(e,t){let n=new Map(t.map(e=>[e.id,e]));return t.some(e=>e.data.highlighted===!0)?e.map(e=>{let t=n.get(e.source),r=n.get(e.target),i=t?.data.highlighted===!0&&r?.data.highlighted===!0,a=u[e.data?.kind??`reads`];return{...e,animated:i?!0:e.animated,style:{...e.style,stroke:a,opacity:i?1:.32,strokeWidth:i?2.75:1.75},markerEnd:e.markerEnd&&typeof e.markerEnd==`object`?{...e.markerEnd,color:a}:e.markerEnd}}):e.map(e=>({...e,style:{...e.style,opacity:1}}))}var C=100;function w(e,t){return e.filter(e=>t.has(e.flow)).sort((e,t)=>t.startedAt-e.startedAt).slice(0,C)}var T=a();function E({cache:a,dataSlot:o}){let s=d(a);return(0,T.jsxs)(r,{children:[(0,T.jsx)(n,{render:t=>(0,T.jsx)(`span`,{...t,className:e(`flex w-4 shrink-0 items-center justify-center`,s.className),"data-slot":o,"data-cache":a,"aria-label":s.label,children:(0,T.jsx)(i,{icon:s.icon,className:`size-3`,"aria-hidden":!0})})}),(0,T.jsx)(t,{side:`top`,children:s.label})]})}export{S as a,p as c,x as i,w as n,y as o,m as r,g as s,E as t};