pinokiod 8.0.39 → 8.0.40

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.
@@ -1,169 +0,0 @@
1
- const fs = require('fs')
2
- const path = require('path')
3
-
4
- // Persisted record of blobs and their known names.
5
- // The registry is a cache of the filesystem, never the source of truth
6
- // (spec/requirements/shared-model-store.md "Registry").
7
- class Registry {
8
- constructor(root) {
9
- this.root = root
10
- this.snapshotPath = path.resolve(root, "registry.json")
11
- this.eventsPath = path.resolve(root, "events.ndjson")
12
- this.reset()
13
- this.persistTimer = null
14
- this.persistDelay = 500
15
- this.flushPromise = Promise.resolve()
16
- }
17
- reset() {
18
- this.blobs = new Map() // hash -> { size, first_seen, source_urls, verified_at, orphan }
19
- this.links = new Map() // path -> { hash, app, source_id, dev, ino, created, mode }
20
- this.scanIndex = new Map() // path -> { size, mtime, dev, ino, hash, source_id }
21
- this.duplicates = new Map() // path -> { hash, size, app, source_id, discovered } pending user conversion
22
- this.excluded = new Map() // path -> { ts, source_id, size } user chose "keep independent"
23
- this.lastScan = null // scan totals, duration, and hash work
24
- this.totals = { lifetime_bytes_saved: 0 }
25
- this.byIno = new Map() // "dev:ino" -> hash
26
- }
27
- setLastScan(meta) {
28
- this.lastScan = meta
29
- this.schedulePersist()
30
- }
31
- inoKey(dev, ino) {
32
- return `${dev}:${ino}`
33
- }
34
- addBlob(hash, meta) {
35
- if (!this.blobs.has(hash)) {
36
- this.blobs.set(hash, Object.assign({
37
- size: 0,
38
- first_seen: Date.now(),
39
- source_urls: [],
40
- verified_at: null
41
- }, meta))
42
- } else if (meta && meta.source_urls && meta.source_urls.length) {
43
- const blob = this.blobs.get(hash)
44
- for (const url of meta.source_urls) {
45
- if (!blob.source_urls.includes(url)) blob.source_urls.push(url)
46
- }
47
- }
48
- this.schedulePersist()
49
- }
50
- addLink(filePath, entry) {
51
- this.links.set(filePath, Object.assign({ created: Date.now(), mode: "link" }, entry))
52
- if (entry.dev !== undefined && entry.ino !== undefined) {
53
- this.byIno.set(this.inoKey(entry.dev, entry.ino), entry.hash)
54
- }
55
- this.schedulePersist()
56
- }
57
- removeLink(filePath) {
58
- const entry = this.links.get(filePath)
59
- if (entry) {
60
- this.links.delete(filePath)
61
- const key = this.inoKey(entry.dev, entry.ino)
62
- let stillUsed = false
63
- for (const other of this.links.values()) {
64
- if (this.inoKey(other.dev, other.ino) === key) { stillUsed = true; break }
65
- }
66
- if (!stillUsed) this.byIno.delete(key)
67
- this.schedulePersist()
68
- }
69
- }
70
- removeBlob(hash) {
71
- this.blobs.delete(hash)
72
- for (const [p, entry] of [...this.links]) {
73
- if (entry.hash === hash) this.removeLink(p)
74
- }
75
- this.schedulePersist()
76
- }
77
- addSaved(bytes) {
78
- this.totals.lifetime_bytes_saved += bytes
79
- this.schedulePersist()
80
- }
81
- // Returns { corrupt: true } when the snapshot exists but cannot be parsed,
82
- // so the caller can rebuild from disk instead of blocking startup.
83
- async load() {
84
- this.reset()
85
- let raw
86
- try {
87
- raw = await fs.promises.readFile(this.snapshotPath, "utf8")
88
- } catch (e) {
89
- return { corrupt: false, existed: false }
90
- }
91
- let json
92
- try {
93
- json = JSON.parse(raw)
94
- } catch (e) {
95
- return { corrupt: true, existed: true }
96
- }
97
- for (const [k, v] of Object.entries(json.blobs || {})) this.blobs.set(k, v)
98
- for (const [k, v] of Object.entries(json.links || {})) {
99
- this.links.set(k, v)
100
- if (v.dev !== undefined && v.ino !== undefined) {
101
- this.byIno.set(this.inoKey(v.dev, v.ino), v.hash)
102
- }
103
- }
104
- for (const [k, v] of Object.entries(json.scan_index || {})) this.scanIndex.set(k, v)
105
- for (const [k, v] of Object.entries(json.duplicates || {})) this.duplicates.set(k, v)
106
- for (const [k, v] of Object.entries(json.excluded || {})) this.excluded.set(k, v)
107
- if (json.last_scan) this.lastScan = json.last_scan
108
- if (json.totals && typeof json.totals.lifetime_bytes_saved === "number") {
109
- this.totals = json.totals
110
- }
111
- return { corrupt: false, existed: true }
112
- }
113
- schedulePersist() {
114
- if (this.persistTimer) return
115
- this.persistTimer = setTimeout(() => {
116
- this.persistTimer = null
117
- this.flush().catch(() => {})
118
- }, this.persistDelay)
119
- if (this.persistTimer.unref) this.persistTimer.unref()
120
- }
121
- async flush() {
122
- if (this.persistTimer) {
123
- clearTimeout(this.persistTimer)
124
- this.persistTimer = null
125
- }
126
- const json = {
127
- version: 1,
128
- blobs: Object.fromEntries(this.blobs),
129
- links: Object.fromEntries(this.links),
130
- scan_index: Object.fromEntries(this.scanIndex),
131
- duplicates: Object.fromEntries(this.duplicates),
132
- excluded: Object.fromEntries(this.excluded),
133
- last_scan: this.lastScan,
134
- totals: this.totals
135
- }
136
- const write = async () => {
137
- const tmp = this.snapshotPath + ".tmp"
138
- await fs.promises.mkdir(this.root, { recursive: true })
139
- await fs.promises.writeFile(tmp, JSON.stringify(json))
140
- await fs.promises.rename(tmp, this.snapshotPath)
141
- }
142
- const pending = this.flushPromise.then(write, write)
143
- this.flushPromise = pending.catch(() => {})
144
- return pending
145
- }
146
- async appendEvent(event) {
147
- const line = JSON.stringify(Object.assign({ ts: Date.now() }, event)) + "\n"
148
- await fs.promises.appendFile(this.eventsPath, line).catch(() => {})
149
- }
150
- // Torn final line (crash mid-append) is discarded, per persistence rules.
151
- async readEvents() {
152
- let raw
153
- try {
154
- raw = await fs.promises.readFile(this.eventsPath, "utf8")
155
- } catch (e) {
156
- return []
157
- }
158
- const events = []
159
- for (const line of raw.split("\n")) {
160
- if (!line.trim()) continue
161
- try {
162
- events.push(JSON.parse(line))
163
- } catch (e) {}
164
- }
165
- return events
166
- }
167
- }
168
-
169
- module.exports = Registry
@@ -1,470 +0,0 @@
1
- const fs = require('fs')
2
- const path = require('path')
3
- const fastq = require('fastq')
4
-
5
- // Manual scan engine (spec/requirements/shared-model-store.md).
6
- // Scans run ONLY when the user asks — there are no automatic triggers.
7
- // The walk is generic: every regular file counts toward folder totals, and
8
- // every file >= the size threshold is a candidate. No name heuristics.
9
- // A scan never converts anything: it adopts new content (adds a vault name,
10
- // which mutates nothing) and lists byte-identical copies as pending, for the
11
- // user to deduplicate explicitly.
12
-
13
- const SHA256_RE = /^[0-9a-f]{64}$/
14
- const TMP_SUFFIX = ".pinokio-dedup-tmp"
15
- const DIR_CONCURRENCY = 8
16
- const STAT_CONCURRENCY = 32
17
-
18
- const fileSnapshot = (st) => ({
19
- dev: st.dev,
20
- ino: st.ino,
21
- size: st.size,
22
- mtime: st.mtimeMs,
23
- ctime: st.ctimeMs
24
- })
25
-
26
- const sameSnapshot = (snapshot, st) => !!(
27
- snapshot && st &&
28
- snapshot.dev === st.dev &&
29
- snapshot.ino === st.ino &&
30
- snapshot.size === st.size &&
31
- snapshot.mtime === st.mtimeMs &&
32
- snapshot.ctime === st.ctimeMs
33
- )
34
-
35
- class Sweeper {
36
- constructor(vault) {
37
- this.vault = vault
38
- this.kernel = vault.kernel
39
- this.state = this.idleState()
40
- this.currentHash = null
41
- this.hashQueue = fastq.promise(this, this._hashJob, 1)
42
- this.stats = { hashes: 0, ingested: 0 }
43
- this.statConcurrency = vault.statConcurrency || STAT_CONCURRENCY
44
- this.dirConcurrency = vault.dirConcurrency || DIR_CONCURRENCY
45
- this.metadataBaseCache = new Map()
46
- this.hashJobsByIno = new Map()
47
- }
48
- idleState() {
49
- return {
50
- active: false, dirs: 0, files: 0, bytes_total: 0,
51
- home_bytes_total: 0, source_bytes: {},
52
- candidates: 0, hashed: 0, hash_total: 0, hash_bytes: 0, queued: 0,
53
- inode_reuses: 0, unstable_hashes: 0,
54
- source_ids: [], estimated_files: null, estimated_walk_weight: null,
55
- started: null, duration_ms: null,
56
- walk_duration_ms: null, hash_wait_duration_ms: null,
57
- hash_duration_ms: 0
58
- }
59
- }
60
- // The one entry point: the Pinokio home plus explicit linked imports.
61
- async scan() {
62
- if (this.state.active) return { already_running: true }
63
- this.state = Object.assign(this.idleState(), { active: true, started: Date.now() })
64
- this.metadataBaseCache.clear()
65
- this.hashJobsByIno.clear()
66
- try {
67
- await this.vault.refreshSources()
68
- const scanRoots = this.vault.scanRoots()
69
- this.state.source_ids = scanRoots.map((source) => source.source_id).sort()
70
- const previous = this.vault.registry.lastScan
71
- const previousSourceIds = previous
72
- ? (Array.isArray(previous.source_ids) ? previous.source_ids : Object.keys(previous.source_bytes || {})).slice().sort()
73
- : []
74
- const sameSources = previousSourceIds.length === this.state.source_ids.length &&
75
- previousSourceIds.every((sourceId, index) => sourceId === this.state.source_ids[index])
76
- if (previous && previous.files > 0 && sameSources) {
77
- this.state.estimated_files = previous.files
78
- const measuredWeight = previous.duration_ms > 0 && previous.walk_duration_ms >= 0
79
- ? previous.walk_duration_ms / previous.duration_ms
80
- : 0.8
81
- this.state.estimated_walk_weight = Math.max(0.15, Math.min(0.98, measuredWeight))
82
- }
83
- const walkStarted = Date.now()
84
- for (const source of scanRoots) {
85
- if (!Object.prototype.hasOwnProperty.call(this.state.source_bytes, source.source_id)) {
86
- this.state.source_bytes[source.source_id] = 0
87
- }
88
- await this.walk(source.root, source.source_id === "pinokio" ? null : source.source_id)
89
- }
90
- this.state.walk_duration_ms = Date.now() - walkStarted
91
- const hashWaitStarted = Date.now()
92
- await this.settle()
93
- this.state.hash_wait_duration_ms = Date.now() - hashWaitStarted
94
- this.state.duration_ms = Date.now() - this.state.started
95
- this.vault.registry.setLastScan({
96
- ts: Date.now(),
97
- dirs: this.state.dirs,
98
- files: this.state.files,
99
- bytes_total: this.state.bytes_total,
100
- home_bytes_total: this.state.home_bytes_total,
101
- source_bytes: Object.assign({}, this.state.source_bytes),
102
- source_ids: this.state.source_ids.slice(),
103
- candidates: this.state.candidates,
104
- hashed: this.state.hashed,
105
- hash_total: this.state.hash_total,
106
- hash_bytes: this.state.hash_bytes,
107
- inode_reuses: this.state.inode_reuses,
108
- unstable_hashes: this.state.unstable_hashes,
109
- walk_duration_ms: this.state.walk_duration_ms,
110
- hash_wait_duration_ms: this.state.hash_wait_duration_ms,
111
- hash_duration_ms: this.state.hash_duration_ms,
112
- duration_ms: this.state.duration_ms
113
- })
114
- } finally {
115
- // A failed walk must not leave background hash work or per-scan inode
116
- // state alive when the next manual scan starts.
117
- await this.settle().catch(() => {})
118
- this.hashJobsByIno.clear()
119
- this.state.active = false
120
- }
121
- return {
122
- dirs: this.state.dirs, files: this.state.files,
123
- bytes_total: this.state.bytes_total, candidates: this.state.candidates
124
- }
125
- }
126
- async settle() {
127
- await this.hashQueue.drained()
128
- }
129
- async walk(root, preferredSourceId = null) {
130
- const vaultRoot = this.vault.root
131
- const stack = [root]
132
- while (stack.length) {
133
- const dirs = []
134
- while (dirs.length < this.dirConcurrency && stack.length) {
135
- dirs.push(stack.pop())
136
- }
137
- const directoryResults = await Promise.all(dirs.map(async (dir) => {
138
- this.state.dirs += 1
139
- try {
140
- const entries = await fs.promises.readdir(dir, { withFileTypes: true })
141
- return { dir, entries }
142
- } catch (e) {
143
- return { dir, entries: null }
144
- }
145
- }))
146
- const files = []
147
- const hfDirectories = []
148
- for (const { dir, entries } of directoryResults) {
149
- if (!entries) continue
150
- // HF hub blobs dir: filenames ARE sha256 for LFS files — no hashing.
151
- if (path.basename(dir) === "blobs" && /^(models|datasets|spaces)--/.test(path.basename(path.dirname(dir)))) {
152
- hfDirectories.push({ dir, entries })
153
- continue
154
- }
155
- for (const entry of entries) {
156
- if (entry.isSymbolicLink()) continue
157
- const full = path.resolve(dir, entry.name)
158
- if (entry.isDirectory()) {
159
- if (full === vaultRoot) continue
160
- stack.push(full)
161
- } else if (entry.isFile()) {
162
- files.push(full)
163
- }
164
- }
165
- }
166
- // One metadata pool is shared by all files in this directory batch, so
167
- // trees made of many small directories still use the bounded workers.
168
- await this.considerFiles(files, preferredSourceId)
169
- for (const { dir, entries } of hfDirectories) {
170
- await this.ingestHfBlobs(dir, entries, preferredSourceId)
171
- }
172
- }
173
- }
174
- async considerFiles(filePaths, preferredSourceId = null) {
175
- const stats = new Array(filePaths.length)
176
- let cursor = 0
177
- const workers = Array.from({ length: Math.min(this.statConcurrency, filePaths.length) }, async () => {
178
- while (cursor < filePaths.length) {
179
- const index = cursor++
180
- try {
181
- stats[index] = await fs.promises.stat(filePaths[index])
182
- } catch (error) {
183
- stats[index] = null
184
- }
185
- }
186
- })
187
- await Promise.all(workers)
188
- // Preserve deterministic classification order while parallelizing only
189
- // the expensive filesystem metadata reads.
190
- for (let index = 0; index < filePaths.length; index++) {
191
- if (stats[index]) await this.considerStat(filePaths[index], stats[index], preferredSourceId)
192
- }
193
- }
194
- async considerFile(filePath, preferredSourceId = null) {
195
- if (filePath.endsWith(TMP_SUFFIX)) return
196
- let st
197
- try {
198
- st = await fs.promises.stat(filePath)
199
- } catch (e) {
200
- return
201
- }
202
- await this.considerStat(filePath, st, preferredSourceId)
203
- }
204
- async considerStat(filePath, st, preferredSourceId = null) {
205
- if (filePath.endsWith(TMP_SUFFIX)) return
206
- const registry = this.vault.registry
207
- this.state.files += 1
208
- this.state.bytes_total += st.size
209
- const sourceKey = preferredSourceId || "pinokio"
210
- this.state.source_bytes[sourceKey] = (this.state.source_bytes[sourceKey] || 0) + st.size
211
- if (!preferredSourceId) this.state.home_bytes_total += st.size
212
- if (st.size < this.vault.sizeThreshold) return
213
- if (registry.excluded.has(filePath)) return
214
- this.state.candidates += 1
215
- const source = this.vault.sourceForPath(filePath, preferredSourceId)
216
- const sourceId = source ? source.id : null
217
- const appName = source && source.kind === "app" ? source.app : null
218
- // Known and unchanged: nothing to do.
219
- const inoHash = registry.byIno.get(registry.inoKey(st.dev, st.ino))
220
- if (inoHash && st.nlink > 1) {
221
- const cached = registry.scanIndex.get(filePath)
222
- const unchanged = cached && cached.size === st.size && cached.mtime === st.mtimeMs && cached.ino === st.ino
223
- if (unchanged || !cached) {
224
- if (!registry.links.has(filePath)) {
225
- registry.addLink(filePath, { hash: inoHash, app: appName, source_id: sourceId, dev: st.dev, ino: st.ino, mode: "link" })
226
- }
227
- if (!cached) this.updateScanIndex(filePath, st, inoHash, sourceId)
228
- return
229
- }
230
- }
231
- const cached = registry.scanIndex.get(filePath)
232
- if (cached && cached.size === st.size && cached.mtime === st.mtimeMs && cached.ino === st.ino && cached.hash) {
233
- await this.classify(filePath, st, cached.hash, sourceId)
234
- return
235
- }
236
- const harvested = await this.harvestLocalDirEtag(filePath, st)
237
- if (harvested) {
238
- await this.classify(filePath, st, harvested, sourceId)
239
- return
240
- }
241
- // Only coalesce an inode which the filesystem itself reports as having
242
- // multiple names. This avoids trusting placeholder inode values on
243
- // filesystems which do not expose usable identity metadata.
244
- const reusableInode = st.nlink > 1 && st.ino !== undefined && st.ino !== 0
245
- const inodeKey = reusableInode ? registry.inoKey(st.dev, st.ino) : null
246
- const existingJob = inodeKey ? this.hashJobsByIno.get(inodeKey) : null
247
- const name = { filePath, source_id: sourceId, expected: fileSnapshot(st) }
248
- if (existingJob) {
249
- existingJob.names.push(name)
250
- this.state.inode_reuses += 1
251
- return
252
- }
253
- const job = { inodeKey, names: [name] }
254
- if (inodeKey) this.hashJobsByIno.set(inodeKey, job)
255
- this.state.hash_total += 1
256
- this.state.queued += 1
257
- this.hashQueue.push(job).catch(() => {})
258
- }
259
- async _hashJob(job) {
260
- const primary = job.names[0]
261
- const started = Date.now()
262
- this.currentHash = { path: primary.filePath }
263
- try {
264
- const { hash, size } = await this.vault.hashFile(primary.filePath)
265
- this.stats.hashes += 1
266
- this.state.hashed += 1
267
- this.state.hash_bytes += size || 0
268
- const primaryStat = await fs.promises.stat(primary.filePath)
269
- // Never publish a digest if the path changed while its bytes were read.
270
- // The next manual scan can retry a continuously-mutating file safely.
271
- if (size !== primaryStat.size || !sameSnapshot(primary.expected, primaryStat)) {
272
- this.state.unstable_hashes += 1
273
- return
274
- }
275
- let index = 0
276
- while (index < job.names.length) {
277
- const name = job.names[index++]
278
- let st
279
- try {
280
- st = name === primary ? primaryStat : await fs.promises.stat(name.filePath)
281
- } catch (e) {
282
- continue
283
- }
284
- // Same (device, inode) is physical identity, not a content guess.
285
- // Size and mtime also prevent applying a completed digest after an
286
- // in-place mutation. ctime is omitted here because adoption itself
287
- // adds a name and therefore legitimately changes inode metadata.
288
- if (st.dev !== primaryStat.dev || st.ino !== primaryStat.ino ||
289
- st.size !== primaryStat.size || st.mtimeMs !== primaryStat.mtimeMs) continue
290
- await this.classify(name.filePath, st, hash, name.source_id)
291
- }
292
- } catch (e) {
293
- } finally {
294
- this.state.hash_duration_ms += Date.now() - started
295
- if (job.inodeKey && this.hashJobsByIno.get(job.inodeKey) === job) {
296
- this.hashJobsByIno.delete(job.inodeKey)
297
- }
298
- this.currentHash = null
299
- this.state.queued = Math.max(0, this.state.queued - 1)
300
- }
301
- }
302
- // hash known → adopt if new content; otherwise a byte-identical copy:
303
- // ALWAYS pending, never converted by a scan.
304
- async classify(filePath, st, hash, preferredSourceId = null) {
305
- const registry = this.vault.registry
306
- const source = this.vault.sourceForPath(filePath, preferredSourceId)
307
- const sourceId = source ? source.id : null
308
- const appName = source && source.kind === "app" ? source.app : null
309
- // Divergence: this inode was registered under a different hash — the
310
- // shared content was written in place. Evict the stale blob.
311
- const priorHash = registry.byIno.get(registry.inoKey(st.dev, st.ino))
312
- if (priorHash && priorHash !== hash) {
313
- const stalePath = this.vault.storePathFor(priorHash)
314
- try {
315
- const staleStat = await fs.promises.stat(stalePath)
316
- if (staleStat.dev === st.dev && staleStat.ino === st.ino) {
317
- await fs.promises.unlink(stalePath)
318
- }
319
- } catch (e) {}
320
- registry.removeBlob(priorHash)
321
- registry.appendEvent({ kind: "diverged", hash: priorHash, path: filePath, app: appName, source_id: sourceId, size: st.size })
322
- }
323
- const storePath = this.vault.storePathFor(hash)
324
- let storeStat = null
325
- try {
326
- storeStat = await fs.promises.stat(storePath)
327
- } catch (e) {}
328
- if (!storeStat && !registry.blobs.has(hash)) {
329
- await this.vault.adopt(filePath, hash, { app: appName, source_id: sourceId })
330
- this.updateScanIndex(filePath, st, hash, sourceId)
331
- return
332
- }
333
- if (storeStat && storeStat.dev === st.dev && storeStat.ino === st.ino) {
334
- if (!registry.links.has(filePath)) {
335
- registry.addLink(filePath, { hash, app: appName, source_id: sourceId, dev: st.dev, ino: st.ino, mode: "link" })
336
- }
337
- this.updateScanIndex(filePath, st, hash, sourceId)
338
- return
339
- }
340
- if (!registry.duplicates.has(filePath)) {
341
- registry.duplicates.set(filePath, { hash, size: st.size, app: appName, source_id: sourceId, discovered: Date.now() })
342
- registry.schedulePersist()
343
- registry.appendEvent({ kind: "found", hash, path: filePath, app: appName, source_id: sourceId, size: st.size })
344
- }
345
- this.updateScanIndex(filePath, st, hash, sourceId)
346
- }
347
- updateScanIndex(filePath, st, hash, sourceId = null) {
348
- this.vault.registry.scanIndex.set(filePath, {
349
- size: st.size, mtime: st.mtimeMs, dev: st.dev, ino: st.ino, hash: hash || null, source_id: sourceId
350
- })
351
- this.vault.registry.schedulePersist()
352
- }
353
- async ingestHfBlobs(dir, entries, preferredSourceId = null) {
354
- for (const entry of entries) {
355
- if (!entry.isFile() || entry.isSymbolicLink()) continue
356
- const name = entry.name
357
- const full = path.resolve(dir, name)
358
- let st
359
- try {
360
- st = await fs.promises.stat(full)
361
- } catch (e) { continue }
362
- this.state.files += 1
363
- this.state.bytes_total += st.size
364
- const sourceKey = preferredSourceId || "pinokio"
365
- this.state.source_bytes[sourceKey] = (this.state.source_bytes[sourceKey] || 0) + st.size
366
- if (!preferredSourceId) this.state.home_bytes_total += st.size
367
- if (!SHA256_RE.test(name)) continue
368
- if (st.size < this.vault.sizeThreshold) continue
369
- if (this.vault.registry.excluded.has(full)) continue
370
- this.state.candidates += 1
371
- this.stats.ingested += 1
372
- await this.classify(full, st, name, preferredSourceId)
373
- }
374
- }
375
- // hf CLI --local-dir metadata: etag is the sha256 for LFS files; freshness
376
- // via the recorded timestamp against mtime (huggingface_hub's own rule).
377
- async harvestLocalDirEtag(filePath, st) {
378
- try {
379
- const start = path.dirname(filePath)
380
- let dir = start
381
- const stop = path.resolve(this.kernel.homedir)
382
- const visited = []
383
- let base = null
384
- for (let depth = 0; depth < 12; depth++) {
385
- if (this.metadataBaseCache.has(dir)) {
386
- base = this.metadataBaseCache.get(dir)
387
- break
388
- }
389
- visited.push(dir)
390
- const metaRoot = path.resolve(dir, ".cache", "huggingface")
391
- let exists = false
392
- try {
393
- exists = (await fs.promises.stat(metaRoot)).isDirectory()
394
- } catch (e) {}
395
- if (exists) {
396
- base = dir
397
- break
398
- }
399
- if (dir === stop || path.dirname(dir) === dir) break
400
- dir = path.dirname(dir)
401
- }
402
- for (const visitedDir of visited) this.metadataBaseCache.set(visitedDir, base)
403
- if (!base) return null
404
- const relative = path.relative(base, filePath)
405
- const metaPath = path.resolve(base, ".cache", "huggingface", relative + ".metadata")
406
- let raw
407
- try {
408
- raw = await fs.promises.readFile(metaPath, "utf8")
409
- } catch (e) {
410
- return null
411
- }
412
- const lines = raw.split("\n")
413
- const etag = String(lines[1] || "").replace(/"/g, "").trim().toLowerCase()
414
- const ts = parseFloat(lines[2] || "0") * 1000
415
- if (SHA256_RE.test(etag) && st.mtimeMs <= ts + 1000) return etag
416
- } catch (e) {}
417
- return null
418
- }
419
- appNameFor(filePath) {
420
- return this.vault.appNameFor(filePath)
421
- }
422
- // User-triggered batch conversion (the Deduplicate buttons). Locked files
423
- // stay pending; the user can retry after stopping the app.
424
- async convertPending(appName, opts = {}) {
425
- const registry = this.vault.registry
426
- const batch = opts.batch_id || `batch-${Date.now()}`
427
- const scopeId = opts.scope_id || null
428
- const summary = { converted: 0, bytes_saved: 0, locked: 0, unavailable: 0, failed: 0 }
429
- for (const [filePath, entry] of [...registry.duplicates]) {
430
- const location = this.vault.locationForPath(filePath, entry.source_id)
431
- if (scopeId && location.source_id !== scopeId) continue
432
- if (!scopeId && appName !== undefined && (entry.app || null) !== appName) continue
433
- try {
434
- if (scopeId) {
435
- const source = this.vault.sourceForPath(filePath, location.source_id)
436
- let targetStat = null
437
- let storeStat = null
438
- try {
439
- targetStat = await fs.promises.stat(filePath)
440
- storeStat = await fs.promises.stat(this.vault.storePathFor(entry.hash))
441
- } catch (e) {}
442
- if (!source || !source.shareable || !targetStat || !storeStat || targetStat.dev !== storeStat.dev) {
443
- summary.unavailable += 1
444
- continue
445
- }
446
- }
447
- const result = await this.vault.convert(filePath, entry.hash, {
448
- app: entry.app, source_id: location.source_id, batch_id: batch
449
- })
450
- if (result.status === "converted" || result.status === "already") {
451
- registry.duplicates.delete(filePath)
452
- summary.converted += 1
453
- summary.bytes_saved += result.bytes_saved || 0
454
- } else if (result.status === "locked") {
455
- summary.locked += 1
456
- } else if (result.status === "unavailable" || result.status === "copy-mode") {
457
- summary.unavailable += 1
458
- } else {
459
- summary.failed += 1
460
- }
461
- } catch (e) {
462
- summary.failed += 1
463
- }
464
- }
465
- registry.schedulePersist()
466
- return summary
467
- }
468
- }
469
-
470
- module.exports = Sweeper