pinokiod 8.0.40 → 8.0.41

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.
@@ -0,0 +1,740 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const fastq = require('fastq')
4
+ const { walkBatches, statMany } = require('./walker')
5
+ const { fileSnapshot, sameSnapshot, sameContentState } = require('./snapshot')
6
+ const { SHA256_RE, TMP_SUFFIX, DIR_CONCURRENCY, STAT_CONCURRENCY, HASH_QUEUE_LIMIT } = require('./constants')
7
+
8
+ const isMissingError = (error) => !!(error && (error.code === "ENOENT" || error.code === "ENOTDIR"))
9
+ const isAccessError = (error) => !!(error && (error.code === "EACCES" || error.code === "EPERM"))
10
+ const isPathWithin = (root, target) => {
11
+ const rel = path.relative(path.resolve(root), path.resolve(target))
12
+ return rel === "" || (!rel.startsWith(`..${path.sep}`) && rel !== ".." && !path.isAbsolute(rel))
13
+ }
14
+ const readFileNoFollow = async (filePath) => {
15
+ const before = await fs.promises.lstat(filePath)
16
+ if (!before.isFile()) return null
17
+ const handle = await fs.promises.open(
18
+ filePath,
19
+ fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)
20
+ )
21
+ try {
22
+ const opened = await handle.stat()
23
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) return null
24
+ return await handle.readFile("utf8")
25
+ } finally {
26
+ await handle.close()
27
+ }
28
+ }
29
+
30
+ // Manual scan engine (spec/requirements/shared-model-store.md).
31
+ // Scans run ONLY when the user asks — there are no automatic triggers.
32
+ // The walk is generic: every regular file counts toward folder totals, and
33
+ // every file >= the size threshold is a candidate. No name heuristics.
34
+ // A scan never converts anything: it adopts new content (adds a vault name,
35
+ // which mutates nothing) and lists byte-identical copies as pending, for the
36
+ // user to deduplicate explicitly.
37
+
38
+ class Sweeper {
39
+ constructor(vault) {
40
+ this.vault = vault
41
+ this.kernel = vault.kernel
42
+ this.state = this.idleState()
43
+ this.currentHash = null
44
+ this.hashQueue = fastq.promise(this, this._hashJob, 1)
45
+ this.statConcurrency = vault.statConcurrency || STAT_CONCURRENCY
46
+ this.dirConcurrency = vault.dirConcurrency || DIR_CONCURRENCY
47
+ this.hashQueueLimit = vault.hashQueueLimit || HASH_QUEUE_LIMIT
48
+ this.hashCapacityWaiters = []
49
+ this.metadataBaseCache = new Map()
50
+ this.hashJobsByIno = new Map()
51
+ this.completedHashesByIno = new Map()
52
+ this.inaccessiblePaths = new Set()
53
+ this.inaccessibleCodes = new Map()
54
+ }
55
+ idleState() {
56
+ return {
57
+ active: false, phase: "idle", dirs: 0, files: 0, bytes_total: 0,
58
+ counted_dirs: 0, counted_files: 0, total_files: null, count_estimate_files: null,
59
+ home_bytes_total: 0, source_bytes: {}, source_files: {}, source_hash_failures: {}, scope_id: null,
60
+ candidates: 0, hashed: 0, hash_total: 0, hash_bytes: 0, queued: 0,
61
+ inode_reuses: 0, unstable_hashes: 0, hash_failures: 0,
62
+ inaccessible: 0, inaccessible_paths: [],
63
+ estimated_count_weight: 0.4, estimated_walk_weight: 0.5,
64
+ started: null, duration_ms: null,
65
+ count_duration_ms: null, walk_duration_ms: null, hash_wait_duration_ms: null,
66
+ hash_duration_ms: 0
67
+ }
68
+ }
69
+ // A global scan covers Pinokio plus explicit imports. A scoped scan covers
70
+ // exactly one physical source (used by an app's Save space page).
71
+ async scan(scopeId = null) {
72
+ if (this.state.active) return { already_running: true }
73
+ this.state = Object.assign(this.idleState(), {
74
+ active: true, phase: "counting", started: Date.now(), scope_id: scopeId
75
+ })
76
+ this.metadataBaseCache.clear()
77
+ this.hashJobsByIno.clear()
78
+ this.completedHashesByIno.clear()
79
+ this.inaccessiblePaths.clear()
80
+ this.inaccessibleCodes.clear()
81
+ this.fatalError = null
82
+ const registryWasDirty = !!(
83
+ this.vault.registry.persistDirty || this.vault.registry.persistTimer
84
+ )
85
+ const registrySnapshot = this.vault.registry.snapshotState()
86
+ this.vault.registry.beginBatch()
87
+ this.vault.beginScanDraft()
88
+ let completed = false
89
+ let incomplete = false
90
+ let rollbackMetadataChanged = false
91
+ try {
92
+ await this.vault.refreshSources()
93
+ if (!scopeId) this.vault.reconcileConfiguredSources()
94
+ if (!scopeId) {
95
+ for (const source of this.vault.sources()) {
96
+ if (source.kind === "external" && source.available === false && source.root) {
97
+ this.recordUnavailable(source.root, source.error_code)
98
+ }
99
+ }
100
+ }
101
+ const scanRoots = this.vault.scanRoots(scopeId)
102
+ if (!scanRoots.length) throw new Error("That scan location is no longer available.")
103
+ const previous = this.vault.registry.scanFor(scopeId)
104
+ if (previous) {
105
+ if (Number.isFinite(previous.files) && previous.files > 0) {
106
+ this.state.count_estimate_files = previous.files
107
+ }
108
+ const duration = Math.max(0, Number(previous.duration_ms) || 0)
109
+ const walkDuration = Math.max(0, Number(previous.walk_duration_ms) || 0)
110
+ const waitDuration = Math.max(0, Number(previous.hash_wait_duration_ms) || 0)
111
+ const countDuration = Number.isFinite(previous.count_duration_ms)
112
+ ? Math.max(0, previous.count_duration_ms)
113
+ : Math.max(0, duration - walkDuration - waitDuration)
114
+ const finishDuration = Math.max(1, duration - countDuration - walkDuration)
115
+ const measuredDuration = countDuration + walkDuration + finishDuration
116
+ if (measuredDuration > 1) {
117
+ this.state.estimated_count_weight = countDuration / measuredDuration
118
+ this.state.estimated_walk_weight = walkDuration / measuredDuration
119
+ }
120
+ }
121
+ const countStarted = Date.now()
122
+ for (const source of scanRoots) await this.countFiles(source.root)
123
+ this.state.count_duration_ms = Date.now() - countStarted
124
+ this.state.total_files = this.state.counted_files
125
+ this.state.phase = "discovering"
126
+ const walkStarted = Date.now()
127
+ for (const source of scanRoots) {
128
+ if (!Object.prototype.hasOwnProperty.call(this.state.source_bytes, source.source_id)) {
129
+ this.state.source_bytes[source.source_id] = 0
130
+ }
131
+ await this.walk(source.root, source.source_id === "pinokio" ? null : source.source_id)
132
+ }
133
+ this.state.walk_duration_ms = Date.now() - walkStarted
134
+ this.state.phase = "analyzing"
135
+ const hashWaitStarted = Date.now()
136
+ await this.settle()
137
+ this.state.hash_wait_duration_ms = Date.now() - hashWaitStarted
138
+ const scanMeta = {
139
+ ts: Date.now(),
140
+ complete: true,
141
+ candidate_min_bytes: this.vault.sizeThreshold,
142
+ dirs: this.state.dirs,
143
+ files: this.state.files,
144
+ bytes_total: this.state.bytes_total,
145
+ home_bytes_total: this.state.home_bytes_total,
146
+ source_bytes: Object.assign({}, this.state.source_bytes),
147
+ source_files: Object.assign({}, this.state.source_files),
148
+ source_hash_failures: Object.assign({}, this.state.source_hash_failures),
149
+ candidates: this.state.candidates,
150
+ hashed: this.state.hashed,
151
+ hash_total: this.state.hash_total,
152
+ hash_bytes: this.state.hash_bytes,
153
+ inode_reuses: this.state.inode_reuses,
154
+ unstable_hashes: this.state.unstable_hashes,
155
+ hash_failures: this.state.hash_failures,
156
+ count_duration_ms: this.state.count_duration_ms,
157
+ walk_duration_ms: this.state.walk_duration_ms,
158
+ hash_wait_duration_ms: this.state.hash_wait_duration_ms,
159
+ hash_duration_ms: this.state.hash_duration_ms,
160
+ unreadable: []
161
+ }
162
+ // Refresh dead-name and orphan state from filesystem identity after
163
+ // every completed discovery pass, as required by the Vault contract.
164
+ this.state.phase = "verifying"
165
+ await this.vault.verify({
166
+ includePath: scopeId
167
+ ? (filePath) => isPathWithin(scanRoots[0].root, filePath)
168
+ : undefined,
169
+ reconcile: !scopeId,
170
+ verifyBlobs: !scopeId,
171
+ preservePath: (filePath) => this.isInaccessible(filePath),
172
+ onAccessError: (error, filePath) => this.recordInaccessible(error, filePath)
173
+ })
174
+ this.state.duration_ms = Date.now() - this.state.started
175
+ incomplete = this.state.inaccessible > 0
176
+ scanMeta.ts = Date.now()
177
+ scanMeta.complete = !incomplete
178
+ scanMeta.duration_ms = this.state.duration_ms
179
+ scanMeta.unreadable = [...this.inaccessiblePaths].map((filePath) => ({
180
+ path: filePath,
181
+ code: this.inaccessibleCodes.get(filePath) || "EACCES"
182
+ }))
183
+ this.refreshExcludedVerification(scanRoots)
184
+ if (incomplete) this.markUnavailableRows()
185
+ this.vault.registry.setScanAttempt(scanMeta, scopeId)
186
+ if (!incomplete) {
187
+ this.vault.registry.setLastScan(scanMeta, scopeId)
188
+ }
189
+ if (!scopeId) {
190
+ for (const source of this.vault.sources()) {
191
+ if (source.kind !== "app" || !source.available) continue
192
+ const sourceId = source.id
193
+ const sourceIncomplete = [...this.inaccessiblePaths].some((unreadable) =>
194
+ isPathWithin(source.root, unreadable) || isPathWithin(unreadable, source.root))
195
+ const bytes = scanMeta.source_bytes[sourceId] || 0
196
+ const scopedMeta = {
197
+ ts: scanMeta.ts,
198
+ complete: !sourceIncomplete,
199
+ duration_ms: scanMeta.duration_ms,
200
+ count_duration_ms: scanMeta.count_duration_ms,
201
+ walk_duration_ms: scanMeta.walk_duration_ms,
202
+ hash_wait_duration_ms: scanMeta.hash_wait_duration_ms,
203
+ candidate_min_bytes: scanMeta.candidate_min_bytes,
204
+ files: scanMeta.source_files[sourceId] || 0,
205
+ bytes_total: bytes,
206
+ home_bytes_total: 0,
207
+ hash_failures: scanMeta.source_hash_failures[sourceId] || 0,
208
+ source_bytes: { [sourceId]: bytes },
209
+ source_files: { [sourceId]: scanMeta.source_files[sourceId] || 0 },
210
+ source_hash_failures: {
211
+ [sourceId]: scanMeta.source_hash_failures[sourceId] || 0
212
+ },
213
+ unreadable: sourceIncomplete
214
+ ? scanMeta.unreadable.filter((entry) =>
215
+ isPathWithin(source.root, entry.path) || isPathWithin(entry.path, source.root))
216
+ : []
217
+ }
218
+ this.vault.registry.setScanAttempt(scopedMeta, sourceId)
219
+ if (!sourceIncomplete) this.vault.registry.setLastScan(scopedMeta, sourceId)
220
+ }
221
+ }
222
+ await this.vault.commitScanDraft()
223
+ completed = true
224
+ } catch (error) {
225
+ // Drain every queued hash before restoring the snapshot; otherwise a
226
+ // late worker could mutate the restored registry after rollback.
227
+ await this.settle().catch(() => {})
228
+ rollbackMetadataChanged = await this.vault.abortScanDraft(registrySnapshot)
229
+ throw error
230
+ } finally {
231
+ // A failed walk must not leave background hash work or per-scan inode
232
+ // state alive when the next manual scan starts.
233
+ await this.settle().catch(() => {})
234
+ await this.vault.registry.eventPromise
235
+ this.hashJobsByIno.clear()
236
+ this.completedHashesByIno.clear()
237
+ this.state.active = false
238
+ this.state.phase = completed ? (incomplete ? "incomplete" : "complete") : "failed"
239
+ try {
240
+ await this.vault.registry.endBatch(completed ? { flush: true } : { discard: true })
241
+ } catch (error) {
242
+ if (!completed) throw error
243
+ this.vault.scanPersistenceWarning = true
244
+ }
245
+ if (!completed && (registryWasDirty || rollbackMetadataChanged)) {
246
+ this.vault.registry.schedulePersist()
247
+ }
248
+ }
249
+ return {
250
+ dirs: this.state.dirs, files: this.state.files,
251
+ bytes_total: this.state.bytes_total, candidates: this.state.candidates,
252
+ incomplete, inaccessible: this.state.inaccessible,
253
+ inaccessible_paths: this.state.inaccessible_paths.slice()
254
+ }
255
+ }
256
+ recordInaccessible(error, filePath) {
257
+ if (!isAccessError(error)) return false
258
+ return this.recordUnavailable(filePath, error.code)
259
+ }
260
+ recordUnavailable(filePath, code = null) {
261
+ const target = path.resolve(filePath)
262
+ if ([...this.inaccessiblePaths].some((existing) => isPathWithin(existing, target))) return true
263
+ for (const existing of [...this.inaccessiblePaths]) {
264
+ if (isPathWithin(target, existing)) {
265
+ this.inaccessiblePaths.delete(existing)
266
+ this.inaccessibleCodes.delete(existing)
267
+ }
268
+ }
269
+ this.inaccessiblePaths.add(target)
270
+ this.inaccessibleCodes.set(target, code || "EACCES")
271
+ this.state.inaccessible = this.inaccessiblePaths.size
272
+ this.state.inaccessible_paths = [...this.inaccessiblePaths].slice(0, 20)
273
+ return true
274
+ }
275
+ isInaccessible(filePath) {
276
+ return [...this.inaccessiblePaths].some((target) => isPathWithin(target, filePath))
277
+ }
278
+ markUnavailableRows() {
279
+ const mark = (entries) => {
280
+ for (const [filePath, entry] of entries) {
281
+ if (!this.isInaccessible(filePath)) continue
282
+ entry.unverified = true
283
+ }
284
+ }
285
+ mark(this.vault.registry.links)
286
+ mark(this.vault.registry.duplicates)
287
+ mark(this.vault.registry.excluded)
288
+ this.vault.registry.schedulePersist()
289
+ }
290
+ refreshExcludedVerification(scanRoots) {
291
+ for (const [filePath, entry] of this.vault.registry.excluded) {
292
+ if (!scanRoots.some((source) => isPathWithin(source.root, filePath))) continue
293
+ entry.unverified = this.isInaccessible(filePath)
294
+ }
295
+ this.vault.registry.schedulePersist()
296
+ }
297
+ async countFiles(root) {
298
+ const vaultRoot = this.vault.root
299
+ for await (const directoryResults of walkBatches(root, {
300
+ concurrency: this.dirConcurrency,
301
+ skipDirectory: (full) => full === vaultRoot,
302
+ strictErrors: true,
303
+ onRootMissing: (error, filePath) =>
304
+ this.recordUnavailable(filePath, error && error.code),
305
+ onError: (error, filePath) => this.recordInaccessible(error, filePath)
306
+ })) {
307
+ this.state.counted_dirs += directoryResults.filter((group) => group.firstChunk).length
308
+ this.state.counted_files += directoryResults.reduce((sum, group) => sum +
309
+ group.files.filter((file) => !file.path.endsWith(TMP_SUFFIX)).length, 0)
310
+ }
311
+ }
312
+ async settle() {
313
+ await this.hashQueue.drained()
314
+ if (this.fatalError) {
315
+ const error = this.fatalError
316
+ this.fatalError = null
317
+ throw error
318
+ }
319
+ }
320
+ async walk(root, preferredSourceId = null) {
321
+ const vaultRoot = this.vault.root
322
+ for await (const directoryResults of walkBatches(root, {
323
+ concurrency: this.dirConcurrency,
324
+ skipDirectory: (full) => full === vaultRoot,
325
+ strictErrors: true,
326
+ onRootMissing: (error, filePath) =>
327
+ this.recordUnavailable(filePath, error && error.code),
328
+ onError: (error, filePath) => this.recordInaccessible(error, filePath)
329
+ })) {
330
+ this.state.dirs += directoryResults.filter((group) => group.firstChunk).length
331
+ const files = []
332
+ const hfDirectories = []
333
+ for (const { dir, entries, files: directoryFiles } of directoryResults) {
334
+ if (!entries) continue
335
+ const isHfBlobDirectory = path.basename(dir) === "blobs" &&
336
+ /^(models|datasets|spaces)--/.test(path.basename(path.dirname(dir)))
337
+ const hashEntries = []
338
+ for (const file of directoryFiles) {
339
+ // A 64-hex HF blob name is a hash-free input. Everything else still
340
+ // follows the generic candidate path.
341
+ if (isHfBlobDirectory && file.entry.isFile() && SHA256_RE.test(file.entry.name)) {
342
+ hashEntries.push(file.entry)
343
+ }
344
+ else files.push(file.path)
345
+ }
346
+ if (hashEntries.length) hfDirectories.push({ dir, entries: hashEntries })
347
+ }
348
+ // One metadata pool is shared by all files in this directory batch, so
349
+ // trees made of many small directories still use the bounded workers.
350
+ await this.considerFiles(files, preferredSourceId)
351
+ for (const { dir, entries } of hfDirectories) {
352
+ await this.ingestHfBlobs(dir, entries, preferredSourceId)
353
+ }
354
+ }
355
+ }
356
+ async considerFiles(filePaths, preferredSourceId = null) {
357
+ const stats = await statMany(filePaths, this.statConcurrency, null, {
358
+ followSymlinks: false,
359
+ strictErrors: true,
360
+ onError: (error, filePath) => this.recordInaccessible(error, filePath)
361
+ })
362
+ // Preserve deterministic classification order while parallelizing only
363
+ // the expensive filesystem metadata reads.
364
+ for (let index = 0; index < filePaths.length; index++) {
365
+ if (stats[index] && stats[index].isFile()) {
366
+ await this.considerStat(filePaths[index], stats[index], preferredSourceId)
367
+ }
368
+ }
369
+ }
370
+ countFile(sourceId) {
371
+ this.state.files += 1
372
+ this.state.source_files[sourceId] = (this.state.source_files[sourceId] || 0) + 1
373
+ if (this.state.total_files !== null && this.state.files > this.state.total_files) {
374
+ this.state.total_files = this.state.files
375
+ }
376
+ }
377
+ async considerStat(filePath, st, preferredSourceId = null, options = {}) {
378
+ if (filePath.endsWith(TMP_SUFFIX)) return
379
+ const registry = this.vault.registry
380
+ const source = this.vault.sourceForPath(filePath, preferredSourceId)
381
+ const sourceId = source ? source.id : null
382
+ const appName = source && source.kind === "app" ? source.app : null
383
+ const sourceKey = sourceId || preferredSourceId || "pinokio"
384
+ if (options.count !== false) {
385
+ this.countFile(sourceKey)
386
+ this.state.bytes_total += st.size
387
+ this.state.source_bytes[sourceKey] = (this.state.source_bytes[sourceKey] || 0) + st.size
388
+ if (!preferredSourceId) this.state.home_bytes_total += st.size
389
+ }
390
+ if (registry.excluded.has(filePath)) {
391
+ registry.untrack(filePath)
392
+ return
393
+ }
394
+ if (st.size < this.vault.sizeThreshold) {
395
+ if (this.preserveTrackedBelowThreshold(filePath, st, appName, sourceId)) return
396
+ if (!registry.links.has(filePath)) {
397
+ registry.untrack(filePath)
398
+ return
399
+ }
400
+ } else {
401
+ this.state.candidates += 1
402
+ }
403
+ // Known and unchanged: nothing to do.
404
+ const inoHash = registry.byIno.get(registry.inoKey(st.dev, st.ino))
405
+ if (inoHash && st.nlink > 1) {
406
+ const cached = registry.scanIndex.get(filePath)
407
+ const unchanged = cached && cached.hash === inoHash && sameSnapshot(cached, st)
408
+ if (unchanged) {
409
+ registry.addLink(filePath, {
410
+ hash: inoHash, app: appName, source_id: sourceId,
411
+ dev: st.dev, ino: st.ino, unverified: false
412
+ })
413
+ return
414
+ }
415
+ }
416
+ const cached = registry.scanIndex.get(filePath)
417
+ if (cached && cached.hash && sameSnapshot(cached, st)) {
418
+ await this.classify(filePath, st, cached.hash, sourceId)
419
+ return
420
+ }
421
+ const harvested = await this.harvestLocalDirEtag(
422
+ filePath,
423
+ st,
424
+ source && source.root ? source.root : this.kernel.homedir
425
+ )
426
+ if (harvested) {
427
+ await this.classify(filePath, st, harvested, sourceId)
428
+ return
429
+ }
430
+ // Only coalesce an inode which the filesystem itself reports as having
431
+ // multiple names. This avoids trusting placeholder inode values on
432
+ // filesystems which do not expose usable identity metadata.
433
+ const reusableInode = st.nlink > 1 && st.ino !== undefined && st.ino !== 0
434
+ const inodeKey = reusableInode ? registry.inoKey(st.dev, st.ino) : null
435
+ const existingJob = inodeKey ? this.hashJobsByIno.get(inodeKey) : null
436
+ const name = { filePath, source_id: sourceId, expected: fileSnapshot(st) }
437
+ if (existingJob) {
438
+ existingJob.names.push(name)
439
+ this.state.inode_reuses += 1
440
+ return
441
+ }
442
+ const completed = inodeKey ? this.completedHashesByIno.get(inodeKey) : null
443
+ if (completed && sameContentState(completed.expected, st)) {
444
+ this.state.inode_reuses += 1
445
+ await this.classify(filePath, st, completed.hash, sourceId)
446
+ return
447
+ }
448
+ if (completed) this.completedHashesByIno.delete(inodeKey)
449
+ const job = { inodeKey, names: [name] }
450
+ if (inodeKey) this.hashJobsByIno.set(inodeKey, job)
451
+ this.state.hash_total += 1
452
+ this.state.queued += 1
453
+ this.hashQueue.push(job).catch(() => {})
454
+ if (this.state.queued >= this.hashQueueLimit) {
455
+ await new Promise((resolve) => this.hashCapacityWaiters.push(resolve))
456
+ }
457
+ }
458
+ invalidateLinkedInode(hash, dev, ino) {
459
+ const registry = this.vault.registry
460
+ for (const [filePath, entry] of [...registry.links]) {
461
+ if (entry.hash === hash && entry.dev === dev && entry.ino === ino) {
462
+ registry.untrack(filePath)
463
+ }
464
+ }
465
+ registry.removeBlob(hash)
466
+ }
467
+ preserveTrackedBelowThreshold(filePath, st, appName, sourceId) {
468
+ const registry = this.vault.registry
469
+ const linked = registry.links.get(filePath)
470
+ const cached = registry.scanIndex.get(filePath)
471
+ if (!linked || !cached || cached.hash !== linked.hash ||
472
+ linked.dev !== st.dev || linked.ino !== st.ino ||
473
+ !sameSnapshot(cached, st)) return false
474
+ registry.addLink(filePath, {
475
+ hash: linked.hash,
476
+ app: appName,
477
+ source_id: sourceId,
478
+ dev: st.dev,
479
+ ino: st.ino,
480
+ unverified: false
481
+ })
482
+ return true
483
+ }
484
+ async _hashJob(job) {
485
+ const primary = job.names[0]
486
+ const started = Date.now()
487
+ this.currentHash = { path: primary.filePath }
488
+ try {
489
+ const { hash, size } = await this.vault.hashFile(primary.filePath)
490
+ this.state.hashed += 1
491
+ this.state.hash_bytes += size || 0
492
+ const primaryStat = await fs.promises.lstat(primary.filePath)
493
+ // Never publish a digest if the path changed while its bytes were read.
494
+ // The next manual scan can retry a continuously-mutating file safely.
495
+ if (size !== primaryStat.size || !sameSnapshot(primary.expected, primaryStat)) {
496
+ this.state.unstable_hashes += 1
497
+ return
498
+ }
499
+ let index = 0
500
+ while (index < job.names.length) {
501
+ const name = job.names[index++]
502
+ let st
503
+ try {
504
+ st = name === primary ? primaryStat : await fs.promises.lstat(name.filePath)
505
+ } catch (e) {
506
+ if (isMissingError(e) || this.recordInaccessible(e, name.filePath)) continue
507
+ throw e
508
+ }
509
+ // Same (device, inode) is physical identity, not a content guess.
510
+ // Size and mtime also prevent applying a completed digest after an
511
+ // in-place mutation. ctime is omitted here because adoption itself
512
+ // adds a name and therefore legitimately changes inode metadata.
513
+ if (!sameContentState(fileSnapshot(primaryStat), st)) continue
514
+ await this.classify(name.filePath, st, hash, name.source_id)
515
+ }
516
+ if (job.inodeKey) {
517
+ const current = await fs.promises.lstat(primary.filePath)
518
+ if (sameContentState(fileSnapshot(primaryStat), current)) {
519
+ this.completedHashesByIno.set(job.inodeKey, {
520
+ hash,
521
+ expected: fileSnapshot(current)
522
+ })
523
+ }
524
+ }
525
+ } catch (e) {
526
+ if (!this.recordInaccessible(e, primary.filePath)) {
527
+ this.state.hash_failures += 1
528
+ const sourceId = primary.source_id || "pinokio"
529
+ this.state.source_hash_failures[sourceId] =
530
+ (this.state.source_hash_failures[sourceId] || 0) + 1
531
+ if (!this.fatalError) this.fatalError = e
532
+ }
533
+ } finally {
534
+ this.state.hash_duration_ms += Date.now() - started
535
+ if (job.inodeKey && this.hashJobsByIno.get(job.inodeKey) === job) {
536
+ this.hashJobsByIno.delete(job.inodeKey)
537
+ }
538
+ this.currentHash = null
539
+ this.state.queued = Math.max(0, this.state.queued - 1)
540
+ if (this.state.queued < this.hashQueueLimit) {
541
+ for (const resolve of this.hashCapacityWaiters.splice(0)) resolve()
542
+ }
543
+ }
544
+ }
545
+ // hash known → adopt if new content; otherwise a byte-identical copy:
546
+ // ALWAYS pending, never converted by a scan.
547
+ async classify(filePath, st, hash, preferredSourceId = null) {
548
+ const registry = this.vault.registry
549
+ try {
550
+ const current = await fs.promises.lstat(filePath)
551
+ // A sibling name of the same inode may already have been adopted in
552
+ // this hash job, which changes ctime but not identity or content.
553
+ if (!sameContentState(fileSnapshot(st), current)) {
554
+ registry.untrack(filePath)
555
+ this.state.unstable_hashes += 1
556
+ return
557
+ }
558
+ st = current
559
+ } catch (error) {
560
+ if (!isMissingError(error)) throw error
561
+ registry.untrack(filePath)
562
+ return
563
+ }
564
+ const source = this.vault.sourceForPath(filePath, preferredSourceId)
565
+ const sourceId = source ? source.id : null
566
+ const appName = source && source.kind === "app" ? source.app : null
567
+ // Divergence: this inode was registered under a different hash — the
568
+ // shared content was written in place. Retire the stale store name only
569
+ // when the scan commits so a later fatal error leaves the filesystem and
570
+ // registry at their pre-scan state.
571
+ const priorHash = registry.byIno.get(registry.inoKey(st.dev, st.ino))
572
+ if (priorHash && priorHash !== hash) {
573
+ const stalePath = this.vault.storePathFor(priorHash)
574
+ let retiredStaleStore = false
575
+ try {
576
+ const staleStat = await this.vault.storeStatIfPresent(stalePath)
577
+ if (staleStat && staleStat.isFile() && staleStat.dev === st.dev && staleStat.ino === st.ino) {
578
+ const inodeKey = registry.inoKey(st.dev, st.ino)
579
+ this.vault.rememberRetiredScanStore(stalePath, staleStat, [
580
+ filePath,
581
+ ...(registry.pathsByIno.get(inodeKey) || [])
582
+ ])
583
+ retiredStaleStore = true
584
+ }
585
+ } catch (error) {
586
+ if (!isMissingError(error)) throw error
587
+ }
588
+ if (retiredStaleStore) this.invalidateLinkedInode(priorHash, st.dev, st.ino)
589
+ else registry.untrack(filePath)
590
+ this.vault.recordEvent({ kind: "diverged", hash: priorHash, path: filePath, app: appName, source_id: sourceId, size: st.size })
591
+ }
592
+ const storePath = this.vault.storePathFor(hash)
593
+ let storeStat = null
594
+ try {
595
+ storeStat = await this.vault.storeStatIfPresent(storePath)
596
+ } catch (error) {
597
+ if (!isMissingError(error)) throw error
598
+ }
599
+ const registered = registry.links.get(filePath)
600
+ if (!storeStat && ((source && source.shareable) || !registry.blobs.has(hash) || (registered &&
601
+ registered.hash === hash && registered.dev === st.dev && registered.ino === st.ino))) {
602
+ const adopted = await this.vault.adopt(filePath, hash, {
603
+ app: appName,
604
+ source_id: sourceId,
605
+ expected: fileSnapshot(st)
606
+ })
607
+ if (adopted.status === "stale") {
608
+ registry.untrack(filePath)
609
+ this.state.unstable_hashes += 1
610
+ return
611
+ }
612
+ if (adopted.status === "adopted" || adopted.status === "already") {
613
+ return
614
+ }
615
+ try {
616
+ storeStat = await this.vault.storeStatIfPresent(storePath)
617
+ } catch (error) {
618
+ if (!isMissingError(error)) throw error
619
+ }
620
+ }
621
+ if (storeStat && storeStat.dev === st.dev && storeStat.ino === st.ino) {
622
+ registry.addLink(filePath, {
623
+ hash, app: appName, source_id: sourceId,
624
+ dev: st.dev, ino: st.ino, unverified: false
625
+ })
626
+ this.updateScanIndex(filePath, st, hash, sourceId)
627
+ return
628
+ }
629
+ const scanEntry = {
630
+ size: st.size, mtime: st.mtimeMs, ctime: st.ctimeMs,
631
+ dev: st.dev, ino: st.ino, hash: hash || null, source_id: sourceId
632
+ }
633
+ const previous = registry.setDuplicate(filePath, {
634
+ hash, size: st.size, app: appName, source_id: sourceId, discovered: Date.now(),
635
+ dev: st.dev, ino: st.ino, mtime: st.mtimeMs, ctime: st.ctimeMs,
636
+ unverified: false
637
+ }, scanEntry)
638
+ if (!previous || previous.hash !== hash) {
639
+ this.vault.recordEvent({ kind: "found", hash, path: filePath, app: appName, source_id: sourceId, size: st.size })
640
+ }
641
+ }
642
+ updateScanIndex(filePath, st, hash, sourceId = null) {
643
+ this.vault.registry.setScanEntry(filePath, {
644
+ size: st.size, mtime: st.mtimeMs, ctime: st.ctimeMs,
645
+ dev: st.dev, ino: st.ino, hash: hash || null, source_id: sourceId
646
+ })
647
+ }
648
+ async ingestHfBlobs(dir, entries, preferredSourceId = null) {
649
+ for (const entry of entries) {
650
+ if (!entry.isFile() || entry.isSymbolicLink()) continue
651
+ const name = entry.name
652
+ const full = path.resolve(dir, name)
653
+ let st
654
+ try {
655
+ st = await fs.promises.lstat(full)
656
+ } catch (e) {
657
+ if (!isMissingError(e) && !this.recordInaccessible(e, full)) throw e
658
+ continue
659
+ }
660
+ if (!st.isFile()) continue
661
+ const source = this.vault.sourceForPath(full, preferredSourceId)
662
+ const sourceId = source ? source.id : null
663
+ const appName = source && source.kind === "app" ? source.app : null
664
+ const sourceKey = sourceId || preferredSourceId || "pinokio"
665
+ this.countFile(sourceKey)
666
+ this.state.bytes_total += st.size
667
+ this.state.source_bytes[sourceKey] = (this.state.source_bytes[sourceKey] || 0) + st.size
668
+ if (!preferredSourceId) this.state.home_bytes_total += st.size
669
+ if (!SHA256_RE.test(name)) continue
670
+ if (this.vault.registry.excluded.has(full)) {
671
+ this.vault.registry.untrack(full)
672
+ continue
673
+ }
674
+ if (st.size < this.vault.sizeThreshold) {
675
+ if (this.preserveTrackedBelowThreshold(full, st, appName, sourceId)) continue
676
+ if (this.vault.registry.links.has(full)) {
677
+ await this.considerStat(full, st, preferredSourceId, { count: false })
678
+ } else {
679
+ this.vault.registry.untrack(full)
680
+ }
681
+ continue
682
+ } else {
683
+ this.state.candidates += 1
684
+ }
685
+ await this.classify(full, st, name, preferredSourceId)
686
+ }
687
+ }
688
+ // hf CLI --local-dir metadata: etag is the sha256 for LFS files; freshness
689
+ // via the recorded timestamp against mtime (huggingface_hub's own rule).
690
+ async harvestLocalDirEtag(filePath, st, sourceRoot) {
691
+ try {
692
+ const start = path.dirname(filePath)
693
+ let dir = start
694
+ // Metadata outside the configured source is not authoritative for a
695
+ // scanned file, even when the source happens to be nested below it.
696
+ const stop = path.resolve(sourceRoot || this.kernel.homedir)
697
+ const visited = []
698
+ let base = null
699
+ for (let depth = 0; depth < 12; depth++) {
700
+ if (this.metadataBaseCache.has(dir)) {
701
+ base = this.metadataBaseCache.get(dir)
702
+ break
703
+ }
704
+ visited.push(dir)
705
+ const metaRoot = path.resolve(dir, ".cache", "huggingface")
706
+ let exists = false
707
+ try {
708
+ exists = (await fs.promises.lstat(metaRoot)).isDirectory()
709
+ } catch (e) {}
710
+ if (exists) {
711
+ base = dir
712
+ break
713
+ }
714
+ if (dir === stop || path.dirname(dir) === dir) break
715
+ dir = path.dirname(dir)
716
+ }
717
+ for (const visitedDir of visited) this.metadataBaseCache.set(visitedDir, base)
718
+ if (!base) return null
719
+ const relative = path.relative(base, filePath)
720
+ const metaRoot = path.resolve(base, ".cache", "huggingface")
721
+ const metaRootStat = await fs.promises.lstat(metaRoot)
722
+ if (!metaRootStat.isDirectory()) return null
723
+ const metaPath = path.resolve(metaRoot, relative + ".metadata")
724
+ let raw
725
+ try {
726
+ raw = await readFileNoFollow(metaPath)
727
+ } catch (e) {
728
+ return null
729
+ }
730
+ if (raw === null) return null
731
+ const lines = raw.split("\n")
732
+ const etag = String(lines[1] || "").replace(/"/g, "").trim().toLowerCase()
733
+ const ts = parseFloat(lines[2] || "0") * 1000
734
+ if (SHA256_RE.test(etag) && ts >= st.mtimeMs) return etag
735
+ } catch (e) {}
736
+ return null
737
+ }
738
+ }
739
+
740
+ module.exports = Sweeper