pinokiod 8.0.39 → 8.0.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/kernel/api/hf/index.js +2 -2
  2. package/kernel/api/index.js +12 -3
  3. package/kernel/connect/index.js +2 -2
  4. package/kernel/connect/providers/huggingface/index.js +14 -5
  5. package/kernel/index.js +14 -2
  6. package/kernel/shell.js +3 -2
  7. package/kernel/shells.js +6 -0
  8. package/kernel/vault/constants.js +13 -0
  9. package/kernel/vault/hash_worker.js +9 -2
  10. package/kernel/vault/index.js +1856 -316
  11. package/kernel/vault/registry.js +526 -52
  12. package/kernel/vault/snapshot.js +29 -0
  13. package/kernel/vault/sweeper.js +480 -210
  14. package/kernel/vault/walker.js +142 -0
  15. package/package.json +1 -1
  16. package/server/index.js +79 -90
  17. package/server/lib/privacy_filter_cache.js +4 -1
  18. package/server/public/storage-size.js +12 -0
  19. package/server/public/style.css +13 -0
  20. package/server/public/tab-link-popover.js +3 -0
  21. package/server/public/vault-nav.js +96 -0
  22. package/server/public/vault.css +1079 -0
  23. package/server/public/vault.js +1835 -0
  24. package/server/views/app.ejs +93 -16
  25. package/server/views/connect/huggingface.ejs +7 -9
  26. package/server/views/partials/main_sidebar.ejs +6 -1
  27. package/server/views/partials/vault_workspace.ejs +55 -0
  28. package/server/views/vault.ejs +5 -1532
  29. package/server/views/vault_app.ejs +22 -0
  30. package/test/hf-api.test.js +4 -2
  31. package/test/huggingface-connect.test.js +7 -11
  32. package/test/huggingface-token-validity.test.js +135 -0
  33. package/test/plugin-action-functions.test.js +19 -0
  34. package/test/privacy-filter-cache.test.js +1 -0
  35. package/test/vault-engine.test.js +1276 -26
  36. package/test/vault-sweep.test.js +1043 -35
  37. package/test/vault-ui.test.js +2184 -65
@@ -1,5 +1,23 @@
1
1
  const fs = require('fs')
2
2
  const path = require('path')
3
+ const crypto = require('crypto')
4
+ const { SHA256_RE, CANDIDATE_SIZE_OPTIONS } = require('./constants')
5
+
6
+ const isMissingError = (error) => !!(error && (error.code === "ENOENT" || error.code === "ENOTDIR"))
7
+ const isRecord = (value) => !!(value && typeof value === "object" && !Array.isArray(value))
8
+ const unsafeRegistryPath = (filePath) => {
9
+ const error = new Error(`Storage index path is not safe: ${filePath}`)
10
+ error.code = "EVAULTPATH"
11
+ return error
12
+ }
13
+ const lstatIfPresent = async (filePath) => {
14
+ try {
15
+ return await fs.promises.lstat(filePath)
16
+ } catch (error) {
17
+ if (isMissingError(error)) return null
18
+ throw error
19
+ }
20
+ }
3
21
 
4
22
  // Persisted record of blobs and their known names.
5
23
  // The registry is a cache of the filesystem, never the source of truth
@@ -12,20 +30,164 @@ class Registry {
12
30
  this.reset()
13
31
  this.persistTimer = null
14
32
  this.persistDelay = 500
33
+ this.persistFailures = 0
15
34
  this.flushPromise = Promise.resolve()
35
+ this.eventPromise = Promise.resolve()
36
+ this.persistDepth = 0
37
+ this.persistDirty = false
38
+ this.maxEvents = 2000
39
+ this.compactEvery = 500
40
+ this.maxEventBytesPerEntry = 4096
41
+ this.eventsSinceCompact = 0
16
42
  }
17
43
  reset() {
18
44
  this.blobs = new Map() // hash -> { size, first_seen, source_urls, verified_at, orphan }
19
- this.links = new Map() // path -> { hash, app, source_id, dev, ino, created, mode }
45
+ this.links = new Map() // path -> { hash, app, source_id, dev, ino, created, batch_id? }
20
46
  this.scanIndex = new Map() // path -> { size, mtime, dev, ino, hash, source_id }
21
47
  this.duplicates = new Map() // path -> { hash, size, app, source_id, discovered } pending user conversion
22
48
  this.excluded = new Map() // path -> { ts, source_id, size } user chose "keep independent"
23
- this.lastScan = null // scan totals, duration, and hash work
24
- this.totals = { lifetime_bytes_saved: 0 }
49
+ this.lastScan = null // last complete global scan
50
+ this.lastScanAttempt = null // last global scan attempt, complete or partial
51
+ this.sourceScans = new Map() // source id -> last complete scoped scan
52
+ this.sourceScanAttempts = new Map() // source id -> last scoped scan attempt
53
+ this.settings = { external_sources: [] } // durable user preferences, not derived scan state
25
54
  this.byIno = new Map() // "dev:ino" -> hash
55
+ this.pathsByIno = new Map() // "dev:ino" -> Set(paths), for bounded group updates
56
+ this.eventsCache = null
57
+ this.eventError = null
58
+ }
59
+ async rootDirectory(create = false) {
60
+ let st = await lstatIfPresent(this.root)
61
+ if (!st && create) {
62
+ try {
63
+ await fs.promises.mkdir(this.root)
64
+ } catch (error) {
65
+ if (!error || error.code !== "EEXIST") throw error
66
+ }
67
+ st = await lstatIfPresent(this.root)
68
+ }
69
+ if (st && !st.isDirectory()) throw unsafeRegistryPath(this.root)
70
+ return st
71
+ }
72
+ async readFile(filePath) {
73
+ const before = await fs.promises.lstat(filePath)
74
+ if (!before.isFile()) throw unsafeRegistryPath(filePath)
75
+ const handle = await fs.promises.open(
76
+ filePath,
77
+ fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)
78
+ )
79
+ try {
80
+ const opened = await handle.stat()
81
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) {
82
+ throw unsafeRegistryPath(filePath)
83
+ }
84
+ return await handle.readFile("utf8")
85
+ } finally {
86
+ await handle.close()
87
+ }
88
+ }
89
+ async readEventTail() {
90
+ const before = await fs.promises.lstat(this.eventsPath)
91
+ if (!before.isFile()) throw unsafeRegistryPath(this.eventsPath)
92
+ const handle = await fs.promises.open(
93
+ this.eventsPath,
94
+ fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)
95
+ )
96
+ try {
97
+ const opened = await handle.stat()
98
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) {
99
+ throw unsafeRegistryPath(this.eventsPath)
100
+ }
101
+ const limit = Math.max(64 * 1024, this.maxEvents * this.maxEventBytesPerEntry)
102
+ const length = Math.min(opened.size, limit)
103
+ if (!length) return ""
104
+ const start = opened.size - length
105
+ const buffer = Buffer.allocUnsafe(length)
106
+ const { bytesRead } = await handle.read(buffer, 0, length, start)
107
+ let raw = buffer.subarray(0, bytesRead).toString("utf8")
108
+ if (start > 0) {
109
+ const newline = raw.indexOf("\n")
110
+ raw = newline === -1 ? "" : raw.slice(newline + 1)
111
+ }
112
+ return raw
113
+ } finally {
114
+ await handle.close()
115
+ }
116
+ }
117
+ async atomicWrite(filePath, contents) {
118
+ await this.rootDirectory(true)
119
+ const tmp = path.resolve(this.root,
120
+ `.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`)
121
+ let tmpStat = null
122
+ try {
123
+ await fs.promises.writeFile(tmp, contents, { flag: "wx" })
124
+ tmpStat = await fs.promises.lstat(tmp)
125
+ const current = await lstatIfPresent(tmp)
126
+ if (!current || current.dev !== tmpStat.dev || current.ino !== tmpStat.ino) {
127
+ throw unsafeRegistryPath(tmp)
128
+ }
129
+ await fs.promises.rename(tmp, filePath)
130
+ tmpStat = null
131
+ } finally {
132
+ if (tmpStat) {
133
+ const current = await lstatIfPresent(tmp).catch(() => null)
134
+ if (current && current.dev === tmpStat.dev && current.ino === tmpStat.ino) {
135
+ await fs.promises.unlink(tmp).catch(() => {})
136
+ }
137
+ }
138
+ }
26
139
  }
27
- setLastScan(meta) {
28
- this.lastScan = meta
140
+ async appendFile(filePath, contents) {
141
+ await this.rootDirectory(true)
142
+ let before
143
+ let handle
144
+ for (let attempt = 0; attempt < 2; attempt++) {
145
+ before = await lstatIfPresent(filePath)
146
+ if (before && !before.isFile()) throw unsafeRegistryPath(filePath)
147
+ const flags = fs.constants.O_WRONLY | fs.constants.O_APPEND |
148
+ (fs.constants.O_NOFOLLOW || 0) |
149
+ (before ? 0 : fs.constants.O_CREAT | fs.constants.O_EXCL)
150
+ try {
151
+ handle = await fs.promises.open(filePath, flags, 0o600)
152
+ break
153
+ } catch (error) {
154
+ if (!before && error && error.code === "EEXIST" && attempt === 0) continue
155
+ throw error
156
+ }
157
+ }
158
+ if (!handle) throw unsafeRegistryPath(filePath)
159
+ try {
160
+ const opened = await handle.stat()
161
+ if (!opened.isFile() || (before &&
162
+ (opened.dev !== before.dev || opened.ino !== before.ino))) {
163
+ throw unsafeRegistryPath(filePath)
164
+ }
165
+ await handle.writeFile(contents)
166
+ } finally {
167
+ await handle.close()
168
+ }
169
+ }
170
+ scanFor(sourceId = null) {
171
+ return sourceId ? (this.sourceScans.get(sourceId) || null) : this.lastScan
172
+ }
173
+ scanAttemptFor(sourceId = null) {
174
+ return sourceId
175
+ ? (this.sourceScanAttempts.get(sourceId) || null)
176
+ : this.lastScanAttempt
177
+ }
178
+ setLastScan(meta, sourceId = null) {
179
+ if (sourceId) {
180
+ this.sourceScans.set(sourceId, meta)
181
+ this.sourceScanAttempts.set(sourceId, meta)
182
+ } else {
183
+ this.lastScan = meta
184
+ this.lastScanAttempt = meta
185
+ }
186
+ this.schedulePersist()
187
+ }
188
+ setScanAttempt(meta, sourceId = null) {
189
+ if (sourceId) this.sourceScanAttempts.set(sourceId, meta)
190
+ else this.lastScanAttempt = meta
29
191
  this.schedulePersist()
30
192
  }
31
193
  inoKey(dev, ino) {
@@ -48,35 +210,139 @@ class Registry {
48
210
  this.schedulePersist()
49
211
  }
50
212
  addLink(filePath, entry) {
51
- this.links.set(filePath, Object.assign({ created: Date.now(), mode: "link" }, entry))
213
+ const previous = this.links.get(filePath)
214
+ if (previous && previous.dev !== undefined && previous.ino !== undefined) {
215
+ this.removeIno(previous.dev, previous.ino, filePath)
216
+ }
217
+ // A path has exactly one derived classification. Registering a physical
218
+ // name always clears an older pending-duplicate classification first.
219
+ this.duplicates.delete(filePath)
220
+ const next = Object.assign({ created: Date.now() }, entry)
221
+ delete next.mode
222
+ const sameTrackedFile = previous && previous.hash === next.hash &&
223
+ previous.dev === next.dev && previous.ino === next.ino
224
+ if (next.batch_id === undefined && sameTrackedFile && previous.batch_id) {
225
+ next.batch_id = previous.batch_id
226
+ }
227
+ this.links.set(filePath, next)
52
228
  if (entry.dev !== undefined && entry.ino !== undefined) {
53
- this.byIno.set(this.inoKey(entry.dev, entry.ino), entry.hash)
229
+ const key = this.inoKey(entry.dev, entry.ino)
230
+ this.byIno.set(key, entry.hash)
231
+ if (!this.pathsByIno.has(key)) this.pathsByIno.set(key, new Set())
232
+ this.pathsByIno.get(key).add(filePath)
54
233
  }
55
234
  this.schedulePersist()
56
235
  }
236
+ setDuplicate(filePath, entry, scanEntry = null) {
237
+ const previous = this.duplicates.get(filePath) || null
238
+ this.removeLink(filePath)
239
+ this.duplicates.set(filePath, entry)
240
+ if (scanEntry) this.scanIndex.set(filePath, scanEntry)
241
+ this.schedulePersist()
242
+ return previous
243
+ }
244
+ setScanEntry(filePath, entry) {
245
+ this.scanIndex.set(filePath, entry)
246
+ this.schedulePersist()
247
+ }
248
+ untrack(filePath) {
249
+ const hadLink = this.links.has(filePath)
250
+ const hadDuplicate = this.duplicates.delete(filePath)
251
+ const hadIndex = this.scanIndex.delete(filePath)
252
+ if (hadLink) this.removeLink(filePath)
253
+ if (hadDuplicate || hadIndex) this.schedulePersist()
254
+ }
255
+ exclude(filePath, entry) {
256
+ this.untrack(filePath)
257
+ this.excluded.set(filePath, entry)
258
+ this.schedulePersist()
259
+ }
260
+ allowSharing(filePath) {
261
+ const entry = this.excluded.get(filePath) || null
262
+ if (!this.excluded.delete(filePath)) return null
263
+ this.schedulePersist()
264
+ return entry
265
+ }
57
266
  removeLink(filePath) {
58
267
  const entry = this.links.get(filePath)
59
268
  if (entry) {
60
269
  this.links.delete(filePath)
61
- const key = this.inoKey(entry.dev, entry.ino)
62
- let stillUsed = false
63
- for (const other of this.links.values()) {
64
- if (this.inoKey(other.dev, other.ino) === key) { stillUsed = true; break }
65
- }
66
- if (!stillUsed) this.byIno.delete(key)
270
+ this.removeIno(entry.dev, entry.ino, filePath)
67
271
  this.schedulePersist()
68
272
  }
69
273
  }
274
+ removeIno(dev, ino, filePath) {
275
+ if (dev === undefined || ino === undefined) return
276
+ const key = this.inoKey(dev, ino)
277
+ const paths = this.pathsByIno.get(key)
278
+ if (paths && filePath !== undefined) paths.delete(filePath)
279
+ if (!paths || paths.size === 0) {
280
+ this.pathsByIno.delete(key)
281
+ this.byIno.delete(key)
282
+ }
283
+ }
70
284
  removeBlob(hash) {
71
- this.blobs.delete(hash)
72
- for (const [p, entry] of [...this.links]) {
73
- if (entry.hash === hash) this.removeLink(p)
285
+ this.removeBlobs(new Set([hash]))
286
+ }
287
+ removeBlobs(hashes) {
288
+ if (!hashes || hashes.size === 0) return
289
+ for (const hash of hashes) this.blobs.delete(hash)
290
+ for (const [p, entry] of this.links) {
291
+ if (hashes.has(entry.hash)) {
292
+ this.removeLink(p)
293
+ this.scanIndex.delete(p)
294
+ }
295
+ }
296
+ // A pending review item is only actionable while its matching Vault copy
297
+ // exists. Clear the dependent cache records with the content group.
298
+ for (const [p, entry] of this.duplicates) {
299
+ if (hashes.has(entry.hash)) {
300
+ this.duplicates.delete(p)
301
+ this.scanIndex.delete(p)
302
+ }
74
303
  }
75
304
  this.schedulePersist()
76
305
  }
77
- addSaved(bytes) {
78
- this.totals.lifetime_bytes_saved += bytes
79
- this.schedulePersist()
306
+ snapshotState() {
307
+ const cloneMap = (source) => new Map([...source].map(([key, value]) => [
308
+ key,
309
+ value && typeof value === "object"
310
+ ? Object.assign({}, value, Array.isArray(value.source_urls)
311
+ ? { source_urls: [...value.source_urls] }
312
+ : {})
313
+ : value
314
+ ]))
315
+ return {
316
+ blobs: cloneMap(this.blobs),
317
+ links: cloneMap(this.links),
318
+ scanIndex: cloneMap(this.scanIndex),
319
+ duplicates: cloneMap(this.duplicates),
320
+ excluded: cloneMap(this.excluded),
321
+ lastScan: this.lastScan ? Object.assign({}, this.lastScan) : null,
322
+ lastScanAttempt: this.lastScanAttempt ? Object.assign({}, this.lastScanAttempt) : null,
323
+ sourceScans: cloneMap(this.sourceScans),
324
+ sourceScanAttempts: cloneMap(this.sourceScanAttempts),
325
+ settings: Object.assign({}, this.settings, {
326
+ external_sources: [...(this.settings.external_sources || [])]
327
+ }),
328
+ byIno: new Map(this.byIno),
329
+ pathsByIno: new Map([...this.pathsByIno].map(([key, paths]) => [key, new Set(paths)]))
330
+ }
331
+ }
332
+ replaceState(state, options = {}) {
333
+ this.blobs = state.blobs
334
+ this.links = state.links
335
+ this.scanIndex = state.scanIndex
336
+ this.duplicates = state.duplicates
337
+ this.excluded = state.excluded
338
+ this.lastScan = state.lastScan
339
+ this.lastScanAttempt = state.lastScanAttempt || null
340
+ this.sourceScans = state.sourceScans
341
+ this.sourceScanAttempts = state.sourceScanAttempts || new Map()
342
+ this.settings = Object.assign({ external_sources: [] }, state.settings)
343
+ this.byIno = state.byIno
344
+ this.pathsByIno = state.pathsByIno
345
+ if (options.persist !== false) this.schedulePersist()
80
346
  }
81
347
  // Returns { corrupt: true } when the snapshot exists but cannot be parsed,
82
348
  // so the caller can rebuild from disk instead of blocking startup.
@@ -84,9 +350,11 @@ class Registry {
84
350
  this.reset()
85
351
  let raw
86
352
  try {
87
- raw = await fs.promises.readFile(this.snapshotPath, "utf8")
353
+ await this.rootDirectory(false)
354
+ raw = await this.readFile(this.snapshotPath)
88
355
  } catch (e) {
89
- return { corrupt: false, existed: false }
356
+ if (e && e.code === "ENOENT") return { corrupt: false, existed: false }
357
+ throw e
90
358
  }
91
359
  let json
92
360
  try {
@@ -94,28 +362,154 @@ class Registry {
94
362
  } catch (e) {
95
363
  return { corrupt: true, existed: true }
96
364
  }
97
- for (const [k, v] of Object.entries(json.blobs || {})) this.blobs.set(k, v)
365
+ if (!isRecord(json)) {
366
+ return { corrupt: true, existed: true }
367
+ }
368
+ for (const [k, v] of Object.entries(json.blobs || {})) {
369
+ if (SHA256_RE.test(k) && isRecord(v)) {
370
+ this.blobs.set(k, Object.assign({}, v, {
371
+ size: Number.isFinite(v.size) && v.size >= 0 ? v.size : 0,
372
+ source_urls: Array.isArray(v.source_urls)
373
+ ? v.source_urls.filter((url) => typeof url === "string")
374
+ : []
375
+ }))
376
+ }
377
+ }
378
+ const legacyCopies = []
98
379
  for (const [k, v] of Object.entries(json.links || {})) {
99
- this.links.set(k, v)
100
- if (v.dev !== undefined && v.ino !== undefined) {
101
- this.byIno.set(this.inoKey(v.dev, v.ino), v.hash)
380
+ if (!isRecord(v) || !SHA256_RE.test(v.hash) ||
381
+ !Number.isFinite(v.dev) || !Number.isFinite(v.ino)) continue
382
+ if (v.mode === "copy") {
383
+ legacyCopies.push([k, v])
384
+ continue
385
+ }
386
+ const link = Object.assign({}, v)
387
+ delete link.mode
388
+ this.links.set(k, link)
389
+ if (link.dev !== undefined && link.ino !== undefined) {
390
+ const key = this.inoKey(link.dev, link.ino)
391
+ this.byIno.set(key, link.hash)
392
+ if (!this.pathsByIno.has(key)) this.pathsByIno.set(key, new Set())
393
+ this.pathsByIno.get(key).add(k)
394
+ }
395
+ }
396
+ for (const [k, v] of Object.entries(json.scan_index || {})) {
397
+ if (isRecord(v) && (!v.hash || SHA256_RE.test(v.hash)) &&
398
+ [v.size, v.mtime, v.ctime, v.dev, v.ino].every(Number.isFinite)) {
399
+ this.scanIndex.set(k, v)
400
+ }
401
+ }
402
+ for (const [k, v] of Object.entries(json.duplicates || {})) {
403
+ if (isRecord(v) && SHA256_RE.test(v.hash) && !this.links.has(k)) {
404
+ this.duplicates.set(k, Object.assign({}, v, {
405
+ size: Number.isFinite(v.size) && v.size >= 0 ? v.size : 0
406
+ }))
407
+ }
408
+ }
409
+ // Older builds recorded independent files as mode:"copy" links. They were
410
+ // never shared. Migrate only their review metadata and never recreate that
411
+ // unsupported state.
412
+ for (const [filePath, link] of legacyCopies) {
413
+ if (this.duplicates.has(filePath) || !this.blobs.has(link.hash)) continue
414
+ const indexed = this.scanIndex.get(filePath)
415
+ if (!indexed || indexed.hash !== link.hash) continue
416
+ this.duplicates.set(filePath, {
417
+ hash: link.hash,
418
+ size: indexed.size,
419
+ app: link.app || null,
420
+ source_id: link.source_id || indexed.source_id || null,
421
+ discovered: link.created || Date.now(),
422
+ dev: indexed.dev,
423
+ ino: indexed.ino,
424
+ mtime: indexed.mtime,
425
+ ctime: indexed.ctime,
426
+ unavailable_reason: "unsupported_disk"
427
+ })
428
+ }
429
+ for (const [k, v] of Object.entries(json.excluded || {})) {
430
+ if (!isRecord(v)) continue
431
+ const linked = this.links.get(k)
432
+ if (linked) {
433
+ this.links.delete(k)
434
+ this.removeIno(linked.dev, linked.ino, k)
102
435
  }
436
+ this.duplicates.delete(k)
437
+ this.scanIndex.delete(k)
438
+ this.excluded.set(k, Object.assign({}, v, {
439
+ size: Number.isFinite(v.size) && v.size >= 0 ? v.size : 0
440
+ }))
103
441
  }
104
- for (const [k, v] of Object.entries(json.scan_index || {})) this.scanIndex.set(k, v)
105
- for (const [k, v] of Object.entries(json.duplicates || {})) this.duplicates.set(k, v)
106
- for (const [k, v] of Object.entries(json.excluded || {})) this.excluded.set(k, v)
107
- if (json.last_scan) this.lastScan = json.last_scan
108
- if (json.totals && typeof json.totals.lifetime_bytes_saved === "number") {
109
- this.totals = json.totals
442
+ if (isRecord(json.scans)) {
443
+ for (const [scopeId, value] of Object.entries(json.scans)) {
444
+ if (!isRecord(value)) continue
445
+ const complete = isRecord(value.last_complete) ? value.last_complete : null
446
+ const attempt = isRecord(value.last_attempt) ? value.last_attempt : null
447
+ if (scopeId === "global") {
448
+ this.lastScan = complete
449
+ this.lastScanAttempt = attempt
450
+ } else {
451
+ if (complete) this.sourceScans.set(scopeId, complete)
452
+ if (attempt) this.sourceScanAttempts.set(scopeId, attempt)
453
+ }
454
+ }
455
+ } else {
456
+ if (isRecord(json.last_scan)) {
457
+ this.lastScan = json.last_scan
458
+ this.lastScanAttempt = json.last_scan
459
+ }
460
+ for (const [sourceId, scan] of Object.entries(json.source_scans || {})) {
461
+ if (!isRecord(scan)) continue
462
+ this.sourceScans.set(sourceId, scan)
463
+ this.sourceScanAttempts.set(sourceId, scan)
464
+ }
465
+ }
466
+ if (isRecord(json.settings)) {
467
+ if (CANDIDATE_SIZE_OPTIONS.includes(json.settings.candidate_min_bytes)) {
468
+ this.settings.candidate_min_bytes = json.settings.candidate_min_bytes
469
+ }
470
+ if (Array.isArray(json.settings.external_sources)) {
471
+ const seen = new Set()
472
+ for (const value of json.settings.external_sources) {
473
+ if (typeof value !== "string" || !path.isAbsolute(value)) continue
474
+ const resolved = path.resolve(value)
475
+ const key = process.platform === "win32" ? resolved.toLowerCase() : resolved
476
+ if (seen.has(key)) continue
477
+ seen.add(key)
478
+ this.settings.external_sources.push(resolved)
479
+ }
480
+ }
110
481
  }
111
482
  return { corrupt: false, existed: true }
112
483
  }
113
- schedulePersist() {
484
+ beginBatch() {
485
+ if (this.persistDepth === 0 && this.persistTimer) {
486
+ clearTimeout(this.persistTimer)
487
+ this.persistTimer = null
488
+ this.persistDirty = true
489
+ }
490
+ this.persistDepth += 1
491
+ }
492
+ async endBatch(options = {}) {
493
+ if (this.persistDepth > 0) this.persistDepth -= 1
494
+ if (this.persistDepth) return
495
+ if (options.discard) {
496
+ this.persistDirty = false
497
+ return
498
+ }
499
+ if (!this.persistDirty) return
500
+ if (options.flush === false) this.schedulePersist()
501
+ else await this.flush()
502
+ }
503
+ schedulePersist(delay = this.persistDelay) {
504
+ this.persistDirty = true
505
+ if (this.persistDepth > 0) {
506
+ return
507
+ }
114
508
  if (this.persistTimer) return
115
509
  this.persistTimer = setTimeout(() => {
116
510
  this.persistTimer = null
117
511
  this.flush().catch(() => {})
118
- }, this.persistDelay)
512
+ }, delay)
119
513
  if (this.persistTimer.unref) this.persistTimer.unref()
120
514
  }
121
515
  async flush() {
@@ -123,6 +517,22 @@ class Registry {
123
517
  clearTimeout(this.persistTimer)
124
518
  this.persistTimer = null
125
519
  }
520
+ this.persistDirty = false
521
+ const scans = {
522
+ global: {
523
+ last_complete: this.lastScan,
524
+ last_attempt: this.lastScanAttempt
525
+ }
526
+ }
527
+ for (const sourceId of new Set([
528
+ ...this.sourceScans.keys(),
529
+ ...this.sourceScanAttempts.keys()
530
+ ])) {
531
+ scans[sourceId] = {
532
+ last_complete: this.sourceScans.get(sourceId) || null,
533
+ last_attempt: this.sourceScanAttempts.get(sourceId) || null
534
+ }
535
+ }
126
536
  const json = {
127
537
  version: 1,
128
538
  blobs: Object.fromEntries(this.blobs),
@@ -130,40 +540,104 @@ class Registry {
130
540
  scan_index: Object.fromEntries(this.scanIndex),
131
541
  duplicates: Object.fromEntries(this.duplicates),
132
542
  excluded: Object.fromEntries(this.excluded),
133
- last_scan: this.lastScan,
134
- totals: this.totals
543
+ scans,
544
+ settings: this.settings
135
545
  }
136
546
  const write = async () => {
137
- const tmp = this.snapshotPath + ".tmp"
138
- await fs.promises.mkdir(this.root, { recursive: true })
139
- await fs.promises.writeFile(tmp, JSON.stringify(json))
140
- await fs.promises.rename(tmp, this.snapshotPath)
547
+ await this.atomicWrite(this.snapshotPath, JSON.stringify(json))
141
548
  }
142
549
  const pending = this.flushPromise.then(write, write)
143
550
  this.flushPromise = pending.catch(() => {})
144
- return pending
551
+ try {
552
+ await pending
553
+ this.persistFailures = 0
554
+ } catch (error) {
555
+ // The in-memory mutation remains authoritative after a failed write.
556
+ // Keep it dirty and retry instead of silently losing it on restart.
557
+ const retryDelay = Math.min(this.persistDelay * (2 ** this.persistFailures), 30000)
558
+ this.persistFailures += 1
559
+ this.schedulePersist(retryDelay)
560
+ throw error
561
+ }
145
562
  }
146
563
  async appendEvent(event) {
147
- const line = JSON.stringify(Object.assign({ ts: Date.now() }, event)) + "\n"
148
- await fs.promises.appendFile(this.eventsPath, line).catch(() => {})
564
+ return this.appendEvents([event])
149
565
  }
150
- // Torn final line (crash mid-append) is discarded, per persistence rules.
151
- async readEvents() {
152
- let raw
153
- try {
154
- raw = await fs.promises.readFile(this.eventsPath, "utf8")
155
- } catch (e) {
156
- return []
566
+ async appendEvents(events) {
567
+ if (!events.length) return
568
+ const entries = events.map((event) => Object.assign({ ts: Date.now() }, event))
569
+ const contents = entries.map((event) => JSON.stringify(event)).join("\n") + "\n"
570
+ const append = async () => {
571
+ await this.appendFile(this.eventsPath, contents)
572
+ if (this.eventsCache) this.eventsCache.push(...entries)
573
+ this.eventsSinceCompact += entries.length
574
+ if ((this.eventsCache && this.eventsCache.length > this.maxEvents + this.compactEvery) ||
575
+ this.eventsSinceCompact >= this.compactEvery) {
576
+ await this.compactEventsNow()
577
+ }
157
578
  }
579
+ const pending = this.eventPromise.then(append, append)
580
+ // One ordered writer keeps action and scan batches ordered. Keep it usable
581
+ // after a failure while returning the real append result so the dashboard
582
+ // can expose missing activity without misreporting the action.
583
+ this.eventPromise = pending.then(
584
+ () => { this.eventError = null },
585
+ (error) => {
586
+ this.eventError = error && error.message ? error.message : String(error)
587
+ }
588
+ )
589
+ return pending
590
+ }
591
+ async compactEventsNow(events = null) {
592
+ if (!events) {
593
+ let raw = ""
594
+ try {
595
+ raw = await this.readEventTail()
596
+ } catch (error) {
597
+ if (!error || error.code !== "ENOENT") throw error
598
+ }
599
+ events = this.parseEvents(raw)
600
+ }
601
+ const recent = events.slice(-this.maxEvents)
602
+ await this.atomicWrite(this.eventsPath,
603
+ recent.map((event) => JSON.stringify(event)).join("\n") + (recent.length ? "\n" : ""))
604
+ this.eventsCache = recent
605
+ this.eventsSinceCompact = 0
606
+ }
607
+ parseEvents(raw) {
158
608
  const events = []
159
- for (const line of raw.split("\n")) {
609
+ for (const line of String(raw || "").split("\n")) {
160
610
  if (!line.trim()) continue
161
611
  try {
162
- events.push(JSON.parse(line))
163
- } catch (e) {}
612
+ const event = JSON.parse(line)
613
+ if (isRecord(event)) events.push(event)
614
+ } catch (error) {}
164
615
  }
165
616
  return events
166
617
  }
618
+ // Torn final line (crash mid-append) is discarded, per persistence rules.
619
+ async readEvents() {
620
+ const read = async () => {
621
+ if (this.eventsCache) {
622
+ return this.eventsCache.slice(-this.maxEvents)
623
+ }
624
+ let raw
625
+ try {
626
+ await this.rootDirectory(false)
627
+ raw = await this.readEventTail()
628
+ } catch (error) {
629
+ if (!error || error.code !== "ENOENT") throw error
630
+ this.eventsCache = []
631
+ return []
632
+ }
633
+ const events = this.parseEvents(raw)
634
+ this.eventsCache = events.slice(-this.maxEvents)
635
+ return this.eventsCache.slice()
636
+ }
637
+ const pending = this.eventPromise.then(read, read)
638
+ this.eventPromise = pending.then(() => {}, () => {})
639
+ return pending
640
+ }
167
641
  }
168
642
 
169
643
  module.exports = Registry