pinokiod 8.0.36 → 8.0.37
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/index.js +12 -0
- package/kernel/vault/hash_worker.js +19 -0
- package/kernel/vault/index.js +651 -0
- package/kernel/vault/registry.js +169 -0
- package/kernel/vault/sweeper.js +309 -0
- package/package.json +1 -1
- package/server/index.js +91 -0
- package/server/views/partials/main_sidebar.ejs +1 -0
- package/server/views/vault.ejs +1275 -0
- package/test/vault-engine.test.js +273 -0
- package/test/vault-sweep.test.js +233 -0
- package/test/vault-ui.test.js +216 -0
|
@@ -0,0 +1,169 @@
|
|
|
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 } user chose "keep independent"
|
|
23
|
+
this.lastScan = null // { ts, dirs, files, bytes_total, home_bytes_total, source_bytes }
|
|
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
|
|
@@ -0,0 +1,309 @@
|
|
|
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
|
+
|
|
16
|
+
class Sweeper {
|
|
17
|
+
constructor(vault) {
|
|
18
|
+
this.vault = vault
|
|
19
|
+
this.kernel = vault.kernel
|
|
20
|
+
this.state = this.idleState()
|
|
21
|
+
this.currentHash = null
|
|
22
|
+
this.hashQueue = fastq.promise(this, this._hashJob, 1)
|
|
23
|
+
this.stats = { hashes: 0, ingested: 0 }
|
|
24
|
+
}
|
|
25
|
+
idleState() {
|
|
26
|
+
return {
|
|
27
|
+
active: false, dirs: 0, files: 0, bytes_total: 0,
|
|
28
|
+
home_bytes_total: 0, source_bytes: {},
|
|
29
|
+
candidates: 0, hashed: 0, queued: 0, started: null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// The one entry point: the Pinokio home plus explicit linked imports.
|
|
33
|
+
async scan() {
|
|
34
|
+
if (this.state.active) return { already_running: true }
|
|
35
|
+
this.state = Object.assign(this.idleState(), { active: true, started: Date.now() })
|
|
36
|
+
try {
|
|
37
|
+
await this.vault.refreshSources()
|
|
38
|
+
for (const source of this.vault.scanRoots()) {
|
|
39
|
+
await this.walk(source.root, source.source_id === "pinokio" ? null : source.source_id)
|
|
40
|
+
}
|
|
41
|
+
await this.settle()
|
|
42
|
+
this.vault.registry.setLastScan({
|
|
43
|
+
ts: Date.now(),
|
|
44
|
+
dirs: this.state.dirs,
|
|
45
|
+
files: this.state.files,
|
|
46
|
+
bytes_total: this.state.bytes_total,
|
|
47
|
+
home_bytes_total: this.state.home_bytes_total,
|
|
48
|
+
source_bytes: Object.assign({}, this.state.source_bytes)
|
|
49
|
+
})
|
|
50
|
+
} finally {
|
|
51
|
+
this.state.active = false
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
dirs: this.state.dirs, files: this.state.files,
|
|
55
|
+
bytes_total: this.state.bytes_total, candidates: this.state.candidates
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async settle() {
|
|
59
|
+
await this.hashQueue.drained()
|
|
60
|
+
}
|
|
61
|
+
async walk(root, preferredSourceId = null) {
|
|
62
|
+
const vaultRoot = this.vault.root
|
|
63
|
+
const stack = [root]
|
|
64
|
+
while (stack.length) {
|
|
65
|
+
const dir = stack.pop()
|
|
66
|
+
this.state.dirs += 1
|
|
67
|
+
let entries
|
|
68
|
+
try {
|
|
69
|
+
entries = await fs.promises.readdir(dir, { withFileTypes: true })
|
|
70
|
+
} catch (e) {
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
// HF hub blobs dir: filenames ARE sha256 for LFS files — no hashing.
|
|
74
|
+
if (path.basename(dir) === "blobs" && /^(models|datasets|spaces)--/.test(path.basename(path.dirname(dir)))) {
|
|
75
|
+
await this.ingestHfBlobs(dir, entries, preferredSourceId)
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (entry.isSymbolicLink()) continue
|
|
80
|
+
const full = path.resolve(dir, entry.name)
|
|
81
|
+
if (entry.isDirectory()) {
|
|
82
|
+
if (full === vaultRoot) continue
|
|
83
|
+
stack.push(full)
|
|
84
|
+
} else if (entry.isFile()) {
|
|
85
|
+
await this.considerFile(full, preferredSourceId)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async considerFile(filePath, preferredSourceId = null) {
|
|
91
|
+
if (filePath.endsWith(TMP_SUFFIX)) return
|
|
92
|
+
const registry = this.vault.registry
|
|
93
|
+
let st
|
|
94
|
+
try {
|
|
95
|
+
st = await fs.promises.stat(filePath)
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
this.state.files += 1
|
|
100
|
+
this.state.bytes_total += st.size
|
|
101
|
+
const sourceKey = preferredSourceId || "pinokio"
|
|
102
|
+
this.state.source_bytes[sourceKey] = (this.state.source_bytes[sourceKey] || 0) + st.size
|
|
103
|
+
if (!preferredSourceId) this.state.home_bytes_total += st.size
|
|
104
|
+
if (st.size < this.vault.sizeThreshold) return
|
|
105
|
+
if (registry.excluded.has(filePath)) return
|
|
106
|
+
this.state.candidates += 1
|
|
107
|
+
const source = this.vault.sourceForPath(filePath, preferredSourceId)
|
|
108
|
+
const sourceId = source ? source.id : null
|
|
109
|
+
const appName = source && source.kind === "app" ? source.app : null
|
|
110
|
+
// Known and unchanged: nothing to do.
|
|
111
|
+
const inoHash = registry.byIno.get(registry.inoKey(st.dev, st.ino))
|
|
112
|
+
if (inoHash && st.nlink > 1) {
|
|
113
|
+
const cached = registry.scanIndex.get(filePath)
|
|
114
|
+
const unchanged = cached && cached.size === st.size && cached.mtime === st.mtimeMs && cached.ino === st.ino
|
|
115
|
+
if (unchanged || !cached) {
|
|
116
|
+
if (!registry.links.has(filePath)) {
|
|
117
|
+
registry.addLink(filePath, { hash: inoHash, app: appName, source_id: sourceId, dev: st.dev, ino: st.ino, mode: "link" })
|
|
118
|
+
}
|
|
119
|
+
if (!cached) this.updateScanIndex(filePath, st, inoHash, sourceId)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const cached = registry.scanIndex.get(filePath)
|
|
124
|
+
if (cached && cached.size === st.size && cached.mtime === st.mtimeMs && cached.ino === st.ino && cached.hash) {
|
|
125
|
+
await this.classify(filePath, st, cached.hash, sourceId)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
const harvested = await this.harvestLocalDirEtag(filePath, st)
|
|
129
|
+
if (harvested) {
|
|
130
|
+
await this.classify(filePath, st, harvested, sourceId)
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
this.state.queued += 1
|
|
134
|
+
this.hashQueue.push({ filePath, source_id: sourceId }).catch(() => {})
|
|
135
|
+
}
|
|
136
|
+
async _hashJob({ filePath, source_id }) {
|
|
137
|
+
this.currentHash = { path: filePath }
|
|
138
|
+
try {
|
|
139
|
+
const { hash } = await this.vault.hashFile(filePath)
|
|
140
|
+
this.stats.hashes += 1
|
|
141
|
+
this.state.hashed += 1
|
|
142
|
+
const st = await fs.promises.stat(filePath)
|
|
143
|
+
await this.classify(filePath, st, hash, source_id)
|
|
144
|
+
} catch (e) {
|
|
145
|
+
} finally {
|
|
146
|
+
this.currentHash = null
|
|
147
|
+
this.state.queued = Math.max(0, this.state.queued - 1)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// hash known → adopt if new content; otherwise a byte-identical copy:
|
|
151
|
+
// ALWAYS pending, never converted by a scan.
|
|
152
|
+
async classify(filePath, st, hash, preferredSourceId = null) {
|
|
153
|
+
const registry = this.vault.registry
|
|
154
|
+
const source = this.vault.sourceForPath(filePath, preferredSourceId)
|
|
155
|
+
const sourceId = source ? source.id : null
|
|
156
|
+
const appName = source && source.kind === "app" ? source.app : null
|
|
157
|
+
// Divergence: this inode was registered under a different hash — the
|
|
158
|
+
// shared content was written in place. Evict the stale blob.
|
|
159
|
+
const priorHash = registry.byIno.get(registry.inoKey(st.dev, st.ino))
|
|
160
|
+
if (priorHash && priorHash !== hash) {
|
|
161
|
+
const stalePath = this.vault.storePathFor(priorHash)
|
|
162
|
+
try {
|
|
163
|
+
const staleStat = await fs.promises.stat(stalePath)
|
|
164
|
+
if (staleStat.dev === st.dev && staleStat.ino === st.ino) {
|
|
165
|
+
await fs.promises.unlink(stalePath)
|
|
166
|
+
}
|
|
167
|
+
} catch (e) {}
|
|
168
|
+
registry.removeBlob(priorHash)
|
|
169
|
+
registry.appendEvent({ kind: "diverged", hash: priorHash, path: filePath, app: appName, source_id: sourceId, size: st.size })
|
|
170
|
+
}
|
|
171
|
+
const storePath = this.vault.storePathFor(hash)
|
|
172
|
+
let storeStat = null
|
|
173
|
+
try {
|
|
174
|
+
storeStat = await fs.promises.stat(storePath)
|
|
175
|
+
} catch (e) {}
|
|
176
|
+
if (!storeStat && !registry.blobs.has(hash)) {
|
|
177
|
+
await this.vault.adopt(filePath, hash, { app: appName, source_id: sourceId })
|
|
178
|
+
this.updateScanIndex(filePath, st, hash, sourceId)
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
if (storeStat && storeStat.dev === st.dev && storeStat.ino === st.ino) {
|
|
182
|
+
if (!registry.links.has(filePath)) {
|
|
183
|
+
registry.addLink(filePath, { hash, app: appName, source_id: sourceId, dev: st.dev, ino: st.ino, mode: "link" })
|
|
184
|
+
}
|
|
185
|
+
this.updateScanIndex(filePath, st, hash, sourceId)
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
if (!registry.duplicates.has(filePath)) {
|
|
189
|
+
registry.duplicates.set(filePath, { hash, size: st.size, app: appName, source_id: sourceId, discovered: Date.now() })
|
|
190
|
+
registry.schedulePersist()
|
|
191
|
+
registry.appendEvent({ kind: "found", hash, path: filePath, app: appName, source_id: sourceId, size: st.size })
|
|
192
|
+
}
|
|
193
|
+
this.updateScanIndex(filePath, st, hash, sourceId)
|
|
194
|
+
}
|
|
195
|
+
updateScanIndex(filePath, st, hash, sourceId = null) {
|
|
196
|
+
this.vault.registry.scanIndex.set(filePath, {
|
|
197
|
+
size: st.size, mtime: st.mtimeMs, dev: st.dev, ino: st.ino, hash: hash || null, source_id: sourceId
|
|
198
|
+
})
|
|
199
|
+
this.vault.registry.schedulePersist()
|
|
200
|
+
}
|
|
201
|
+
async ingestHfBlobs(dir, entries, preferredSourceId = null) {
|
|
202
|
+
for (const entry of entries) {
|
|
203
|
+
if (!entry.isFile() || entry.isSymbolicLink()) continue
|
|
204
|
+
const name = entry.name
|
|
205
|
+
const full = path.resolve(dir, name)
|
|
206
|
+
let st
|
|
207
|
+
try {
|
|
208
|
+
st = await fs.promises.stat(full)
|
|
209
|
+
} catch (e) { continue }
|
|
210
|
+
this.state.files += 1
|
|
211
|
+
this.state.bytes_total += st.size
|
|
212
|
+
const sourceKey = preferredSourceId || "pinokio"
|
|
213
|
+
this.state.source_bytes[sourceKey] = (this.state.source_bytes[sourceKey] || 0) + st.size
|
|
214
|
+
if (!preferredSourceId) this.state.home_bytes_total += st.size
|
|
215
|
+
if (!SHA256_RE.test(name)) continue
|
|
216
|
+
if (st.size < this.vault.sizeThreshold) continue
|
|
217
|
+
if (this.vault.registry.excluded.has(full)) continue
|
|
218
|
+
this.state.candidates += 1
|
|
219
|
+
this.stats.ingested += 1
|
|
220
|
+
await this.classify(full, st, name, preferredSourceId)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// hf CLI --local-dir metadata: etag is the sha256 for LFS files; freshness
|
|
224
|
+
// via the recorded timestamp against mtime (huggingface_hub's own rule).
|
|
225
|
+
async harvestLocalDirEtag(filePath, st) {
|
|
226
|
+
try {
|
|
227
|
+
let dir = path.dirname(filePath)
|
|
228
|
+
const stop = path.resolve(this.kernel.homedir)
|
|
229
|
+
const rel = []
|
|
230
|
+
for (let depth = 0; depth < 12; depth++) {
|
|
231
|
+
const metaRoot = path.resolve(dir, ".cache", "huggingface")
|
|
232
|
+
let exists = false
|
|
233
|
+
try {
|
|
234
|
+
exists = (await fs.promises.stat(metaRoot)).isDirectory()
|
|
235
|
+
} catch (e) {}
|
|
236
|
+
if (exists) {
|
|
237
|
+
const metaPath = path.resolve(metaRoot, ...rel, path.basename(filePath) + ".metadata")
|
|
238
|
+
let raw
|
|
239
|
+
try {
|
|
240
|
+
raw = await fs.promises.readFile(metaPath, "utf8")
|
|
241
|
+
} catch (e) {
|
|
242
|
+
return null
|
|
243
|
+
}
|
|
244
|
+
const lines = raw.split("\n")
|
|
245
|
+
const etag = String(lines[1] || "").replace(/"/g, "").trim().toLowerCase()
|
|
246
|
+
const ts = parseFloat(lines[2] || "0") * 1000
|
|
247
|
+
if (SHA256_RE.test(etag) && st.mtimeMs <= ts + 1000) {
|
|
248
|
+
return etag
|
|
249
|
+
}
|
|
250
|
+
return null
|
|
251
|
+
}
|
|
252
|
+
if (dir === stop || path.dirname(dir) === dir) return null
|
|
253
|
+
rel.unshift(path.basename(dir))
|
|
254
|
+
dir = path.dirname(dir)
|
|
255
|
+
}
|
|
256
|
+
} catch (e) {}
|
|
257
|
+
return null
|
|
258
|
+
}
|
|
259
|
+
appNameFor(filePath) {
|
|
260
|
+
return this.vault.appNameFor(filePath)
|
|
261
|
+
}
|
|
262
|
+
// User-triggered batch conversion (the Deduplicate buttons). Locked files
|
|
263
|
+
// stay pending; the user can retry after stopping the app.
|
|
264
|
+
async convertPending(appName, opts = {}) {
|
|
265
|
+
const registry = this.vault.registry
|
|
266
|
+
const batch = opts.batch_id || `batch-${Date.now()}`
|
|
267
|
+
const scopeId = opts.scope_id || null
|
|
268
|
+
const summary = { converted: 0, bytes_saved: 0, locked: 0, unavailable: 0, failed: 0 }
|
|
269
|
+
for (const [filePath, entry] of [...registry.duplicates]) {
|
|
270
|
+
const location = this.vault.locationForPath(filePath, entry.source_id)
|
|
271
|
+
if (scopeId && location.source_id !== scopeId) continue
|
|
272
|
+
if (!scopeId && appName !== undefined && (entry.app || null) !== appName) continue
|
|
273
|
+
try {
|
|
274
|
+
if (scopeId) {
|
|
275
|
+
const source = this.vault.sourceForPath(filePath, location.source_id)
|
|
276
|
+
let targetStat = null
|
|
277
|
+
let storeStat = null
|
|
278
|
+
try {
|
|
279
|
+
targetStat = await fs.promises.stat(filePath)
|
|
280
|
+
storeStat = await fs.promises.stat(this.vault.storePathFor(entry.hash))
|
|
281
|
+
} catch (e) {}
|
|
282
|
+
if (!source || !source.shareable || !targetStat || !storeStat || targetStat.dev !== storeStat.dev) {
|
|
283
|
+
summary.unavailable += 1
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const result = await this.vault.convert(filePath, entry.hash, {
|
|
288
|
+
app: entry.app, source_id: location.source_id, batch_id: batch
|
|
289
|
+
})
|
|
290
|
+
if (result.status === "converted" || result.status === "already") {
|
|
291
|
+
registry.duplicates.delete(filePath)
|
|
292
|
+
summary.converted += 1
|
|
293
|
+
summary.bytes_saved += result.bytes_saved || 0
|
|
294
|
+
} else if (result.status === "locked") {
|
|
295
|
+
summary.locked += 1
|
|
296
|
+
} else {
|
|
297
|
+
registry.duplicates.delete(filePath)
|
|
298
|
+
summary.failed += 1
|
|
299
|
+
}
|
|
300
|
+
} catch (e) {
|
|
301
|
+
summary.failed += 1
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
registry.schedulePersist()
|
|
305
|
+
return summary
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
module.exports = Sweeper
|
package/package.json
CHANGED
package/server/index.js
CHANGED
|
@@ -15732,6 +15732,97 @@ class Server {
|
|
|
15732
15732
|
// }))
|
|
15733
15733
|
|
|
15734
15734
|
|
|
15735
|
+
// Vault dashboard data: everything from memory, never a walk. The scan
|
|
15736
|
+
// itself runs ONLY via the explicit scan action below.
|
|
15737
|
+
this.app.get("/info/dedup", ex(async (req, res) => {
|
|
15738
|
+
const vault = this.kernel.vault
|
|
15739
|
+
if (!vault || !vault.enabled) {
|
|
15740
|
+
res.json({ enabled: false })
|
|
15741
|
+
return
|
|
15742
|
+
}
|
|
15743
|
+
res.json(await vault.status())
|
|
15744
|
+
}))
|
|
15745
|
+
this.app.get("/vault", ex(async (req, res) => {
|
|
15746
|
+
res.render("vault", { theme: this.theme, agent: req.agent })
|
|
15747
|
+
}))
|
|
15748
|
+
this.app.post("/vault/action", ex(async (req, res) => {
|
|
15749
|
+
const vault = this.kernel.vault
|
|
15750
|
+
if (!vault || !vault.enabled) {
|
|
15751
|
+
res.json({ error: "vault is not enabled" })
|
|
15752
|
+
return
|
|
15753
|
+
}
|
|
15754
|
+
const body = req.body || {}
|
|
15755
|
+
try {
|
|
15756
|
+
switch (body.action) {
|
|
15757
|
+
case "deduplicate": {
|
|
15758
|
+
const scopeId = typeof body.scope_id === "string" ? body.scope_id : null
|
|
15759
|
+
if (scopeId) {
|
|
15760
|
+
const source = vault.sources().find((item) => item.id === scopeId && item.kind !== "virtual")
|
|
15761
|
+
if (!source) {
|
|
15762
|
+
res.json({ error: "That location is no longer available. Scan again to refresh it." })
|
|
15763
|
+
return
|
|
15764
|
+
}
|
|
15765
|
+
if (!source.shareable) {
|
|
15766
|
+
res.json({ error: "This location is on a disk that cannot share space with this vault." })
|
|
15767
|
+
return
|
|
15768
|
+
}
|
|
15769
|
+
if (source.kind === "app") {
|
|
15770
|
+
const appRoot = this.kernel.path("api", source.app) + path.sep
|
|
15771
|
+
const running = this.kernel.api && this.kernel.api.running ? this.kernel.api.running : {}
|
|
15772
|
+
const busy = Object.keys(running).some((k) => running[k] && k.startsWith(appRoot))
|
|
15773
|
+
if (busy) {
|
|
15774
|
+
res.json({ error: "This app has a running script. Stop it first, then deduplicate." })
|
|
15775
|
+
return
|
|
15776
|
+
}
|
|
15777
|
+
}
|
|
15778
|
+
res.json(await vault.sweeper.convertPending(undefined, {
|
|
15779
|
+
scope_id: scopeId, batch_id: `batch-${Date.now()}`
|
|
15780
|
+
}))
|
|
15781
|
+
return
|
|
15782
|
+
}
|
|
15783
|
+
const app = Object.prototype.hasOwnProperty.call(body, "app") ? (body.app || null) : undefined
|
|
15784
|
+
if (app) {
|
|
15785
|
+
const appRoot = this.kernel.path("api", app) + path.sep
|
|
15786
|
+
const running = this.kernel.api && this.kernel.api.running ? this.kernel.api.running : {}
|
|
15787
|
+
const busy = Object.keys(running).some((k) => running[k] && k.startsWith(appRoot))
|
|
15788
|
+
if (busy) {
|
|
15789
|
+
res.json({ error: "This app has a running script. Stop it first, then deduplicate." })
|
|
15790
|
+
return
|
|
15791
|
+
}
|
|
15792
|
+
}
|
|
15793
|
+
res.json(await vault.sweeper.convertPending(app, { batch_id: `batch-${Date.now()}` }))
|
|
15794
|
+
return
|
|
15795
|
+
}
|
|
15796
|
+
case "reclaim":
|
|
15797
|
+
res.json(await vault.reclaim(body.hash))
|
|
15798
|
+
return
|
|
15799
|
+
case "reclaim_all":
|
|
15800
|
+
res.json(await vault.reclaimAll())
|
|
15801
|
+
return
|
|
15802
|
+
case "scan":
|
|
15803
|
+
vault.sweeper.scan().catch(() => {})
|
|
15804
|
+
res.json({ started: true })
|
|
15805
|
+
return
|
|
15806
|
+
case "rebuild":
|
|
15807
|
+
await vault.rebuild()
|
|
15808
|
+
res.json({ done: true })
|
|
15809
|
+
return
|
|
15810
|
+
case "undo":
|
|
15811
|
+
res.json(await vault.undoBatch(body.batch_id))
|
|
15812
|
+
return
|
|
15813
|
+
case "detach":
|
|
15814
|
+
res.json(await vault.detach(body.path))
|
|
15815
|
+
return
|
|
15816
|
+
case "reshare":
|
|
15817
|
+
res.json(await vault.reshare(body.path))
|
|
15818
|
+
return
|
|
15819
|
+
default:
|
|
15820
|
+
res.json({ error: "unknown action" })
|
|
15821
|
+
}
|
|
15822
|
+
} catch (e) {
|
|
15823
|
+
res.json({ error: e && e.message ? e.message : String(e) })
|
|
15824
|
+
}
|
|
15825
|
+
}))
|
|
15735
15826
|
this.app.get("/info/scripts", ex(async (req, res) => {
|
|
15736
15827
|
/*
|
|
15737
15828
|
returns something like this by using the this.kernel.memory.local variable, extracting the api name and adding all running scripts in each associated array, setting the uri as the script path, and the local variables as the local attribute
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
<div class='main-sidebar-section' aria-label="Manage">
|
|
27
27
|
<div class="main-sidebar-section-title">Manage</div>
|
|
28
28
|
<a class="tab <%= sidebarSelected === 'checkpoints' ? 'selected' : '' %>" href="/checkpoints"><i class="fa-solid fa-clock-rotate-left"></i><div class='caption'>Checkpoints</div></a>
|
|
29
|
+
<a class="tab <%= sidebarSelected === 'vault' ? 'selected' : '' %>" href="/vault"><i class="fa-solid fa-hard-drive"></i><div class='caption'>Vault</div></a>
|
|
29
30
|
<a class="tab <%= sidebarSelected === 'tools' ? 'selected' : '' %>" href="/tools"><i class="fa-solid fa-toolbox"></i><div class='caption'>Tools</div></a>
|
|
30
31
|
<a class="tab <%= sidebarSelected === 'plugins' ? 'selected' : '' %>" href="/plugins"><i class="fa-solid fa-plug-circle-bolt"></i><div class='caption'>Plugins</div></a>
|
|
31
32
|
<a class="tab <%= sidebarSelected === 'tasks' ? 'selected' : '' %>" href="/tasks"><i class="fa-solid fa-list-check"></i><div class='caption'>Tasks</div></a>
|