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