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.
- package/LICENSE +21 -0
- package/README.md +362 -0
- package/THIRD_PARTY_LICENSES.md +41 -0
- package/bin/mlx-bun.mjs +32 -0
- package/docs/curve-designer.html +303 -0
- package/docs/design/adapters-end-to-end.md +201 -0
- package/docs/design/compat-cli-surface-design.md +227 -0
- package/docs/design/docs-reorg-plan.md +152 -0
- package/docs/design/hlg-sampling.md +340 -0
- package/docs/design/memory-system.md +339 -0
- package/docs/design/optimization_plan.md +344 -0
- package/docs/design/parallel-slots.md +515 -0
- package/docs/design/runtime-isolation.md +247 -0
- package/docs/design/segmented-backward-training.md +508 -0
- package/docs/investigations/chunk-finetune-experiment.md +84 -0
- package/docs/investigations/curve-bisector-routing.md +123 -0
- package/docs/investigations/curve-runs/bisector-route.json +98 -0
- package/docs/investigations/curve-runs/bisector-trace.json +1393 -0
- package/docs/investigations/curve-runs/curve-terrain.json +1179 -0
- package/docs/investigations/curve-runs/curve-terrain.svg +42 -0
- package/docs/investigations/curve-terrain.html +105 -0
- package/docs/investigations/expert-offload-single-user-moe.md +292 -0
- package/docs/investigations/hlg-ab.html +34 -0
- package/docs/investigations/hlg-explorer.html +238 -0
- package/docs/investigations/hlg-figs/cliff.svg +1 -0
- package/docs/investigations/hlg-figs/cross.svg +1 -0
- package/docs/investigations/hlg-figs/edges.svg +1 -0
- package/docs/investigations/hlg-figs/k20-grids.svg +1 -0
- package/docs/investigations/hlg-figs/k20-peaks.svg +1 -0
- package/docs/investigations/hlg-figs/lw-curve.svg +1 -0
- package/docs/investigations/hlg-figs/terrain.svg +1 -0
- package/docs/investigations/hlg-report.html +262 -0
- package/docs/investigations/hlg-runs/hlg-ab.json +149 -0
- package/docs/investigations/hlg-runs/hlg-blind-prompts.md +87 -0
- package/docs/investigations/hlg-runs/hlg-blind.json +107 -0
- package/docs/investigations/hlg-runs/hlg-cliff.json +197 -0
- package/docs/investigations/hlg-runs/hlg-cross.json +505 -0
- package/docs/investigations/hlg-runs/hlg-edge.json +388 -0
- package/docs/investigations/hlg-runs/hlg-eval1.json +25 -0
- package/docs/investigations/hlg-runs/hlg-frontier.json +86 -0
- package/docs/investigations/hlg-runs/hlg-grid.json +242 -0
- package/docs/investigations/hlg-runs/hlg-grid2.json +343 -0
- package/docs/investigations/hlg-runs/hlg-lw-coarse.json +110 -0
- package/docs/investigations/hlg-runs/hlg-map.json +383 -0
- package/docs/investigations/hlg-runs/run0-original.log +159 -0
- package/docs/investigations/hlg-runs/runA-recommended.log +131 -0
- package/docs/investigations/hlg-runs/runAA-cliff-cpm5.log +26 -0
- package/docs/investigations/hlg-runs/runAB-grid2-e4b.log +18 -0
- package/docs/investigations/hlg-runs/runAC-peak-e4b-partial.log +25 -0
- package/docs/investigations/hlg-runs/runAD-ab-samples.log +8 -0
- package/docs/investigations/hlg-runs/runAE-edge-e4b.log +51 -0
- package/docs/investigations/hlg-runs/runAF-logit-calibration.log +10 -0
- package/docs/investigations/hlg-runs/runAG-eval-config2.log +16 -0
- package/docs/investigations/hlg-runs/runB-gain-variants.log +192 -0
- package/docs/investigations/hlg-runs/runC-pivots.log +162 -0
- package/docs/investigations/hlg-runs/runD-oetf.log +132 -0
- package/docs/investigations/hlg-runs/runE-eotf.log +162 -0
- package/docs/investigations/hlg-runs/runF-shaper-working.log +86 -0
- package/docs/investigations/hlg-runs/runG-shaper-sweep.log +101 -0
- package/docs/investigations/hlg-runs/runH-wsweep.log +176 -0
- package/docs/investigations/hlg-runs/runI-asweep.log +176 -0
- package/docs/investigations/hlg-runs/runJ-knobmap.log +49 -0
- package/docs/investigations/hlg-runs/runK-frontier.log +19 -0
- package/docs/investigations/hlg-runs/runL-widemap.log +65 -0
- package/docs/investigations/hlg-runs/runM-frontier-emb.log +15 -0
- package/docs/investigations/hlg-runs/runN-verify.log +116 -0
- package/docs/investigations/hlg-runs/runO-frontier-seed5000.log +15 -0
- package/docs/investigations/hlg-runs/runP-grid-AxSm.log +15 -0
- package/docs/investigations/hlg-runs/runQ-grid-tgW.log +15 -0
- package/docs/investigations/hlg-runs/runR-blind-transcript.log +79 -0
- package/docs/investigations/hlg-runs/runS-cross-coherence.log +33 -0
- package/docs/investigations/hlg-runs/runT-cross-cpm5.log +33 -0
- package/docs/investigations/hlg-runs/runU-cross-12b.log +33 -0
- package/docs/investigations/hlg-runs/runV-lw-e4b.log +22 -0
- package/docs/investigations/hlg-runs/runW-lw-12b.log +22 -0
- package/docs/investigations/hlg-runs/runX-lw-cpm5.log +22 -0
- package/docs/investigations/hlg-runs/runY-cliff-e4b.log +26 -0
- package/docs/investigations/hlg-runs/runZ-cliff-12b.log +26 -0
- package/docs/investigations/hlg-sampling-investigation.md +970 -0
- package/docs/investigations/lab-build-journal.md +157 -0
- package/docs/investigations/pi-builtin-investigation.md +308 -0
- package/docs/investigations/starter-model-port-handoff.md +323 -0
- package/docs/planning/IDEAS.md +26 -0
- package/docs/planning/PRODUCT_ROADMAP.md +426 -0
- package/docs/planning/ResearchTopics.md +119 -0
- package/docs/planning/chunk-task-roadmap.md +77 -0
- package/docs/planning/journal.md +155 -0
- package/docs/reference/distribution.md +121 -0
- package/docs/reference/embedding.md +168 -0
- package/docs/reference/library-api.md +153 -0
- package/docs/reference/server-api.md +224 -0
- package/docs/reference/server-config.md +243 -0
- package/docs/segmented-backward-handoff.md +148 -0
- package/package.json +68 -0
- package/src/anthropic.ts +491 -0
- package/src/chat-template.ts +186 -0
- package/src/cli.ts +1075 -0
- package/src/config.ts +229 -0
- package/src/curve-sampler.ts +122 -0
- package/src/dataset/generators.ts +1087 -0
- package/src/dataset/index.ts +12 -0
- package/src/dataset/job.ts +81 -0
- package/src/dataset/llm.ts +71 -0
- package/src/dataset/registry.ts +528 -0
- package/src/download.ts +283 -0
- package/src/eval/README.md +86 -0
- package/src/eval/kl-prompts.ts +29 -0
- package/src/eval/kl.ts +294 -0
- package/src/eval/quality-db.ts +92 -0
- package/src/eval/runner.ts +102 -0
- package/src/eval/score.ts +49 -0
- package/src/eval/tasks/bfcl.ts +500 -0
- package/src/eval/tasks/gsm8k.ts +85 -0
- package/src/eval/tasks/hashhop.ts +109 -0
- package/src/eval/tasks/humaneval.ts +252 -0
- package/src/eval/tasks/ifeval.ts +354 -0
- package/src/eval/tasks/mmlu.ts +145 -0
- package/src/evaldb.ts +199 -0
- package/src/expert-offload-build.ts +77 -0
- package/src/expert-offload.ts +61 -0
- package/src/expert-trace.ts +67 -0
- package/src/fit.ts +254 -0
- package/src/generate.ts +451 -0
- package/src/harness-pi.ts +177 -0
- package/src/hf-push.ts +628 -0
- package/src/index.ts +22 -0
- package/src/jobs/db.ts +139 -0
- package/src/jobs/index.ts +29 -0
- package/src/jobs/job-entry.ts +106 -0
- package/src/jobs/runner.ts +304 -0
- package/src/jobs/sse.ts +114 -0
- package/src/jobs/types.ts +58 -0
- package/src/kv-store.ts +185 -0
- package/src/lora.ts +314 -0
- package/src/md-modules.d.ts +7 -0
- package/src/mlx/array.ts +237 -0
- package/src/mlx/autograd.ts +267 -0
- package/src/mlx/checkpoint.ts +116 -0
- package/src/mlx/compile.ts +158 -0
- package/src/mlx/custom-vjp.ts +140 -0
- package/src/mlx/ffi.ts +433 -0
- package/src/mlx/metal-kernel.ts +130 -0
- package/src/mlx/ops.ts +707 -0
- package/src/mmap.ts +90 -0
- package/src/model/batched-mask.ts +204 -0
- package/src/model/batched-rotating.ts +300 -0
- package/src/model/compiled-decode.ts +681 -0
- package/src/model/factory.ts +27 -0
- package/src/model/fingerprint.ts +33 -0
- package/src/model/flash-attention.ts +674 -0
- package/src/model/fused-decode-kernel.ts +305 -0
- package/src/model/fused-geglu-kernel.ts +87 -0
- package/src/model/gemma4-base.ts +1616 -0
- package/src/model/gemma4.ts +1012 -0
- package/src/model/generated/gemma4-12b.ts +526 -0
- package/src/model/generated/gemma4-26b.ts +500 -0
- package/src/model/generated/gemma4-e4b.ts +757 -0
- package/src/model/generated/index.ts +18 -0
- package/src/model/minicpm5.ts +278 -0
- package/src/model/qwen3-delta.ts +224 -0
- package/src/model/qwen3_5.ts +429 -0
- package/src/model/support.ts +46 -0
- package/src/native-pack.ts +152 -0
- package/src/pi-provider.ts +95 -0
- package/src/pi-terminal.ts +264 -0
- package/src/pi-web.ts +924 -0
- package/src/preflight.ts +123 -0
- package/src/prompt-cache.ts +113 -0
- package/src/quantize/allocator.ts +627 -0
- package/src/quantize/calibration.ts +180 -0
- package/src/quantize/config-writer.ts +132 -0
- package/src/quantize/index.ts +57 -0
- package/src/quantize/job.ts +136 -0
- package/src/quantize/quantizer.ts +462 -0
- package/src/quantize/safetensors-writer.ts +171 -0
- package/src/quantize/sensitivity.ts +234 -0
- package/src/registry.ts +336 -0
- package/src/responses.ts +774 -0
- package/src/safetensors.ts +158 -0
- package/src/sampler.ts +517 -0
- package/src/serve/batch-scheduler.ts +362 -0
- package/src/serve/generation-gateway.ts +160 -0
- package/src/server.ts +1891 -0
- package/src/spec/drafter.ts +261 -0
- package/src/spec/generate.ts +221 -0
- package/src/status-page.html +497 -0
- package/src/tokenizer.ts +38 -0
- package/src/tool-call.ts +242 -0
- package/src/train/dataset.ts +461 -0
- package/src/train/export.ts +32 -0
- package/src/train/forward.ts +229 -0
- package/src/train/index.ts +43 -0
- package/src/train/job.ts +148 -0
- package/src/train/lora-params.ts +194 -0
- package/src/train/loss.ts +342 -0
- package/src/train/merge.ts +195 -0
- package/src/train/optimizer.ts +176 -0
- package/src/train/rank.ts +101 -0
- package/src/train/segmented.ts +520 -0
- package/src/train/trainer.ts +600 -0
- package/src/tui.ts +161 -0
- package/src/vision/embedder.ts +168 -0
- package/src/vision/preprocess.ts +247 -0
- package/src/vision/prompt.ts +158 -0
- package/src/vision/siglip.ts +599 -0
- package/src/web/app.html +2471 -0
- package/src/web/skills/web-research/SKILL.md +44 -0
- package/src/web/skills.ts +59 -0
- package/src/web-tools.ts +426 -0
- 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
|
+
}
|
package/src/jobs/sse.ts
ADDED
|
@@ -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
|
+
}
|