okengine 0.6.1 → 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 (130) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +73 -181
  3. package/package.json +2 -2
  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 +19 -13
  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 +13 -13
  19. package/site/content/docs/reference/environment-variables.mdx +12 -10
  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/registry.ts +79 -1
  48. package/src/client/live-gap.test.ts +35 -0
  49. package/src/client/transport.test.ts +21 -0
  50. package/src/client/transport.ts +27 -5
  51. package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
  52. package/src/config/define-config.test.ts +6 -6
  53. package/src/config/index.ts +1 -1
  54. package/src/console/server/console.test.ts +45 -0
  55. package/src/console/server/flows.ts +39 -9
  56. package/src/console/server/i18n.ts +41 -0
  57. package/src/console/server/vault.ts +12 -38
  58. package/src/console/ui/dist/assets/index-ClX0ZCe5.js +10 -0
  59. package/src/console/ui/dist/assets/{panel-access-BGv45snf.js → panel-access-ri1X9Otj.js} +1 -1
  60. package/src/console/ui/dist/assets/{panel-ai-B2S7LEii.js → panel-ai-DLHjRoJk.js} +1 -1
  61. package/src/console/ui/dist/assets/{panel-architecture-D7UJh91v.js → panel-architecture-DI3bQF3J.js} +1 -1
  62. package/src/console/ui/dist/assets/{panel-channels-9T3ybqRu.js → panel-channels-BDYWX29p.js} +1 -1
  63. package/src/console/ui/dist/assets/{panel-clock-Cb1UXGRQ.js → panel-clock-i-JpTjVB.js} +1 -1
  64. package/src/console/ui/dist/assets/{panel-diff-DmYbKWmN.js → panel-diff-CFuWxiXy.js} +1 -1
  65. package/src/console/ui/dist/assets/{panel-flows-PiHwT55z.js → panel-flows-B8TRhcoe.js} +1 -1
  66. package/src/console/ui/dist/assets/{panel-gates-BQGYXvjT.js → panel-gates-CfkBgco7.js} +1 -1
  67. package/src/console/ui/dist/assets/{panel-overview-BBnRO18l.js → panel-overview-DONDzBd2.js} +1 -1
  68. package/src/console/ui/dist/assets/{panel-plugins-D0PsmVw2.js → panel-plugins-DDJwTehL.js} +1 -1
  69. package/src/console/ui/dist/assets/{panel-runs-CWuRDe0r.js → panel-runs-D5zf-D9c.js} +1 -1
  70. package/src/console/ui/dist/assets/{panel-signals-Bbg4ewpP.js → panel-signals-Dsxdu_AR.js} +1 -1
  71. package/src/console/ui/dist/assets/{panel-store-CPCbsDRa.js → panel-store-hy7O8HOs.js} +1 -1
  72. package/src/console/ui/dist/assets/{panel-traces-DVAzuA_S.js → panel-traces-CBiAe5go.js} +1 -1
  73. package/src/console/ui/dist/assets/{panel-vault-D1_MvOmo.js → panel-vault-Za1GyJfM.js} +1 -1
  74. package/src/console/ui/dist/index.html +1 -1
  75. package/src/console/ui/shell/components/ui.tsx +5 -1
  76. package/src/console/ui/shell/setup/Wizard.tsx +26 -5
  77. package/src/docker/cleanup.test.ts +193 -0
  78. package/src/docker/cleanup.ts +355 -0
  79. package/src/docker/docker.test.ts +4 -0
  80. package/src/docker/index.ts +19 -0
  81. package/src/docker/recipes/ollama.ts +10 -5
  82. package/src/drivers/ai-ollama.ts +45 -2
  83. package/src/drivers/ai-providers.test.ts +31 -0
  84. package/src/drivers/index.ts +3 -0
  85. package/src/drivers/signal-engine.ts +98 -25
  86. package/src/drivers/signal-nats.ts +4 -4
  87. package/src/drivers/signal-postgres.ts +158 -64
  88. package/src/drivers/signal-redis.ts +4 -4
  89. package/src/drivers/signal-types.ts +61 -2
  90. package/src/drivers/vault-driver-removal.test.ts +5 -1
  91. package/src/elements/clock/chaos-child.ts +150 -0
  92. package/src/elements/clock/chaos.test.ts +204 -0
  93. package/src/elements/clock/reconcile.ts +110 -0
  94. package/src/elements/clock/runtime.ts +11 -0
  95. package/src/elements/clock.test.ts +164 -2
  96. package/src/elements/clock.ts +1 -0
  97. package/src/elements/gate/runtime.ts +6 -0
  98. package/src/elements/signal/chaos-child.ts +41 -4
  99. package/src/elements/signal/declare.ts +4 -1
  100. package/src/elements/signal/delivery-modes.test.ts +179 -0
  101. package/src/elements/signal/key-ordering.test.ts +306 -0
  102. package/src/elements/signal/lease-reclaim.test.ts +123 -0
  103. package/src/elements/signal/optional-emit.test.ts +76 -0
  104. package/src/elements/signal/order-lifecycle.test.ts +130 -0
  105. package/src/elements/signal/orphan-messages.test.ts +152 -0
  106. package/src/elements/signal/runtime.ts +11 -4
  107. package/src/elements/signal/schema-emit.test.ts +107 -0
  108. package/src/elements/signal.test.ts +89 -5
  109. package/src/elements/store/files-fx.ts +104 -0
  110. package/src/elements/store/files-image.test.ts +251 -0
  111. package/src/elements/store/files-image.ts +494 -0
  112. package/src/elements/store/runtime.ts +60 -0
  113. package/src/elements/store.ts +24 -0
  114. package/src/elements/vault/boot-chain.ts +150 -0
  115. package/src/elements/vault/runtime.ts +8 -0
  116. package/src/kernel/app.ts +3 -1
  117. package/src/kernel/boot-bind/clock.ts +52 -7
  118. package/src/kernel/boot-bind/gate.ts +71 -3
  119. package/src/kernel/boot-bind/honor-config.test.ts +247 -0
  120. package/src/kernel/boot-bind/signal.ts +64 -7
  121. package/src/kernel/boot-bind/vault.ts +27 -8
  122. package/src/kernel/boot.ts +10 -2
  123. package/src/kernel/errors.ts +8 -2
  124. package/src/kernel/fx.test.ts +13 -0
  125. package/src/kernel/fx.ts +27 -4
  126. package/src/runtime/dev-request-log.test.ts +20 -1
  127. package/src/runtime/dev-request-log.ts +38 -0
  128. package/src/term.test.ts +15 -0
  129. package/src/term.ts +8 -3
  130. package/src/console/ui/dist/assets/index-CjxwRGVv.js +0 -10
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Discover and tear down leftover `oke-dev-<id>` compose stacks.
3
+ *
4
+ * Identity matches {@link stackAppSlug}: compose project `oke-dev-<6hex>`.
5
+ * Cleanup uses `docker compose -p <project> down -v` (no compose files required).
6
+ */
7
+
8
+ import { join } from "node:path";
9
+ import { stackAppSlug } from "./stack-id.ts";
10
+
11
+ /** Compose project names created by `oke dev --docker`. */
12
+ export const OKE_DEV_PROJECT_RE = /^oke-dev-[0-9a-f]{6}$/;
13
+
14
+ /** Result of one `docker …` invocation. */
15
+ export interface DockerRunResult {
16
+ readonly stdout: string;
17
+ readonly stderr: string;
18
+ readonly exitCode: number;
19
+ }
20
+
21
+ /** Injectable docker CLI runner (tests). */
22
+ export type DockerRunner = (args: readonly string[]) => Promise<DockerRunResult>;
23
+
24
+ /** One container belonging to an OKE compose project. */
25
+ export interface OkeContainer {
26
+ readonly id: string;
27
+ readonly name: string;
28
+ readonly service: string;
29
+ readonly state: string;
30
+ readonly project: string;
31
+ }
32
+
33
+ /** One compose project with zero or more containers. */
34
+ export interface OkeStack {
35
+ readonly project: string;
36
+ readonly containers: readonly OkeContainer[];
37
+ }
38
+
39
+ /**
40
+ * Compose project name for a project root (`oke-dev-<id>`).
41
+ *
42
+ * @param cwd - Project root
43
+ */
44
+ export function composeProjectName(cwd: string): string {
45
+ return `oke-${stackAppSlug(cwd)}`;
46
+ }
47
+
48
+ /**
49
+ * Whether a compose project name is an OKE local stack.
50
+ *
51
+ * @param name - Compose project name
52
+ */
53
+ export function isOkeDevProject(name: string): boolean {
54
+ return OKE_DEV_PROJECT_RE.test(name);
55
+ }
56
+
57
+ /**
58
+ * Unique compose projects implied by a container selection.
59
+ *
60
+ * @param containers - Selected containers
61
+ */
62
+ export function projectsFromContainerSelection(
63
+ containers: readonly OkeContainer[],
64
+ ): readonly string[] {
65
+ return [...new Set(containers.map((c) => c.project))].sort();
66
+ }
67
+
68
+ /**
69
+ * True when cwd looks like an okengine app root.
70
+ *
71
+ * @param cwd - Directory to probe
72
+ */
73
+ export async function isOkeProjectRoot(cwd: string): Promise<boolean> {
74
+ for (const name of ["oke.config.ts", "oke.config.mts", "oke.config.js"] as const) {
75
+ if (await Bun.file(join(cwd, name)).exists()) return true;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ /**
81
+ * Default runner — `docker` on PATH.
82
+ *
83
+ * @param args - Args after `docker`
84
+ */
85
+ export async function defaultDockerRunner(args: readonly string[]): Promise<DockerRunResult> {
86
+ const proc = Bun.spawn(["docker", ...args], {
87
+ stdout: "pipe",
88
+ stderr: "pipe",
89
+ });
90
+ const [stdout, stderr, exitCode] = await Promise.all([
91
+ new Response(proc.stdout).text(),
92
+ new Response(proc.stderr).text(),
93
+ proc.exited,
94
+ ]);
95
+ return { stdout, stderr, exitCode };
96
+ }
97
+
98
+ /**
99
+ * Discover every `oke-dev-*` compose project on this machine.
100
+ *
101
+ * Sources: `compose ls`, container labels, volume/network name prefixes.
102
+ *
103
+ * @param run - Docker runner
104
+ */
105
+ export async function listOkeComposeProjects(
106
+ run: DockerRunner = defaultDockerRunner,
107
+ ): Promise<readonly OkeStack[]> {
108
+ const projectNames = new Set<string>();
109
+
110
+ for (const name of await listComposeProjectNames(run)) {
111
+ if (isOkeDevProject(name)) projectNames.add(name);
112
+ }
113
+ for (const c of await listAllOkeContainers(run)) {
114
+ projectNames.add(c.project);
115
+ }
116
+ for (const name of await listOkeResourceProjectNames(run, "volume")) {
117
+ projectNames.add(name);
118
+ }
119
+ for (const name of await listOkeResourceProjectNames(run, "network")) {
120
+ projectNames.add(name);
121
+ }
122
+
123
+ const stacks: OkeStack[] = [];
124
+ for (const project of [...projectNames].sort()) {
125
+ const containers = await listStackContainers(project, run);
126
+ stacks.push({ project, containers });
127
+ }
128
+ return stacks;
129
+ }
130
+
131
+ /**
132
+ * List containers for one compose project.
133
+ *
134
+ * @param project - Compose project name
135
+ * @param run - Docker runner
136
+ */
137
+ export async function listStackContainers(
138
+ project: string,
139
+ run: DockerRunner = defaultDockerRunner,
140
+ ): Promise<readonly OkeContainer[]> {
141
+ const result = await run([
142
+ "ps",
143
+ "-a",
144
+ "--filter",
145
+ `label=com.docker.compose.project=${project}`,
146
+ "--format",
147
+ "{{json .}}",
148
+ ]);
149
+ if (result.exitCode !== 0) {
150
+ throw new Error(
151
+ `oke docker clean: docker ps failed (${result.exitCode}): ${result.stderr.trim() || result.stdout.trim()}`,
152
+ );
153
+ }
154
+ return parseContainerJsonLines(result.stdout, project);
155
+ }
156
+
157
+ /**
158
+ * Tear down one stack — containers, networks, and named volumes.
159
+ *
160
+ * @param project - Compose project name
161
+ * @param run - Docker runner
162
+ */
163
+ export async function downStack(
164
+ project: string,
165
+ run: DockerRunner = defaultDockerRunner,
166
+ ): Promise<void> {
167
+ if (!isOkeDevProject(project)) {
168
+ throw new Error(`oke docker clean: refusing non-OKE project ${project}`);
169
+ }
170
+ const result = await run(["compose", "-p", project, "down", "-v", "--remove-orphans"]);
171
+ if (result.exitCode !== 0) {
172
+ throw new Error(
173
+ `oke docker clean: docker compose down failed for ${project} (${result.exitCode}): ${result.stderr.trim() || result.stdout.trim()}`,
174
+ );
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Stable selection key for a container (or empty stack sentinel).
180
+ *
181
+ * @param project - Compose project
182
+ * @param containerName - Container name, or empty for volume-only stacks
183
+ */
184
+ export function selectionKey(project: string, containerName = ""): string {
185
+ return containerName ? `${project}\0${containerName}` : `${project}\0`;
186
+ }
187
+
188
+ /**
189
+ * Parse selection keys back into projects (and optional container filter).
190
+ *
191
+ * @param keys - Values from multiselect
192
+ * @param stacks - Known stacks (for container lookup)
193
+ */
194
+ export function resolveSelection(
195
+ keys: readonly string[],
196
+ stacks: readonly OkeStack[],
197
+ ): {
198
+ readonly projects: readonly string[];
199
+ readonly containers: readonly OkeContainer[];
200
+ } {
201
+ const byProject = new Map(stacks.map((s) => [s.project, s] as const));
202
+ const projects = new Set<string>();
203
+ const containers: OkeContainer[] = [];
204
+
205
+ for (const key of keys) {
206
+ const sep = key.indexOf("\0");
207
+ const project = sep === -1 ? key : key.slice(0, sep);
208
+ const containerName = sep === -1 ? "" : key.slice(sep + 1);
209
+ if (!isOkeDevProject(project)) continue;
210
+ projects.add(project);
211
+ const stack = byProject.get(project);
212
+ if (!stack) continue;
213
+ if (!containerName) continue;
214
+ const found = stack.containers.find((c) => c.name === containerName);
215
+ if (found) containers.push(found);
216
+ }
217
+
218
+ return { projects: [...projects].sort(), containers };
219
+ }
220
+
221
+ async function listComposeProjectNames(run: DockerRunner): Promise<readonly string[]> {
222
+ const result = await run(["compose", "ls", "-a", "--format", "json"]);
223
+ if (result.exitCode !== 0) {
224
+ // Compose plugin missing or Docker down — other probes may still find leftovers.
225
+ return [];
226
+ }
227
+ const names: string[] = [];
228
+ for (const row of parseJsonRecords(result.stdout)) {
229
+ const name = stringField(row, "Name") ?? stringField(row, "name");
230
+ if (name) names.push(name);
231
+ }
232
+ return names;
233
+ }
234
+
235
+ async function listAllOkeContainers(run: DockerRunner): Promise<readonly OkeContainer[]> {
236
+ const result = await run([
237
+ "ps",
238
+ "-a",
239
+ "--filter",
240
+ "label=com.docker.compose.project",
241
+ "--format",
242
+ "{{json .}}",
243
+ ]);
244
+ if (result.exitCode !== 0) return [];
245
+ const out: OkeContainer[] = [];
246
+ for (const row of parseJsonRecords(result.stdout)) {
247
+ const project =
248
+ labelValue(row, "com.docker.compose.project") ??
249
+ stringField(row, "ComposeProject") ??
250
+ stringField(row, "Project");
251
+ if (!project || !isOkeDevProject(project)) continue;
252
+ out.push(containerFromRow(row, project));
253
+ }
254
+ return out;
255
+ }
256
+
257
+ async function listOkeResourceProjectNames(
258
+ run: DockerRunner,
259
+ kind: "volume" | "network",
260
+ ): Promise<readonly string[]> {
261
+ const result = await run([kind, "ls", "--format", "{{json .}}"]);
262
+ if (result.exitCode !== 0) return [];
263
+ const names = new Set<string>();
264
+ for (const row of parseJsonRecords(result.stdout)) {
265
+ const name = stringField(row, "Name") ?? stringField(row, "name");
266
+ if (!name) continue;
267
+ const project = projectFromResourceName(name);
268
+ if (project) names.add(project);
269
+ }
270
+ return [...names];
271
+ }
272
+
273
+ /**
274
+ * Extract `oke-dev-<id>` from a volume/network name like `oke-dev-abc123_oke`.
275
+ *
276
+ * @param name - Docker resource name
277
+ */
278
+ export function projectFromResourceName(name: string): string | null {
279
+ const m = /^(oke-dev-[0-9a-f]{6})(?:_|$)/.exec(name);
280
+ return m?.[1] ?? null;
281
+ }
282
+
283
+ function parseContainerJsonLines(stdout: string, project: string): readonly OkeContainer[] {
284
+ return parseJsonRecords(stdout).map((row) => containerFromRow(row, project));
285
+ }
286
+
287
+ function containerFromRow(row: Record<string, unknown>, project: string): OkeContainer {
288
+ const names = stringField(row, "Names") ?? stringField(row, "Name") ?? "";
289
+ const name = names.split(",")[0]?.replace(/^\//, "") || names;
290
+ const service =
291
+ labelValue(row, "com.docker.compose.service") ?? stringField(row, "Service") ?? name;
292
+ const id = stringField(row, "ID") ?? stringField(row, "Id") ?? name;
293
+ const state =
294
+ stringField(row, "State") ?? stringField(row, "Status") ?? stringField(row, "state") ?? "";
295
+ return { id, name, service, state, project };
296
+ }
297
+
298
+ function labelValue(row: Record<string, unknown>, key: string): string | null {
299
+ const labels = row.Labels ?? row.labels;
300
+ if (typeof labels === "string") {
301
+ for (const part of labels.split(",")) {
302
+ const eq = part.indexOf("=");
303
+ if (eq === -1) continue;
304
+ if (part.slice(0, eq) === key) return part.slice(eq + 1);
305
+ }
306
+ return null;
307
+ }
308
+ if (labels && typeof labels === "object") {
309
+ const v = (labels as Record<string, unknown>)[key];
310
+ return typeof v === "string" ? v : null;
311
+ }
312
+ return null;
313
+ }
314
+
315
+ function stringField(row: Record<string, unknown>, key: string): string | null {
316
+ const v = row[key];
317
+ return typeof v === "string" && v.length > 0 ? v : null;
318
+ }
319
+
320
+ /**
321
+ * Parse Docker `--format json` / `{{json .}}` output (array or NDJSON).
322
+ *
323
+ * @param text - stdout
324
+ */
325
+ export function parseJsonRecords(text: string): readonly Record<string, unknown>[] {
326
+ const trimmed = text.trim();
327
+ if (!trimmed) return [];
328
+ try {
329
+ const parsed: unknown = JSON.parse(trimmed);
330
+ if (Array.isArray(parsed)) {
331
+ return parsed.filter(
332
+ (x): x is Record<string, unknown> => typeof x === "object" && x !== null,
333
+ );
334
+ }
335
+ if (typeof parsed === "object" && parsed !== null) {
336
+ return [parsed as Record<string, unknown>];
337
+ }
338
+ } catch {
339
+ // NDJSON — one object per line
340
+ }
341
+ const out: Record<string, unknown>[] = [];
342
+ for (const line of trimmed.split("\n")) {
343
+ const t = line.trim();
344
+ if (!t) continue;
345
+ try {
346
+ const row: unknown = JSON.parse(t);
347
+ if (typeof row === "object" && row !== null && !Array.isArray(row)) {
348
+ out.push(row as Record<string, unknown>);
349
+ }
350
+ } catch {
351
+ // skip malformed lines
352
+ }
353
+ }
354
+ return out;
355
+ }
@@ -81,6 +81,10 @@ describe("image recipes", () => {
81
81
  expect(applied.volumes).toContain("ai-data:/root/.ollama");
82
82
  expect(applied.healthcheck?.test.join(" ")).toContain("ollama list");
83
83
  expect(String(applied.command)).toContain("ollama pull");
84
+ // Compose must see `$$` so `$pid` / `$i` are not treated as project env.
85
+ expect(String(applied.command)).toContain("pid=$$!");
86
+ expect(String(applied.command)).toContain("wait $$pid");
87
+ expect(String(applied.command)).not.toMatch(/(?<!\$)\$pid\b/);
84
88
  const url = recipeFor(spec.image).url(spec, {
85
89
  host: "127.0.0.1",
86
90
  port: 11434,
@@ -66,6 +66,25 @@ export {
66
66
  stackAppSlug,
67
67
  stackInstanceId,
68
68
  } from "./stack-id.ts";
69
+ export {
70
+ OKE_DEV_PROJECT_RE,
71
+ composeProjectName,
72
+ defaultDockerRunner,
73
+ downStack,
74
+ isOkeDevProject,
75
+ isOkeProjectRoot,
76
+ listOkeComposeProjects,
77
+ listStackContainers,
78
+ parseJsonRecords,
79
+ projectFromResourceName,
80
+ projectsFromContainerSelection,
81
+ resolveSelection,
82
+ selectionKey,
83
+ type DockerRunResult,
84
+ type DockerRunner,
85
+ type OkeContainer,
86
+ type OkeStack,
87
+ } from "./cleanup.ts";
69
88
  export {
70
89
  formatImagesLock,
71
90
  pinImages,
@@ -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. */
@@ -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 () => {
@@ -41,6 +41,7 @@ export type {
41
41
  SignalDriver,
42
42
  SignalBus,
43
43
  SignalMessage,
44
+ SignalEmitOptions,
44
45
  SignalFailureReason,
45
46
  DeadLetter,
46
47
  SignalTransaction,
@@ -55,6 +56,8 @@ export type {
55
56
  SignalDiscardOptions,
56
57
  } from "./signal-types.ts";
57
58
 
59
+ export { SIGNAL_DEFAULT_LEASE_MS } from "./signal-types.ts";
60
+
58
61
  export {
59
62
  memorySqlDriver,
60
63
  memoryKvDriver,