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.
- package/kernel/api/index.js +12 -3
- package/kernel/index.js +24 -0
- package/kernel/shell.js +2 -1
- package/kernel/vault/constants.js +13 -0
- package/kernel/vault/hash_worker.js +31 -0
- package/kernel/vault/index.js +2360 -0
- package/kernel/vault/registry.js +643 -0
- package/kernel/vault/snapshot.js +29 -0
- package/kernel/vault/sweeper.js +740 -0
- package/kernel/vault/walker.js +142 -0
- package/package.json +1 -1
- package/server/index.js +109 -0
- package/server/lib/privacy_filter_cache.js +4 -1
- package/server/public/storage-size.js +12 -0
- package/server/public/style.css +13 -0
- package/server/public/tab-link-popover.js +3 -0
- package/server/public/vault-nav.js +96 -0
- package/server/public/vault.css +1079 -0
- package/server/public/vault.js +1835 -0
- package/server/views/app.ejs +93 -16
- package/server/views/partials/main_sidebar.ejs +6 -0
- package/server/views/partials/vault_workspace.ejs +55 -0
- package/server/views/vault.ejs +29 -0
- package/server/views/vault_app.ejs +22 -0
- package/test/plugin-action-functions.test.js +19 -0
- package/test/privacy-filter-cache.test.js +1 -0
- package/test/vault-engine.test.js +1610 -0
- package/test/vault-sweep.test.js +1344 -0
- package/test/vault-ui.test.js +2443 -0
|
@@ -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
|