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.
- package/AGENTS.md +1 -1
- package/manifest.v1.schema.json +9 -1
- package/package.json +3 -3
- package/site/content/docs/console/runs.mdx +10 -0
- package/site/content/docs/console/traces.mdx +4 -0
- package/site/content/docs/elements/ai.mdx +54 -32
- package/site/content/docs/elements/channel.mdx +105 -14
- package/site/content/docs/elements/clock.mdx +1 -0
- package/site/content/docs/elements/flow.mdx +42 -8
- package/site/content/docs/get-started/basic-usage.mdx +5 -1
- package/site/content/docs/get-started/installation.mdx +9 -5
- package/site/content/docs/meta.json +2 -0
- package/site/content/docs/plugins/otp.mdx +58 -46
- package/site/content/docs/providers/azure-redis.mdx +85 -0
- package/site/content/docs/providers/cockroachdb.mdx +86 -0
- package/site/content/docs/providers/digitalocean-caching.mdx +88 -0
- package/site/content/docs/providers/dragonfly-cloud.mdx +82 -0
- package/site/content/docs/providers/elasticache.mdx +92 -0
- package/site/content/docs/providers/index.mdx +101 -0
- package/site/content/docs/providers/memorystore.mdx +89 -0
- package/site/content/docs/providers/meta.json +20 -0
- package/site/content/docs/providers/neon.mdx +115 -0
- package/site/content/docs/providers/redis-cloud.mdx +93 -0
- package/site/content/docs/providers/supabase.mdx +117 -0
- package/site/content/docs/providers/upstash.mdx +91 -0
- package/site/content/docs/providers/yugabytedb.mdx +95 -0
- package/site/content/docs/recipes/caddy.mdx +128 -0
- package/site/content/docs/recipes/cockroachdb.mdx +127 -0
- package/site/content/docs/recipes/dragonfly.mdx +125 -0
- package/site/content/docs/recipes/index.mdx +146 -0
- package/site/content/docs/recipes/llama-cpp.mdx +153 -0
- package/site/content/docs/recipes/mailpit.mdx +136 -0
- package/site/content/docs/recipes/meilisearch.mdx +139 -0
- package/site/content/docs/recipes/meta.json +31 -0
- package/site/content/docs/recipes/ollama.mdx +142 -0
- package/site/content/docs/recipes/openbao.mdx +141 -0
- package/site/content/docs/recipes/pgdog.mdx +137 -0
- package/site/content/docs/recipes/postgres.mdx +136 -0
- package/site/content/docs/recipes/redis.mdx +130 -0
- package/site/content/docs/recipes/rustfs.mdx +148 -0
- package/site/content/docs/recipes/sglang.mdx +105 -0
- package/site/content/docs/recipes/supabase-docker.mdx +145 -0
- package/site/content/docs/recipes/timescale.mdx +129 -0
- package/site/content/docs/recipes/traefik.mdx +136 -0
- package/site/content/docs/recipes/valkey.mdx +121 -0
- package/site/content/docs/recipes/vllm.mdx +106 -0
- package/site/content/docs/recipes/yugabytedb.mdx +135 -0
- package/site/content/docs/reference/cli.md +42 -6
- package/site/content/docs/reference/configuration.mdx +5 -3
- package/site/content/docs/reference/environment-variables.mdx +12 -12
- package/site/content/docs/reference/fx.mdx +38 -4
- package/src/auth/otp-capability.ts +14 -14
- package/src/cli/ai-setup/ai-setup.test.ts +32 -12
- package/src/cli/ai-setup/apply.ts +65 -23
- package/src/cli/ai-setup/catalog.ts +1316 -35
- package/src/cli/ai-setup/detect-ollama.ts +47 -0
- package/src/cli/ai-setup/index.ts +44 -7
- package/src/cli/ai-setup/prompts.ts +430 -486
- package/src/cli/ai-setup/recommend.test.ts +150 -50
- package/src/cli/ai-setup/recommend.ts +118 -101
- package/src/cli/ask-seed.test.ts +68 -0
- package/src/cli/ask-seed.ts +79 -0
- package/src/cli/db-seed-redact.test.ts +25 -0
- package/src/cli/db-seed.ts +36 -7
- package/src/cli/db.test.ts +1 -1
- package/src/cli/db.ts +2 -2
- package/src/cli/dev-controls.test.ts +107 -0
- package/src/cli/dev-controls.ts +354 -0
- package/src/cli/dev-schema-sync.ts +12 -4
- package/src/cli/dev.test.ts +50 -30
- package/src/cli/dev.ts +607 -66
- package/src/cli/drizzle-env.test.ts +40 -6
- package/src/cli/drizzle-env.ts +22 -1
- package/src/cli/ensure-drizzle-config.ts +6 -4
- package/src/cli/hero-meta.test.ts +13 -1
- package/src/cli/hero-meta.ts +28 -6
- package/src/cli/index.ts +5 -0
- package/src/cli/registry.ts +24 -0
- package/src/cli/replay.test.ts +105 -0
- package/src/cli/replay.ts +206 -0
- package/src/compiler/extract.ts +3 -3
- package/src/config/index.ts +3 -3
- package/src/console/server/claim.ts +3 -1
- package/src/console/ui/dist/assets/{index-ClX0ZCe5.js → index-D-9KFce5.js} +2 -2
- package/src/console/ui/dist/assets/panel-overview-CW0CQEc2.js +1 -0
- package/src/console/ui/dist/assets/panel-runs-BxuyDI3x.js +1 -0
- package/src/console/ui/dist/assets/{panel-signals-Dsxdu_AR.js → panel-signals-BXofbj7v.js} +1 -1
- package/src/console/ui/dist/assets/{panel-store-hy7O8HOs.js → panel-store-B8mH-arU.js} +1 -1
- package/src/console/ui/dist/assets/{panel-traces-CBiAe5go.js → panel-traces-DSA-ZWnf.js} +1 -1
- package/src/console/ui/dist/index.html +1 -1
- package/src/console/ui/overview/slo.ts +31 -3
- package/src/console/ui/overview/types.ts +6 -0
- package/src/console/ui/runs/errors.test.ts +53 -0
- package/src/console/ui/runs/errors.ts +88 -0
- package/src/console/ui/runs/index.ts +8 -0
- package/src/console/ui/runs/search.ts +16 -0
- package/src/console/ui/shell/panels/runs/RunsPanel.tsx +72 -1
- package/src/docker/ai-model-status.test.ts +101 -0
- package/src/docker/ai-model-status.ts +276 -0
- package/src/docker/compose-health.test.ts +115 -0
- package/src/docker/compose-health.ts +234 -0
- package/src/docker/compose.ts +24 -8
- package/src/docker/derive.ts +35 -9
- package/src/docker/docker.test.ts +304 -12
- package/src/docker/helpers.ts +56 -0
- package/src/docker/index.ts +52 -0
- package/src/docker/ollama-pull.ts +232 -0
- package/src/docker/recipes/cockroach.ts +24 -0
- package/src/docker/recipes/index.ts +32 -0
- package/src/docker/recipes/llama-cpp.ts +273 -0
- package/src/docker/recipes/ollama.ts +19 -23
- package/src/docker/recipes/pgdog.ts +4 -1
- package/src/docker/recipes/postgres.ts +5 -18
- package/src/docker/recipes/sglang.ts +55 -0
- package/src/docker/recipes/supabase.ts +21 -0
- package/src/docker/recipes/timescale.ts +18 -0
- package/src/docker/recipes/vllm.ts +44 -0
- package/src/docker/recipes/yugabyte.ts +23 -0
- package/src/docker/stack-id.test.ts +22 -0
- package/src/docker/stack-id.ts +38 -4
- package/src/docker/types.ts +15 -0
- package/src/drivers/ai-ollama.integration.test.ts +7 -4
- package/src/drivers/signal-engine.ts +10 -0
- package/src/drivers/signal-postgres.ts +24 -2
- package/src/drivers/signal-types.ts +10 -0
- package/src/elements/channel/fallback.test.ts +91 -0
- package/src/elements/channel/hard-bounce.test.ts +82 -0
- package/src/elements/channel/injection.test.ts +153 -0
- package/src/elements/channel/locale-ar.test.ts +102 -0
- package/src/elements/channel/otp-delivery.ts +5 -2
- package/src/elements/channel/receipts-ledger.test.ts +100 -0
- package/src/elements/channel/runtime.ts +24 -1
- package/src/elements/channel/test-helpers.ts +60 -0
- package/src/elements/channel/whatsapp-compliance.test.ts +124 -0
- package/src/elements/store/cache.ts +1 -1
- package/src/elements/store/emit-drizzle.ts +43 -9
- package/src/elements/store/runtime.ts +1 -1
- package/src/elements/store/schema-decl.test.ts +2 -0
- package/src/elements/store/schema-decl.ts +15 -0
- package/src/elements/store/seed.ts +1 -1
- package/src/index.ts +1 -0
- package/src/kernel/app.ts +118 -11
- package/src/kernel/boot-bind/signal.ts +6 -2
- package/src/kernel/boot-bind/store.ts +2 -1
- package/src/kernel/boot.test.ts +6 -5
- package/src/kernel/boot.ts +8 -3
- package/src/kernel/compensate.test.ts +131 -0
- package/src/kernel/correlation.test.ts +108 -0
- package/src/kernel/effect-timing.test.ts +82 -0
- package/src/kernel/flow.ts +38 -0
- package/src/kernel/fx-runs.test.ts +49 -0
- package/src/kernel/fx.test.ts +14 -0
- package/src/kernel/fx.ts +111 -1
- package/src/manifest/types.ts +8 -2
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
- package/src/plugins/auth-methods.security.test.ts +3 -3
- package/src/plugins/auth-methods.test.ts +1 -1
- package/src/plugins/index.ts +2 -2
- package/src/plugins/otp.test.ts +21 -19
- package/src/plugins/otp.ts +36 -34
- package/src/plugins/taqnyat.live.test.ts +3 -3
- package/src/runs/collect.ts +3 -0
- package/src/runs/export-otlp.ts +64 -0
- package/src/runs/index.ts +16 -0
- package/src/runs/parquet.ts +9 -0
- package/src/runs/types.ts +5 -0
- package/src/runs/window.test.ts +68 -0
- package/src/runs/window.ts +148 -0
- package/src/runtime/boot-warn.test.ts +45 -0
- package/src/runtime/boot-warn.ts +17 -0
- package/src/runtime/dev-request-log.ts +5 -5
- package/src/term.test.ts +95 -10
- package/src/term.ts +369 -14
- package/src/console/ui/dist/assets/panel-overview-DONDzBd2.js +0 -1
- package/src/console/ui/dist/assets/panel-runs-D5zf-D9c.js +0 -1
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
composeHealthByService,
|
|
4
|
+
composeRowToStatus,
|
|
5
|
+
parseComposePsJson,
|
|
6
|
+
startComposeHealthWatch,
|
|
7
|
+
watchComposeHealth,
|
|
8
|
+
} from "./compose-health.ts";
|
|
9
|
+
|
|
10
|
+
describe("compose-health", () => {
|
|
11
|
+
test("composeRowToStatus maps Health and State", () => {
|
|
12
|
+
expect(composeRowToStatus({ Health: "healthy", State: "running" })).toBe("ready");
|
|
13
|
+
expect(composeRowToStatus({ Health: "starting", State: "running" })).toBe("pending");
|
|
14
|
+
expect(composeRowToStatus({ Health: "unhealthy", State: "running" })).toBe("error");
|
|
15
|
+
expect(composeRowToStatus({ State: "exited" })).toBe("error");
|
|
16
|
+
expect(composeRowToStatus({ State: "stopped" })).toBe("error");
|
|
17
|
+
expect(composeRowToStatus({ Status: "Exited (0) 3 seconds ago" })).toBe("error");
|
|
18
|
+
expect(composeRowToStatus({ State: "running" })).toBe("ready");
|
|
19
|
+
expect(composeRowToStatus({ Status: "Up 2 seconds (health: starting)" })).toBe("pending");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("parseComposePsJson accepts NDJSON and arrays", () => {
|
|
23
|
+
const nd = [
|
|
24
|
+
JSON.stringify({ Service: "ai", Health: "starting", State: "running" }),
|
|
25
|
+
JSON.stringify({ Service: "store-sql", Health: "healthy", State: "running" }),
|
|
26
|
+
].join("\n");
|
|
27
|
+
expect(parseComposePsJson(nd)).toHaveLength(2);
|
|
28
|
+
|
|
29
|
+
const arr = JSON.stringify([
|
|
30
|
+
{ Service: "redis", State: "running" },
|
|
31
|
+
{ Service: "vault", Health: "unhealthy", State: "running" },
|
|
32
|
+
]);
|
|
33
|
+
const map = composeHealthByService(arr);
|
|
34
|
+
expect(map.get("redis")).toBe("ready");
|
|
35
|
+
expect(map.get("vault")).toBe("error");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("watchComposeHealth emits changes until done", async () => {
|
|
39
|
+
let calls = 0;
|
|
40
|
+
const updates: string[] = [];
|
|
41
|
+
const map = await watchComposeHealth({
|
|
42
|
+
files: ["compose.yml"],
|
|
43
|
+
cwd: "/tmp",
|
|
44
|
+
env: {},
|
|
45
|
+
intervalMs: 1,
|
|
46
|
+
timeoutMs: 5_000,
|
|
47
|
+
sleep: async () => {},
|
|
48
|
+
run: async () => {
|
|
49
|
+
calls += 1;
|
|
50
|
+
if (calls === 1) {
|
|
51
|
+
return JSON.stringify([{ Service: "store-sql", Health: "starting", State: "running" }]);
|
|
52
|
+
}
|
|
53
|
+
return JSON.stringify([{ Service: "store-sql", Health: "healthy", State: "running" }]);
|
|
54
|
+
},
|
|
55
|
+
onUpdate: (service, status) => {
|
|
56
|
+
updates.push(`${service}:${status}`);
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
expect(map.get("store-sql")).toBe("ready");
|
|
60
|
+
expect(updates).toEqual(["store-sql:pending", "store-sql:ready"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("readComposeHealth / watch use ps -a", async () => {
|
|
64
|
+
let seen: readonly string[] = [];
|
|
65
|
+
await watchComposeHealth({
|
|
66
|
+
files: ["compose.yml"],
|
|
67
|
+
cwd: "/tmp",
|
|
68
|
+
env: {},
|
|
69
|
+
intervalMs: 1,
|
|
70
|
+
timeoutMs: 1,
|
|
71
|
+
sleep: async () => {},
|
|
72
|
+
run: async (args) => {
|
|
73
|
+
seen = args;
|
|
74
|
+
return JSON.stringify([{ Service: "ai", State: "exited", Status: "Exited (0) 1s ago" }]);
|
|
75
|
+
},
|
|
76
|
+
isDone: () => true,
|
|
77
|
+
});
|
|
78
|
+
expect(seen).toContain("-a");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("startComposeHealthWatch reports ready → error", async () => {
|
|
82
|
+
let calls = 0;
|
|
83
|
+
const changes: string[] = [];
|
|
84
|
+
let stopWatch: (() => void) | undefined;
|
|
85
|
+
await new Promise<void>((resolve, reject) => {
|
|
86
|
+
const timer = setTimeout(() => reject(new Error("health watch timeout")), 2_000);
|
|
87
|
+
stopWatch = startComposeHealthWatch({
|
|
88
|
+
files: ["compose.yml"],
|
|
89
|
+
cwd: "/tmp",
|
|
90
|
+
env: {},
|
|
91
|
+
intervalMs: 1,
|
|
92
|
+
sleep: async () => {},
|
|
93
|
+
run: async () => {
|
|
94
|
+
calls += 1;
|
|
95
|
+
if (calls === 1) {
|
|
96
|
+
return JSON.stringify([{ Service: "ai", Health: "healthy", State: "running" }]);
|
|
97
|
+
}
|
|
98
|
+
return JSON.stringify([
|
|
99
|
+
{ Service: "ai", State: "exited", Status: "Exited (0) 1 second ago" },
|
|
100
|
+
]);
|
|
101
|
+
},
|
|
102
|
+
onChange: (_map, changed) => {
|
|
103
|
+
for (const c of changed) changes.push(`${c.service}:${c.status}`);
|
|
104
|
+
if (changes.includes("ai:ready") && changes.includes("ai:error")) {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
stopWatch?.();
|
|
107
|
+
resolve();
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
expect(changes).toContain("ai:ready");
|
|
113
|
+
expect(changes).toContain("ai:error");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read `docker compose ps` health for `oke dev` status dots.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { DevStatus } from "../term.ts";
|
|
6
|
+
|
|
7
|
+
/** One service row from `docker compose ps --format json`. */
|
|
8
|
+
export type ComposePsRow = {
|
|
9
|
+
readonly Service?: string;
|
|
10
|
+
readonly State?: string;
|
|
11
|
+
readonly Health?: string;
|
|
12
|
+
readonly Status?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Map a compose ps row to a {@link DevStatus}.
|
|
17
|
+
*
|
|
18
|
+
* @param row - Parsed ps line
|
|
19
|
+
*/
|
|
20
|
+
export function composeRowToStatus(row: ComposePsRow): DevStatus {
|
|
21
|
+
const health = (row.Health ?? "").trim().toLowerCase();
|
|
22
|
+
const state = (row.State ?? "").trim().toLowerCase();
|
|
23
|
+
if (health === "healthy") return "ready";
|
|
24
|
+
if (health === "unhealthy") return "error";
|
|
25
|
+
if (health === "starting") return "pending";
|
|
26
|
+
if (state === "running" || state === "up") return "ready";
|
|
27
|
+
if (
|
|
28
|
+
state === "exited" ||
|
|
29
|
+
state === "dead" ||
|
|
30
|
+
state === "stopped" ||
|
|
31
|
+
state === "restarting" ||
|
|
32
|
+
state === "removing" ||
|
|
33
|
+
state === "oomkilled"
|
|
34
|
+
) {
|
|
35
|
+
return "error";
|
|
36
|
+
}
|
|
37
|
+
// Status text for stopped containers (`Exited (0) 2 seconds ago`).
|
|
38
|
+
const statusText = (row.Status ?? "").toLowerCase();
|
|
39
|
+
if (statusText.startsWith("exited") || statusText.startsWith("dead")) return "error";
|
|
40
|
+
if (state === "created" || state === "paused" || state.length === 0) return "pending";
|
|
41
|
+
// Status text like "Up 3 seconds (healthy)"
|
|
42
|
+
if (statusText.includes("(healthy)")) return "ready";
|
|
43
|
+
if (statusText.includes("(unhealthy)")) return "error";
|
|
44
|
+
if (statusText.includes("(health: starting)") || statusText.includes("(starting)")) {
|
|
45
|
+
return "pending";
|
|
46
|
+
}
|
|
47
|
+
if (statusText.startsWith("up")) return "ready";
|
|
48
|
+
return "pending";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse NDJSON or a JSON array from `docker compose ps --format json`.
|
|
53
|
+
*
|
|
54
|
+
* @param text - stdout
|
|
55
|
+
*/
|
|
56
|
+
export function parseComposePsJson(text: string): readonly ComposePsRow[] {
|
|
57
|
+
const trimmed = text.trim();
|
|
58
|
+
if (!trimmed) return [];
|
|
59
|
+
if (trimmed.startsWith("[")) {
|
|
60
|
+
try {
|
|
61
|
+
const arr: unknown = JSON.parse(trimmed);
|
|
62
|
+
return Array.isArray(arr) ? (arr as ComposePsRow[]) : [];
|
|
63
|
+
} catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const rows: ComposePsRow[] = [];
|
|
68
|
+
for (const line of trimmed.split("\n")) {
|
|
69
|
+
const t = line.trim();
|
|
70
|
+
if (!t) continue;
|
|
71
|
+
try {
|
|
72
|
+
rows.push(JSON.parse(t) as ComposePsRow);
|
|
73
|
+
} catch {
|
|
74
|
+
// skip garbage lines
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return rows;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build service-name → status from compose ps output.
|
|
82
|
+
*
|
|
83
|
+
* @param text - stdout from `docker compose ps --format json`
|
|
84
|
+
*/
|
|
85
|
+
export function composeHealthByService(text: string): Map<string, DevStatus> {
|
|
86
|
+
const map = new Map<string, DevStatus>();
|
|
87
|
+
for (const row of parseComposePsJson(text)) {
|
|
88
|
+
const name = row.Service?.trim();
|
|
89
|
+
if (!name) continue;
|
|
90
|
+
map.set(name, composeRowToStatus(row));
|
|
91
|
+
}
|
|
92
|
+
return map;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Options for {@link readComposeHealth}. */
|
|
96
|
+
export type ReadComposeHealthOptions = {
|
|
97
|
+
readonly files: readonly string[];
|
|
98
|
+
readonly cwd: string;
|
|
99
|
+
readonly env: Record<string, string>;
|
|
100
|
+
/** Injectable runner (tests). */
|
|
101
|
+
readonly run?: (
|
|
102
|
+
args: readonly string[],
|
|
103
|
+
cwd: string,
|
|
104
|
+
env: Record<string, string>,
|
|
105
|
+
) => Promise<string>;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Query `docker compose ps` and return health by Compose service name.
|
|
110
|
+
*
|
|
111
|
+
* @param opts - Compose files + cwd
|
|
112
|
+
*/
|
|
113
|
+
export async function readComposeHealth(
|
|
114
|
+
opts: ReadComposeHealthOptions,
|
|
115
|
+
): Promise<Map<string, DevStatus>> {
|
|
116
|
+
const run =
|
|
117
|
+
opts.run ??
|
|
118
|
+
(async (args, cwd, env) => {
|
|
119
|
+
const proc = Bun.spawn(["docker", ...args], {
|
|
120
|
+
cwd,
|
|
121
|
+
stdout: "pipe",
|
|
122
|
+
stderr: "pipe",
|
|
123
|
+
env: { ...process.env, ...env },
|
|
124
|
+
});
|
|
125
|
+
const [stdout, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
126
|
+
if (code !== 0) return "";
|
|
127
|
+
return stdout;
|
|
128
|
+
});
|
|
129
|
+
// `-a` includes stopped/exited — default `ps` hides them and ● would stay green.
|
|
130
|
+
const args = ["compose", ...opts.files.flatMap((f) => ["-f", f]), "ps", "-a", "--format", "json"];
|
|
131
|
+
const text = await run(args, opts.cwd, opts.env);
|
|
132
|
+
return composeHealthByService(text);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Options for {@link watchComposeHealth}. */
|
|
136
|
+
export type WatchComposeHealthOptions = ReadComposeHealthOptions & {
|
|
137
|
+
/** Fired when a service status changes. */
|
|
138
|
+
readonly onUpdate?: (service: string, status: DevStatus) => void;
|
|
139
|
+
/** Stop early when this returns true. Default: every service ready or error. */
|
|
140
|
+
readonly isDone?: (map: Map<string, DevStatus>) => boolean;
|
|
141
|
+
readonly intervalMs?: number;
|
|
142
|
+
readonly timeoutMs?: number;
|
|
143
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
144
|
+
readonly now?: () => number;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Poll compose health until ready/error/timeout, emitting per-service changes.
|
|
149
|
+
*
|
|
150
|
+
* @param opts - Compose target + callbacks
|
|
151
|
+
*/
|
|
152
|
+
export async function watchComposeHealth(
|
|
153
|
+
opts: WatchComposeHealthOptions,
|
|
154
|
+
): Promise<Map<string, DevStatus>> {
|
|
155
|
+
const intervalMs = opts.intervalMs ?? 500;
|
|
156
|
+
const timeoutMs = opts.timeoutMs ?? 20_000;
|
|
157
|
+
const sleep = opts.sleep ?? ((ms: number) => Bun.sleep(ms));
|
|
158
|
+
const now = opts.now ?? Date.now;
|
|
159
|
+
const started = now();
|
|
160
|
+
const prev = new Map<string, DevStatus>();
|
|
161
|
+
let latest = new Map<string, DevStatus>();
|
|
162
|
+
const done =
|
|
163
|
+
opts.isDone ??
|
|
164
|
+
((map) => map.size > 0 && [...map.values()].every((s) => s === "ready" || s === "error"));
|
|
165
|
+
|
|
166
|
+
for (;;) {
|
|
167
|
+
latest = await readComposeHealth(opts);
|
|
168
|
+
for (const [name, status] of latest) {
|
|
169
|
+
if (prev.get(name) !== status) {
|
|
170
|
+
prev.set(name, status);
|
|
171
|
+
opts.onUpdate?.(name, status);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (done(latest)) return latest;
|
|
175
|
+
if (now() - started >= timeoutMs) return latest;
|
|
176
|
+
await sleep(intervalMs);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Options for {@link startComposeHealthWatch}. */
|
|
181
|
+
export type StartComposeHealthWatchOptions = ReadComposeHealthOptions & {
|
|
182
|
+
/** Fired on every poll with the full map (and when any service changed). */
|
|
183
|
+
readonly onChange: (
|
|
184
|
+
map: Map<string, DevStatus>,
|
|
185
|
+
changed: readonly { readonly service: string; readonly status: DevStatus }[],
|
|
186
|
+
) => void;
|
|
187
|
+
readonly intervalMs?: number;
|
|
188
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Poll compose health for the life of an `oke dev` session.
|
|
193
|
+
*
|
|
194
|
+
* @param opts - Compose target + change callback
|
|
195
|
+
* @returns Stop function
|
|
196
|
+
*/
|
|
197
|
+
export function startComposeHealthWatch(opts: StartComposeHealthWatchOptions): () => void {
|
|
198
|
+
const intervalMs = opts.intervalMs ?? 2_000;
|
|
199
|
+
const sleep = opts.sleep ?? ((ms: number) => Bun.sleep(ms));
|
|
200
|
+
let stopped = false;
|
|
201
|
+
const prev = new Map<string, DevStatus>();
|
|
202
|
+
|
|
203
|
+
const tick = async (): Promise<void> => {
|
|
204
|
+
while (!stopped) {
|
|
205
|
+
const latest = await readComposeHealth(opts);
|
|
206
|
+
if (stopped) return;
|
|
207
|
+
const changed: { service: string; status: DevStatus }[] = [];
|
|
208
|
+
for (const [name, status] of latest) {
|
|
209
|
+
if (prev.get(name) !== status) {
|
|
210
|
+
prev.set(name, status);
|
|
211
|
+
changed.push({ service: name, status });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Services that vanished from ps -a (removed) → treat as error once.
|
|
215
|
+
for (const name of [...prev.keys()]) {
|
|
216
|
+
if (!latest.has(name)) {
|
|
217
|
+
prev.delete(name);
|
|
218
|
+
changed.push({ service: name, status: "error" });
|
|
219
|
+
latest.set(name, "error");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (changed.length > 0 || prev.size === 0) {
|
|
223
|
+
for (const [name, status] of latest) prev.set(name, status);
|
|
224
|
+
opts.onChange(latest, changed);
|
|
225
|
+
}
|
|
226
|
+
await sleep(intervalMs);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
void tick();
|
|
231
|
+
return () => {
|
|
232
|
+
stopped = true;
|
|
233
|
+
};
|
|
234
|
+
}
|
package/src/docker/compose.ts
CHANGED
|
@@ -133,13 +133,14 @@ export function emitComposeLayers(
|
|
|
133
133
|
for (const spec of specs) {
|
|
134
134
|
const recipe = recipeFor(spec.image, recipes);
|
|
135
135
|
const applied = recipe.apply(spec);
|
|
136
|
+
const bindPrefix = applied.publishBind ? `${applied.publishBind}:` : "";
|
|
136
137
|
const ports = [
|
|
137
|
-
`${spec.hostPort}:${spec.port}`,
|
|
138
|
+
`${bindPrefix}${spec.hostPort}:${spec.port}`,
|
|
138
139
|
...(applied.extraPorts ?? []).map((p) => {
|
|
139
140
|
const hostPort = options.instanceId
|
|
140
141
|
? extraHostPortForInstance(spec.role, p.host, options.instanceId)
|
|
141
142
|
: p.host;
|
|
142
|
-
return `${hostPort}:${p.container}`;
|
|
143
|
+
return `${bindPrefix}${hostPort}:${p.container}`;
|
|
143
144
|
}),
|
|
144
145
|
];
|
|
145
146
|
const service: Record<string, unknown> = {
|
|
@@ -156,6 +157,8 @@ export function emitComposeLayers(
|
|
|
156
157
|
if (applied.user) service.user = applied.user;
|
|
157
158
|
if (applied.ulimits) service.ulimits = applied.ulimits;
|
|
158
159
|
if (applied.labels) service.labels = applied.labels;
|
|
160
|
+
if (applied.ipc) service.ipc = applied.ipc;
|
|
161
|
+
if (applied.deploy) service.deploy = applied.deploy;
|
|
159
162
|
if (applied.dependsOn) {
|
|
160
163
|
const deps = { ...applied.dependsOn };
|
|
161
164
|
if (!includeApp) delete deps.app;
|
|
@@ -357,9 +360,14 @@ export function buildStackEnv(
|
|
|
357
360
|
env.SMTP_PORT = String(spec.hostPort);
|
|
358
361
|
if (uiHost !== undefined) env.MAILPIT_UI_URL = `http://${host}:${uiHost}`;
|
|
359
362
|
} else if (spec.role === "vault") {
|
|
360
|
-
// Token is minted by
|
|
363
|
+
// Token is minted by OpenBao bootstrap and preserved via stack controls —
|
|
364
|
+
// never a generated password here.
|
|
361
365
|
env[`${prefix}_URL`] = url;
|
|
362
366
|
env.OKE_VAULT_URL = url;
|
|
367
|
+
if (controls.OKE_VAULT_TOKEN) {
|
|
368
|
+
env.OKE_VAULT_TOKEN = controls.OKE_VAULT_TOKEN;
|
|
369
|
+
env.OKE_VAULT_MOUNT = controls.OKE_VAULT_MOUNT ?? "secret";
|
|
370
|
+
}
|
|
363
371
|
} else if (spec.role === "store.index") {
|
|
364
372
|
// Meilisearch: standalone HTTP URL + the generated master key.
|
|
365
373
|
env[`${prefix}_URL`] = url;
|
|
@@ -412,14 +420,15 @@ const ROLE_SECTION_TITLE: Readonly<Record<string, string>> = {
|
|
|
412
420
|
"channel.email": "channel.email — Mailpit (SMTP + UI)",
|
|
413
421
|
signal: "signal — message bus",
|
|
414
422
|
vault: "vault — OpenBao",
|
|
415
|
-
ai: "ai —
|
|
423
|
+
ai: "ai — local inference (llama.cpp / Ollama / vLLM / SGLang)",
|
|
416
424
|
proxy: "proxy — TLS terminator (Caddy / Traefik)",
|
|
417
425
|
};
|
|
418
426
|
|
|
419
427
|
/** Friendly aliases emitted beside their role block. */
|
|
420
428
|
const ROLE_ALIASES: Readonly<Record<string, readonly string[]>> = {
|
|
421
429
|
"store.sql": ["DATABASE_URL", "PGDATA", "POSTGRES_INITDB_ARGS"],
|
|
422
|
-
|
|
430
|
+
// OKE_PGDOG_URL is already emitted as `${prefix}_URL`.
|
|
431
|
+
pgdog: [],
|
|
423
432
|
"store.kv": ["REDIS_URL", "OKE_STORE_KV_MAXMEMORY", "OKE_STORE_KV_MAXMEMORY_POLICY"],
|
|
424
433
|
"store.files": [
|
|
425
434
|
"S3_ACCESS_KEY_ID",
|
|
@@ -442,8 +451,11 @@ const ROLE_ALIASES: Readonly<Record<string, readonly string[]>> = {
|
|
|
442
451
|
"MP_SMTP_AUTH_ACCEPT_ANY",
|
|
443
452
|
"MP_SMTP_AUTH_ALLOW_INSECURE",
|
|
444
453
|
],
|
|
445
|
-
|
|
454
|
+
// OKE_AI_URL is already emitted as `${prefix}_URL` — do not alias it again.
|
|
455
|
+
ai: ["OKE_AI_MODEL"],
|
|
446
456
|
proxy: ["OKE_PROXY_HOST", "OKE_PROXY_ACME_EMAIL"],
|
|
457
|
+
vault: ["OKE_VAULT_TOKEN", "OKE_VAULT_MOUNT"],
|
|
458
|
+
"store.index": ["OKE_STORE_INDEX_KEY"],
|
|
447
459
|
};
|
|
448
460
|
|
|
449
461
|
/** Optional controls documented in `.env.docker` and preserved on regeneration. */
|
|
@@ -458,8 +470,8 @@ const ROLE_CONTROL_EXAMPLES: Readonly<Record<string, readonly string[]>> = {
|
|
|
458
470
|
"MP_SMTP_AUTH_ACCEPT_ANY=1",
|
|
459
471
|
"MP_SMTP_AUTH_ALLOW_INSECURE=1",
|
|
460
472
|
],
|
|
461
|
-
//
|
|
462
|
-
ai: ["OKE_AI_MODEL=
|
|
473
|
+
// Curated Docker Hub `ai/` model id for llama.cpp; Ollama tags differ.
|
|
474
|
+
ai: ["OKE_AI_MODEL=smollm2"],
|
|
463
475
|
proxy: ["OKE_PROXY_HOST=localhost", "OKE_PROXY_ACME_EMAIL=admin@example.com"],
|
|
464
476
|
};
|
|
465
477
|
|
|
@@ -481,6 +493,10 @@ function roleFromEnvKey(key: string): string | undefined {
|
|
|
481
493
|
if (key === "OKE_PROXY_URL" || key === "OKE_PROXY_HOST" || key === "OKE_PROXY_ACME_EMAIL") {
|
|
482
494
|
return "proxy";
|
|
483
495
|
}
|
|
496
|
+
if (key === "OKE_VAULT_TOKEN" || key === "OKE_VAULT_MOUNT" || key === "OKE_VAULT_URL") {
|
|
497
|
+
return "vault";
|
|
498
|
+
}
|
|
499
|
+
if (key === "OKE_STORE_INDEX_KEY" || key === "OKE_STORE_INDEX_URL") return "store.index";
|
|
484
500
|
return undefined;
|
|
485
501
|
}
|
|
486
502
|
|
package/src/docker/derive.ts
CHANGED
|
@@ -12,6 +12,11 @@ import {
|
|
|
12
12
|
} from "./compose.ts";
|
|
13
13
|
import { emitDockerfile } from "./dockerfile.ts";
|
|
14
14
|
import { buildCaddyfile } from "./recipes/caddy.ts";
|
|
15
|
+
import {
|
|
16
|
+
buildLlamaCppEntrypoint,
|
|
17
|
+
LLAMA_CPP_ENTRYPOINT_FILE,
|
|
18
|
+
llamaCpp,
|
|
19
|
+
} from "./recipes/llama-cpp.ts";
|
|
15
20
|
import { buildPgDogToml, buildPgDogUsersToml } from "./recipes/pgdog.ts";
|
|
16
21
|
import type { DeriveOptions, DeriveResult, GeneratedFile } from "./types.ts";
|
|
17
22
|
import { DEFAULT_DOCKER_DIR } from "./types.ts";
|
|
@@ -45,12 +50,21 @@ export function deriveInfrastructure(options: DeriveOptions): DeriveResult {
|
|
|
45
50
|
path: "Dockerfile",
|
|
46
51
|
content: emitDockerfile({ appPort: normalised.appPort }),
|
|
47
52
|
};
|
|
53
|
+
const stackEnv = buildStackEnv(
|
|
54
|
+
specs,
|
|
55
|
+
normalised.recipes ?? [],
|
|
56
|
+
normalised.host ?? "127.0.0.1",
|
|
57
|
+
normalised.controls,
|
|
58
|
+
normalised.instanceId,
|
|
59
|
+
);
|
|
60
|
+
|
|
48
61
|
// Always emit Dockerfile for deploy; stack-only runs ignore it.
|
|
49
62
|
const files = [
|
|
50
63
|
dockerfile,
|
|
51
64
|
...composeFilesContent,
|
|
52
65
|
...pgdogConfigFiles(specs),
|
|
53
66
|
...proxyConfigFiles(specs, normalised.appPort),
|
|
67
|
+
...llamaCppEntrypointFiles(specs),
|
|
54
68
|
];
|
|
55
69
|
|
|
56
70
|
for (const f of files) {
|
|
@@ -62,17 +76,26 @@ export function deriveInfrastructure(options: DeriveOptions): DeriveResult {
|
|
|
62
76
|
}
|
|
63
77
|
}
|
|
64
78
|
|
|
65
|
-
const stackEnv = buildStackEnv(
|
|
66
|
-
specs,
|
|
67
|
-
normalised.recipes ?? [],
|
|
68
|
-
normalised.host ?? "127.0.0.1",
|
|
69
|
-
normalised.controls,
|
|
70
|
-
normalised.instanceId,
|
|
71
|
-
);
|
|
72
|
-
|
|
73
79
|
return { specs, files, stackEnv, composeFiles };
|
|
74
80
|
}
|
|
75
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Emit `llama-entrypoint.sh` when the AI role is llama.cpp so first boot can
|
|
84
|
+
* Hub-pull then serve single-model (router `--docker-repo` hangs on b10290+).
|
|
85
|
+
*
|
|
86
|
+
* @param specs - Normalised services
|
|
87
|
+
*/
|
|
88
|
+
function llamaCppEntrypointFiles(specs: DeriveResult["specs"]): GeneratedFile[] {
|
|
89
|
+
const ai = specs.find((s) => s.role === "ai");
|
|
90
|
+
if (!ai || !llamaCpp.match(ai.image)) return [];
|
|
91
|
+
return [
|
|
92
|
+
{
|
|
93
|
+
path: LLAMA_CPP_ENTRYPOINT_FILE,
|
|
94
|
+
content: buildLlamaCppEntrypoint(),
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
|
|
76
99
|
/**
|
|
77
100
|
* Emit PgDog TOML configs when both `pgdog` and `store.sql` are in the stack.
|
|
78
101
|
*
|
|
@@ -88,7 +111,10 @@ function pgdogConfigFiles(specs: DeriveResult["specs"]): GeneratedFile[] {
|
|
|
88
111
|
return [
|
|
89
112
|
{
|
|
90
113
|
path: "pgdog.toml",
|
|
91
|
-
content: buildPgDogToml({
|
|
114
|
+
content: buildPgDogToml({
|
|
115
|
+
database: sql.credentials.database,
|
|
116
|
+
postgresPort: sql.port,
|
|
117
|
+
}),
|
|
92
118
|
},
|
|
93
119
|
{
|
|
94
120
|
path: "users.toml",
|