dsh-dl-server 0.3.1 → 0.4.1

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