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.
- package/kernel/api/hf/index.js +2 -2
- package/kernel/api/index.js +12 -3
- package/kernel/connect/index.js +2 -2
- package/kernel/connect/providers/huggingface/index.js +14 -5
- package/kernel/index.js +14 -2
- package/kernel/shell.js +3 -2
- package/kernel/shells.js +6 -0
- package/kernel/vault/constants.js +13 -0
- package/kernel/vault/hash_worker.js +9 -2
- package/kernel/vault/index.js +1856 -316
- package/kernel/vault/registry.js +526 -52
- package/kernel/vault/snapshot.js +29 -0
- package/kernel/vault/sweeper.js +480 -210
- package/kernel/vault/walker.js +142 -0
- package/package.json +1 -1
- package/server/index.js +79 -90
- package/server/lib/privacy_filter_cache.js +4 -1
- package/server/public/storage-size.js +12 -0
- package/server/public/style.css +13 -0
- package/server/public/tab-link-popover.js +3 -0
- package/server/public/vault-nav.js +96 -0
- package/server/public/vault.css +1079 -0
- package/server/public/vault.js +1835 -0
- package/server/views/app.ejs +93 -16
- package/server/views/connect/huggingface.ejs +7 -9
- package/server/views/partials/main_sidebar.ejs +6 -1
- package/server/views/partials/vault_workspace.ejs +55 -0
- package/server/views/vault.ejs +5 -1532
- package/server/views/vault_app.ejs +22 -0
- package/test/hf-api.test.js +4 -2
- package/test/huggingface-connect.test.js +7 -11
- package/test/huggingface-token-validity.test.js +135 -0
- package/test/plugin-action-functions.test.js +19 -0
- package/test/privacy-filter-cache.test.js +1 -0
- package/test/vault-engine.test.js +1276 -26
- package/test/vault-sweep.test.js +1043 -35
- package/test/vault-ui.test.js +2184 -65
package/kernel/vault/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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' | '
|
|
57
|
-
this.volumeModes = new Map() // dev -> 'link' | '
|
|
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 {
|
|
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.
|
|
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 (
|
|
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 {
|
|
104
|
-
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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.
|
|
182
|
-
mountPath = candidate
|
|
183
|
-
break
|
|
589
|
+
fileStat = await fs.promises.lstat(filePath)
|
|
184
590
|
} catch (error) {
|
|
185
|
-
if (error
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
234
|
-
|
|
235
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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 {
|
|
785
|
+
return {
|
|
786
|
+
enabled: true,
|
|
787
|
+
mode: this.mode,
|
|
788
|
+
corrupt_recovered: !!loaded.corrupt,
|
|
789
|
+
missing_recovered: missingWithBlobs
|
|
790
|
+
}
|
|
270
791
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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 = "
|
|
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
|
-
|
|
287
|
-
if (
|
|
288
|
-
} catch (
|
|
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
|
-
|
|
291
|
-
|
|
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)
|
|
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.
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1058
|
+
const storeStat = await this.storeStatIfPresent(storePath)
|
|
1059
|
+
if (!storeStat) return { status: "no-blob" }
|
|
1060
|
+
let targetStat
|
|
399
1061
|
try {
|
|
400
|
-
|
|
401
|
-
} catch (
|
|
402
|
-
return { status: "
|
|
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, {
|
|
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
|
-
|
|
419
|
-
await fs.promises.
|
|
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
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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.
|
|
459
|
-
return
|
|
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
|
-
|
|
468
|
-
|
|
1358
|
+
if (!includePath(linkPath)) continue
|
|
1359
|
+
if (preservePath(linkPath)) continue
|
|
469
1360
|
try {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
486
|
-
try {
|
|
487
|
-
st = await fs.promises.stat(storePath)
|
|
488
|
-
} catch (e) {}
|
|
1430
|
+
const st = await this.storeStatIfPresent(storePath)
|
|
489
1431
|
if (!st) {
|
|
490
|
-
|
|
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
|
-
|
|
493
|
-
|
|
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
|
|
1462
|
+
await this.storeStatIfPresent(storePath, { createParent: true })
|
|
496
1463
|
await fs.promises.link(linkPath, storePath)
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
530
|
-
|
|
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(
|
|
534
|
-
} catch (
|
|
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 (
|
|
1553
|
+
if (!SHA256_RE.test(name) || !name.startsWith(shard)) continue
|
|
537
1554
|
try {
|
|
538
|
-
const st = await fs.promises.
|
|
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 (
|
|
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
|
-
:
|
|
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) ||
|
|
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
|
|
569
|
-
if (
|
|
570
|
-
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
registry.
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
const files =
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
627
|
-
const
|
|
628
|
-
|
|
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:
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
652
|
-
|
|
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
|
|
660
|
-
|
|
661
|
-
|
|
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
|
|
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 &&
|
|
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,
|
|
1819
|
+
shareable, unverified: !!entry.unverified,
|
|
1820
|
+
unavailable_reason: entry.unverified ? "unavailable" : unavailableReason, match
|
|
680
1821
|
}, location))
|
|
681
1822
|
}
|
|
682
|
-
const events = (await registry.readEvents()).
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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
|
-
|
|
711
|
-
|
|
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
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
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:
|
|
728
|
-
//
|
|
729
|
-
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
await
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
this.
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
-
|
|
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
|
|
758
|
-
if (
|
|
759
|
-
|
|
760
|
-
|
|
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
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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:
|
|
2322
|
+
bytes_saved: bytesSaved
|
|
794
2323
|
})
|
|
795
2324
|
summary.undone += 1
|
|
796
2325
|
summary.bytes += storeStat.size
|
|
797
|
-
|
|
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
|
-
|
|
806
|
-
|
|
807
|
-
const
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
}
|