pinokiod 8.0.39 → 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.
Files changed (37) hide show
  1. package/kernel/api/hf/index.js +2 -2
  2. package/kernel/api/index.js +12 -3
  3. package/kernel/connect/index.js +2 -2
  4. package/kernel/connect/providers/huggingface/index.js +14 -5
  5. package/kernel/index.js +14 -2
  6. package/kernel/shell.js +3 -2
  7. package/kernel/shells.js +6 -0
  8. package/kernel/vault/constants.js +13 -0
  9. package/kernel/vault/hash_worker.js +9 -2
  10. package/kernel/vault/index.js +1856 -316
  11. package/kernel/vault/registry.js +526 -52
  12. package/kernel/vault/snapshot.js +29 -0
  13. package/kernel/vault/sweeper.js +480 -210
  14. package/kernel/vault/walker.js +142 -0
  15. package/package.json +1 -1
  16. package/server/index.js +79 -90
  17. package/server/lib/privacy_filter_cache.js +4 -1
  18. package/server/public/storage-size.js +12 -0
  19. package/server/public/style.css +13 -0
  20. package/server/public/tab-link-popover.js +3 -0
  21. package/server/public/vault-nav.js +96 -0
  22. package/server/public/vault.css +1079 -0
  23. package/server/public/vault.js +1835 -0
  24. package/server/views/app.ejs +93 -16
  25. package/server/views/connect/huggingface.ejs +7 -9
  26. package/server/views/partials/main_sidebar.ejs +6 -1
  27. package/server/views/partials/vault_workspace.ejs +55 -0
  28. package/server/views/vault.ejs +5 -1532
  29. package/server/views/vault_app.ejs +22 -0
  30. package/test/hf-api.test.js +4 -2
  31. package/test/huggingface-connect.test.js +7 -11
  32. package/test/huggingface-token-validity.test.js +135 -0
  33. package/test/plugin-action-functions.test.js +19 -0
  34. package/test/privacy-filter-cache.test.js +1 -0
  35. package/test/vault-engine.test.js +1276 -26
  36. package/test/vault-sweep.test.js +1043 -35
  37. package/test/vault-ui.test.js +2184 -65
@@ -1,6 +1,31 @@
1
1
  const fs = require('fs')
2
2
  const path = require('path')
3
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
+ }
4
29
 
5
30
  // Manual scan engine (spec/requirements/shared-model-store.md).
6
31
  // Scans run ONLY when the user asks — there are no automatic triggers.
@@ -10,28 +35,6 @@ const fastq = require('fastq')
10
35
  // which mutates nothing) and lists byte-identical copies as pending, for the
11
36
  // user to deduplicate explicitly.
12
37
 
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
38
  class Sweeper {
36
39
  constructor(vault) {
37
40
  this.vault = vault
@@ -39,47 +42,87 @@ class Sweeper {
39
42
  this.state = this.idleState()
40
43
  this.currentHash = null
41
44
  this.hashQueue = fastq.promise(this, this._hashJob, 1)
42
- this.stats = { hashes: 0, ingested: 0 }
43
45
  this.statConcurrency = vault.statConcurrency || STAT_CONCURRENCY
44
46
  this.dirConcurrency = vault.dirConcurrency || DIR_CONCURRENCY
47
+ this.hashQueueLimit = vault.hashQueueLimit || HASH_QUEUE_LIMIT
48
+ this.hashCapacityWaiters = []
45
49
  this.metadataBaseCache = new Map()
46
50
  this.hashJobsByIno = new Map()
51
+ this.completedHashesByIno = new Map()
52
+ this.inaccessiblePaths = new Set()
53
+ this.inaccessibleCodes = new Map()
47
54
  }
48
55
  idleState() {
49
56
  return {
50
- active: false, dirs: 0, files: 0, bytes_total: 0,
51
- home_bytes_total: 0, source_bytes: {},
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,
52
60
  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,
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,
55
64
  started: null, duration_ms: null,
56
- walk_duration_ms: null, hash_wait_duration_ms: null,
65
+ count_duration_ms: null, walk_duration_ms: null, hash_wait_duration_ms: null,
57
66
  hash_duration_ms: 0
58
67
  }
59
68
  }
60
- // The one entry point: the Pinokio home plus explicit linked imports.
61
- async scan() {
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) {
62
72
  if (this.state.active) return { already_running: true }
63
- this.state = Object.assign(this.idleState(), { active: true, started: Date.now() })
73
+ this.state = Object.assign(this.idleState(), {
74
+ active: true, phase: "counting", started: Date.now(), scope_id: scopeId
75
+ })
64
76
  this.metadataBaseCache.clear()
65
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
66
91
  try {
67
92
  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))
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
+ }
82
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"
83
126
  const walkStarted = Date.now()
84
127
  for (const source of scanRoots) {
85
128
  if (!Object.prototype.hasOwnProperty.call(this.state.source_bytes, source.source_id)) {
@@ -88,80 +131,219 @@ class Sweeper {
88
131
  await this.walk(source.root, source.source_id === "pinokio" ? null : source.source_id)
89
132
  }
90
133
  this.state.walk_duration_ms = Date.now() - walkStarted
134
+ this.state.phase = "analyzing"
91
135
  const hashWaitStarted = Date.now()
92
136
  await this.settle()
93
137
  this.state.hash_wait_duration_ms = Date.now() - hashWaitStarted
94
- this.state.duration_ms = Date.now() - this.state.started
95
- this.vault.registry.setLastScan({
138
+ const scanMeta = {
96
139
  ts: Date.now(),
140
+ complete: true,
141
+ candidate_min_bytes: this.vault.sizeThreshold,
97
142
  dirs: this.state.dirs,
98
143
  files: this.state.files,
99
144
  bytes_total: this.state.bytes_total,
100
145
  home_bytes_total: this.state.home_bytes_total,
101
146
  source_bytes: Object.assign({}, this.state.source_bytes),
102
- source_ids: this.state.source_ids.slice(),
147
+ source_files: Object.assign({}, this.state.source_files),
148
+ source_hash_failures: Object.assign({}, this.state.source_hash_failures),
103
149
  candidates: this.state.candidates,
104
150
  hashed: this.state.hashed,
105
151
  hash_total: this.state.hash_total,
106
152
  hash_bytes: this.state.hash_bytes,
107
153
  inode_reuses: this.state.inode_reuses,
108
154
  unstable_hashes: this.state.unstable_hashes,
155
+ hash_failures: this.state.hash_failures,
156
+ count_duration_ms: this.state.count_duration_ms,
109
157
  walk_duration_ms: this.state.walk_duration_ms,
110
158
  hash_wait_duration_ms: this.state.hash_wait_duration_ms,
111
159
  hash_duration_ms: this.state.hash_duration_ms,
112
- duration_ms: this.state.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)
113
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
114
230
  } finally {
115
231
  // A failed walk must not leave background hash work or per-scan inode
116
232
  // state alive when the next manual scan starts.
117
233
  await this.settle().catch(() => {})
234
+ await this.vault.registry.eventPromise
118
235
  this.hashJobsByIno.clear()
236
+ this.completedHashesByIno.clear()
119
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
+ }
120
248
  }
121
249
  return {
122
250
  dirs: this.state.dirs, files: this.state.files,
123
- bytes_total: this.state.bytes_total, candidates: this.state.candidates
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)
124
310
  }
125
311
  }
126
312
  async settle() {
127
313
  await this.hashQueue.drained()
314
+ if (this.fatalError) {
315
+ const error = this.fatalError
316
+ this.fatalError = null
317
+ throw error
318
+ }
128
319
  }
129
320
  async walk(root, preferredSourceId = null) {
130
321
  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
- }))
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
146
331
  const files = []
147
332
  const hfDirectories = []
148
- for (const { dir, entries } of directoryResults) {
333
+ for (const { dir, entries, files: directoryFiles } of directoryResults) {
149
334
  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)
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)
163
343
  }
344
+ else files.push(file.path)
164
345
  }
346
+ if (hashEntries.length) hfDirectories.push({ dir, entries: hashEntries })
165
347
  }
166
348
  // One metadata pool is shared by all files in this directory batch, so
167
349
  // trees made of many small directories still use the bounded workers.
@@ -172,68 +354,75 @@ class Sweeper {
172
354
  }
173
355
  }
174
356
  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
- }
357
+ const stats = await statMany(filePaths, this.statConcurrency, null, {
358
+ followSymlinks: false,
359
+ strictErrors: true,
360
+ onError: (error, filePath) => this.recordInaccessible(error, filePath)
186
361
  })
187
- await Promise.all(workers)
188
362
  // Preserve deterministic classification order while parallelizing only
189
363
  // the expensive filesystem metadata reads.
190
364
  for (let index = 0; index < filePaths.length; index++) {
191
- if (stats[index]) await this.considerStat(filePaths[index], stats[index], preferredSourceId)
365
+ if (stats[index] && stats[index].isFile()) {
366
+ await this.considerStat(filePaths[index], stats[index], preferredSourceId)
367
+ }
192
368
  }
193
369
  }
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
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
201
375
  }
202
- await this.considerStat(filePath, st, preferredSourceId)
203
376
  }
204
- async considerStat(filePath, st, preferredSourceId = null) {
377
+ async considerStat(filePath, st, preferredSourceId = null, options = {}) {
205
378
  if (filePath.endsWith(TMP_SUFFIX)) return
206
379
  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
380
  const source = this.vault.sourceForPath(filePath, preferredSourceId)
216
381
  const sourceId = source ? source.id : null
217
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
+ }
218
403
  // Known and unchanged: nothing to do.
219
404
  const inoHash = registry.byIno.get(registry.inoKey(st.dev, st.ino))
220
405
  if (inoHash && st.nlink > 1) {
221
406
  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)
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
+ })
228
413
  return
229
414
  }
230
415
  }
231
416
  const cached = registry.scanIndex.get(filePath)
232
- if (cached && cached.size === st.size && cached.mtime === st.mtimeMs && cached.ino === st.ino && cached.hash) {
417
+ if (cached && cached.hash && sameSnapshot(cached, st)) {
233
418
  await this.classify(filePath, st, cached.hash, sourceId)
234
419
  return
235
420
  }
236
- const harvested = await this.harvestLocalDirEtag(filePath, st)
421
+ const harvested = await this.harvestLocalDirEtag(
422
+ filePath,
423
+ st,
424
+ source && source.root ? source.root : this.kernel.homedir
425
+ )
237
426
  if (harvested) {
238
427
  await this.classify(filePath, st, harvested, sourceId)
239
428
  return
@@ -250,11 +439,47 @@ class Sweeper {
250
439
  this.state.inode_reuses += 1
251
440
  return
252
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)
253
449
  const job = { inodeKey, names: [name] }
254
450
  if (inodeKey) this.hashJobsByIno.set(inodeKey, job)
255
451
  this.state.hash_total += 1
256
452
  this.state.queued += 1
257
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
258
483
  }
259
484
  async _hashJob(job) {
260
485
  const primary = job.names[0]
@@ -262,10 +487,9 @@ class Sweeper {
262
487
  this.currentHash = { path: primary.filePath }
263
488
  try {
264
489
  const { hash, size } = await this.vault.hashFile(primary.filePath)
265
- this.stats.hashes += 1
266
490
  this.state.hashed += 1
267
491
  this.state.hash_bytes += size || 0
268
- const primaryStat = await fs.promises.stat(primary.filePath)
492
+ const primaryStat = await fs.promises.lstat(primary.filePath)
269
493
  // Never publish a digest if the path changed while its bytes were read.
270
494
  // The next manual scan can retry a continuously-mutating file safely.
271
495
  if (size !== primaryStat.size || !sameSnapshot(primary.expected, primaryStat)) {
@@ -277,19 +501,35 @@ class Sweeper {
277
501
  const name = job.names[index++]
278
502
  let st
279
503
  try {
280
- st = name === primary ? primaryStat : await fs.promises.stat(name.filePath)
504
+ st = name === primary ? primaryStat : await fs.promises.lstat(name.filePath)
281
505
  } catch (e) {
282
- continue
506
+ if (isMissingError(e) || this.recordInaccessible(e, name.filePath)) continue
507
+ throw e
283
508
  }
284
509
  // Same (device, inode) is physical identity, not a content guess.
285
510
  // Size and mtime also prevent applying a completed digest after an
286
511
  // in-place mutation. ctime is omitted here because adoption itself
287
512
  // 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
513
+ if (!sameContentState(fileSnapshot(primaryStat), st)) continue
290
514
  await this.classify(name.filePath, st, hash, name.source_id)
291
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
+ }
292
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
+ }
293
533
  } finally {
294
534
  this.state.hash_duration_ms += Date.now() - started
295
535
  if (job.inodeKey && this.hashJobsByIno.get(job.inodeKey) === job) {
@@ -297,58 +537,113 @@ class Sweeper {
297
537
  }
298
538
  this.currentHash = null
299
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
+ }
300
543
  }
301
544
  }
302
545
  // hash known → adopt if new content; otherwise a byte-identical copy:
303
546
  // ALWAYS pending, never converted by a scan.
304
547
  async classify(filePath, st, hash, preferredSourceId = null) {
305
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
+ }
306
564
  const source = this.vault.sourceForPath(filePath, preferredSourceId)
307
565
  const sourceId = source ? source.id : null
308
566
  const appName = source && source.kind === "app" ? source.app : null
309
567
  // Divergence: this inode was registered under a different hash — the
310
- // shared content was written in place. Evict the stale blob.
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.
311
571
  const priorHash = registry.byIno.get(registry.inoKey(st.dev, st.ino))
312
572
  if (priorHash && priorHash !== hash) {
313
573
  const stalePath = this.vault.storePathFor(priorHash)
574
+ let retiredStaleStore = false
314
575
  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)
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
318
584
  }
319
- } catch (e) {}
320
- registry.removeBlob(priorHash)
321
- registry.appendEvent({ kind: "diverged", hash: priorHash, path: filePath, app: appName, source_id: sourceId, size: st.size })
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 })
322
591
  }
323
592
  const storePath = this.vault.storePathFor(hash)
324
593
  let storeStat = null
325
594
  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
595
+ storeStat = await this.vault.storeStatIfPresent(storePath)
596
+ } catch (error) {
597
+ if (!isMissingError(error)) throw error
332
598
  }
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" })
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
336
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
+ })
337
626
  this.updateScanIndex(filePath, st, hash, sourceId)
338
627
  return
339
628
  }
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 })
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 })
344
640
  }
345
- this.updateScanIndex(filePath, st, hash, sourceId)
346
641
  }
347
642
  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
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
350
646
  })
351
- this.vault.registry.schedulePersist()
352
647
  }
353
648
  async ingestHfBlobs(dir, entries, preferredSourceId = null) {
354
649
  for (const entry of entries) {
@@ -357,28 +652,48 @@ class Sweeper {
357
652
  const full = path.resolve(dir, name)
358
653
  let st
359
654
  try {
360
- st = await fs.promises.stat(full)
361
- } catch (e) { continue }
362
- this.state.files += 1
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)
363
666
  this.state.bytes_total += st.size
364
- const sourceKey = preferredSourceId || "pinokio"
365
667
  this.state.source_bytes[sourceKey] = (this.state.source_bytes[sourceKey] || 0) + st.size
366
668
  if (!preferredSourceId) this.state.home_bytes_total += st.size
367
669
  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
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
+ }
372
685
  await this.classify(full, st, name, preferredSourceId)
373
686
  }
374
687
  }
375
688
  // hf CLI --local-dir metadata: etag is the sha256 for LFS files; freshness
376
689
  // via the recorded timestamp against mtime (huggingface_hub's own rule).
377
- async harvestLocalDirEtag(filePath, st) {
690
+ async harvestLocalDirEtag(filePath, st, sourceRoot) {
378
691
  try {
379
692
  const start = path.dirname(filePath)
380
693
  let dir = start
381
- const stop = path.resolve(this.kernel.homedir)
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)
382
697
  const visited = []
383
698
  let base = null
384
699
  for (let depth = 0; depth < 12; depth++) {
@@ -390,7 +705,7 @@ class Sweeper {
390
705
  const metaRoot = path.resolve(dir, ".cache", "huggingface")
391
706
  let exists = false
392
707
  try {
393
- exists = (await fs.promises.stat(metaRoot)).isDirectory()
708
+ exists = (await fs.promises.lstat(metaRoot)).isDirectory()
394
709
  } catch (e) {}
395
710
  if (exists) {
396
711
  base = dir
@@ -402,69 +717,24 @@ class Sweeper {
402
717
  for (const visitedDir of visited) this.metadataBaseCache.set(visitedDir, base)
403
718
  if (!base) return null
404
719
  const relative = path.relative(base, filePath)
405
- const metaPath = path.resolve(base, ".cache", "huggingface", relative + ".metadata")
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")
406
724
  let raw
407
725
  try {
408
- raw = await fs.promises.readFile(metaPath, "utf8")
726
+ raw = await readFileNoFollow(metaPath)
409
727
  } catch (e) {
410
728
  return null
411
729
  }
730
+ if (raw === null) return null
412
731
  const lines = raw.split("\n")
413
732
  const etag = String(lines[1] || "").replace(/"/g, "").trim().toLowerCase()
414
733
  const ts = parseFloat(lines[2] || "0") * 1000
415
- if (SHA256_RE.test(etag) && st.mtimeMs <= ts + 1000) return etag
734
+ if (SHA256_RE.test(etag) && ts >= st.mtimeMs) return etag
416
735
  } catch (e) {}
417
736
  return null
418
737
  }
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
738
  }
469
739
 
470
740
  module.exports = Sweeper