okengine 0.9.0 → 0.10.0

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 (175) hide show
  1. package/AGENTS.md +1 -1
  2. package/manifest.v1.schema.json +9 -1
  3. package/package.json +3 -3
  4. package/site/content/docs/console/runs.mdx +10 -0
  5. package/site/content/docs/console/traces.mdx +4 -0
  6. package/site/content/docs/elements/ai.mdx +54 -32
  7. package/site/content/docs/elements/channel.mdx +105 -14
  8. package/site/content/docs/elements/clock.mdx +1 -0
  9. package/site/content/docs/elements/flow.mdx +42 -8
  10. package/site/content/docs/get-started/basic-usage.mdx +5 -1
  11. package/site/content/docs/get-started/installation.mdx +9 -5
  12. package/site/content/docs/meta.json +2 -0
  13. package/site/content/docs/plugins/otp.mdx +58 -46
  14. package/site/content/docs/providers/azure-redis.mdx +85 -0
  15. package/site/content/docs/providers/cockroachdb.mdx +86 -0
  16. package/site/content/docs/providers/digitalocean-caching.mdx +88 -0
  17. package/site/content/docs/providers/dragonfly-cloud.mdx +82 -0
  18. package/site/content/docs/providers/elasticache.mdx +92 -0
  19. package/site/content/docs/providers/index.mdx +101 -0
  20. package/site/content/docs/providers/memorystore.mdx +89 -0
  21. package/site/content/docs/providers/meta.json +20 -0
  22. package/site/content/docs/providers/neon.mdx +115 -0
  23. package/site/content/docs/providers/redis-cloud.mdx +93 -0
  24. package/site/content/docs/providers/supabase.mdx +117 -0
  25. package/site/content/docs/providers/upstash.mdx +91 -0
  26. package/site/content/docs/providers/yugabytedb.mdx +95 -0
  27. package/site/content/docs/recipes/caddy.mdx +128 -0
  28. package/site/content/docs/recipes/cockroachdb.mdx +127 -0
  29. package/site/content/docs/recipes/dragonfly.mdx +125 -0
  30. package/site/content/docs/recipes/index.mdx +146 -0
  31. package/site/content/docs/recipes/llama-cpp.mdx +153 -0
  32. package/site/content/docs/recipes/mailpit.mdx +136 -0
  33. package/site/content/docs/recipes/meilisearch.mdx +139 -0
  34. package/site/content/docs/recipes/meta.json +31 -0
  35. package/site/content/docs/recipes/ollama.mdx +142 -0
  36. package/site/content/docs/recipes/openbao.mdx +141 -0
  37. package/site/content/docs/recipes/pgdog.mdx +137 -0
  38. package/site/content/docs/recipes/postgres.mdx +136 -0
  39. package/site/content/docs/recipes/redis.mdx +130 -0
  40. package/site/content/docs/recipes/rustfs.mdx +148 -0
  41. package/site/content/docs/recipes/sglang.mdx +105 -0
  42. package/site/content/docs/recipes/supabase-docker.mdx +145 -0
  43. package/site/content/docs/recipes/timescale.mdx +129 -0
  44. package/site/content/docs/recipes/traefik.mdx +136 -0
  45. package/site/content/docs/recipes/valkey.mdx +121 -0
  46. package/site/content/docs/recipes/vllm.mdx +106 -0
  47. package/site/content/docs/recipes/yugabytedb.mdx +135 -0
  48. package/site/content/docs/reference/cli.md +42 -6
  49. package/site/content/docs/reference/configuration.mdx +5 -3
  50. package/site/content/docs/reference/environment-variables.mdx +12 -12
  51. package/site/content/docs/reference/fx.mdx +38 -4
  52. package/src/auth/otp-capability.ts +14 -14
  53. package/src/cli/ai-setup/ai-setup.test.ts +32 -12
  54. package/src/cli/ai-setup/apply.ts +65 -23
  55. package/src/cli/ai-setup/catalog.ts +1316 -35
  56. package/src/cli/ai-setup/detect-ollama.ts +47 -0
  57. package/src/cli/ai-setup/index.ts +44 -7
  58. package/src/cli/ai-setup/prompts.ts +430 -486
  59. package/src/cli/ai-setup/recommend.test.ts +150 -50
  60. package/src/cli/ai-setup/recommend.ts +118 -101
  61. package/src/cli/ask-seed.test.ts +68 -0
  62. package/src/cli/ask-seed.ts +79 -0
  63. package/src/cli/db-seed-redact.test.ts +25 -0
  64. package/src/cli/db-seed.ts +36 -7
  65. package/src/cli/db.test.ts +1 -1
  66. package/src/cli/db.ts +2 -2
  67. package/src/cli/dev-controls.test.ts +107 -0
  68. package/src/cli/dev-controls.ts +354 -0
  69. package/src/cli/dev-schema-sync.ts +12 -4
  70. package/src/cli/dev.test.ts +50 -30
  71. package/src/cli/dev.ts +607 -66
  72. package/src/cli/drizzle-env.test.ts +40 -6
  73. package/src/cli/drizzle-env.ts +22 -1
  74. package/src/cli/ensure-drizzle-config.ts +6 -4
  75. package/src/cli/hero-meta.test.ts +13 -1
  76. package/src/cli/hero-meta.ts +28 -6
  77. package/src/cli/index.ts +5 -0
  78. package/src/cli/registry.ts +24 -0
  79. package/src/cli/replay.test.ts +105 -0
  80. package/src/cli/replay.ts +206 -0
  81. package/src/compiler/extract.ts +3 -3
  82. package/src/config/index.ts +3 -3
  83. package/src/console/server/claim.ts +3 -1
  84. package/src/console/ui/dist/assets/{index-ClX0ZCe5.js → index-D-9KFce5.js} +2 -2
  85. package/src/console/ui/dist/assets/panel-overview-CW0CQEc2.js +1 -0
  86. package/src/console/ui/dist/assets/panel-runs-BxuyDI3x.js +1 -0
  87. package/src/console/ui/dist/assets/{panel-signals-Dsxdu_AR.js → panel-signals-BXofbj7v.js} +1 -1
  88. package/src/console/ui/dist/assets/{panel-store-hy7O8HOs.js → panel-store-B8mH-arU.js} +1 -1
  89. package/src/console/ui/dist/assets/{panel-traces-CBiAe5go.js → panel-traces-DSA-ZWnf.js} +1 -1
  90. package/src/console/ui/dist/index.html +1 -1
  91. package/src/console/ui/overview/slo.ts +31 -3
  92. package/src/console/ui/overview/types.ts +6 -0
  93. package/src/console/ui/runs/errors.test.ts +53 -0
  94. package/src/console/ui/runs/errors.ts +88 -0
  95. package/src/console/ui/runs/index.ts +8 -0
  96. package/src/console/ui/runs/search.ts +16 -0
  97. package/src/console/ui/shell/panels/runs/RunsPanel.tsx +72 -1
  98. package/src/docker/ai-model-status.test.ts +101 -0
  99. package/src/docker/ai-model-status.ts +276 -0
  100. package/src/docker/compose-health.test.ts +115 -0
  101. package/src/docker/compose-health.ts +234 -0
  102. package/src/docker/compose.ts +24 -8
  103. package/src/docker/derive.ts +35 -9
  104. package/src/docker/docker.test.ts +304 -12
  105. package/src/docker/helpers.ts +56 -0
  106. package/src/docker/index.ts +52 -0
  107. package/src/docker/ollama-pull.ts +232 -0
  108. package/src/docker/recipes/cockroach.ts +24 -0
  109. package/src/docker/recipes/index.ts +32 -0
  110. package/src/docker/recipes/llama-cpp.ts +273 -0
  111. package/src/docker/recipes/ollama.ts +19 -23
  112. package/src/docker/recipes/pgdog.ts +4 -1
  113. package/src/docker/recipes/postgres.ts +5 -18
  114. package/src/docker/recipes/sglang.ts +55 -0
  115. package/src/docker/recipes/supabase.ts +21 -0
  116. package/src/docker/recipes/timescale.ts +18 -0
  117. package/src/docker/recipes/vllm.ts +44 -0
  118. package/src/docker/recipes/yugabyte.ts +23 -0
  119. package/src/docker/stack-id.test.ts +22 -0
  120. package/src/docker/stack-id.ts +38 -4
  121. package/src/docker/types.ts +15 -0
  122. package/src/drivers/ai-ollama.integration.test.ts +7 -4
  123. package/src/drivers/signal-engine.ts +10 -0
  124. package/src/drivers/signal-postgres.ts +24 -2
  125. package/src/drivers/signal-types.ts +10 -0
  126. package/src/elements/channel/fallback.test.ts +91 -0
  127. package/src/elements/channel/hard-bounce.test.ts +82 -0
  128. package/src/elements/channel/injection.test.ts +153 -0
  129. package/src/elements/channel/locale-ar.test.ts +102 -0
  130. package/src/elements/channel/otp-delivery.ts +5 -2
  131. package/src/elements/channel/receipts-ledger.test.ts +100 -0
  132. package/src/elements/channel/runtime.ts +24 -1
  133. package/src/elements/channel/test-helpers.ts +60 -0
  134. package/src/elements/channel/whatsapp-compliance.test.ts +124 -0
  135. package/src/elements/store/cache.ts +1 -1
  136. package/src/elements/store/emit-drizzle.ts +43 -9
  137. package/src/elements/store/runtime.ts +1 -1
  138. package/src/elements/store/schema-decl.test.ts +2 -0
  139. package/src/elements/store/schema-decl.ts +15 -0
  140. package/src/elements/store/seed.ts +1 -1
  141. package/src/index.ts +1 -0
  142. package/src/kernel/app.ts +118 -11
  143. package/src/kernel/boot-bind/signal.ts +6 -2
  144. package/src/kernel/boot-bind/store.ts +2 -1
  145. package/src/kernel/boot.test.ts +6 -5
  146. package/src/kernel/boot.ts +8 -3
  147. package/src/kernel/compensate.test.ts +131 -0
  148. package/src/kernel/correlation.test.ts +108 -0
  149. package/src/kernel/effect-timing.test.ts +82 -0
  150. package/src/kernel/flow.ts +38 -0
  151. package/src/kernel/fx-runs.test.ts +49 -0
  152. package/src/kernel/fx.test.ts +14 -0
  153. package/src/kernel/fx.ts +111 -1
  154. package/src/manifest/types.ts +8 -2
  155. package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
  156. package/src/plugins/auth-methods.security.test.ts +3 -3
  157. package/src/plugins/auth-methods.test.ts +1 -1
  158. package/src/plugins/index.ts +2 -2
  159. package/src/plugins/otp.test.ts +21 -19
  160. package/src/plugins/otp.ts +36 -34
  161. package/src/plugins/taqnyat.live.test.ts +3 -3
  162. package/src/runs/collect.ts +3 -0
  163. package/src/runs/export-otlp.ts +64 -0
  164. package/src/runs/index.ts +16 -0
  165. package/src/runs/parquet.ts +9 -0
  166. package/src/runs/types.ts +5 -0
  167. package/src/runs/window.test.ts +68 -0
  168. package/src/runs/window.ts +148 -0
  169. package/src/runtime/boot-warn.test.ts +45 -0
  170. package/src/runtime/boot-warn.ts +17 -0
  171. package/src/runtime/dev-request-log.ts +5 -5
  172. package/src/term.test.ts +95 -10
  173. package/src/term.ts +369 -14
  174. package/src/console/ui/dist/assets/panel-overview-DONDzBd2.js +0 -1
  175. package/src/console/ui/dist/assets/panel-runs-D5zf-D9c.js +0 -1
@@ -1,28 +1,15 @@
1
1
  /**
2
- * Postgres / pgvector / Timescale image recipe (≤15 lines of recipe body).
2
+ * Postgres / pgvector image recipe (≤15 lines of recipe body).
3
3
  */
4
4
 
5
- import { credEnv } from "../helpers.ts";
6
- import type { ImageRecipe, ServiceSpec } from "../types.ts";
5
+ import { postgresEnv, postgresHealth } from "../helpers.ts";
6
+ import type { ImageRecipe } from "../types.ts";
7
7
 
8
- const postgresEnv = (s: ServiceSpec) => ({
9
- POSTGRES_USER: credEnv(s, "USER"),
10
- POSTGRES_PASSWORD: credEnv(s, "PASSWORD"),
11
- POSTGRES_DB: credEnv(s, "DB"),
12
- });
13
-
14
- const postgresHealth = {
15
- test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"],
16
- interval: "5s",
17
- timeout: "3s",
18
- retries: 10,
19
- };
20
-
21
- /** Postgres-protocol servers (Postgres · Neon · Supabase · pgvector · Timescale). */
8
+ /** Postgres-protocol servers (Postgres · pgvector). Timescale / Supabase match first. */
22
9
  export const postgres: ImageRecipe = {
23
10
  id: "postgres",
24
11
  port: 5432,
25
- match: (i) => /postgres|pgvector|timescale/i.test(i),
12
+ match: (i) => /postgres|pgvector/i.test(i),
26
13
  apply: (s) => ({ environment: postgresEnv(s), healthcheck: postgresHealth }),
27
14
  url: (_s, c) =>
28
15
  `postgres://${c.user}:${encodeURIComponent(c.password)}@${c.host}:${c.port}/${c.database}`,
@@ -0,0 +1,55 @@
1
+ /**
2
+ * SGLang image recipe — production-tier structured/agent inference.
3
+ *
4
+ * Prefer a managed provider unless you run inference yourself. Use SGLang when
5
+ * the workload is structured generation / agent-heavy on real GPU hardware;
6
+ * use vLLM for general multi-user serving. Pin an explicit tag — never
7
+ * `latest`. Host publish is loopback-only.
8
+ */
9
+
10
+ import type { ImageRecipe } from "../types.ts";
11
+
12
+ /** Pinned SGLang runtime image — never `latest`. */
13
+ export const SGLANG_IMAGE = "lmsysorg/sglang:v0.5.16-runtime";
14
+
15
+ /** SGLang — OpenAI-compatible HTTP on 30000; GPU reservation; loopback publish. */
16
+ export const sglang: ImageRecipe = {
17
+ id: "sglang",
18
+ port: 30000,
19
+ match: (i) => /sglang/i.test(i),
20
+ apply: (s) => ({
21
+ command: [
22
+ "python3",
23
+ "-m",
24
+ "sglang.launch_server",
25
+ "--model-path",
26
+ "${OKE_AI_MODEL:-Qwen/Qwen3-0.6B}",
27
+ "--host",
28
+ "0.0.0.0",
29
+ "--port",
30
+ "30000",
31
+ ],
32
+ environment: {
33
+ OKE_AI_MODEL: "${OKE_AI_MODEL:-Qwen/Qwen3-0.6B}",
34
+ HF_HOME: "/root/.cache/huggingface",
35
+ },
36
+ volumes: [`${s.serviceName}-models:/root/.cache/huggingface`],
37
+ ipc: "host",
38
+ publishBind: "127.0.0.1",
39
+ deploy: {
40
+ resources: {
41
+ reservations: {
42
+ devices: [{ driver: "nvidia", count: "all", capabilities: ["gpu"] }],
43
+ },
44
+ },
45
+ },
46
+ healthcheck: {
47
+ test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:30000/health >/dev/null || exit 1"],
48
+ interval: "10s",
49
+ timeout: "5s",
50
+ retries: 30,
51
+ start_period: "120s",
52
+ },
53
+ }),
54
+ url: (_s, c) => `http://${c.host}:${c.port}/v1`,
55
+ };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Supabase image recipe — self-hosted `supabase/postgres` (Postgres + bundled extensions).
3
+ *
4
+ * Postgres-protocol only: the Auth / Storage / Realtime / Kong / Studio layer of the
5
+ * full Supabase stack is not run here — oke already covers those with Vault, Store ·
6
+ * files, and Signal. This recipe just gets you Postgres plus Supabase's extension
7
+ * bundle (pgvector, pg_graphql, pg_cron, wrappers, …) without the rest of the platform.
8
+ */
9
+
10
+ import { postgresEnv, postgresHealth } from "../helpers.ts";
11
+ import type { ImageRecipe } from "../types.ts";
12
+
13
+ /** Supabase Postgres image — checked before the generic `postgres` recipe. */
14
+ export const supabase: ImageRecipe = {
15
+ id: "supabase",
16
+ port: 5432,
17
+ match: (i) => /supabase\/postgres/i.test(i),
18
+ apply: (s) => ({ environment: postgresEnv(s), healthcheck: postgresHealth }),
19
+ url: (_s, c) =>
20
+ `postgres://${c.user}:${encodeURIComponent(c.password)}@${c.host}:${c.port}/${c.database}`,
21
+ };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * TimescaleDB image recipe — Postgres + hypertables (same POSTGRES_* contract).
3
+ *
4
+ * Driver id stays `postgres`. Matched ahead of the generic Postgres recipe.
5
+ */
6
+
7
+ import { postgresEnv, postgresHealth } from "../helpers.ts";
8
+ import type { ImageRecipe } from "../types.ts";
9
+
10
+ /** TimescaleDB — Postgres protocol on 5432. */
11
+ export const timescale: ImageRecipe = {
12
+ id: "timescale",
13
+ port: 5432,
14
+ match: (i) => /timescale/i.test(i),
15
+ apply: (s) => ({ environment: postgresEnv(s), healthcheck: postgresHealth }),
16
+ url: (_s, c) =>
17
+ `postgres://${c.user}:${encodeURIComponent(c.password)}@${c.host}:${c.port}/${c.database}`,
18
+ };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * vLLM image recipe — production-tier local inference (`vllm/vllm-openai`).
3
+ *
4
+ * OpenAI-compatible server for multi-user GPU concurrency. Prefer a managed
5
+ * provider unless you run inference yourself. Pin an explicit tag — never
6
+ * `latest`. Host publish is loopback-only (same hard rule as llama.cpp).
7
+ */
8
+
9
+ import type { ImageRecipe } from "../types.ts";
10
+
11
+ /** Pinned vLLM OpenAI server image — never `latest`. */
12
+ export const VLLM_IMAGE = "vllm/vllm-openai:v0.26.0";
13
+
14
+ /** vLLM — OpenAI-compatible HTTP on 8000; GPU reservation; loopback publish. */
15
+ export const vllm: ImageRecipe = {
16
+ id: "vllm",
17
+ port: 8000,
18
+ match: (i) => /(?:^|\/)vllm(?:[:@/]|$)/i.test(i),
19
+ apply: (s) => ({
20
+ command: ["--model", "${OKE_AI_MODEL:-Qwen/Qwen3-0.6B}", "--host", "0.0.0.0", "--port", "8000"],
21
+ environment: {
22
+ OKE_AI_MODEL: "${OKE_AI_MODEL:-Qwen/Qwen3-0.6B}",
23
+ HF_HOME: "/root/.cache/huggingface",
24
+ },
25
+ volumes: [`${s.serviceName}-models:/root/.cache/huggingface`],
26
+ ipc: "host",
27
+ publishBind: "127.0.0.1",
28
+ deploy: {
29
+ resources: {
30
+ reservations: {
31
+ devices: [{ driver: "nvidia", count: "all", capabilities: ["gpu"] }],
32
+ },
33
+ },
34
+ },
35
+ healthcheck: {
36
+ test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8000/health >/dev/null || exit 1"],
37
+ interval: "10s",
38
+ timeout: "5s",
39
+ retries: 30,
40
+ start_period: "120s",
41
+ },
42
+ }),
43
+ url: (_s, c) => `http://${c.host}:${c.port}/v1`,
44
+ };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * YugabyteDB image recipe — self-hosted single-node YSQL (Postgres-wire).
3
+ *
4
+ * Driver id stays `postgres`. Official image: `yugabytedb/yugabyte`.
5
+ */
6
+
7
+ import { yugabyteEnv, yugabyteHealth } from "../helpers.ts";
8
+ import type { ImageRecipe } from "../types.ts";
9
+
10
+ /** YugabyteDB — YSQL on 5433. */
11
+ export const yugabyte: ImageRecipe = {
12
+ id: "yugabyte",
13
+ port: 5433,
14
+ match: (i) => /yugabyte/i.test(i),
15
+ apply: (s) => ({
16
+ environment: yugabyteEnv(s),
17
+ command: ["bin/yugabyted", "start", "--base_dir=/home/yugabyte/yb_data", "--background=false"],
18
+ healthcheck: yugabyteHealth,
19
+ volumes: [`${s.serviceName}-data:/home/yugabyte/yb_data`],
20
+ }),
21
+ url: (_s, c) =>
22
+ `postgres://${c.user}:${encodeURIComponent(c.password)}@${c.host}:${c.port}/${c.database}`,
23
+ };
@@ -3,10 +3,14 @@
3
3
  */
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
6
+ import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
6
9
  import {
7
10
  extraHostPortForInstance,
8
11
  hostPortForInstance,
9
12
  instancePortOffset,
13
+ loadExistingStackControls,
10
14
  parseStackControls,
11
15
  parseStackCredentials,
12
16
  stackAppSlug,
@@ -51,6 +55,24 @@ describe("hostPortForInstance", () => {
51
55
  });
52
56
  });
53
57
 
58
+ describe("loadExistingStackControls", () => {
59
+ test("seeds OKE_AI_MODEL from .env.local when .env.docker omits it", async () => {
60
+ const dir = await mkdtemp(join(tmpdir(), "oke-stack-ai-"));
61
+ await writeFile(join(dir, ".env.local"), "OKE_AI_MODEL=gemma4:e4b-q4_K_M\n", "utf8");
62
+ const controls = await loadExistingStackControls(dir);
63
+ expect(controls?.OKE_AI_MODEL).toBe("gemma4:e4b-q4_K_M");
64
+ });
65
+
66
+ test("prefers active OKE_AI_MODEL in docker/.env.docker over .env.local", async () => {
67
+ const dir = await mkdtemp(join(tmpdir(), "oke-stack-ai-"));
68
+ await mkdir(join(dir, "docker"), { recursive: true });
69
+ await writeFile(join(dir, "docker", ".env.docker"), "OKE_AI_MODEL=smollm2\n", "utf8");
70
+ await writeFile(join(dir, ".env.local"), "OKE_AI_MODEL=gemma4:e4b-q4_K_M\n", "utf8");
71
+ const controls = await loadExistingStackControls(dir);
72
+ expect(controls?.OKE_AI_MODEL).toBe("smollm2");
73
+ });
74
+ });
75
+
54
76
  describe("parseStackCredentials", () => {
55
77
  test("reads OKE_* credential keys", () => {
56
78
  const text = `
@@ -103,7 +103,9 @@ export function parseStackCredentials(
103
103
  map.get(`${prefix}_USER`) ?? (role === "store.files" ? map.get("S3_ACCESS_KEY_ID") : "oke");
104
104
  const password =
105
105
  map.get(`${prefix}_PASSWORD`) ??
106
- (role === "store.files" ? map.get("S3_SECRET_ACCESS_KEY") : undefined);
106
+ (role === "store.files" ? map.get("S3_SECRET_ACCESS_KEY") : undefined) ??
107
+ // Meilisearch master key is written as OKE_STORE_INDEX_KEY (not *_PASSWORD).
108
+ (role === "store.index" ? map.get("OKE_STORE_INDEX_KEY") : undefined);
107
109
  const database = map.get(`${prefix}_DB`) ?? "oke";
108
110
  if (user && password && database) {
109
111
  out[role] = { user, password, database };
@@ -128,6 +130,9 @@ export const STACK_CONTROL_KEYS = [
128
130
  "OKE_AI_MODEL",
129
131
  "OKE_PROXY_HOST",
130
132
  "OKE_PROXY_ACME_EMAIL",
133
+ // OpenBao app token — minted by bootstrap, must survive `writeDerivedFiles`.
134
+ "OKE_VAULT_TOKEN",
135
+ "OKE_VAULT_MOUNT",
131
136
  ] as const;
132
137
 
133
138
  /**
@@ -170,19 +175,48 @@ export async function loadExistingStackCredentials(
170
175
  /**
171
176
  * Load optional controls from `docker/.env.docker` when present.
172
177
  *
178
+ * Falls back to `.oke/openbao/app.token` for `OKE_VAULT_TOKEN` when the env
179
+ * file was regenerated without the bootstrap overlay (per-project token).
180
+ *
173
181
  * @param cwd - Project root
174
182
  */
175
183
  export async function loadExistingStackControls(
176
184
  cwd: string,
177
185
  ): Promise<Readonly<Record<string, string>> | undefined> {
178
186
  const candidates = [resolve(cwd, DEFAULT_DOCKER_DIR, ".env.docker"), resolve(cwd, ".env.docker")];
187
+ let controls: Record<string, string> = {};
179
188
  for (const path of candidates) {
180
189
  const file = Bun.file(path);
181
190
  if (!(await file.exists())) continue;
182
- const controls = parseStackControls(await file.text());
183
- if (Object.keys(controls).length > 0) return controls;
191
+ controls = parseStackControls(await file.text());
192
+ break;
184
193
  }
185
- return undefined;
194
+ // create-oke / `oke ai setup` write the chosen model into `.env.local`.
195
+ // Seed it into stack controls on first docker boot so `LLAMA_ARG_DOCKER_REPO`
196
+ // is not stuck on the recipe default (`smollm2`).
197
+ if (!controls.OKE_AI_MODEL) {
198
+ const localEnv = Bun.file(resolve(cwd, ".env.local"));
199
+ if (await localEnv.exists()) {
200
+ const model = parseDotenv(await localEnv.text())
201
+ .get("OKE_AI_MODEL")
202
+ ?.trim();
203
+ if (model) controls = { ...controls, OKE_AI_MODEL: model };
204
+ }
205
+ }
206
+ if (!controls.OKE_VAULT_TOKEN) {
207
+ const tokenFile = Bun.file(resolve(cwd, ".oke", "openbao", "app.token"));
208
+ if (await tokenFile.exists()) {
209
+ const token = (await tokenFile.text()).trim();
210
+ if (token) {
211
+ controls = {
212
+ ...controls,
213
+ OKE_VAULT_TOKEN: token,
214
+ OKE_VAULT_MOUNT: controls.OKE_VAULT_MOUNT ?? "secret",
215
+ };
216
+ }
217
+ }
218
+ }
219
+ return Object.keys(controls).length > 0 ? controls : undefined;
186
220
  }
187
221
 
188
222
  function parseDotenv(text: string): Map<string, string> {
@@ -56,6 +56,12 @@ export interface RecipeExtraPort {
56
56
  readonly container: number;
57
57
  }
58
58
 
59
+ /**
60
+ * Host bind for published ports — AI inference recipes set `127.0.0.1` so the
61
+ * raw API never lands on `0.0.0.0` (LAN/WAN). Omit for Docker's default bind.
62
+ */
63
+ export type RecipePublishBind = "127.0.0.1";
64
+
59
65
  /** Compose `ulimits` value — soft/hard object or a single limit. */
60
66
  export type RecipeUlimit = number | { readonly soft?: number; readonly hard?: number };
61
67
 
@@ -73,6 +79,15 @@ export interface RecipeApplyResult {
73
79
  readonly labels?: Readonly<Record<string, string>>;
74
80
  /** Additional published ports (e.g. Mailpit UI, RustFS console). */
75
81
  readonly extraPorts?: readonly RecipeExtraPort[];
82
+ /**
83
+ * Host interface for published ports. AI recipes use `127.0.0.1` — never
84
+ * omit this for inference servers (raw API must not bind `0.0.0.0`).
85
+ */
86
+ readonly publishBind?: RecipePublishBind;
87
+ /** Compose `ipc` (e.g. vLLM `host` for shared-memory workers). */
88
+ readonly ipc?: string;
89
+ /** Compose `deploy` (e.g. GPU device reservations for vLLM / SGLang). */
90
+ readonly deploy?: Readonly<Record<string, unknown>>;
76
91
  /**
77
92
  * Compose `depends_on` (e.g. pooler waits for Postgres).
78
93
  * Keys are compose service names (`store-sql`), not role keys.
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Opt-in (never auto-pull a multi-GB model in ordinary `bun test`):
5
5
  * 1. `OKE_TEST_OLLAMA_URL` — point at any reachable Ollama
6
- * 2. `OKE_TEST_OLLAMA_DOCKER=1` — start `ollama/ollama` via the recipe and pull
7
- * `qwen3.5:9b` (slow first run)
6
+ * 2. `OKE_TEST_OLLAMA_DOCKER=1` — start `ollama/ollama` via the recipe; host-side
7
+ * `ensureOllamaModel` POSTs `/api/pull` for `qwen3.5:9b` (slow first run)
8
8
  *
9
9
  * Without one of those, the suite skips with a visible reason (never an empty pass).
10
10
  * A local daemon on :11434 is not enough — set `OKE_TEST_OLLAMA_URL` explicitly.
@@ -123,8 +123,11 @@ async function resolveLiveUrl(): Promise<{ url: string; model: string }> {
123
123
  await rm(dir, { recursive: true, force: true });
124
124
  };
125
125
 
126
- // Model pull can take a long time on first run.
127
- await waitForOllama(url, 20 * 60_000, true);
126
+ // Serve must answer before we POST /api/pull (same path as `oke dev -d`).
127
+ await waitForOllama(url, 120_000, false);
128
+ const { ensureOllamaModel } = await import("../docker/ollama-pull.ts");
129
+ await ensureOllamaModel({ url, model: OLLAMA_DEFAULT_MODEL });
130
+ await waitForOllama(url, 60_000, true);
128
131
  return { url, model: OLLAMA_DEFAULT_MODEL };
129
132
  }
130
133
 
@@ -39,6 +39,8 @@ interface MutMessage {
39
39
  payload: unknown;
40
40
  /** Optional per-key ordering token (`once` serialization). */
41
41
  key: string | null;
42
+ /** Producer run id for trace parent/child joins. */
43
+ parentRunId: string | null;
42
44
  delivery: SignalDelivery;
43
45
  attempts: number;
44
46
  failures: SignalFailureReason[];
@@ -163,6 +165,7 @@ export async function createSignalEngine(
163
165
  messages = snap.messages.map((m) => ({
164
166
  ...m,
165
167
  key: m.key ?? null,
168
+ parentRunId: m.parentRunId ?? null,
166
169
  lockedBy: m.lockedBy,
167
170
  leaseExpiresAt: m.leaseExpiresAt ?? null,
168
171
  deliveredTo: new Set(m.deliveredTo),
@@ -179,6 +182,7 @@ export async function createSignalEngine(
179
182
  signal: m.signal,
180
183
  payload: m.payload,
181
184
  key: m.key,
185
+ parentRunId: m.parentRunId,
182
186
  delivery: m.delivery,
183
187
  attempts: m.attempts,
184
188
  failures: m.failures,
@@ -230,11 +234,16 @@ export async function createSignalEngine(
230
234
  const value = await validateSignalEmitPayload(name, decl, payload);
231
235
  const t = now();
232
236
  const key = typeof options?.key === "string" && options.key.length > 0 ? options.key : null;
237
+ const parentRunId =
238
+ typeof options?.parentRunId === "string" && options.parentRunId.length > 0
239
+ ? options.parentRunId
240
+ : null;
233
241
  stagedMessages.push({
234
242
  id: crypto.randomUUID(),
235
243
  signal: name,
236
244
  payload: value,
237
245
  key,
246
+ parentRunId,
238
247
  delivery: decl.delivery,
239
248
  attempts: 0,
240
249
  failures: [],
@@ -332,6 +341,7 @@ export async function createSignalEngine(
332
341
  signal: m.signal,
333
342
  payload: m.payload,
334
343
  ...(m.key !== null ? { key: m.key } : {}),
344
+ ...(m.parentRunId !== null ? { parentRunId: m.parentRunId } : {}),
335
345
  delivery: m.delivery,
336
346
  attempts: m.attempts,
337
347
  failures: [...m.failures],
@@ -44,6 +44,7 @@ interface MsgRow {
44
44
  locked_by: string | null;
45
45
  lease_expires_at: number | null;
46
46
  delivered_to: string;
47
+ parent_run_id: string | null;
47
48
  }
48
49
 
49
50
  /** Minimal SQL + listen surface for the postgres signal driver. */
@@ -113,6 +114,7 @@ export function createPostgresSignalFake(options?: {
113
114
  ...m,
114
115
  ordering_key: m.ordering_key ?? null,
115
116
  lease_expires_at: m.lease_expires_at ?? null,
117
+ parent_run_id: m.parent_run_id ?? null,
116
118
  })),
117
119
  writes: new Map(snap.writes),
118
120
  };
@@ -304,6 +306,10 @@ export function createPostgresSignalFake(options?: {
304
306
  ? null
305
307
  : Number(row.lease_expires_at),
306
308
  delivered_to: String(row.delivered_to ?? "[]"),
309
+ parent_run_id:
310
+ row.parent_run_id === undefined || row.parent_run_id === null
311
+ ? null
312
+ : String(row.parent_run_id),
307
313
  });
308
314
  return { changes: 1 };
309
315
  }
@@ -409,7 +415,8 @@ async function ensureSchema(sql: PostgresSignalSql): Promise<void> {
409
415
  status TEXT,
410
416
  locked_by TEXT,
411
417
  lease_expires_at BIGINT,
412
- delivered_to TEXT
418
+ delivered_to TEXT,
419
+ parent_run_id TEXT
413
420
  )`);
414
421
  // Existing tables created before leases / keys: add columns in place.
415
422
  try {
@@ -424,6 +431,11 @@ async function ensureSchema(sql: PostgresSignalSql): Promise<void> {
424
431
  } catch {
425
432
  /* fake / older engines without IF NOT EXISTS — ignore */
426
433
  }
434
+ try {
435
+ await sql.exec(`ALTER TABLE oke_signal_messages ADD COLUMN IF NOT EXISTS parent_run_id TEXT`);
436
+ } catch {
437
+ /* fake / older engines without IF NOT EXISTS — ignore */
438
+ }
427
439
  await sql.exec(`CREATE TABLE IF NOT EXISTS oke_signal_writes (
428
440
  key TEXT PRIMARY KEY,
429
441
  value TEXT
@@ -435,11 +447,16 @@ function rowToMessage(row: Record<string, unknown>): SignalMessage {
435
447
  row.ordering_key === undefined || row.ordering_key === null || row.ordering_key === ""
436
448
  ? undefined
437
449
  : String(row.ordering_key);
450
+ const parentRunId =
451
+ row.parent_run_id === undefined || row.parent_run_id === null || row.parent_run_id === ""
452
+ ? undefined
453
+ : String(row.parent_run_id);
438
454
  return {
439
455
  id: String(row.id),
440
456
  signal: String(row.signal),
441
457
  payload: JSON.parse(String(row.payload)),
442
458
  ...(key !== undefined ? { key } : {}),
459
+ ...(parentRunId !== undefined ? { parentRunId } : {}),
443
460
  delivery: row.delivery as SignalDelivery,
444
461
  attempts: Number(row.attempts),
445
462
  failures: JSON.parse(String(row.failures ?? "[]")) as SignalFailureReason[],
@@ -554,8 +571,12 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
554
571
  const t = now();
555
572
  const orderingKey =
556
573
  typeof options?.key === "string" && options.key.length > 0 ? options.key : null;
574
+ const parentRunId =
575
+ typeof options?.parentRunId === "string" && options.parentRunId.length > 0
576
+ ? options.parentRunId
577
+ : null;
557
578
  await tx.exec(
558
- `INSERT INTO oke_signal_messages (id, signal, payload, ordering_key, delivery, attempts, failures, created_at, available_at, status, locked_by, lease_expires_at, delivered_to) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
579
+ `INSERT INTO oke_signal_messages (id, signal, payload, ordering_key, delivery, attempts, failures, created_at, available_at, status, locked_by, lease_expires_at, delivered_to, parent_run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
559
580
  [
560
581
  crypto.randomUUID(),
561
582
  signal,
@@ -570,6 +591,7 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
570
591
  null,
571
592
  null,
572
593
  "[]",
594
+ parentRunId,
573
595
  ],
574
596
  );
575
597
  }
@@ -40,6 +40,11 @@ export interface SignalEmitOptions {
40
40
  * Omit for pure competing-consumer behavior (no ordering guarantee).
41
41
  */
42
42
  readonly key?: string;
43
+ /**
44
+ * Producer WideEvent / run id for Console trace chains.
45
+ * Set automatically by `fx.emit` when the emitter has a run id.
46
+ */
47
+ readonly parentRunId?: string;
43
48
  }
44
49
 
45
50
  /** A durable signal message. */
@@ -49,6 +54,11 @@ export interface SignalMessage {
49
54
  readonly payload: unknown;
50
55
  /** Optional ordering key from {@link SignalEmitOptions.key}. */
51
56
  readonly key?: string;
57
+ /**
58
+ * Producer run id when the emit originated from a Flow execution.
59
+ * Consuming Runs record this as {@link import("../runs/types.ts").WideEvent.parentId}.
60
+ */
61
+ readonly parentRunId?: string;
52
62
  readonly delivery: SignalDelivery;
53
63
  readonly attempts: number;
54
64
  readonly failures: readonly SignalFailureReason[];
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Same-medium FallbackTransport adversarial proofs:
3
+ * - provider error advances the chain and delivers via secondary
4
+ * - permanent client errors (status 400 / invalid address) do NOT failover
5
+ * (aligned with shouldFallbackOtpMedium)
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { channel, createChannelRuntime } from "../channel.ts";
10
+ import { countingOkTransport, driverFromTransport, failingTransport } from "./test-helpers.ts";
11
+
12
+ describe("FallbackTransport same-medium failover", () => {
13
+ test("provider error advances to secondary and delivers", async () => {
14
+ const secondary = countingOkTransport("resend");
15
+ const runtime = createChannelRuntime({
16
+ templates: [channel.template("booking", { medium: "email" })],
17
+ drivers: [
18
+ driverFromTransport(
19
+ "smtp",
20
+ failingTransport(
21
+ "smtp",
22
+ Object.assign(new Error("provider timeout"), { statusCode: 500 }),
23
+ ),
24
+ ),
25
+ driverFromTransport("resend", secondary.transport),
26
+ ],
27
+ catalog: { booking: { en: { subject: "Booked", text: "ok" } } },
28
+ });
29
+
30
+ const result = await runtime.send("booking", {
31
+ to: "user@example.com",
32
+ via: ["smtp", "resend"],
33
+ });
34
+
35
+ expect(result.ok).toBe(true);
36
+ expect(secondary.calls.count).toBe(1);
37
+ expect(result.attempts.some((a) => a.driverId === "smtp" && !a.ok)).toBe(true);
38
+ expect(result.attempts.some((a) => a.ok)).toBe(true);
39
+
40
+ const receipt = runtime.receipts.forTemplate("booking")[0]!;
41
+ expect(receipt.status).toBe("fallback");
42
+ expect(receipt.attempts.length).toBeGreaterThanOrEqual(2);
43
+ });
44
+
45
+ test("statusCode 400 does not advance to secondary", async () => {
46
+ const secondary = countingOkTransport("resend");
47
+ const runtime = createChannelRuntime({
48
+ templates: [channel.template("booking", { medium: "email" })],
49
+ drivers: [
50
+ driverFromTransport(
51
+ "smtp",
52
+ failingTransport("smtp", Object.assign(new Error("bad request"), { statusCode: 400 })),
53
+ ),
54
+ driverFromTransport("resend", secondary.transport),
55
+ ],
56
+ });
57
+
58
+ const result = await runtime.send("booking", {
59
+ to: "user@example.com",
60
+ via: ["smtp", "resend"],
61
+ });
62
+
63
+ expect(secondary.calls.count).toBe(0);
64
+ expect(result.ok).toBe(false);
65
+ expect(result.attempts.every((a) => a.driverId !== "resend")).toBe(true);
66
+ });
67
+
68
+ test("invalid recipient address does not advance to secondary", async () => {
69
+ const secondary = countingOkTransport("resend");
70
+ const runtime = createChannelRuntime({
71
+ templates: [channel.template("booking", { medium: "email" })],
72
+ drivers: [
73
+ driverFromTransport(
74
+ "smtp",
75
+ failingTransport("smtp", new Error("invalid recipient address")),
76
+ ),
77
+ driverFromTransport("resend", secondary.transport),
78
+ ],
79
+ });
80
+
81
+ const result = await runtime.send("booking", {
82
+ to: "not-an-email",
83
+ via: ["smtp", "resend"],
84
+ });
85
+
86
+ expect(secondary.calls.count).toBe(0);
87
+ expect(result.ok).toBe(false);
88
+ const receipt = runtime.receipts.forTemplate("booking")[0]!;
89
+ expect(receipt.status).toBe("blocked/invalid-address");
90
+ });
91
+ });