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.
- package/AGENTS.md +2 -2
- package/README.md +73 -181
- package/package.json +2 -2
- package/site/content/docs/console/clock.mdx +25 -5
- package/site/content/docs/console/signals.mdx +13 -4
- package/site/content/docs/elements/ai.mdx +29 -2
- package/site/content/docs/elements/channel.mdx +19 -13
- package/site/content/docs/elements/clock.mdx +56 -24
- package/site/content/docs/elements/flow.mdx +2 -0
- package/site/content/docs/elements/gate.mdx +8 -1
- package/site/content/docs/elements/signal.mdx +173 -29
- package/site/content/docs/elements/store.mdx +464 -73
- package/site/content/docs/elements/vault.mdx +15 -5
- package/site/content/docs/get-started/basic-usage.mdx +13 -6
- package/site/content/docs/get-started/installation.mdx +49 -13
- package/site/content/docs/get-started/introduction.mdx +1 -1
- package/site/content/docs/reference/cli.md +30 -2
- package/site/content/docs/reference/configuration.mdx +13 -13
- package/site/content/docs/reference/environment-variables.mdx +12 -10
- package/site/content/docs/reference/errors.mdx +12 -11
- package/site/content/docs/reference/fx.mdx +15 -13
- package/site/content/docs/reference/security.md +3 -1
- package/src/cli/ai-setup/ai-setup.test.ts +144 -0
- package/src/cli/ai-setup/apply.ts +214 -0
- package/src/cli/ai-setup/catalog.ts +263 -0
- package/src/cli/ai-setup/detect-ollama.ts +166 -0
- package/src/cli/ai-setup/index.ts +228 -0
- package/src/cli/ai-setup/prompts.ts +649 -0
- package/src/cli/ai-setup/recommend.test.ts +100 -0
- package/src/cli/ai-setup/recommend.ts +203 -0
- package/src/cli/ai.ts +38 -0
- package/src/cli/db-auto-push.test.ts +6 -1
- package/src/cli/db-auto-push.ts +8 -3
- package/src/cli/dev-db-push.test.ts +59 -0
- package/src/cli/dev.test.ts +40 -0
- package/src/cli/dev.ts +55 -1
- package/src/cli/docker-clean.test.ts +179 -0
- package/src/cli/docker-clean.ts +406 -0
- package/src/cli/docker.ts +16 -4
- package/src/cli/ensure-drizzle-config.ts +1 -1
- package/src/cli/hero-meta.test.ts +6 -5
- package/src/cli/hero-meta.ts +7 -1
- package/src/cli/index.ts +5 -0
- package/src/cli/load-config.ts +4 -4
- package/src/cli/openbao-bootstrap.test.ts +1 -0
- package/src/cli/openbao-bootstrap.ts +9 -2
- package/src/cli/registry.ts +79 -1
- package/src/client/live-gap.test.ts +35 -0
- package/src/client/transport.test.ts +21 -0
- package/src/client/transport.ts +27 -5
- package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
- package/src/config/define-config.test.ts +6 -6
- package/src/config/index.ts +1 -1
- package/src/console/server/console.test.ts +45 -0
- package/src/console/server/flows.ts +39 -9
- package/src/console/server/i18n.ts +41 -0
- package/src/console/server/vault.ts +12 -38
- package/src/console/ui/dist/assets/index-ClX0ZCe5.js +10 -0
- package/src/console/ui/dist/assets/{panel-access-BGv45snf.js → panel-access-ri1X9Otj.js} +1 -1
- package/src/console/ui/dist/assets/{panel-ai-B2S7LEii.js → panel-ai-DLHjRoJk.js} +1 -1
- package/src/console/ui/dist/assets/{panel-architecture-D7UJh91v.js → panel-architecture-DI3bQF3J.js} +1 -1
- package/src/console/ui/dist/assets/{panel-channels-9T3ybqRu.js → panel-channels-BDYWX29p.js} +1 -1
- package/src/console/ui/dist/assets/{panel-clock-Cb1UXGRQ.js → panel-clock-i-JpTjVB.js} +1 -1
- package/src/console/ui/dist/assets/{panel-diff-DmYbKWmN.js → panel-diff-CFuWxiXy.js} +1 -1
- package/src/console/ui/dist/assets/{panel-flows-PiHwT55z.js → panel-flows-B8TRhcoe.js} +1 -1
- package/src/console/ui/dist/assets/{panel-gates-BQGYXvjT.js → panel-gates-CfkBgco7.js} +1 -1
- package/src/console/ui/dist/assets/{panel-overview-BBnRO18l.js → panel-overview-DONDzBd2.js} +1 -1
- package/src/console/ui/dist/assets/{panel-plugins-D0PsmVw2.js → panel-plugins-DDJwTehL.js} +1 -1
- package/src/console/ui/dist/assets/{panel-runs-CWuRDe0r.js → panel-runs-D5zf-D9c.js} +1 -1
- package/src/console/ui/dist/assets/{panel-signals-Bbg4ewpP.js → panel-signals-Dsxdu_AR.js} +1 -1
- package/src/console/ui/dist/assets/{panel-store-CPCbsDRa.js → panel-store-hy7O8HOs.js} +1 -1
- package/src/console/ui/dist/assets/{panel-traces-DVAzuA_S.js → panel-traces-CBiAe5go.js} +1 -1
- package/src/console/ui/dist/assets/{panel-vault-D1_MvOmo.js → panel-vault-Za1GyJfM.js} +1 -1
- package/src/console/ui/dist/index.html +1 -1
- package/src/console/ui/shell/components/ui.tsx +5 -1
- package/src/console/ui/shell/setup/Wizard.tsx +26 -5
- package/src/docker/cleanup.test.ts +193 -0
- package/src/docker/cleanup.ts +355 -0
- package/src/docker/docker.test.ts +4 -0
- package/src/docker/index.ts +19 -0
- package/src/docker/recipes/ollama.ts +10 -5
- package/src/drivers/ai-ollama.ts +45 -2
- package/src/drivers/ai-providers.test.ts +31 -0
- package/src/drivers/index.ts +3 -0
- package/src/drivers/signal-engine.ts +98 -25
- package/src/drivers/signal-nats.ts +4 -4
- package/src/drivers/signal-postgres.ts +158 -64
- package/src/drivers/signal-redis.ts +4 -4
- package/src/drivers/signal-types.ts +61 -2
- package/src/drivers/vault-driver-removal.test.ts +5 -1
- package/src/elements/clock/chaos-child.ts +150 -0
- package/src/elements/clock/chaos.test.ts +204 -0
- package/src/elements/clock/reconcile.ts +110 -0
- package/src/elements/clock/runtime.ts +11 -0
- package/src/elements/clock.test.ts +164 -2
- package/src/elements/clock.ts +1 -0
- package/src/elements/gate/runtime.ts +6 -0
- package/src/elements/signal/chaos-child.ts +41 -4
- package/src/elements/signal/declare.ts +4 -1
- package/src/elements/signal/delivery-modes.test.ts +179 -0
- package/src/elements/signal/key-ordering.test.ts +306 -0
- package/src/elements/signal/lease-reclaim.test.ts +123 -0
- package/src/elements/signal/optional-emit.test.ts +76 -0
- package/src/elements/signal/order-lifecycle.test.ts +130 -0
- package/src/elements/signal/orphan-messages.test.ts +152 -0
- package/src/elements/signal/runtime.ts +11 -4
- package/src/elements/signal/schema-emit.test.ts +107 -0
- package/src/elements/signal.test.ts +89 -5
- package/src/elements/store/files-fx.ts +104 -0
- package/src/elements/store/files-image.test.ts +251 -0
- package/src/elements/store/files-image.ts +494 -0
- package/src/elements/store/runtime.ts +60 -0
- package/src/elements/store.ts +24 -0
- package/src/elements/vault/boot-chain.ts +150 -0
- package/src/elements/vault/runtime.ts +8 -0
- package/src/kernel/app.ts +3 -1
- package/src/kernel/boot-bind/clock.ts +52 -7
- package/src/kernel/boot-bind/gate.ts +71 -3
- package/src/kernel/boot-bind/honor-config.test.ts +247 -0
- package/src/kernel/boot-bind/signal.ts +64 -7
- package/src/kernel/boot-bind/vault.ts +27 -8
- package/src/kernel/boot.ts +10 -2
- package/src/kernel/errors.ts +8 -2
- package/src/kernel/fx.test.ts +13 -0
- package/src/kernel/fx.ts +27 -4
- package/src/runtime/dev-request-log.test.ts +20 -1
- package/src/runtime/dev-request-log.ts +38 -0
- package/src/term.test.ts +15 -0
- package/src/term.ts +8 -3
- 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,
|
package/src/docker/index.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
17
|
-
'/bin/ollama pull "
|
|
18
|
-
"wait
|
|
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. */
|
package/src/drivers/ai-ollama.ts
CHANGED
|
@@ -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
|
|
11
|
-
* `http://127.0.0.1:11434`. Supports `complete`,
|
|
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 () => {
|
package/src/drivers/index.ts
CHANGED
|
@@ -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,
|