pinokiod 8.0.35 → 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.
@@ -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