pinokiod 8.0.73 → 8.0.74

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.
@@ -52,7 +52,6 @@ class AutomaticScans {
52
52
  this.manualDepth = 0
53
53
  this.observedApps = new Set()
54
54
  this.changedPaths = new Map()
55
- this.changedPathIndex = new Map()
56
55
  this.pendingStops = new Map()
57
56
  this.lifecycleWork = new Set()
58
57
  this.stopSettleMs = STOP_SETTLE_MS
@@ -138,7 +137,6 @@ class AutomaticScans {
138
137
  }
139
138
  this.observedApps.clear()
140
139
  this.changedPaths.clear()
141
- this.changedPathIndex.clear()
142
140
  if (watcherError) throw watcherError
143
141
  }
144
142
 
@@ -151,117 +149,123 @@ class AutomaticScans {
151
149
  })
152
150
  return
153
151
  }
154
- for (const event of events || []) {
152
+ const watcherEvents = Array.isArray(events) ? events : []
153
+ if (!watcherEvents.some((event) => event && event.type === "delete")) {
154
+ for (const event of watcherEvents) {
155
+ if (event && ["create", "update"].includes(event.type)) {
156
+ this.recordChangedPath(event.path)
157
+ }
158
+ }
159
+ return
160
+ }
161
+ const batches = new Map()
162
+ for (let index = 0; index < watcherEvents.length; index++) {
163
+ const event = watcherEvents[index]
155
164
  if (!event || !["create", "update", "delete"].includes(event.type)) {
156
165
  continue
157
166
  }
158
- if (event.type === "delete") this.removeChangedPath(event.path)
159
- else this.recordChangedPath(event.path)
167
+ const target = this.changedPathTarget(
168
+ event.path, event.type === "delete")
169
+ if (!target) continue
170
+ let batch = batches.get(target.app)
171
+ if (!batch) {
172
+ batch = {
173
+ root: target.root,
174
+ updates: new Map(),
175
+ deletes: new Map()
176
+ }
177
+ batches.set(target.app, batch)
178
+ }
179
+ const key = pathKey(target.path)
180
+ if (event.type === "delete") batch.deletes.set(key, index)
181
+ else batch.updates.set(key, { index, path: target.path })
182
+ }
183
+ for (const [app, batch] of batches) {
184
+ this.applyChangedPathBatch(app, batch)
160
185
  }
161
186
  }
162
187
 
163
- recordChangedPath(filePath) {
188
+ changedPathTarget(filePath, allowRoot = false) {
164
189
  if (this.disposed || !this.supported ||
165
190
  typeof filePath !== "string" || !filePath) {
166
- return false
191
+ return null
167
192
  }
168
193
  const app = this.appForLaunchPath(filePath)
169
- if (!app || !this.observedApps.has(app)) return false
170
- const appRoot = path.resolve(this.vault.kernel.homedir, "api", app)
194
+ if (!app || !this.observedApps.has(app)) return null
195
+ const root = path.resolve(this.vault.kernel.homedir, "api", app)
171
196
  const resolved = path.resolve(filePath)
172
- if (resolved === appRoot || !inside(appRoot, resolved)) return false
197
+ if ((!allowRoot && resolved === root) || !inside(root, resolved)) return null
198
+ return { app, root, path: resolved }
199
+ }
200
+
201
+ recordChangedPath(filePath) {
202
+ const target = this.changedPathTarget(filePath)
203
+ if (!target) return false
204
+ const { app, path: resolved } = target
173
205
  let paths = this.changedPaths.get(app)
174
206
  if (!paths) {
175
207
  paths = new Set()
176
208
  this.changedPaths.set(app, paths)
177
209
  }
178
- let index = this.changedPathIndex.get(app)
179
- if (!index) {
180
- index = { exact: new Map(), descendants: new Map() }
181
- this.changedPathIndex.set(app, index)
182
- }
183
- const key = pathKey(resolved)
184
- if (index.exact.has(key)) return true
185
210
  paths.add(resolved)
186
- index.exact.set(key, resolved)
187
- this.addChangedPathToIndex(index.descendants, appRoot, resolved)
188
211
  return true
189
212
  }
190
213
 
191
- changedPathParentKeys(appRoot, filePath) {
192
- const rootKey = pathKey(appRoot)
193
- const keys = []
194
- let current = path.dirname(path.resolve(filePath))
214
+ lastDeleteIndex(root, filePath, deletes) {
215
+ const rootKey = pathKey(root)
216
+ let current = path.resolve(filePath)
217
+ let latest = -1
195
218
  while (true) {
196
219
  const key = pathKey(current)
197
- keys.push(key)
198
- if (key === rootKey) return keys
220
+ const index = deletes.get(key)
221
+ if (index !== undefined && index > latest) latest = index
222
+ if (key === rootKey) return latest
199
223
  current = path.dirname(current)
200
224
  }
201
225
  }
202
226
 
203
- addChangedPathToIndex(index, appRoot, filePath) {
204
- for (const key of this.changedPathParentKeys(appRoot, filePath)) {
205
- let descendants = index.get(key)
206
- if (!descendants) {
207
- descendants = new Set()
208
- index.set(key, descendants)
209
- }
210
- descendants.add(filePath)
227
+ applyChangedPathBatch(app, { root, updates, deletes }) {
228
+ const paths = this.changedPaths.get(app)
229
+ if (!deletes.size) {
230
+ const collected = paths || new Set()
231
+ for (const update of updates.values()) collected.add(update.path)
232
+ if (collected.size) this.changedPaths.set(app, collected)
233
+ return
211
234
  }
212
- }
213
-
214
- removeChangedPathFromIndex(index, appRoot, filePath) {
215
- index.exact.delete(pathKey(filePath))
216
- for (const key of this.changedPathParentKeys(appRoot, filePath)) {
217
- const descendants = index.descendants.get(key)
218
- if (!descendants) continue
219
- descendants.delete(filePath)
220
- if (!descendants.size) index.descendants.delete(key)
235
+ const retained = new Set()
236
+ if (paths) {
237
+ for (const candidate of paths) {
238
+ const key = pathKey(candidate)
239
+ const update = updates.get(key)
240
+ if (this.lastDeleteIndex(root, candidate, deletes) >
241
+ (update ? update.index : -1) || retained.has(key)) {
242
+ paths.delete(candidate)
243
+ } else {
244
+ retained.add(key)
245
+ }
246
+ }
221
247
  }
222
- }
223
-
224
- removeChangedPath(filePath) {
225
- if (this.disposed || !this.supported ||
226
- typeof filePath !== "string" || !filePath) {
227
- return false
248
+ let collected = paths
249
+ for (const [key, update] of updates) {
250
+ if (update.index <= this.lastDeleteIndex(root, update.path, deletes) ||
251
+ retained.has(key)) continue
252
+ if (!collected) collected = new Set()
253
+ collected.add(update.path)
254
+ retained.add(key)
228
255
  }
229
- const app = this.appForLaunchPath(filePath)
230
- if (!app || !this.observedApps.has(app)) return false
231
- const paths = this.changedPaths.get(app)
232
- if (!paths) return false
233
- const resolved = path.resolve(filePath)
234
- const appRoot = path.resolve(this.vault.kernel.homedir, "api", app)
235
- const index = this.changedPathIndex.get(app)
236
- if (!index) return false
237
- const key = pathKey(resolved)
238
- const exact = index.exact.get(key)
239
- const descendants = index.descendants.get(key)
240
- if (!exact && !descendants) return false
241
- const matches = new Set(descendants || [])
242
- if (exact) matches.add(exact)
243
- for (const candidate of matches) {
244
- paths.delete(candidate)
245
- this.removeChangedPathFromIndex(index, appRoot, candidate)
246
- }
247
- if (!paths.size) this.discardChangedPaths(app)
248
- return true
256
+ if (collected && collected.size) this.changedPaths.set(app, collected)
257
+ else this.changedPaths.delete(app)
249
258
  }
250
259
 
251
260
  discardChangedPaths(app) {
252
261
  this.changedPaths.delete(app)
253
- this.changedPathIndex.delete(app)
254
262
  }
255
263
 
256
264
  consumeChangedPaths(app, paths) {
257
265
  const collected = this.changedPaths.get(app)
258
266
  if (!collected) return
259
- const appRoot = path.resolve(this.vault.kernel.homedir, "api", app)
260
- const index = this.changedPathIndex.get(app)
261
267
  for (const filePath of paths || []) {
262
- const resolved = path.resolve(filePath)
263
- if (!collected.delete(resolved) || !index) continue
264
- this.removeChangedPathFromIndex(index, appRoot, resolved)
268
+ collected.delete(path.resolve(filePath))
265
269
  }
266
270
  if (!collected.size) this.discardChangedPaths(app)
267
271
  }
@@ -931,10 +935,11 @@ class AutomaticScans {
931
935
  if (reusable) {
932
936
  counts.hash_reuses += 1
933
937
  memory.set(key, reusable)
934
- verifiedHashes.set(pathKey(entry.path), Object.assign({}, currentEntry, {
938
+ const verifiedEntry = Object.assign({}, currentEntry, {
935
939
  hash: reusable
936
- }))
937
- return reusable
940
+ })
941
+ verifiedHashes.set(pathKey(entry.path), verifiedEntry)
942
+ return verifiedEntry
938
943
  }
939
944
  let verified
940
945
  try {
@@ -961,10 +966,11 @@ class AutomaticScans {
961
966
  counts.hashed += 1
962
967
  counts.hash_bytes += Math.max(0, Number(verified.result.size) || 0)
963
968
  memory.set(key, verified.result.hash)
964
- verifiedHashes.set(pathKey(entry.path), Object.assign({}, currentEntry, {
969
+ const verifiedEntry = Object.assign({}, currentEntry, {
965
970
  hash: verified.result.hash
966
- }))
967
- return verified.result.hash
971
+ })
972
+ verifiedHashes.set(pathKey(entry.path), verifiedEntry)
973
+ return verifiedEntry
968
974
  }
969
975
 
970
976
  async runPrecheck(active) {
@@ -1061,7 +1067,6 @@ class AutomaticScans {
1061
1067
  const preparePeers = () => {
1062
1068
  if (!group || group.unmatched) return
1063
1069
  group.unmatched = new Map()
1064
- group.unmatchedIdentities = new Map()
1065
1070
  for (const [hash, value] of group.candidatesByHash) {
1066
1071
  if (value.identities.size > 1) {
1067
1072
  for (const candidate of value.candidates) {
@@ -1073,9 +1078,6 @@ class AutomaticScans {
1073
1078
  identity,
1074
1079
  candidates: value.candidates
1075
1080
  })
1076
- group.unmatchedIdentities.set(
1077
- identity,
1078
- (group.unmatchedIdentities.get(identity) || 0) + 1)
1079
1081
  }
1080
1082
  }
1081
1083
  }
@@ -1104,47 +1106,39 @@ class AutomaticScans {
1104
1106
  candidatesByHash: new Map(),
1105
1107
  matched: new Set(),
1106
1108
  unmatched: null,
1107
- unmatchedIdentities: null,
1108
1109
  memory: new Map()
1109
1110
  }
1110
1111
  }
1111
1112
  if (entry.kind === "changed") {
1112
1113
  const candidate = entry
1113
1114
  this.checkpoint(active)
1114
- const hash = await this.verifiedAutomaticHash(
1115
+ const verified = await this.verifiedAutomaticHash(
1115
1116
  active, candidate, group.memory, verifiedHashes, counts)
1116
- if (!hash) continue
1117
+ if (!verified) continue
1118
+ const hash = verified.hash
1117
1119
  let value = group.candidatesByHash.get(hash)
1118
1120
  if (!value) {
1119
1121
  value = { candidates: [], identities: new Set() }
1120
1122
  group.candidatesByHash.set(hash, value)
1121
1123
  }
1122
- value.candidates.push(candidate)
1123
- value.identities.add(this.automaticIdentityKey(candidate))
1124
+ value.candidates.push(verified)
1125
+ value.identities.add(this.automaticIdentityKey(verified))
1124
1126
  continue
1125
1127
  }
1126
1128
  preparePeers()
1127
1129
  if (!group.unmatched.size) continue
1128
1130
  this.checkpoint(active)
1129
- const identity = this.automaticIdentityKey(entry)
1130
- if (group.unmatchedIdentities.size === 1 &&
1131
- group.unmatchedIdentities.has(identity)) continue
1132
- const hash = await this.verifiedAutomaticHash(
1131
+ const verified = await this.verifiedAutomaticHash(
1133
1132
  active, entry, group.memory, verifiedHashes, counts)
1134
- const wanted = hash && group.unmatched.get(hash)
1133
+ const wanted = verified && group.unmatched.get(verified.hash)
1134
+ const identity = verified && this.automaticIdentityKey(verified)
1135
1135
  if (!wanted || wanted.identity === identity) {
1136
1136
  continue
1137
1137
  }
1138
1138
  for (const candidate of wanted.candidates) {
1139
1139
  group.matched.add(pathKey(candidate.path))
1140
1140
  }
1141
- group.unmatched.delete(hash)
1142
- const remaining = group.unmatchedIdentities.get(wanted.identity) - 1
1143
- if (remaining) {
1144
- group.unmatchedIdentities.set(wanted.identity, remaining)
1145
- } else {
1146
- group.unmatchedIdentities.delete(wanted.identity)
1147
- }
1141
+ group.unmatched.delete(verified.hash)
1148
1142
  }
1149
1143
  if (verifiedHashes.size >= ENTRY_BATCH_SIZE) {
1150
1144
  await this.vault.registry.rememberHashCache(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.0.73",
3
+ "version": "8.0.74",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -167,7 +167,8 @@
167
167
  tab.classList.remove("app-vault-result-attention")
168
168
  }
169
169
  })
170
- tab.addEventListener("click", () => {
170
+ tab.addEventListener("click", (event) => {
171
+ if (!event.isTrusted) return
171
172
  const signature = status.dataset.resultSignature || ""
172
173
  if (!signature) return
173
174
  requestAutomaticScanFocus(signature)
@@ -221,7 +221,7 @@ test("the app workspace presents the automatic-result coachmark without starting
221
221
  assert.doesNotMatch(present, /innerHTML|post\(|btn-scan\.click/)
222
222
  })
223
223
 
224
- test("selecting a badged row acknowledges it without delaying navigation", async () => {
224
+ test("user-selecting a badged row acknowledges it without delaying navigation", async () => {
225
225
  const script = await fs.promises.readFile(
226
226
  path.join(root, "server", "public", "app-vault-mode.js"), "utf8")
227
227
  const parent = new JSDOM("", { url: "http://localhost/" })
@@ -256,14 +256,19 @@ test("selecting a badged row acknowledges it without delaying navigation", async
256
256
  }
257
257
  }
258
258
 
259
- dom.window.eval(script)
260
259
  const tab = dom.window.document.getElementById("save-space-tab")
261
- let preventedByHandler = null
262
- tab.addEventListener("click", (event) => {
263
- preventedByHandler = event.defaultPrevented
264
- event.preventDefault()
260
+ let clickHandler
261
+ const addEventListener = tab.addEventListener.bind(tab)
262
+ tab.addEventListener = (type, listener, options) => {
263
+ if (type === "click") clickHandler = listener
264
+ addEventListener(type, listener, options)
265
+ }
266
+ dom.window.eval(script)
267
+ let preventedByHandler = false
268
+ clickHandler({
269
+ isTrusted: true,
270
+ preventDefault: () => { preventedByHandler = true }
265
271
  })
266
- tab.click()
267
272
 
268
273
  await waitFor(() => requests.length === 1)
269
274
  assert.equal(preventedByHandler, false)
@@ -289,6 +294,49 @@ test("selecting a badged row acknowledges it without delaying navigation", async
289
294
  parent.window.close()
290
295
  })
291
296
 
297
+ test("programmatic tab clicks do not acknowledge a result", async () => {
298
+ const script = await fs.promises.readFile(
299
+ path.join(root, "server", "public", "app-vault-mode.js"), "utf8")
300
+ const signature = "e".repeat(64)
301
+ const parent = new JSDOM("", { url: "http://localhost/" })
302
+ const dom = new JSDOM(`<a id="save-space-tab" href="/vault/app/ComfyUI">
303
+ <span data-app-vault-result-badge></span>
304
+ <span data-app-vault-mode data-app="ComfyUI" data-mode="automatic"
305
+ data-ready="true" data-result-signature="${signature}">
306
+ <span data-app-vault-mode-label>Auto</span>
307
+ </span>
308
+ </a>`, {
309
+ runScripts: "outside-only",
310
+ url: "http://localhost/v/ComfyUI"
311
+ })
312
+ Object.defineProperty(dom.window, "parent", {
313
+ configurable: true,
314
+ value: parent.window
315
+ })
316
+ parent.window.postMessage = () => {}
317
+ const requests = []
318
+ dom.window.fetch = async (url, options = {}) => {
319
+ requests.push({ url, options })
320
+ return { ok: true, json: async () => ({}) }
321
+ }
322
+
323
+ dom.window.eval(script)
324
+ const tab = dom.window.document.getElementById("save-space-tab")
325
+ tab.addEventListener("click", (event) => event.preventDefault())
326
+ tab.click()
327
+
328
+ assert.equal(requests.length, 0)
329
+ assert.equal(tab.querySelector("[data-app-vault-result-badge]").hidden,
330
+ false)
331
+ assert.equal(dom.window.document.querySelector("[data-app-vault-mode]")
332
+ .dataset.resultSignature, signature)
333
+ assert.equal(dom.window.sessionStorage.getItem(
334
+ "pinokio:vault:auto-scan-focus:ComfyUI"), null)
335
+
336
+ dom.window.close()
337
+ parent.window.close()
338
+ })
339
+
292
340
  test("a retained Disk Saver handoff targets the visible existing frame", async () => {
293
341
  const template = await fs.promises.readFile(
294
342
  path.join(root, "server", "views", "app.ejs"), "utf8")
@@ -336,8 +384,15 @@ test("a retained Disk Saver handoff targets the visible existing frame", async (
336
384
  json: async () => ({ acknowledged: true, app: "ComfyUI" })
337
385
  })
338
386
 
387
+ const tab = dom.window.document.getElementById("save-space-tab")
388
+ let clickHandler
389
+ const addEventListener = tab.addEventListener.bind(tab)
390
+ tab.addEventListener = (type, listener, options) => {
391
+ if (type === "click") clickHandler = listener
392
+ addEventListener(type, listener, options)
393
+ }
339
394
  dom.window.eval(script)
340
- dom.window.document.getElementById("save-space-tab").click()
395
+ clickHandler({ isTrusted: true })
341
396
  await waitFor(() => visibleMessages.length === 1)
342
397
 
343
398
  assert.deepEqual(hiddenMessages, [])
@@ -265,6 +265,46 @@ describe("automatic app checks", () => {
265
265
  await close(vault)
266
266
  })
267
267
 
268
+ test("a watcher batch retains a path recreated after deletion", async () => {
269
+ const home = await makeHome()
270
+ const app = "delete-create-app"
271
+ const root = path.join(home, "api", app)
272
+ const temporary = path.join(root, "temporary")
273
+ const recreated = path.join(temporary, "model.bin")
274
+ const automatic = makeAutomatic({
275
+ enabled: true,
276
+ kernel: { homedir: home, platform: "darwin" }
277
+ })
278
+ automatic.observedApps.add(app)
279
+
280
+ automatic.handleWatcherEvents(null, [
281
+ { type: "delete", path: temporary },
282
+ { type: "create", path: recreated }
283
+ ])
284
+
285
+ assert.deepEqual([...automatic.changedPaths.get(app)], [recreated])
286
+ })
287
+
288
+ test("a watcher batch removes a path deleted after creation", async () => {
289
+ const home = await makeHome()
290
+ const app = "create-delete-app"
291
+ const root = path.join(home, "api", app)
292
+ const temporary = path.join(root, "temporary")
293
+ const removed = path.join(temporary, "model.bin")
294
+ const automatic = makeAutomatic({
295
+ enabled: true,
296
+ kernel: { homedir: home, platform: "darwin" }
297
+ })
298
+ automatic.observedApps.add(app)
299
+
300
+ automatic.handleWatcherEvents(null, [
301
+ { type: "create", path: removed },
302
+ { type: "delete", path: temporary }
303
+ ])
304
+
305
+ assert.equal(automatic.changedPaths.has(app), false)
306
+ })
307
+
268
308
  test("disposing automatic checks releases the watcher and pending work", async () => {
269
309
  const home = await makeHome()
270
310
  const appRoot = path.join(home, "api", "disposed-app")
@@ -1428,6 +1468,80 @@ describe("automatic app checks", () => {
1428
1468
  await close(vault)
1429
1469
  })
1430
1470
 
1471
+ test("files that become hardlinks after staging are not duplicates", async () => {
1472
+ const home = await makeHome()
1473
+ const app = "became-hardlinks-app"
1474
+ const appRoot = path.join(home, "api", app)
1475
+ const first = path.join(appRoot, "first.bin")
1476
+ const second = path.join(appRoot, "second.bin")
1477
+ await fs.promises.mkdir(appRoot)
1478
+ await fs.promises.writeFile(first, "same-content")
1479
+ await fs.promises.writeFile(second, "same-content")
1480
+ const before = await Promise.all([
1481
+ fs.promises.lstat(first),
1482
+ fs.promises.lstat(second)
1483
+ ])
1484
+ assert.notEqual(before[0].ino, before[1].ino)
1485
+ const vault = await makeVault(home)
1486
+ vault.automaticScans.candidateThreshold = async () => 1
1487
+ const stage = vault.registry.stageAutomaticPrecheckFiles.bind(
1488
+ vault.registry)
1489
+ vault.registry.stageAutomaticPrecheckFiles = async (...args) => {
1490
+ await stage(...args)
1491
+ await fs.promises.unlink(second)
1492
+ await fs.promises.link(first, second)
1493
+ }
1494
+
1495
+ vault.automaticScans.queueApp(app, [first, second])
1496
+ await waitFor(() => !vault.automaticScans.active &&
1497
+ !vault.automaticScans.entries.has(app))
1498
+
1499
+ const after = await Promise.all([
1500
+ fs.promises.lstat(first),
1501
+ fs.promises.lstat(second)
1502
+ ])
1503
+ assert.equal(after[0].ino, after[1].ino)
1504
+ assert.deepEqual(vault.automaticScans.snapshot().rows, [])
1505
+ await close(vault)
1506
+ })
1507
+
1508
+ test("hardlinks that become independent after staging are duplicates", async () => {
1509
+ const home = await makeHome()
1510
+ const app = "split-hardlinks-app"
1511
+ const appRoot = path.join(home, "api", app)
1512
+ const first = path.join(appRoot, "first.bin")
1513
+ const second = path.join(appRoot, "second.bin")
1514
+ await fs.promises.mkdir(appRoot)
1515
+ await fs.promises.writeFile(first, "same-content")
1516
+ await fs.promises.link(first, second)
1517
+ const before = await Promise.all([
1518
+ fs.promises.lstat(first),
1519
+ fs.promises.lstat(second)
1520
+ ])
1521
+ assert.equal(before[0].ino, before[1].ino)
1522
+ const vault = await makeVault(home)
1523
+ vault.automaticScans.candidateThreshold = async () => 1
1524
+ const stage = vault.registry.stageAutomaticPrecheckFiles.bind(
1525
+ vault.registry)
1526
+ vault.registry.stageAutomaticPrecheckFiles = async (...args) => {
1527
+ await stage(...args)
1528
+ await fs.promises.unlink(second)
1529
+ await fs.promises.writeFile(second, "same-content")
1530
+ }
1531
+
1532
+ vault.automaticScans.queueApp(app, [first, second])
1533
+ await waitFor(() => !vault.automaticScans.active &&
1534
+ vault.automaticScans.entries.get(app)?.state === "result")
1535
+
1536
+ const after = await Promise.all([
1537
+ fs.promises.lstat(first),
1538
+ fs.promises.lstat(second)
1539
+ ])
1540
+ assert.notEqual(after[0].ino, after[1].ino)
1541
+ assert.equal(vault.automaticScans.snapshot().rows[0].state, "result")
1542
+ await close(vault)
1543
+ })
1544
+
1431
1545
  test("stale file rows and known identical inodes are not peers", async () => {
1432
1546
  const home = await makeHome()
1433
1547
  const appRoot = path.join(home, "api", "no-peer-app")