okengine 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/AGENTS.md +1 -1
  2. package/manifest.v1.schema.json +9 -1
  3. package/package.json +3 -3
  4. package/site/content/docs/console/runs.mdx +10 -0
  5. package/site/content/docs/console/traces.mdx +4 -0
  6. package/site/content/docs/elements/ai.mdx +54 -32
  7. package/site/content/docs/elements/channel.mdx +105 -14
  8. package/site/content/docs/elements/clock.mdx +1 -0
  9. package/site/content/docs/elements/flow.mdx +42 -8
  10. package/site/content/docs/get-started/basic-usage.mdx +5 -1
  11. package/site/content/docs/get-started/installation.mdx +9 -5
  12. package/site/content/docs/meta.json +2 -0
  13. package/site/content/docs/plugins/otp.mdx +58 -46
  14. package/site/content/docs/providers/azure-redis.mdx +85 -0
  15. package/site/content/docs/providers/cockroachdb.mdx +86 -0
  16. package/site/content/docs/providers/digitalocean-caching.mdx +88 -0
  17. package/site/content/docs/providers/dragonfly-cloud.mdx +82 -0
  18. package/site/content/docs/providers/elasticache.mdx +92 -0
  19. package/site/content/docs/providers/index.mdx +101 -0
  20. package/site/content/docs/providers/memorystore.mdx +89 -0
  21. package/site/content/docs/providers/meta.json +20 -0
  22. package/site/content/docs/providers/neon.mdx +115 -0
  23. package/site/content/docs/providers/redis-cloud.mdx +93 -0
  24. package/site/content/docs/providers/supabase.mdx +117 -0
  25. package/site/content/docs/providers/upstash.mdx +91 -0
  26. package/site/content/docs/providers/yugabytedb.mdx +95 -0
  27. package/site/content/docs/recipes/caddy.mdx +128 -0
  28. package/site/content/docs/recipes/cockroachdb.mdx +127 -0
  29. package/site/content/docs/recipes/dragonfly.mdx +125 -0
  30. package/site/content/docs/recipes/index.mdx +146 -0
  31. package/site/content/docs/recipes/llama-cpp.mdx +153 -0
  32. package/site/content/docs/recipes/mailpit.mdx +136 -0
  33. package/site/content/docs/recipes/meilisearch.mdx +139 -0
  34. package/site/content/docs/recipes/meta.json +31 -0
  35. package/site/content/docs/recipes/ollama.mdx +142 -0
  36. package/site/content/docs/recipes/openbao.mdx +141 -0
  37. package/site/content/docs/recipes/pgdog.mdx +137 -0
  38. package/site/content/docs/recipes/postgres.mdx +136 -0
  39. package/site/content/docs/recipes/redis.mdx +130 -0
  40. package/site/content/docs/recipes/rustfs.mdx +148 -0
  41. package/site/content/docs/recipes/sglang.mdx +105 -0
  42. package/site/content/docs/recipes/supabase-docker.mdx +145 -0
  43. package/site/content/docs/recipes/timescale.mdx +129 -0
  44. package/site/content/docs/recipes/traefik.mdx +136 -0
  45. package/site/content/docs/recipes/valkey.mdx +121 -0
  46. package/site/content/docs/recipes/vllm.mdx +106 -0
  47. package/site/content/docs/recipes/yugabytedb.mdx +135 -0
  48. package/site/content/docs/reference/cli.md +42 -6
  49. package/site/content/docs/reference/configuration.mdx +5 -3
  50. package/site/content/docs/reference/environment-variables.mdx +12 -12
  51. package/site/content/docs/reference/fx.mdx +38 -4
  52. package/src/auth/otp-capability.ts +14 -14
  53. package/src/cli/ai-setup/ai-setup.test.ts +32 -12
  54. package/src/cli/ai-setup/apply.ts +65 -23
  55. package/src/cli/ai-setup/catalog.ts +1316 -35
  56. package/src/cli/ai-setup/detect-ollama.ts +47 -0
  57. package/src/cli/ai-setup/index.ts +44 -7
  58. package/src/cli/ai-setup/prompts.ts +430 -486
  59. package/src/cli/ai-setup/recommend.test.ts +150 -50
  60. package/src/cli/ai-setup/recommend.ts +118 -101
  61. package/src/cli/ask-seed.test.ts +68 -0
  62. package/src/cli/ask-seed.ts +79 -0
  63. package/src/cli/db-seed-redact.test.ts +25 -0
  64. package/src/cli/db-seed.ts +36 -7
  65. package/src/cli/db.test.ts +1 -1
  66. package/src/cli/db.ts +2 -2
  67. package/src/cli/dev-controls.test.ts +107 -0
  68. package/src/cli/dev-controls.ts +354 -0
  69. package/src/cli/dev-schema-sync.ts +12 -4
  70. package/src/cli/dev.test.ts +50 -30
  71. package/src/cli/dev.ts +607 -66
  72. package/src/cli/drizzle-env.test.ts +40 -6
  73. package/src/cli/drizzle-env.ts +22 -1
  74. package/src/cli/ensure-drizzle-config.ts +6 -4
  75. package/src/cli/hero-meta.test.ts +13 -1
  76. package/src/cli/hero-meta.ts +28 -6
  77. package/src/cli/index.ts +5 -0
  78. package/src/cli/registry.ts +24 -0
  79. package/src/cli/replay.test.ts +105 -0
  80. package/src/cli/replay.ts +206 -0
  81. package/src/compiler/extract.ts +3 -3
  82. package/src/config/index.ts +3 -3
  83. package/src/console/server/claim.ts +3 -1
  84. package/src/console/ui/dist/assets/{index-ClX0ZCe5.js → index-D-9KFce5.js} +2 -2
  85. package/src/console/ui/dist/assets/panel-overview-CW0CQEc2.js +1 -0
  86. package/src/console/ui/dist/assets/panel-runs-BxuyDI3x.js +1 -0
  87. package/src/console/ui/dist/assets/{panel-signals-Dsxdu_AR.js → panel-signals-BXofbj7v.js} +1 -1
  88. package/src/console/ui/dist/assets/{panel-store-hy7O8HOs.js → panel-store-B8mH-arU.js} +1 -1
  89. package/src/console/ui/dist/assets/{panel-traces-CBiAe5go.js → panel-traces-DSA-ZWnf.js} +1 -1
  90. package/src/console/ui/dist/index.html +1 -1
  91. package/src/console/ui/overview/slo.ts +31 -3
  92. package/src/console/ui/overview/types.ts +6 -0
  93. package/src/console/ui/runs/errors.test.ts +53 -0
  94. package/src/console/ui/runs/errors.ts +88 -0
  95. package/src/console/ui/runs/index.ts +8 -0
  96. package/src/console/ui/runs/search.ts +16 -0
  97. package/src/console/ui/shell/panels/runs/RunsPanel.tsx +72 -1
  98. package/src/docker/ai-model-status.test.ts +101 -0
  99. package/src/docker/ai-model-status.ts +276 -0
  100. package/src/docker/compose-health.test.ts +115 -0
  101. package/src/docker/compose-health.ts +234 -0
  102. package/src/docker/compose.ts +24 -8
  103. package/src/docker/derive.ts +35 -9
  104. package/src/docker/docker.test.ts +304 -12
  105. package/src/docker/helpers.ts +56 -0
  106. package/src/docker/index.ts +52 -0
  107. package/src/docker/ollama-pull.ts +232 -0
  108. package/src/docker/recipes/cockroach.ts +24 -0
  109. package/src/docker/recipes/index.ts +32 -0
  110. package/src/docker/recipes/llama-cpp.ts +273 -0
  111. package/src/docker/recipes/ollama.ts +19 -23
  112. package/src/docker/recipes/pgdog.ts +4 -1
  113. package/src/docker/recipes/postgres.ts +5 -18
  114. package/src/docker/recipes/sglang.ts +55 -0
  115. package/src/docker/recipes/supabase.ts +21 -0
  116. package/src/docker/recipes/timescale.ts +18 -0
  117. package/src/docker/recipes/vllm.ts +44 -0
  118. package/src/docker/recipes/yugabyte.ts +23 -0
  119. package/src/docker/stack-id.test.ts +22 -0
  120. package/src/docker/stack-id.ts +38 -4
  121. package/src/docker/types.ts +15 -0
  122. package/src/drivers/ai-ollama.integration.test.ts +7 -4
  123. package/src/drivers/signal-engine.ts +10 -0
  124. package/src/drivers/signal-postgres.ts +24 -2
  125. package/src/drivers/signal-types.ts +10 -0
  126. package/src/elements/channel/fallback.test.ts +91 -0
  127. package/src/elements/channel/hard-bounce.test.ts +82 -0
  128. package/src/elements/channel/injection.test.ts +153 -0
  129. package/src/elements/channel/locale-ar.test.ts +102 -0
  130. package/src/elements/channel/otp-delivery.ts +5 -2
  131. package/src/elements/channel/receipts-ledger.test.ts +100 -0
  132. package/src/elements/channel/runtime.ts +24 -1
  133. package/src/elements/channel/test-helpers.ts +60 -0
  134. package/src/elements/channel/whatsapp-compliance.test.ts +124 -0
  135. package/src/elements/store/cache.ts +1 -1
  136. package/src/elements/store/emit-drizzle.ts +43 -9
  137. package/src/elements/store/runtime.ts +1 -1
  138. package/src/elements/store/schema-decl.test.ts +2 -0
  139. package/src/elements/store/schema-decl.ts +15 -0
  140. package/src/elements/store/seed.ts +1 -1
  141. package/src/index.ts +1 -0
  142. package/src/kernel/app.ts +118 -11
  143. package/src/kernel/boot-bind/signal.ts +6 -2
  144. package/src/kernel/boot-bind/store.ts +2 -1
  145. package/src/kernel/boot.test.ts +6 -5
  146. package/src/kernel/boot.ts +8 -3
  147. package/src/kernel/compensate.test.ts +131 -0
  148. package/src/kernel/correlation.test.ts +108 -0
  149. package/src/kernel/effect-timing.test.ts +82 -0
  150. package/src/kernel/flow.ts +38 -0
  151. package/src/kernel/fx-runs.test.ts +49 -0
  152. package/src/kernel/fx.test.ts +14 -0
  153. package/src/kernel/fx.ts +111 -1
  154. package/src/manifest/types.ts +8 -2
  155. package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
  156. package/src/plugins/auth-methods.security.test.ts +3 -3
  157. package/src/plugins/auth-methods.test.ts +1 -1
  158. package/src/plugins/index.ts +2 -2
  159. package/src/plugins/otp.test.ts +21 -19
  160. package/src/plugins/otp.ts +36 -34
  161. package/src/plugins/taqnyat.live.test.ts +3 -3
  162. package/src/runs/collect.ts +3 -0
  163. package/src/runs/export-otlp.ts +64 -0
  164. package/src/runs/index.ts +16 -0
  165. package/src/runs/parquet.ts +9 -0
  166. package/src/runs/types.ts +5 -0
  167. package/src/runs/window.test.ts +68 -0
  168. package/src/runs/window.ts +148 -0
  169. package/src/runtime/boot-warn.test.ts +45 -0
  170. package/src/runtime/boot-warn.ts +17 -0
  171. package/src/runtime/dev-request-log.ts +5 -5
  172. package/src/term.test.ts +95 -10
  173. package/src/term.ts +369 -14
  174. package/src/console/ui/dist/assets/panel-overview-DONDzBd2.js +0 -1
  175. package/src/console/ui/dist/assets/panel-runs-D5zf-D9c.js +0 -1
@@ -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
+ }
@@ -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 the bootstrap never a generated password here.
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 — Ollama (local models)",
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
- pgdog: ["OKE_PGDOG_URL"],
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
- ai: ["OKE_AI_URL", "OKE_AI_MODEL"],
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
- // qwen3.5:9b is a balanced local-dev starting point override freely.
462
- ai: ["OKE_AI_MODEL=qwen3.5:9b"],
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
 
@@ -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({ database: sql.credentials.database }),
114
+ content: buildPgDogToml({
115
+ database: sql.credentials.database,
116
+ postgresPort: sql.port,
117
+ }),
92
118
  },
93
119
  {
94
120
  path: "users.toml",