mlx-bun 0.0.4

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 (210) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +362 -0
  3. package/THIRD_PARTY_LICENSES.md +41 -0
  4. package/bin/mlx-bun.mjs +32 -0
  5. package/docs/curve-designer.html +303 -0
  6. package/docs/design/adapters-end-to-end.md +201 -0
  7. package/docs/design/compat-cli-surface-design.md +227 -0
  8. package/docs/design/docs-reorg-plan.md +152 -0
  9. package/docs/design/hlg-sampling.md +340 -0
  10. package/docs/design/memory-system.md +339 -0
  11. package/docs/design/optimization_plan.md +344 -0
  12. package/docs/design/parallel-slots.md +515 -0
  13. package/docs/design/runtime-isolation.md +247 -0
  14. package/docs/design/segmented-backward-training.md +508 -0
  15. package/docs/investigations/chunk-finetune-experiment.md +84 -0
  16. package/docs/investigations/curve-bisector-routing.md +123 -0
  17. package/docs/investigations/curve-runs/bisector-route.json +98 -0
  18. package/docs/investigations/curve-runs/bisector-trace.json +1393 -0
  19. package/docs/investigations/curve-runs/curve-terrain.json +1179 -0
  20. package/docs/investigations/curve-runs/curve-terrain.svg +42 -0
  21. package/docs/investigations/curve-terrain.html +105 -0
  22. package/docs/investigations/expert-offload-single-user-moe.md +292 -0
  23. package/docs/investigations/hlg-ab.html +34 -0
  24. package/docs/investigations/hlg-explorer.html +238 -0
  25. package/docs/investigations/hlg-figs/cliff.svg +1 -0
  26. package/docs/investigations/hlg-figs/cross.svg +1 -0
  27. package/docs/investigations/hlg-figs/edges.svg +1 -0
  28. package/docs/investigations/hlg-figs/k20-grids.svg +1 -0
  29. package/docs/investigations/hlg-figs/k20-peaks.svg +1 -0
  30. package/docs/investigations/hlg-figs/lw-curve.svg +1 -0
  31. package/docs/investigations/hlg-figs/terrain.svg +1 -0
  32. package/docs/investigations/hlg-report.html +262 -0
  33. package/docs/investigations/hlg-runs/hlg-ab.json +149 -0
  34. package/docs/investigations/hlg-runs/hlg-blind-prompts.md +87 -0
  35. package/docs/investigations/hlg-runs/hlg-blind.json +107 -0
  36. package/docs/investigations/hlg-runs/hlg-cliff.json +197 -0
  37. package/docs/investigations/hlg-runs/hlg-cross.json +505 -0
  38. package/docs/investigations/hlg-runs/hlg-edge.json +388 -0
  39. package/docs/investigations/hlg-runs/hlg-eval1.json +25 -0
  40. package/docs/investigations/hlg-runs/hlg-frontier.json +86 -0
  41. package/docs/investigations/hlg-runs/hlg-grid.json +242 -0
  42. package/docs/investigations/hlg-runs/hlg-grid2.json +343 -0
  43. package/docs/investigations/hlg-runs/hlg-lw-coarse.json +110 -0
  44. package/docs/investigations/hlg-runs/hlg-map.json +383 -0
  45. package/docs/investigations/hlg-runs/run0-original.log +159 -0
  46. package/docs/investigations/hlg-runs/runA-recommended.log +131 -0
  47. package/docs/investigations/hlg-runs/runAA-cliff-cpm5.log +26 -0
  48. package/docs/investigations/hlg-runs/runAB-grid2-e4b.log +18 -0
  49. package/docs/investigations/hlg-runs/runAC-peak-e4b-partial.log +25 -0
  50. package/docs/investigations/hlg-runs/runAD-ab-samples.log +8 -0
  51. package/docs/investigations/hlg-runs/runAE-edge-e4b.log +51 -0
  52. package/docs/investigations/hlg-runs/runAF-logit-calibration.log +10 -0
  53. package/docs/investigations/hlg-runs/runAG-eval-config2.log +16 -0
  54. package/docs/investigations/hlg-runs/runB-gain-variants.log +192 -0
  55. package/docs/investigations/hlg-runs/runC-pivots.log +162 -0
  56. package/docs/investigations/hlg-runs/runD-oetf.log +132 -0
  57. package/docs/investigations/hlg-runs/runE-eotf.log +162 -0
  58. package/docs/investigations/hlg-runs/runF-shaper-working.log +86 -0
  59. package/docs/investigations/hlg-runs/runG-shaper-sweep.log +101 -0
  60. package/docs/investigations/hlg-runs/runH-wsweep.log +176 -0
  61. package/docs/investigations/hlg-runs/runI-asweep.log +176 -0
  62. package/docs/investigations/hlg-runs/runJ-knobmap.log +49 -0
  63. package/docs/investigations/hlg-runs/runK-frontier.log +19 -0
  64. package/docs/investigations/hlg-runs/runL-widemap.log +65 -0
  65. package/docs/investigations/hlg-runs/runM-frontier-emb.log +15 -0
  66. package/docs/investigations/hlg-runs/runN-verify.log +116 -0
  67. package/docs/investigations/hlg-runs/runO-frontier-seed5000.log +15 -0
  68. package/docs/investigations/hlg-runs/runP-grid-AxSm.log +15 -0
  69. package/docs/investigations/hlg-runs/runQ-grid-tgW.log +15 -0
  70. package/docs/investigations/hlg-runs/runR-blind-transcript.log +79 -0
  71. package/docs/investigations/hlg-runs/runS-cross-coherence.log +33 -0
  72. package/docs/investigations/hlg-runs/runT-cross-cpm5.log +33 -0
  73. package/docs/investigations/hlg-runs/runU-cross-12b.log +33 -0
  74. package/docs/investigations/hlg-runs/runV-lw-e4b.log +22 -0
  75. package/docs/investigations/hlg-runs/runW-lw-12b.log +22 -0
  76. package/docs/investigations/hlg-runs/runX-lw-cpm5.log +22 -0
  77. package/docs/investigations/hlg-runs/runY-cliff-e4b.log +26 -0
  78. package/docs/investigations/hlg-runs/runZ-cliff-12b.log +26 -0
  79. package/docs/investigations/hlg-sampling-investigation.md +970 -0
  80. package/docs/investigations/lab-build-journal.md +157 -0
  81. package/docs/investigations/pi-builtin-investigation.md +308 -0
  82. package/docs/investigations/starter-model-port-handoff.md +323 -0
  83. package/docs/planning/IDEAS.md +26 -0
  84. package/docs/planning/PRODUCT_ROADMAP.md +426 -0
  85. package/docs/planning/ResearchTopics.md +119 -0
  86. package/docs/planning/chunk-task-roadmap.md +77 -0
  87. package/docs/planning/journal.md +155 -0
  88. package/docs/reference/distribution.md +121 -0
  89. package/docs/reference/embedding.md +168 -0
  90. package/docs/reference/library-api.md +153 -0
  91. package/docs/reference/server-api.md +224 -0
  92. package/docs/reference/server-config.md +243 -0
  93. package/docs/segmented-backward-handoff.md +148 -0
  94. package/package.json +68 -0
  95. package/src/anthropic.ts +491 -0
  96. package/src/chat-template.ts +186 -0
  97. package/src/cli.ts +1075 -0
  98. package/src/config.ts +229 -0
  99. package/src/curve-sampler.ts +122 -0
  100. package/src/dataset/generators.ts +1087 -0
  101. package/src/dataset/index.ts +12 -0
  102. package/src/dataset/job.ts +81 -0
  103. package/src/dataset/llm.ts +71 -0
  104. package/src/dataset/registry.ts +528 -0
  105. package/src/download.ts +283 -0
  106. package/src/eval/README.md +86 -0
  107. package/src/eval/kl-prompts.ts +29 -0
  108. package/src/eval/kl.ts +294 -0
  109. package/src/eval/quality-db.ts +92 -0
  110. package/src/eval/runner.ts +102 -0
  111. package/src/eval/score.ts +49 -0
  112. package/src/eval/tasks/bfcl.ts +500 -0
  113. package/src/eval/tasks/gsm8k.ts +85 -0
  114. package/src/eval/tasks/hashhop.ts +109 -0
  115. package/src/eval/tasks/humaneval.ts +252 -0
  116. package/src/eval/tasks/ifeval.ts +354 -0
  117. package/src/eval/tasks/mmlu.ts +145 -0
  118. package/src/evaldb.ts +199 -0
  119. package/src/expert-offload-build.ts +77 -0
  120. package/src/expert-offload.ts +61 -0
  121. package/src/expert-trace.ts +67 -0
  122. package/src/fit.ts +254 -0
  123. package/src/generate.ts +451 -0
  124. package/src/harness-pi.ts +177 -0
  125. package/src/hf-push.ts +628 -0
  126. package/src/index.ts +22 -0
  127. package/src/jobs/db.ts +139 -0
  128. package/src/jobs/index.ts +29 -0
  129. package/src/jobs/job-entry.ts +106 -0
  130. package/src/jobs/runner.ts +304 -0
  131. package/src/jobs/sse.ts +114 -0
  132. package/src/jobs/types.ts +58 -0
  133. package/src/kv-store.ts +185 -0
  134. package/src/lora.ts +314 -0
  135. package/src/md-modules.d.ts +7 -0
  136. package/src/mlx/array.ts +237 -0
  137. package/src/mlx/autograd.ts +267 -0
  138. package/src/mlx/checkpoint.ts +116 -0
  139. package/src/mlx/compile.ts +158 -0
  140. package/src/mlx/custom-vjp.ts +140 -0
  141. package/src/mlx/ffi.ts +433 -0
  142. package/src/mlx/metal-kernel.ts +130 -0
  143. package/src/mlx/ops.ts +707 -0
  144. package/src/mmap.ts +90 -0
  145. package/src/model/batched-mask.ts +204 -0
  146. package/src/model/batched-rotating.ts +300 -0
  147. package/src/model/compiled-decode.ts +681 -0
  148. package/src/model/factory.ts +27 -0
  149. package/src/model/fingerprint.ts +33 -0
  150. package/src/model/flash-attention.ts +674 -0
  151. package/src/model/fused-decode-kernel.ts +305 -0
  152. package/src/model/fused-geglu-kernel.ts +87 -0
  153. package/src/model/gemma4-base.ts +1616 -0
  154. package/src/model/gemma4.ts +1012 -0
  155. package/src/model/generated/gemma4-12b.ts +526 -0
  156. package/src/model/generated/gemma4-26b.ts +500 -0
  157. package/src/model/generated/gemma4-e4b.ts +757 -0
  158. package/src/model/generated/index.ts +18 -0
  159. package/src/model/minicpm5.ts +278 -0
  160. package/src/model/qwen3-delta.ts +224 -0
  161. package/src/model/qwen3_5.ts +429 -0
  162. package/src/model/support.ts +46 -0
  163. package/src/native-pack.ts +152 -0
  164. package/src/pi-provider.ts +95 -0
  165. package/src/pi-terminal.ts +264 -0
  166. package/src/pi-web.ts +924 -0
  167. package/src/preflight.ts +123 -0
  168. package/src/prompt-cache.ts +113 -0
  169. package/src/quantize/allocator.ts +627 -0
  170. package/src/quantize/calibration.ts +180 -0
  171. package/src/quantize/config-writer.ts +132 -0
  172. package/src/quantize/index.ts +57 -0
  173. package/src/quantize/job.ts +136 -0
  174. package/src/quantize/quantizer.ts +462 -0
  175. package/src/quantize/safetensors-writer.ts +171 -0
  176. package/src/quantize/sensitivity.ts +234 -0
  177. package/src/registry.ts +336 -0
  178. package/src/responses.ts +774 -0
  179. package/src/safetensors.ts +158 -0
  180. package/src/sampler.ts +517 -0
  181. package/src/serve/batch-scheduler.ts +362 -0
  182. package/src/serve/generation-gateway.ts +160 -0
  183. package/src/server.ts +1891 -0
  184. package/src/spec/drafter.ts +261 -0
  185. package/src/spec/generate.ts +221 -0
  186. package/src/status-page.html +497 -0
  187. package/src/tokenizer.ts +38 -0
  188. package/src/tool-call.ts +242 -0
  189. package/src/train/dataset.ts +461 -0
  190. package/src/train/export.ts +32 -0
  191. package/src/train/forward.ts +229 -0
  192. package/src/train/index.ts +43 -0
  193. package/src/train/job.ts +148 -0
  194. package/src/train/lora-params.ts +194 -0
  195. package/src/train/loss.ts +342 -0
  196. package/src/train/merge.ts +195 -0
  197. package/src/train/optimizer.ts +176 -0
  198. package/src/train/rank.ts +101 -0
  199. package/src/train/segmented.ts +520 -0
  200. package/src/train/trainer.ts +600 -0
  201. package/src/tui.ts +161 -0
  202. package/src/vision/embedder.ts +168 -0
  203. package/src/vision/preprocess.ts +247 -0
  204. package/src/vision/prompt.ts +158 -0
  205. package/src/vision/siglip.ts +599 -0
  206. package/src/web/app.html +2471 -0
  207. package/src/web/skills/web-research/SKILL.md +44 -0
  208. package/src/web/skills.ts +59 -0
  209. package/src/web-tools.ts +426 -0
  210. package/src/weights.ts +91 -0
package/src/jobs/db.ts ADDED
@@ -0,0 +1,139 @@
1
+ // Job store: every background job (quantize / finetune / dataset) is a row
2
+ // here (bun:sqlite, WAL). The runner streams NDJSON events to a per-job log
3
+ // file and mirrors progress/message back onto the row so a status page can
4
+ // read state without tailing logs. Port of optiq lab db.py's `jobs` table.
5
+ //
6
+ // WAL is load-bearing: the SSE poller reads while the job (or its child
7
+ // process) writes — without WAL the reader and writer fight over the lock.
8
+
9
+ import { Database } from "bun:sqlite";
10
+ import { mkdirSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import type { JobRow, JobStatus } from "./types";
13
+
14
+ export const DEFAULT_JOBS_DB = `${process.env.HOME}/.cache/mlx-bun/jobs.sqlite`;
15
+ export const DEFAULT_JOBS_DIR = `${process.env.HOME}/.cache/mlx-bun/jobs`;
16
+
17
+ const SCHEMA = `
18
+ CREATE TABLE IF NOT EXISTS jobs (
19
+ id TEXT PRIMARY KEY,
20
+ kind TEXT NOT NULL,
21
+ status TEXT NOT NULL,
22
+ config_json TEXT NOT NULL,
23
+ progress REAL NOT NULL DEFAULT 0,
24
+ message TEXT,
25
+ log_path TEXT NOT NULL,
26
+ output_path TEXT,
27
+ error TEXT,
28
+ started_at TEXT NOT NULL DEFAULT (datetime('now')),
29
+ ended_at TEXT
30
+ );
31
+ CREATE INDEX IF NOT EXISTS idx_jobs_status_started
32
+ ON jobs(status, started_at DESC);
33
+ `;
34
+
35
+ /** A new job id: `job_` + 16 hex chars from a CSPRNG (matches optiq's
36
+ * `job_<uuid4 hex[:16]>` shape; collision-free enough for a local lab). */
37
+ export function newJobId(): string {
38
+ const bytes = new Uint8Array(8);
39
+ crypto.getRandomValues(bytes);
40
+ let hex = "";
41
+ for (const b of bytes) hex += b.toString(16).padStart(2, "0");
42
+ return `job_${hex}`;
43
+ }
44
+
45
+ export class JobStore {
46
+ readonly db: Database;
47
+ readonly dbPath: string;
48
+ readonly logsDir: string;
49
+
50
+ constructor(dbPath: string = DEFAULT_JOBS_DB, logsDir: string = DEFAULT_JOBS_DIR) {
51
+ if (dbPath !== ":memory:") {
52
+ try { mkdirSync(dbPath.slice(0, dbPath.lastIndexOf("/")), { recursive: true }); } catch {}
53
+ }
54
+ try { mkdirSync(logsDir, { recursive: true }); } catch {}
55
+ this.dbPath = dbPath;
56
+ this.logsDir = logsDir;
57
+ this.db = new Database(dbPath);
58
+ this.db.exec("PRAGMA journal_mode = WAL");
59
+ this.db.exec(SCHEMA);
60
+ this.migrate();
61
+ }
62
+
63
+ /** Additive-only migration: job rows are real records (a finished
64
+ * quantize output is referenced by `output_path`), never dropped on
65
+ * schema drift — unlike the registry cache. Re-opening an existing DB
66
+ * is a no-op. */
67
+ private migrate(): void {
68
+ const cols = (this.db.query("PRAGMA table_info(jobs)").all() as { name: string }[])
69
+ .map((c) => c.name);
70
+ // No columns to add today; the hook stays so future fields are additive.
71
+ void cols;
72
+ }
73
+
74
+ /** Create a queued job. `log_path` is computed under `logsDir`; the file
75
+ * is created so a tail started immediately sees an empty (not missing)
76
+ * file. */
77
+ create(kind: string, config: Record<string, unknown>, outputPath?: string): JobRow {
78
+ const id = newJobId();
79
+ const logPath = join(this.logsDir, `${id}.log`);
80
+ // touch the log so tailJob's first read finds an (empty) file
81
+ try { Bun.write(logPath, ""); } catch {}
82
+ this.db.prepare(`
83
+ INSERT INTO jobs (id, kind, status, config_json, log_path, output_path)
84
+ VALUES (?, ?, 'queued', ?, ?, ?)
85
+ `).run(id, kind, JSON.stringify(config), logPath, outputPath ?? null);
86
+ return this.get(id)!;
87
+ }
88
+
89
+ get(id: string): JobRow | null {
90
+ return (this.db.query("SELECT * FROM jobs WHERE id = ?").get(id) as JobRow | null) ?? null;
91
+ }
92
+
93
+ recent(limit = 10, kind?: string): JobRow[] {
94
+ if (kind) {
95
+ return this.db
96
+ .query("SELECT * FROM jobs WHERE kind = ? ORDER BY started_at DESC LIMIT ?")
97
+ .all(kind, limit) as JobRow[];
98
+ }
99
+ return this.db
100
+ .query("SELECT * FROM jobs ORDER BY started_at DESC LIMIT ?")
101
+ .all(limit) as JobRow[];
102
+ }
103
+
104
+ setStatus(id: string, status: JobStatus, opts: { error?: string; endedAt?: string } = {}): void {
105
+ const sets = ["status = ?"];
106
+ const args: (string | null)[] = [status];
107
+ if (opts.error !== undefined) { sets.push("error = ?"); args.push(opts.error); }
108
+ if (opts.endedAt !== undefined) { sets.push("ended_at = ?"); args.push(opts.endedAt); }
109
+ args.push(id);
110
+ this.db.prepare(`UPDATE jobs SET ${sets.join(", ")} WHERE id = ?`).run(...args);
111
+ }
112
+
113
+ setProgress(id: string, progress: number, message?: string): void {
114
+ if (message !== undefined) {
115
+ this.db.prepare("UPDATE jobs SET progress = ?, message = ? WHERE id = ?")
116
+ .run(progress, message, id);
117
+ } else {
118
+ this.db.prepare("UPDATE jobs SET progress = ? WHERE id = ?").run(progress, id);
119
+ }
120
+ }
121
+
122
+ setOutputPath(id: string, p: string): void {
123
+ this.db.prepare("UPDATE jobs SET output_path = ? WHERE id = ?").run(p, id);
124
+ }
125
+
126
+ /** Boot recovery: any job left 'queued'/'running' from a previous process
127
+ * lifetime is a zombie (its in-process task / child process died with us).
128
+ * The UI surfaces these for cleanup. */
129
+ markZombies(): number {
130
+ const r = this.db
131
+ .prepare("UPDATE jobs SET status = 'zombie' WHERE status IN ('queued', 'running')")
132
+ .run();
133
+ return r.changes;
134
+ }
135
+
136
+ close(): void {
137
+ this.db.close();
138
+ }
139
+ }
@@ -0,0 +1,29 @@
1
+ // Public API for the background job system. The server imports from here:
2
+ // create/track jobs (JobStore), submit work (in-process or GPU-leased
3
+ // subprocess), gate inference on GPU jobs, and stream a job's log over SSE.
4
+
5
+ export { JobStore, DEFAULT_JOBS_DB, DEFAULT_JOBS_DIR, newJobId } from "./db";
6
+ export {
7
+ registerRunner,
8
+ getRunner,
9
+ submitInProcess,
10
+ submitSubprocess,
11
+ makeEmit,
12
+ isGpuBusy,
13
+ currentGpuJob,
14
+ drainQueue,
15
+ JOB_ENTRY_PATH,
16
+ } from "./runner";
17
+ export type { SubmitResult, SubprocessOpts } from "./runner";
18
+ export { tailJob, streamJobResponse } from "./sse";
19
+ export type { TailOpts } from "./sse";
20
+
21
+ // Re-export the shared contract so consumers have one import site.
22
+ export type {
23
+ JobEvent,
24
+ Emit,
25
+ JobRunner,
26
+ JobKind,
27
+ JobStatus,
28
+ JobRow,
29
+ } from "./types";
@@ -0,0 +1,106 @@
1
+ // Child-process job entry: `bun job-entry.ts <jobId>`.
2
+ //
3
+ // Spawned by submitSubprocess so MLX/GPU state lives in its own address space
4
+ // and an uncatchable crash (segfault / process.exit) dies here without taking
5
+ // the server down. Port of optiq lab jobs.py `_job_main` — opens its OWN
6
+ // JobStore (a SQLite connection MUST NOT cross the process boundary), resolves
7
+ // the runner, runs it with a log-appending emit, sets terminal status, and
8
+ // exits 0 (done) / 1 (failed).
9
+
10
+ import { JobStore } from "./db";
11
+ import { getRunner, makeEmit } from "./runner";
12
+ import type { JobRunner } from "./types";
13
+
14
+ /** kind → module to dynamically import so it registers its runner. Real job
15
+ * kinds are built by parallel efforts; importing the module is expected to
16
+ * call `registerRunner(kind, ...)` at top level. Missing module ⇒ the job
17
+ * fails cleanly (see resolveRunner). */
18
+ const KIND_MODULES: Record<string, string> = {
19
+ quantize: "../quantize/job.ts",
20
+ finetune: "../train/job.ts",
21
+ };
22
+
23
+ /** Resolve a runner for `kind`. Test kinds (noop/crash) are pre-registered in
24
+ * runner.ts. Real kinds are lazily imported; a missing module is a clean
25
+ * failure, not a crash. */
26
+ async function resolveRunner(kind: string): Promise<JobRunner> {
27
+ const existing = getRunner(kind);
28
+ if (existing) return existing;
29
+
30
+ const modPath = KIND_MODULES[kind];
31
+ if (modPath) {
32
+ try {
33
+ await import(modPath);
34
+ } catch (e) {
35
+ throw new Error(
36
+ `kind "${kind}": failed to load runner module ${modPath} ` +
37
+ `(${e instanceof Error ? e.message : String(e)})`,
38
+ );
39
+ }
40
+ const loaded = getRunner(kind);
41
+ if (loaded) return loaded;
42
+ throw new Error(`kind "${kind}": module ${modPath} did not register a runner`);
43
+ }
44
+ throw new Error(`no runner registered for kind "${kind}"`);
45
+ }
46
+
47
+ function nowIso(): string {
48
+ return new Date().toISOString().replace("T", " ").slice(0, 19);
49
+ }
50
+
51
+ async function main(): Promise<void> {
52
+ const jobId = process.argv[2];
53
+ if (!jobId) {
54
+ console.error("usage: bun job-entry.ts <jobId>");
55
+ process.exit(2);
56
+ }
57
+
58
+ // Own fresh connection — overrides come from env so a spawned child finds
59
+ // the same DB/logs the parent used (tests set these to a tmp dir).
60
+ const store = new JobStore(
61
+ process.env.MLX_BUN_JOBS_DB || undefined,
62
+ process.env.MLX_BUN_JOBS_DIR || undefined,
63
+ );
64
+
65
+ const row = store.get(jobId);
66
+ if (!row) {
67
+ console.error(`job ${jobId} not found`);
68
+ store.close();
69
+ process.exit(2);
70
+ }
71
+
72
+ const emit = makeEmit(store, jobId, row.log_path);
73
+ store.setStatus(jobId, "running");
74
+ emit({ type: "started", ts: Date.now() });
75
+
76
+ let config: Record<string, unknown>;
77
+ try {
78
+ config = JSON.parse(row.config_json) as Record<string, unknown>;
79
+ } catch (e) {
80
+ const error = `bad config_json: ${e instanceof Error ? e.message : String(e)}`;
81
+ store.setStatus(jobId, "failed", { error, endedAt: nowIso() });
82
+ emit({ type: "failed", error, ts: Date.now() });
83
+ store.close();
84
+ process.exit(1);
85
+ }
86
+
87
+ try {
88
+ const runner = await resolveRunner(row.kind);
89
+ const result = await runner(emit, config);
90
+ const out = result?.outputPath;
91
+ if (out) store.setOutputPath(jobId, out);
92
+ store.setProgress(jobId, 1);
93
+ store.setStatus(jobId, "done", { endedAt: nowIso() });
94
+ emit({ type: "done", ts: Date.now(), output_dir: out ?? row.output_path ?? undefined });
95
+ store.close();
96
+ process.exit(0);
97
+ } catch (e) {
98
+ const error = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
99
+ store.setStatus(jobId, "failed", { error, endedAt: nowIso() });
100
+ emit({ type: "failed", error, ts: Date.now() });
101
+ store.close();
102
+ process.exit(1);
103
+ }
104
+ }
105
+
106
+ main();
@@ -0,0 +1,304 @@
1
+ // Job runner: turns a registered `JobRunner` into a tracked, logged job.
2
+ // Two execution modes — both create a row, both stream NDJSON events to the
3
+ // log, both own terminal status:
4
+ //
5
+ // submitInProcess — runs the runner as a fire-and-forget async task in
6
+ // THIS process. For pure-JS work (dataset builds) with
7
+ // no MLX/GPU state to isolate.
8
+ // submitSubprocess — spawns `bun job-entry.ts <jobId>` so MLX state is
9
+ // isolated and an uncatchable crash (segfault,
10
+ // process.exit) can't take the server down. Gated by a
11
+ // single global GPU lease: only one MLX job runs at a
12
+ // time; the rest wait 'queued' and drain in order.
13
+ //
14
+ // Port of optiq lab jobs.py `submit` + `_job_main`, split across this file
15
+ // and job-entry.ts.
16
+
17
+ import { appendFileSync } from "node:fs";
18
+ import { fileURLToPath } from "node:url";
19
+ import type { Emit, JobEvent, JobRunner } from "./types";
20
+ import type { JobStore } from "./db";
21
+
22
+ /** Absolute path to the child-process entry (`bun job-entry.ts <jobId>`). */
23
+ export const JOB_ENTRY_PATH = fileURLToPath(new URL("./job-entry.ts", import.meta.url));
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Runner registry
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const RUNNERS = new Map<string, JobRunner>();
30
+
31
+ export function registerRunner(kind: string, run: JobRunner): void {
32
+ RUNNERS.set(kind, run);
33
+ }
34
+
35
+ export function getRunner(kind: string): JobRunner | undefined {
36
+ return RUNNERS.get(kind);
37
+ }
38
+
39
+ // Built-in test kinds. Real kinds (quantize/finetune) register their runner
40
+ // on import of their module (see job-entry.ts KIND_MODULES).
41
+
42
+ /** Emits a couple of stage events then resolves — exercises the happy path. */
43
+ const noopRunner: JobRunner = async (emit) => {
44
+ emit({ type: "stage", stage: "warmup", progress: 0.25, message: "starting up" });
45
+ emit({ type: "stage", stage: "work", progress: 0.75, message: "doing the thing" });
46
+ return { outputPath: undefined };
47
+ };
48
+
49
+ /** Hard-exits the process — simulates an uncatchable crash (segfault-class).
50
+ * Only meaningful under submitSubprocess: the child dies, the parent lives. */
51
+ const crashRunner: JobRunner = async (emit) => {
52
+ emit({ type: "stage", stage: "boom", message: "about to crash" });
53
+ process.exit(1);
54
+ };
55
+
56
+ registerRunner("noop", noopRunner);
57
+ registerRunner("crash", crashRunner);
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Emit factory
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /** Build the `emit` sink for a job: appends each event as one NDJSON line
64
+ * (line-buffered via appendFileSync for crash durability) and mirrors
65
+ * progress/message from stage/metric events onto the row. Never throws — a
66
+ * logging failure must not kill a job. The terminal `done`/`failed` status
67
+ * is owned by the submit wrapper / job-entry, NOT here. */
68
+ export function makeEmit(store: JobStore, jobId: string, logPath: string): Emit {
69
+ return (e: JobEvent) => {
70
+ try {
71
+ appendFileSync(logPath, JSON.stringify(e) + "\n");
72
+ } catch {
73
+ // swallow: a job must survive a logging hiccup
74
+ }
75
+ if (e.type === "stage" || e.type === "metric") {
76
+ const progress = typeof e.progress === "number" ? e.progress : undefined;
77
+ const message = typeof e.message === "string" ? e.message : undefined;
78
+ if (progress !== undefined || message !== undefined) {
79
+ try {
80
+ if (progress !== undefined) store.setProgress(jobId, progress, message);
81
+ else if (message !== undefined) {
82
+ // message-only update preserves current progress
83
+ store.db.prepare("UPDATE jobs SET message = ? WHERE id = ?").run(message, jobId);
84
+ }
85
+ } catch {
86
+ // swallow: DB contention must not kill a job
87
+ }
88
+ }
89
+ }
90
+ };
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // In-process submit
95
+ // ---------------------------------------------------------------------------
96
+
97
+ export interface SubmitResult {
98
+ jobId: string;
99
+ outputPath?: string;
100
+ }
101
+
102
+ /** Run a registered runner as a fire-and-forget async task in this process.
103
+ * Sets 'running' + emits 'started' synchronously, then schedules the runner;
104
+ * on success sets 'done'/progress 1/ended_at + emits 'done', on throw sets
105
+ * 'failed'/error/ended_at + emits 'failed'. Returns once the task is
106
+ * scheduled (does not await completion — poll the row / tail the log). */
107
+ export function submitInProcess(
108
+ store: JobStore,
109
+ kind: string,
110
+ config: Record<string, unknown>,
111
+ outputPath?: string,
112
+ ): SubmitResult {
113
+ const row = store.create(kind, config, outputPath);
114
+ const emit = makeEmit(store, row.id, row.log_path);
115
+ store.setStatus(row.id, "running");
116
+ emit({ type: "started", ts: Date.now() });
117
+
118
+ const runner = getRunner(kind);
119
+ void (async () => {
120
+ if (!runner) {
121
+ const error = `no runner registered for kind "${kind}"`;
122
+ store.setStatus(row.id, "failed", { error, endedAt: nowIso() });
123
+ emit({ type: "failed", error, ts: Date.now() });
124
+ return;
125
+ }
126
+ try {
127
+ const result = await runner(emit, JSON.parse(row.config_json));
128
+ const out = result?.outputPath;
129
+ if (out) store.setOutputPath(row.id, out);
130
+ store.setProgress(row.id, 1);
131
+ store.setStatus(row.id, "done", { endedAt: nowIso() });
132
+ emit({ type: "done", ts: Date.now(), output_dir: out ?? outputPath });
133
+ } catch (e) {
134
+ const error = errString(e);
135
+ store.setStatus(row.id, "failed", { error, endedAt: nowIso() });
136
+ emit({ type: "failed", error, ts: Date.now() });
137
+ }
138
+ })();
139
+
140
+ return { jobId: row.id, outputPath };
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Subprocess submit + single global GPU lease
145
+ // ---------------------------------------------------------------------------
146
+
147
+ interface QueuedSpawn {
148
+ store: JobStore;
149
+ jobId: string;
150
+ entry: string;
151
+ bin: string;
152
+ }
153
+
154
+ let gpuLeaseHolder: string | null = null;
155
+ const spawnQueue: QueuedSpawn[] = [];
156
+
157
+ /** True while a GPU (subprocess) job is running — the server gates token
158
+ * generation on this so quantize/finetune don't fight live inference. */
159
+ export function isGpuBusy(): boolean {
160
+ return gpuLeaseHolder !== null;
161
+ }
162
+
163
+ /** The job id currently holding the GPU lease, or null. */
164
+ export function currentGpuJob(): string | null {
165
+ return gpuLeaseHolder;
166
+ }
167
+
168
+ export interface SubprocessOpts {
169
+ /** Override the entry script (tests). Defaults to JOB_ENTRY_PATH. */
170
+ entry?: string;
171
+ /** Override the runtime binary (tests). Defaults to "bun". */
172
+ bin?: string;
173
+ }
174
+
175
+ /** Create a 'queued' row and either spawn it now (lease free) or leave it
176
+ * queued to drain when the lease frees. Returns immediately with the job id
177
+ * — caller tails the log / polls the row. */
178
+ export function submitSubprocess(
179
+ store: JobStore,
180
+ kind: string,
181
+ config: Record<string, unknown>,
182
+ outputPath?: string,
183
+ opts: SubprocessOpts = {},
184
+ ): SubmitResult {
185
+ const row = store.create(kind, config, outputPath);
186
+ const item: QueuedSpawn = {
187
+ store,
188
+ jobId: row.id,
189
+ entry: opts.entry ?? JOB_ENTRY_PATH,
190
+ bin: opts.bin ?? "bun",
191
+ };
192
+ if (gpuLeaseHolder === null) {
193
+ spawnNow(item);
194
+ } else {
195
+ spawnQueue.push(item);
196
+ }
197
+ return { jobId: row.id, outputPath };
198
+ }
199
+
200
+ /** Spawn the child, acquire the lease, stream stdout/stderr into the log,
201
+ * reconcile terminal status on exit, then release the lease and drain. */
202
+ function spawnNow(item: QueuedSpawn): void {
203
+ const { store, jobId, entry, bin } = item;
204
+ gpuLeaseHolder = jobId;
205
+
206
+ // The child opens its OWN JobStore over the SAME DB/logs — a sqlite
207
+ // connection can't cross the process boundary, so we hand the paths via
208
+ // env. A :memory: DB can't be shared with a child; subprocess jobs require
209
+ // a file-backed DB.
210
+ const proc = Bun.spawn([bin, entry, jobId], {
211
+ stdout: "pipe",
212
+ stderr: "pipe",
213
+ env: {
214
+ ...process.env,
215
+ MLX_BUN_JOBS_DB: store.dbPath,
216
+ MLX_BUN_JOBS_DIR: store.logsDir,
217
+ },
218
+ });
219
+
220
+ const row = store.get(jobId);
221
+ const logPath = row?.log_path;
222
+ const logLine = (line: string) => {
223
+ if (!line || !logPath) return;
224
+ const ev: JobEvent = { type: "log", line };
225
+ try { appendFileSync(logPath, JSON.stringify(ev) + "\n"); } catch {}
226
+ };
227
+
228
+ void pumpLines(proc.stdout, logLine);
229
+ void pumpLines(proc.stderr, logLine);
230
+
231
+ void (async () => {
232
+ const code = await proc.exited;
233
+ // code 0 ⇒ trust the child's terminal status (it set done/failed itself).
234
+ // non-zero ⇒ if the row never reached terminal (crash before the wrapper
235
+ // could write), force it failed.
236
+ if (code !== 0) {
237
+ const cur = store.get(jobId);
238
+ if (cur && (cur.status === "queued" || cur.status === "running")) {
239
+ store.setStatus(jobId, "failed", {
240
+ error: `exited ${code}`,
241
+ endedAt: nowIso(),
242
+ });
243
+ }
244
+ }
245
+ releaseLease(jobId);
246
+ drainQueue();
247
+ })();
248
+ }
249
+
250
+ function releaseLease(jobId: string): void {
251
+ if (gpuLeaseHolder === jobId) gpuLeaseHolder = null;
252
+ }
253
+
254
+ /** Run the next queued subprocess job if the lease is free. */
255
+ export function drainQueue(): void {
256
+ if (gpuLeaseHolder !== null) return;
257
+ const next = spawnQueue.shift();
258
+ if (next) spawnNow(next);
259
+ }
260
+
261
+ /** Read a child stream line-by-line, buffering partial trailing lines, and
262
+ * hand each complete line to `sink`. */
263
+ async function pumpLines(
264
+ stream: ReadableStream<Uint8Array> | undefined,
265
+ sink: (line: string) => void,
266
+ ): Promise<void> {
267
+ if (!stream) return;
268
+ const reader = stream.getReader();
269
+ const decoder = new TextDecoder();
270
+ let buf = "";
271
+ try {
272
+ for (;;) {
273
+ const { done, value } = await reader.read();
274
+ if (done) break;
275
+ buf += decoder.decode(value, { stream: true });
276
+ let nl: number;
277
+ while ((nl = buf.indexOf("\n")) !== -1) {
278
+ sink(buf.slice(0, nl));
279
+ buf = buf.slice(nl + 1);
280
+ }
281
+ }
282
+ buf += decoder.decode();
283
+ if (buf) sink(buf);
284
+ } catch {
285
+ // stream torn down with the process — nothing actionable
286
+ } finally {
287
+ reader.releaseLock();
288
+ }
289
+ }
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // helpers
293
+ // ---------------------------------------------------------------------------
294
+
295
+ function nowIso(): string {
296
+ // SQLite datetime('now') is "YYYY-MM-DD HH:MM:SS" in UTC — match it so the
297
+ // ended_at column is uniform whether set here or by a column default.
298
+ return new Date().toISOString().replace("T", " ").slice(0, 19);
299
+ }
300
+
301
+ function errString(e: unknown): string {
302
+ if (e instanceof Error) return `${e.name}: ${e.message}`;
303
+ return String(e);
304
+ }
@@ -0,0 +1,114 @@
1
+ // Job log → SSE. `tailJob` is an async generator over a job's NDJSON log
2
+ // (offset-seek read, partial-line tolerant, polls while the row is
3
+ // non-terminal — port of optiq lab jobs.py `tail`). `streamJobResponse`
4
+ // wraps it in a Bun `Response` whose body is an EventSource-compatible SSE
5
+ // stream, matching the writer style in src/server.ts.
6
+
7
+ import type { JobEvent, JobStatus } from "./types";
8
+ import type { JobStore } from "./db";
9
+
10
+ const TERMINAL: ReadonlySet<JobStatus> = new Set<JobStatus>(["done", "failed", "zombie"]);
11
+
12
+ export interface TailOpts {
13
+ /** Keep polling while the job is non-terminal (SSE use case). When false,
14
+ * yield what's currently logged and stop. */
15
+ follow?: boolean;
16
+ /** Poll interval in ms while waiting for new lines. */
17
+ pollMs?: number;
18
+ }
19
+
20
+ /** Yield each recorded JobEvent from a job's log in order. Reads from a held
21
+ * byte offset, buffers a trailing partial line until its newline arrives
22
+ * (tolerates a writer mid-line), and — when following — polls until the row
23
+ * is terminal, then drains any final lines and stops. */
24
+ export async function* tailJob(
25
+ store: JobStore,
26
+ jobId: string,
27
+ opts: TailOpts = {},
28
+ ): AsyncIterable<JobEvent> {
29
+ const follow = opts.follow ?? true;
30
+ const pollMs = opts.pollMs ?? 200;
31
+
32
+ const row = store.get(jobId);
33
+ if (!row) throw new Error(`job ${jobId} not found`);
34
+ const logPath = row.log_path;
35
+
36
+ let offset = 0;
37
+ let buf = "";
38
+
39
+ for (;;) {
40
+ const file = Bun.file(logPath);
41
+ const size = await file.size;
42
+ if (size > offset) {
43
+ const slice = file.slice(offset, size);
44
+ buf += await slice.text();
45
+ offset = size;
46
+
47
+ let nl: number;
48
+ while ((nl = buf.indexOf("\n")) !== -1) {
49
+ const line = buf.slice(0, nl).trim();
50
+ buf = buf.slice(nl + 1);
51
+ if (!line) continue;
52
+ try {
53
+ yield JSON.parse(line) as JobEvent;
54
+ } catch {
55
+ // partial / corrupt line: re-buffer it and wait for more bytes
56
+ buf = line + "\n" + buf;
57
+ break;
58
+ }
59
+ }
60
+ }
61
+
62
+ if (!follow) return;
63
+
64
+ const status = store.get(jobId)?.status;
65
+ if (status && TERMINAL.has(status)) {
66
+ // Drain any bytes written between the last read and the terminal flip.
67
+ const f = Bun.file(logPath);
68
+ const sz = await f.size;
69
+ if (sz > offset) {
70
+ buf += await f.slice(offset, sz).text();
71
+ offset = sz;
72
+ }
73
+ for (const line of buf.split("\n")) {
74
+ const s = line.trim();
75
+ if (!s) continue;
76
+ try { yield JSON.parse(s) as JobEvent; } catch { /* drop trailing partial */ }
77
+ }
78
+ return;
79
+ }
80
+
81
+ await Bun.sleep(pollMs);
82
+ }
83
+ }
84
+
85
+ /** Build an SSE `Response` streaming a job's events. Writes `retry: 1500`
86
+ * first (so EventSource fires `onopen`), then one `data:` frame per event,
87
+ * and finally an `event: end` marker before closing on terminal. */
88
+ export function streamJobResponse(store: JobStore, jobId: string): Response {
89
+ const stream = new ReadableStream<Uint8Array>({
90
+ async start(controller) {
91
+ const enc = new TextEncoder();
92
+ try {
93
+ controller.enqueue(enc.encode("retry: 1500\n\n"));
94
+ for await (const event of tailJob(store, jobId, { follow: true })) {
95
+ controller.enqueue(enc.encode(`data: ${JSON.stringify(event)}\n\n`));
96
+ }
97
+ controller.enqueue(enc.encode("event: end\ndata: {}\n\n"));
98
+ } catch (e) {
99
+ const error = e instanceof Error ? e.message : String(e);
100
+ controller.enqueue(enc.encode(`data: ${JSON.stringify({ type: "failed", error, ts: Date.now() })}\n\n`));
101
+ controller.enqueue(enc.encode("event: end\ndata: {}\n\n"));
102
+ } finally {
103
+ controller.close();
104
+ }
105
+ },
106
+ });
107
+ return new Response(stream, {
108
+ headers: {
109
+ "content-type": "text/event-stream",
110
+ "cache-control": "no-cache",
111
+ connection: "keep-alive",
112
+ },
113
+ });
114
+ }