Skip to content

Commit 07895f0

Browse files
committed
Download manager phase 0: job model, persisted store, broadcast plumbing
Prep work for a durable, resumable model download queue (full spec: persistent download center, shard-aware handling, checksum verification, retry/backoff, bandwidth controls). This phase lays the foundation the rest builds on: - New app/src/download-jobs-store.ts: DownloadJob/DownloadShard records with a full lifecycle state machine (queued -> resolving -> downloading -> verifying -> installing -> ready, plus paused/failed/cancelled), persisted the same way every other *-store.ts in this app is (json-store.ts's readJson/writeJson, flat-array-per-file). - New app/src/download-verification.ts: computeSha256 (streamed, never buffers a whole file — same discipline the download path itself already follows), hasGgufMagic (catches the case where a Range request into a CDN error page still matches the expected size), getDiskSpace (Node's fs.statfsSync, no new dependency). - New app/src/download-queue.ts: broadcast plumbing and startup resume. Uses a getWindow() closure rather than a captured event.sender — the same pattern menu.ts/updater.ts already use, since a job needs to push progress at times disconnected from any single IPC invoke, potentially after the window that started it was replaced. resumeInterruptedJobs() requeues anything left in "downloading"/"resolving" state from a previous session (an actual restart-mid-download, not a deliberate pause) back to "queued" — wired into app.whenReady() after ollama.start() and createWindow(), fire-and-forget like the existing connectEnabledMcpServers() call. The actual download worker (concurrency, retry, verification pipeline) lands in the next phase — this one is deliberately just the durable state + plumbing skeleton. 30 new tests across the three new modules.
1 parent 82c77b6 commit 07895f0

7 files changed

Lines changed: 443 additions & 0 deletions
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { describe, it, expect } from "vitest";
2+
import { listJobs, getJob, createJob, updateJob, deleteJob, type DownloadShard } from "./download-jobs-store";
3+
4+
function shard(overrides: Partial<DownloadShard> = {}): DownloadShard {
5+
return {
6+
filename: "model.gguf",
7+
path: "/models/model.gguf",
8+
expectedBytes: 1000,
9+
receivedBytes: 0,
10+
state: "queued",
11+
...overrides,
12+
};
13+
}
14+
15+
describe("download-jobs-store", () => {
16+
it("starts with an empty list", () => {
17+
expect(listJobs()).toEqual([]);
18+
});
19+
20+
it("creates a job with generated id, default state, and timestamps", () => {
21+
const job = createJob({
22+
kind: "huggingface",
23+
modelName: "Test Model",
24+
publisher: "test-org",
25+
backend: "llamacpp",
26+
destinationDir: "/models",
27+
modelId: "test-org/test-model",
28+
shards: [shard()],
29+
});
30+
expect(job.id).toBeTruthy();
31+
expect(job.state).toBe("queued");
32+
expect(job.retryCount).toBe(0);
33+
expect(job.createdAt).toBeTruthy();
34+
expect(job.updatedAt).toBe(job.createdAt);
35+
expect(listJobs().map((j) => j.id)).toContain(job.id);
36+
});
37+
38+
it("round-trips a job through getJob", () => {
39+
const created = createJob({
40+
kind: "huggingface",
41+
modelName: "Another Model",
42+
publisher: "org",
43+
backend: "llamacpp",
44+
destinationDir: "/models",
45+
modelId: "org/another-model",
46+
shards: [shard()],
47+
});
48+
expect(getJob(created.id)).toEqual(created);
49+
});
50+
51+
it("returns null for an unknown job id", () => {
52+
expect(getJob("does-not-exist")).toBeNull();
53+
});
54+
55+
it("updates state, error, retryCount, and shards while bumping updatedAt", async () => {
56+
const created = createJob({
57+
kind: "huggingface",
58+
modelName: "M",
59+
publisher: "org",
60+
backend: "llamacpp",
61+
destinationDir: "/models",
62+
modelId: "org/m",
63+
shards: [shard()],
64+
});
65+
await new Promise((r) => setTimeout(r, 5));
66+
const updatedShards = [shard({ receivedBytes: 500 })];
67+
const updated = updateJob(created.id, { state: "downloading", shards: updatedShards, retryCount: 1 });
68+
expect(updated?.state).toBe("downloading");
69+
expect(updated?.shards).toEqual(updatedShards);
70+
expect(updated?.retryCount).toBe(1);
71+
expect(updated?.updatedAt).not.toBe(created.updatedAt);
72+
});
73+
74+
it("returns null when updating an unknown job", () => {
75+
expect(updateJob("does-not-exist", { state: "ready" })).toBeNull();
76+
});
77+
78+
it("sets a typed error with kind and retryable flags", () => {
79+
const created = createJob({
80+
kind: "huggingface",
81+
modelName: "M",
82+
publisher: "org",
83+
backend: "llamacpp",
84+
destinationDir: "/models",
85+
modelId: "org/m",
86+
shards: [shard()],
87+
});
88+
const updated = updateJob(created.id, {
89+
state: "failed",
90+
error: { message: "Gated repo — accept the license first.", kind: "license_required", retryable: false },
91+
});
92+
expect(updated?.error).toEqual({ message: "Gated repo — accept the license first.", kind: "license_required", retryable: false });
93+
});
94+
95+
it("deletes a job", () => {
96+
const created = createJob({
97+
kind: "huggingface",
98+
modelName: "M",
99+
publisher: "org",
100+
backend: "llamacpp",
101+
destinationDir: "/models",
102+
modelId: "org/m",
103+
shards: [shard()],
104+
});
105+
deleteJob(created.id);
106+
expect(getJob(created.id)).toBeNull();
107+
});
108+
109+
it("deleting an unknown job is a harmless no-op", () => {
110+
expect(() => deleteJob("does-not-exist")).not.toThrow();
111+
});
112+
});

app/src/download-jobs-store.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import * as path from "node:path";
2+
import { randomUUID } from "node:crypto";
3+
import { app } from "electron";
4+
import { readJson, writeJson } from "./json-store";
5+
6+
export type DownloadJobState =
7+
| "queued"
8+
| "resolving"
9+
| "downloading"
10+
| "paused"
11+
| "verifying"
12+
| "installing"
13+
| "ready"
14+
| "failed"
15+
| "cancelled";
16+
17+
export type DownloadErrorKind =
18+
| "auth_required"
19+
| "license_required"
20+
| "not_found"
21+
| "disk_space"
22+
| "permission"
23+
| "verification_failed"
24+
| "network"
25+
| "unknown";
26+
27+
export interface DownloadJobError {
28+
message: string;
29+
kind: DownloadErrorKind;
30+
retryable: boolean;
31+
}
32+
33+
export interface DownloadShard {
34+
filename: string;
35+
path: string;
36+
expectedBytes: number;
37+
receivedBytes: number;
38+
sha256?: string;
39+
etag?: string;
40+
state: DownloadJobState;
41+
}
42+
43+
export interface DownloadJob {
44+
id: string;
45+
kind: "huggingface" | "ollama";
46+
modelName: string;
47+
publisher: string;
48+
quantization?: string;
49+
backend: "llamacpp" | "mlx" | "vllm" | "ollama";
50+
destinationDir: string;
51+
modelId: string;
52+
shards: DownloadShard[];
53+
state: DownloadJobState;
54+
error?: DownloadJobError;
55+
retryCount: number;
56+
createdAt: string;
57+
updatedAt: string;
58+
}
59+
60+
function filePath(): string {
61+
return path.join(app.getPath("userData"), "download-jobs.json");
62+
}
63+
64+
export function listJobs(): DownloadJob[] {
65+
return readJson<DownloadJob[]>(filePath(), []);
66+
}
67+
68+
export function getJob(id: string): DownloadJob | null {
69+
return listJobs().find((j) => j.id === id) ?? null;
70+
}
71+
72+
export function createJob(
73+
partial: Pick<DownloadJob, "kind" | "modelName" | "publisher" | "backend" | "destinationDir" | "modelId" | "shards"> &
74+
Partial<Pick<DownloadJob, "quantization">>
75+
): DownloadJob {
76+
const now = new Date().toISOString();
77+
const job: DownloadJob = {
78+
id: randomUUID(),
79+
state: "queued",
80+
retryCount: 0,
81+
createdAt: now,
82+
updatedAt: now,
83+
...partial,
84+
};
85+
const all = listJobs();
86+
all.push(job);
87+
writeJson(filePath(), all);
88+
return job;
89+
}
90+
91+
// Callers pass whole replacement `shards` arrays rather than patching one
92+
// shard in place — the queue worker always has the full up-to-date shard
93+
// list in memory already (it's the one mutating it), so round-tripping a
94+
// single shard's delta through this API would just add complexity for no
95+
// benefit.
96+
export function updateJob(
97+
id: string,
98+
partial: Partial<Pick<DownloadJob, "state" | "error" | "retryCount" | "shards">>
99+
): DownloadJob | null {
100+
const all = listJobs();
101+
const idx = all.findIndex((j) => j.id === id);
102+
if (idx === -1) return null;
103+
all[idx] = { ...all[idx], ...partial, updatedAt: new Date().toISOString() };
104+
writeJson(filePath(), all);
105+
return all[idx];
106+
}
107+
108+
export function deleteJob(id: string): void {
109+
writeJson(filePath(), listJobs().filter((j) => j.id !== id));
110+
}

app/src/download-queue.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { createJob, updateJob, listJobs, type DownloadShard, type DownloadJobState } from "./download-jobs-store";
3+
import { init, broadcast, resumeInterruptedJobs } from "./download-queue";
4+
5+
function shard(overrides: Partial<DownloadShard> = {}): DownloadShard {
6+
return {
7+
filename: "model.gguf",
8+
path: "/models/model.gguf",
9+
expectedBytes: 1000,
10+
receivedBytes: 0,
11+
state: "queued",
12+
...overrides,
13+
};
14+
}
15+
16+
function makeJob(state: DownloadJobState) {
17+
const job = createJob({
18+
kind: "huggingface",
19+
modelName: "M",
20+
publisher: "org",
21+
backend: "llamacpp",
22+
destinationDir: "/models",
23+
modelId: "org/m",
24+
shards: [shard()],
25+
});
26+
updateJob(job.id, { state });
27+
return job.id;
28+
}
29+
30+
describe("download-queue: broadcast plumbing", () => {
31+
it("does nothing when no window has been registered via init()", () => {
32+
expect(() => broadcast()).not.toThrow();
33+
});
34+
35+
it("sends the current job list to the registered window's webContents on broadcast", () => {
36+
const send = vi.fn();
37+
init(() => ({ webContents: { send } }) as never);
38+
broadcast();
39+
expect(send).toHaveBeenCalledWith("downloads:update", listJobs());
40+
});
41+
});
42+
43+
describe("resumeInterruptedJobs", () => {
44+
it("requeues jobs stuck in downloading or resolving back to queued", () => {
45+
const downloadingId = makeJob("downloading");
46+
const resolvingId = makeJob("resolving");
47+
48+
resumeInterruptedJobs();
49+
50+
expect(listJobs().find((j) => j.id === downloadingId)?.state).toBe("queued");
51+
expect(listJobs().find((j) => j.id === resolvingId)?.state).toBe("queued");
52+
});
53+
54+
it("leaves jobs in paused, ready, failed, or cancelled untouched", () => {
55+
const pausedId = makeJob("paused");
56+
const readyId = makeJob("ready");
57+
58+
resumeInterruptedJobs();
59+
60+
expect(listJobs().find((j) => j.id === pausedId)?.state).toBe("paused");
61+
expect(listJobs().find((j) => j.id === readyId)?.state).toBe("ready");
62+
});
63+
});

app/src/download-queue.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { BrowserWindow } from "electron";
2+
import { listJobs, updateJob } from "./download-jobs-store";
3+
4+
// Deliberately a getWindow() closure, not a captured event.sender from one
5+
// ipcMain.handle invocation — jobs push progress at times disconnected from
6+
// any single IPC call (a download keeps running long after the request that
7+
// started it returned), and the window that started a job may have since
8+
// been closed/reopened (e.g. macOS activate-with-no-windows). Mirrors the
9+
// same pattern menu.ts and updater.ts already use for this exact reason.
10+
let windowGetter: () => BrowserWindow | null = () => null;
11+
12+
export function init(getWindow: () => BrowserWindow | null): void {
13+
windowGetter = getWindow;
14+
}
15+
16+
// The one broadcast channel in the app that isn't scoped to a requestId —
17+
// every open window gets the full current job list whenever anything
18+
// changes, so the download center reflects live state regardless of which
19+
// page is open or when it mounted.
20+
export function broadcast(): void {
21+
windowGetter()?.webContents.send("downloads:update", listJobs());
22+
}
23+
24+
// Called once at app startup. A job left in "downloading" or "resolving"
25+
// can only mean the app was killed mid-download last session — not a
26+
// deliberate pause — so it goes back to "queued" for the worker to pick up
27+
// again (the underlying .part file and its recorded progress are untouched;
28+
// resuming is exactly what downloadGgufFile's Range-request logic already
29+
// does for a partial file, whatever caused the interruption).
30+
export function resumeInterruptedJobs(): void {
31+
for (const job of listJobs()) {
32+
if (job.state === "downloading" || job.state === "resolving") {
33+
updateJob(job.id, { state: "queued" });
34+
}
35+
}
36+
broadcast();
37+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, it, expect, beforeEach } from "vitest";
2+
import * as fs from "node:fs";
3+
import * as os from "node:os";
4+
import * as path from "node:path";
5+
import { createHash } from "node:crypto";
6+
import { computeSha256, hasGgufMagic, getDiskSpace } from "./download-verification";
7+
8+
describe("computeSha256", () => {
9+
let dir: string;
10+
11+
beforeEach(() => {
12+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "download-verification-test-"));
13+
});
14+
15+
it("matches Node's own synchronous hash of the same content", async () => {
16+
const file = path.join(dir, "data.bin");
17+
const content = Buffer.from("hello world".repeat(1000));
18+
fs.writeFileSync(file, content);
19+
const expected = createHash("sha256").update(content).digest("hex");
20+
expect(await computeSha256(file)).toBe(expected);
21+
});
22+
23+
it("rejects when the file doesn't exist", async () => {
24+
await expect(computeSha256(path.join(dir, "missing.bin"))).rejects.toThrow();
25+
});
26+
});
27+
28+
describe("hasGgufMagic", () => {
29+
let dir: string;
30+
31+
beforeEach(() => {
32+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "download-verification-test-"));
33+
});
34+
35+
it("returns true for a file starting with the GGUF magic bytes", () => {
36+
const file = path.join(dir, "model.gguf");
37+
fs.writeFileSync(file, Buffer.concat([Buffer.from("GGUF"), Buffer.from([3, 0, 0, 0])]));
38+
expect(hasGgufMagic(file)).toBe(true);
39+
});
40+
41+
it("returns false for a file without the magic bytes (e.g. an HTML error page)", () => {
42+
const file = path.join(dir, "not-a-model.gguf");
43+
fs.writeFileSync(file, "<html><body>404</body></html>");
44+
expect(hasGgufMagic(file)).toBe(false);
45+
});
46+
47+
it("returns false for a file too short to contain the magic bytes", () => {
48+
const file = path.join(dir, "tiny.gguf");
49+
fs.writeFileSync(file, "GG");
50+
expect(hasGgufMagic(file)).toBe(false);
51+
});
52+
53+
it("returns false when the file doesn't exist", () => {
54+
expect(hasGgufMagic(path.join(dir, "missing.gguf"))).toBe(false);
55+
});
56+
});
57+
58+
describe("getDiskSpace", () => {
59+
it("reports positive free and total bytes for a real directory", () => {
60+
const { freeBytes, totalBytes } = getDiskSpace(os.tmpdir());
61+
expect(totalBytes).toBeGreaterThan(0);
62+
expect(freeBytes).toBeGreaterThan(0);
63+
expect(freeBytes).toBeLessThanOrEqual(totalBytes);
64+
});
65+
});

0 commit comments

Comments
 (0)