pinokiod 8.0.39 → 8.0.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/kernel/api/hf/index.js +2 -2
  2. package/kernel/api/index.js +12 -3
  3. package/kernel/connect/index.js +2 -2
  4. package/kernel/connect/providers/huggingface/index.js +14 -5
  5. package/kernel/index.js +14 -2
  6. package/kernel/shell.js +3 -2
  7. package/kernel/shells.js +6 -0
  8. package/kernel/vault/constants.js +13 -0
  9. package/kernel/vault/hash_worker.js +9 -2
  10. package/kernel/vault/index.js +1856 -316
  11. package/kernel/vault/registry.js +526 -52
  12. package/kernel/vault/snapshot.js +29 -0
  13. package/kernel/vault/sweeper.js +480 -210
  14. package/kernel/vault/walker.js +142 -0
  15. package/package.json +1 -1
  16. package/server/index.js +79 -90
  17. package/server/lib/privacy_filter_cache.js +4 -1
  18. package/server/public/storage-size.js +12 -0
  19. package/server/public/style.css +13 -0
  20. package/server/public/tab-link-popover.js +3 -0
  21. package/server/public/vault-nav.js +96 -0
  22. package/server/public/vault.css +1079 -0
  23. package/server/public/vault.js +1835 -0
  24. package/server/views/app.ejs +93 -16
  25. package/server/views/connect/huggingface.ejs +7 -9
  26. package/server/views/partials/main_sidebar.ejs +6 -1
  27. package/server/views/partials/vault_workspace.ejs +55 -0
  28. package/server/views/vault.ejs +5 -1532
  29. package/server/views/vault_app.ejs +22 -0
  30. package/test/hf-api.test.js +4 -2
  31. package/test/huggingface-connect.test.js +7 -11
  32. package/test/huggingface-token-validity.test.js +135 -0
  33. package/test/plugin-action-functions.test.js +19 -0
  34. package/test/privacy-filter-cache.test.js +1 -0
  35. package/test/vault-engine.test.js +1276 -26
  36. package/test/vault-sweep.test.js +1043 -35
  37. package/test/vault-ui.test.js +2184 -65
@@ -1,10 +1,15 @@
1
1
  const fs = require('fs')
2
- const os = require('os')
3
2
  const path = require('path')
4
3
  const crypto = require('crypto')
5
4
  const { Worker } = require('worker_threads')
6
5
  const Registry = require('./registry')
7
6
  const Sweeper = require('./sweeper')
7
+ const { walkBatches, statMany } = require('./walker')
8
+ const { fileSnapshot, sameSnapshot, sameContentState } = require('./snapshot')
9
+ const {
10
+ SIZE_THRESHOLD, CANDIDATE_SIZE_OPTIONS, TMP_SUFFIX, SHA256_RE,
11
+ DIR_CONCURRENCY, STAT_CONCURRENCY
12
+ } = require('./constants')
8
13
 
9
14
  // Shared model store engine (spec/requirements/shared-model-store.md).
10
15
  // Store + registry + volume probe + hashing + adopt/convert/verify, plus the
@@ -12,11 +17,39 @@ const Sweeper = require('./sweeper')
12
17
  // Nothing here runs automatically except startup verify; discovery happens
13
18
  // only through the user's manual scan.
14
19
 
15
- const SIZE_THRESHOLD = 100 * 1024 * 1024
16
- const TMP_SUFFIX = ".pinokio-dedup-tmp"
17
- const NO_LINK_CODES = new Set(["EXDEV", "ENOTSUP", "EPERM", "ENOSYS"])
20
+ const NO_LINK_CODES = new Set(["EXDEV", "ENOTSUP", "ENOSYS"])
18
21
  const LOCK_CODES = new Set(["EBUSY", "EPERM", "EACCES"])
19
- const STAT_CONCURRENCY = 32
22
+ const COPY_PROGRESS_CHUNK_SIZE = 8 * 1024 * 1024
23
+ const isMissingError = (error) => !!(error && (error.code === "ENOENT" || error.code === "ENOTDIR"))
24
+ const isAccessError = (error) => !!(error && (error.code === "EACCES" || error.code === "EPERM"))
25
+ const lstatIfPresent = async (filePath) => {
26
+ try {
27
+ return await fs.promises.lstat(filePath)
28
+ } catch (error) {
29
+ if (isMissingError(error)) return null
30
+ throw error
31
+ }
32
+ }
33
+ const sameIdentity = (left, right) => !!(
34
+ left && right && left.dev === right.dev && left.ino === right.ino
35
+ )
36
+ const unlinkIfSame = async (filePath, expected) => {
37
+ if (!expected) return false
38
+ const current = await lstatIfPresent(filePath)
39
+ if (!sameIdentity(current, expected)) return false
40
+ try {
41
+ await fs.promises.unlink(filePath)
42
+ return true
43
+ } catch (error) {
44
+ if (isMissingError(error)) return false
45
+ throw error
46
+ }
47
+ }
48
+ const unsafeStoragePath = (filePath) => {
49
+ const error = new Error(`Storage path is not a real directory: ${filePath}`)
50
+ error.code = "EVAULTPATH"
51
+ return error
52
+ }
20
53
 
21
54
  const isPathWithin = (root, target) => {
22
55
  const rel = path.relative(path.resolve(root), path.resolve(target))
@@ -29,23 +62,18 @@ const samePath = (left, right) => {
29
62
  return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b
30
63
  }
31
64
 
32
- const sourceId = (kind, name) => `${kind}:${encodeURIComponent(name)}`
65
+ const sameFileMetadata = (left, right) => {
66
+ if (!left || !right) return false
67
+ if ((left.mode & 0o7777) !== (right.mode & 0o7777)) return false
68
+ if (left.uid !== undefined && right.uid !== undefined && left.uid !== right.uid) return false
69
+ if (left.gid !== undefined && right.gid !== undefined && left.gid !== right.gid) return false
70
+ return true
71
+ }
33
72
 
34
- const statMany = async (paths, concurrency = STAT_CONCURRENCY) => {
35
- const results = new Array(paths.length)
36
- let cursor = 0
37
- const workers = Array.from({ length: Math.min(concurrency, paths.length) }, async () => {
38
- while (cursor < paths.length) {
39
- const index = cursor++
40
- try {
41
- results[index] = await fs.promises.stat(paths[index])
42
- } catch (error) {
43
- results[index] = null
44
- }
45
- }
46
- })
47
- await Promise.all(workers)
48
- return results
73
+ const sourceId = (kind, name) => `${kind}:${encodeURIComponent(name)}`
74
+ const externalSourceId = (root) => {
75
+ const value = process.platform === "win32" ? path.resolve(root).toLowerCase() : path.resolve(root)
76
+ return `external:${crypto.createHash("sha256").update(value).digest("hex")}`
49
77
  }
50
78
 
51
79
  class Vault {
@@ -53,18 +81,32 @@ class Vault {
53
81
  this.kernel = kernel
54
82
  this.enabled = false
55
83
  this.initialized = false
56
- this.mode = null // 'link' | 'copy' for the vault's own volume
57
- this.volumeModes = new Map() // dev -> 'link' | 'copy'
84
+ this.mode = null // 'link' | 'unsupported' for the vault's volume
85
+ this.volumeModes = new Map() // dev -> 'link' | 'unsupported'
58
86
  this.registry = null
59
87
  this.worker = null
60
88
  this.workerJobs = new Map()
61
89
  this.workerSeq = 0
62
- this.workerStarts = 0
63
90
  this.workerIdleTimer = null
64
91
  this.workerIdleMs = 750
65
92
  this.statConcurrency = STAT_CONCURRENCY
93
+ this.dirConcurrency = DIR_CONCURRENCY
66
94
  this.sizeThreshold = SIZE_THRESHOLD
67
95
  this._sources = []
96
+ this._sourceBases = new Map()
97
+ this.operationTail = Promise.resolve()
98
+ this.initializationPromise = null
99
+ this.verificationPending = false
100
+ this.scanError = null
101
+ this.scanPersistenceWarning = false
102
+ this.scanScopeId = null
103
+ this.fileAction = null
104
+ this.fileActions = new Map()
105
+ this.actionSequence = 0
106
+ this.scanEvents = null
107
+ this.scanCreatedStores = null
108
+ this.scanRetiredStores = null
109
+ this.scanRestoredStores = null
68
110
  }
69
111
  get root() {
70
112
  return path.resolve(this.kernel.homedir, "vault")
@@ -73,8 +115,309 @@ class Vault {
73
115
  return path.resolve(this.root, "sha256")
74
116
  }
75
117
  storePathFor(hash) {
118
+ if (typeof hash !== "string" || !SHA256_RE.test(hash)) {
119
+ throw new TypeError("Invalid vault content identifier.")
120
+ }
76
121
  return path.resolve(this.blobRoot, hash.slice(0, 2), hash)
77
122
  }
123
+ async directoryIfSafe(directory) {
124
+ const st = await lstatIfPresent(directory)
125
+ if (st && !st.isDirectory()) throw unsafeStoragePath(directory)
126
+ return st
127
+ }
128
+ async ensureDirectory(directory) {
129
+ let st = await this.directoryIfSafe(directory)
130
+ if (st) return st
131
+ try {
132
+ await fs.promises.mkdir(directory)
133
+ } catch (error) {
134
+ if (!error || error.code !== "EEXIST") throw error
135
+ }
136
+ st = await this.directoryIfSafe(directory)
137
+ if (!st) throw unsafeStoragePath(directory)
138
+ return st
139
+ }
140
+ async storeStatIfPresent(storePath, options = {}) {
141
+ if (!isPathWithin(this.blobRoot, storePath)) throw unsafeStoragePath(storePath)
142
+ if (!await this.directoryIfSafe(this.root) || !await this.directoryIfSafe(this.blobRoot)) {
143
+ throw unsafeStoragePath(this.blobRoot)
144
+ }
145
+ const shard = path.dirname(storePath)
146
+ let shardStat = await this.directoryIfSafe(shard)
147
+ if (!shardStat && options.createParent) shardStat = await this.ensureDirectory(shard)
148
+ if (!shardStat) return null
149
+ return lstatIfPresent(storePath)
150
+ }
151
+ // All dashboard mutations share one queue. Status reads remain concurrent,
152
+ // while scans, conversions, repair, undo, detach, and reclaim can never
153
+ // publish interleaved filesystem/registry state.
154
+ runExclusive(operation) {
155
+ const pending = this.operationTail.then(operation, operation)
156
+ this.operationTail = pending.catch(() => {})
157
+ return pending
158
+ }
159
+ // A filesystem mutation is committed before its registry snapshot is
160
+ // flushed. Keep that distinction in the response: failed persistence is
161
+ // retried by Registry and must not make an already-completed action look as
162
+ // though it never happened.
163
+ runMutation(operation) {
164
+ return this.runExclusive(async () => {
165
+ const result = await operation()
166
+ try {
167
+ if (this.registry) await this.registry.flush()
168
+ } catch (error) {
169
+ return Object.assign({}, result, { persistence_warning: true })
170
+ }
171
+ return result
172
+ })
173
+ }
174
+ queueFileMutation(kind, filePath, sourceId, bytesTotal, operation, progress = {}) {
175
+ const id = `action-${crypto.randomUUID()}`
176
+ const action = {
177
+ id,
178
+ kind,
179
+ path: filePath || null,
180
+ source_id: sourceId || null,
181
+ phase: "queued",
182
+ bytes_copied: 0,
183
+ bytes_total: Math.max(0, Number(bytesTotal) || 0),
184
+ files_completed: Math.max(0, Number(progress.files_completed) || 0),
185
+ files_total: Math.max(0, Number(progress.files_total) || 0)
186
+ }
187
+ this.fileActions.set(id, action)
188
+ this.actionSequence += 1
189
+ return this.runExclusive(async () => {
190
+ action.phase = kind.startsWith("deduplicate") ? "deduplicating" : "copying"
191
+ this.fileAction = action
192
+ this.actionSequence += 1
193
+ try {
194
+ const result = await operation(action)
195
+ try {
196
+ if (this.registry) await this.registry.flush()
197
+ } catch (error) {
198
+ return Object.assign({}, result, { persistence_warning: true })
199
+ }
200
+ return result
201
+ } finally {
202
+ if (this.fileAction === action) this.fileAction = null
203
+ this.fileActions.delete(id)
204
+ this.actionSequence += 1
205
+ }
206
+ })
207
+ }
208
+ beginScanDraft() {
209
+ this.scanEvents = []
210
+ this.scanCreatedStores = []
211
+ this.scanRetiredStores = []
212
+ this.scanRestoredStores = []
213
+ }
214
+ rememberScanStore(storePath, stat) {
215
+ if (this.scanCreatedStores) this.scanCreatedStores.push({ storePath, stat })
216
+ }
217
+ rememberRetiredScanStore(storePath, stat, sourcePaths = []) {
218
+ if (this.scanRetiredStores) {
219
+ this.scanRetiredStores.push({ storePath, stat, sourcePaths: [...new Set(sourcePaths)] })
220
+ }
221
+ }
222
+ async restoreRetiredScanStore(item) {
223
+ const current = await this.storeStatIfPresent(item.storePath)
224
+ if (current) return sameIdentity(current, item.stat) ? current : null
225
+ const key = this.registry.inoKey(item.stat.dev, item.stat.ino)
226
+ const sourcePaths = new Set([
227
+ ...(item.sourcePaths || []),
228
+ ...(this.registry.pathsByIno.get(key) || [])
229
+ ])
230
+ for (const filePath of sourcePaths) {
231
+ const st = await lstatIfPresent(filePath)
232
+ if (!sameIdentity(st, item.stat)) continue
233
+ await fs.promises.link(filePath, item.storePath)
234
+ const restored = await this.storeStatIfPresent(item.storePath)
235
+ return sameIdentity(restored, item.stat) ? restored : null
236
+ }
237
+ return null
238
+ }
239
+ async commitScanDraft() {
240
+ const events = (this.scanEvents || []).slice(-this.registry.maxEvents)
241
+ const retired = this.scanRetiredStores || []
242
+ const removed = []
243
+ try {
244
+ for (const item of retired) {
245
+ if (!await unlinkIfSame(item.storePath, item.stat)) continue
246
+ removed.push(item)
247
+ const hash = this.registry.byIno.get(this.registry.inoKey(item.stat.dev, item.stat.ino))
248
+ if (hash) await this.refreshLinkSnapshots(hash, item.stat.dev, item.stat.ino)
249
+ }
250
+ } catch (error) {
251
+ for (const item of removed.reverse()) {
252
+ const restored = await this.restoreRetiredScanStore(item).catch(() => null)
253
+ if (restored) this.scanRestoredStores.push({ item, stat: restored })
254
+ }
255
+ throw error
256
+ }
257
+ this.scanEvents = null
258
+ this.scanCreatedStores = null
259
+ this.scanRetiredStores = null
260
+ this.scanRestoredStores = null
261
+ try {
262
+ await this.registry.appendEvents(events)
263
+ return true
264
+ } catch (error) {
265
+ return false
266
+ }
267
+ }
268
+ async abortScanDraft(snapshot) {
269
+ const created = this.scanCreatedStores || []
270
+ const restored = this.scanRestoredStores || []
271
+ this.scanEvents = null
272
+ this.scanCreatedStores = null
273
+ this.scanRetiredStores = null
274
+ this.scanRestoredStores = null
275
+ for (const item of created.reverse()) {
276
+ await unlinkIfSame(item.storePath, item.stat).catch(() => {})
277
+ }
278
+ this.registry.replaceState(snapshot, { persist: false })
279
+ for (const item of restored) {
280
+ const hash = this.registry.byIno.get(
281
+ this.registry.inoKey(item.stat.dev, item.stat.ino))
282
+ if (hash) await this.refreshLinkSnapshots(hash, item.stat.dev, item.stat.ino)
283
+ }
284
+ return restored.length > 0
285
+ }
286
+ startScan(scopeId = null) {
287
+ if (!this.enabled || !this.sweeper) return { started: false, disabled: true }
288
+ if (this.scanPromise) return { started: false, already_running: true }
289
+ this.scanError = null
290
+ this.scanPersistenceWarning = false
291
+ this.scanScopeId = scopeId
292
+ this.scanPromise = this.runExclusive(() => this.sweeper.scan(scopeId))
293
+ .catch((error) => {
294
+ this.scanError = error && error.message ? error.message : String(error)
295
+ throw error
296
+ })
297
+ .finally(() => {
298
+ this.scanPromise = null
299
+ this.scanScopeId = null
300
+ })
301
+ this.scanPromise.catch(() => {})
302
+ return { started: true }
303
+ }
304
+ async perform(action, payload = {}) {
305
+ if (!this.enabled) return { error: "Save space is disabled." }
306
+ await this.ensureInitialized()
307
+ switch (action) {
308
+ case "add_source": {
309
+ const result = await this.runExclusive(() => this.addExternalSource(payload.path))
310
+ return {
311
+ created: result.created,
312
+ source: {
313
+ id: result.source.id,
314
+ label: result.source.label,
315
+ target_path: result.source.root,
316
+ shareable: !!result.source.shareable
317
+ }
318
+ }
319
+ }
320
+ case "remove_source":
321
+ return this.runMutation(() => this.removeExternalSource(payload.source_id))
322
+ case "scan":
323
+ if (payload.scope_id && !this.scanSource(payload.scope_id)) {
324
+ return { error: "That scan location is no longer available." }
325
+ }
326
+ if (payload.candidate_min_bytes !== undefined) {
327
+ if (!CANDIDATE_SIZE_OPTIONS.includes(payload.candidate_min_bytes)) {
328
+ return { error: "Choose one of the available minimum file sizes." }
329
+ }
330
+ if (this.scanPromise || (this.sweeper && this.sweeper.state.active)) {
331
+ return { error: "Wait for the current scan to finish before changing the minimum file size." }
332
+ }
333
+ this.sizeThreshold = payload.candidate_min_bytes
334
+ this.registry.settings.candidate_min_bytes = this.sizeThreshold
335
+ this.registry.schedulePersist()
336
+ }
337
+ return this.startScan(payload.scope_id || null)
338
+ case "deduplicate":
339
+ if (typeof payload.scope_id !== "string" || !payload.scope_id) {
340
+ return { error: "Choose a location to deduplicate." }
341
+ }
342
+ {
343
+ const paths = Array.isArray(payload.paths) ? payload.paths : null
344
+ const filesTotal = paths
345
+ ? new Set(paths.filter((filePath) => typeof filePath === "string" && filePath)).size
346
+ : this.registry.duplicates.size
347
+ return this.queueFileMutation(
348
+ "deduplicate",
349
+ null,
350
+ payload.scope_id,
351
+ 0,
352
+ (progress) => this.deduplicateScope(payload.scope_id, {
353
+ batch_id: `batch-${crypto.randomUUID()}`,
354
+ paths,
355
+ progress
356
+ }),
357
+ { files_total: filesTotal }
358
+ )
359
+ }
360
+ case "deduplicate_all": {
361
+ const seenPaths = new Set()
362
+ const pathsByScope = new Map()
363
+ for (const group of Array.isArray(payload.groups) ? payload.groups : []) {
364
+ if (!group || typeof group.scope_id !== "string" || !group.scope_id) continue
365
+ if (!pathsByScope.has(group.scope_id)) pathsByScope.set(group.scope_id, [])
366
+ for (const filePath of Array.isArray(group.paths) ? group.paths : []) {
367
+ if (typeof filePath !== "string" || !filePath || seenPaths.has(filePath)) continue
368
+ seenPaths.add(filePath)
369
+ pathsByScope.get(group.scope_id).push(filePath)
370
+ }
371
+ }
372
+ const groups = [...pathsByScope]
373
+ .map(([scope_id, paths]) => ({ scope_id, paths }))
374
+ .filter((group) => group.paths.length)
375
+ if (!groups.length) return { error: "No displayed files were selected." }
376
+ return this.queueFileMutation(
377
+ "deduplicate_all",
378
+ null,
379
+ null,
380
+ 0,
381
+ (progress) => this.deduplicateAll(groups, progress),
382
+ { files_total: seenPaths.size }
383
+ )
384
+ }
385
+ case "reclaim":
386
+ return this.runMutation(() => this.reclaim(payload.hash))
387
+ case "reclaim_all":
388
+ return this.runMutation(() => this.reclaimAll())
389
+ case "repair":
390
+ case "rebuild":
391
+ if (this.scanPromise || (this.sweeper && this.sweeper.state.active)) {
392
+ return { error: "Wait for the current scan to finish before repairing the index." }
393
+ }
394
+ return this.runMutation(async () => {
395
+ await this.rebuild(undefined, { flush: false })
396
+ return { done: true }
397
+ })
398
+ case "undo":
399
+ return this.queueFileMutation("undo", null, null, 0,
400
+ (action) => this.undoBatch(payload.batch_id, action))
401
+ case "detach": {
402
+ const entry = this.registry.links.get(payload.path)
403
+ return this.queueFileMutation(
404
+ "detach",
405
+ payload.path,
406
+ entry && entry.source_id,
407
+ entry && this.registry.blobs.get(entry.hash)
408
+ ? this.registry.blobs.get(entry.hash).size
409
+ : 0,
410
+ (action) => this.detach(payload.path, action)
411
+ )
412
+ }
413
+ case "skip":
414
+ return this.runMutation(() => this.skip(payload.path))
415
+ case "reshare":
416
+ return this.runMutation(() => this.reshare(payload.path))
417
+ default:
418
+ return { error: "unknown action" }
419
+ }
420
+ }
78
421
  async refreshSources() {
79
422
  const home = path.resolve(this.kernel.homedir)
80
423
  const apiRoot = path.resolve(home, "api")
@@ -84,49 +427,62 @@ class Vault {
84
427
  { id: "external", kind: "virtual", label: "External folders", root: null, parent_id: null }
85
428
  ]
86
429
  let storeDev = null
87
- try { storeDev = (await fs.promises.stat(this.root)).dev } catch (e) {}
430
+ try {
431
+ storeDev = (await fs.promises.stat(this.root)).dev
432
+ } catch (error) {
433
+ if (!isMissingError(error)) throw error
434
+ }
88
435
  const decorate = async (source) => {
89
436
  if (!source.root) return source
90
437
  try {
91
- const st = await fs.promises.stat(source.root)
438
+ const st = await fs.promises.lstat(source.root)
92
439
  source.dev = st.dev
93
- source.available = st.isDirectory()
440
+ source.available = st.isDirectory() && !st.isSymbolicLink()
94
441
  source.shareable = source.available && storeDev !== null && st.dev === storeDev && this.mode === "link"
95
- } catch (e) {
442
+ } catch (error) {
443
+ if (!isMissingError(error) && !isAccessError(error)) throw error
96
444
  source.available = false
97
445
  source.shareable = false
446
+ source.error_code = error && error.code ? error.code : null
98
447
  }
99
448
  return source
100
449
  }
101
450
 
451
+ const externalPaths = this.registry && Array.isArray(this.registry.settings.external_sources)
452
+ ? this.registry.settings.external_sources
453
+ : []
454
+ for (const externalPath of externalPaths) {
455
+ const canonical = path.resolve(externalPath)
456
+ sources.push(await decorate({
457
+ id: externalSourceId(canonical),
458
+ kind: "external",
459
+ label: path.basename(canonical) || canonical,
460
+ root: canonical,
461
+ parent_id: "external"
462
+ }))
463
+ }
464
+
102
465
  let apiEntries = []
103
- try { apiEntries = await fs.promises.readdir(apiRoot, { withFileTypes: true }) } catch (e) {}
104
- const seenExternalRoots = new Set()
466
+ try {
467
+ apiEntries = await fs.promises.readdir(apiRoot, { withFileTypes: true })
468
+ } catch (error) {
469
+ if (!isMissingError(error)) throw error
470
+ }
105
471
  for (const entry of apiEntries) {
106
472
  const mountPath = path.resolve(apiRoot, entry.name)
107
- if (entry.isSymbolicLink()) {
108
- try {
109
- const root = await fs.promises.realpath(mountPath)
110
- const st = await fs.promises.stat(root)
111
- if (!st.isDirectory()) continue
112
- const canonical = path.resolve(root)
113
- if (seenExternalRoots.has(canonical)) continue
114
- seenExternalRoots.add(canonical)
115
- sources.push(await decorate({
116
- id: sourceId("external", entry.name), kind: "external", label: entry.name,
117
- root: canonical, mount_path: mountPath, parent_id: "external"
118
- }))
119
- } catch (e) {}
120
- } else if (entry.isDirectory()) {
473
+ if (entry.isDirectory()) {
121
474
  sources.push(await decorate({
122
475
  id: sourceId("app", entry.name), kind: "app", label: entry.name,
123
476
  app: entry.name, root: mountPath, parent_id: "apps"
124
477
  }))
125
478
  }
126
479
  }
127
-
128
480
  let homeEntries = []
129
- try { homeEntries = await fs.promises.readdir(home, { withFileTypes: true }) } catch (e) {}
481
+ try {
482
+ homeEntries = await fs.promises.readdir(home, { withFileTypes: true })
483
+ } catch (error) {
484
+ if (!isMissingError(error)) throw error
485
+ }
130
486
  for (const entry of homeEntries) {
131
487
  if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name === "api" || entry.name === "vault") continue
132
488
  sources.push(await decorate({
@@ -137,12 +493,21 @@ class Vault {
137
493
  const homeSource = sources.find((source) => source.id === "pinokio")
138
494
  if (homeSource) await decorate(homeSource)
139
495
  this._sources = sources
496
+ this._sourceBases = new Map()
497
+ for (const source of sources) {
498
+ if (!source.root || source.kind === "virtual") continue
499
+ const resolved = path.resolve(source.root)
500
+ const key = process.platform === "win32" ? resolved.toLowerCase() : resolved
501
+ if (!this._sourceBases.has(key)) this._sourceBases.set(key, [])
502
+ this._sourceBases.get(key).push(source)
503
+ }
140
504
  return sources
141
505
  }
142
506
  sources() {
143
507
  return this._sources
144
508
  }
145
509
  async addExternalSource(folderPath) {
510
+ if (!this.enabled) throw new Error("Save space is disabled.")
146
511
  if (typeof folderPath !== "string" || !path.isAbsolute(folderPath.trim())) {
147
512
  throw new Error("Choose a valid folder.")
148
513
  }
@@ -158,8 +523,7 @@ class Vault {
158
523
  if (!stats.isDirectory()) throw new Error("Choose a folder, not a file.")
159
524
 
160
525
  const home = path.resolve(this.kernel.homedir)
161
- let canonicalHome = home
162
- try { canonicalHome = path.resolve(await fs.promises.realpath(home)) } catch (error) {}
526
+ const canonicalHome = path.resolve(await fs.promises.realpath(home))
163
527
  if (isPathWithin(canonicalHome, canonical) || isPathWithin(canonical, canonicalHome)) {
164
528
  throw new Error("That folder is already inside Pinokio and is included in scans.")
165
529
  }
@@ -169,53 +533,138 @@ class Vault {
169
533
  if (existing) {
170
534
  return { created: false, source: existing }
171
535
  }
536
+ const configured = [...this.registry.settings.external_sources]
537
+ if (configured.some((root) => isPathWithin(root, canonical) || isPathWithin(canonical, root))) {
538
+ throw new Error("Choose a folder that does not contain or overlap another scan location.")
539
+ }
540
+ const previousScan = this.registry.lastScan
541
+ const previousAttempt = this.registry.lastScanAttempt
542
+ this.registry.settings.external_sources = [...configured, canonical]
543
+ this.registry.lastScan = null
544
+ this.registry.lastScanAttempt = null
545
+ this.registry.schedulePersist()
546
+ try {
547
+ await this.registry.flush()
548
+ await this.refreshSources()
549
+ const source = this._sources.find((item) =>
550
+ item.kind === "external" && item.root && samePath(item.root, canonical))
551
+ if (!source) throw new Error("The folder could not be added.")
552
+ return { created: true, source }
553
+ } catch (error) {
554
+ this.registry.settings.external_sources = configured
555
+ this.registry.lastScan = previousScan
556
+ this.registry.lastScanAttempt = previousAttempt
557
+ this.registry.schedulePersist()
558
+ await this.registry.flush().catch(() => {})
559
+ await this.refreshSources().catch(() => {})
560
+ throw error
561
+ }
562
+ }
563
+ async removeExternalSource(sourceId) {
564
+ if (typeof sourceId !== "string" || !sourceId) {
565
+ throw new Error("Choose an external folder to remove.")
566
+ }
567
+ await this.refreshSources()
568
+ const source = this._sources.find((item) =>
569
+ item.id === sourceId && item.kind === "external" && item.root)
570
+ if (!source) throw new Error("That external folder is no longer configured.")
172
571
 
173
- const apiRoot = path.resolve(home, "api")
174
- await fs.promises.mkdir(apiRoot, { recursive: true })
175
- const baseLabel = path.basename(canonical) || "external-folder"
176
- let mountPath = null
177
- for (let index = 1; index < 10000; index++) {
178
- const label = index === 1 ? baseLabel : `${baseLabel}-${index}`
179
- const candidate = path.resolve(apiRoot, label)
572
+ let canonical
573
+ let rootStat
574
+ try {
575
+ canonical = path.resolve(await fs.promises.realpath(source.root))
576
+ rootStat = await fs.promises.stat(canonical)
577
+ } catch (error) {
578
+ throw new Error("Reconnect this folder before removing it.")
579
+ }
580
+ if (!rootStat.isDirectory() || !samePath(canonical, source.root)) {
581
+ throw new Error("Reconnect this folder before removing it.")
582
+ }
583
+
584
+ const storesToRemove = new Map()
585
+ for (const [filePath, entry] of this.registry.links) {
586
+ if (entry.source_id !== source.id) continue
587
+ let fileStat
180
588
  try {
181
- await fs.promises.symlink(canonical, candidate, this.kernel.platform === "win32" ? "junction" : "dir")
182
- mountPath = candidate
183
- break
589
+ fileStat = await fs.promises.lstat(filePath)
184
590
  } catch (error) {
185
- if (error && error.code === "EEXIST") continue
186
- if (error && (error.code === "EACCES" || error.code === "EPERM")) {
187
- throw new Error("Pinokio does not have permission to add that folder.")
188
- }
189
- throw new Error("Pinokio could not add that folder.")
591
+ if (isMissingError(error)) continue
592
+ throw new Error("Some files in this folder could not be checked.")
593
+ }
594
+ if (!fileStat.isFile()) continue
595
+ const storePath = this.storePathFor(entry.hash)
596
+ const storeStat = await this.storeStatIfPresent(storePath)
597
+ const visibleNames = fileStat.nlink - (sameIdentity(fileStat, storeStat) ? 1 : 0)
598
+ if (visibleNames > 1) {
599
+ throw new Error("Turn sharing off for this folder before removing it.")
600
+ }
601
+ if (visibleNames === 1 && sameIdentity(fileStat, storeStat)) {
602
+ storesToRemove.set(entry.hash, {
603
+ path: storePath,
604
+ stat: storeStat
605
+ })
606
+ }
607
+ }
608
+
609
+ for (const [hash, store] of storesToRemove) {
610
+ if (await unlinkIfSame(store.path, store.stat)) {
611
+ this.registry.removeBlob(hash)
190
612
  }
191
613
  }
192
- if (!mountPath) throw new Error("Pinokio could not create a unique name for that folder.")
193
614
 
615
+ for (const [filePath, entry] of [...this.registry.links]) {
616
+ if (entry.source_id === source.id) this.registry.untrack(filePath)
617
+ }
618
+ for (const [filePath, entry] of [...this.registry.duplicates]) {
619
+ if (entry.source_id === source.id) this.registry.untrack(filePath)
620
+ }
621
+ for (const [filePath, entry] of [...this.registry.scanIndex]) {
622
+ if (entry.source_id === source.id) this.registry.untrack(filePath)
623
+ }
624
+ for (const [filePath, entry] of [...this.registry.excluded]) {
625
+ if (entry.source_id === source.id) this.registry.excluded.delete(filePath)
626
+ }
627
+ this.registry.sourceScans.delete(source.id)
628
+ this.registry.sourceScanAttempts.delete(source.id)
629
+ this.registry.settings.external_sources = this.registry.settings.external_sources
630
+ .filter((root) => !samePath(root, source.root))
631
+ this.registry.lastScan = null
632
+ this.registry.lastScanAttempt = null
633
+ this.registry.schedulePersist()
194
634
  await this.refreshSources()
195
- const source = this._sources.find((item) => item.kind === "external" && item.mount_path && samePath(item.mount_path, mountPath))
196
- if (!source) throw new Error("The folder was added but could not be loaded. Restart Pinokio and try again.")
197
- return { created: true, source }
635
+ return { removed: true, source_id: source.id }
198
636
  }
199
637
  sourceForPath(filePath, preferredId) {
200
- const sources = this._sources || []
201
- if (preferredId) {
202
- const preferred = sources.find((source) => source.id === preferredId)
203
- if (preferred) return preferred
638
+ let cursor = path.resolve(filePath)
639
+ while (true) {
640
+ const key = process.platform === "win32" ? cursor.toLowerCase() : cursor
641
+ const matches = this._sourceBases.get(key)
642
+ if (matches && matches.length) {
643
+ return matches.find((source) => source.id === preferredId) || matches[0]
644
+ }
645
+ const parent = path.dirname(cursor)
646
+ if (parent === cursor) return null
647
+ cursor = parent
648
+ }
649
+ }
650
+ async canonicalPathIsWithinSource(filePath, source, options = {}) {
651
+ if (!source || !source.root) return false
652
+ try {
653
+ const [canonicalRoot, canonicalFile] = await Promise.all([
654
+ fs.promises.realpath(source.root),
655
+ fs.promises.realpath(filePath)
656
+ ])
657
+ return isPathWithin(canonicalRoot, canonicalFile)
658
+ } catch (error) {
659
+ if (options.strictErrors && !isMissingError(error)) throw error
660
+ return false
204
661
  }
205
- const absolute = path.resolve(filePath)
206
- const concrete = sources.filter((source) => source.root && source.kind !== "virtual")
207
- .filter((source) => isPathWithin(source.root, absolute) || (source.mount_path && isPathWithin(source.mount_path, absolute)))
208
- .sort((a, b) => Math.max((b.root || "").length, (b.mount_path || "").length) -
209
- Math.max((a.root || "").length, (a.mount_path || "").length))
210
- return concrete[0] || sources.find((source) => source.id === "pinokio") || null
211
662
  }
212
663
  locationForPath(filePath, preferredId) {
213
664
  const source = this.sourceForPath(filePath, preferredId)
214
665
  if (!source) return { source_id: null, relative_path: path.basename(filePath) }
215
666
  const absolute = path.resolve(filePath)
216
- let base = source.root
217
- if (source.mount_path && isPathWithin(source.mount_path, absolute)) base = source.mount_path
218
- const relative = path.relative(base, absolute).split(path.sep).join("/") || path.basename(absolute)
667
+ const relative = path.relative(source.root, absolute).split(path.sep).join("/") || path.basename(absolute)
219
668
  return {
220
669
  source_id: source.id,
221
670
  source_kind: source.kind,
@@ -223,19 +672,66 @@ class Vault {
223
672
  relative_path: relative
224
673
  }
225
674
  }
226
- scanRoots() {
675
+ async recordEvent(event) {
676
+ let entry = event
677
+ if (event.path) {
678
+ const location = this.locationForPath(event.path, event.source_id)
679
+ const context = location.source_id ? location : { relative_path: event.path }
680
+ entry = Object.assign({}, event, context)
681
+ }
682
+ if (this.scanEvents) {
683
+ this.scanEvents.push(entry)
684
+ if (this.scanEvents.length > this.registry.maxEvents * 2) {
685
+ this.scanEvents.splice(0, this.registry.maxEvents)
686
+ }
687
+ return true
688
+ }
689
+ try {
690
+ await this.registry.appendEvent(entry)
691
+ return true
692
+ } catch (error) {
693
+ return false
694
+ }
695
+ }
696
+ scanSource(scopeId) {
697
+ if (!scopeId) return null
698
+ return this._sources.find((source) =>
699
+ source.id === scopeId && source.kind !== "virtual" && source.available && source.root) || null
700
+ }
701
+ scanRoots(scopeId = null) {
702
+ if (scopeId) {
703
+ const source = this.scanSource(scopeId)
704
+ return source ? [{ root: path.resolve(source.root), source_id: source.id }] : []
705
+ }
227
706
  const home = path.resolve(this.kernel.homedir)
228
- const roots = [{ root: home, source_id: "pinokio" }]
229
- const seen = new Set([home])
707
+ const candidates = [{ root: home, source_id: "pinokio" }]
230
708
  for (const source of this._sources) {
231
709
  if (source.kind !== "external" || !source.available || !source.root) continue
232
710
  const canonical = path.resolve(source.root)
233
- if (isPathWithin(home, canonical) || isPathWithin(canonical, home) || seen.has(canonical)) continue
234
- seen.add(canonical)
235
- roots.push({ root: canonical, source_id: source.id })
711
+ if (isPathWithin(home, canonical) || isPathWithin(canonical, home)) continue
712
+ candidates.push({ root: canonical, source_id: source.id })
713
+ }
714
+ candidates.sort((left, right) => left.root.length - right.root.length)
715
+ const roots = []
716
+ for (const candidate of candidates) {
717
+ if (roots.some((existing) => isPathWithin(existing.root, candidate.root))) continue
718
+ roots.push(candidate)
236
719
  }
237
720
  return roots
238
721
  }
722
+ reconcileConfiguredSources() {
723
+ if (!this.registry) return
724
+ const configured = (filePath, sourceId) => !!this.sourceForPath(filePath, sourceId)
725
+ for (const [filePath, entry] of [...this.registry.links]) {
726
+ if (!configured(filePath, entry.source_id)) this.registry.untrack(filePath)
727
+ }
728
+ for (const [filePath, entry] of [...this.registry.duplicates]) {
729
+ if (!configured(filePath, entry.source_id)) this.registry.untrack(filePath)
730
+ }
731
+ for (const [filePath, entry] of [...this.registry.scanIndex]) {
732
+ if (!configured(filePath, entry.source_id)) this.registry.untrack(filePath)
733
+ }
734
+ }
239
735
  // Kill switch: system ENVIRONMENT variable PINOKIO_VAULT, default unset =
240
736
  // enabled. Read directly (single key) to avoid loading the environment
241
737
  // module chain before the kernel is fully up.
@@ -248,48 +744,125 @@ class Vault {
248
744
  const m = line.match(/^\s*PINOKIO_VAULT\s*=\s*(.*)\s*$/)
249
745
  if (m) value = m[1].trim()
250
746
  }
251
- } catch (e) {}
747
+ } catch (error) {
748
+ if (!isMissingError(error)) throw error
749
+ }
252
750
  }
253
751
  return String(value).toLowerCase() !== "false"
254
752
  }
255
753
  // Disabled ⇒ do nothing observable: no directory, no registry, no probes.
256
- async init() {
754
+ async init(options = {}) {
257
755
  this.enabled = await this.isEnabled()
258
756
  if (!this.enabled) return { enabled: false }
259
- await fs.promises.mkdir(this.blobRoot, { recursive: true })
757
+ if (options.existingOnly) {
758
+ try {
759
+ await fs.promises.stat(this.root)
760
+ } catch (error) {
761
+ if (error && error.code === "ENOENT") return { enabled: true, fresh: true }
762
+ throw error
763
+ }
764
+ }
765
+ return this.initializeStorage()
766
+ }
767
+ async initializeStorage() {
768
+ if (this.initialized) return { enabled: true, mode: this.mode }
769
+ await this.ensureDirectory(this.root)
770
+ await this.ensureDirectory(this.blobRoot)
260
771
  this.registry = new Registry(this.root)
261
772
  this.mode = await this.probe(this.root)
262
- await this.refreshSources()
263
773
  const loaded = await this.registry.load()
264
- if (loaded.corrupt) {
265
- await this.rebuild(undefined, { preserveState: false })
774
+ await this.refreshSources()
775
+ const missingWithBlobs = !loaded.existed && await this.hasStoredBlobs()
776
+ if (loaded.corrupt || missingWithBlobs) {
777
+ // Recovery may inspect Vault's own hash shards, but it must not discover
778
+ // or walk configured sources. A later explicit Scan or Repair rebuilds
779
+ // visible-path state.
780
+ await this.rebuild([], { preserveState: false })
266
781
  }
782
+ this.sizeThreshold = this.registry.settings.candidate_min_bytes || SIZE_THRESHOLD
267
783
  this.sweeper = new Sweeper(this)
268
784
  this.initialized = true
269
- return { enabled: true, mode: this.mode, corrupt_recovered: !!loaded.corrupt }
785
+ return {
786
+ enabled: true,
787
+ mode: this.mode,
788
+ corrupt_recovered: !!loaded.corrupt,
789
+ missing_recovered: missingWithBlobs
790
+ }
270
791
  }
271
- // Capability probe: temp file + node:fs.link + nlink check. Once per volume.
272
- async probe(dir) {
273
- let dev
792
+ async ensureInitialized() {
793
+ if (!this.enabled) return { enabled: false }
794
+ if (this.initialized && !this.verificationPending) return { enabled: true, mode: this.mode }
795
+ if (!this.initializationPromise) {
796
+ this.initializationPromise = (async () => {
797
+ const result = this.initialized
798
+ ? { enabled: true, mode: this.mode }
799
+ : await this.initializeStorage()
800
+ await this.runExclusive(async () => {
801
+ await this.verify()
802
+ await this.registry.flush()
803
+ })
804
+ this.verificationPending = false
805
+ return result
806
+ })().catch((error) => {
807
+ if (this.initialized) this.verificationPending = true
808
+ throw error
809
+ }).finally(() => {
810
+ this.initializationPromise = null
811
+ })
812
+ }
813
+ return this.initializationPromise
814
+ }
815
+ async hasStoredBlobs() {
816
+ let shards = []
274
817
  try {
275
- dev = (await fs.promises.stat(dir)).dev
276
- if (this.volumeModes.has(dev)) return this.volumeModes.get(dev)
277
- } catch (e) {
278
- return "copy"
818
+ shards = await fs.promises.readdir(this.blobRoot, { withFileTypes: true })
819
+ } catch (error) {
820
+ if (isMissingError(error)) return false
821
+ throw error
279
822
  }
823
+ for (const shard of shards) {
824
+ if (!shard.isDirectory() || !/^[0-9a-f]{2}$/.test(shard.name)) continue
825
+ const shardPath = path.resolve(this.blobRoot, shard.name)
826
+ const shardStat = await this.directoryIfSafe(shardPath)
827
+ if (!shardStat) continue
828
+ let names = []
829
+ try {
830
+ names = await fs.promises.readdir(shardPath)
831
+ } catch (error) {
832
+ if (isMissingError(error)) continue
833
+ throw error
834
+ }
835
+ if (names.some((name) => SHA256_RE.test(name) && name.startsWith(shard.name))) return true
836
+ }
837
+ return false
838
+ }
839
+ // Capability probe: temp file + node:fs.link + nlink check. Once per volume.
840
+ async probe(dir) {
841
+ const dev = (await fs.promises.stat(dir)).dev
842
+ if (this.volumeModes.has(dev)) return this.volumeModes.get(dev)
280
843
  const a = path.resolve(dir, `.pinokio-probe-${crypto.randomBytes(6).toString("hex")}`)
281
844
  const b = a + "-link"
282
- let mode = "copy"
845
+ let mode = "unsupported"
846
+ let aStat = null
847
+ let bStat = null
848
+ let failure = null
283
849
  try {
284
- await fs.promises.writeFile(a, "probe")
850
+ await fs.promises.writeFile(a, "probe", { flag: "wx" })
851
+ aStat = await fs.promises.lstat(a)
285
852
  await fs.promises.link(a, b)
286
- const st = await fs.promises.stat(b)
287
- if (st.nlink === 2) mode = "link"
288
- } catch (e) {
853
+ bStat = await fs.promises.lstat(b)
854
+ if (sameIdentity(bStat, aStat) && bStat.nlink === 2) mode = "link"
855
+ } catch (error) {
856
+ if (!NO_LINK_CODES.has(error && error.code)) failure = error
289
857
  } finally {
290
- await fs.promises.unlink(a).catch(() => {})
291
- await fs.promises.unlink(b).catch(() => {})
858
+ try {
859
+ await unlinkIfSame(b, bStat)
860
+ await unlinkIfSame(a, aStat)
861
+ } catch (error) {
862
+ if (!failure) failure = error
863
+ }
292
864
  }
865
+ if (failure) throw failure
293
866
  this.volumeModes.set(dev, mode)
294
867
  return mode
295
868
  }
@@ -301,13 +874,16 @@ class Vault {
301
874
  if (!this.worker) {
302
875
  const worker = new Worker(path.resolve(__dirname, "hash_worker.js"))
303
876
  this.worker = worker
304
- this.workerStarts += 1
305
877
  worker.unref()
306
878
  worker.on("message", ({ id, hash, size, error }) => {
307
879
  const job = this.workerJobs.get(id)
308
880
  if (!job) return
309
881
  this.workerJobs.delete(id)
310
- if (error) job.reject(new Error(error))
882
+ if (error) {
883
+ const failure = new Error(error.message || String(error))
884
+ if (error.code) failure.code = error.code
885
+ job.reject(failure)
886
+ }
311
887
  else job.resolve({ hash, size })
312
888
  // Keep the worker warm across the scan queue. Starting one worker per
313
889
  // file costs ~30ms and adds up quickly on a first scan.
@@ -347,47 +923,131 @@ class Vault {
347
923
  this.worker.postMessage({ id, filePath })
348
924
  })
349
925
  }
926
+ async refreshLinkSnapshots(hash, dev, ino) {
927
+ if (!this.registry) return
928
+ const key = this.registry.inoKey(dev, ino)
929
+ const paths = [...(this.registry.pathsByIno.get(key) || [])]
930
+ for (const linkPath of paths) {
931
+ const entry = this.registry.links.get(linkPath)
932
+ if (!entry || entry.hash !== hash) continue
933
+ try {
934
+ const source = this.sourceForPath(linkPath, entry.source_id)
935
+ if (!source || !await this.canonicalPathIsWithinSource(linkPath, source)) continue
936
+ const st = await fs.promises.lstat(linkPath)
937
+ if (st.dev !== dev || st.ino !== ino) continue
938
+ this.registry.setScanEntry(linkPath, Object.assign(fileSnapshot(st), {
939
+ hash,
940
+ source_id: entry.source_id || null
941
+ }))
942
+ } catch (error) {
943
+ this.registry.scanIndex.delete(linkPath)
944
+ entry.unverified = true
945
+ }
946
+ }
947
+ this.registry.schedulePersist()
948
+ }
949
+ async verifyStoreContent(hash, storePath, storeStat) {
950
+ if (!storeStat || !storeStat.isFile()) return { valid: false }
951
+ const key = this.registry.inoKey(storeStat.dev, storeStat.ino)
952
+ for (const linkPath of this.registry.pathsByIno.get(key) || []) {
953
+ const entry = this.registry.links.get(linkPath)
954
+ if (!entry) continue
955
+ if (entry.hash !== hash ||
956
+ entry.dev !== storeStat.dev || entry.ino !== storeStat.ino) continue
957
+ const expected = this.registry.scanIndex.get(linkPath)
958
+ if (expected && expected.hash === hash && sameSnapshot(expected, storeStat)) {
959
+ return { valid: true, snapshot: fileSnapshot(storeStat) }
960
+ }
961
+ }
962
+
963
+ const before = fileSnapshot(storeStat)
964
+ let hashed
965
+ try {
966
+ hashed = await this.hashFile(storePath)
967
+ } catch (error) {
968
+ if (isMissingError(error)) return { valid: false }
969
+ throw error
970
+ }
971
+ let after
972
+ try {
973
+ after = await this.storeStatIfPresent(storePath)
974
+ } catch (error) {
975
+ if (isMissingError(error)) return { valid: false }
976
+ throw error
977
+ }
978
+ if (!sameSnapshot(before, after) || hashed.hash !== hash || hashed.size !== after.size) {
979
+ return { valid: false }
980
+ }
981
+ await this.refreshLinkSnapshots(hash, after.dev, after.ino)
982
+ return { valid: true, snapshot: fileSnapshot(after) }
983
+ }
350
984
  // adopt: give an existing file a store name. Metadata-only, never copies.
351
985
  async adopt(filePath, hash, meta = {}) {
352
986
  if (!this.enabled) return { status: "disabled" }
353
987
  const storePath = this.storePathFor(hash)
354
- const st = await fs.promises.stat(filePath)
988
+ const st = await fs.promises.lstat(filePath)
989
+ if (!st.isFile()) return { status: "stale" }
990
+ if (meta.expected && !sameSnapshot(meta.expected, st)) return { status: "stale" }
991
+ const before = fileSnapshot(st)
355
992
  const linkEntry = {
356
993
  hash,
357
994
  app: meta.app || null,
358
995
  source_id: meta.source_id || null,
359
996
  dev: st.dev,
360
997
  ino: st.ino,
361
- mode: "link"
362
998
  }
363
- let storeStat = null
364
- try {
365
- storeStat = await fs.promises.stat(storePath)
366
- } catch (e) {}
999
+ const storeStat = await this.storeStatIfPresent(storePath, {
1000
+ createParent: this.mode === "link"
1001
+ })
367
1002
  if (storeStat) {
368
1003
  if (storeStat.dev === st.dev && storeStat.ino === st.ino) {
1004
+ const current = await lstatIfPresent(filePath)
1005
+ if (!sameSnapshot(before, current)) return { status: "stale" }
369
1006
  this.registry.addBlob(hash, { size: st.size, source_urls: meta.source_urls || [] })
370
1007
  this.registry.addLink(filePath, linkEntry)
1008
+ await this.refreshLinkSnapshots(hash, st.dev, st.ino)
371
1009
  return { status: "already" }
372
1010
  }
1011
+ const current = await lstatIfPresent(filePath)
1012
+ if (!sameSnapshot(before, current)) return { status: "stale" }
373
1013
  return { status: "duplicate", storePath }
374
1014
  }
1015
+ if (this.mode !== "link") return { status: "unavailable", code: "ENOTSUP" }
375
1016
  try {
376
- await fs.promises.mkdir(path.dirname(storePath), { recursive: true })
377
1017
  await fs.promises.link(filePath, storePath)
378
1018
  } catch (e) {
379
1019
  if (NO_LINK_CODES.has(e.code)) {
380
- linkEntry.mode = "copy"
381
- this.registry.addBlob(hash, { size: st.size, source_urls: meta.source_urls || [] })
382
- this.registry.addLink(filePath, linkEntry)
383
- await this.registry.appendEvent({ kind: "adopt", hash, path: filePath, app: meta.app || null, source_id: meta.source_id || null, bytes_saved: 0, mode: "copy" })
384
- return { status: "copy-mode" }
1020
+ return { status: "unavailable", code: e.code }
385
1021
  }
386
1022
  throw e
387
1023
  }
1024
+ const [current, currentStore] = await Promise.all([
1025
+ lstatIfPresent(filePath),
1026
+ this.storeStatIfPresent(storePath)
1027
+ ])
1028
+ if (!sameContentState(before, current) || !currentStore ||
1029
+ currentStore.dev !== st.dev || currentStore.ino !== st.ino) {
1030
+ // Remove only a store name that still points either to the inode we
1031
+ // intended to link or to the current source inode that won the race.
1032
+ // An unrelated replacement at the reserved store path is preserved.
1033
+ const ownsStoreName = currentStore && (
1034
+ (currentStore.dev === st.dev && currentStore.ino === st.ino) ||
1035
+ (current && currentStore.dev === current.dev && currentStore.ino === current.ino)
1036
+ )
1037
+ if (ownsStoreName) {
1038
+ try {
1039
+ await fs.promises.unlink(storePath)
1040
+ } catch (error) {
1041
+ if (!isMissingError(error)) throw error
1042
+ }
1043
+ }
1044
+ return { status: "stale" }
1045
+ }
388
1046
  this.registry.addBlob(hash, { size: st.size, source_urls: meta.source_urls || [] })
389
1047
  this.registry.addLink(filePath, linkEntry)
390
- await this.registry.appendEvent({ kind: "adopt", hash, path: filePath, app: meta.app || null, source_id: meta.source_id || null, bytes_saved: 0 })
1048
+ this.rememberScanStore(storePath, currentStore)
1049
+ await this.refreshLinkSnapshots(hash, st.dev, st.ino)
1050
+ await this.recordEvent({ kind: "adopt", hash, path: filePath, app: meta.app || null, source_id: meta.source_id || null, bytes_saved: 0 })
391
1051
  return { status: "adopted" }
392
1052
  }
393
1053
  // convert: replace a byte-identical duplicate with a link to an existing
@@ -395,35 +1055,86 @@ class Vault {
395
1055
  async convert(targetPath, hash, meta = {}) {
396
1056
  if (!this.enabled) return { status: "disabled" }
397
1057
  const storePath = this.storePathFor(hash)
398
- let storeStat
1058
+ const storeStat = await this.storeStatIfPresent(storePath)
1059
+ if (!storeStat) return { status: "no-blob" }
1060
+ let targetStat
399
1061
  try {
400
- storeStat = await fs.promises.stat(storePath)
401
- } catch (e) {
402
- return { status: "no-blob" }
1062
+ targetStat = await fs.promises.lstat(targetPath)
1063
+ } catch (error) {
1064
+ if (LOCK_CODES.has(error && error.code)) return { status: "locked" }
1065
+ if (isMissingError(error)) return { status: "stale" }
1066
+ throw error
1067
+ }
1068
+ if (!storeStat.isFile() || !targetStat.isFile()) return { status: "stale" }
1069
+ if (meta.expected && !sameSnapshot(meta.expected, targetStat)) {
1070
+ return { status: "stale" }
1071
+ }
1072
+ if (storeStat.dev !== targetStat.dev) {
1073
+ return { status: "unavailable", code: "EXDEV" }
403
1074
  }
404
- const targetStat = await fs.promises.stat(targetPath)
405
1075
  if (storeStat.dev === targetStat.dev && storeStat.ino === targetStat.ino) {
406
- this.registry.addLink(targetPath, { hash, app: meta.app || null, source_id: meta.source_id || null, dev: storeStat.dev, ino: storeStat.ino, mode: "link" })
1076
+ this.registry.addLink(targetPath, {
1077
+ hash, app: meta.app || null, source_id: meta.source_id || null,
1078
+ dev: storeStat.dev, ino: storeStat.ino, unverified: false
1079
+ })
1080
+ await this.refreshLinkSnapshots(hash, storeStat.dev, storeStat.ino)
407
1081
  return { status: "already" }
408
1082
  }
409
1083
  if (storeStat.size !== targetStat.size) {
410
1084
  return { status: "size-mismatch" }
411
1085
  }
1086
+ if (!sameFileMetadata(storeStat, targetStat)) {
1087
+ return { status: "metadata-mismatch" }
1088
+ }
1089
+ const verifiedStore = await this.verifyStoreContent(hash, storePath, storeStat)
1090
+ if (!verifiedStore.valid) return { status: "stale-blob" }
1091
+ if (meta.source && this.sourceAppIsRunning(meta.source)) return { status: "locked" }
412
1092
  const tmp = targetPath + TMP_SUFFIX
1093
+ let ownsTmp = false
413
1094
  try {
414
1095
  try {
415
1096
  await fs.promises.link(storePath, tmp)
1097
+ ownsTmp = true
416
1098
  } catch (e) {
417
1099
  if (e.code === "EEXIST") {
418
- await fs.promises.unlink(tmp)
419
- await fs.promises.link(storePath, tmp)
1100
+ let tmpStat = null
1101
+ try { tmpStat = await fs.promises.lstat(tmp) } catch (error) {}
1102
+ if (!tmpStat || tmpStat.dev !== storeStat.dev || tmpStat.ino !== storeStat.ino) {
1103
+ return { status: "conflict" }
1104
+ }
420
1105
  } else {
421
1106
  throw e
422
1107
  }
423
1108
  }
1109
+ // Narrow the portable stat/rename race as far as Node permits. The app
1110
+ // guard prevents known Pinokio writers; arbitrary external writers in the
1111
+ // final syscall-sized interval are the documented residual limitation.
1112
+ if (meta.source && this.sourceAppIsRunning(meta.source)) {
1113
+ if (ownsTmp) await unlinkIfSame(tmp, storeStat).catch(() => {})
1114
+ return { status: "locked" }
1115
+ }
1116
+ const currentTmpStat = await lstatIfPresent(tmp)
1117
+ if (!currentTmpStat || currentTmpStat.dev !== storeStat.dev || currentTmpStat.ino !== storeStat.ino) {
1118
+ if (ownsTmp) await unlinkIfSame(tmp, storeStat).catch(() => {})
1119
+ return { status: "conflict" }
1120
+ }
1121
+ const currentTargetStat = await fs.promises.lstat(targetPath)
1122
+ if (!sameSnapshot(fileSnapshot(targetStat), currentTargetStat)) {
1123
+ if (ownsTmp) await unlinkIfSame(tmp, storeStat).catch(() => {})
1124
+ return { status: "stale" }
1125
+ }
1126
+ const currentStoreStat = await this.storeStatIfPresent(storePath)
1127
+ // Creating our temporary hardlink changes ctime on the shared inode.
1128
+ // Identity, size, and mtime still detect replacement or content writes.
1129
+ if (!currentStoreStat || !sameContentState(verifiedStore.snapshot, currentStoreStat) ||
1130
+ !sameFileMetadata(currentStoreStat, currentTargetStat)) {
1131
+ if (ownsTmp) await unlinkIfSame(tmp, storeStat).catch(() => {})
1132
+ return { status: "stale-blob" }
1133
+ }
424
1134
  await fs.promises.rename(tmp, targetPath)
1135
+ ownsTmp = false
425
1136
  } catch (e) {
426
- await fs.promises.unlink(tmp).catch(() => {})
1137
+ if (ownsTmp) await unlinkIfSame(tmp, storeStat).catch(() => {})
427
1138
  if (LOCK_CODES.has(e.code)) {
428
1139
  return { status: "locked" }
429
1140
  }
@@ -432,78 +1143,365 @@ class Vault {
432
1143
  }
433
1144
  throw e
434
1145
  }
435
- const st = await fs.promises.stat(targetPath)
436
- this.registry.addLink(targetPath, { hash, app: meta.app || null, source_id: meta.source_id || null, dev: st.dev, ino: st.ino, mode: "link" })
437
- this.registry.addSaved(storeStat.size)
438
- await this.registry.appendEvent({
1146
+ let st
1147
+ try {
1148
+ st = await fs.promises.lstat(targetPath)
1149
+ } catch (error) {
1150
+ if (isMissingError(error)) return { status: "stale" }
1151
+ // rename() is the commit point: it succeeded with a temporary name
1152
+ // whose identity was already verified. A later metadata read failure
1153
+ // must not report the completed replacement as failed.
1154
+ st = storeStat
1155
+ }
1156
+ if (st.dev !== storeStat.dev || st.ino !== storeStat.ino) {
1157
+ return { status: "stale" }
1158
+ }
1159
+ this.registry.addLink(targetPath, {
1160
+ hash, app: meta.app || null, source_id: meta.source_id || null,
1161
+ dev: st.dev, ino: st.ino, batch_id: meta.batch_id || null,
1162
+ unverified: false
1163
+ })
1164
+ await this.refreshLinkSnapshots(hash, st.dev, st.ino)
1165
+ await this.recordEvent({
439
1166
  kind: "convert", hash, path: targetPath, app: meta.app || null, source_id: meta.source_id || null,
440
- auto: !!meta.auto, bytes_saved: storeStat.size, batch_id: meta.batch_id || null
1167
+ bytes_saved: storeStat.size, batch_id: meta.batch_id || null
441
1168
  })
442
1169
  return { status: "converted", bytes_saved: storeStat.size }
443
1170
  }
1171
+ sourceAppIsRunning(source) {
1172
+ if (!source || source.kind !== "app") return false
1173
+ const appRoot = path.resolve(this.kernel.homedir, "api", source.app)
1174
+ const api = this.kernel.api || {}
1175
+ const running = api.running || {}
1176
+ const runningPaths = api.running_paths || {}
1177
+ return Object.keys(running).some((runningId) => {
1178
+ if (!running[runningId]) return false
1179
+ const runningPath = runningPaths[runningId] || (path.isAbsolute(runningId) ? runningId.split("?")[0] : null)
1180
+ return !!(runningPath && isPathWithin(appRoot, runningPath))
1181
+ })
1182
+ }
1183
+ async deduplicateScope(scopeId, options = {}) {
1184
+ await this.refreshSources()
1185
+ const source = this._sources.find((item) => item.id === scopeId && item.kind !== "virtual")
1186
+ if (!source) return { error: "That location is no longer available. Scan again to refresh it." }
1187
+ if (!source.shareable) return { error: "This location is on a disk that cannot share space with this vault." }
1188
+ if (this.sourceAppIsRunning(source)) {
1189
+ return { error: "This app has a running script. Stop it first, then deduplicate." }
1190
+ }
1191
+
1192
+ const registry = this.registry
1193
+ const batch = options.batch_id || `batch-${crypto.randomUUID()}`
1194
+ const requestedPaths = options.paths === undefined
1195
+ ? new Set([...registry.duplicates.keys()])
1196
+ : new Set((options.paths || [])
1197
+ .filter((filePath) => typeof filePath === "string" && filePath))
1198
+ if (!requestedPaths.size) return { error: "No displayed files were selected." }
1199
+ const candidates = [...registry.duplicates].filter(([filePath, entry]) => {
1200
+ if (!requestedPaths.has(filePath)) return false
1201
+ const currentSource = this.sourceForPath(filePath, entry.source_id)
1202
+ return !!(currentSource && currentSource.id === scopeId && currentSource.shareable)
1203
+ })
1204
+ const progress = options.progress || null
1205
+ if (progress) {
1206
+ progress.files_completed = 0
1207
+ progress.files_total = candidates.length
1208
+ this.actionSequence += 1
1209
+ }
1210
+ const summary = { converted: 0, bytes_saved: 0, locked: 0, stale: 0, incompatible: 0, unavailable: 0, failed: 0 }
1211
+ const staleHashes = new Set()
1212
+ for (const [filePath, entry] of candidates) {
1213
+ try {
1214
+ const currentSource = this.sourceForPath(filePath, entry.source_id)
1215
+ if (!currentSource || currentSource.id !== scopeId || !currentSource.shareable) continue
1216
+ if (entry.unverified) {
1217
+ summary.unavailable += 1
1218
+ continue
1219
+ }
1220
+ if (!await this.canonicalPathIsWithinSource(filePath, currentSource)) {
1221
+ summary.stale += 1
1222
+ continue
1223
+ }
1224
+ const indexed = registry.scanIndex.get(filePath)
1225
+ const expected = indexed && indexed.hash === entry.hash
1226
+ ? indexed
1227
+ : (entry.dev !== undefined && entry.ino !== undefined &&
1228
+ entry.mtime !== undefined && entry.ctime !== undefined ? entry : null)
1229
+ if (!expected) {
1230
+ summary.stale += 1
1231
+ continue
1232
+ }
1233
+ if (staleHashes.has(entry.hash)) {
1234
+ summary.stale += 1
1235
+ continue
1236
+ }
1237
+ try {
1238
+ const result = await this.convert(filePath, entry.hash, {
1239
+ app: source.kind === "app" ? source.app : (entry.app || null),
1240
+ source_id: scopeId,
1241
+ batch_id: batch,
1242
+ source: currentSource,
1243
+ expected
1244
+ })
1245
+ if (result.status === "converted" || result.status === "already") {
1246
+ summary.converted += 1
1247
+ summary.bytes_saved += result.bytes_saved || 0
1248
+ } else if (result.status === "locked") {
1249
+ summary.locked += 1
1250
+ } else if (result.status === "stale") {
1251
+ summary.stale += 1
1252
+ } else if (result.status === "stale-blob") {
1253
+ staleHashes.add(entry.hash)
1254
+ summary.stale += 1
1255
+ } else if (result.status === "metadata-mismatch") {
1256
+ entry.unavailable_reason = "metadata"
1257
+ summary.incompatible += 1
1258
+ } else if (result.status === "unavailable") {
1259
+ summary.unavailable += 1
1260
+ } else {
1261
+ summary.failed += 1
1262
+ }
1263
+ } catch (error) {
1264
+ summary.failed += 1
1265
+ }
1266
+ } finally {
1267
+ if (progress) {
1268
+ progress.files_completed += 1
1269
+ this.actionSequence += 1
1270
+ }
1271
+ if (options.onFileComplete) options.onFileComplete()
1272
+ }
1273
+ }
1274
+ registry.schedulePersist()
1275
+ return summary
1276
+ }
1277
+ async deduplicateAll(groups, progress) {
1278
+ const summary = {
1279
+ converted: 0,
1280
+ bytes_saved: 0,
1281
+ locked: 0,
1282
+ stale: 0,
1283
+ incompatible: 0,
1284
+ unavailable: 0,
1285
+ failed: 0,
1286
+ locations_skipped: 0
1287
+ }
1288
+ const fields = ["converted", "bytes_saved", "locked", "stale", "incompatible", "unavailable", "failed"]
1289
+ progress.files_completed = 0
1290
+ progress.files_total = groups.reduce((sum, group) => sum + group.paths.length, 0)
1291
+ this.actionSequence += 1
1292
+ for (const group of groups) {
1293
+ let completed = 0
1294
+ const result = await this.deduplicateScope(group.scope_id, {
1295
+ batch_id: `batch-${crypto.randomUUID()}`,
1296
+ paths: group.paths,
1297
+ onFileComplete: () => {
1298
+ completed += 1
1299
+ progress.files_completed += 1
1300
+ this.actionSequence += 1
1301
+ }
1302
+ })
1303
+ if (result.error) {
1304
+ summary.unavailable += group.paths.length
1305
+ summary.locations_skipped += 1
1306
+ } else {
1307
+ for (const field of fields) summary[field] += Number(result[field]) || 0
1308
+ }
1309
+ const unprocessed = Math.max(0, group.paths.length - completed)
1310
+ if (unprocessed) {
1311
+ progress.files_completed += unprocessed
1312
+ this.actionSequence += 1
1313
+ }
1314
+ }
1315
+ return summary
1316
+ }
444
1317
  // reclaim: delete an orphan's store name. Refuses when any app name remains.
445
- async reclaim(hash) {
1318
+ async reclaim(hash, options = {}) {
446
1319
  if (!this.enabled) return { status: "disabled" }
447
1320
  const storePath = this.storePathFor(hash)
448
- let st
449
- try {
450
- st = await fs.promises.stat(storePath)
451
- } catch (e) {
452
- this.registry.removeBlob(hash)
453
- return { status: "gone" }
1321
+ if (!this.registry.blobs.has(hash)) return { status: "not-found" }
1322
+ const st = await this.storeStatIfPresent(storePath)
1323
+ if (!st) {
1324
+ if (!options.deferRegistry) this.registry.removeBlob(hash)
1325
+ return options.deferRegistry ? { status: "gone", remove_hash: true } : { status: "gone" }
1326
+ }
1327
+ if (!st.isFile()) {
1328
+ if (!options.deferRegistry) this.registry.removeBlob(hash)
1329
+ return options.deferRegistry
1330
+ ? { status: "invalid", remove_hash: true }
1331
+ : { status: "invalid" }
454
1332
  }
455
1333
  if (st.nlink > 1) return { status: "in-use" }
456
1334
  await fs.promises.unlink(storePath)
457
- this.registry.removeBlob(hash)
458
- await this.registry.appendEvent({ kind: "reclaim", hash, bytes_saved: st.size })
459
- return { status: "reclaimed", bytes_freed: st.size }
1335
+ if (!options.deferRegistry) this.registry.removeBlob(hash)
1336
+ await this.recordEvent({ kind: "reclaim", hash, bytes_saved: st.size })
1337
+ return options.deferRegistry
1338
+ ? { status: "reclaimed", bytes_freed: st.size, remove_hash: true }
1339
+ : { status: "reclaimed", bytes_freed: st.size }
460
1340
  }
461
1341
  // Startup registry verification (trigger 5): bounded by registry size,
462
1342
  // never a discovery walk. Prunes dead links, flags orphans, removes stray
463
1343
  // conversion tmp files, re-adopts blobs whose store name was deleted.
464
- async verify() {
1344
+ async verify(options = {}) {
465
1345
  if (!this.enabled || !this.registry) return
1346
+ const preservePath = options.preservePath || (() => false)
1347
+ const includePath = options.includePath || (() => true)
1348
+ const preserveReadError = (error, filePath, entry) => {
1349
+ if (isMissingError(error)) return false
1350
+ if (options.onAccessError) {
1351
+ if (!isAccessError(error) || !options.onAccessError(error, filePath)) return false
1352
+ }
1353
+ if (entry) entry.unverified = true
1354
+ return true
1355
+ }
1356
+ if (options.reconcile !== false) this.reconcileConfiguredSources()
466
1357
  for (const [linkPath, entry] of [...this.registry.links]) {
467
- await fs.promises.unlink(linkPath + TMP_SUFFIX).catch(() => {})
468
- let st = null
1358
+ if (!includePath(linkPath)) continue
1359
+ if (preservePath(linkPath)) continue
469
1360
  try {
470
- st = await fs.promises.stat(linkPath)
471
- } catch (e) {}
472
- if (!st || st.ino !== entry.ino || st.dev !== entry.dev) {
473
- this.registry.removeLink(linkPath)
1361
+ const source = this.sourceForPath(linkPath, entry.source_id)
1362
+ if (source && source.available === false) {
1363
+ entry.unverified = true
1364
+ continue
1365
+ }
1366
+ if (!source) {
1367
+ this.registry.untrack(linkPath)
1368
+ continue
1369
+ }
1370
+ const st = await lstatIfPresent(linkPath)
1371
+ if (!st) {
1372
+ this.registry.untrack(linkPath)
1373
+ continue
1374
+ }
1375
+ const managedPath = await this.canonicalPathIsWithinSource(
1376
+ linkPath, source, { strictErrors: true })
1377
+ const expected = this.registry.scanIndex.get(linkPath)
1378
+ entry.unverified = !managedPath || !st.isFile() ||
1379
+ st.ino !== entry.ino || st.dev !== entry.dev ||
1380
+ !expected || expected.hash !== entry.hash || !sameSnapshot(expected, st)
1381
+ } catch (error) {
1382
+ if (!preserveReadError(error, linkPath, entry)) throw error
474
1383
  }
475
1384
  }
476
- for (const [dupPath] of [...this.registry.duplicates]) {
1385
+ for (const [dupPath, entry] of [...this.registry.duplicates]) {
1386
+ if (!includePath(dupPath)) continue
1387
+ if (preservePath(dupPath)) continue
1388
+ let valid = false
477
1389
  try {
478
- await fs.promises.stat(dupPath)
479
- } catch (e) {
480
- this.registry.duplicates.delete(dupPath)
1390
+ const source = this.sourceForPath(dupPath, entry.source_id)
1391
+ if (source && source.available === false) {
1392
+ entry.unverified = true
1393
+ continue
1394
+ }
1395
+ const st = await lstatIfPresent(dupPath)
1396
+ if (!st) {
1397
+ this.registry.untrack(dupPath)
1398
+ continue
1399
+ }
1400
+ const expected = this.registry.scanIndex.get(dupPath)
1401
+ valid = !!(source && st.isFile() &&
1402
+ await this.canonicalPathIsWithinSource(dupPath, source, { strictErrors: true }) &&
1403
+ expected && expected.hash === entry.hash && sameSnapshot(expected, st))
1404
+ } catch (error) {
1405
+ if (preserveReadError(error, dupPath, entry)) continue
1406
+ throw error
481
1407
  }
1408
+ if (!valid) {
1409
+ entry.unverified = true
1410
+ continue
1411
+ }
1412
+ entry.unverified = false
1413
+ const storeStat = await this.storeStatIfPresent(this.storePathFor(entry.hash))
1414
+ if (storeStat && storeStat.isFile()) {
1415
+ await unlinkIfSame(dupPath + TMP_SUFFIX, storeStat)
1416
+ }
1417
+ }
1418
+ if (options.verifyBlobs === false) {
1419
+ this.registry.schedulePersist()
1420
+ return
1421
+ }
1422
+ const linksByHash = new Map()
1423
+ for (const [linkPath, entry] of this.registry.links) {
1424
+ if (!linksByHash.has(entry.hash)) linksByHash.set(entry.hash, [])
1425
+ linksByHash.get(entry.hash).push([linkPath, entry])
482
1426
  }
1427
+ const invalidBlobs = new Set()
483
1428
  for (const [hash, blob] of [...this.registry.blobs]) {
484
1429
  const storePath = this.storePathFor(hash)
485
- let st = null
486
- try {
487
- st = await fs.promises.stat(storePath)
488
- } catch (e) {}
1430
+ const st = await this.storeStatIfPresent(storePath)
489
1431
  if (!st) {
490
- // Store name gone; re-adopt from a surviving app name if one exists.
1432
+ const names = linksByHash.get(hash) || []
1433
+ // Deleting a name changes ctime, so trusted re-adoption compares the
1434
+ // preserved identity, size, and mtime. Changed or legacy-unsnapshotted
1435
+ // content waits for the next explicit scan instead of being assigned a
1436
+ // stale hash filename during startup.
491
1437
  let readopted = false
492
- for (const [linkPath, entry] of this.registry.links) {
493
- if (entry.hash !== hash || entry.mode !== "link") continue
1438
+ let inaccessibleName = false
1439
+ for (const [linkPath, entry] of names) {
1440
+ if (preservePath(linkPath)) {
1441
+ inaccessibleName = true
1442
+ continue
1443
+ }
1444
+ let source
1445
+ let linkStat
1446
+ try {
1447
+ source = this.sourceForPath(linkPath, entry.source_id)
1448
+ if (!source || !await this.canonicalPathIsWithinSource(linkPath, source, { strictErrors: true })) continue
1449
+ linkStat = await lstatIfPresent(linkPath)
1450
+ } catch (error) {
1451
+ if (!preserveReadError(error, linkPath, entry)) throw error
1452
+ inaccessibleName = true
1453
+ continue
1454
+ }
1455
+ const expected = this.registry.scanIndex.get(linkPath)
1456
+ if (!linkStat || !expected || expected.hash !== hash ||
1457
+ entry.dev !== linkStat.dev || entry.ino !== linkStat.ino ||
1458
+ !sameContentState(expected, linkStat)) continue
1459
+ const before = fileSnapshot(linkStat)
1460
+ let created = false
494
1461
  try {
495
- await fs.promises.mkdir(path.dirname(storePath), { recursive: true })
1462
+ await this.storeStatIfPresent(storePath, { createParent: true })
496
1463
  await fs.promises.link(linkPath, storePath)
497
- readopted = true
498
- break
499
- } catch (e) {}
1464
+ created = true
1465
+ } catch (error) {
1466
+ if (!error || error.code !== "EEXIST") throw error
1467
+ const currentStore = await this.storeStatIfPresent(storePath)
1468
+ const currentLink = await lstatIfPresent(linkPath)
1469
+ if (!currentStore || !sameContentState(before, currentLink) ||
1470
+ currentStore.dev !== linkStat.dev || currentStore.ino !== linkStat.ino) continue
1471
+ }
1472
+ const [currentLink, currentStore] = await Promise.all([
1473
+ lstatIfPresent(linkPath),
1474
+ this.storeStatIfPresent(storePath)
1475
+ ])
1476
+ if (!sameContentState(before, currentLink) || !currentStore ||
1477
+ currentStore.dev !== linkStat.dev || currentStore.ino !== linkStat.ino) {
1478
+ const ownsStoreName = created && currentStore && (
1479
+ (currentStore.dev === linkStat.dev && currentStore.ino === linkStat.ino) ||
1480
+ (currentLink && currentStore.dev === currentLink.dev && currentStore.ino === currentLink.ino)
1481
+ )
1482
+ if (ownsStoreName) await fs.promises.unlink(storePath)
1483
+ continue
1484
+ }
1485
+ await this.refreshLinkSnapshots(hash, linkStat.dev, linkStat.ino)
1486
+ readopted = true
1487
+ break
500
1488
  }
501
- if (!readopted) this.registry.removeBlob(hash)
1489
+ if (readopted || inaccessibleName) {
1490
+ blob.orphan = false
1491
+ blob.verified_at = inaccessibleName && !readopted ? null : Date.now()
1492
+ } else {
1493
+ invalidBlobs.add(hash)
1494
+ }
1495
+ continue
1496
+ }
1497
+ if (!st.isFile()) {
1498
+ invalidBlobs.add(hash)
502
1499
  continue
503
1500
  }
504
1501
  blob.orphan = st.nlink === 1
505
1502
  blob.verified_at = Date.now()
506
1503
  }
1504
+ this.registry.removeBlobs(invalidBlobs)
507
1505
  this.registry.schedulePersist()
508
1506
  }
509
1507
  // Rebuild derived state from disk: store filenames are hashes, stat gives
@@ -515,27 +1513,47 @@ class Vault {
515
1513
  const registry = this.registry
516
1514
  const preserveState = options.preserveState !== false
517
1515
  const preserved = preserveState ? {
1516
+ blobs: new Map(registry.blobs),
1517
+ links: new Map(registry.links),
518
1518
  excluded: new Map(registry.excluded),
519
- totals: Object.assign({}, registry.totals),
520
1519
  lastScan: registry.lastScan ? Object.assign({}, registry.lastScan) : null,
521
- duplicates: new Map(registry.duplicates)
1520
+ lastScanAttempt: registry.lastScanAttempt
1521
+ ? Object.assign({}, registry.lastScanAttempt)
1522
+ : null,
1523
+ sourceScans: new Map([...registry.sourceScans].map(([id, scan]) => [id, Object.assign({}, scan)])),
1524
+ sourceScanAttempts: new Map([...registry.sourceScanAttempts]
1525
+ .map(([id, scan]) => [id, Object.assign({}, scan)])),
1526
+ settings: Object.assign({}, registry.settings),
1527
+ duplicates: new Map(registry.duplicates),
1528
+ scanIndex: new Map(registry.scanIndex)
522
1529
  } : null
523
1530
  const rebuiltBlobs = new Map()
524
1531
  const rebuiltLinks = new Map()
525
1532
  const storeInoToHash = new Map()
526
1533
  let shards = []
527
1534
  try {
528
- shards = await fs.promises.readdir(this.blobRoot)
529
- } catch (e) {}
530
- for (const shard of shards) {
1535
+ shards = await fs.promises.readdir(this.blobRoot, { withFileTypes: true })
1536
+ } catch (error) {
1537
+ if (!isMissingError(error)) throw error
1538
+ }
1539
+ for (const shardEntry of shards) {
1540
+ if (!shardEntry.isDirectory() || shardEntry.isSymbolicLink() ||
1541
+ !/^[0-9a-f]{2}$/.test(shardEntry.name)) continue
1542
+ const shard = shardEntry.name
1543
+ const shardPath = path.resolve(this.blobRoot, shard)
1544
+ if (!await this.directoryIfSafe(shardPath)) continue
531
1545
  let names = []
532
1546
  try {
533
- names = await fs.promises.readdir(path.resolve(this.blobRoot, shard))
534
- } catch (e) { continue }
1547
+ names = await fs.promises.readdir(shardPath)
1548
+ } catch (error) {
1549
+ if (isMissingError(error)) continue
1550
+ throw error
1551
+ }
535
1552
  for (const name of names) {
536
- if (!/^[0-9a-f]{64}$/.test(name)) continue
1553
+ if (!SHA256_RE.test(name) || !name.startsWith(shard)) continue
537
1554
  try {
538
- const st = await fs.promises.stat(path.resolve(this.blobRoot, shard, name))
1555
+ const st = await fs.promises.lstat(path.resolve(this.blobRoot, shard, name))
1556
+ if (!st.isFile()) continue
539
1557
  const previous = registry.blobs.get(name)
540
1558
  rebuiltBlobs.set(name, {
541
1559
  size: st.size,
@@ -545,13 +1563,26 @@ class Vault {
545
1563
  orphan: st.nlink === 1
546
1564
  })
547
1565
  storeInoToHash.set(`${st.dev}:${st.ino}`, name)
548
- } catch (e) {}
1566
+ } catch (error) {
1567
+ if (!isMissingError(error)) throw error
1568
+ }
549
1569
  }
550
1570
  }
551
1571
  await this.refreshSources()
552
1572
  const walkRoots = roots
553
1573
  ? roots.map((root) => typeof root === "string" ? { root, source_id: null } : root)
554
- : this.scanRoots()
1574
+ : [
1575
+ { root: path.resolve(this.kernel.homedir), source_id: "pinokio" },
1576
+ ...this._sources
1577
+ .filter((source) => source.kind === "external" && source.root)
1578
+ .map((source) => ({ root: source.root, source_id: source.id }))
1579
+ ]
1580
+ for (const entry of walkRoots) {
1581
+ const rootStat = await fs.promises.lstat(entry.root)
1582
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
1583
+ throw new Error(`Repair source is not a real directory: ${entry.root}`)
1584
+ }
1585
+ }
555
1586
  for (const entry of walkRoots) {
556
1587
  await this.walkForInoMatch(
557
1588
  entry.root,
@@ -561,61 +1592,118 @@ class Vault {
561
1592
  )
562
1593
  }
563
1594
  const rebuiltDuplicates = new Map()
1595
+ const rebuiltScanIndex = new Map()
1596
+ const trustedHashes = new Set()
564
1597
  if (preserved) {
1598
+ // A store filename is only a trustworthy content hash when at least one
1599
+ // surviving name still matches the snapshot captured when that content
1600
+ // was hashed. Repair itself never hashes, so unverified names are left
1601
+ // without a scan-index entry and the next manual scan hashes them once.
1602
+ for (const [linkPath, link] of rebuiltLinks) {
1603
+ const expected = preserved.scanIndex.get(linkPath)
1604
+ if (!expected || expected.hash !== link.hash) continue
1605
+ try {
1606
+ const st = await fs.promises.lstat(linkPath)
1607
+ if (sameSnapshot(expected, st)) trustedHashes.add(link.hash)
1608
+ } catch (error) {
1609
+ if (!isMissingError(error)) throw error
1610
+ }
1611
+ }
1612
+ for (const [linkPath, link] of rebuiltLinks) {
1613
+ if (!trustedHashes.has(link.hash)) continue
1614
+ try {
1615
+ const st = await fs.promises.lstat(linkPath)
1616
+ rebuiltScanIndex.set(linkPath, {
1617
+ hash: link.hash, size: st.size, dev: st.dev, ino: st.ino,
1618
+ mtime: st.mtimeMs, ctime: st.ctimeMs,
1619
+ source_id: link.source_id || null
1620
+ })
1621
+ } catch (error) {
1622
+ if (!isMissingError(error)) throw error
1623
+ }
1624
+ }
565
1625
  for (const [duplicatePath, duplicate] of preserved.duplicates) {
566
- if (preserved.excluded.has(duplicatePath) || rebuiltLinks.has(duplicatePath) || !rebuiltBlobs.has(duplicate.hash)) continue
1626
+ if (preserved.excluded.has(duplicatePath) || rebuiltLinks.has(duplicatePath) ||
1627
+ !rebuiltBlobs.has(duplicate.hash) || !trustedHashes.has(duplicate.hash) ||
1628
+ !this.sourceForPath(duplicatePath, duplicate.source_id)) continue
567
1629
  try {
568
- const st = await fs.promises.stat(duplicatePath)
569
- if (st.isFile() && (!duplicate.size || duplicate.size === st.size)) {
570
- rebuiltDuplicates.set(duplicatePath, Object.assign({}, duplicate, { size: st.size }))
1630
+ const source = this.sourceForPath(duplicatePath, duplicate.source_id)
1631
+ if (!await this.canonicalPathIsWithinSource(duplicatePath, source, { strictErrors: true })) continue
1632
+ const st = await fs.promises.lstat(duplicatePath)
1633
+ const indexed = preserved.scanIndex.get(duplicatePath)
1634
+ const expected = indexed && indexed.hash === duplicate.hash ? indexed : duplicate
1635
+ if (st.isFile() && sameSnapshot(expected, st)) {
1636
+ rebuiltDuplicates.set(duplicatePath, Object.assign({}, duplicate, {
1637
+ size: st.size, dev: st.dev, ino: st.ino, mtime: st.mtimeMs, ctime: st.ctimeMs
1638
+ }))
1639
+ rebuiltScanIndex.set(duplicatePath, {
1640
+ hash: duplicate.hash, size: st.size, dev: st.dev, ino: st.ino,
1641
+ mtime: st.mtimeMs, ctime: st.ctimeMs,
1642
+ source_id: duplicate.source_id || expected.source_id || null
1643
+ })
571
1644
  }
572
- } catch (error) {}
1645
+ } catch (error) {
1646
+ if (!isMissingError(error)) throw error
1647
+ }
1648
+ }
1649
+ for (const excludedPath of preserved.excluded.keys()) {
1650
+ rebuiltLinks.delete(excludedPath)
1651
+ rebuiltScanIndex.delete(excludedPath)
573
1652
  }
574
1653
  }
575
1654
  const rebuiltByIno = new Map()
576
- for (const entry of rebuiltLinks.values()) {
577
- rebuiltByIno.set(registry.inoKey(entry.dev, entry.ino), entry.hash)
578
- }
579
- registry.blobs = rebuiltBlobs
580
- registry.links = rebuiltLinks
581
- registry.scanIndex = new Map()
582
- registry.duplicates = rebuiltDuplicates
583
- registry.excluded = preserved ? preserved.excluded : new Map()
584
- registry.lastScan = preserved ? preserved.lastScan : null
585
- registry.totals = preserved ? preserved.totals : { lifetime_bytes_saved: 0 }
586
- registry.byIno = rebuiltByIno
587
- await registry.flush()
1655
+ const rebuiltPathsByIno = new Map()
1656
+ for (const [linkPath, entry] of rebuiltLinks) {
1657
+ const key = registry.inoKey(entry.dev, entry.ino)
1658
+ rebuiltByIno.set(key, entry.hash)
1659
+ if (!rebuiltPathsByIno.has(key)) rebuiltPathsByIno.set(key, new Set())
1660
+ rebuiltPathsByIno.get(key).add(linkPath)
1661
+ }
1662
+ registry.replaceState({
1663
+ blobs: rebuiltBlobs,
1664
+ links: rebuiltLinks,
1665
+ scanIndex: rebuiltScanIndex,
1666
+ duplicates: rebuiltDuplicates,
1667
+ excluded: preserved ? preserved.excluded : new Map(),
1668
+ lastScan: preserved ? preserved.lastScan : null,
1669
+ lastScanAttempt: preserved ? preserved.lastScanAttempt : null,
1670
+ sourceScans: preserved ? preserved.sourceScans : new Map(),
1671
+ sourceScanAttempts: preserved ? preserved.sourceScanAttempts : new Map(),
1672
+ settings: preserved ? preserved.settings : {},
1673
+ byIno: rebuiltByIno,
1674
+ pathsByIno: rebuiltPathsByIno
1675
+ })
1676
+ if (options.flush !== false) await registry.flush()
588
1677
  }
589
1678
  async walkForInoMatch(root, storeInoToHash, preferredSourceId = null, outputLinks = null) {
590
- const stack = [root]
591
1679
  const vaultRoot = path.resolve(this.root)
592
- while (stack.length) {
593
- const dir = stack.pop()
594
- let entries = []
595
- try {
596
- entries = await fs.promises.readdir(dir, { withFileTypes: true })
597
- } catch (error) { continue }
598
- const files = []
599
- for (const entry of entries) {
600
- const full = path.resolve(dir, entry.name)
601
- if (entry.isSymbolicLink()) continue
602
- if (entry.isDirectory()) {
603
- if (full === vaultRoot) continue
604
- stack.push(full)
605
- }
606
- else if (entry.isFile()) files.push(full)
607
- }
608
- const stats = await statMany(files, this.statConcurrency)
1680
+ for await (const batch of walkBatches(root, {
1681
+ concurrency: this.dirConcurrency,
1682
+ skipDirectory: (full) => full === vaultRoot,
1683
+ strictErrors: true,
1684
+ onRootMissing: (error) => { throw error }
1685
+ })) {
1686
+ const files = batch.flatMap((group) => group.files.map((file) => file.path))
1687
+ const stats = await statMany(files, this.statConcurrency, null, {
1688
+ strictErrors: true,
1689
+ followSymlinks: false
1690
+ })
609
1691
  for (let index = 0; index < files.length; index++) {
610
1692
  const st = stats[index]
611
- if (!st || st.size < this.sizeThreshold) continue
1693
+ if (!st || !st.isFile()) continue
612
1694
  const hash = storeInoToHash.get(`${st.dev}:${st.ino}`)
613
1695
  if (hash) {
614
1696
  const source = this.sourceForPath(files[index], preferredSourceId)
1697
+ const previous = this.registry.links.get(files[index])
1698
+ const batchId = previous && previous.hash === hash &&
1699
+ previous.dev === st.dev && previous.ino === st.ino
1700
+ ? previous.batch_id || null
1701
+ : null
615
1702
  const link = {
616
1703
  hash, app: source && source.kind === "app" ? source.app : null,
617
1704
  source_id: source ? source.id : null, dev: st.dev, ino: st.ino,
618
- mode: "link", created: Date.now()
1705
+ created: Date.now(),
1706
+ batch_id: batchId
619
1707
  }
620
1708
  if (outputLinks) outputLinks.set(files[index], link)
621
1709
  else this.registry.addLink(files[index], link)
@@ -623,44 +1711,86 @@ class Vault {
623
1711
  }
624
1712
  }
625
1713
  }
626
- appNameFor(filePath) {
627
- const source = this.sourceForPath(filePath)
628
- return source && source.kind === "app" ? source.app : null
1714
+ cloudSyncProvider() {
1715
+ const home = path.resolve(this.kernel.homedir || "").replace(/\\/g, "/").toLowerCase()
1716
+ if (home.includes("/onedrive") || home.includes("/one drive")) return "OneDrive"
1717
+ if (home.includes("/dropbox")) return "Dropbox"
1718
+ if (home.includes("/library/mobile documents/") || home.includes("/icloud drive/")) return "iCloud Drive"
1719
+ return null
629
1720
  }
630
1721
  // Global state for the vault page and the health endpoint. Answered from
631
- // memory + one stat per blob; never a walk.
632
- async status() {
1722
+ // memory + one stat per blob and occupied shard; never a walk.
1723
+ async status(scopeId = null) {
633
1724
  if (!this.enabled || !this.registry) return { enabled: false }
1725
+ const responseActionSequence = this.actionSequence
634
1726
  const registry = this.registry
1727
+ const scope = scopeId ? this.scanSource(scopeId) : null
1728
+ if (scopeId && !scope) throw new Error("That location is no longer available.")
1729
+ const scopeHashes = scopeId ? new Set() : null
1730
+ if (scopeHashes) {
1731
+ for (const entry of registry.links.values()) {
1732
+ if (entry.source_id === scopeId) scopeHashes.add(entry.hash)
1733
+ }
1734
+ for (const entry of registry.duplicates.values()) {
1735
+ if (entry.source_id === scopeId) scopeHashes.add(entry.hash)
1736
+ }
1737
+ }
635
1738
  const blobs = []
636
1739
  const blobByHash = new Map()
637
1740
  const storeStats = new Map()
638
1741
  const namesByHash = new Map()
1742
+ const undoBatchMap = new Map()
639
1743
  for (const [linkPath, entry] of registry.links) {
1744
+ if (scopeHashes && !scopeHashes.has(entry.hash)) continue
640
1745
  if (!namesByHash.has(entry.hash)) namesByHash.set(entry.hash, [])
641
1746
  const location = this.locationForPath(linkPath, entry.source_id)
642
1747
  namesByHash.get(entry.hash).push(Object.assign({
643
- path: linkPath, app: entry.app || null, mode: entry.mode
1748
+ path: linkPath, app: entry.app || null, mode: "link",
1749
+ unverified: !!entry.unverified
644
1750
  }, location))
1751
+ if (entry.batch_id && (!scopeId || entry.source_id === scopeId)) {
1752
+ if (!undoBatchMap.has(entry.batch_id)) {
1753
+ undoBatchMap.set(entry.batch_id, { batch_id: entry.batch_id, files: 0, bytes: 0, ts: null })
1754
+ }
1755
+ const batch = undoBatchMap.get(entry.batch_id)
1756
+ const blob = registry.blobs.get(entry.hash)
1757
+ batch.files += 1
1758
+ batch.bytes += blob && Number.isFinite(blob.size) ? blob.size : 0
1759
+ }
1760
+ }
1761
+ const blobEntries = [...registry.blobs].filter(([hash]) => !scopeHashes || scopeHashes.has(hash))
1762
+ if (!await this.directoryIfSafe(this.root) || !await this.directoryIfSafe(this.blobRoot)) {
1763
+ throw unsafeStoragePath(this.blobRoot)
645
1764
  }
646
- let bytesOnDisk = 0
647
- let wouldBe = 0
648
- for (const [hash, blob] of registry.blobs) {
1765
+ const storePaths = blobEntries.map(([hash]) => this.storePathFor(hash))
1766
+ await Promise.all([...new Set(storePaths.map((storePath) => path.dirname(storePath)))]
1767
+ .map((shard) => this.directoryIfSafe(shard)))
1768
+ const blobStats = await statMany(
1769
+ storePaths,
1770
+ this.statConcurrency,
1771
+ null,
1772
+ { strictErrors: true, followSymlinks: false }
1773
+ )
1774
+ let savedBySharing = 0
1775
+ for (let index = 0; index < blobEntries.length; index++) {
1776
+ const [hash, blob] = blobEntries[index]
649
1777
  const names = namesByHash.get(hash) || []
650
1778
  const apps = new Set(names.map((name) => name.app).filter(Boolean))
651
- let nlink = null
652
- let storeStat = null
653
- try {
654
- storeStat = await fs.promises.stat(this.storePathFor(hash))
655
- nlink = storeStat.nlink
656
- } catch (e) {}
1779
+ const storeStat = blobStats[index] && blobStats[index].isFile() ? blobStats[index] : null
1780
+ const nlink = storeStat ? storeStat.nlink : null
657
1781
  storeStats.set(hash, storeStat)
658
1782
  const size = blob.size || 0
659
- const physicalCopies = Math.max(1, (storeStat ? 1 : 0) + names.filter((name) => name.mode === "copy").length)
660
- bytesOnDisk += size * physicalCopies
661
- wouldBe += size * Math.max(physicalCopies, names.length)
1783
+ const liveNames = storeStat ? Math.max(0, storeStat.nlink - 1) : 0
1784
+ if (liveNames >= 2) {
1785
+ for (const name of names) {
1786
+ if (name.unverified || (scopeId && name.source_id !== scopeId)) continue
1787
+ savedBySharing += size - (size / liveNames)
1788
+ }
1789
+ }
1790
+ const orphan = !!(storeStat && storeStat.nlink === 1)
662
1791
  const publicBlob = {
663
- hash, size: blob.size || 0, orphan: !!blob.orphan, nlink,
1792
+ hash, size: blob.size || 0, orphan, nlink,
1793
+ sharing_locations: liveNames,
664
1794
  names, apps: [...apps], source_urls: blob.source_urls || []
665
1795
  }
666
1796
  blobs.push(publicBlob)
@@ -668,147 +1798,557 @@ class Vault {
668
1798
  }
669
1799
  const duplicates = []
670
1800
  for (const [p, entry] of registry.duplicates) {
1801
+ if (scopeId && entry.source_id !== scopeId) continue
671
1802
  const location = this.locationForPath(p, entry.source_id)
672
1803
  const source = this.sourceForPath(p, location.source_id)
673
1804
  const index = registry.scanIndex.get(p)
674
1805
  const storeStat = storeStats.get(entry.hash)
675
- const shareable = !!(source && source.shareable && storeStat && (!index || index.dev === undefined || index.dev === storeStat.dev))
1806
+ const shareable = !!(source && source.shareable && storeStat && !entry.unverified &&
1807
+ !entry.unavailable_reason &&
1808
+ (!index || index.dev === undefined || index.dev === storeStat.dev))
1809
+ let unavailableReason = entry.unavailable_reason || null
1810
+ if (!shareable && !unavailableReason) {
1811
+ unavailableReason = source && storeStat && index && index.dev !== undefined && index.dev !== storeStat.dev
1812
+ ? "different_disk"
1813
+ : "unsupported_disk"
1814
+ }
676
1815
  const blob = blobByHash.get(entry.hash)
1816
+ const match = blob ? blob.names.find((name) => name.path !== p) || null : null
677
1817
  duplicates.push(Object.assign({
678
1818
  path: p, hash: entry.hash, size: entry.size || 0, app: entry.app || null,
679
- shareable, matches: blob ? blob.names : []
1819
+ shareable, unverified: !!entry.unverified,
1820
+ unavailable_reason: entry.unverified ? "unavailable" : unavailableReason, match
680
1821
  }, location))
681
1822
  }
682
- const events = (await registry.readEvents()).slice(-100).reverse()
1823
+ const events = (await registry.readEvents()).reverse()
1824
+ .filter((event) => !scopeId || event.source_id === scopeId)
1825
+ .map((event) => {
1826
+ const currentLocation = event.path ? this.locationForPath(event.path, event.source_id) : null
1827
+ const fallbackLocation = currentLocation && currentLocation.source_id
1828
+ ? currentLocation
1829
+ : (event.path ? { relative_path: event.path } : {})
1830
+ const publicEvent = Object.assign({}, fallbackLocation, event)
1831
+ if (event.kind === "convert" && event.batch_id && event.path) {
1832
+ const current = registry.links.get(event.path)
1833
+ publicEvent.undoable = !!(current && (
1834
+ current.batch_id === event.batch_id ||
1835
+ (!current.batch_id && current.hash === event.hash)
1836
+ ))
1837
+ const batch = undoBatchMap.get(event.batch_id)
1838
+ if (batch && Number.isFinite(event.ts)) {
1839
+ batch.ts = Math.max(batch.ts || 0, event.ts)
1840
+ }
1841
+ }
1842
+ return publicEvent
1843
+ })
1844
+ const undoBatches = [...undoBatchMap.values()].sort((a, b) =>
1845
+ (b.ts || 0) - (a.ts || 0) || a.batch_id.localeCompare(b.batch_id))
683
1846
  const scan = this.scanStatus()
684
1847
  const excluded = []
685
1848
  for (const [p, meta] of registry.excluded) {
686
- let size = Number(meta.size) || 0
687
- if (!size) {
688
- try { size = (await fs.promises.stat(p)).size } catch (error) {}
689
- }
690
- excluded.push(Object.assign({ path: p, ts: meta.ts || null, size }, this.locationForPath(p, meta.source_id)))
1849
+ if (scopeId && meta.source_id !== scopeId) continue
1850
+ excluded.push(Object.assign({
1851
+ path: p,
1852
+ ts: meta.ts || null,
1853
+ size: Number(meta.size) || 0,
1854
+ unverified: !!meta.unverified
1855
+ }, this.locationForPath(p, meta.source_id)))
691
1856
  }
692
- const publicSources = this._sources.map((source) => ({
1857
+ const publicSources = this._sources.filter((source) => !scopeId || source.id === scopeId).map((source) => ({
693
1858
  id: source.id, kind: source.kind, label: source.label, root: source.root,
694
- display_path: source.kind === "pinokio" ? source.root : (source.mount_path || source.root),
1859
+ display_path: source.root,
695
1860
  target_path: source.kind === "external" ? source.root : null,
696
- parent_id: source.parent_id, app: source.app || null,
1861
+ parent_id: scopeId ? null : source.parent_id, app: source.app || null,
697
1862
  available: source.available !== false, shareable: source.kind === "virtual" ? null : !!source.shareable
698
1863
  }))
699
- return {
1864
+ const lastComplete = registry.scanFor(scopeId)
1865
+ const lastAttempt = registry.scanAttemptFor(scopeId)
1866
+ const inScope = (entry) => !scopeId || entry.source_id === scopeId
1867
+ const hasUnavailableSource = !scopeId && this._sources.some((source) =>
1868
+ source.kind === "external" && source.available === false)
1869
+ const hasUnverified = hasUnavailableSource || [...registry.links.values()].some((entry) =>
1870
+ inScope(entry) && entry.unverified) ||
1871
+ [...registry.duplicates.values()].some((entry) =>
1872
+ inScope(entry) && entry.unverified) ||
1873
+ [...registry.excluded.values()].some((entry) =>
1874
+ inScope(entry) && entry.unverified)
1875
+ const incomplete = hasUnverified || !!(
1876
+ lastAttempt && lastAttempt.complete === false &&
1877
+ (!lastComplete || (lastAttempt.ts || 0) >= (lastComplete.ts || 0))
1878
+ )
1879
+ const metricsExact = !!lastComplete && !incomplete
1880
+ const beforeBytes = metricsExact && Number.isFinite(lastComplete.bytes_total)
1881
+ ? Math.max(0, lastComplete.bytes_total)
1882
+ : null
1883
+ const afterBytes = beforeBytes === null
1884
+ ? null
1885
+ : Math.max(0, beforeBytes - savedBySharing)
1886
+ const result = {
700
1887
  enabled: true,
701
1888
  mode: this.mode,
1889
+ candidate_min_bytes: this.sizeThreshold,
1890
+ candidate_size_options: CANDIDATE_SIZE_OPTIONS,
702
1891
  scan,
703
- last_scan: registry.lastScan,
704
- bytes_on_disk: bytesOnDisk,
705
- bytes_without_sharing: wouldBe,
706
- saved_by_sharing: wouldBe - bytesOnDisk,
707
- lifetime_bytes_saved: registry.totals.lifetime_bytes_saved,
1892
+ file_action: this.fileActionStatus(scopeId),
1893
+ file_actions: this.fileActionStatuses(scopeId),
1894
+ action_sequence: responseActionSequence,
1895
+ last_scan: lastComplete,
1896
+ last_attempt: lastAttempt,
1897
+ incomplete,
1898
+ metrics_exact: metricsExact,
1899
+ bytes_on_disk: afterBytes,
1900
+ bytes_without_sharing: beforeBytes,
1901
+ saved_by_sharing: savedBySharing,
708
1902
  reclaimable: blobs.filter((b) => b.orphan).reduce((sum, b) => sum + b.size, 0),
709
1903
  pending_bytes: duplicates.filter((d) => d.shareable).reduce((sum, d) => sum + d.size, 0),
710
- unavailable_bytes: duplicates.filter((d) => !d.shareable).reduce((sum, d) => sum + d.size, 0),
711
- sources: publicSources, blobs, duplicates, excluded, events
1904
+ activity_error: registry.eventError,
1905
+ cloud_sync_warning: this.cloudSyncProvider(),
1906
+ sources: publicSources, blobs, duplicates, excluded, events,
1907
+ undo_batches: undoBatches
712
1908
  }
1909
+ if (scopeId) {
1910
+ result.scope_id = scopeId
1911
+ result.reclaimable = 0
1912
+ }
1913
+ return result
713
1914
  }
714
1915
  scanStatus() {
715
1916
  const sweeper = this.sweeper
716
- return sweeper ? Object.assign({}, sweeper.state, {
717
- current_file: sweeper.currentHash ? path.basename(sweeper.currentHash.path) : null
718
- }) : null
1917
+ if (!sweeper) return null
1918
+ const pending = !!this.scanPromise && !sweeper.state.active
1919
+ const state = pending
1920
+ ? Object.assign(sweeper.idleState(), { phase: "queued", scope_id: this.scanScopeId })
1921
+ : sweeper.state
1922
+ return Object.assign({}, state, {
1923
+ current_file: sweeper.currentHash ? path.basename(sweeper.currentHash.path) : null,
1924
+ pending,
1925
+ error: this.scanError,
1926
+ persistence_warning: this.scanPersistenceWarning &&
1927
+ !!(this.registry && this.registry.persistDirty)
1928
+ })
719
1929
  }
720
- progressStatus() {
1930
+ progressStatus(scopeId = null) {
721
1931
  return {
722
1932
  enabled: !!this.enabled,
1933
+ candidate_min_bytes: this.sizeThreshold,
1934
+ candidate_size_options: CANDIDATE_SIZE_OPTIONS,
723
1935
  scan: this.scanStatus(),
724
- last_scan: this.registry ? this.registry.lastScan : null
1936
+ file_action: this.fileActionStatus(scopeId),
1937
+ file_actions: this.fileActionStatuses(scopeId),
1938
+ action_sequence: this.actionSequence,
1939
+ last_scan: this.registry ? this.registry.scanFor(scopeId) : null,
1940
+ last_attempt: this.registry ? this.registry.scanAttemptFor(scopeId) : null
1941
+ }
1942
+ }
1943
+ fileActionStatus(scopeId = null) {
1944
+ const action = this.fileAction
1945
+ if (!action || (scopeId && action.source_id !== scopeId)) return null
1946
+ return {
1947
+ id: action.id,
1948
+ kind: action.kind,
1949
+ phase: action.phase,
1950
+ path: action.path,
1951
+ source_id: action.source_id,
1952
+ bytes_copied: action.bytes_copied,
1953
+ bytes_total: action.bytes_total,
1954
+ files_completed: action.files_completed,
1955
+ files_total: action.files_total
1956
+ }
1957
+ }
1958
+ fileActionStatuses(scopeId = null) {
1959
+ return [...this.fileActions.values()]
1960
+ .filter((action) => !scopeId || action.source_id === scopeId)
1961
+ .map((action) => ({
1962
+ id: action.id,
1963
+ kind: action.kind,
1964
+ phase: action.phase,
1965
+ path: action.path,
1966
+ source_id: action.source_id,
1967
+ bytes_copied: action.bytes_copied,
1968
+ bytes_total: action.bytes_total,
1969
+ files_completed: action.files_completed,
1970
+ files_total: action.files_total
1971
+ }))
1972
+ }
1973
+ navigationStatus(scopeId = null) {
1974
+ if (!this.enabled || !this.initialized || !this.registry) {
1975
+ return {
1976
+ any_scan: false,
1977
+ scanned: false,
1978
+ scanning: false,
1979
+ pending_bytes: 0
1980
+ }
1981
+ }
1982
+ const scan = this.scanStatus()
1983
+ const active = !!(scan && (scan.pending || scan.active || scan.queued > 0))
1984
+ const sources = new Map(this._sources.map((source) => [source.id, source]))
1985
+ let pendingBytes = 0
1986
+ for (const entry of this.registry.duplicates.values()) {
1987
+ if (scopeId && entry.source_id !== scopeId) continue
1988
+ const source = sources.get(entry.source_id)
1989
+ if (!source || source.available === false || !source.shareable ||
1990
+ entry.unverified || entry.unavailable_reason ||
1991
+ !this.registry.blobs.has(entry.hash)) continue
1992
+ pendingBytes += Math.max(0, Number(entry.size) || 0)
1993
+ }
1994
+ const lastComplete = this.registry.scanFor(scopeId)
1995
+ const lastAttempt = this.registry.scanAttemptFor(scopeId)
1996
+ const hasUnavailableSource = !scopeId && this._sources.some((source) =>
1997
+ source.kind === "external" && source.available === false)
1998
+ const hasUnverified = hasUnavailableSource || [...this.registry.links.values()].some((entry) =>
1999
+ (!scopeId || entry.source_id === scopeId) && entry.unverified) ||
2000
+ [...this.registry.duplicates.values()].some((entry) =>
2001
+ (!scopeId || entry.source_id === scopeId) && entry.unverified) ||
2002
+ [...this.registry.excluded.values()].some((entry) =>
2003
+ (!scopeId || entry.source_id === scopeId) && entry.unverified)
2004
+ const incomplete = hasUnverified || !!(
2005
+ lastAttempt && lastAttempt.complete === false &&
2006
+ (!lastComplete || (lastAttempt.ts || 0) >= (lastComplete.ts || 0))
2007
+ )
2008
+ return {
2009
+ any_scan: !!this.registry.lastScanAttempt || this.registry.sourceScanAttempts.size > 0,
2010
+ scanned: !!lastAttempt,
2011
+ scanning: active && (!scopeId || scan.scope_id === scopeId),
2012
+ pending_bytes: pendingBytes,
2013
+ incomplete
2014
+ }
2015
+ }
2016
+ navigationStatusForApp(appName) {
2017
+ return this.navigationStatus(sourceId("app", appName))
2018
+ }
2019
+ async preflightCopyCapacity(targets) {
2020
+ if (!targets.length) return { ok: true }
2021
+ if (!fs.promises.statfs) return { ok: false, status: "capacity-unavailable" }
2022
+ const byDevice = new Map()
2023
+ try {
2024
+ for (const target of targets) {
2025
+ const st = await fs.promises.lstat(target.path)
2026
+ const key = String(st.dev)
2027
+ if (!byDevice.has(key)) {
2028
+ byDevice.set(key, { path: path.dirname(target.path), bytes: 0 })
2029
+ }
2030
+ byDevice.get(key).bytes += Math.max(0, Number(target.bytes) || 0)
2031
+ }
2032
+ for (const group of byDevice.values()) {
2033
+ const stat = await fs.promises.statfs(group.path)
2034
+ const blockSize = BigInt(stat.bsize)
2035
+ const availableBlocks = BigInt(stat.bavail)
2036
+ const available = blockSize * availableBlocks
2037
+ if (available < BigInt(Math.ceil(group.bytes))) {
2038
+ return { ok: false, status: "insufficient-space" }
2039
+ }
2040
+ }
2041
+ return { ok: true }
2042
+ } catch (error) {
2043
+ return { ok: false, status: "capacity-unavailable" }
2044
+ }
2045
+ }
2046
+ async copyOut(filePath, sourcePath, expectedTarget, expectedSource = expectedTarget, options = {}) {
2047
+ const tmp = filePath + TMP_SUFFIX
2048
+ let copiedStat = null
2049
+ try {
2050
+ if (options.onProgress) {
2051
+ let sourceHandle = null
2052
+ let targetHandle = null
2053
+ try {
2054
+ sourceHandle = await fs.promises.open(sourcePath, "r")
2055
+ const sourceStat = await sourceHandle.stat()
2056
+ targetHandle = await fs.promises.open(tmp,
2057
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
2058
+ sourceStat.mode & 0o7777)
2059
+ copiedStat = await targetHandle.stat()
2060
+ const buffer = Buffer.allocUnsafe(COPY_PROGRESS_CHUNK_SIZE)
2061
+ let offset = 0
2062
+ options.onProgress(0, sourceStat.size)
2063
+ while (offset < sourceStat.size) {
2064
+ const length = Math.min(buffer.length, sourceStat.size - offset)
2065
+ const { bytesRead } = await sourceHandle.read(buffer, 0, length, offset)
2066
+ if (!bytesRead) {
2067
+ const error = new Error("Source file ended while copying.")
2068
+ error.code = "EIO"
2069
+ throw error
2070
+ }
2071
+ let written = 0
2072
+ while (written < bytesRead) {
2073
+ const result = await targetHandle.write(
2074
+ buffer, written, bytesRead - written, offset + written)
2075
+ if (!result.bytesWritten) {
2076
+ const error = new Error("Could not write the independent copy.")
2077
+ error.code = "EIO"
2078
+ throw error
2079
+ }
2080
+ written += result.bytesWritten
2081
+ }
2082
+ offset += bytesRead
2083
+ options.onProgress(offset, sourceStat.size)
2084
+ }
2085
+ await targetHandle.chmod(sourceStat.mode & 0o7777)
2086
+ } finally {
2087
+ await Promise.all([
2088
+ sourceHandle && sourceHandle.close(),
2089
+ targetHandle && targetHandle.close()
2090
+ ].filter(Boolean))
2091
+ }
2092
+ } else {
2093
+ await fs.promises.copyFile(sourcePath, tmp, fs.constants.COPYFILE_EXCL)
2094
+ }
2095
+ copiedStat = await fs.promises.lstat(tmp)
2096
+ if (options.canReplace && !options.canReplace()) {
2097
+ await unlinkIfSame(tmp, copiedStat)
2098
+ return { status: "locked" }
2099
+ }
2100
+ const currentTarget = await lstatIfPresent(filePath)
2101
+ const currentSource = sourcePath === filePath ? currentTarget : await lstatIfPresent(sourcePath)
2102
+ const currentTmp = await lstatIfPresent(tmp)
2103
+ if (!currentTarget || !currentSource || !sameSnapshot(expectedTarget, currentTarget) ||
2104
+ !sameSnapshot(expectedSource, currentSource) || !sameIdentity(currentTmp, copiedStat)) {
2105
+ await unlinkIfSame(tmp, copiedStat)
2106
+ return { status: "stale" }
2107
+ }
2108
+ await fs.promises.rename(tmp, filePath)
2109
+ let finalStat
2110
+ try {
2111
+ finalStat = await lstatIfPresent(filePath)
2112
+ } catch (error) {
2113
+ // rename() committed the independent copy, but its pre-rename ctime is
2114
+ // not final metadata. Keep only stable identity fields and require a
2115
+ // later scan to verify the name.
2116
+ const committedStat = copiedStat
2117
+ copiedStat = null
2118
+ return { status: "copied", stat: committedStat, unverified: true }
2119
+ }
2120
+ if (!finalStat || finalStat.dev !== copiedStat.dev || finalStat.ino !== copiedStat.ino) {
2121
+ return { status: "stale" }
2122
+ }
2123
+ copiedStat = null
2124
+ return { status: "copied", stat: finalStat }
2125
+ } catch (error) {
2126
+ await unlinkIfSame(tmp, copiedStat).catch(() => {})
2127
+ if (error && error.code === "EEXIST") return { status: "conflict" }
2128
+ if (isMissingError(error)) return { status: "stale" }
2129
+ throw error
725
2130
  }
726
2131
  }
727
- // detach: make one name an independent copy again ("go back"), and pin it
728
- // so future scans neither re-list nor re-share it. Works on linked names
729
- // (copies bytes out) and on pending duplicates (just ignores them).
730
- async detach(filePath) {
2132
+ // detach: undo sharing for one linked name and restore it as a pending
2133
+ // duplicate.
2134
+ async detach(filePath, queuedAction = null) {
731
2135
  if (!this.enabled) return { status: "disabled" }
2136
+ await this.refreshSources()
732
2137
  const entry = this.registry.links.get(filePath)
733
2138
  if (entry) {
734
- const tmp = filePath + TMP_SUFFIX
735
- await fs.promises.copyFile(filePath, tmp)
736
- await fs.promises.rename(tmp, filePath)
737
- const st = await fs.promises.stat(filePath)
738
- this.registry.removeLink(filePath)
739
- this.registry.excluded.set(filePath, { ts: Date.now(), source_id: entry.source_id || null, size: st.size })
740
- this.registry.schedulePersist()
741
- await this.registry.appendEvent({ kind: "detach", hash: entry.hash, path: filePath, app: entry.app || null, source_id: entry.source_id || null, size: st.size })
742
- return { status: "detached" }
743
- }
744
- if (this.registry.duplicates.has(filePath)) {
745
- const dup = this.registry.duplicates.get(filePath)
746
- this.registry.duplicates.delete(filePath)
747
- this.registry.excluded.set(filePath, { ts: Date.now(), source_id: dup.source_id || null, size: dup.size || 0 })
748
- this.registry.schedulePersist()
749
- await this.registry.appendEvent({ kind: "detach", hash: dup.hash, path: filePath, app: dup.app || null, source_id: dup.source_id || null, size: dup.size || 0 })
750
- return { status: "ignored" }
2139
+ if (entry.unverified) return { status: "unavailable" }
2140
+ const source = this.sourceForPath(filePath, entry.source_id)
2141
+ if (!source || !await this.canonicalPathIsWithinSource(filePath, source)) {
2142
+ return { status: "stale" }
2143
+ }
2144
+ if (this.sourceAppIsRunning(source)) return { status: "locked" }
2145
+ const before = await lstatIfPresent(filePath)
2146
+ if (!before || !before.isFile() || before.dev !== entry.dev || before.ino !== entry.ino) return { status: "stale" }
2147
+ const capacity = await this.preflightCopyCapacity([{ path: filePath, bytes: before.size }])
2148
+ if (!capacity.ok) return { status: capacity.status }
2149
+ const action = queuedAction || {
2150
+ kind: "detach", path: filePath, source_id: entry.source_id || null,
2151
+ bytes_copied: 0, bytes_total: before.size
2152
+ }
2153
+ action.bytes_total = before.size
2154
+ const ownsAction = !queuedAction
2155
+ if (ownsAction) this.fileAction = action
2156
+ try {
2157
+ const copied = await this.copyOut(filePath, filePath, fileSnapshot(before), fileSnapshot(before), {
2158
+ canReplace: () => !this.sourceAppIsRunning(source),
2159
+ onProgress: (bytesCopied, bytesTotal) => {
2160
+ action.bytes_copied = bytesCopied
2161
+ action.bytes_total = bytesTotal
2162
+ }
2163
+ })
2164
+ if (copied.status !== "copied") return copied
2165
+ const st = copied.stat
2166
+ const baseEntry = {
2167
+ hash: entry.hash, size: st.size, source_id: entry.source_id || null,
2168
+ dev: st.dev, ino: st.ino
2169
+ }
2170
+ const scanEntry = copied.unverified ? null : Object.assign({}, baseEntry, {
2171
+ mtime: st.mtimeMs, ctime: st.ctimeMs
2172
+ })
2173
+ this.registry.setDuplicate(filePath, Object.assign({
2174
+ app: entry.app || null, discovered: Date.now(),
2175
+ unverified: !!copied.unverified
2176
+ }, scanEntry || baseEntry), scanEntry)
2177
+ if (copied.unverified) this.registry.scanIndex.delete(filePath)
2178
+ await this.refreshLinkSnapshots(entry.hash, entry.dev, entry.ino)
2179
+ await this.recordEvent({
2180
+ kind: "detach", hash: entry.hash, path: filePath,
2181
+ app: entry.app || null, source_id: entry.source_id || null,
2182
+ size: st.size, bytes_saved: st.size
2183
+ })
2184
+ return { status: "detached" }
2185
+ } finally {
2186
+ if (ownsAction && this.fileAction === action) this.fileAction = null
2187
+ }
751
2188
  }
752
2189
  return { status: "not-found" }
753
2190
  }
754
- // reshare: un-pin a detached path; the next scan may list it again.
2191
+ async skip(filePath) {
2192
+ if (!this.enabled) return { status: "disabled" }
2193
+ const duplicate = this.registry.duplicates.get(filePath)
2194
+ if (!duplicate) return { status: "not-found" }
2195
+ if (duplicate.unverified) return { status: "unavailable" }
2196
+ this.registry.exclude(filePath, {
2197
+ ts: Date.now(),
2198
+ source_id: duplicate.source_id || null,
2199
+ size: duplicate.size || 0
2200
+ })
2201
+ await this.recordEvent({
2202
+ kind: "skip",
2203
+ hash: duplicate.hash,
2204
+ path: filePath,
2205
+ app: duplicate.app || null,
2206
+ source_id: duplicate.source_id || null,
2207
+ size: duplicate.size || 0
2208
+ })
2209
+ return { status: "ignored" }
2210
+ }
2211
+ // reshare: un-pin a skipped path; the next scan may list it again.
755
2212
  async reshare(filePath) {
756
2213
  if (!this.enabled) return { status: "disabled" }
757
- const excluded = this.registry.excluded.get(filePath)
758
- if (this.registry.excluded.delete(filePath)) {
759
- this.registry.schedulePersist()
760
- await this.registry.appendEvent({ kind: "reshare", path: filePath, source_id: excluded && excluded.source_id ? excluded.source_id : null })
2214
+ const current = this.registry.excluded.get(filePath)
2215
+ if (current && current.unverified) return { status: "unavailable" }
2216
+ const excluded = this.registry.allowSharing(filePath)
2217
+ if (excluded) {
2218
+ await this.recordEvent({ kind: "reshare", path: filePath, source_id: excluded && excluded.source_id ? excluded.source_id : null })
761
2219
  return { status: "resharable" }
762
2220
  }
763
2221
  return { status: "not-found" }
764
2222
  }
765
2223
  // Undo a conversion batch: replace each converted name with an independent
766
2224
  // copy of the bytes (spec, UX contract surface 3).
767
- async undoBatch(batchId) {
2225
+ async undoBatch(batchId, queuedAction = null) {
768
2226
  if (!this.enabled || !batchId) return { undone: 0 }
2227
+ await this.refreshSources()
769
2228
  const events = await this.registry.readEvents()
770
- const summary = { undone: 0, bytes: 0 }
771
- for (const event of events) {
772
- if (event.kind !== "convert" || event.batch_id !== batchId || !event.path) continue
773
- const entry = this.registry.links.get(event.path)
774
- if (!entry || entry.hash !== event.hash) continue
775
- const storePath = this.storePathFor(event.hash)
2229
+ const conversionEvents = new Map(events
2230
+ .filter((event) => event.kind === "convert" && event.batch_id === batchId && event.path)
2231
+ .map((event) => [event.path, event]))
2232
+ const targets = new Set(conversionEvents.keys())
2233
+ for (const [filePath, entry] of this.registry.links) {
2234
+ if (entry.batch_id === batchId) targets.add(filePath)
2235
+ }
2236
+ const summary = { undone: 0, bytes: 0, failed: 0 }
2237
+ const prepared = []
2238
+ for (const filePath of targets) {
2239
+ const event = conversionEvents.get(filePath) || {}
2240
+ const entry = this.registry.links.get(filePath)
2241
+ if (!entry || (entry.batch_id && entry.batch_id !== batchId) ||
2242
+ (event.hash && entry.hash !== event.hash)) continue
2243
+ if (entry.unverified) {
2244
+ summary.failed += 1
2245
+ continue
2246
+ }
2247
+ const storePath = this.storePathFor(entry.hash)
776
2248
  try {
777
- const st = await fs.promises.stat(event.path)
778
- const storeStat = await fs.promises.stat(storePath)
779
- if (st.ino !== storeStat.ino || st.dev !== storeStat.dev) continue
780
- const tmp = event.path + TMP_SUFFIX
781
- await fs.promises.copyFile(storePath, tmp)
782
- await fs.promises.rename(tmp, event.path)
783
- this.registry.removeLink(event.path)
784
- this.registry.duplicates.set(event.path, {
785
- hash: event.hash, size: storeStat.size, app: entry.app || null,
786
- source_id: entry.source_id || event.source_id || null, discovered: Date.now()
2249
+ const source = this.sourceForPath(filePath, entry.source_id || event.source_id)
2250
+ if (!source || !await this.canonicalPathIsWithinSource(filePath, source)) {
2251
+ summary.failed += 1
2252
+ continue
2253
+ }
2254
+ if (this.sourceAppIsRunning(source)) {
2255
+ summary.failed += 1
2256
+ continue
2257
+ }
2258
+ const st = await fs.promises.lstat(filePath)
2259
+ const storeStat = await this.storeStatIfPresent(storePath)
2260
+ if (!st.isFile() || !storeStat || !storeStat.isFile()) {
2261
+ summary.failed += 1
2262
+ continue
2263
+ }
2264
+ if (st.ino !== storeStat.ino || st.dev !== storeStat.dev ||
2265
+ st.dev !== entry.dev || st.ino !== entry.ino) {
2266
+ summary.failed += 1
2267
+ continue
2268
+ }
2269
+ prepared.push({ filePath, event, entry, source, storePath, st, storeStat })
2270
+ } catch (e) {
2271
+ summary.failed += 1
2272
+ }
2273
+ }
2274
+ const capacity = await this.preflightCopyCapacity(prepared.map((target) => ({
2275
+ path: target.filePath,
2276
+ bytes: target.storeStat.size
2277
+ })))
2278
+ if (!capacity.ok) {
2279
+ return Object.assign(summary, { status: capacity.status })
2280
+ }
2281
+ const action = queuedAction || {
2282
+ kind: "undo", path: null, source_id: null,
2283
+ bytes_copied: 0, bytes_total: 0
2284
+ }
2285
+ action.bytes_total = prepared.reduce((sum, target) => sum + target.storeStat.size, 0)
2286
+ let completedBytes = 0
2287
+ for (const target of prepared) {
2288
+ const { filePath, event, entry, source, storePath, st, storeStat } = target
2289
+ try {
2290
+ action.path = filePath
2291
+ action.source_id = entry.source_id || event.source_id || null
2292
+ const copied = await this.copyOut(filePath, storePath, fileSnapshot(st), fileSnapshot(storeStat), {
2293
+ canReplace: () => !this.sourceAppIsRunning(source),
2294
+ onProgress: (bytesCopied) => {
2295
+ action.bytes_copied = completedBytes + bytesCopied
2296
+ }
787
2297
  })
788
- this.registry.totals.lifetime_bytes_saved = Math.max(0,
789
- this.registry.totals.lifetime_bytes_saved - (event.bytes_saved || 0))
790
- await this.registry.appendEvent({
791
- kind: "undo", hash: event.hash, path: event.path,
2298
+ if (copied.status !== "copied") {
2299
+ summary.failed += 1
2300
+ continue
2301
+ }
2302
+ const independentStat = copied.stat
2303
+ const duplicateEntry = {
2304
+ hash: entry.hash, size: storeStat.size, app: entry.app || null,
2305
+ source_id: entry.source_id || event.source_id || null, discovered: Date.now(),
2306
+ dev: independentStat.dev, ino: independentStat.ino,
2307
+ unverified: !!copied.unverified
2308
+ }
2309
+ const scanEntry = copied.unverified ? null : {
2310
+ hash: entry.hash, size: independentStat.size,
2311
+ dev: independentStat.dev, ino: independentStat.ino,
2312
+ mtime: independentStat.mtimeMs, ctime: independentStat.ctimeMs,
2313
+ source_id: entry.source_id || event.source_id || null
2314
+ }
2315
+ this.registry.setDuplicate(filePath, duplicateEntry, scanEntry)
2316
+ if (copied.unverified) this.registry.scanIndex.delete(filePath)
2317
+ await this.refreshLinkSnapshots(entry.hash, entry.dev, entry.ino)
2318
+ const bytesSaved = event.bytes_saved || storeStat.size
2319
+ await this.recordEvent({
2320
+ kind: "undo", hash: entry.hash, path: filePath,
792
2321
  source_id: entry.source_id || event.source_id || null, batch_id: batchId,
793
- bytes_saved: event.bytes_saved || storeStat.size
2322
+ bytes_saved: bytesSaved
794
2323
  })
795
2324
  summary.undone += 1
796
2325
  summary.bytes += storeStat.size
797
- } catch (e) {}
2326
+ completedBytes += storeStat.size
2327
+ action.bytes_copied = completedBytes
2328
+ } catch (e) {
2329
+ summary.failed += 1
2330
+ }
798
2331
  }
799
2332
  this.registry.schedulePersist()
800
2333
  return summary
801
2334
  }
802
2335
  async reclaimAll() {
803
2336
  if (!this.enabled) return { reclaimed: 0, bytes_freed: 0 }
804
- const summary = { reclaimed: 0, bytes_freed: 0 }
805
- for (const [hash, blob] of [...this.registry.blobs]) {
806
- if (!blob.orphan) continue
807
- const result = await this.reclaim(hash)
808
- if (result.status === "reclaimed") {
809
- summary.reclaimed += 1
810
- summary.bytes_freed += result.bytes_freed || 0
2337
+ const summary = { reclaimed: 0, bytes_freed: 0, failed: 0 }
2338
+ const removedHashes = new Set()
2339
+ try {
2340
+ for (const [hash] of [...this.registry.blobs]) {
2341
+ const result = await this.reclaim(hash, {
2342
+ deferRegistry: true
2343
+ })
2344
+ if (result.remove_hash) removedHashes.add(hash)
2345
+ if (result.status === "reclaimed") {
2346
+ summary.reclaimed += 1
2347
+ summary.bytes_freed += result.bytes_freed || 0
2348
+ } else if (!["gone", "in-use", "unavailable"].includes(result.status)) summary.failed += 1
811
2349
  }
2350
+ } finally {
2351
+ this.registry.removeBlobs(removedHashes)
812
2352
  }
813
2353
  return summary
814
2354
  }