Skip to content

Commit a024fea

Browse files
ADFA-4894 feat(dashboard): download control surface — paused phase + pause/resume/retry
The engine half of the download-control contract (ADFA-4893), on top of the resilient runners already shipped in 1.2.3. The app-facing buttons land separately under ADFA-4896. - jobs.ts: a 'paused' job phase + pause(id) / resume(id) / retry(id) on the durable engine, and a shared classifyStop(ctx) so every runner treats pause vs cancel the same way. Pause stops the runner but KEEPS the partial (the PAUSED-vs-CANCELLED distinction from the APK ADFA-5119); resume re-launches (kiwix continues via --continue, books per item); retry re-runs an errored job. resume/retry move the row to an ACTIVE phase before relaunch so completion/UI stay consistent. - kiwix.exec / books.exec: on stop, keep the partial on pause and clean only on cancel. - routes.ts: POST /:type/jobs/:id/{pause,resume,retry}, returning 409 when the verb doesn't apply. - jobs.test.ts (npm run test:db — needs the native better-sqlite3 build): pause->paused, resume->done, retry-from-error, and the out-of-phase no-ops. Version 1.2.4. tsc --noEmit clean; default suite 79/79. Device-verified: pause keeps the partial and resume continues via --continue from a non-zero %, the verbs return 409 out of phase, a paused job survives a dash-node restart, and test:db passes 3/3 in the box.
1 parent 2fe444a commit a024fea

7 files changed

Lines changed: 220 additions & 18 deletions

File tree

‎static/dashboard/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ One line per version, newest first. Every REST-facing change bumps the version i
44
(the app surfaces it via `/system/dashboard/update-check` and the "Update available" pill), so this
55
file is the human record of what each bump enables. Keep entries short: `version - change (TICKET)`.
66

7+
- **1.2.4** - Download control surface (ADFA-4894, the engine half; app buttons land under ADFA-4896). Adds a **`paused`** job phase and `pause(id)` / `resume(id)` / `retry(id)` to the durable engine, exposed as `POST /:type/jobs/:id/{pause,resume,retry}`. Pause stops the runner but KEEPS the partial (resume continues via aria2 `--continue` for kiwix / per-item for books — the PAUSED-vs-CANCELLED distinction mirrors the APK ADFA-5119); retry re-runs an errored job. maps resume still restarts layers (no per-layer checkpoint yet → ADFA-4898). (ADFA-4894)
78
- **1.2.3-dev** - Resilient content downloads (ADFA-4894, Area 1 of the ADFA-4893 contract). A network drop mid-job recovers or fails cleanly instead of failing the whole batch at the first blip: a shared `withRetry` backoff helper; job cancel now aborts an in-flight `fetch` (new `AbortSignal` on the runner), not just child processes; **books** get a per-attempt timeout + per-item retry/backoff and no longer fail a batch on one dropped EPUB; **kiwix** prunes the partial `.zim` + `.aria2` on cancel (clean-on-cancel) and gains aria2 reconnect flags (`--max-tries`/`--retry-wait`/`--timeout`, documented divergence from the app's `--max-tries=1` outer-loop model). kiwix also gets an **outer** reconnect loop (`withRetry` around aria2) so a full Wi-Fi drop — where aria2 exits on DNS (code 19) and can't retry itself — recovers across the handoff, resuming via `--continue`; books' retry budget widened to ~30s to outlast a real reassociation. Maps per-layer checkpoint is pending the on-box `tile-extract.py`. Version stays `1.2.3-dev.N` through the PR (device-verified on -dev.1); final bump to `1.2.3` at merge. (ADFA-4894)
89
- **1.2.2** - New `GET /kolibri/subtree/:nodeId`: serves a channel's topic tree in Studio's `contentnode_tree` shape, with per-node byte sizes summed from the local content DB over each node's MPTT range, so the app browses an imported channel's whole tree offline (parsed by the same mapper it uses for Studio). Read-only; **404** when the channel's metadata is not imported, which the app reads as "fall back to Studio". Ships the endpoint PR added without a version bump. (ADFA-5094)
910
- **1.2.1** - Four Kolibri defects found by testing against a real device. `/kolibri/estimate` now reports free space (the call was missing the mandatory `?path=Content` and had been failing silently since it was written) and answers **409** with a readable message for a channel that is not installed, instead of letting Kolibri return a bare 500; `/kolibri/catalog` reports real sizes and resource counts (it was reading Studio's field names on an endpoint that uses Kolibri's); a failed import now carries the cause instead of just the exception class name. Also finishes the English pass: `routes.ts` was the last file the 1.1.6 sweep missed — REST-facing too, since one response string changes (`POST /credentials/kolibri` on 401). (ADFA-4954)

‎static/dashboard/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
{
22
"name": "dashboard-console",
3-
"version": "1.2.3-dev.2",
3+
"version": "1.2.4",
44
"description": "",
55
"main": "index.js",
66
"scripts": {
77
"test": "node --require ts-node/register --test sockets/maps.socket.test.ts sockets/rolling-log.test.ts sockets/kolibri.session.test.ts sockets/credentials.test.ts sockets/net-retry.test.ts",
8+
"test:db": "node --require ts-node/register --test sockets/jobs.test.ts",
89
"typecheck": "tsc --noEmit",
910
"build": "tsc",
1011
"start": "node dist/server.js"

‎static/dashboard/routes.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,3 +722,27 @@ apiRouter.post('/:type/jobs/:id/cancel', (req: Request, res: Response): void =>
722722
jobs.cancel(job.id);
723723
res.json({ ok: true });
724724
});
725+
726+
// ADFA-4894 (control surface): pause / resume / retry over the durable job engine. Pause keeps the
727+
// partial (resume continues from it via --continue / per-item); retry re-runs an errored job. Each
728+
// returns 409 when the job is not in a phase the verb applies to, so the caller reflects real state.
729+
apiRouter.post('/:type/jobs/:id/pause', (req: Request, res: Response): void => {
730+
const job = jobs.get(String(req.params.id));
731+
if (!job || job.type !== String(req.params.type)) { res.status(404).json({ error: 'not found' }); return; }
732+
if (!jobs.pause(job.id)) { res.status(409).json({ error: 'job is not pausable in its current phase' }); return; }
733+
res.json({ ok: true });
734+
});
735+
736+
apiRouter.post('/:type/jobs/:id/resume', (req: Request, res: Response): void => {
737+
const job = jobs.get(String(req.params.id));
738+
if (!job || job.type !== String(req.params.type)) { res.status(404).json({ error: 'not found' }); return; }
739+
if (!jobs.resume(job.id)) { res.status(409).json({ error: 'job is not paused' }); return; }
740+
res.json({ ok: true });
741+
});
742+
743+
apiRouter.post('/:type/jobs/:id/retry', (req: Request, res: Response): void => {
744+
const job = jobs.get(String(req.params.id));
745+
if (!job || job.type !== String(req.params.type)) { res.status(404).json({ error: 'not found' }); return; }
746+
if (!jobs.retry(job.id)) { res.status(409).json({ error: 'job is not in error' }); return; }
747+
res.json({ ok: true });
748+
});

‎static/dashboard/sockets/books.exec.ts‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
// the EPUB and upload it to the local Calibre-Web. Ported from the Phase 1 books.socket
55
// download_books_batch handler (auth/CSRF + fetch + upload), made durable and reporting
66
// structured per-book progress. A job item is { id, title, url }.
7-
import { jobs, RunnerContext, CanceledError } from './jobs';
7+
import { jobs, RunnerContext, CanceledError, PausedError, classifyStop } from './jobs';
88
import { getCredential } from './credentials';
9-
import { withRetry, Aborted } from './net-retry';
9+
import { withRetry } from './net-retry';
1010
import fs from 'fs';
1111
import path from 'path';
1212

@@ -151,9 +151,12 @@ const booksRunner: (ctx: RunnerContext) => Promise<void> = async (ctx) => {
151151
onRetry: ({ attempt, err }) => ctx.log(`[books] retry ${attempt} for "${title}": ${err instanceof Error ? err.message : String(err)}`),
152152
});
153153
} catch (err) {
154-
// withRetry throws Aborted on cancel; surface it as the engine's CanceledError so the
155-
// job lands on 'canceled', not 'error'.
156-
if (err instanceof Aborted || ctx.isCanceled()) throw new CanceledError();
154+
// ADFA-4894: pause stops the fetch but keeps nothing mid-file (books resumes per item on
155+
// the next launch); cancel is terminal. Either way, distinguish from a real error via the
156+
// job flags so the phase lands on 'paused' / 'canceled', not 'error'.
157+
const stop = classifyStop(ctx);
158+
if (stop === 'paused') throw new PausedError();
159+
if (stop === 'canceled') throw new CanceledError();
157160
throw err;
158161
} finally {
159162
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/// <reference types="node" />
2+
// Run with `npm run test:db` — this exercises the JobManager, which loads better-sqlite3's native
3+
// binding, so it needs the package built (present wherever the dashboard is built/deployed). It is
4+
// kept out of the default `npm test` so that suite stays green on hosts without the native build.
5+
//
6+
// Use an isolated throwaway DB so the test never touches the real /library jobs.db. Must be set
7+
// BEFORE ./jobs is loaded (the JobManager opens the DB in its constructor) — hence the require()
8+
// below runs after this line, rather than a hoisted top-of-file import.
9+
process.env.K2GO_JOBS_DB = `/tmp/jobs-test-${process.pid}-${Date.now()}.db`;
10+
11+
import test from 'node:test';
12+
import assert from 'node:assert/strict';
13+
import type { RunnerContext } from './jobs';
14+
15+
// require (not import): a static import is hoisted above the env line; a dynamic import() goes
16+
// through the ESM loader, which needs a file extension ts-node's CJS require does not.
17+
const { jobs, CanceledError, PausedError } = require('./jobs') as typeof import('./jobs');
18+
19+
const tick = (ms = 30) => new Promise((r) => setTimeout(r, ms));
20+
21+
/**
22+
* A fake runner that reports 'downloading' then blocks on a gate until either released (→ done) or
23+
* the job's AbortSignal fires (pause/cancel → the matching engine error). Models a real runner's
24+
* pause-vs-cancel handling without a child process or network.
25+
*/
26+
function gatedRunner() {
27+
let release: (() => void) | null = null;
28+
const runner = async (ctx: RunnerContext) => {
29+
ctx.update({ phase: 'downloading', percent: 10 });
30+
try {
31+
await new Promise<void>((resolve, reject) => {
32+
release = resolve;
33+
ctx.signal.addEventListener('abort', () => reject(new Error('abort')), { once: true });
34+
});
35+
} catch {
36+
if (ctx.isPaused()) throw new PausedError();
37+
if (ctx.isCanceled()) throw new CanceledError();
38+
throw new Error('unexpected stop');
39+
}
40+
ctx.update({ phase: 'done', percent: 100 });
41+
};
42+
return { runner, release: () => release?.() };
43+
}
44+
45+
test('pause marks paused; resume re-runs to done', async () => {
46+
const g = gatedRunner();
47+
jobs.registerRunner('kiwix', g.runner);
48+
49+
const job = jobs.create('kiwix', ['wikipedia/x.zim']);
50+
await tick();
51+
assert.equal(jobs.get(job.id)?.phase, 'downloading');
52+
53+
assert.equal(jobs.pause(job.id), true);
54+
await tick();
55+
assert.equal(jobs.get(job.id)?.phase, 'paused'); // paused, not canceled
56+
57+
assert.equal(jobs.resume(job.id), true);
58+
await tick(); // let the relaunched run reach its gate
59+
g.release();
60+
await tick();
61+
assert.equal(jobs.get(job.id)?.phase, 'done');
62+
});
63+
64+
test('retry re-runs a job that ended in error', async () => {
65+
let attempt = 0;
66+
jobs.registerRunner('kiwix', async (ctx: RunnerContext) => {
67+
attempt++;
68+
ctx.update({ phase: 'downloading' });
69+
if (attempt === 1) throw new Error('boom'); // first run fails
70+
ctx.update({ phase: 'done', percent: 100 }); // retry succeeds
71+
});
72+
73+
const job = jobs.create('kiwix', ['wikipedia/y.zim']);
74+
await tick();
75+
assert.equal(jobs.get(job.id)?.phase, 'error');
76+
77+
assert.equal(jobs.retry(job.id), true);
78+
await tick();
79+
assert.equal(jobs.get(job.id)?.phase, 'done');
80+
assert.equal(attempt, 2);
81+
});
82+
83+
test('the verbs no-op outside their phase', async () => {
84+
jobs.registerRunner('kiwix', async (ctx: RunnerContext) => {
85+
ctx.update({ phase: 'done', percent: 100 }); // completes immediately
86+
});
87+
88+
const job = jobs.create('kiwix', ['wikipedia/z.zim']);
89+
await tick();
90+
assert.equal(jobs.get(job.id)?.phase, 'done');
91+
92+
assert.equal(jobs.pause(job.id), false); // not active
93+
assert.equal(jobs.resume(job.id), false); // not paused
94+
assert.equal(jobs.retry(job.id), false); // not in error
95+
});

‎static/dashboard/sockets/jobs.ts‎

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import { RollingLog, LogSlice } from './rolling-log';
1616
export type JobType = 'kiwix' | 'maps' | 'books' | 'kolibri';
1717
export type JobPhase =
1818
| 'queued' | 'downloading' | 'indexing' | 'processing'
19+
// ADFA-4894 (control surface): 'paused' is a stopped-but-resumable state — like 'canceled' it
20+
// halts the runner, but UNLIKE cancel it keeps the partial on disk so resume() continues from it.
21+
| 'paused'
1922
| 'done' | 'error' | 'canceled';
2023

2124
/** A persisted job row. `percent` is -1 when indeterminate; `speed` is bytes/sec. */
@@ -50,7 +53,10 @@ export interface RunnerContext {
5053
update(patch: JobUpdate): void;
5154
isCanceled(): boolean;
5255
throwIfCanceled(): void;
53-
/** ADFA-4894: aborted when the job is canceled, so an in-flight fetch (books) can be
56+
/** ADFA-4894 (control surface): true when the job was PAUSED (not canceled). A runner uses this
57+
* to skip its clean-on-cancel — a paused download keeps its partial so resume() continues it. */
58+
isPaused(): boolean;
59+
/** ADFA-4894: aborted when the job is canceled OR paused, so an in-flight fetch (books) can be
5460
* interrupted — a child process is killed via SIGKILL, but a fetch has no process. */
5561
readonly signal: AbortSignal;
5662
/** Spawn a child that is killed automatically on cancel and untracked on exit. */
@@ -65,13 +71,30 @@ export class CanceledError extends Error {
6571
constructor() { super('canceled'); this.name = 'CanceledError'; }
6672
}
6773

74+
/** ADFA-4894: a runner throws this when stopped by pause(); mapped to 'paused' (partial kept). */
75+
export class PausedError extends Error {
76+
constructor() { super('paused'); this.name = 'PausedError'; }
77+
}
78+
79+
/**
80+
* ADFA-4894: why a runner is stopping — so every runner handles pause vs cancel the same way (pause
81+
* keeps the partial; cancel cleans it). Returns null for a real error. Keyed on the job flags, not
82+
* the error type: a fetch/withRetry only aborts because the job was paused or canceled, so the flags
83+
* are the source of truth and each runner does just its own cleanup on 'canceled'.
84+
*/
85+
export function classifyStop(ctx: RunnerContext): 'paused' | 'canceled' | null {
86+
if (ctx.isPaused()) return 'paused';
87+
if (ctx.isCanceled()) return 'canceled';
88+
return null;
89+
}
90+
6891
const DB_PATH = process.env.K2GO_JOBS_DB || '/library/dashboard/jobs.db';
6992
const ACTIVE: JobPhase[] = ['queued', 'downloading', 'indexing', 'processing'];
7093

7194
class JobManager {
7295
private db: Database.Database;
7396
private runners = new Map<JobType, Runner>();
74-
private runtime = new Map<string, { canceled: boolean; procs: Set<ChildProcess>; ac: AbortController }>();
97+
private runtime = new Map<string, { canceled: boolean; paused: boolean; procs: Set<ChildProcess>; ac: AbortController }>();
7598
// ADFA-4879: bounded rolling log tail per job for the live-log REST endpoint.
7699
private readonly logTail = new RollingLog();
77100

@@ -143,6 +166,58 @@ class JobManager {
143166
return true;
144167
}
145168

169+
/**
170+
* ADFA-4894 (control surface): pause a running job. Stops the runner like cancel — kills its
171+
* children, aborts an in-flight fetch — but marks it 'paused' and, crucially, the runner does NOT
172+
* clean its partial (it checks {@link RunnerContext.isPaused}), so {@link #resume} continues from
173+
* the checkpoint. Only an ACTIVE job can be paused. Idempotent on a non-active job.
174+
*/
175+
pause(id: string): boolean {
176+
const job = this.get(id);
177+
if (!job) return false;
178+
if (!ACTIVE.includes(job.phase)) return false;
179+
const rt = this.runtime.get(id);
180+
if (rt) {
181+
rt.paused = true;
182+
for (const p of rt.procs) { try { p.kill('SIGKILL'); } catch { /* already gone */ } }
183+
try { rt.ac.abort(); } catch { /* interrupt an in-flight fetch */ }
184+
}
185+
this.patch(id, { phase: 'paused' });
186+
return true;
187+
}
188+
189+
/**
190+
* ADFA-4894: resume a paused job — re-launch the runner, which continues from its on-disk
191+
* checkpoint (aria2 --continue for kiwix; per-item for books). No-op unless the job is 'paused'
192+
* and nothing is already running for it.
193+
*/
194+
resume(id: string): boolean {
195+
const job = this.get(id);
196+
if (!job || job.phase !== 'paused') return false;
197+
if (this.runtime.has(id)) return false; // already running
198+
// Leave 'paused' for an ACTIVE phase before relaunching, so the completion guard in launch()
199+
// (which only finalizes an ACTIVE job to 'done') and the UI both see it running at once.
200+
this.patch(id, { phase: 'queued' });
201+
this.launch(job);
202+
return true;
203+
}
204+
205+
/**
206+
* ADFA-4894: retry a job that ended in 'error' — re-launch it. Where the runner is resumable the
207+
* partial is still on disk (kiwix keeps it on a real error), so this continues rather than
208+
* restarts. No-op unless the job is in 'error' and nothing is already running for it.
209+
*/
210+
retry(id: string): boolean {
211+
const job = this.get(id);
212+
if (!job || job.phase !== 'error') return false;
213+
if (this.runtime.has(id)) return false;
214+
// Move off 'error' to an ACTIVE phase before relaunch (clears the error, satisfies the
215+
// completion guard, and the UI shows it running immediately).
216+
this.patch(id, { phase: 'queued', error: null });
217+
this.launch(job);
218+
return true;
219+
}
220+
146221
/** Resume jobs that were mid-flight when the dashboard last stopped. */
147222
reconcileOnBoot(): void {
148223
const stuck = this.db.prepare(
@@ -172,7 +247,7 @@ class JobManager {
172247
const runner = this.runners.get(job.type);
173248
if (!runner) { this.patch(job.id, { phase: 'error', error: `no runner for ${job.type}` }); return; }
174249

175-
const rt = { canceled: false, procs: new Set<ChildProcess>(), ac: new AbortController() };
250+
const rt = { canceled: false, paused: false, procs: new Set<ChildProcess>(), ac: new AbortController() };
176251
this.runtime.set(job.id, rt);
177252

178253
let items: unknown[] = [];
@@ -184,6 +259,7 @@ class JobManager {
184259
update: (p) => this.patch(job.id, p),
185260
isCanceled: () => rt.canceled,
186261
throwIfCanceled: () => { if (rt.canceled) throw new CanceledError(); },
262+
isPaused: () => rt.paused,
187263
signal: rt.ac.signal,
188264
spawn: (cmd, args, opts) => {
189265
const child = opts ? spawn(cmd, args, opts) : spawn(cmd, args);
@@ -201,7 +277,9 @@ class JobManager {
201277
if (cur && ACTIVE.includes(cur.phase)) this.patch(job.id, { phase: 'done', percent: 100 });
202278
})
203279
.catch((err: unknown) => {
204-
if (err instanceof CanceledError || rt.canceled) {
280+
if (err instanceof PausedError || rt.paused) {
281+
this.patch(job.id, { phase: 'paused' });
282+
} else if (err instanceof CanceledError || rt.canceled) {
205283
this.patch(job.id, { phase: 'canceled' });
206284
} else {
207285
const msg = err instanceof Error ? err.message : String(err);

‎static/dashboard/sockets/kiwix.exec.ts‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
// bytes/sec) instead of streamed as terminal text. Ported from the Phase 1
66
// kiwix.socket handler, minus the socket/closure lifetime — the job outlives any
77
// client (see jobs.ts).
8-
import { jobs, RunnerContext, CanceledError } from './jobs';
9-
import { withRetry, Aborted } from './net-retry';
8+
import { jobs, RunnerContext, CanceledError, PausedError, classifyStop } from './jobs';
9+
import { withRetry } from './net-retry';
1010
import { execSync } from 'child_process';
1111
import fs from 'fs';
1212
import path from 'path';
@@ -182,12 +182,12 @@ const kiwixRunner: (ctx: RunnerContext) => Promise<void> = async (ctx) => {
182182
onRetry: ({ attempt, err }) => ctx.log(`[kiwix] reconnect attempt ${attempt} after: ${err instanceof Error ? err.message : String(err)}`),
183183
});
184184
} catch (e) {
185-
// ADFA-4894: a canceled download leaves nothing half-written; a real error KEEPS the partial
186-
// (+ .aria2) so a later run resumes it via --continue rather than starting from zero.
187-
if (e instanceof CanceledError || e instanceof Aborted || ctx.isCanceled()) {
188-
cleanupPartials(files);
189-
throw new CanceledError();
190-
}
185+
// ADFA-4894: pause KEEPS the partial (+ .aria2) so resume continues via --continue; cancel
186+
// discards it; a real error also keeps it, so a later retry/reconcile resumes rather than
187+
// starting from zero.
188+
const stop = classifyStop(ctx);
189+
if (stop === 'paused') throw new PausedError();
190+
if (stop === 'canceled') { cleanupPartials(files); throw new CanceledError(); }
191191
throw e;
192192
}
193193

0 commit comments

Comments
 (0)