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.
- package/kernel/api/hf/index.js +2 -2
- package/kernel/api/index.js +12 -3
- package/kernel/connect/index.js +2 -2
- package/kernel/connect/providers/huggingface/index.js +14 -5
- package/kernel/index.js +14 -2
- package/kernel/shell.js +3 -2
- package/kernel/shells.js +6 -0
- package/kernel/vault/constants.js +13 -0
- package/kernel/vault/hash_worker.js +9 -2
- package/kernel/vault/index.js +1856 -316
- package/kernel/vault/registry.js +526 -52
- package/kernel/vault/snapshot.js +29 -0
- package/kernel/vault/sweeper.js +480 -210
- package/kernel/vault/walker.js +142 -0
- package/package.json +1 -1
- package/server/index.js +79 -90
- 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/connect/huggingface.ejs +7 -9
- package/server/views/partials/main_sidebar.ejs +6 -1
- package/server/views/partials/vault_workspace.ejs +55 -0
- package/server/views/vault.ejs +5 -1532
- package/server/views/vault_app.ejs +22 -0
- package/test/hf-api.test.js +4 -2
- package/test/huggingface-connect.test.js +7 -11
- package/test/huggingface-token-validity.test.js +135 -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 +1276 -26
- package/test/vault-sweep.test.js +1043 -35
- package/test/vault-ui.test.js +2184 -65
package/kernel/vault/sweeper.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
61
|
-
|
|
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(), {
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (entry.
|
|
157
|
-
|
|
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 =
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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]
|
|
365
|
+
if (stats[index] && stats[index].isFile()) {
|
|
366
|
+
await this.considerStat(filePaths[index], stats[index], preferredSourceId)
|
|
367
|
+
}
|
|
192
368
|
}
|
|
193
369
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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.
|
|
223
|
-
if (unchanged
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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.
|
|
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(
|
|
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.
|
|
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.
|
|
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 (
|
|
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.
|
|
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
|
|
316
|
-
if (staleStat.dev === st.dev && staleStat.ino === st.ino) {
|
|
317
|
-
|
|
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 (
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
327
|
-
} catch (
|
|
328
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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.
|
|
349
|
-
size: st.size, mtime: st.mtimeMs,
|
|
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.
|
|
361
|
-
} catch (e) {
|
|
362
|
-
|
|
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 (
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|