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
package/kernel/index.js
CHANGED
|
@@ -37,6 +37,7 @@ const PinokioDomainRouter = require("./router/pinokio_domain_router")
|
|
|
37
37
|
const Procs = require('./procs')
|
|
38
38
|
const Peer = require('./peer')
|
|
39
39
|
const Git = require('./git')
|
|
40
|
+
const Vault = require('./vault')
|
|
40
41
|
const Connect = require('./connect')
|
|
41
42
|
const Favicon = require('./favicon')
|
|
42
43
|
const AppLauncher = require('./app_launcher')
|
|
@@ -1216,6 +1217,17 @@ class Kernel {
|
|
|
1216
1217
|
await this.git.loadCheckpoints()
|
|
1217
1218
|
console.timeEnd("git.loadCheckpoints")
|
|
1218
1219
|
|
|
1220
|
+
// Shared model store: registry verification only at startup — never a
|
|
1221
|
+
// discovery walk (spec/requirements/shared-model-store.md, trigger 5).
|
|
1222
|
+
this.vault = new Vault(this)
|
|
1223
|
+
if (this.homedir) {
|
|
1224
|
+
this.vault.init().then((result) => {
|
|
1225
|
+
if (result && result.enabled) return this.vault.verify()
|
|
1226
|
+
}).catch((err) => {
|
|
1227
|
+
console.warn("Vault init error:", err && err.message ? err.message : err)
|
|
1228
|
+
})
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1219
1231
|
// let contents = await fs.promises.readdir(this.homedir)
|
|
1220
1232
|
//await this.bin.init()
|
|
1221
1233
|
let ts = Date.now()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const { parentPort } = require('worker_threads')
|
|
2
|
+
const crypto = require('crypto')
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
|
|
5
|
+
parentPort.on('message', ({ id, filePath }) => {
|
|
6
|
+
const hash = crypto.createHash('sha256')
|
|
7
|
+
let size = 0
|
|
8
|
+
const stream = fs.createReadStream(filePath)
|
|
9
|
+
stream.on('data', (chunk) => {
|
|
10
|
+
size += chunk.length
|
|
11
|
+
hash.update(chunk)
|
|
12
|
+
})
|
|
13
|
+
stream.on('error', (error) => {
|
|
14
|
+
parentPort.postMessage({ id, error: error.message })
|
|
15
|
+
})
|
|
16
|
+
stream.on('end', () => {
|
|
17
|
+
parentPort.postMessage({ id, hash: hash.digest('hex'), size })
|
|
18
|
+
})
|
|
19
|
+
})
|
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const os = require('os')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const crypto = require('crypto')
|
|
5
|
+
const { Worker } = require('worker_threads')
|
|
6
|
+
const Registry = require('./registry')
|
|
7
|
+
const Sweeper = require('./sweeper')
|
|
8
|
+
|
|
9
|
+
// Shared model store engine (spec/requirements/shared-model-store.md).
|
|
10
|
+
// Store + registry + volume probe + hashing + adopt/convert/verify, plus the
|
|
11
|
+
// user-facing operations behind the vault dashboard (detach, undo, reclaim).
|
|
12
|
+
// Nothing here runs automatically except startup verify; discovery happens
|
|
13
|
+
// only through the user's manual scan.
|
|
14
|
+
|
|
15
|
+
const SIZE_THRESHOLD = 100 * 1024 * 1024
|
|
16
|
+
const TMP_SUFFIX = ".pinokio-dedup-tmp"
|
|
17
|
+
const PRUNE_DIRS = new Set(["node_modules", ".git", "env", "venv", "__pycache__", "logs"])
|
|
18
|
+
const NO_LINK_CODES = new Set(["EXDEV", "ENOTSUP", "EPERM", "ENOSYS"])
|
|
19
|
+
const LOCK_CODES = new Set(["EBUSY", "EPERM", "EACCES"])
|
|
20
|
+
|
|
21
|
+
const isPathWithin = (root, target) => {
|
|
22
|
+
const rel = path.relative(path.resolve(root), path.resolve(target))
|
|
23
|
+
return rel === "" || (!rel.startsWith(`..${path.sep}`) && rel !== ".." && !path.isAbsolute(rel))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const sourceId = (kind, name) => `${kind}:${encodeURIComponent(name)}`
|
|
27
|
+
|
|
28
|
+
class Vault {
|
|
29
|
+
constructor(kernel) {
|
|
30
|
+
this.kernel = kernel
|
|
31
|
+
this.enabled = false
|
|
32
|
+
this.initialized = false
|
|
33
|
+
this.mode = null // 'link' | 'copy' for the vault's own volume
|
|
34
|
+
this.volumeModes = new Map() // dev -> 'link' | 'copy'
|
|
35
|
+
this.registry = null
|
|
36
|
+
this.worker = null
|
|
37
|
+
this.workerJobs = new Map()
|
|
38
|
+
this.workerSeq = 0
|
|
39
|
+
this.sizeThreshold = SIZE_THRESHOLD
|
|
40
|
+
this._sources = []
|
|
41
|
+
}
|
|
42
|
+
get root() {
|
|
43
|
+
return path.resolve(this.kernel.homedir, "vault")
|
|
44
|
+
}
|
|
45
|
+
get blobRoot() {
|
|
46
|
+
return path.resolve(this.root, "sha256")
|
|
47
|
+
}
|
|
48
|
+
storePathFor(hash) {
|
|
49
|
+
return path.resolve(this.blobRoot, hash.slice(0, 2), hash)
|
|
50
|
+
}
|
|
51
|
+
async refreshSources() {
|
|
52
|
+
const home = path.resolve(this.kernel.homedir)
|
|
53
|
+
const apiRoot = path.resolve(home, "api")
|
|
54
|
+
const sources = [
|
|
55
|
+
{ id: "pinokio", kind: "pinokio", label: "Pinokio", root: home, parent_id: null },
|
|
56
|
+
{ id: "apps", kind: "virtual", label: "Apps", root: apiRoot, parent_id: "pinokio" },
|
|
57
|
+
{ id: "external", kind: "virtual", label: "External folders", root: null, parent_id: null }
|
|
58
|
+
]
|
|
59
|
+
let storeDev = null
|
|
60
|
+
try { storeDev = (await fs.promises.stat(this.root)).dev } catch (e) {}
|
|
61
|
+
const decorate = async (source) => {
|
|
62
|
+
if (!source.root) return source
|
|
63
|
+
try {
|
|
64
|
+
const st = await fs.promises.stat(source.root)
|
|
65
|
+
source.dev = st.dev
|
|
66
|
+
source.available = st.isDirectory()
|
|
67
|
+
source.shareable = source.available && storeDev !== null && st.dev === storeDev && this.mode === "link"
|
|
68
|
+
} catch (e) {
|
|
69
|
+
source.available = false
|
|
70
|
+
source.shareable = false
|
|
71
|
+
}
|
|
72
|
+
return source
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let apiEntries = []
|
|
76
|
+
try { apiEntries = await fs.promises.readdir(apiRoot, { withFileTypes: true }) } catch (e) {}
|
|
77
|
+
const seenExternalRoots = new Set()
|
|
78
|
+
for (const entry of apiEntries) {
|
|
79
|
+
const mountPath = path.resolve(apiRoot, entry.name)
|
|
80
|
+
if (entry.isSymbolicLink()) {
|
|
81
|
+
try {
|
|
82
|
+
const root = await fs.promises.realpath(mountPath)
|
|
83
|
+
const st = await fs.promises.stat(root)
|
|
84
|
+
if (!st.isDirectory()) continue
|
|
85
|
+
const canonical = path.resolve(root)
|
|
86
|
+
if (seenExternalRoots.has(canonical)) continue
|
|
87
|
+
seenExternalRoots.add(canonical)
|
|
88
|
+
sources.push(await decorate({
|
|
89
|
+
id: sourceId("external", entry.name), kind: "external", label: entry.name,
|
|
90
|
+
root: canonical, mount_path: mountPath, parent_id: "external"
|
|
91
|
+
}))
|
|
92
|
+
} catch (e) {}
|
|
93
|
+
} else if (entry.isDirectory()) {
|
|
94
|
+
sources.push(await decorate({
|
|
95
|
+
id: sourceId("app", entry.name), kind: "app", label: entry.name,
|
|
96
|
+
app: entry.name, root: mountPath, parent_id: "apps"
|
|
97
|
+
}))
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let homeEntries = []
|
|
102
|
+
try { homeEntries = await fs.promises.readdir(home, { withFileTypes: true }) } catch (e) {}
|
|
103
|
+
for (const entry of homeEntries) {
|
|
104
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name === "api" || entry.name === "vault") continue
|
|
105
|
+
sources.push(await decorate({
|
|
106
|
+
id: sourceId("folder", entry.name), kind: "folder", label: entry.name,
|
|
107
|
+
root: path.resolve(home, entry.name), parent_id: "pinokio"
|
|
108
|
+
}))
|
|
109
|
+
}
|
|
110
|
+
const homeSource = sources.find((source) => source.id === "pinokio")
|
|
111
|
+
if (homeSource) await decorate(homeSource)
|
|
112
|
+
this._sources = sources
|
|
113
|
+
return sources
|
|
114
|
+
}
|
|
115
|
+
sources() {
|
|
116
|
+
return this._sources
|
|
117
|
+
}
|
|
118
|
+
sourceForPath(filePath, preferredId) {
|
|
119
|
+
const sources = this._sources || []
|
|
120
|
+
if (preferredId) {
|
|
121
|
+
const preferred = sources.find((source) => source.id === preferredId)
|
|
122
|
+
if (preferred) return preferred
|
|
123
|
+
}
|
|
124
|
+
const absolute = path.resolve(filePath)
|
|
125
|
+
const concrete = sources.filter((source) => source.root && source.kind !== "virtual")
|
|
126
|
+
.filter((source) => isPathWithin(source.root, absolute) || (source.mount_path && isPathWithin(source.mount_path, absolute)))
|
|
127
|
+
.sort((a, b) => Math.max((b.root || "").length, (b.mount_path || "").length) -
|
|
128
|
+
Math.max((a.root || "").length, (a.mount_path || "").length))
|
|
129
|
+
return concrete[0] || sources.find((source) => source.id === "pinokio") || null
|
|
130
|
+
}
|
|
131
|
+
locationForPath(filePath, preferredId) {
|
|
132
|
+
const source = this.sourceForPath(filePath, preferredId)
|
|
133
|
+
if (!source) return { source_id: null, relative_path: path.basename(filePath) }
|
|
134
|
+
const absolute = path.resolve(filePath)
|
|
135
|
+
let base = source.root
|
|
136
|
+
if (source.mount_path && isPathWithin(source.mount_path, absolute)) base = source.mount_path
|
|
137
|
+
const relative = path.relative(base, absolute).split(path.sep).join("/") || path.basename(absolute)
|
|
138
|
+
return {
|
|
139
|
+
source_id: source.id,
|
|
140
|
+
source_kind: source.kind,
|
|
141
|
+
source_label: source.label,
|
|
142
|
+
relative_path: relative
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
scanRoots() {
|
|
146
|
+
const home = path.resolve(this.kernel.homedir)
|
|
147
|
+
const roots = [{ root: home, source_id: "pinokio" }]
|
|
148
|
+
const seen = new Set([home])
|
|
149
|
+
for (const source of this._sources) {
|
|
150
|
+
if (source.kind !== "external" || !source.available || !source.root) continue
|
|
151
|
+
const canonical = path.resolve(source.root)
|
|
152
|
+
if (isPathWithin(home, canonical) || isPathWithin(canonical, home) || seen.has(canonical)) continue
|
|
153
|
+
seen.add(canonical)
|
|
154
|
+
roots.push({ root: canonical, source_id: source.id })
|
|
155
|
+
}
|
|
156
|
+
return roots
|
|
157
|
+
}
|
|
158
|
+
// Kill switch: system ENVIRONMENT variable PINOKIO_VAULT, default unset =
|
|
159
|
+
// enabled. Read directly (single key) to avoid loading the environment
|
|
160
|
+
// module chain before the kernel is fully up.
|
|
161
|
+
async isEnabled() {
|
|
162
|
+
let value = process.env.PINOKIO_VAULT
|
|
163
|
+
if (value === undefined && this.kernel.homedir) {
|
|
164
|
+
try {
|
|
165
|
+
const raw = await fs.promises.readFile(path.resolve(this.kernel.homedir, "ENVIRONMENT"), "utf8")
|
|
166
|
+
for (const line of raw.split("\n")) {
|
|
167
|
+
const m = line.match(/^\s*PINOKIO_VAULT\s*=\s*(.*)\s*$/)
|
|
168
|
+
if (m) value = m[1].trim()
|
|
169
|
+
}
|
|
170
|
+
} catch (e) {}
|
|
171
|
+
}
|
|
172
|
+
return String(value).toLowerCase() !== "false"
|
|
173
|
+
}
|
|
174
|
+
// Disabled ⇒ do nothing observable: no directory, no registry, no probes.
|
|
175
|
+
async init() {
|
|
176
|
+
this.enabled = await this.isEnabled()
|
|
177
|
+
if (!this.enabled) return { enabled: false }
|
|
178
|
+
await fs.promises.mkdir(this.blobRoot, { recursive: true })
|
|
179
|
+
this.registry = new Registry(this.root)
|
|
180
|
+
this.mode = await this.probe(this.root)
|
|
181
|
+
await this.refreshSources()
|
|
182
|
+
const loaded = await this.registry.load()
|
|
183
|
+
if (loaded.corrupt) {
|
|
184
|
+
await this.rebuild()
|
|
185
|
+
}
|
|
186
|
+
this.sweeper = new Sweeper(this)
|
|
187
|
+
this.initialized = true
|
|
188
|
+
return { enabled: true, mode: this.mode, corrupt_recovered: !!loaded.corrupt }
|
|
189
|
+
}
|
|
190
|
+
// Capability probe: temp file + node:fs.link + nlink check. Once per volume.
|
|
191
|
+
async probe(dir) {
|
|
192
|
+
let dev
|
|
193
|
+
try {
|
|
194
|
+
dev = (await fs.promises.stat(dir)).dev
|
|
195
|
+
if (this.volumeModes.has(dev)) return this.volumeModes.get(dev)
|
|
196
|
+
} catch (e) {
|
|
197
|
+
return "copy"
|
|
198
|
+
}
|
|
199
|
+
const a = path.resolve(dir, `.pinokio-probe-${crypto.randomBytes(6).toString("hex")}`)
|
|
200
|
+
const b = a + "-link"
|
|
201
|
+
let mode = "copy"
|
|
202
|
+
try {
|
|
203
|
+
await fs.promises.writeFile(a, "probe")
|
|
204
|
+
await fs.promises.link(a, b)
|
|
205
|
+
const st = await fs.promises.stat(b)
|
|
206
|
+
if (st.nlink === 2) mode = "link"
|
|
207
|
+
} catch (e) {
|
|
208
|
+
} finally {
|
|
209
|
+
await fs.promises.unlink(a).catch(() => {})
|
|
210
|
+
await fs.promises.unlink(b).catch(() => {})
|
|
211
|
+
}
|
|
212
|
+
this.volumeModes.set(dev, mode)
|
|
213
|
+
return mode
|
|
214
|
+
}
|
|
215
|
+
async hashFile(filePath) {
|
|
216
|
+
if (!this.worker) {
|
|
217
|
+
const worker = new Worker(path.resolve(__dirname, "hash_worker.js"))
|
|
218
|
+
this.worker = worker
|
|
219
|
+
worker.unref()
|
|
220
|
+
worker.on("message", ({ id, hash, size, error }) => {
|
|
221
|
+
const job = this.workerJobs.get(id)
|
|
222
|
+
if (!job) return
|
|
223
|
+
this.workerJobs.delete(id)
|
|
224
|
+
if (error) job.reject(new Error(error))
|
|
225
|
+
else job.resolve({ hash, size })
|
|
226
|
+
// Terminate when idle: lingering workers hold pipe handles that can
|
|
227
|
+
// keep processes alive (observed under node --test); respawning for
|
|
228
|
+
// the next batch costs ~30ms and hashing is idle-priority anyway.
|
|
229
|
+
if (this.workerJobs.size === 0 && this.worker === worker) {
|
|
230
|
+
this.worker = null
|
|
231
|
+
worker.terminate().catch(() => {})
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
worker.on("error", (error) => {
|
|
235
|
+
if (this.worker !== worker) return
|
|
236
|
+
for (const job of this.workerJobs.values()) job.reject(error)
|
|
237
|
+
this.workerJobs.clear()
|
|
238
|
+
this.worker = null
|
|
239
|
+
})
|
|
240
|
+
worker.on("exit", (code) => {
|
|
241
|
+
if (this.worker !== worker) return
|
|
242
|
+
for (const job of this.workerJobs.values()) {
|
|
243
|
+
job.reject(new Error(`hash worker exited with code ${code}`))
|
|
244
|
+
}
|
|
245
|
+
this.workerJobs.clear()
|
|
246
|
+
this.worker = null
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
const id = ++this.workerSeq
|
|
250
|
+
return new Promise((resolve, reject) => {
|
|
251
|
+
this.workerJobs.set(id, { resolve, reject })
|
|
252
|
+
this.worker.postMessage({ id, filePath })
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
// adopt: give an existing file a store name. Metadata-only, never copies.
|
|
256
|
+
async adopt(filePath, hash, meta = {}) {
|
|
257
|
+
if (!this.enabled) return { status: "disabled" }
|
|
258
|
+
const storePath = this.storePathFor(hash)
|
|
259
|
+
const st = await fs.promises.stat(filePath)
|
|
260
|
+
const linkEntry = {
|
|
261
|
+
hash,
|
|
262
|
+
app: meta.app || null,
|
|
263
|
+
source_id: meta.source_id || null,
|
|
264
|
+
dev: st.dev,
|
|
265
|
+
ino: st.ino,
|
|
266
|
+
mode: "link"
|
|
267
|
+
}
|
|
268
|
+
let storeStat = null
|
|
269
|
+
try {
|
|
270
|
+
storeStat = await fs.promises.stat(storePath)
|
|
271
|
+
} catch (e) {}
|
|
272
|
+
if (storeStat) {
|
|
273
|
+
if (storeStat.dev === st.dev && storeStat.ino === st.ino) {
|
|
274
|
+
this.registry.addBlob(hash, { size: st.size, source_urls: meta.source_urls || [] })
|
|
275
|
+
this.registry.addLink(filePath, linkEntry)
|
|
276
|
+
return { status: "already" }
|
|
277
|
+
}
|
|
278
|
+
return { status: "duplicate", storePath }
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
await fs.promises.mkdir(path.dirname(storePath), { recursive: true })
|
|
282
|
+
await fs.promises.link(filePath, storePath)
|
|
283
|
+
} catch (e) {
|
|
284
|
+
if (NO_LINK_CODES.has(e.code)) {
|
|
285
|
+
linkEntry.mode = "copy"
|
|
286
|
+
this.registry.addBlob(hash, { size: st.size, source_urls: meta.source_urls || [] })
|
|
287
|
+
this.registry.addLink(filePath, linkEntry)
|
|
288
|
+
await this.registry.appendEvent({ kind: "adopt", hash, path: filePath, app: meta.app || null, source_id: meta.source_id || null, bytes_saved: 0, mode: "copy" })
|
|
289
|
+
return { status: "copy-mode" }
|
|
290
|
+
}
|
|
291
|
+
throw e
|
|
292
|
+
}
|
|
293
|
+
this.registry.addBlob(hash, { size: st.size, source_urls: meta.source_urls || [] })
|
|
294
|
+
this.registry.addLink(filePath, linkEntry)
|
|
295
|
+
await this.registry.appendEvent({ kind: "adopt", hash, path: filePath, app: meta.app || null, source_id: meta.source_id || null, bytes_saved: 0 })
|
|
296
|
+
return { status: "adopted" }
|
|
297
|
+
}
|
|
298
|
+
// convert: replace a byte-identical duplicate with a link to an existing
|
|
299
|
+
// blob. Atomic link+rename; target is never missing at any instant.
|
|
300
|
+
async convert(targetPath, hash, meta = {}) {
|
|
301
|
+
if (!this.enabled) return { status: "disabled" }
|
|
302
|
+
const storePath = this.storePathFor(hash)
|
|
303
|
+
let storeStat
|
|
304
|
+
try {
|
|
305
|
+
storeStat = await fs.promises.stat(storePath)
|
|
306
|
+
} catch (e) {
|
|
307
|
+
return { status: "no-blob" }
|
|
308
|
+
}
|
|
309
|
+
const targetStat = await fs.promises.stat(targetPath)
|
|
310
|
+
if (storeStat.dev === targetStat.dev && storeStat.ino === targetStat.ino) {
|
|
311
|
+
this.registry.addLink(targetPath, { hash, app: meta.app || null, source_id: meta.source_id || null, dev: storeStat.dev, ino: storeStat.ino, mode: "link" })
|
|
312
|
+
return { status: "already" }
|
|
313
|
+
}
|
|
314
|
+
if (storeStat.size !== targetStat.size) {
|
|
315
|
+
return { status: "size-mismatch" }
|
|
316
|
+
}
|
|
317
|
+
const volumeMode = this.volumeModes.get(targetStat.dev)
|
|
318
|
+
const tmp = targetPath + TMP_SUFFIX
|
|
319
|
+
try {
|
|
320
|
+
try {
|
|
321
|
+
await fs.promises.link(storePath, tmp)
|
|
322
|
+
} catch (e) {
|
|
323
|
+
if (e.code === "EEXIST") {
|
|
324
|
+
await fs.promises.unlink(tmp)
|
|
325
|
+
await fs.promises.link(storePath, tmp)
|
|
326
|
+
} else {
|
|
327
|
+
throw e
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
await fs.promises.rename(tmp, targetPath)
|
|
331
|
+
} catch (e) {
|
|
332
|
+
await fs.promises.unlink(tmp).catch(() => {})
|
|
333
|
+
if (NO_LINK_CODES.has(e.code) && volumeMode !== "link") {
|
|
334
|
+
this.registry.addLink(targetPath, { hash, app: meta.app || null, source_id: meta.source_id || null, dev: targetStat.dev, ino: targetStat.ino, mode: "copy" })
|
|
335
|
+
return { status: "copy-mode" }
|
|
336
|
+
}
|
|
337
|
+
if (LOCK_CODES.has(e.code)) {
|
|
338
|
+
return { status: "locked" }
|
|
339
|
+
}
|
|
340
|
+
throw e
|
|
341
|
+
}
|
|
342
|
+
const st = await fs.promises.stat(targetPath)
|
|
343
|
+
this.registry.addLink(targetPath, { hash, app: meta.app || null, source_id: meta.source_id || null, dev: st.dev, ino: st.ino, mode: "link" })
|
|
344
|
+
this.registry.addSaved(storeStat.size)
|
|
345
|
+
await this.registry.appendEvent({
|
|
346
|
+
kind: "convert", hash, path: targetPath, app: meta.app || null, source_id: meta.source_id || null,
|
|
347
|
+
auto: !!meta.auto, bytes_saved: storeStat.size, batch_id: meta.batch_id || null
|
|
348
|
+
})
|
|
349
|
+
return { status: "converted", bytes_saved: storeStat.size }
|
|
350
|
+
}
|
|
351
|
+
// reclaim: delete an orphan's store name. Refuses when any app name remains.
|
|
352
|
+
async reclaim(hash) {
|
|
353
|
+
if (!this.enabled) return { status: "disabled" }
|
|
354
|
+
const storePath = this.storePathFor(hash)
|
|
355
|
+
let st
|
|
356
|
+
try {
|
|
357
|
+
st = await fs.promises.stat(storePath)
|
|
358
|
+
} catch (e) {
|
|
359
|
+
this.registry.removeBlob(hash)
|
|
360
|
+
return { status: "gone" }
|
|
361
|
+
}
|
|
362
|
+
if (st.nlink > 1) return { status: "in-use" }
|
|
363
|
+
await fs.promises.unlink(storePath)
|
|
364
|
+
this.registry.removeBlob(hash)
|
|
365
|
+
await this.registry.appendEvent({ kind: "reclaim", hash, bytes_saved: st.size })
|
|
366
|
+
return { status: "reclaimed", bytes_freed: st.size }
|
|
367
|
+
}
|
|
368
|
+
// Startup registry verification (trigger 5): bounded by registry size,
|
|
369
|
+
// never a discovery walk. Prunes dead links, flags orphans, removes stray
|
|
370
|
+
// conversion tmp files, re-adopts blobs whose store name was deleted.
|
|
371
|
+
async verify() {
|
|
372
|
+
if (!this.enabled || !this.registry) return
|
|
373
|
+
for (const [linkPath, entry] of [...this.registry.links]) {
|
|
374
|
+
await fs.promises.unlink(linkPath + TMP_SUFFIX).catch(() => {})
|
|
375
|
+
let st = null
|
|
376
|
+
try {
|
|
377
|
+
st = await fs.promises.stat(linkPath)
|
|
378
|
+
} catch (e) {}
|
|
379
|
+
if (!st || st.ino !== entry.ino || st.dev !== entry.dev) {
|
|
380
|
+
this.registry.removeLink(linkPath)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
for (const [dupPath] of [...this.registry.duplicates]) {
|
|
384
|
+
try {
|
|
385
|
+
await fs.promises.stat(dupPath)
|
|
386
|
+
} catch (e) {
|
|
387
|
+
this.registry.duplicates.delete(dupPath)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
for (const [hash, blob] of [...this.registry.blobs]) {
|
|
391
|
+
const storePath = this.storePathFor(hash)
|
|
392
|
+
let st = null
|
|
393
|
+
try {
|
|
394
|
+
st = await fs.promises.stat(storePath)
|
|
395
|
+
} catch (e) {}
|
|
396
|
+
if (!st) {
|
|
397
|
+
// Store name gone; re-adopt from a surviving app name if one exists.
|
|
398
|
+
let readopted = false
|
|
399
|
+
for (const [linkPath, entry] of this.registry.links) {
|
|
400
|
+
if (entry.hash !== hash || entry.mode !== "link") continue
|
|
401
|
+
try {
|
|
402
|
+
await fs.promises.mkdir(path.dirname(storePath), { recursive: true })
|
|
403
|
+
await fs.promises.link(linkPath, storePath)
|
|
404
|
+
readopted = true
|
|
405
|
+
break
|
|
406
|
+
} catch (e) {}
|
|
407
|
+
}
|
|
408
|
+
if (!readopted) this.registry.removeBlob(hash)
|
|
409
|
+
continue
|
|
410
|
+
}
|
|
411
|
+
blob.orphan = st.nlink === 1
|
|
412
|
+
blob.verified_at = Date.now()
|
|
413
|
+
}
|
|
414
|
+
this.registry.schedulePersist()
|
|
415
|
+
}
|
|
416
|
+
// Rebuild from disk alone: store filenames are hashes, stat gives nlink,
|
|
417
|
+
// (dev, ino) matching re-associates app paths. No hashing required.
|
|
418
|
+
async rebuild(roots) {
|
|
419
|
+
if (!this.enabled) return
|
|
420
|
+
this.registry.reset()
|
|
421
|
+
const storeInoToHash = new Map()
|
|
422
|
+
let shards = []
|
|
423
|
+
try {
|
|
424
|
+
shards = await fs.promises.readdir(this.blobRoot)
|
|
425
|
+
} catch (e) {}
|
|
426
|
+
for (const shard of shards) {
|
|
427
|
+
let names = []
|
|
428
|
+
try {
|
|
429
|
+
names = await fs.promises.readdir(path.resolve(this.blobRoot, shard))
|
|
430
|
+
} catch (e) { continue }
|
|
431
|
+
for (const name of names) {
|
|
432
|
+
if (!/^[0-9a-f]{64}$/.test(name)) continue
|
|
433
|
+
try {
|
|
434
|
+
const st = await fs.promises.stat(path.resolve(this.blobRoot, shard, name))
|
|
435
|
+
this.registry.addBlob(name, { size: st.size })
|
|
436
|
+
this.registry.blobs.get(name).orphan = st.nlink === 1
|
|
437
|
+
storeInoToHash.set(`${st.dev}:${st.ino}`, name)
|
|
438
|
+
} catch (e) {}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
await this.refreshSources()
|
|
442
|
+
const walkRoots = roots
|
|
443
|
+
? roots.map((root) => typeof root === "string" ? { root, source_id: null } : root)
|
|
444
|
+
: this.scanRoots()
|
|
445
|
+
for (const entry of walkRoots) {
|
|
446
|
+
await this.walkForInoMatch(entry.root, storeInoToHash, entry.source_id === "pinokio" ? null : entry.source_id)
|
|
447
|
+
}
|
|
448
|
+
await this.registry.flush()
|
|
449
|
+
}
|
|
450
|
+
async walkForInoMatch(dir, storeInoToHash, preferredSourceId = null) {
|
|
451
|
+
let entries = []
|
|
452
|
+
try {
|
|
453
|
+
entries = await fs.promises.readdir(dir, { withFileTypes: true })
|
|
454
|
+
} catch (e) { return }
|
|
455
|
+
for (const entry of entries) {
|
|
456
|
+
const full = path.resolve(dir, entry.name)
|
|
457
|
+
if (entry.isSymbolicLink()) continue
|
|
458
|
+
if (entry.isDirectory()) {
|
|
459
|
+
if (PRUNE_DIRS.has(entry.name)) continue
|
|
460
|
+
await this.walkForInoMatch(full, storeInoToHash, preferredSourceId)
|
|
461
|
+
} else if (entry.isFile()) {
|
|
462
|
+
try {
|
|
463
|
+
const st = await fs.promises.stat(full)
|
|
464
|
+
if (st.size < SIZE_THRESHOLD) continue
|
|
465
|
+
const hash = storeInoToHash.get(`${st.dev}:${st.ino}`)
|
|
466
|
+
if (hash) {
|
|
467
|
+
const source = this.sourceForPath(full, preferredSourceId)
|
|
468
|
+
this.registry.addLink(full, {
|
|
469
|
+
hash, app: source && source.kind === "app" ? source.app : null,
|
|
470
|
+
source_id: source ? source.id : null, dev: st.dev, ino: st.ino, mode: "link"
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
} catch (e) {}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
appNameFor(filePath) {
|
|
478
|
+
const source = this.sourceForPath(filePath)
|
|
479
|
+
return source && source.kind === "app" ? source.app : null
|
|
480
|
+
}
|
|
481
|
+
// Global state for the vault page and the health endpoint. Answered from
|
|
482
|
+
// memory + one stat per blob; never a walk.
|
|
483
|
+
async status() {
|
|
484
|
+
if (!this.enabled || !this.registry) return { enabled: false }
|
|
485
|
+
const registry = this.registry
|
|
486
|
+
const blobs = []
|
|
487
|
+
const storeStats = new Map()
|
|
488
|
+
let bytesOnDisk = 0
|
|
489
|
+
let wouldBe = 0
|
|
490
|
+
for (const [hash, blob] of registry.blobs) {
|
|
491
|
+
const names = []
|
|
492
|
+
const apps = new Set()
|
|
493
|
+
for (const [linkPath, entry] of registry.links) {
|
|
494
|
+
if (entry.hash === hash) {
|
|
495
|
+
const location = this.locationForPath(linkPath, entry.source_id)
|
|
496
|
+
names.push(Object.assign({ path: linkPath, app: entry.app || null, mode: entry.mode }, location))
|
|
497
|
+
if (entry.app) apps.add(entry.app)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
let nlink = null
|
|
501
|
+
let storeStat = null
|
|
502
|
+
try {
|
|
503
|
+
storeStat = await fs.promises.stat(this.storePathFor(hash))
|
|
504
|
+
nlink = storeStat.nlink
|
|
505
|
+
} catch (e) {}
|
|
506
|
+
storeStats.set(hash, storeStat)
|
|
507
|
+
bytesOnDisk += blob.size || 0
|
|
508
|
+
wouldBe += (blob.size || 0) * Math.max(1, names.length)
|
|
509
|
+
blobs.push({
|
|
510
|
+
hash, size: blob.size || 0, orphan: !!blob.orphan, nlink,
|
|
511
|
+
names, apps: [...apps], source_urls: blob.source_urls || []
|
|
512
|
+
})
|
|
513
|
+
}
|
|
514
|
+
const duplicates = []
|
|
515
|
+
for (const [p, entry] of registry.duplicates) {
|
|
516
|
+
const location = this.locationForPath(p, entry.source_id)
|
|
517
|
+
const source = this.sourceForPath(p, location.source_id)
|
|
518
|
+
const index = registry.scanIndex.get(p)
|
|
519
|
+
const storeStat = storeStats.get(entry.hash)
|
|
520
|
+
const shareable = !!(source && source.shareable && storeStat && (!index || index.dev === undefined || index.dev === storeStat.dev))
|
|
521
|
+
const blob = blobs.find((item) => item.hash === entry.hash)
|
|
522
|
+
duplicates.push(Object.assign({
|
|
523
|
+
path: p, hash: entry.hash, size: entry.size || 0, app: entry.app || null,
|
|
524
|
+
shareable, matches: blob ? blob.names : []
|
|
525
|
+
}, location))
|
|
526
|
+
}
|
|
527
|
+
const events = (await registry.readEvents()).slice(-100).reverse()
|
|
528
|
+
const sweeper = this.sweeper
|
|
529
|
+
const scan = sweeper ? Object.assign({}, sweeper.state, {
|
|
530
|
+
current_file: sweeper.currentHash ? path.basename(sweeper.currentHash.path) : null
|
|
531
|
+
}) : null
|
|
532
|
+
const excluded = []
|
|
533
|
+
for (const [p, meta] of registry.excluded) {
|
|
534
|
+
excluded.push(Object.assign({ path: p, ts: meta.ts || null }, this.locationForPath(p, meta.source_id)))
|
|
535
|
+
}
|
|
536
|
+
const publicSources = this._sources.map((source) => ({
|
|
537
|
+
id: source.id, kind: source.kind, label: source.label, root: source.root,
|
|
538
|
+
display_path: source.kind === "pinokio" ? source.root : (source.mount_path || source.root),
|
|
539
|
+
target_path: source.kind === "external" ? source.root : null,
|
|
540
|
+
parent_id: source.parent_id, app: source.app || null,
|
|
541
|
+
available: source.available !== false, shareable: source.kind === "virtual" ? null : !!source.shareable
|
|
542
|
+
}))
|
|
543
|
+
return {
|
|
544
|
+
enabled: true,
|
|
545
|
+
mode: this.mode,
|
|
546
|
+
scan,
|
|
547
|
+
last_scan: registry.lastScan,
|
|
548
|
+
bytes_on_disk: bytesOnDisk,
|
|
549
|
+
bytes_without_sharing: wouldBe,
|
|
550
|
+
saved_by_sharing: wouldBe - bytesOnDisk,
|
|
551
|
+
lifetime_bytes_saved: registry.totals.lifetime_bytes_saved,
|
|
552
|
+
reclaimable: blobs.filter((b) => b.orphan).reduce((sum, b) => sum + b.size, 0),
|
|
553
|
+
pending_bytes: duplicates.filter((d) => d.shareable).reduce((sum, d) => sum + d.size, 0),
|
|
554
|
+
unavailable_bytes: duplicates.filter((d) => !d.shareable).reduce((sum, d) => sum + d.size, 0),
|
|
555
|
+
sources: publicSources, blobs, duplicates, excluded, events
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
// detach: make one name an independent copy again ("go back"), and pin it
|
|
559
|
+
// so future scans neither re-list nor re-share it. Works on linked names
|
|
560
|
+
// (copies bytes out) and on pending duplicates (just ignores them).
|
|
561
|
+
async detach(filePath) {
|
|
562
|
+
if (!this.enabled) return { status: "disabled" }
|
|
563
|
+
const entry = this.registry.links.get(filePath)
|
|
564
|
+
if (entry) {
|
|
565
|
+
const storePath = this.storePathFor(entry.hash)
|
|
566
|
+
const tmp = filePath + TMP_SUFFIX
|
|
567
|
+
await fs.promises.copyFile(filePath, tmp)
|
|
568
|
+
await fs.promises.rename(tmp, filePath)
|
|
569
|
+
this.registry.removeLink(filePath)
|
|
570
|
+
this.registry.excluded.set(filePath, { ts: Date.now(), source_id: entry.source_id || null })
|
|
571
|
+
this.registry.schedulePersist()
|
|
572
|
+
await this.registry.appendEvent({ kind: "detach", hash: entry.hash, path: filePath, app: entry.app || null, source_id: entry.source_id || null })
|
|
573
|
+
return { status: "detached" }
|
|
574
|
+
}
|
|
575
|
+
if (this.registry.duplicates.has(filePath)) {
|
|
576
|
+
const dup = this.registry.duplicates.get(filePath)
|
|
577
|
+
this.registry.duplicates.delete(filePath)
|
|
578
|
+
this.registry.excluded.set(filePath, { ts: Date.now(), source_id: dup.source_id || null })
|
|
579
|
+
this.registry.schedulePersist()
|
|
580
|
+
await this.registry.appendEvent({ kind: "detach", hash: dup.hash, path: filePath, app: dup.app || null, source_id: dup.source_id || null })
|
|
581
|
+
return { status: "ignored" }
|
|
582
|
+
}
|
|
583
|
+
return { status: "not-found" }
|
|
584
|
+
}
|
|
585
|
+
// reshare: un-pin a detached path; the next scan may list it again.
|
|
586
|
+
async reshare(filePath) {
|
|
587
|
+
if (!this.enabled) return { status: "disabled" }
|
|
588
|
+
const excluded = this.registry.excluded.get(filePath)
|
|
589
|
+
if (this.registry.excluded.delete(filePath)) {
|
|
590
|
+
this.registry.schedulePersist()
|
|
591
|
+
await this.registry.appendEvent({ kind: "reshare", path: filePath, source_id: excluded && excluded.source_id ? excluded.source_id : null })
|
|
592
|
+
return { status: "resharable" }
|
|
593
|
+
}
|
|
594
|
+
return { status: "not-found" }
|
|
595
|
+
}
|
|
596
|
+
// Undo a conversion batch: replace each converted name with an independent
|
|
597
|
+
// copy of the bytes (spec, UX contract surface 3).
|
|
598
|
+
async undoBatch(batchId) {
|
|
599
|
+
if (!this.enabled || !batchId) return { undone: 0 }
|
|
600
|
+
const events = await this.registry.readEvents()
|
|
601
|
+
const summary = { undone: 0, bytes: 0 }
|
|
602
|
+
for (const event of events) {
|
|
603
|
+
if (event.kind !== "convert" || event.batch_id !== batchId || !event.path) continue
|
|
604
|
+
const entry = this.registry.links.get(event.path)
|
|
605
|
+
if (!entry || entry.hash !== event.hash) continue
|
|
606
|
+
const storePath = this.storePathFor(event.hash)
|
|
607
|
+
try {
|
|
608
|
+
const st = await fs.promises.stat(event.path)
|
|
609
|
+
const storeStat = await fs.promises.stat(storePath)
|
|
610
|
+
if (st.ino !== storeStat.ino || st.dev !== storeStat.dev) continue
|
|
611
|
+
const tmp = event.path + TMP_SUFFIX
|
|
612
|
+
await fs.promises.copyFile(storePath, tmp)
|
|
613
|
+
await fs.promises.rename(tmp, event.path)
|
|
614
|
+
this.registry.removeLink(event.path)
|
|
615
|
+
this.registry.duplicates.set(event.path, {
|
|
616
|
+
hash: event.hash, size: storeStat.size, app: entry.app || null,
|
|
617
|
+
source_id: entry.source_id || event.source_id || null, discovered: Date.now()
|
|
618
|
+
})
|
|
619
|
+
this.registry.totals.lifetime_bytes_saved = Math.max(0,
|
|
620
|
+
this.registry.totals.lifetime_bytes_saved - (event.bytes_saved || 0))
|
|
621
|
+
await this.registry.appendEvent({
|
|
622
|
+
kind: "undo", hash: event.hash, path: event.path,
|
|
623
|
+
source_id: entry.source_id || event.source_id || null, batch_id: batchId
|
|
624
|
+
})
|
|
625
|
+
summary.undone += 1
|
|
626
|
+
summary.bytes += storeStat.size
|
|
627
|
+
} catch (e) {}
|
|
628
|
+
}
|
|
629
|
+
this.registry.schedulePersist()
|
|
630
|
+
return summary
|
|
631
|
+
}
|
|
632
|
+
async reclaimAll() {
|
|
633
|
+
if (!this.enabled) return { reclaimed: 0, bytes_freed: 0 }
|
|
634
|
+
const summary = { reclaimed: 0, bytes_freed: 0 }
|
|
635
|
+
for (const [hash, blob] of [...this.registry.blobs]) {
|
|
636
|
+
if (!blob.orphan) continue
|
|
637
|
+
const result = await this.reclaim(hash)
|
|
638
|
+
if (result.status === "reclaimed") {
|
|
639
|
+
summary.reclaimed += 1
|
|
640
|
+
summary.bytes_freed += result.bytes_freed || 0
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return summary
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
Vault.SIZE_THRESHOLD = SIZE_THRESHOLD
|
|
648
|
+
Vault.TMP_SUFFIX = TMP_SUFFIX
|
|
649
|
+
Vault.PRUNE_DIRS = PRUNE_DIRS
|
|
650
|
+
|
|
651
|
+
module.exports = Vault
|