dsh-dl-server 0.4.0 → 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,893 +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'
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
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 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
- mirrorHealth: mirrors.length > 0 ? mirrors : undefined,
485
- })
486
- }
487
-
488
- /**
489
- * Candidates were recorded at enqueue time; the transport only learns which of
490
- * them actually proved to serve the same bytes once the transfer starts.
491
- * @param {import('./types.js').JobRecord} record
492
- */
493
- #mirrorView(record) {
494
- if (!record.mirrors) return undefined
495
- const report = this.transport?.mirrorReports?.get(record.urls[0])
496
- return {
497
- candidates: record.mirrors,
498
- verified: report?.verified ?? [],
499
- rejected: report?.rejected ?? [],
500
- }
501
- }
502
-
503
- #schedule() {
504
- if (this.closed || this.timer) return
505
- this.timer = setTimeout(() => {
506
- this.timer = undefined
507
- void this.#pump()
508
- }, 0)
509
- this.timer.unref?.()
510
- }
511
-
512
- async #pump() {
513
- if (this.closed) return
514
- const queued = [...this.jobs.entries()]
515
- .map(([, record]) => record)
516
- .filter((record) => record.state === 'queued')
517
- .sort((a, b) => a.seq - b.seq)
518
-
519
- if (queued.length === 0) return
520
-
521
- // One session hogging every slot starves the others. Only enforce a cap
522
- // when at least two sessions are actually competing: a lone session keeps
523
- // the full concurrency it would get anyway.
524
- const sessionKeyOf = (record) => `${record.ownerWorkspace}::${record.ownerSession}`
525
- const sessions = new Set(queued.map(sessionKeyOf))
526
- const cap = sessions.size >= 2 ? Math.max(1, Math.floor(this.config.maxConcurrentJobs / 2)) : Infinity
527
-
528
- /** @type {Map<string, number>} */
529
- const running = new Map()
530
- for (const [id] of this.active) {
531
- const record = this.jobs.get(id)
532
- if (!record) continue
533
- const key = sessionKeyOf(record)
534
- running.set(key, (running.get(key) ?? 0) + 1)
535
- }
536
-
537
- /** @type {Map<string, import('./types.js').JobRecord[]>} */
538
- const bySession = new Map()
539
- for (const record of queued) {
540
- const key = sessionKeyOf(record)
541
- const list = bySession.get(key)
542
- if (list) list.push(record)
543
- else bySession.set(key, [record])
544
- }
545
-
546
- // Start each pass after the session that was served last, otherwise the
547
- // first session in iteration order wins every tie and round-robin is a lie.
548
- const order = [...bySession.keys()]
549
- const startAt = this.lastServed && order.includes(this.lastServed) ? (order.indexOf(this.lastServed) + 1) % order.length : 0
550
- const rotated = [...order.slice(startAt), ...order.slice(0, startAt)]
551
-
552
- let progressed = true
553
- while (progressed && this.active.size < this.config.maxConcurrentJobs) {
554
- progressed = false
555
- for (const key of rotated) {
556
- if (this.active.size >= this.config.maxConcurrentJobs) break
557
- const list = bySession.get(key)
558
- if (!list || list.length === 0) continue
559
- if ((running.get(key) ?? 0) >= cap) continue
560
- const record = list.shift()
561
- if (this.active.has(record.id)) continue
562
- running.set(key, (running.get(key) ?? 0) + 1)
563
- this.lastServed = key
564
- void this.#run(record)
565
- progressed = true
566
- }
567
- }
568
- }
569
-
570
- /** @param {import('./types.js').JobRecord} record */
571
- async #run(record) {
572
- const id = record.id
573
- const controller = new AbortController()
574
- this.active.set(id, { controller, startedAt: this.now() })
575
-
576
- record.state = 'running'
577
- record.updatedAt = this.now()
578
- await this.#persist(id, record)
579
-
580
- const stats = { done: record.bytesCheckpoint, total: record.bytesTotal, speed: 0, lastDone: record.bytesCheckpoint, lastAt: this.now() }
581
- this.progress.set(id, stats)
582
-
583
- const urls = this.#orderUrls(record.urls)
584
-
585
- try {
586
- const result = await this.transport.transfer({
587
- urls,
588
- destPath: record.destPath,
589
- headers: record.headers,
590
- connectionsCap: this.config.connectionsPerJob,
591
- speedLimitKbps: this.config.speedLimitKbps,
592
- bytesDone: record.bytesCheckpoint,
593
- signal: controller.signal,
594
- onUrlResult: (url, ok) => {
595
- if (ok) this.noteUrlSuccess(url)
596
- else this.noteUrlFailure(url)
597
- },
598
- onProgress: (done, total) => {
599
- const stamp = this.now()
600
- const elapsed = (stamp - stats.lastAt) / 1000
601
- if (elapsed >= 0.4) {
602
- stats.speed = Math.max(0, (done - stats.lastDone) / elapsed)
603
- stats.lastDone = done
604
- stats.lastAt = stamp
605
- }
606
- stats.done = done
607
- if (total > 0) stats.total = total
608
- },
609
- })
610
-
611
- if (record.expectSha256) {
612
- const actual = await sha256File(record.destPath)
613
- if (actual.toLowerCase() !== record.expectSha256.toLowerCase()) {
614
- throw new Error(`sha256 mismatch: expected ${record.expectSha256}, got ${actual}`)
615
- }
616
- }
617
-
618
- await this.#finish(id, 'completed', {
619
- bytesTotal: result.total > 0 ? result.total : stats.total,
620
- bytesCheckpoint: result.bytes,
621
- finalBytes: result.bytes,
622
- })
623
- } catch (error) {
624
- const aborted = controller.signal.aborted
625
- if (aborted) {
626
- // pause / cancel / close already recorded the target state. Only park it
627
- // here when nobody else transitioned the job — a resume that landed
628
- // while this transfer was unwinding must not be undone.
629
- if (record.state === 'running') {
630
- await this.#stopAs(id, 'host_restart')
631
- }
632
- } else {
633
- await this.#fail(id, error)
634
- }
635
- } finally {
636
- this.active.delete(id)
637
- this.progress.delete(id)
638
- this.#schedule()
639
- }
640
- }
641
-
642
- /** @param {string} id @param {Error} error */
643
- async #fail(id, error) {
644
- const record = this.jobs.get(id)
645
- if (!record || isTerminal(record.state)) return
646
-
647
- record.attempts += 1
648
- record.lastError = error?.message ?? String(error)
649
- record.updatedAt = this.now()
650
-
651
- if (record.attempts < MAX_ATTEMPTS) {
652
- record.state = 'queued'
653
- record.pauseReason = undefined
654
- this.log('warn', `${id} failed (attempt ${record.attempts}/${MAX_ATTEMPTS}), retrying: ${record.lastError}`)
655
- } else {
656
- record.state = 'failed'
657
- this.log('warn', `${id} failed permanently: ${record.lastError}`)
658
- }
659
- await this.#persist(id, record)
660
- }
661
-
662
- /** @param {string} id @param {'user'|'session_closed'|'host_restart'} reason */
663
- async #stopAs(id, reason) {
664
- const record = this.jobs.get(id)
665
- if (!record || isTerminal(record.state)) return
666
- // Only an in-flight job may be stopped. A pause already parked it, and a
667
- // resume may have re-queued it while the aborted transfer was still
668
- // unwinding — overwriting that would leave the job stuck in `paused`.
669
- if (record.state !== 'running' && record.state !== 'queued') return
670
- const stats = this.progress.get(id)
671
- record.state = 'paused'
672
- record.pauseReason = reason
673
- if (stats) record.bytesCheckpoint = stats.done
674
- record.updatedAt = this.now()
675
- await this.#persist(id, record)
676
- }
677
-
678
- /**
679
- * @param {string} id
680
- * @param {'completed'} state
681
- * @param {Partial<import('./types.js').JobRecord>} patch
682
- */
683
- async #finish(id, state, patch) {
684
- const record = this.jobs.get(id)
685
- if (!record) return
686
- Object.assign(record, patch, { state, updatedAt: this.now() })
687
- delete record.pauseReason
688
- await this.#persist(id, record)
689
- this.log('info', `${id} ${state} (${record.finalBytes ?? record.bytesCheckpoint} bytes)`)
690
- }
691
-
692
- async #checkpointAll() {
693
- for (const [id] of this.active) {
694
- const stats = this.progress.get(id)
695
- const record = this.jobs.get(id)
696
- if (!stats || !record || record.state !== 'running') continue
697
- if (stats.done <= record.bytesCheckpoint) continue
698
- record.bytesCheckpoint = stats.done
699
- record.bytesTotal = stats.total > 0 ? stats.total : record.bytesTotal
700
- record.updatedAt = this.now()
701
- try {
702
- await this.#persist(id, record)
703
- } catch (error) {
704
- // A failed checkpoint means the next resume restarts from a stale
705
- // offset, which corrupts the file. Report it instead of swallowing it.
706
- this.#noteStorageFailure(id, error)
707
- }
708
- }
709
- }
710
-
711
- /** @param {string} id @param {unknown} error */
712
- #noteStorageFailure(id, error) {
713
- const message = error instanceof Error ? error.message : String(error)
714
- this.storage.errors += 1
715
- this.storage.lastError = `${id}: ${message}`
716
- this.storage.lastAt = this.now()
717
- if (this.storage.errors === 3) {
718
- this.log('error', `checkpoint writes are failing (${message}); resume offsets may be stale`)
719
- }
720
- }
721
-
722
- /**
723
- * Persist a lifecycle transition. Unlike the user-facing mutations, these run
724
- * on the scheduler and during shutdown: a broken storage must not turn into an
725
- * unhandled rejection that takes the whole plugin down with it.
726
- * @param {string} id @param {import('./types.js').JobRecord} record
727
- */
728
- async #persist(id, record) {
729
- try {
730
- await this.jobs.put(id, record)
731
- return true
732
- } catch (error) {
733
- this.#noteStorageFailure(id, error)
734
- return false
735
- }
736
- }
737
-
738
- /** @param {import('./types.js').JobRecord} record */
739
- async #dropPartialFiles(record) {
740
- for (const file of [`${record.destPath}.part`, `${record.destPath}.aria2`]) {
741
- await fs.rm(file, { force: true }).catch(() => {})
742
- }
743
- }
744
-
745
- async #cancelOrphans() {
746
- if (!this.isOwnerAlive) {
747
- this.log('info', 'orphan scan skipped (no session probe wired in yet)')
748
- return
749
- }
750
- for (const [, record] of [...this.jobs.entries()]) {
751
- if (isTerminal(record.state)) continue
752
- if (await this.isOwnerAlive(record.ownerSession)) continue
753
- record.state = 'canceled'
754
- record.lastError = 'owner session no longer exists'
755
- record.updatedAt = this.now()
756
- await this.#persist(record.id, record)
757
- await this.#dropPartialFiles(record)
758
- this.log('info', `${record.id} canceled: owner session is gone`)
759
- }
760
- }
761
-
762
- /**
763
- * Refuse a destination whose directory we cannot write to, before the download
764
- * starts. A sandbox can make a path readable but not writable; discovering that
765
- * at the first byte wastes the whole queue slot.
766
- * @param {string} destPath
767
- */
768
- async #assertDestWritable(destPath) {
769
- let dir = path.dirname(destPath)
770
- // Walk up to the nearest directory that exists: an absent leaf is fine,
771
- // it gets created at transfer time.
772
- for (;;) {
773
- try {
774
- await fs.access(dir, fs.constants?.W_OK ?? 2)
775
- return
776
- } catch (error) {
777
- const code = error?.code
778
- if (code === 'ENOENT') {
779
- const parent = path.dirname(dir)
780
- if (parent === dir) return
781
- dir = parent
782
- continue
783
- }
784
- if (code === 'EACCES' || code === 'EPERM') {
785
- throw new QueueError('permission-denied', `cannot write to ${path.dirname(destPath)} (${code})`)
786
- }
787
- // Anything else (e.g. an exotic filesystem) is not a reason to block.
788
- return
789
- }
790
- }
791
- }
792
-
793
- /**
794
- * Cheap guard against enqueueing onto a full disk. Sums what already-queued
795
- * jobs are expected to consume, so twenty small enqueues cannot add up to a
796
- * full disk one "looks fine" at a time.
797
- * @param {string} destPath
798
- * @param {number} [expectedBytes]
799
- */
800
- async #assertDiskSpace(destPath, expectedBytes) {
801
- const committed = [...this.jobs.entries()]
802
- .map(([, record]) => record)
803
- .filter((record) => !isTerminal(record.state))
804
- .reduce((sum, record) => {
805
- const remaining = record.bytesTotal > 0 ? record.bytesTotal - record.bytesCheckpoint : UNKNOWN_SIZE_RESERVE
806
- return sum + Math.max(0, remaining)
807
- }, 0)
808
-
809
- const reserve = Number.isFinite(expectedBytes) && expectedBytes > 0 ? expectedBytes : UNKNOWN_SIZE_RESERVE
810
- const needed = MIN_FREE_BYTES + committed + reserve
811
-
812
- try {
813
- const stats = await fs.statfs(path.dirname(destPath))
814
- const free = Number(stats.bavail) * Number(stats.bsize)
815
- if (Number.isFinite(free) && free < needed) {
816
- const shortfall = needed - free
817
- throw new QueueError(
818
- 'disk-full',
819
- `need ${Math.ceil(needed / 1048576)} MB free for ${path.dirname(destPath)} ` +
820
- `(512 MB reserve + ${Math.ceil(committed / 1048576)} MB queued + ${Math.ceil(reserve / 1048576)} MB this job), ` +
821
- `only ${Math.floor(free / 1048576)} MB available, short by ${Math.ceil(shortfall / 1048576)} MB`,
822
- )
823
- }
824
- } catch (error) {
825
- if (error instanceof QueueError) throw error
826
- /* statfs is unavailable on some platforms; do not block the enqueue */
827
- }
828
- }
829
-
830
- // ------------------------------------------------------------------- mirrors
831
-
832
- /**
833
- * Mark a URL host as failed. Two consecutive failures park it for a while so a
834
- * dead mirror is not retried on every job; the cooldown is in memory only, so
835
- * a restart clears it rather than turning a blip into a permanent ban.
836
- * @param {string} url
837
- */
838
- noteUrlFailure(url) {
839
- const host = hostOf(url)
840
- if (!host) return
841
- const entry = this.mirrorHealth.get(host) ?? { fails: 0, cooldownUntil: 0 }
842
- entry.fails += 1
843
- if (entry.fails >= MIRROR_FAIL_THRESHOLD) {
844
- entry.cooldownUntil = this.now() + MIRROR_COOLDOWN_MS
845
- this.log('warn', `mirror ${host} cooling down for ${MIRROR_COOLDOWN_MS / 60000} min after ${entry.fails} failures`)
846
- }
847
- this.mirrorHealth.set(host, entry)
848
- }
849
-
850
- /** @param {string} url */
851
- noteUrlSuccess(url) {
852
- const host = hostOf(url)
853
- if (!host) return
854
- this.mirrorHealth.delete(host)
855
- }
856
-
857
- /** Hosts currently in cooldown, for `download_status`. */
858
- mirrorStatus() {
859
- const out = []
860
- for (const [host, entry] of this.mirrorHealth) {
861
- if (entry.cooldownUntil > this.now()) {
862
- out.push({ host, fails: entry.fails, cooldownUntil: entry.cooldownUntil })
863
- }
864
- }
865
- return out
866
- }
867
-
868
- /** Reorder URLs so cooling hosts are tried last. @param {string[]} urls */
869
- #orderUrls(urls) {
870
- const stamp = this.now()
871
- const warm = []
872
- const cold = []
873
- for (const url of urls) {
874
- const host = hostOf(url)
875
- const entry = host ? this.mirrorHealth.get(host) : undefined
876
- if (entry && entry.cooldownUntil > stamp) cold.push(url)
877
- else warm.push(url)
878
- }
879
- return cold.length === 0 ? urls : [...warm, ...cold]
880
- }
881
- }
882
-
883
- /** @param {string} file */
884
- async function sha256File(file) {
885
- const hash = createHash('sha256')
886
- await new Promise((resolve, reject) => {
887
- createReadStream(file)
888
- .on('data', (chunk) => hash.update(chunk))
889
- .on('end', resolve)
890
- .on('error', reject)
891
- })
892
- return hash.digest('hex')
893
- }
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
+ }