dsh-dl-server 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/queue.js CHANGED
@@ -1,591 +1,870 @@
1
- import { createHash } from 'node:crypto'
2
- import { createReadStream } from 'node:fs'
3
- import fs from 'node:fs/promises'
4
- import path from 'node:path'
5
- import { formatJobId, isTerminal, parseJobId } from './types.js'
6
-
7
- /** How many times one job may fail before it is parked as `failed`. */
8
- export const MAX_ATTEMPTS = 3
9
-
10
- /** Safe default for `download_cleanup`. */
11
- export const DEFAULT_SCOPE = 'allWithoutFiles'
12
-
13
- export const CLEANUP_SCOPES = ['records', 'cache', 'allWithoutFiles', 'all']
14
-
15
- /** Drop undefined properties: the tool registry requires lossless JSON. */
16
- function compact(object) {
17
- const out = {}
18
- for (const [key, value] of Object.entries(object)) {
19
- if (value !== undefined) out[key] = value
20
- }
21
- return out
22
- }
23
-
24
- /** `J#1042` → `j1042`: the path-safe key used by the per-record storage layout. */
25
- function storageKeyOf(id) {
26
- const seq = parseJobId(id)
27
- return seq === undefined ? id : `j${seq}`
28
- }
29
-
30
- export class QueueError extends Error {
31
- /** @param {string} code @param {string} message */
32
- constructor(code, message) {
33
- super(message)
34
- this.code = code
35
- }
36
- }
37
-
38
- /**
39
- * @typedef {object} Caller
40
- * @property {string} workspace WorkspaceId
41
- * @property {string} session SessionId of the parent session
42
- * @property {string} [origin] subagent name, when the call came from one
43
- */
44
-
45
- /**
46
- * The queue engine. Owns scheduling, state transitions and checkpoints; the
47
- * transport owns bytes and the domain owns durability.
48
- */
49
- export class Queue {
50
- /**
51
- * @param {object} deps
52
- * @param {any} deps.domain opened DLS domain
53
- * @param {import('./transport.js').Transport} deps.transport
54
- * @param {object} deps.config
55
- * @param {(level: string, message: string) => void} [deps.log]
56
- * @param {() => number} [deps.now]
57
- * @param {(sessionId: string) => boolean | Promise<boolean>} [deps.isOwnerAlive]
58
- */
59
- constructor({ domain, transport, config, log = () => {}, now = Date.now, isOwnerAlive }) {
60
- // Per-record keys must match /^[a-zA-Z0-9_-]+$/ while the public id is
61
- // `J#1042`; translate at the storage boundary so callers stay on job ids.
62
- const rawJobs = domain.table('jobs')
63
- this.jobs = {
64
- entries: () => rawJobs.entries(),
65
- get: (id) => rawJobs.get(storageKeyOf(id)),
66
- put: (id, record) => rawJobs.put(storageKeyOf(id), record),
67
- // `deleteMany` only exists from 0.2.2; loop over `delete` so the plugin
68
- // also runs on 0.2.1 profiles.
69
- deleteMany: async (ids) => {
70
- let removed = 0
71
- for (const id of ids) {
72
- if (await rawJobs.delete(storageKeyOf(id))) removed += 1
73
- }
74
- return removed
75
- },
76
- }
77
- this.domain = domain
78
- this.transport = transport
79
- this.config = config
80
- this.log = log
81
- this.now = now
82
- this.isOwnerAlive = isOwnerAlive
83
-
84
- /** @type {Map<string, { controller: AbortController, startedAt: number }>} */
85
- this.active = new Map()
86
- /** @type {Map<string, { done: number, total: number, speed: number, lastDone: number, lastAt: number }>} */
87
- this.progress = new Map()
88
- this.closed = false
89
- this.timer = undefined
90
- this.checkpointTimer = undefined
91
- }
92
-
93
- // ---------------------------------------------------------------- lifecycle
94
-
95
- /** Reconcile persisted state with reality. Call once before serving. */
96
- async recover() {
97
- const resumable = []
98
-
99
- for (const [, record] of this.jobs.entries()) {
100
- if (record.state === 'running') {
101
- record.state = 'paused'
102
- record.pauseReason = 'host_restart'
103
- record.updatedAt = this.now()
104
- await this.jobs.put(record.id, record)
105
- resumable.push(record)
106
- } else if (record.state === 'paused' && record.pauseReason === 'host_restart') {
107
- // A previous shutdown may persist the pause before the process exits; such a
108
- // record still deserves the same resume treatment as a freshly paused one.
109
- resumable.push(record)
110
- }
111
- }
112
-
113
- if (resumable.length > 0) {
114
- this.log('info', `${resumable.length} job(s) interrupted by a restart -> paused(host_restart)`)
115
- }
116
-
117
- if (this.config.autoResumeOnHostRestart) {
118
- for (const record of resumable) {
119
- await this.resume(record.id, { workspace: record.ownerWorkspace, session: record.ownerSession }, { internal: true })
120
- }
121
- }
122
-
123
- await this.#cancelOrphans()
124
-
125
- this.checkpointTimer = setInterval(() => void this.#checkpointAll(), this.config.checkpointSeconds * 1000)
126
- this.checkpointTimer.unref?.()
127
- this.#schedule()
128
- }
129
-
130
- async close() {
131
- this.closed = true
132
- clearTimeout(this.timer)
133
- if (this.checkpointTimer) clearInterval(this.checkpointTimer)
134
-
135
- const pending = []
136
- for (const [id, entry] of this.active) {
137
- entry.controller.abort()
138
- pending.push(this.#stopAs(id, 'host_restart'))
139
- }
140
- await Promise.allSettled(pending)
141
- }
142
-
143
- // ------------------------------------------------------------------- reading
144
-
145
- /** Every session sees every job. */
146
- list() {
147
- return [...this.jobs.entries()].map(([, record]) => this.#view(record))
148
- }
149
-
150
- /** @param {string} id */
151
- get(id) {
152
- const record = this.jobs.get(id)
153
- return record ? this.#view(record) : undefined
154
- }
155
-
156
- /**
157
- * Block until a job reaches a terminal state or the timeout expires.
158
- * @param {string} id
159
- * @param {number} timeoutMs
160
- */
161
- async wait(id, timeoutMs = 60_000) {
162
- const deadline = this.now() + timeoutMs
163
- for (;;) {
164
- const record = this.jobs.get(id)
165
- if (!record) throw new QueueError('not-found', `job ${id} does not exist`)
166
- if (isTerminal(record.state)) return this.#view(record)
167
- if (this.now() >= deadline) return this.#view(record)
168
- await new Promise((resolve) => setTimeout(resolve, 200))
169
- }
170
- }
171
-
172
- // ------------------------------------------------------------------- writing
173
-
174
- /**
175
- * Add a job. Returns immediately; the scheduler picks it up.
176
- * @param {{ urls: string[], destPath: string, expectSha256?: string }} spec
177
- * @param {Caller} caller
178
- */
179
- async enqueue(spec, caller) {
180
- if (!Array.isArray(spec.urls) || spec.urls.length === 0) {
181
- throw new QueueError('url-invalid', 'at least one URL is required')
182
- }
183
- for (const url of spec.urls) {
184
- try {
185
- const parsed = new URL(url)
186
- if (!/^https?:$/.test(parsed.protocol)) throw new Error('scheme')
187
- } catch {
188
- throw new QueueError('url-invalid', `not an http(s) URL: ${url}`)
189
- }
190
- }
191
-
192
- await this.#assertDiskSpace(spec.destPath)
193
-
194
- const seq = await this.#nextSeq()
195
- const id = formatJobId(seq)
196
- const stamp = this.now()
197
-
198
- /** @type {import('./types.js').JobRecord} */
199
- const record = {
200
- id,
201
- seq,
202
- ownerWorkspace: caller.workspace,
203
- ownerSession: caller.session,
204
- origin: caller.origin,
205
- urls: [...spec.urls],
206
- destPath: spec.destPath,
207
- state: 'queued',
208
- createdAt: stamp,
209
- updatedAt: stamp,
210
- bytesTotal: -1,
211
- bytesCheckpoint: 0,
212
- attempts: 0,
213
- expectSha256: spec.expectSha256,
214
- }
215
-
216
- await this.jobs.put(id, record)
217
- this.log('info', `${id} enqueued by ${caller.workspace}#${caller.session.slice(0, 8)}`)
218
- this.#schedule()
219
- return this.#view(record)
220
- }
221
-
222
- /** @param {string} id @param {Caller} caller */
223
- async pause(id, caller) {
224
- const record = this.#writable(id, caller)
225
- if (record.state !== 'running' && record.state !== 'queued') {
226
- throw new QueueError('bad-state', `${id} is ${record.state}, cannot pause`)
227
- }
228
- if (record.state === 'queued') {
229
- record.state = 'paused'
230
- record.pauseReason = 'user'
231
- record.updatedAt = this.now()
232
- await this.jobs.put(id, record)
233
- return this.#view(record)
234
- }
235
- this.active.get(id)?.controller.abort()
236
- await this.#stopAs(id, 'user')
237
- return this.#view(this.jobs.get(id))
238
- }
239
-
240
- /** @param {string} id @param {Caller} caller @param {{ internal?: boolean }} [options] */
241
- async resume(id, caller, options = {}) {
242
- const record = options.internal ? this.#must(id) : this.#writable(id, caller)
243
- if (record.state !== 'paused' && record.state !== 'queued') {
244
- throw new QueueError('bad-state', `${id} is ${record.state}, cannot resume`)
245
- }
246
- record.state = 'queued'
247
- delete record.pauseReason
248
- record.updatedAt = this.now()
249
- await this.jobs.put(id, record)
250
- this.#schedule()
251
- return this.#view(record)
252
- }
253
-
254
- /** @param {string} id @param {Caller} caller */
255
- async cancel(id, caller) {
256
- const record = this.#must(id)
257
- if (!this.#canWrite(record, caller)) {
258
- throw new QueueError('not-owner', `${id} belongs to another session`)
259
- }
260
- if (isTerminal(record.state)) {
261
- throw new QueueError('bad-state', `${id} is already ${record.state}`)
262
- }
263
-
264
- const controller = this.active.get(id)?.controller
265
- controller?.abort()
266
-
267
- record.state = 'canceled'
268
- record.updatedAt = this.now()
269
- await this.jobs.put(id, record)
270
- this.progress.delete(id)
271
-
272
- await this.#dropPartialFiles(record)
273
- this.log('info', `${id} canceled`)
274
- return this.#view(record)
275
- }
276
-
277
- /**
278
- * @param {string} scope
279
- * @param {Caller} caller
280
- */
281
- async cleanup(scope, caller) {
282
- if (!CLEANUP_SCOPES.includes(scope)) {
283
- throw new QueueError('bad-state', `unknown scope: ${scope}`)
284
- }
285
-
286
- const mine = [...this.jobs.entries()]
287
- .map(([, record]) => record)
288
- .filter((record) => record.ownerWorkspace === caller.workspace && record.ownerSession === caller.session)
289
-
290
- let removedRecords = 0
291
- let removedCacheFiles = 0
292
- let removedFiles = 0
293
- let freedBytes = 0
294
-
295
- const touch = async (file) => {
296
- try {
297
- const info = await fs.stat(file)
298
- await fs.rm(file, { force: true })
299
- return info.size
300
- } catch {
301
- return undefined
302
- }
303
- }
304
-
305
- if (scope === 'records' || scope === 'allWithoutFiles' || scope === 'all') {
306
- const removable = mine.filter((record) => isTerminal(record.state)).map((record) => record.id)
307
- if (removable.length > 0) {
308
- removedRecords = await this.jobs.deleteMany(removable)
309
- }
310
- }
311
-
312
- if (scope === 'cache' || scope === 'allWithoutFiles' || scope === 'all') {
313
- for (const record of mine) {
314
- for (const file of [`${record.destPath}.part`, `${record.destPath}.aria2`]) {
315
- const freed = await touch(file)
316
- if (freed !== undefined) removedCacheFiles += 1
317
- freedBytes += freed ?? 0
318
- }
319
- }
320
- }
321
-
322
- if (scope === 'all') {
323
- for (const record of mine) {
324
- if (!isTerminal(record.state)) continue
325
- const freed = await touch(record.destPath)
326
- if (freed !== undefined) removedFiles += 1
327
- freedBytes += freed ?? 0
328
- }
329
- }
330
-
331
- return { scope, removedRecords, removedCacheFiles, removedFiles, freedBytes }
332
- }
333
-
334
- // ------------------------------------------------------------------ internals
335
-
336
- /** @param {string} id */
337
- #must(id) {
338
- const record = this.jobs.get(id)
339
- if (!record) throw new QueueError('not-found', `job ${id} does not exist`)
340
- return record
341
- }
342
-
343
- /** @param {string} id @param {Caller} caller */
344
- #writable(id, caller) {
345
- const record = this.#must(id)
346
- if (!this.#canWrite(record, caller)) {
347
- throw new QueueError('not-owner', `${id} belongs to another session`)
348
- }
349
- return record
350
- }
351
-
352
- /** @param {import('./types.js').JobRecord} record @param {Caller} caller */
353
- #canWrite(record, caller) {
354
- if (isTerminal(record.state)) return true
355
- return record.ownerWorkspace === caller.workspace && record.ownerSession === caller.session
356
- }
357
-
358
- async #nextSeq() {
359
- const state = this.domain.global.get()
360
- const seq = state.nextSeq
361
- await this.domain.global.set({ nextSeq: seq + 1 })
362
- return seq
363
- }
364
-
365
- /** @param {import('./types.js').JobRecord} record */
366
- #view(record) {
367
- const live = this.progress.get(record.id)
368
- const total = live?.total && live.total > 0 ? live.total : record.bytesTotal
369
- const done = isTerminal(record.state)
370
- ? (record.finalBytes ?? record.bytesCheckpoint)
371
- : (live?.done ?? record.bytesCheckpoint)
372
-
373
- return compact({
374
- id: record.id,
375
- state: record.state,
376
- pauseReason: record.pauseReason,
377
- owner: { workspace: record.ownerWorkspace, session: record.ownerSession },
378
- origin: record.origin,
379
- urls: record.urls,
380
- dest: record.destPath,
381
- bytesTotal: total,
382
- bytesDone: done,
383
- speedBps: live?.speed ?? 0,
384
- etaSeconds: live && live.speed > 0 && total > 0 ? Math.max(0, Math.round((total - done) / live.speed)) : undefined,
385
- attempts: record.attempts,
386
- lastError: record.lastError,
387
- createdAt: record.createdAt,
388
- updatedAt: record.updatedAt,
389
- })
390
- }
391
-
392
- #schedule() {
393
- if (this.closed || this.timer) return
394
- this.timer = setTimeout(() => {
395
- this.timer = undefined
396
- void this.#pump()
397
- }, 0)
398
- this.timer.unref?.()
399
- }
400
-
401
- async #pump() {
402
- if (this.closed) return
403
- const queued = [...this.jobs.entries()]
404
- .map(([, record]) => record)
405
- .filter((record) => record.state === 'queued')
406
- .sort((a, b) => a.seq - b.seq)
407
-
408
- for (const record of queued) {
409
- if (this.active.size >= this.config.maxConcurrentJobs) break
410
- if (this.active.has(record.id)) continue
411
- void this.#run(record)
412
- }
413
- }
414
-
415
- /** @param {import('./types.js').JobRecord} record */
416
- async #run(record) {
417
- const id = record.id
418
- const controller = new AbortController()
419
- this.active.set(id, { controller, startedAt: this.now() })
420
-
421
- record.state = 'running'
422
- record.updatedAt = this.now()
423
- await this.jobs.put(id, record)
424
-
425
- const stats = { done: record.bytesCheckpoint, total: record.bytesTotal, speed: 0, lastDone: record.bytesCheckpoint, lastAt: this.now() }
426
- this.progress.set(id, stats)
427
-
428
- try {
429
- const result = await this.transport.transfer({
430
- urls: record.urls,
431
- destPath: record.destPath,
432
- connections: this.config.connectionsPerJob,
433
- speedLimitKbps: this.config.speedLimitKbps,
434
- bytesDone: record.bytesCheckpoint,
435
- signal: controller.signal,
436
- onProgress: (done, total) => {
437
- const stamp = this.now()
438
- const elapsed = (stamp - stats.lastAt) / 1000
439
- if (elapsed >= 0.4) {
440
- stats.speed = Math.max(0, (done - stats.lastDone) / elapsed)
441
- stats.lastDone = done
442
- stats.lastAt = stamp
443
- }
444
- stats.done = done
445
- if (total > 0) stats.total = total
446
- },
447
- })
448
-
449
- if (record.expectSha256) {
450
- const actual = await sha256File(record.destPath)
451
- if (actual.toLowerCase() !== record.expectSha256.toLowerCase()) {
452
- throw new Error(`sha256 mismatch: expected ${record.expectSha256}, got ${actual}`)
453
- }
454
- }
455
-
456
- await this.#finish(id, 'completed', {
457
- bytesTotal: result.total > 0 ? result.total : stats.total,
458
- bytesCheckpoint: result.bytes,
459
- finalBytes: result.bytes,
460
- })
461
- } catch (error) {
462
- const aborted = controller.signal.aborted
463
- if (aborted) {
464
- // pause / cancel / close already recorded the target state
465
- if (!isTerminal(record.state)) {
466
- await this.#stopAs(id, 'host_restart')
467
- }
468
- } else {
469
- await this.#fail(id, error)
470
- }
471
- } finally {
472
- this.active.delete(id)
473
- this.progress.delete(id)
474
- this.#schedule()
475
- }
476
- }
477
-
478
- /** @param {string} id @param {Error} error */
479
- async #fail(id, error) {
480
- const record = this.jobs.get(id)
481
- if (!record || isTerminal(record.state)) return
482
-
483
- record.attempts += 1
484
- record.lastError = error?.message ?? String(error)
485
- record.updatedAt = this.now()
486
-
487
- if (record.attempts < MAX_ATTEMPTS) {
488
- record.state = 'queued'
489
- record.pauseReason = undefined
490
- this.log('warn', `${id} failed (attempt ${record.attempts}/${MAX_ATTEMPTS}), retrying: ${record.lastError}`)
491
- } else {
492
- record.state = 'failed'
493
- this.log('warn', `${id} failed permanently: ${record.lastError}`)
494
- }
495
- await this.jobs.put(id, record)
496
- }
497
-
498
- /** @param {string} id @param {'user'|'session_closed'|'host_restart'} reason */
499
- async #stopAs(id, reason) {
500
- const record = this.jobs.get(id)
501
- if (!record || isTerminal(record.state)) return
502
- const stats = this.progress.get(id)
503
- record.state = 'paused'
504
- record.pauseReason = reason
505
- if (stats) record.bytesCheckpoint = stats.done
506
- record.updatedAt = this.now()
507
- await this.jobs.put(id, record)
508
- }
509
-
510
- /**
511
- * @param {string} id
512
- * @param {'completed'} state
513
- * @param {Partial<import('./types.js').JobRecord>} patch
514
- */
515
- async #finish(id, state, patch) {
516
- const record = this.jobs.get(id)
517
- if (!record) return
518
- Object.assign(record, patch, { state, updatedAt: this.now() })
519
- delete record.pauseReason
520
- await this.jobs.put(id, record)
521
- this.log('info', `${id} ${state} (${record.finalBytes ?? record.bytesCheckpoint} bytes)`)
522
- }
523
-
524
- async #checkpointAll() {
525
- for (const [id] of this.active) {
526
- const stats = this.progress.get(id)
527
- const record = this.jobs.get(id)
528
- if (!stats || !record || record.state !== 'running') continue
529
- if (stats.done <= record.bytesCheckpoint) continue
530
- record.bytesCheckpoint = stats.done
531
- record.bytesTotal = stats.total > 0 ? stats.total : record.bytesTotal
532
- record.updatedAt = this.now()
533
- await this.jobs.put(id, record).catch(() => {})
534
- }
535
- }
536
-
537
- /** @param {import('./types.js').JobRecord} record */
538
- async #dropPartialFiles(record) {
539
- for (const file of [`${record.destPath}.part`, `${record.destPath}.aria2`]) {
540
- await fs.rm(file, { force: true }).catch(() => {})
541
- }
542
- }
543
-
544
- async #cancelOrphans() {
545
- if (!this.isOwnerAlive) {
546
- this.log('info', 'orphan scan skipped (no session probe wired in yet)')
547
- return
548
- }
549
- for (const [, record] of [...this.jobs.entries()]) {
550
- if (isTerminal(record.state)) continue
551
- if (await this.isOwnerAlive(record.ownerSession)) continue
552
- record.state = 'canceled'
553
- record.lastError = 'owner session no longer exists'
554
- record.updatedAt = this.now()
555
- await this.jobs.put(record.id, record)
556
- await this.#dropPartialFiles(record)
557
- this.log('info', `${record.id} canceled: owner session is gone`)
558
- }
559
- }
560
-
561
- /**
562
- * Cheap guard against enqueueing onto a full disk. The real file size is not
563
- * known yet, so this only catches the obvious case.
564
- * @param {string} destPath
565
- */
566
- async #assertDiskSpace(destPath) {
567
- const minimumFree = 512 * 1024 * 1024
568
- try {
569
- const stats = await fs.statfs(path.dirname(destPath))
570
- const free = Number(stats.bavail) * Number(stats.bsize)
571
- if (Number.isFinite(free) && free < minimumFree) {
572
- throw new QueueError('disk-full', `less than 512 MB free for ${path.dirname(destPath)}`)
573
- }
574
- } catch (error) {
575
- if (error instanceof QueueError) throw error
576
- /* statfs is unavailable on some platforms; do not block the enqueue */
577
- }
578
- }
579
- }
580
-
581
- /** @param {string} file */
582
- async function sha256File(file) {
583
- const hash = createHash('sha256')
584
- await new Promise((resolve, reject) => {
585
- createReadStream(file)
586
- .on('data', (chunk) => hash.update(chunk))
587
- .on('end', resolve)
588
- .on('error', reject)
589
- })
590
- return hash.digest('hex')
591
- }
1
+ import { createHash } from 'node:crypto'
2
+ import { createReadStream } from 'node:fs'
3
+ import fs from 'node:fs/promises'
4
+ import path from 'node:path'
5
+ import { expandMirrors, hostOf } from './mirrors.js'
6
+ import { formatJobId, isTerminal, parseJobId } from './types.js'
7
+
8
+ /** How many times one job may fail before it is parked as `failed`. */
9
+ export const MAX_ATTEMPTS = 3
10
+
11
+ /** Safe default for `download_cleanup`. */
12
+ export const DEFAULT_SCOPE = 'allWithoutFiles'
13
+
14
+ export const CLEANUP_SCOPES = ['records', 'cache', 'allWithoutFiles', 'all']
15
+
16
+ /** Free space always kept in reserve, on top of what queued jobs will consume. */
17
+ const MIN_FREE_BYTES = 512 * 1024 * 1024
18
+
19
+ /** Assumed size of a queued job whose total is not known yet. */
20
+ const UNKNOWN_SIZE_RESERVE = 256 * 1024 * 1024
21
+
22
+ /** A mirror host that fails this many times in a row is skipped for COOLDOWN_MS. */
23
+ const MIRROR_FAIL_THRESHOLD = 2
24
+ const MIRROR_COOLDOWN_MS = 10 * 60 * 1000
25
+
26
+ /** Drop undefined properties: the tool registry requires lossless JSON. */
27
+ function compact(object) {
28
+ const out = {}
29
+ for (const [key, value] of Object.entries(object)) {
30
+ if (value !== undefined) out[key] = value
31
+ }
32
+ return out
33
+ }
34
+
35
+ /** `J#1042` → `j1042`: the path-safe key used by the per-record storage layout. */
36
+ function storageKeyOf(id) {
37
+ const seq = parseJobId(id)
38
+ return seq === undefined ? id : `j${seq}`
39
+ }
40
+
41
+ /**
42
+ * Comparison key for a destination path. Windows folds case and accepts both
43
+ * separators, so `D:\A\b.bin` and `d:/a/B.bin` are the same file there.
44
+ * @param {string} destPath
45
+ */
46
+ function destKeyOf(destPath) {
47
+ const resolved = path.resolve(destPath).replace(/[\\/]+$/, '')
48
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved
49
+ }
50
+
51
+ /** @param {string} file */
52
+ async function fileExists(file) {
53
+ try {
54
+ const info = await fs.stat(file)
55
+ return info.isFile() && info.size > 0
56
+ } catch {
57
+ return false
58
+ }
59
+ }
60
+
61
+ export class QueueError extends Error {
62
+ /** @param {string} code @param {string} message */
63
+ constructor(code, message) {
64
+ super(message)
65
+ this.code = code
66
+ }
67
+ }
68
+
69
+ /**
70
+ * @typedef {object} Caller
71
+ * @property {string} workspace WorkspaceId
72
+ * @property {string} session SessionId of the parent session
73
+ * @property {string} [origin] subagent name, when the call came from one
74
+ */
75
+
76
+ /**
77
+ * The queue engine. Owns scheduling, state transitions and checkpoints; the
78
+ * transport owns bytes and the domain owns durability.
79
+ */
80
+ export class Queue {
81
+ /**
82
+ * @param {object} deps
83
+ * @param {any} deps.domain opened DLS domain
84
+ * @param {import('./transport.js').Transport} deps.transport
85
+ * @param {object} deps.config
86
+ * @param {(level: string, message: string) => void} [deps.log]
87
+ * @param {() => number} [deps.now]
88
+ * @param {(sessionId: string) => boolean | Promise<boolean>} [deps.isOwnerAlive]
89
+ */
90
+ constructor({ domain, transport, config, log = () => {}, now = Date.now, isOwnerAlive }) {
91
+ // Per-record keys must match /^[a-zA-Z0-9_-]+$/ while the public id is
92
+ // `J#1042`; translate at the storage boundary so callers stay on job ids.
93
+ const rawJobs = domain.table('jobs')
94
+ this.jobs = {
95
+ entries: () => rawJobs.entries(),
96
+ get: (id) => rawJobs.get(storageKeyOf(id)),
97
+ put: (id, record) => rawJobs.put(storageKeyOf(id), record),
98
+ // `deleteMany` only exists from 0.2.2; loop over `delete` so the plugin
99
+ // also runs on 0.2.1 profiles.
100
+ deleteMany: async (ids) => {
101
+ let removed = 0
102
+ for (const id of ids) {
103
+ if (await rawJobs.delete(storageKeyOf(id))) removed += 1
104
+ }
105
+ return removed
106
+ },
107
+ }
108
+ this.domain = domain
109
+ this.transport = transport
110
+ this.config = config
111
+ this.log = log
112
+ this.now = now
113
+ this.isOwnerAlive = isOwnerAlive
114
+
115
+ /** @type {Map<string, { controller: AbortController, startedAt: number }>} */
116
+ this.active = new Map()
117
+ /** @type {Map<string, { done: number, total: number, speed: number, lastDone: number, lastAt: number }>} */
118
+ this.progress = new Map()
119
+ this.closed = false
120
+ this.timer = undefined
121
+ this.checkpointTimer = undefined
122
+ /** Session served last, so the next pass starts with a different one. */
123
+ this.lastServed = undefined
124
+
125
+ /** @type {{ errors: number, lastError?: string, lastAt?: number }} */
126
+ this.storage = { errors: 0 }
127
+ /** @type {Map<string, { fails: number, cooldownUntil: number }>} */
128
+ this.mirrorHealth = new Map()
129
+ }
130
+
131
+ // ---------------------------------------------------------------- lifecycle
132
+
133
+ /** Reconcile persisted state with reality. Call once before serving. */
134
+ async recover() {
135
+ const resumable = []
136
+
137
+ for (const [, record] of this.jobs.entries()) {
138
+ if (record.state === 'running') {
139
+ record.state = 'paused'
140
+ record.pauseReason = 'host_restart'
141
+ record.updatedAt = this.now()
142
+ await this.jobs.put(record.id, record)
143
+ resumable.push(record)
144
+ } else if (record.state === 'paused' && record.pauseReason === 'host_restart') {
145
+ // A previous shutdown may persist the pause before the process exits; such a
146
+ // record still deserves the same resume treatment as a freshly paused one.
147
+ resumable.push(record)
148
+ }
149
+ }
150
+
151
+ if (resumable.length > 0) {
152
+ this.log('info', `${resumable.length} job(s) interrupted by a restart -> paused(host_restart)`)
153
+ }
154
+
155
+ if (this.config.autoResumeOnHostRestart) {
156
+ for (const record of resumable) {
157
+ await this.resume(record.id, { workspace: record.ownerWorkspace, session: record.ownerSession }, { internal: true })
158
+ }
159
+ }
160
+
161
+ await this.#cancelOrphans()
162
+
163
+ this.checkpointTimer = setInterval(() => void this.#checkpointAll(), this.config.checkpointSeconds * 1000)
164
+ this.checkpointTimer.unref?.()
165
+ this.#schedule()
166
+ }
167
+
168
+ async close() {
169
+ this.closed = true
170
+ clearTimeout(this.timer)
171
+ if (this.checkpointTimer) clearInterval(this.checkpointTimer)
172
+
173
+ const pending = []
174
+ for (const [id, entry] of this.active) {
175
+ entry.controller.abort()
176
+ pending.push(this.#stopAs(id, 'host_restart'))
177
+ }
178
+ await Promise.allSettled(pending)
179
+ }
180
+
181
+ // ------------------------------------------------------------------- reading
182
+
183
+ /** Every session sees every job. */
184
+ list() {
185
+ return [...this.jobs.entries()].map(([, record]) => this.#view(record))
186
+ }
187
+
188
+ /** @param {string} id */
189
+ get(id) {
190
+ const record = this.jobs.get(id)
191
+ return record ? this.#view(record) : undefined
192
+ }
193
+
194
+ /**
195
+ * Block until a job reaches a terminal state or the timeout expires.
196
+ * @param {string} id
197
+ * @param {number} timeoutMs
198
+ */
199
+ async wait(id, timeoutMs = 60_000) {
200
+ const deadline = this.now() + timeoutMs
201
+ for (;;) {
202
+ const record = this.jobs.get(id)
203
+ if (!record) throw new QueueError('not-found', `job ${id} does not exist`)
204
+ if (isTerminal(record.state)) return this.#view(record)
205
+ if (this.now() >= deadline) return this.#view(record)
206
+ await new Promise((resolve) => setTimeout(resolve, 200))
207
+ }
208
+ }
209
+
210
+ // ------------------------------------------------------------------- writing
211
+
212
+ /**
213
+ * Add a job. Returns immediately; the scheduler picks it up.
214
+ * @param {{ urls: string[], destPath: string, expectSha256?: string, expectedBytes?: number }} spec
215
+ * @param {Caller} caller
216
+ */
217
+ async enqueue(spec, caller) {
218
+ if (!Array.isArray(spec.urls) || spec.urls.length === 0) {
219
+ throw new QueueError('url-invalid', 'at least one URL is required')
220
+ }
221
+ for (const url of spec.urls) {
222
+ try {
223
+ const parsed = new URL(url)
224
+ if (!/^https?:$/.test(parsed.protocol)) throw new Error('scheme')
225
+ } catch {
226
+ throw new QueueError('url-invalid', `not an http(s) URL: ${url}`)
227
+ }
228
+ }
229
+
230
+ const expanded = expandMirrors(spec.urls)
231
+ const destPath = path.resolve(spec.destPath)
232
+ const key = destKeyOf(destPath)
233
+
234
+ // Two jobs writing the same path is a conflict no matter which URL each one
235
+ // uses: aria2 would fight over the same .aria2 control file. Dedupe on the
236
+ // destination, not on the URL — one file can legitimately have many URLs.
237
+ const sameDest = [...this.jobs.entries()].map(([, record]) => record).filter((record) => destKeyOf(record.destPath) === key)
238
+
239
+ const active = sameDest.find((record) => !isTerminal(record.state))
240
+ if (active) {
241
+ if (active.urls[0] === expanded.urls[0]) {
242
+ this.log('info', `${active.id} reused by ${caller.session.slice(0, 8)} (same destination and source)`)
243
+ return { ...this.#view(active), deduplicated: true }
244
+ }
245
+ throw new QueueError(
246
+ 'dest-in-use',
247
+ `${destPath} is already being downloaded as ${active.id} from ${active.urls[0]}; cancel it first or choose another destination`,
248
+ )
249
+ }
250
+
251
+ // A finished job for the same file is what the caller wants far more often
252
+ // than a second download. Only reuse it while the file is still on disk —
253
+ // a deleted file must be fetched again.
254
+ const finished = sameDest.find((record) => record.state === 'completed' && record.urls[0] === expanded.urls[0])
255
+ if (finished && (await fileExists(finished.destPath))) {
256
+ this.log('info', `${finished.id} reused by ${caller.session.slice(0, 8)} (already downloaded)`)
257
+ return { ...this.#view(finished), deduplicated: true }
258
+ }
259
+
260
+ await this.#assertDestWritable(destPath)
261
+ await this.#assertDiskSpace(destPath, spec.expectedBytes)
262
+
263
+ const seq = await this.#nextSeq()
264
+ const id = formatJobId(seq)
265
+ const stamp = this.now()
266
+
267
+ /** @type {import('./types.js').JobRecord} */
268
+ const record = {
269
+ id,
270
+ seq,
271
+ ownerWorkspace: caller.workspace,
272
+ ownerSession: caller.session,
273
+ origin: caller.origin,
274
+ urls: expanded.urls,
275
+ mirrors: expanded.applied.length > 0 ? expanded.applied : undefined,
276
+ destPath,
277
+ state: 'queued',
278
+ createdAt: stamp,
279
+ updatedAt: stamp,
280
+ bytesTotal: -1,
281
+ bytesCheckpoint: 0,
282
+ attempts: 0,
283
+ expectSha256: spec.expectSha256,
284
+ }
285
+
286
+ await this.jobs.put(id, record)
287
+ this.log('info', `${id} enqueued by ${caller.workspace}#${caller.session.slice(0, 8)}`)
288
+ this.#schedule()
289
+ return this.#view(record)
290
+ }
291
+
292
+ /** @param {string} id @param {Caller} caller */
293
+ async pause(id, caller) {
294
+ const record = this.#writable(id, caller)
295
+ if (record.state !== 'running' && record.state !== 'queued') {
296
+ throw new QueueError('bad-state', `${id} is ${record.state}, cannot pause`)
297
+ }
298
+ if (record.state === 'queued') {
299
+ record.state = 'paused'
300
+ record.pauseReason = 'user'
301
+ record.updatedAt = this.now()
302
+ await this.jobs.put(id, record)
303
+ return this.#view(record)
304
+ }
305
+ this.active.get(id)?.controller.abort()
306
+ await this.#stopAs(id, 'user')
307
+ return this.#view(this.jobs.get(id))
308
+ }
309
+
310
+ /** @param {string} id @param {Caller} caller @param {{ internal?: boolean }} [options] */
311
+ async resume(id, caller, options = {}) {
312
+ const record = options.internal ? this.#must(id) : this.#writable(id, caller)
313
+ if (record.state !== 'paused' && record.state !== 'queued') {
314
+ throw new QueueError('bad-state', `${id} is ${record.state}, cannot resume`)
315
+ }
316
+ record.state = 'queued'
317
+ delete record.pauseReason
318
+ record.updatedAt = this.now()
319
+ await this.jobs.put(id, record)
320
+ this.#schedule()
321
+ return this.#view(record)
322
+ }
323
+
324
+ /** @param {string} id @param {Caller} caller */
325
+ async cancel(id, caller) {
326
+ const record = this.#must(id)
327
+ if (!this.#canWrite(record, caller)) {
328
+ throw new QueueError('not-owner', `${id} belongs to another session`)
329
+ }
330
+ if (isTerminal(record.state)) {
331
+ throw new QueueError('bad-state', `${id} is already ${record.state}`)
332
+ }
333
+
334
+ const controller = this.active.get(id)?.controller
335
+ controller?.abort()
336
+
337
+ record.state = 'canceled'
338
+ record.updatedAt = this.now()
339
+ await this.jobs.put(id, record)
340
+ this.progress.delete(id)
341
+
342
+ await this.#dropPartialFiles(record)
343
+ this.log('info', `${id} canceled`)
344
+ return this.#view(record)
345
+ }
346
+
347
+ /**
348
+ * @param {string} scope
349
+ * @param {Caller} caller
350
+ */
351
+ async cleanup(scope, caller) {
352
+ if (!CLEANUP_SCOPES.includes(scope)) {
353
+ throw new QueueError('bad-state', `unknown scope: ${scope}`)
354
+ }
355
+
356
+ const mine = [...this.jobs.entries()]
357
+ .map(([, record]) => record)
358
+ .filter((record) => record.ownerWorkspace === caller.workspace && record.ownerSession === caller.session)
359
+
360
+ let removedRecords = 0
361
+ let removedCacheFiles = 0
362
+ let removedFiles = 0
363
+ let freedBytes = 0
364
+
365
+ const touch = async (file) => {
366
+ try {
367
+ const info = await fs.stat(file)
368
+ await fs.rm(file, { force: true })
369
+ return info.size
370
+ } catch {
371
+ return undefined
372
+ }
373
+ }
374
+
375
+ if (scope === 'records' || scope === 'allWithoutFiles' || scope === 'all') {
376
+ const removable = mine.filter((record) => isTerminal(record.state)).map((record) => record.id)
377
+ if (removable.length > 0) {
378
+ removedRecords = await this.jobs.deleteMany(removable)
379
+ }
380
+ }
381
+
382
+ if (scope === 'cache' || scope === 'allWithoutFiles' || scope === 'all') {
383
+ for (const record of mine) {
384
+ for (const file of [`${record.destPath}.part`, `${record.destPath}.aria2`]) {
385
+ const freed = await touch(file)
386
+ if (freed !== undefined) removedCacheFiles += 1
387
+ freedBytes += freed ?? 0
388
+ }
389
+ }
390
+ }
391
+
392
+ if (scope === 'all') {
393
+ for (const record of mine) {
394
+ if (!isTerminal(record.state)) continue
395
+ const freed = await touch(record.destPath)
396
+ if (freed !== undefined) removedFiles += 1
397
+ freedBytes += freed ?? 0
398
+ }
399
+ }
400
+
401
+ return { scope, removedRecords, removedCacheFiles, removedFiles, freedBytes }
402
+ }
403
+
404
+ // ------------------------------------------------------------------ internals
405
+
406
+ /** @param {string} id */
407
+ #must(id) {
408
+ const record = this.jobs.get(id)
409
+ if (!record) throw new QueueError('not-found', `job ${id} does not exist`)
410
+ return record
411
+ }
412
+
413
+ /** @param {string} id @param {Caller} caller */
414
+ #writable(id, caller) {
415
+ const record = this.#must(id)
416
+ if (!this.#canWrite(record, caller)) {
417
+ throw new QueueError('not-owner', `${id} belongs to another session`)
418
+ }
419
+ return record
420
+ }
421
+
422
+ /** @param {import('./types.js').JobRecord} record @param {Caller} caller */
423
+ #canWrite(record, caller) {
424
+ if (isTerminal(record.state)) return true
425
+ return record.ownerWorkspace === caller.workspace && record.ownerSession === caller.session
426
+ }
427
+
428
+ async #nextSeq() {
429
+ const state = this.domain.global.get()
430
+ const seq = state.nextSeq
431
+ await this.domain.global.set({ nextSeq: seq + 1 })
432
+ return seq
433
+ }
434
+
435
+ /** @param {import('./types.js').JobRecord} record */
436
+ #view(record) {
437
+ const live = this.progress.get(record.id)
438
+ const total = live?.total && live.total > 0 ? live.total : record.bytesTotal
439
+ const done = isTerminal(record.state)
440
+ ? (record.finalBytes ?? record.bytesCheckpoint)
441
+ : (live?.done ?? record.bytesCheckpoint)
442
+
443
+ return compact({
444
+ id: record.id,
445
+ state: record.state,
446
+ pauseReason: record.pauseReason,
447
+ owner: { workspace: record.ownerWorkspace, session: record.ownerSession },
448
+ origin: record.origin,
449
+ urls: record.urls,
450
+ mirrors: this.#mirrorView(record),
451
+ dest: record.destPath,
452
+ bytesTotal: total,
453
+ bytesDone: done,
454
+ speedBps: live?.speed ?? 0,
455
+ etaSeconds: live && live.speed > 0 && total > 0 ? Math.max(0, Math.round((total - done) / live.speed)) : undefined,
456
+ attempts: record.attempts,
457
+ lastError: record.lastError,
458
+ createdAt: record.createdAt,
459
+ updatedAt: record.updatedAt,
460
+ })
461
+ }
462
+
463
+ /** Storage-write and mirror health, surfaced through `download_status`. */
464
+ health() {
465
+ const mirrors = this.mirrorStatus()
466
+ return compact({
467
+ storage: this.storage.errors > 0 ? { ...this.storage } : { errors: 0 },
468
+ mirrorHealth: mirrors.length > 0 ? mirrors : undefined,
469
+ })
470
+ }
471
+
472
+ /**
473
+ * Candidates were recorded at enqueue time; the transport only learns which of
474
+ * them actually proved to serve the same bytes once the transfer starts.
475
+ * @param {import('./types.js').JobRecord} record
476
+ */
477
+ #mirrorView(record) {
478
+ if (!record.mirrors) return undefined
479
+ const report = this.transport?.mirrorReports?.get(record.urls[0])
480
+ return {
481
+ candidates: record.mirrors,
482
+ verified: report?.verified ?? [],
483
+ rejected: report?.rejected ?? [],
484
+ }
485
+ }
486
+
487
+ #schedule() {
488
+ if (this.closed || this.timer) return
489
+ this.timer = setTimeout(() => {
490
+ this.timer = undefined
491
+ void this.#pump()
492
+ }, 0)
493
+ this.timer.unref?.()
494
+ }
495
+
496
+ async #pump() {
497
+ if (this.closed) return
498
+ const queued = [...this.jobs.entries()]
499
+ .map(([, record]) => record)
500
+ .filter((record) => record.state === 'queued')
501
+ .sort((a, b) => a.seq - b.seq)
502
+
503
+ if (queued.length === 0) return
504
+
505
+ // One session hogging every slot starves the others. Only enforce a cap
506
+ // when at least two sessions are actually competing: a lone session keeps
507
+ // the full concurrency it would get anyway.
508
+ const sessionKeyOf = (record) => `${record.ownerWorkspace}::${record.ownerSession}`
509
+ const sessions = new Set(queued.map(sessionKeyOf))
510
+ const cap = sessions.size >= 2 ? Math.max(1, Math.floor(this.config.maxConcurrentJobs / 2)) : Infinity
511
+
512
+ /** @type {Map<string, number>} */
513
+ const running = new Map()
514
+ for (const [id] of this.active) {
515
+ const record = this.jobs.get(id)
516
+ if (!record) continue
517
+ const key = sessionKeyOf(record)
518
+ running.set(key, (running.get(key) ?? 0) + 1)
519
+ }
520
+
521
+ /** @type {Map<string, import('./types.js').JobRecord[]>} */
522
+ const bySession = new Map()
523
+ for (const record of queued) {
524
+ const key = sessionKeyOf(record)
525
+ const list = bySession.get(key)
526
+ if (list) list.push(record)
527
+ else bySession.set(key, [record])
528
+ }
529
+
530
+ // Start each pass after the session that was served last, otherwise the
531
+ // first session in iteration order wins every tie and round-robin is a lie.
532
+ const order = [...bySession.keys()]
533
+ const startAt = this.lastServed && order.includes(this.lastServed) ? (order.indexOf(this.lastServed) + 1) % order.length : 0
534
+ const rotated = [...order.slice(startAt), ...order.slice(0, startAt)]
535
+
536
+ let progressed = true
537
+ while (progressed && this.active.size < this.config.maxConcurrentJobs) {
538
+ progressed = false
539
+ for (const key of rotated) {
540
+ if (this.active.size >= this.config.maxConcurrentJobs) break
541
+ const list = bySession.get(key)
542
+ if (!list || list.length === 0) continue
543
+ if ((running.get(key) ?? 0) >= cap) continue
544
+ const record = list.shift()
545
+ if (this.active.has(record.id)) continue
546
+ running.set(key, (running.get(key) ?? 0) + 1)
547
+ this.lastServed = key
548
+ void this.#run(record)
549
+ progressed = true
550
+ }
551
+ }
552
+ }
553
+
554
+ /** @param {import('./types.js').JobRecord} record */
555
+ async #run(record) {
556
+ const id = record.id
557
+ const controller = new AbortController()
558
+ this.active.set(id, { controller, startedAt: this.now() })
559
+
560
+ record.state = 'running'
561
+ record.updatedAt = this.now()
562
+ await this.#persist(id, record)
563
+
564
+ const stats = { done: record.bytesCheckpoint, total: record.bytesTotal, speed: 0, lastDone: record.bytesCheckpoint, lastAt: this.now() }
565
+ this.progress.set(id, stats)
566
+
567
+ const urls = this.#orderUrls(record.urls)
568
+
569
+ try {
570
+ const result = await this.transport.transfer({
571
+ urls,
572
+ destPath: record.destPath,
573
+ connectionsCap: this.config.connectionsPerJob,
574
+ speedLimitKbps: this.config.speedLimitKbps,
575
+ bytesDone: record.bytesCheckpoint,
576
+ signal: controller.signal,
577
+ onUrlResult: (url, ok) => {
578
+ if (ok) this.noteUrlSuccess(url)
579
+ else this.noteUrlFailure(url)
580
+ },
581
+ onProgress: (done, total) => {
582
+ const stamp = this.now()
583
+ const elapsed = (stamp - stats.lastAt) / 1000
584
+ if (elapsed >= 0.4) {
585
+ stats.speed = Math.max(0, (done - stats.lastDone) / elapsed)
586
+ stats.lastDone = done
587
+ stats.lastAt = stamp
588
+ }
589
+ stats.done = done
590
+ if (total > 0) stats.total = total
591
+ },
592
+ })
593
+
594
+ if (record.expectSha256) {
595
+ const actual = await sha256File(record.destPath)
596
+ if (actual.toLowerCase() !== record.expectSha256.toLowerCase()) {
597
+ throw new Error(`sha256 mismatch: expected ${record.expectSha256}, got ${actual}`)
598
+ }
599
+ }
600
+
601
+ await this.#finish(id, 'completed', {
602
+ bytesTotal: result.total > 0 ? result.total : stats.total,
603
+ bytesCheckpoint: result.bytes,
604
+ finalBytes: result.bytes,
605
+ })
606
+ } catch (error) {
607
+ const aborted = controller.signal.aborted
608
+ if (aborted) {
609
+ // pause / cancel / close already recorded the target state
610
+ if (!isTerminal(record.state)) {
611
+ await this.#stopAs(id, 'host_restart')
612
+ }
613
+ } else {
614
+ await this.#fail(id, error)
615
+ }
616
+ } finally {
617
+ this.active.delete(id)
618
+ this.progress.delete(id)
619
+ this.#schedule()
620
+ }
621
+ }
622
+
623
+ /** @param {string} id @param {Error} error */
624
+ async #fail(id, error) {
625
+ const record = this.jobs.get(id)
626
+ if (!record || isTerminal(record.state)) return
627
+
628
+ record.attempts += 1
629
+ record.lastError = error?.message ?? String(error)
630
+ record.updatedAt = this.now()
631
+
632
+ if (record.attempts < MAX_ATTEMPTS) {
633
+ record.state = 'queued'
634
+ record.pauseReason = undefined
635
+ this.log('warn', `${id} failed (attempt ${record.attempts}/${MAX_ATTEMPTS}), retrying: ${record.lastError}`)
636
+ } else {
637
+ record.state = 'failed'
638
+ this.log('warn', `${id} failed permanently: ${record.lastError}`)
639
+ }
640
+ await this.#persist(id, record)
641
+ }
642
+
643
+ /** @param {string} id @param {'user'|'session_closed'|'host_restart'} reason */
644
+ async #stopAs(id, reason) {
645
+ const record = this.jobs.get(id)
646
+ if (!record || isTerminal(record.state)) return
647
+ const stats = this.progress.get(id)
648
+ record.state = 'paused'
649
+ record.pauseReason = reason
650
+ if (stats) record.bytesCheckpoint = stats.done
651
+ record.updatedAt = this.now()
652
+ await this.#persist(id, record)
653
+ }
654
+
655
+ /**
656
+ * @param {string} id
657
+ * @param {'completed'} state
658
+ * @param {Partial<import('./types.js').JobRecord>} patch
659
+ */
660
+ async #finish(id, state, patch) {
661
+ const record = this.jobs.get(id)
662
+ if (!record) return
663
+ Object.assign(record, patch, { state, updatedAt: this.now() })
664
+ delete record.pauseReason
665
+ await this.#persist(id, record)
666
+ this.log('info', `${id} ${state} (${record.finalBytes ?? record.bytesCheckpoint} bytes)`)
667
+ }
668
+
669
+ async #checkpointAll() {
670
+ for (const [id] of this.active) {
671
+ const stats = this.progress.get(id)
672
+ const record = this.jobs.get(id)
673
+ if (!stats || !record || record.state !== 'running') continue
674
+ if (stats.done <= record.bytesCheckpoint) continue
675
+ record.bytesCheckpoint = stats.done
676
+ record.bytesTotal = stats.total > 0 ? stats.total : record.bytesTotal
677
+ record.updatedAt = this.now()
678
+ try {
679
+ await this.#persist(id, record)
680
+ } catch (error) {
681
+ // A failed checkpoint means the next resume restarts from a stale
682
+ // offset, which corrupts the file. Report it instead of swallowing it.
683
+ this.#noteStorageFailure(id, error)
684
+ }
685
+ }
686
+ }
687
+
688
+ /** @param {string} id @param {unknown} error */
689
+ #noteStorageFailure(id, error) {
690
+ const message = error instanceof Error ? error.message : String(error)
691
+ this.storage.errors += 1
692
+ this.storage.lastError = `${id}: ${message}`
693
+ this.storage.lastAt = this.now()
694
+ if (this.storage.errors === 3) {
695
+ this.log('error', `checkpoint writes are failing (${message}); resume offsets may be stale`)
696
+ }
697
+ }
698
+
699
+ /**
700
+ * Persist a lifecycle transition. Unlike the user-facing mutations, these run
701
+ * on the scheduler and during shutdown: a broken storage must not turn into an
702
+ * unhandled rejection that takes the whole plugin down with it.
703
+ * @param {string} id @param {import('./types.js').JobRecord} record
704
+ */
705
+ async #persist(id, record) {
706
+ try {
707
+ await this.jobs.put(id, record)
708
+ return true
709
+ } catch (error) {
710
+ this.#noteStorageFailure(id, error)
711
+ return false
712
+ }
713
+ }
714
+
715
+ /** @param {import('./types.js').JobRecord} record */
716
+ async #dropPartialFiles(record) {
717
+ for (const file of [`${record.destPath}.part`, `${record.destPath}.aria2`]) {
718
+ await fs.rm(file, { force: true }).catch(() => {})
719
+ }
720
+ }
721
+
722
+ async #cancelOrphans() {
723
+ if (!this.isOwnerAlive) {
724
+ this.log('info', 'orphan scan skipped (no session probe wired in yet)')
725
+ return
726
+ }
727
+ for (const [, record] of [...this.jobs.entries()]) {
728
+ if (isTerminal(record.state)) continue
729
+ if (await this.isOwnerAlive(record.ownerSession)) continue
730
+ record.state = 'canceled'
731
+ record.lastError = 'owner session no longer exists'
732
+ record.updatedAt = this.now()
733
+ await this.#persist(record.id, record)
734
+ await this.#dropPartialFiles(record)
735
+ this.log('info', `${record.id} canceled: owner session is gone`)
736
+ }
737
+ }
738
+
739
+ /**
740
+ * Refuse a destination whose directory we cannot write to, before the download
741
+ * starts. A sandbox can make a path readable but not writable; discovering that
742
+ * at the first byte wastes the whole queue slot.
743
+ * @param {string} destPath
744
+ */
745
+ async #assertDestWritable(destPath) {
746
+ let dir = path.dirname(destPath)
747
+ // Walk up to the nearest directory that exists: an absent leaf is fine,
748
+ // it gets created at transfer time.
749
+ for (;;) {
750
+ try {
751
+ await fs.access(dir, fs.constants?.W_OK ?? 2)
752
+ return
753
+ } catch (error) {
754
+ const code = error?.code
755
+ if (code === 'ENOENT') {
756
+ const parent = path.dirname(dir)
757
+ if (parent === dir) return
758
+ dir = parent
759
+ continue
760
+ }
761
+ if (code === 'EACCES' || code === 'EPERM') {
762
+ throw new QueueError('permission-denied', `cannot write to ${path.dirname(destPath)} (${code})`)
763
+ }
764
+ // Anything else (e.g. an exotic filesystem) is not a reason to block.
765
+ return
766
+ }
767
+ }
768
+ }
769
+
770
+ /**
771
+ * Cheap guard against enqueueing onto a full disk. Sums what already-queued
772
+ * jobs are expected to consume, so twenty small enqueues cannot add up to a
773
+ * full disk one "looks fine" at a time.
774
+ * @param {string} destPath
775
+ * @param {number} [expectedBytes]
776
+ */
777
+ async #assertDiskSpace(destPath, expectedBytes) {
778
+ const committed = [...this.jobs.entries()]
779
+ .map(([, record]) => record)
780
+ .filter((record) => !isTerminal(record.state))
781
+ .reduce((sum, record) => {
782
+ const remaining = record.bytesTotal > 0 ? record.bytesTotal - record.bytesCheckpoint : UNKNOWN_SIZE_RESERVE
783
+ return sum + Math.max(0, remaining)
784
+ }, 0)
785
+
786
+ const reserve = Number.isFinite(expectedBytes) && expectedBytes > 0 ? expectedBytes : UNKNOWN_SIZE_RESERVE
787
+ const needed = MIN_FREE_BYTES + committed + reserve
788
+
789
+ try {
790
+ const stats = await fs.statfs(path.dirname(destPath))
791
+ const free = Number(stats.bavail) * Number(stats.bsize)
792
+ if (Number.isFinite(free) && free < needed) {
793
+ const shortfall = needed - free
794
+ throw new QueueError(
795
+ 'disk-full',
796
+ `need ${Math.ceil(needed / 1048576)} MB free for ${path.dirname(destPath)} ` +
797
+ `(512 MB reserve + ${Math.ceil(committed / 1048576)} MB queued + ${Math.ceil(reserve / 1048576)} MB this job), ` +
798
+ `only ${Math.floor(free / 1048576)} MB available, short by ${Math.ceil(shortfall / 1048576)} MB`,
799
+ )
800
+ }
801
+ } catch (error) {
802
+ if (error instanceof QueueError) throw error
803
+ /* statfs is unavailable on some platforms; do not block the enqueue */
804
+ }
805
+ }
806
+
807
+ // ------------------------------------------------------------------- mirrors
808
+
809
+ /**
810
+ * Mark a URL host as failed. Two consecutive failures park it for a while so a
811
+ * dead mirror is not retried on every job; the cooldown is in memory only, so
812
+ * a restart clears it rather than turning a blip into a permanent ban.
813
+ * @param {string} url
814
+ */
815
+ noteUrlFailure(url) {
816
+ const host = hostOf(url)
817
+ if (!host) return
818
+ const entry = this.mirrorHealth.get(host) ?? { fails: 0, cooldownUntil: 0 }
819
+ entry.fails += 1
820
+ if (entry.fails >= MIRROR_FAIL_THRESHOLD) {
821
+ entry.cooldownUntil = this.now() + MIRROR_COOLDOWN_MS
822
+ this.log('warn', `mirror ${host} cooling down for ${MIRROR_COOLDOWN_MS / 60000} min after ${entry.fails} failures`)
823
+ }
824
+ this.mirrorHealth.set(host, entry)
825
+ }
826
+
827
+ /** @param {string} url */
828
+ noteUrlSuccess(url) {
829
+ const host = hostOf(url)
830
+ if (!host) return
831
+ this.mirrorHealth.delete(host)
832
+ }
833
+
834
+ /** Hosts currently in cooldown, for `download_status`. */
835
+ mirrorStatus() {
836
+ const out = []
837
+ for (const [host, entry] of this.mirrorHealth) {
838
+ if (entry.cooldownUntil > this.now()) {
839
+ out.push({ host, fails: entry.fails, cooldownUntil: entry.cooldownUntil })
840
+ }
841
+ }
842
+ return out
843
+ }
844
+
845
+ /** Reorder URLs so cooling hosts are tried last. @param {string[]} urls */
846
+ #orderUrls(urls) {
847
+ const stamp = this.now()
848
+ const warm = []
849
+ const cold = []
850
+ for (const url of urls) {
851
+ const host = hostOf(url)
852
+ const entry = host ? this.mirrorHealth.get(host) : undefined
853
+ if (entry && entry.cooldownUntil > stamp) cold.push(url)
854
+ else warm.push(url)
855
+ }
856
+ return cold.length === 0 ? urls : [...warm, ...cold]
857
+ }
858
+ }
859
+
860
+ /** @param {string} file */
861
+ async function sha256File(file) {
862
+ const hash = createHash('sha256')
863
+ await new Promise((resolve, reject) => {
864
+ createReadStream(file)
865
+ .on('data', (chunk) => hash.update(chunk))
866
+ .on('end', resolve)
867
+ .on('error', reject)
868
+ })
869
+ return hash.digest('hex')
870
+ }