okengine 0.6.0 → 0.7.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 (162) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +98 -71
  3. package/package.json +4 -4
  4. package/site/content/docs/console/clock.mdx +25 -5
  5. package/site/content/docs/console/signals.mdx +13 -4
  6. package/site/content/docs/elements/ai.mdx +29 -2
  7. package/site/content/docs/elements/channel.mdx +90 -20
  8. package/site/content/docs/elements/clock.mdx +56 -24
  9. package/site/content/docs/elements/flow.mdx +2 -0
  10. package/site/content/docs/elements/gate.mdx +8 -1
  11. package/site/content/docs/elements/signal.mdx +173 -29
  12. package/site/content/docs/elements/store.mdx +464 -73
  13. package/site/content/docs/elements/vault.mdx +15 -5
  14. package/site/content/docs/get-started/basic-usage.mdx +13 -6
  15. package/site/content/docs/get-started/installation.mdx +49 -13
  16. package/site/content/docs/get-started/introduction.mdx +1 -1
  17. package/site/content/docs/reference/cli.md +30 -2
  18. package/site/content/docs/reference/configuration.mdx +18 -17
  19. package/site/content/docs/reference/environment-variables.mdx +44 -18
  20. package/site/content/docs/reference/errors.mdx +12 -11
  21. package/site/content/docs/reference/fx.mdx +15 -13
  22. package/site/content/docs/reference/security.md +3 -1
  23. package/src/cli/ai-setup/ai-setup.test.ts +144 -0
  24. package/src/cli/ai-setup/apply.ts +214 -0
  25. package/src/cli/ai-setup/catalog.ts +263 -0
  26. package/src/cli/ai-setup/detect-ollama.ts +166 -0
  27. package/src/cli/ai-setup/index.ts +228 -0
  28. package/src/cli/ai-setup/prompts.ts +649 -0
  29. package/src/cli/ai-setup/recommend.test.ts +100 -0
  30. package/src/cli/ai-setup/recommend.ts +203 -0
  31. package/src/cli/ai.ts +38 -0
  32. package/src/cli/db-auto-push.test.ts +6 -1
  33. package/src/cli/db-auto-push.ts +8 -3
  34. package/src/cli/dev-db-push.test.ts +59 -0
  35. package/src/cli/dev.test.ts +40 -0
  36. package/src/cli/dev.ts +55 -1
  37. package/src/cli/docker-clean.test.ts +179 -0
  38. package/src/cli/docker-clean.ts +406 -0
  39. package/src/cli/docker.ts +16 -4
  40. package/src/cli/ensure-drizzle-config.ts +1 -1
  41. package/src/cli/hero-meta.test.ts +6 -5
  42. package/src/cli/hero-meta.ts +7 -1
  43. package/src/cli/index.ts +5 -0
  44. package/src/cli/load-config.ts +4 -4
  45. package/src/cli/openbao-bootstrap.test.ts +1 -0
  46. package/src/cli/openbao-bootstrap.ts +9 -2
  47. package/src/cli/openbao-restart.integration.test.ts +106 -97
  48. package/src/cli/registry.ts +79 -1
  49. package/src/client/live-gap.test.ts +35 -0
  50. package/src/client/transport.test.ts +21 -0
  51. package/src/client/transport.ts +27 -5
  52. package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
  53. package/src/config/define-config.test.ts +6 -6
  54. package/src/config/index.ts +1 -1
  55. package/src/console/server/console.test.ts +45 -0
  56. package/src/console/server/flows.ts +39 -9
  57. package/src/console/server/i18n.ts +41 -0
  58. package/src/console/server/vault.ts +12 -38
  59. package/src/console/ui/dist/assets/index-ClX0ZCe5.js +10 -0
  60. package/src/console/ui/dist/assets/{panel-access-BGv45snf.js → panel-access-ri1X9Otj.js} +1 -1
  61. package/src/console/ui/dist/assets/{panel-ai-B2S7LEii.js → panel-ai-DLHjRoJk.js} +1 -1
  62. package/src/console/ui/dist/assets/{panel-architecture-D7UJh91v.js → panel-architecture-DI3bQF3J.js} +1 -1
  63. package/src/console/ui/dist/assets/{panel-channels-9T3ybqRu.js → panel-channels-BDYWX29p.js} +1 -1
  64. package/src/console/ui/dist/assets/{panel-clock-Cb1UXGRQ.js → panel-clock-i-JpTjVB.js} +1 -1
  65. package/src/console/ui/dist/assets/{panel-diff-DmYbKWmN.js → panel-diff-CFuWxiXy.js} +1 -1
  66. package/src/console/ui/dist/assets/{panel-flows-PiHwT55z.js → panel-flows-B8TRhcoe.js} +1 -1
  67. package/src/console/ui/dist/assets/{panel-gates-BQGYXvjT.js → panel-gates-CfkBgco7.js} +1 -1
  68. package/src/console/ui/dist/assets/{panel-overview-BBnRO18l.js → panel-overview-DONDzBd2.js} +1 -1
  69. package/src/console/ui/dist/assets/{panel-plugins-D0PsmVw2.js → panel-plugins-DDJwTehL.js} +1 -1
  70. package/src/console/ui/dist/assets/{panel-runs-CWuRDe0r.js → panel-runs-D5zf-D9c.js} +1 -1
  71. package/src/console/ui/dist/assets/{panel-signals-Bbg4ewpP.js → panel-signals-Dsxdu_AR.js} +1 -1
  72. package/src/console/ui/dist/assets/{panel-store-CPCbsDRa.js → panel-store-hy7O8HOs.js} +1 -1
  73. package/src/console/ui/dist/assets/{panel-traces-DVAzuA_S.js → panel-traces-CBiAe5go.js} +1 -1
  74. package/src/console/ui/dist/assets/{panel-vault-D1_MvOmo.js → panel-vault-Za1GyJfM.js} +1 -1
  75. package/src/console/ui/dist/index.html +1 -1
  76. package/src/console/ui/shell/components/ui.tsx +5 -1
  77. package/src/console/ui/shell/setup/Wizard.tsx +26 -5
  78. package/src/docker/cleanup.test.ts +193 -0
  79. package/src/docker/cleanup.ts +355 -0
  80. package/src/docker/docker.test.ts +4 -0
  81. package/src/docker/dockerfile.integration.test.ts +126 -119
  82. package/src/docker/index.ts +19 -0
  83. package/src/docker/recipes/ollama.ts +10 -5
  84. package/src/docker/stack.integration.test.ts +118 -102
  85. package/src/drivers/ai-ollama-tools.integration.test.ts +8 -6
  86. package/src/drivers/ai-ollama.integration.test.ts +3 -19
  87. package/src/drivers/ai-ollama.ts +45 -2
  88. package/src/drivers/ai-providers.test.ts +31 -0
  89. package/src/drivers/channel-fcm.ts +49 -53
  90. package/src/drivers/channel-msegat.ts +61 -0
  91. package/src/drivers/channel-sently-map.ts +57 -0
  92. package/src/drivers/channel-sently.test.ts +99 -0
  93. package/src/drivers/channel-sndr.ts +28 -0
  94. package/src/drivers/channel-taqnyat.ts +57 -0
  95. package/src/drivers/channel-types.ts +79 -2
  96. package/src/drivers/channel-unifonic.ts +26 -43
  97. package/src/drivers/channel-wa-cloud.ts +33 -47
  98. package/src/drivers/channel-webpush.ts +39 -239
  99. package/src/drivers/index.ts +7 -0
  100. package/src/drivers/signal-engine.ts +98 -25
  101. package/src/drivers/signal-nats.ts +4 -4
  102. package/src/drivers/signal-postgres.ts +158 -64
  103. package/src/drivers/signal-redis.ts +4 -4
  104. package/src/drivers/signal-types.ts +61 -2
  105. package/src/drivers/vault-driver-removal.test.ts +5 -1
  106. package/src/elements/channel/costs.test.ts +2 -2
  107. package/src/elements/channel/costs.ts +14 -2
  108. package/src/elements/channel/mime.ts +11 -0
  109. package/src/elements/channel/runtime.ts +94 -0
  110. package/src/elements/channel/sndr-webhooks.test.ts +26 -0
  111. package/src/elements/channel.ts +10 -1
  112. package/src/elements/clock/chaos-child.ts +150 -0
  113. package/src/elements/clock/chaos.test.ts +204 -0
  114. package/src/elements/clock/reconcile.ts +110 -0
  115. package/src/elements/clock/runtime.ts +11 -0
  116. package/src/elements/clock.test.ts +164 -2
  117. package/src/elements/clock.ts +1 -0
  118. package/src/elements/gate/runtime.ts +6 -0
  119. package/src/elements/index.ts +9 -0
  120. package/src/elements/signal/chaos-child.ts +41 -4
  121. package/src/elements/signal/declare.ts +4 -1
  122. package/src/elements/signal/delivery-modes.test.ts +179 -0
  123. package/src/elements/signal/key-ordering.test.ts +306 -0
  124. package/src/elements/signal/lease-reclaim.test.ts +123 -0
  125. package/src/elements/signal/optional-emit.test.ts +76 -0
  126. package/src/elements/signal/order-lifecycle.test.ts +130 -0
  127. package/src/elements/signal/orphan-messages.test.ts +152 -0
  128. package/src/elements/signal/runtime.ts +11 -4
  129. package/src/elements/signal/schema-emit.test.ts +107 -0
  130. package/src/elements/signal.test.ts +89 -5
  131. package/src/elements/store/files-fx.ts +104 -0
  132. package/src/elements/store/files-image.test.ts +251 -0
  133. package/src/elements/store/files-image.ts +494 -0
  134. package/src/elements/store/runtime.ts +60 -0
  135. package/src/elements/store.ts +24 -0
  136. package/src/elements/vault/boot-chain.ts +150 -0
  137. package/src/elements/vault/runtime.ts +8 -0
  138. package/src/kernel/app.ts +3 -1
  139. package/src/kernel/boot-bind/channel.test.ts +68 -3
  140. package/src/kernel/boot-bind/channel.ts +93 -2
  141. package/src/kernel/boot-bind/clock.ts +52 -7
  142. package/src/kernel/boot-bind/gate.ts +71 -3
  143. package/src/kernel/boot-bind/honor-config.test.ts +247 -0
  144. package/src/kernel/boot-bind/signal.ts +64 -7
  145. package/src/kernel/boot-bind/vault.ts +27 -8
  146. package/src/kernel/boot.ts +10 -2
  147. package/src/kernel/errors.ts +8 -2
  148. package/src/kernel/fx.test.ts +13 -0
  149. package/src/kernel/fx.ts +27 -4
  150. package/src/plugins/auth-delivery.mailpit.integration.test.ts +10 -4
  151. package/src/release/exports.test.ts +26 -0
  152. package/src/release/exports.ts +64 -5
  153. package/src/release/index.ts +5 -0
  154. package/src/release/measure.exports.test.ts +13 -1
  155. package/src/release/measure.ts +76 -13
  156. package/src/release/official-plugins.ts +46 -0
  157. package/src/release/readme.test.ts +30 -2
  158. package/src/runtime/dev-request-log.test.ts +20 -1
  159. package/src/runtime/dev-request-log.ts +38 -0
  160. package/src/term.test.ts +15 -0
  161. package/src/term.ts +8 -3
  162. package/src/console/ui/dist/assets/index-CjxwRGVv.js +0 -10
@@ -8,14 +8,19 @@
8
8
 
9
9
  import type { ImageRecipe } from "../types.ts";
10
10
 
11
- /** Start serve, pull configured model, keep serve in foreground. */
11
+ /**
12
+ * Start serve, pull configured model, keep serve in foreground.
13
+ *
14
+ * Shell `$` must be written as `$$` so Compose does not interpolate `pid` /
15
+ * `i` as project env (same pattern as the redis recipe).
16
+ */
12
17
  const OLLAMA_BOOT = [
13
18
  "set -e",
14
19
  "/bin/ollama serve &",
15
- "pid=$!",
16
- 'i=0; until /bin/ollama list >/dev/null 2>&1; do i=$((i+1)); [ "$i" -lt 90 ] || exit 1; sleep 1; done',
17
- '/bin/ollama pull "${OKE_AI_MODEL:-qwen3.5:9b}"',
18
- "wait $pid",
20
+ "pid=$$!",
21
+ 'i=0; until /bin/ollama list >/dev/null 2>&1; do i=$$((i+1)); [ "$$i" -lt 90 ] || exit 1; sleep 1; done',
22
+ '/bin/ollama pull "$${OKE_AI_MODEL:-qwen3.5:9b}"',
23
+ "wait $$pid",
19
24
  ].join("; ");
20
25
 
21
26
  /** Ollama local model server. API on 11434. */
@@ -1,5 +1,7 @@
1
1
  /**
2
2
  * Integration: generated compose brings up postgres; app URL talks to it.
3
+ *
4
+ * Opt-in via `OKE_TEST_DOCKER=1` plus a live Docker daemon. Never an empty pass.
3
5
  */
4
6
 
5
7
  import { describe, expect, test } from "bun:test";
@@ -8,127 +10,141 @@ import { tmpdir } from "node:os";
8
10
  import { join } from "node:path";
9
11
  import { deriveInfrastructure, formatStackEnv, writeDerivedFiles } from "./index.ts";
10
12
 
11
- async function dockerAvailable(): Promise<boolean> {
13
+ function dockerAvailable(): boolean {
12
14
  try {
13
- const proc = Bun.spawn(["docker", "info"], {
14
- stdout: "pipe",
15
- stderr: "pipe",
16
- });
17
- return (await proc.exited) === 0;
15
+ return Bun.spawnSync(["docker", "info"], { stdout: "pipe", stderr: "pipe" }).exitCode === 0;
18
16
  } catch {
19
17
  return false;
20
18
  }
21
19
  }
22
20
 
23
- describe("oke dev --docker postgres integration", () => {
24
- test("compose brings up postgres and the app talks to it", async () => {
25
- if (!(await dockerAvailable())) {
26
- console.warn("skipping: docker daemon not available");
27
- return;
28
- }
29
-
30
- const dir = await mkdtemp(join(tmpdir(), "oke-stack-pg-"));
31
- const dockerDir = join(dir, "docker");
32
- const project = `oke-pg-${Date.now()}`;
33
- try {
34
- const derived = deriveInfrastructure({
35
- images: { "store.sql": "postgres:18-alpine" },
36
- credentials: {
37
- "store.sql": {
38
- user: "oke",
39
- password: "stack-integration-pass",
40
- database: "oke",
41
- },
42
- },
43
- app: "stacktest",
44
- host: "127.0.0.1",
45
- includeApp: false,
46
- composeDir: "docker",
47
- });
48
- await writeDerivedFiles(derived, dockerDir, {
49
- writeStackEnv: true,
50
- });
21
+ const WANT = process.env.OKE_TEST_DOCKER === "1";
22
+ const DOCKER = WANT && dockerAvailable();
23
+ if (!DOCKER) {
24
+ console.log(
25
+ WANT
26
+ ? "skip: docker postgres stack e2e (docker daemon not available)"
27
+ : "skip: docker postgres stack e2e (OKE_TEST_DOCKER≠1)",
28
+ );
29
+ }
30
+ const live = DOCKER ? test : test.skip;
51
31
 
52
- // Infra-only: network + role compose (no app build).
53
- const composeFiles = ["compose.yml", "compose.store.sql.yml"];
54
- const up = Bun.spawn(
55
- ["docker", "compose", "-p", project, ...composeFiles.flatMap((f) => ["-f", f]), "up", "-d"],
56
- {
57
- cwd: dockerDir,
58
- stdout: "pipe",
59
- stderr: "pipe",
60
- env: {
61
- ...process.env,
62
- ...derived.stackEnv,
32
+ describe("oke dev --docker postgres integration", () => {
33
+ live(
34
+ "compose brings up postgres and the app talks to it",
35
+ async () => {
36
+ const dir = await mkdtemp(join(tmpdir(), "oke-stack-pg-"));
37
+ const dockerDir = join(dir, "docker");
38
+ const project = `oke-pg-${Date.now()}`;
39
+ try {
40
+ const derived = deriveInfrastructure({
41
+ images: { "store.sql": "postgres:18-alpine" },
42
+ credentials: {
43
+ "store.sql": {
44
+ user: "oke",
45
+ password: "stack-integration-pass",
46
+ database: "oke",
47
+ },
63
48
  },
64
- },
65
- );
66
- const [upOut, upErr, upCode] = await Promise.all([
67
- new Response(up.stdout).text(),
68
- new Response(up.stderr).text(),
69
- up.exited,
70
- ]);
71
- expect(upCode).toBe(0);
72
- if (upCode !== 0) console.error(upOut, upErr);
49
+ app: "stacktest",
50
+ host: "127.0.0.1",
51
+ includeApp: false,
52
+ composeDir: "docker",
53
+ });
54
+ await writeDerivedFiles(derived, dockerDir, {
55
+ writeStackEnv: true,
56
+ });
73
57
 
74
- // Wait for healthy
75
- let healthy = false;
76
- for (let i = 0; i < 40; i++) {
77
- const ps = Bun.spawn(
58
+ // Infra-only: network + role compose (no app build).
59
+ const composeFiles = ["compose.yml", "compose.store.sql.yml"];
60
+ const up = Bun.spawn(
78
61
  [
79
62
  "docker",
80
63
  "compose",
81
64
  "-p",
82
65
  project,
83
66
  ...composeFiles.flatMap((f) => ["-f", f]),
84
- "ps",
85
- "--format",
86
- "json",
67
+ "up",
68
+ "-d",
87
69
  ],
88
- { cwd: dockerDir, stdout: "pipe", stderr: "pipe" },
70
+ {
71
+ cwd: dockerDir,
72
+ stdout: "pipe",
73
+ stderr: "pipe",
74
+ env: {
75
+ ...process.env,
76
+ ...derived.stackEnv,
77
+ },
78
+ },
89
79
  );
90
- const text = await new Response(ps.stdout).text();
91
- await ps.exited;
92
- if (/healthy/i.test(text)) {
93
- healthy = true;
94
- break;
80
+ const [upOut, upErr, upCode] = await Promise.all([
81
+ new Response(up.stdout).text(),
82
+ new Response(up.stderr).text(),
83
+ up.exited,
84
+ ]);
85
+ expect(upCode).toBe(0);
86
+ if (upCode !== 0) console.error(upOut, upErr);
87
+
88
+ // Wait for healthy
89
+ let healthy = false;
90
+ for (let i = 0; i < 40; i++) {
91
+ const ps = Bun.spawn(
92
+ [
93
+ "docker",
94
+ "compose",
95
+ "-p",
96
+ project,
97
+ ...composeFiles.flatMap((f) => ["-f", f]),
98
+ "ps",
99
+ "--format",
100
+ "json",
101
+ ],
102
+ { cwd: dockerDir, stdout: "pipe", stderr: "pipe" },
103
+ );
104
+ const text = await new Response(ps.stdout).text();
105
+ await ps.exited;
106
+ if (/healthy/i.test(text)) {
107
+ healthy = true;
108
+ break;
109
+ }
110
+ await Bun.sleep(500);
95
111
  }
96
- await Bun.sleep(500);
97
- }
98
- expect(healthy).toBe(true);
112
+ expect(healthy).toBe(true);
99
113
 
100
- const url = derived.stackEnv.DATABASE_URL!;
101
- // App talks to postgres via recipe URL (Bun.SQL) — kernel never sees env-var names.
102
- const sql = new Bun.SQL(url);
103
- try {
104
- const rows = (await sql`select 1::int as n`) as Array<{ n: number }>;
105
- expect(rows[0]?.n).toBe(1);
114
+ const url = derived.stackEnv.DATABASE_URL!;
115
+ // App talks to postgres via recipe URL (Bun.SQL) — kernel never sees env-var names.
116
+ const sql = new Bun.SQL(url);
117
+ try {
118
+ const rows = (await sql`select 1::int as n`) as Array<{ n: number }>;
119
+ expect(rows[0]?.n).toBe(1);
120
+ } finally {
121
+ await sql.close();
122
+ }
123
+
124
+ // Prove credentials live in docker/.env.docker, not YAML.
125
+ const yml = await Bun.file(join(dockerDir, "compose.store.sql.yml")).text();
126
+ expect(yml).not.toContain("stack-integration-pass");
127
+ expect(formatStackEnv(derived.stackEnv)).toContain("stack-integration-pass");
128
+ expect(await Bun.file(join(dockerDir, ".env.docker")).exists()).toBe(true);
106
129
  } finally {
107
- await sql.close();
130
+ await Bun.spawn(
131
+ [
132
+ "docker",
133
+ "compose",
134
+ "-p",
135
+ project,
136
+ "-f",
137
+ "compose.yml",
138
+ "-f",
139
+ "compose.store.sql.yml",
140
+ "down",
141
+ "-v",
142
+ ],
143
+ { cwd: dockerDir, stdout: "pipe", stderr: "pipe" },
144
+ ).exited.catch(() => {});
145
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
108
146
  }
109
-
110
- // Prove credentials live in docker/.env.docker, not YAML.
111
- const yml = await Bun.file(join(dockerDir, "compose.store.sql.yml")).text();
112
- expect(yml).not.toContain("stack-integration-pass");
113
- expect(formatStackEnv(derived.stackEnv)).toContain("stack-integration-pass");
114
- expect(await Bun.file(join(dockerDir, ".env.docker")).exists()).toBe(true);
115
- } finally {
116
- await Bun.spawn(
117
- [
118
- "docker",
119
- "compose",
120
- "-p",
121
- project,
122
- "-f",
123
- "compose.yml",
124
- "-f",
125
- "compose.store.sql.yml",
126
- "down",
127
- "-v",
128
- ],
129
- { cwd: dockerDir, stdout: "pipe", stderr: "pipe" },
130
- ).exited.catch(() => {});
131
- await rm(dir, { recursive: true, force: true }).catch(() => {});
132
- }
133
- }, 120_000);
147
+ },
148
+ 120_000,
149
+ );
134
150
  });
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Live Ollama tool-calling through fx.ask → fx.call (capability + ledger).
3
3
  *
4
- * Same skip-visible convention as ai-ollama.integration.test.ts.
4
+ * Opt-in via `OKE_TEST_OLLAMA_URL` (reachable Ollama). A local daemon on
5
+ * :11434 is not enough — set the URL explicitly. Never an empty pass.
5
6
  */
6
7
 
7
8
  import { describe, expect, test } from "bun:test";
@@ -11,7 +12,6 @@ import { createGateRuntime, gate } from "../elements/gate.ts";
11
12
  import { createFxContext } from "../kernel/fx.ts";
12
13
  import { OLLAMA_DEFAULT_MODEL, openOllama } from "./ai-ollama.ts";
13
14
 
14
- const DEFAULT_LOCAL = "http://127.0.0.1:11434";
15
15
  const ENV_URL = process.env.OKE_TEST_OLLAMA_URL?.trim();
16
16
 
17
17
  async function probeOllama(url: string): Promise<boolean> {
@@ -23,12 +23,14 @@ async function probeOllama(url: string): Promise<boolean> {
23
23
  }
24
24
  }
25
25
 
26
- const localUp = ENV_URL ? await probeOllama(ENV_URL) : await probeOllama(DEFAULT_LOCAL);
27
- const canLive = Boolean(ENV_URL) || localUp;
26
+ const reachable = ENV_URL ? await probeOllama(ENV_URL) : false;
27
+ const canLive = Boolean(ENV_URL) && reachable;
28
28
 
29
29
  if (!canLive) {
30
30
  console.log(
31
- "skip: live ollama tool-calling e2e (OKE_TEST_OLLAMA_URL not set; no Ollama on :11434)",
31
+ ENV_URL
32
+ ? `skip: live ollama tool-calling e2e (unreachable at ${ENV_URL})`
33
+ : "skip: live ollama tool-calling e2e (OKE_TEST_OLLAMA_URL not set)",
32
34
  );
33
35
  }
34
36
  const live = canLive ? test : test.skip;
@@ -37,7 +39,7 @@ describe("ollama live — tool-calling via fx.call", () => {
37
39
  live(
38
40
  "gated ask → real tool call → capability ledger portal entry",
39
41
  async () => {
40
- const url = ENV_URL || DEFAULT_LOCAL;
42
+ const url = ENV_URL!;
41
43
  const model = process.env.OKE_AI_MODEL?.trim() || OLLAMA_DEFAULT_MODEL;
42
44
 
43
45
  const kv = await memoryKvDriver.open({ name: "ollama-tools-rate" });
@@ -3,11 +3,11 @@
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. Ollama already listening on `http://127.0.0.1:11434`
7
- * 3. `OKE_TEST_OLLAMA_DOCKER=1` — start `ollama/ollama` via the recipe and pull
6
+ * 2. `OKE_TEST_OLLAMA_DOCKER=1` start `ollama/ollama` via the recipe and pull
8
7
  * `qwen3.5:9b` (slow first run)
9
8
  *
10
9
  * Without one of those, the suite skips with a visible reason (never an empty pass).
10
+ * A local daemon on :11434 is not enough — set `OKE_TEST_OLLAMA_URL` explicitly.
11
11
  */
12
12
 
13
13
  import { afterAll, describe, expect, test } from "bun:test";
@@ -20,7 +20,6 @@ import { bindAi } from "../kernel/boot-bind/ai.ts";
20
20
  import { OLLAMA_DEFAULT_MODEL, openOllama } from "./ai-ollama.ts";
21
21
 
22
22
  const OLLAMA_IMAGE = "ollama/ollama:latest";
23
- const DEFAULT_LOCAL = "http://127.0.0.1:11434";
24
23
  const ENV_URL = process.env.OKE_TEST_OLLAMA_URL?.trim();
25
24
  const WANT_DOCKER = process.env.OKE_TEST_OLLAMA_DOCKER === "1";
26
25
 
@@ -32,23 +31,12 @@ function dockerAvailable(): boolean {
32
31
  }
33
32
  }
34
33
 
35
- async function probeOllama(url: string): Promise<boolean> {
36
- try {
37
- const res = await fetch(`${url}/api/tags`, { signal: AbortSignal.timeout(1_500) });
38
- return res.ok;
39
- } catch {
40
- return false;
41
- }
42
- }
43
-
44
34
  const DOCKER = dockerAvailable();
45
- const localUp = ENV_URL ? false : await probeOllama(DEFAULT_LOCAL);
46
- const canLive = Boolean(ENV_URL) || localUp || (WANT_DOCKER && DOCKER);
35
+ const canLive = Boolean(ENV_URL) || (WANT_DOCKER && DOCKER);
47
36
 
48
37
  if (!canLive) {
49
38
  const reasons: string[] = [];
50
39
  if (!ENV_URL) reasons.push("OKE_TEST_OLLAMA_URL not set");
51
- if (!localUp) reasons.push("no Ollama on :11434");
52
40
  if (!WANT_DOCKER) reasons.push("OKE_TEST_OLLAMA_DOCKER≠1");
53
41
  else if (!DOCKER) reasons.push("docker daemon not available");
54
42
  console.log(`skip: live ollama e2e (${reasons.join("; ")})`);
@@ -94,10 +82,6 @@ async function resolveLiveUrl(): Promise<{ url: string; model: string }> {
94
82
  return { url: ENV_URL, model: process.env.OKE_AI_MODEL?.trim() || OLLAMA_DEFAULT_MODEL };
95
83
  }
96
84
 
97
- if (localUp) {
98
- return { url: DEFAULT_LOCAL, model: process.env.OKE_AI_MODEL?.trim() || OLLAMA_DEFAULT_MODEL };
99
- }
100
-
101
85
  const dir = await mkdtemp(join(tmpdir(), "oke-ollama-"));
102
86
  const dockerDir = join(dir, "docker");
103
87
  const project = `oke-ollama-${Date.now()}`;
@@ -7,14 +7,17 @@
7
7
  * configured but unreachable throws {@link OllamaUnavailableError} — never a
8
8
  * silent mock fallback.
9
9
  *
10
- * Native API: `POST /api/chat` (not the OpenAI-compat shim). Default base URL
11
- * `http://127.0.0.1:11434`. Supports `complete`, `stream` (NDJSON), and tools.
10
+ * Native API: `POST /api/chat` and `POST /api/embeddings` (not the OpenAI-compat
11
+ * shim). Default base URL `http://127.0.0.1:11434`. Supports `complete`,
12
+ * `stream` (NDJSON), `embed`, and tools.
12
13
  */
13
14
 
14
15
  import type {
15
16
  AiCompleteOptions,
16
17
  AiCompleteResult,
17
18
  AiDriver,
19
+ AiEmbedOptions,
20
+ AiEmbedResult,
18
21
  AiModelClient,
19
22
  AiOpenOptions,
20
23
  AiStreamChunk,
@@ -158,6 +161,41 @@ export async function openOllama(options: AiOpenOptions = {}): Promise<AiModelCl
158
161
  }
159
162
  yield* readOllamaNdjson(res.body, opts.signal);
160
163
  },
164
+ async embed(opts: AiEmbedOptions): Promise<AiEmbedResult> {
165
+ const resolvedModel = resolveOllamaModel(options, opts.model);
166
+ const inputs = Array.isArray(opts.input) ? opts.input : [opts.input];
167
+ const vectors: number[][] = [];
168
+
169
+ for (const prompt of inputs) {
170
+ let res: Response;
171
+ try {
172
+ res = await fetchFn(`${baseUrl}/api/embeddings`, {
173
+ method: "POST",
174
+ headers: { "content-type": "application/json" },
175
+ body: JSON.stringify({ model: resolvedModel, prompt }),
176
+ });
177
+ } catch (err) {
178
+ throw new OllamaUnavailableError(
179
+ `ollama: unreachable at ${baseUrl} — ${err instanceof Error ? err.message : String(err)}`,
180
+ );
181
+ }
182
+ const raw = (await res.json().catch(() => ({}))) as OllamaEmbedResponse;
183
+ if (!res.ok) {
184
+ const msg = raw.error ?? `ollama embed HTTP ${res.status}`;
185
+ throw new OllamaUnavailableError(`ollama: ${msg}`);
186
+ }
187
+ if (!Array.isArray(raw.embedding)) {
188
+ throw new OllamaUnavailableError("ollama: embed response missing embedding");
189
+ }
190
+ vectors.push(raw.embedding);
191
+ }
192
+
193
+ return {
194
+ vectors,
195
+ model: resolvedModel,
196
+ driverId: "ollama",
197
+ };
198
+ },
161
199
  };
162
200
  }
163
201
 
@@ -325,3 +363,8 @@ interface OllamaChatResponse {
325
363
  readonly eval_count?: number;
326
364
  readonly error?: string;
327
365
  }
366
+
367
+ interface OllamaEmbedResponse {
368
+ readonly embedding?: readonly number[];
369
+ readonly error?: string;
370
+ }
@@ -318,6 +318,37 @@ describe("ollama driver", () => {
318
318
  expect(calls[0]!.body.options).toEqual({ temperature: 0, num_predict: 16 });
319
319
  });
320
320
 
321
+ test("embed via injectable fetch — native /api/embeddings", async () => {
322
+ const paths: string[] = [];
323
+ const fetchFn: typeof fetch = Object.assign(
324
+ async (input: string | URL | Request) => {
325
+ const url = String(input);
326
+ paths.push(url);
327
+ if (url.endsWith("/api/tags")) {
328
+ return new Response(JSON.stringify({ models: [] }), { status: 200 });
329
+ }
330
+ if (url.endsWith("/api/embeddings")) {
331
+ return new Response(JSON.stringify({ embedding: [0.1, 0.2, 0.3] }), {
332
+ status: 200,
333
+ });
334
+ }
335
+ return new Response(JSON.stringify({ error: "unexpected" }), { status: 500 });
336
+ },
337
+ { preconnect: () => {} },
338
+ ) as typeof fetch;
339
+
340
+ const client = await openOllama({
341
+ model: "nomic-embed-text",
342
+ baseUrl: "http://ollama.test:11434",
343
+ fetch: fetchFn,
344
+ });
345
+ const emb = await client.embed!({ input: ["a", "b"] });
346
+ expect(emb.driverId).toBe("ollama");
347
+ expect(emb.vectors).toHaveLength(2);
348
+ expect(emb.vectors[0]).toEqual([0.1, 0.2, 0.3]);
349
+ expect(paths.filter((p) => p.endsWith("/api/embeddings"))).toHaveLength(2);
350
+ });
351
+
321
352
  test("unreachable server throws OllamaUnavailableError (no silent fallback)", async () => {
322
353
  const fetchFn: typeof fetch = Object.assign(
323
354
  async () => {
@@ -1,7 +1,9 @@
1
1
  /**
2
- * `fcm` channel driver — Firebase Cloud Messaging HTTP v1 (protocol-shaped).
2
+ * `fcm` channel driver — Firebase Cloud Messaging HTTP v1 via sently.
3
3
  */
4
4
 
5
+ import { FcmTransport } from "sently/transports/fcm";
6
+ import { mapSentlySendError, mapSentlySendResult } from "./channel-sently-map.ts";
5
7
  import type {
6
8
  ChannelDriver,
7
9
  ChannelMessage,
@@ -13,70 +15,64 @@ import type {
13
15
  /**
14
16
  * Open an FCM push driver.
15
17
  *
16
- * @param options - `token` (OAuth access token) + `from` (project id)
18
+ * Prefer service-account credentials (`clientEmail` + `privateKey` + `from`/
19
+ * `projectId`). For tests, pass `token` as a pre-fetched access token via
20
+ * `getAccessToken` (still requires `from` / project id).
21
+ *
22
+ * @param options - Project id + service account or injectable token
17
23
  */
18
24
  export function openFcmChannel(options: ChannelOpenOptions = {}): ChannelDriver {
25
+ const projectId = options.projectId ?? options.from;
26
+ if (!projectId) {
27
+ throw new Error("fcm channel: projectId (or from) is required");
28
+ }
29
+
30
+ const clientEmail = options.clientEmail ?? options.user;
31
+ const privateKey = options.privateKey ?? options.pass;
19
32
  const accessToken = options.token ?? options.apiKey;
20
- const projectId = options.from;
21
- const fetchFn = options.fetch ?? globalThis.fetch;
22
- const base = options.url ?? "https://fcm.googleapis.com";
33
+
34
+ if (!accessToken && !(clientEmail && privateKey)) {
35
+ throw new Error(
36
+ "fcm channel: clientEmail+privateKey (service account) or token (access token) required",
37
+ );
38
+ }
39
+ if ((clientEmail && !privateKey) || (!clientEmail && privateKey)) {
40
+ throw new Error("fcm channel: clientEmail and privateKey must be provided together");
41
+ }
42
+
43
+ // Token-only mode (tests / pre-fetched OAuth): sently still requires placeholder
44
+ // service-account fields; getAccessToken skips JWT exchange.
45
+ const transport = new FcmTransport({
46
+ projectId,
47
+ clientEmail: clientEmail ?? "oke-fcm@local",
48
+ privateKey:
49
+ privateKey ??
50
+ "-----BEGIN PRIVATE KEY-----\nMIIEowIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF6PZGFw=\n-----END PRIVATE KEY-----\n",
51
+ ...(accessToken ? { getAccessToken: async () => accessToken } : {}),
52
+ });
23
53
 
24
54
  const channel: ChannelTransport = {
25
55
  provider: "fcm",
26
56
  mediums: ["push"],
27
57
  async send(message: ChannelMessage): Promise<ChannelSendResult> {
28
- if (!accessToken || !projectId) {
29
- throw new Error("fcm: token and from (project id) are required");
30
- }
31
- const res = await fetchFn(`${base}/v1/projects/${projectId}/messages:send`, {
32
- method: "POST",
33
- headers: {
34
- Authorization: `Bearer ${accessToken}`,
35
- "Content-Type": "application/json",
36
- },
37
- body: JSON.stringify({
38
- message: {
39
- token: message.to,
40
- notification: {
41
- title: message.subject ?? message.template ?? "notification",
42
- body: message.text ?? "",
43
- },
44
- data: Object.fromEntries(
45
- Object.entries(message.data ?? {}).map(([k, v]) => [k, String(v)]),
46
- ),
47
- },
48
- }),
49
- });
50
- const body = (await res.json().catch(() => ({}))) as {
51
- name?: string;
52
- error?: { message?: string };
53
- };
54
- const id = body.name ?? crypto.randomUUID();
55
- if (!res.ok) {
56
- return {
57
- ok: false,
58
- messageId: id,
59
- driverId: "fcm",
60
- attempts: [
61
- {
62
- driverId: "fcm",
63
- ok: false,
64
- error: body.error?.message ?? `HTTP ${res.status}`,
65
- at: Date.now(),
66
- },
67
- ],
68
- };
58
+ try {
59
+ const title = message.subject ?? message.template ?? "notification";
60
+ const body = message.text ?? "";
61
+ const result = await transport.send({
62
+ token: message.to,
63
+ title,
64
+ body,
65
+ ...(message.data ? { data: { ...message.data } } : {}),
66
+ });
67
+ return mapSentlySendResult("fcm", result);
68
+ } catch (err) {
69
+ return mapSentlySendError("fcm", err);
69
70
  }
70
- return {
71
- ok: true,
72
- messageId: id,
73
- driverId: "fcm",
74
- attempts: [{ driverId: "fcm", ok: true, at: Date.now(), messageId: id }],
75
- };
76
71
  },
72
+ verify: () => transport.verify(),
77
73
  };
78
74
 
79
- return { id: "fcm", channel };
75
+ return { id: "fcm", channel, pushTransport: transport };
80
76
  }
81
77
 
82
78
  /** FCM driver factory. */
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `msegat` channel driver — SMS via sently's Msegat transport.
3
+ */
4
+
5
+ import { MsegatTransport } from "sently/transports/msegat";
6
+ import { mapSentlySendError, mapSentlySendResult } from "./channel-sently-map.ts";
7
+ import type {
8
+ ChannelDriver,
9
+ ChannelMessage,
10
+ ChannelOpenOptions,
11
+ ChannelSendResult,
12
+ ChannelTransport,
13
+ } from "./channel-types.ts";
14
+
15
+ /**
16
+ * Open a Msegat SMS driver.
17
+ *
18
+ * @param options - `userName`/`user` + `apiKey` + `sender`/`from`
19
+ */
20
+ export function openMsegatChannel(options: ChannelOpenOptions = {}): ChannelDriver {
21
+ const userName = options.userName ?? options.user;
22
+ const apiKey = options.apiKey;
23
+ const sender = options.sender ?? options.from;
24
+ if (!userName) {
25
+ throw new Error("msegat channel: userName (or user) is required");
26
+ }
27
+ if (!apiKey) {
28
+ throw new Error("msegat channel: apiKey is required");
29
+ }
30
+ if (!sender) {
31
+ throw new Error("msegat channel: sender (or from) is required");
32
+ }
33
+
34
+ const transport = new MsegatTransport({ userName, apiKey, sender });
35
+
36
+ const channel: ChannelTransport = {
37
+ provider: "msegat",
38
+ mediums: ["sms"],
39
+ async send(message: ChannelMessage): Promise<ChannelSendResult> {
40
+ try {
41
+ const result = await transport.send({
42
+ to: message.to,
43
+ body: message.text ?? String(message.data?.code ?? ""),
44
+ ...(message.from ? { from: message.from } : {}),
45
+ });
46
+ return mapSentlySendResult("msegat", result);
47
+ } catch (err) {
48
+ return mapSentlySendError("msegat", err);
49
+ }
50
+ },
51
+ verify: () => transport.verify(),
52
+ };
53
+
54
+ return { id: "msegat", channel, smsTransport: transport };
55
+ }
56
+
57
+ /** Msegat driver factory. */
58
+ export const msegatChannelDriver = {
59
+ id: "msegat" as const,
60
+ open: openMsegatChannel,
61
+ };