pinokiod 8.0.37 → 8.0.39

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.
@@ -12,6 +12,25 @@ const fastq = require('fastq')
12
12
 
13
13
  const SHA256_RE = /^[0-9a-f]{64}$/
14
14
  const TMP_SUFFIX = ".pinokio-dedup-tmp"
15
+ const DIR_CONCURRENCY = 8
16
+ const STAT_CONCURRENCY = 32
17
+
18
+ const fileSnapshot = (st) => ({
19
+ dev: st.dev,
20
+ ino: st.ino,
21
+ size: st.size,
22
+ mtime: st.mtimeMs,
23
+ ctime: st.ctimeMs
24
+ })
25
+
26
+ const sameSnapshot = (snapshot, st) => !!(
27
+ snapshot && st &&
28
+ snapshot.dev === st.dev &&
29
+ snapshot.ino === st.ino &&
30
+ snapshot.size === st.size &&
31
+ snapshot.mtime === st.mtimeMs &&
32
+ snapshot.ctime === st.ctimeMs
33
+ )
15
34
 
16
35
  class Sweeper {
17
36
  constructor(vault) {
@@ -21,33 +40,82 @@ class Sweeper {
21
40
  this.currentHash = null
22
41
  this.hashQueue = fastq.promise(this, this._hashJob, 1)
23
42
  this.stats = { hashes: 0, ingested: 0 }
43
+ this.statConcurrency = vault.statConcurrency || STAT_CONCURRENCY
44
+ this.dirConcurrency = vault.dirConcurrency || DIR_CONCURRENCY
45
+ this.metadataBaseCache = new Map()
46
+ this.hashJobsByIno = new Map()
24
47
  }
25
48
  idleState() {
26
49
  return {
27
50
  active: false, dirs: 0, files: 0, bytes_total: 0,
28
51
  home_bytes_total: 0, source_bytes: {},
29
- candidates: 0, hashed: 0, queued: 0, started: null
52
+ candidates: 0, hashed: 0, hash_total: 0, hash_bytes: 0, queued: 0,
53
+ inode_reuses: 0, unstable_hashes: 0,
54
+ source_ids: [], estimated_files: null, estimated_walk_weight: null,
55
+ started: null, duration_ms: null,
56
+ walk_duration_ms: null, hash_wait_duration_ms: null,
57
+ hash_duration_ms: 0
30
58
  }
31
59
  }
32
60
  // The one entry point: the Pinokio home plus explicit linked imports.
33
61
  async scan() {
34
62
  if (this.state.active) return { already_running: true }
35
63
  this.state = Object.assign(this.idleState(), { active: true, started: Date.now() })
64
+ this.metadataBaseCache.clear()
65
+ this.hashJobsByIno.clear()
36
66
  try {
37
67
  await this.vault.refreshSources()
38
- for (const source of this.vault.scanRoots()) {
68
+ const scanRoots = this.vault.scanRoots()
69
+ this.state.source_ids = scanRoots.map((source) => source.source_id).sort()
70
+ const previous = this.vault.registry.lastScan
71
+ const previousSourceIds = previous
72
+ ? (Array.isArray(previous.source_ids) ? previous.source_ids : Object.keys(previous.source_bytes || {})).slice().sort()
73
+ : []
74
+ const sameSources = previousSourceIds.length === this.state.source_ids.length &&
75
+ previousSourceIds.every((sourceId, index) => sourceId === this.state.source_ids[index])
76
+ if (previous && previous.files > 0 && sameSources) {
77
+ this.state.estimated_files = previous.files
78
+ const measuredWeight = previous.duration_ms > 0 && previous.walk_duration_ms >= 0
79
+ ? previous.walk_duration_ms / previous.duration_ms
80
+ : 0.8
81
+ this.state.estimated_walk_weight = Math.max(0.15, Math.min(0.98, measuredWeight))
82
+ }
83
+ const walkStarted = Date.now()
84
+ for (const source of scanRoots) {
85
+ if (!Object.prototype.hasOwnProperty.call(this.state.source_bytes, source.source_id)) {
86
+ this.state.source_bytes[source.source_id] = 0
87
+ }
39
88
  await this.walk(source.root, source.source_id === "pinokio" ? null : source.source_id)
40
89
  }
90
+ this.state.walk_duration_ms = Date.now() - walkStarted
91
+ const hashWaitStarted = Date.now()
41
92
  await this.settle()
93
+ this.state.hash_wait_duration_ms = Date.now() - hashWaitStarted
94
+ this.state.duration_ms = Date.now() - this.state.started
42
95
  this.vault.registry.setLastScan({
43
96
  ts: Date.now(),
44
97
  dirs: this.state.dirs,
45
98
  files: this.state.files,
46
99
  bytes_total: this.state.bytes_total,
47
100
  home_bytes_total: this.state.home_bytes_total,
48
- source_bytes: Object.assign({}, this.state.source_bytes)
101
+ source_bytes: Object.assign({}, this.state.source_bytes),
102
+ source_ids: this.state.source_ids.slice(),
103
+ candidates: this.state.candidates,
104
+ hashed: this.state.hashed,
105
+ hash_total: this.state.hash_total,
106
+ hash_bytes: this.state.hash_bytes,
107
+ inode_reuses: this.state.inode_reuses,
108
+ unstable_hashes: this.state.unstable_hashes,
109
+ walk_duration_ms: this.state.walk_duration_ms,
110
+ hash_wait_duration_ms: this.state.hash_wait_duration_ms,
111
+ hash_duration_ms: this.state.hash_duration_ms,
112
+ duration_ms: this.state.duration_ms
49
113
  })
50
114
  } finally {
115
+ // A failed walk must not leave background hash work or per-scan inode
116
+ // state alive when the next manual scan starts.
117
+ await this.settle().catch(() => {})
118
+ this.hashJobsByIno.clear()
51
119
  this.state.active = false
52
120
  }
53
121
  return {
@@ -62,40 +130,80 @@ class Sweeper {
62
130
  const vaultRoot = this.vault.root
63
131
  const stack = [root]
64
132
  while (stack.length) {
65
- const dir = stack.pop()
66
- this.state.dirs += 1
67
- let entries
68
- try {
69
- entries = await fs.promises.readdir(dir, { withFileTypes: true })
70
- } catch (e) {
71
- continue
133
+ const dirs = []
134
+ while (dirs.length < this.dirConcurrency && stack.length) {
135
+ dirs.push(stack.pop())
136
+ }
137
+ const directoryResults = await Promise.all(dirs.map(async (dir) => {
138
+ this.state.dirs += 1
139
+ try {
140
+ const entries = await fs.promises.readdir(dir, { withFileTypes: true })
141
+ return { dir, entries }
142
+ } catch (e) {
143
+ return { dir, entries: null }
144
+ }
145
+ }))
146
+ const files = []
147
+ const hfDirectories = []
148
+ for (const { dir, entries } of directoryResults) {
149
+ if (!entries) continue
150
+ // HF hub blobs dir: filenames ARE sha256 for LFS files — no hashing.
151
+ if (path.basename(dir) === "blobs" && /^(models|datasets|spaces)--/.test(path.basename(path.dirname(dir)))) {
152
+ hfDirectories.push({ dir, entries })
153
+ continue
154
+ }
155
+ for (const entry of entries) {
156
+ if (entry.isSymbolicLink()) continue
157
+ const full = path.resolve(dir, entry.name)
158
+ if (entry.isDirectory()) {
159
+ if (full === vaultRoot) continue
160
+ stack.push(full)
161
+ } else if (entry.isFile()) {
162
+ files.push(full)
163
+ }
164
+ }
72
165
  }
73
- // HF hub blobs dir: filenames ARE sha256 for LFS files no hashing.
74
- if (path.basename(dir) === "blobs" && /^(models|datasets|spaces)--/.test(path.basename(path.dirname(dir)))) {
166
+ // One metadata pool is shared by all files in this directory batch, so
167
+ // trees made of many small directories still use the bounded workers.
168
+ await this.considerFiles(files, preferredSourceId)
169
+ for (const { dir, entries } of hfDirectories) {
75
170
  await this.ingestHfBlobs(dir, entries, preferredSourceId)
76
- continue
77
171
  }
78
- for (const entry of entries) {
79
- if (entry.isSymbolicLink()) continue
80
- const full = path.resolve(dir, entry.name)
81
- if (entry.isDirectory()) {
82
- if (full === vaultRoot) continue
83
- stack.push(full)
84
- } else if (entry.isFile()) {
85
- await this.considerFile(full, preferredSourceId)
172
+ }
173
+ }
174
+ async considerFiles(filePaths, preferredSourceId = null) {
175
+ const stats = new Array(filePaths.length)
176
+ let cursor = 0
177
+ const workers = Array.from({ length: Math.min(this.statConcurrency, filePaths.length) }, async () => {
178
+ while (cursor < filePaths.length) {
179
+ const index = cursor++
180
+ try {
181
+ stats[index] = await fs.promises.stat(filePaths[index])
182
+ } catch (error) {
183
+ stats[index] = null
86
184
  }
87
185
  }
186
+ })
187
+ await Promise.all(workers)
188
+ // Preserve deterministic classification order while parallelizing only
189
+ // the expensive filesystem metadata reads.
190
+ for (let index = 0; index < filePaths.length; index++) {
191
+ if (stats[index]) await this.considerStat(filePaths[index], stats[index], preferredSourceId)
88
192
  }
89
193
  }
90
194
  async considerFile(filePath, preferredSourceId = null) {
91
195
  if (filePath.endsWith(TMP_SUFFIX)) return
92
- const registry = this.vault.registry
93
196
  let st
94
197
  try {
95
198
  st = await fs.promises.stat(filePath)
96
199
  } catch (e) {
97
200
  return
98
201
  }
202
+ await this.considerStat(filePath, st, preferredSourceId)
203
+ }
204
+ async considerStat(filePath, st, preferredSourceId = null) {
205
+ if (filePath.endsWith(TMP_SUFFIX)) return
206
+ const registry = this.vault.registry
99
207
  this.state.files += 1
100
208
  this.state.bytes_total += st.size
101
209
  const sourceKey = preferredSourceId || "pinokio"
@@ -130,19 +238,63 @@ class Sweeper {
130
238
  await this.classify(filePath, st, harvested, sourceId)
131
239
  return
132
240
  }
241
+ // Only coalesce an inode which the filesystem itself reports as having
242
+ // multiple names. This avoids trusting placeholder inode values on
243
+ // filesystems which do not expose usable identity metadata.
244
+ const reusableInode = st.nlink > 1 && st.ino !== undefined && st.ino !== 0
245
+ const inodeKey = reusableInode ? registry.inoKey(st.dev, st.ino) : null
246
+ const existingJob = inodeKey ? this.hashJobsByIno.get(inodeKey) : null
247
+ const name = { filePath, source_id: sourceId, expected: fileSnapshot(st) }
248
+ if (existingJob) {
249
+ existingJob.names.push(name)
250
+ this.state.inode_reuses += 1
251
+ return
252
+ }
253
+ const job = { inodeKey, names: [name] }
254
+ if (inodeKey) this.hashJobsByIno.set(inodeKey, job)
255
+ this.state.hash_total += 1
133
256
  this.state.queued += 1
134
- this.hashQueue.push({ filePath, source_id: sourceId }).catch(() => {})
257
+ this.hashQueue.push(job).catch(() => {})
135
258
  }
136
- async _hashJob({ filePath, source_id }) {
137
- this.currentHash = { path: filePath }
259
+ async _hashJob(job) {
260
+ const primary = job.names[0]
261
+ const started = Date.now()
262
+ this.currentHash = { path: primary.filePath }
138
263
  try {
139
- const { hash } = await this.vault.hashFile(filePath)
264
+ const { hash, size } = await this.vault.hashFile(primary.filePath)
140
265
  this.stats.hashes += 1
141
266
  this.state.hashed += 1
142
- const st = await fs.promises.stat(filePath)
143
- await this.classify(filePath, st, hash, source_id)
267
+ this.state.hash_bytes += size || 0
268
+ const primaryStat = await fs.promises.stat(primary.filePath)
269
+ // Never publish a digest if the path changed while its bytes were read.
270
+ // The next manual scan can retry a continuously-mutating file safely.
271
+ if (size !== primaryStat.size || !sameSnapshot(primary.expected, primaryStat)) {
272
+ this.state.unstable_hashes += 1
273
+ return
274
+ }
275
+ let index = 0
276
+ while (index < job.names.length) {
277
+ const name = job.names[index++]
278
+ let st
279
+ try {
280
+ st = name === primary ? primaryStat : await fs.promises.stat(name.filePath)
281
+ } catch (e) {
282
+ continue
283
+ }
284
+ // Same (device, inode) is physical identity, not a content guess.
285
+ // Size and mtime also prevent applying a completed digest after an
286
+ // in-place mutation. ctime is omitted here because adoption itself
287
+ // adds a name and therefore legitimately changes inode metadata.
288
+ if (st.dev !== primaryStat.dev || st.ino !== primaryStat.ino ||
289
+ st.size !== primaryStat.size || st.mtimeMs !== primaryStat.mtimeMs) continue
290
+ await this.classify(name.filePath, st, hash, name.source_id)
291
+ }
144
292
  } catch (e) {
145
293
  } finally {
294
+ this.state.hash_duration_ms += Date.now() - started
295
+ if (job.inodeKey && this.hashJobsByIno.get(job.inodeKey) === job) {
296
+ this.hashJobsByIno.delete(job.inodeKey)
297
+ }
146
298
  this.currentHash = null
147
299
  this.state.queued = Math.max(0, this.state.queued - 1)
148
300
  }
@@ -224,35 +376,43 @@ class Sweeper {
224
376
  // via the recorded timestamp against mtime (huggingface_hub's own rule).
225
377
  async harvestLocalDirEtag(filePath, st) {
226
378
  try {
227
- let dir = path.dirname(filePath)
379
+ const start = path.dirname(filePath)
380
+ let dir = start
228
381
  const stop = path.resolve(this.kernel.homedir)
229
- const rel = []
382
+ const visited = []
383
+ let base = null
230
384
  for (let depth = 0; depth < 12; depth++) {
385
+ if (this.metadataBaseCache.has(dir)) {
386
+ base = this.metadataBaseCache.get(dir)
387
+ break
388
+ }
389
+ visited.push(dir)
231
390
  const metaRoot = path.resolve(dir, ".cache", "huggingface")
232
391
  let exists = false
233
392
  try {
234
393
  exists = (await fs.promises.stat(metaRoot)).isDirectory()
235
394
  } catch (e) {}
236
395
  if (exists) {
237
- const metaPath = path.resolve(metaRoot, ...rel, path.basename(filePath) + ".metadata")
238
- let raw
239
- try {
240
- raw = await fs.promises.readFile(metaPath, "utf8")
241
- } catch (e) {
242
- return null
243
- }
244
- const lines = raw.split("\n")
245
- const etag = String(lines[1] || "").replace(/"/g, "").trim().toLowerCase()
246
- const ts = parseFloat(lines[2] || "0") * 1000
247
- if (SHA256_RE.test(etag) && st.mtimeMs <= ts + 1000) {
248
- return etag
249
- }
250
- return null
396
+ base = dir
397
+ break
251
398
  }
252
- if (dir === stop || path.dirname(dir) === dir) return null
253
- rel.unshift(path.basename(dir))
399
+ if (dir === stop || path.dirname(dir) === dir) break
254
400
  dir = path.dirname(dir)
255
401
  }
402
+ for (const visitedDir of visited) this.metadataBaseCache.set(visitedDir, base)
403
+ if (!base) return null
404
+ const relative = path.relative(base, filePath)
405
+ const metaPath = path.resolve(base, ".cache", "huggingface", relative + ".metadata")
406
+ let raw
407
+ try {
408
+ raw = await fs.promises.readFile(metaPath, "utf8")
409
+ } catch (e) {
410
+ return null
411
+ }
412
+ const lines = raw.split("\n")
413
+ const etag = String(lines[1] || "").replace(/"/g, "").trim().toLowerCase()
414
+ const ts = parseFloat(lines[2] || "0") * 1000
415
+ if (SHA256_RE.test(etag) && st.mtimeMs <= ts + 1000) return etag
256
416
  } catch (e) {}
257
417
  return null
258
418
  }
@@ -293,8 +453,9 @@ class Sweeper {
293
453
  summary.bytes_saved += result.bytes_saved || 0
294
454
  } else if (result.status === "locked") {
295
455
  summary.locked += 1
456
+ } else if (result.status === "unavailable" || result.status === "copy-mode") {
457
+ summary.unavailable += 1
296
458
  } else {
297
- registry.duplicates.delete(filePath)
298
459
  summary.failed += 1
299
460
  }
300
461
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.0.37",
3
+ "version": "8.0.39",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/server/index.js CHANGED
@@ -15740,9 +15740,20 @@ class Server {
15740
15740
  res.json({ enabled: false })
15741
15741
  return
15742
15742
  }
15743
+ if (req.query && req.query.progress === "1") {
15744
+ res.json(vault.progressStatus())
15745
+ return
15746
+ }
15743
15747
  res.json(await vault.status())
15744
15748
  }))
15745
15749
  this.app.get("/vault", ex(async (req, res) => {
15750
+ const { install_required, requirements_pending } = await this.kernel.bin.check({
15751
+ bin: this.kernel.bin.preset("dev"),
15752
+ })
15753
+ if (requirements_pending || install_required) {
15754
+ res.redirect(`/setup/dev?callback=${encodeURIComponent(req.originalUrl)}`)
15755
+ return
15756
+ }
15746
15757
  res.render("vault", { theme: this.theme, agent: req.agent })
15747
15758
  }))
15748
15759
  this.app.post("/vault/action", ex(async (req, res) => {
@@ -15754,6 +15765,19 @@ class Server {
15754
15765
  const body = req.body || {}
15755
15766
  try {
15756
15767
  switch (body.action) {
15768
+ case "add_source": {
15769
+ const result = await vault.addExternalSource(body.path)
15770
+ res.json({
15771
+ created: result.created,
15772
+ source: {
15773
+ id: result.source.id,
15774
+ label: result.source.label,
15775
+ target_path: result.source.root,
15776
+ shareable: !!result.source.shareable
15777
+ }
15778
+ })
15779
+ return
15780
+ }
15757
15781
  case "deduplicate": {
15758
15782
  const scopeId = typeof body.scope_id === "string" ? body.scope_id : null
15759
15783
  if (scopeId) {
@@ -15803,7 +15827,12 @@ class Server {
15803
15827
  vault.sweeper.scan().catch(() => {})
15804
15828
  res.json({ started: true })
15805
15829
  return
15830
+ case "repair":
15806
15831
  case "rebuild":
15832
+ if (vault.sweeper && vault.sweeper.state.active) {
15833
+ res.json({ error: "Wait for the current scan to finish before repairing the index." })
15834
+ return
15835
+ }
15807
15836
  await vault.rebuild()
15808
15837
  res.json({ done: true })
15809
15838
  return