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.
@@ -188,6 +188,11 @@ describe('vault engine (phase 1)', () => {
188
188
  assert.strictEqual(fresh.registry.blobs.get(hash).orphan, false)
189
189
  assert.strictEqual(fresh.registry.links.get(a).hash, hash)
190
190
  assert.strictEqual(fresh.registry.links.get(a).app, 'appA')
191
+ assert.strictEqual(
192
+ fresh.registry.links.has(fresh.storePathFor(hash)),
193
+ false,
194
+ 'the internal vault name is never exposed as a tracked location'
195
+ )
191
196
  })
192
197
 
193
198
  test('item 9: torn events line tolerated; corrupt registry.json triggers rebuild, not failure', async () => {
@@ -224,7 +229,31 @@ describe('vault engine (phase 1)', () => {
224
229
  assert.strictEqual(fs.existsSync(path.resolve(vault.root, 'registry.json.tmp')), false)
225
230
  })
226
231
 
227
- test('rebuild walk prunes noise dirs and skips symlinks', async () => {
232
+ test('manual index repair preserves user decisions, history, and pending review state', async () => {
233
+ const h = await home()
234
+ const vault = await makeVault(h)
235
+ const content = crypto.randomBytes(4096)
236
+ const hash = sha256(content)
237
+ const original = await writeFile(path.resolve(h, 'api', 'appA', 'm.bin'), content)
238
+ const pending = await writeFile(path.resolve(h, 'api', 'appB', 'm.bin'), content)
239
+ const independent = await writeFile(path.resolve(h, 'cache', 'keep.bin'), crypto.randomBytes(2048))
240
+ await vault.adopt(original, hash, { app: 'appA', source_urls: ['https://example.test/model'] })
241
+ vault.registry.duplicates.set(pending, { hash, size: content.length, app: 'appB', discovered: Date.now() })
242
+ vault.registry.excluded.set(independent, { ts: Date.now(), size: 2048 })
243
+ vault.registry.totals.lifetime_bytes_saved = 12345
244
+ vault.registry.lastScan = { ts: 111, dirs: 2, files: 3, bytes_total: 999 }
245
+
246
+ await vault.rebuild([])
247
+
248
+ assert.ok(vault.registry.excluded.has(independent), 'keep-independent decision survives')
249
+ assert.strictEqual(vault.registry.excluded.get(independent).size, 2048)
250
+ assert.strictEqual(vault.registry.totals.lifetime_bytes_saved, 12345)
251
+ assert.deepStrictEqual(vault.registry.lastScan, { ts: 111, dirs: 2, files: 3, bytes_total: 999 })
252
+ assert.ok(vault.registry.duplicates.has(pending), 'pending review item survives when still valid')
253
+ assert.deepStrictEqual(vault.registry.blobs.get(hash).source_urls, ['https://example.test/model'])
254
+ })
255
+
256
+ test('rebuild walks every directory without name heuristics and still skips symlinks', async () => {
228
257
  const h = await home()
229
258
  const vault = await makeVault(h)
230
259
  const big = Buffer.alloc(Vault.SIZE_THRESHOLD, 3)
@@ -234,19 +263,77 @@ describe('vault engine (phase 1)', () => {
234
263
  await fs.promises.unlink(path.resolve(vault.root, 'registry.json')).catch(() => {})
235
264
  const fresh = await makeVault(h)
236
265
  await fresh.rebuild()
237
- // Blob is found (store side), but the env/ path must NOT be re-associated.
238
266
  assert.ok(fresh.registry.blobs.has(hash))
239
- assert.strictEqual(fresh.registry.links.has(inEnv), false)
267
+ assert.strictEqual(fresh.registry.links.has(inEnv), true, 'directory names never hide tracked files')
268
+ })
269
+
270
+ test('hash worker computes sha256 and is reused across a scan batch', async () => {
271
+ const h = await home()
272
+ const vault = await makeVault(h)
273
+ const first = crypto.randomBytes(1024 * 1024)
274
+ const second = crypto.randomBytes(1024 * 1024)
275
+ const firstFile = await writeFile(path.resolve(h, 'api', 'appA', 'x.bin'), first)
276
+ const secondFile = await writeFile(path.resolve(h, 'api', 'appA', 'y.bin'), second)
277
+ const firstResult = await vault.hashFile(firstFile)
278
+ const secondResult = await vault.hashFile(secondFile)
279
+ assert.strictEqual(firstResult.hash, sha256(first))
280
+ assert.strictEqual(firstResult.size, first.length)
281
+ assert.strictEqual(secondResult.hash, sha256(second))
282
+ assert.strictEqual(vault.workerStarts, 1, 'worker startup is amortized across files')
283
+ })
284
+
285
+ test('unsupported conversion leaves the independent copy pending and unregistered', async () => {
286
+ const h = await home()
287
+ const vault = await makeVault(h)
288
+ const content = crypto.randomBytes(4096)
289
+ const hash = sha256(content)
290
+ const original = await writeFile(path.resolve(h, 'api', 'appA', 'm.bin'), content)
291
+ const pending = await writeFile(path.resolve(h, 'api', 'appB', 'm.bin'), content)
292
+ await vault.adopt(original, hash, { app: 'appA' })
293
+ const realLink = fs.promises.link
294
+ fs.promises.link = async (source, target) => {
295
+ if (target === pending + Vault.TMP_SUFFIX) {
296
+ const error = new Error('different volume')
297
+ error.code = 'EXDEV'
298
+ throw error
299
+ }
300
+ return realLink(source, target)
301
+ }
302
+ try {
303
+ const result = await vault.convert(pending, hash, { app: 'appB' })
304
+ assert.strictEqual(result.status, 'unavailable')
305
+ } finally {
306
+ fs.promises.link = realLink
307
+ }
308
+ assert.strictEqual(vault.registry.links.has(pending), false)
309
+ assert.deepStrictEqual(await fs.promises.readFile(pending), content)
240
310
  })
241
311
 
242
- test('hash worker computes correct sha256', async () => {
312
+ test('permission errors during conversion are retryable locks, not unsupported disks', async () => {
243
313
  const h = await home()
244
314
  const vault = await makeVault(h)
245
- const content = crypto.randomBytes(1024 * 1024)
246
- const file = await writeFile(path.resolve(h, 'api', 'appA', 'x.bin'), content)
247
- const result = await vault.hashFile(file)
248
- assert.strictEqual(result.hash, sha256(content))
249
- assert.strictEqual(result.size, content.length)
315
+ const content = crypto.randomBytes(4096)
316
+ const hash = sha256(content)
317
+ const original = await writeFile(path.resolve(h, 'api', 'appA', 'm.bin'), content)
318
+ const pending = await writeFile(path.resolve(h, 'api', 'appB', 'm.bin'), content)
319
+ await vault.adopt(original, hash, { app: 'appA' })
320
+ const realLink = fs.promises.link
321
+ fs.promises.link = async (source, target) => {
322
+ if (target === pending + Vault.TMP_SUFFIX) {
323
+ const error = new Error('file is in use')
324
+ error.code = 'EPERM'
325
+ throw error
326
+ }
327
+ return realLink(source, target)
328
+ }
329
+ try {
330
+ const result = await vault.convert(pending, hash, { app: 'appB' })
331
+ assert.strictEqual(result.status, 'locked')
332
+ } finally {
333
+ fs.promises.link = realLink
334
+ }
335
+ assert.strictEqual(vault.registry.links.has(pending), false)
336
+ assert.deepStrictEqual(await fs.promises.readFile(pending), content)
250
337
  })
251
338
 
252
339
  test('item 11: exFAT volume enters copy mode (macOS loopback; skipped elsewhere)', { skip: process.platform !== 'darwin' }, async (t) => {
@@ -47,6 +47,11 @@ describe('vault manual scan (phase 3)', () => {
47
47
  assert.ok(vault.registry.blobs.has(sha256(content)))
48
48
  assert.ok(result.bytes_total >= content.length + 4, 'folder total includes small files')
49
49
  assert.ok(vault.registry.lastScan && vault.registry.lastScan.bytes_total === result.bytes_total)
50
+ assert.ok(vault.registry.lastScan.duration_ms >= 0, 'scan duration is measured')
51
+ assert.ok(vault.registry.lastScan.walk_duration_ms >= 0, 'walk duration is measured')
52
+ assert.ok(vault.registry.lastScan.hash_duration_ms >= 0, 'hash duration is measured')
53
+ assert.strictEqual(vault.registry.lastScan.hash_total, 1, 'hash work has a determinate total after walking')
54
+ assert.deepStrictEqual(vault.registry.lastScan.source_ids, ['pinokio'], 'scan source set is recorded for later estimates')
50
55
  })
51
56
 
52
57
  test('scans NEVER convert: every byte-identical copy is pending, even in HF caches', async () => {
@@ -123,6 +128,28 @@ describe('vault manual scan (phase 3)', () => {
123
128
  assert.strictEqual(status.last_scan.source_bytes[source.id], content.length, 'external totals remain attributable')
124
129
  })
125
130
 
131
+ test('adding an external source creates one persistent import without starting a scan', async () => {
132
+ const { home, vault } = await makeEnv()
133
+ const external = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-added-external-'))
134
+ homes.push(external)
135
+ const content = crypto.randomBytes(4096)
136
+ const imported = await writeFile(path.resolve(external, 'models', 'added.bin'), content)
137
+
138
+ const added = await vault.addExternalSource(external)
139
+ assert.strictEqual(added.created, true)
140
+ assert.strictEqual(added.source.kind, 'external')
141
+ assert.strictEqual(added.source.parent_id, 'external')
142
+ assert.strictEqual(vault.registry.scanIndex.size, 0, 'adding a source never scans it')
143
+ assert.strictEqual((await fs.promises.lstat(added.source.mount_path)).isSymbolicLink(), true)
144
+
145
+ const repeated = await vault.addExternalSource(external)
146
+ assert.strictEqual(repeated.created, false, 'the same physical folder is not imported twice')
147
+ assert.strictEqual(vault.sources().filter((source) => source.kind === 'external').length, 1)
148
+
149
+ await vault.sweeper.scan()
150
+ assert.ok(vault.registry.scanIndex.has(path.resolve(await fs.promises.realpath(imported))))
151
+ })
152
+
126
153
  test('excluded paths are respected: no adoption, no pending, no re-listing', async () => {
127
154
  const { home, vault } = await makeEnv()
128
155
  const content = crypto.randomBytes(4096)
@@ -140,8 +167,84 @@ describe('vault manual scan (phase 3)', () => {
140
167
  await writeFile(path.resolve(home, 'api', 'appA', 'm.bin'), crypto.randomBytes(4096))
141
168
  await vault.sweeper.scan()
142
169
  const after = vault.sweeper.stats.hashes
170
+ const priorFiles = vault.registry.lastScan.files
143
171
  await vault.sweeper.scan()
144
172
  assert.strictEqual(vault.sweeper.stats.hashes, after)
173
+ assert.strictEqual(vault.sweeper.state.estimated_files, priorFiles, 'same-source rescan uses the prior exact file count')
174
+ })
175
+
176
+ test('file metadata inspection uses bounded concurrency without changing results', async () => {
177
+ const { home, vault } = await makeEnv()
178
+ const dir = path.resolve(home, 'api', 'bulk')
179
+ await Promise.all(Array.from({ length: 48 }, (_, index) =>
180
+ writeFile(path.resolve(dir, `dir-${index}`, 'small.txt'), 'x')))
181
+ const realStat = fs.promises.stat
182
+ let active = 0
183
+ let maximum = 0
184
+ fs.promises.stat = async (target) => {
185
+ if (String(target).startsWith(dir + path.sep)) {
186
+ active += 1
187
+ maximum = Math.max(maximum, active)
188
+ await new Promise((resolve) => setTimeout(resolve, 3))
189
+ try {
190
+ return await realStat(target)
191
+ } finally {
192
+ active -= 1
193
+ }
194
+ }
195
+ return realStat(target)
196
+ }
197
+ try {
198
+ await vault.sweeper.scan()
199
+ } finally {
200
+ fs.promises.stat = realStat
201
+ }
202
+ assert.ok(maximum > 1, `expected concurrent metadata reads, saw ${maximum}`)
203
+ assert.ok(maximum <= vault.statConcurrency, `metadata concurrency exceeded its bound: ${maximum}`)
204
+ assert.strictEqual(vault.registry.lastScan.files >= 48, true)
205
+ })
206
+
207
+ test('multiple names for one inode share one exact hash job', async () => {
208
+ const { home, vault } = await makeEnv()
209
+ const content = crypto.randomBytes(4096)
210
+ const a = await writeFile(path.resolve(home, 'api', 'appA', 'model.bin'), content)
211
+ const b = path.resolve(home, 'api', 'appB', 'model.bin')
212
+ await fs.promises.mkdir(path.dirname(b), { recursive: true })
213
+ await fs.promises.link(a, b)
214
+
215
+ await vault.sweeper.scan()
216
+
217
+ assert.strictEqual(vault.registry.lastScan.hashed, 1, 'physical content was hashed once')
218
+ assert.strictEqual(vault.registry.lastScan.inode_reuses, 1, 'second name reused the inode hash job')
219
+ assert.strictEqual(vault.registry.duplicates.size, 0, 'existing hard links are already shared')
220
+ assert.strictEqual(vault.registry.links.get(a).hash, sha256(content))
221
+ assert.strictEqual(vault.registry.links.get(b).hash, sha256(content))
222
+ })
223
+
224
+ test('a file changed during hashing never publishes the stale digest', async () => {
225
+ const { home, vault } = await makeEnv()
226
+ const original = crypto.randomBytes(4096)
227
+ const oldHash = sha256(original)
228
+ const file = await writeFile(path.resolve(home, 'api', 'appA', 'changing.bin'), original)
229
+ const realHashFile = vault.hashFile.bind(vault)
230
+ vault.hashFile = async (target) => {
231
+ const result = await realHashFile(target)
232
+ await fs.promises.writeFile(target, crypto.randomBytes(original.length))
233
+ const future = new Date(Date.now() + 2000)
234
+ await fs.promises.utimes(target, future, future)
235
+ return result
236
+ }
237
+
238
+ try {
239
+ await vault.sweeper.scan()
240
+ } finally {
241
+ vault.hashFile = realHashFile
242
+ }
243
+
244
+ assert.strictEqual(vault.registry.lastScan.unstable_hashes, 1)
245
+ assert.strictEqual(vault.registry.blobs.has(oldHash), false, 'stale digest was discarded')
246
+ assert.strictEqual(vault.registry.scanIndex.has(file), false, 'unstable path remains unclassified')
247
+ assert.strictEqual((await fs.promises.stat(file)).nlink, 1, 'unstable file was not adopted')
145
248
  })
146
249
 
147
250
  test('conversion tmp files are never candidates', async () => {
@@ -67,6 +67,7 @@ describe('vault dashboard backend (phase 4)', () => {
67
67
  assert.deepStrictEqual(await fs.promises.readFile(b), content)
68
68
  assert.strictEqual((await fs.promises.stat(a)).nlink, 2)
69
69
  assert.ok(vault.registry.excluded.has(b))
70
+ assert.strictEqual(vault.registry.excluded.get(b).size, content.length)
70
71
  assert.strictEqual(fs.existsSync(b + '.pinokio-dedup-tmp'), false)
71
72
 
72
73
  // A scan neither re-adopts nor re-lists the pinned path.
@@ -95,6 +96,7 @@ describe('vault dashboard backend (phase 4)', () => {
95
96
  assert.strictEqual(result.status, 'ignored')
96
97
  assert.strictEqual(vault.registry.duplicates.has(b), false)
97
98
  assert.ok(vault.registry.excluded.has(b))
99
+ assert.strictEqual(vault.registry.excluded.get(b).size, content.length)
98
100
  })
99
101
 
100
102
  test('status(): disk figures, scan metadata, excluded list', async () => {
@@ -107,6 +109,7 @@ describe('vault dashboard backend (phase 4)', () => {
107
109
  assert.strictEqual(status.bytes_on_disk, content.length)
108
110
  assert.ok(status.last_scan && status.last_scan.files > 0)
109
111
  assert.strictEqual(status.excluded.length, 1)
112
+ assert.strictEqual(status.excluded[0].size, content.length)
110
113
  assert.strictEqual(status.blobs.length, 1)
111
114
  assert.deepStrictEqual(status.blobs[0].apps, ['appA'])
112
115
  assert.ok(status.scan && status.scan.active === false)
@@ -149,6 +152,94 @@ describe('vault dashboard backend (phase 4)', () => {
149
152
  assert.doesNotMatch(style[0], /text-overflow:\s*ellipsis/)
150
153
  })
151
154
 
155
+ test('copy-mode tracking never claims physical sharing savings', async () => {
156
+ const { home, vault } = await makeEnv()
157
+ const content = crypto.randomBytes(4096)
158
+ const hash = sha256(content)
159
+ const first = await writeFile(path.resolve(home, 'api', 'appA', 'm.bin'), content)
160
+ const second = await writeFile(path.resolve(home, 'api', 'appB', 'm.bin'), content)
161
+ const firstStat = await fs.promises.stat(first)
162
+ const secondStat = await fs.promises.stat(second)
163
+ vault.registry.addBlob(hash, { size: content.length })
164
+ vault.registry.addLink(first, { hash, app: 'appA', dev: firstStat.dev, ino: firstStat.ino, mode: 'copy' })
165
+ vault.registry.addLink(second, { hash, app: 'appB', dev: secondStat.dev, ino: secondStat.ino, mode: 'copy' })
166
+
167
+ const status = await vault.status()
168
+ assert.strictEqual(status.bytes_on_disk, content.length * 2)
169
+ assert.strictEqual(status.saved_by_sharing, 0)
170
+ })
171
+
172
+ test('repair is advanced, metrics distinguish detected and explicit savings, and refresh retries', async () => {
173
+ const vaultPage = await fs.promises.readFile(path.resolve(__dirname, '..', 'server', 'views', 'vault.ejs'), 'utf8')
174
+ assert.match(vaultPage, /already_shared:\s*"Already shared"/)
175
+ assert.match(vaultPage, /saved_by_vault:\s*"Saved by Vault"/)
176
+ assert.match(vaultPage, /repair_index:\s*"Repair Vault index"/)
177
+ assert.match(vaultPage, /<details class='vault-advanced'/)
178
+ assert.doesNotMatch(vaultPage, /id='btn-rebuild'/)
179
+ const refreshBlock = vaultPage.match(/const refresh = async \(\) => \{[\s\S]*?\n\}/)
180
+ assert.ok(refreshBlock)
181
+ assert.match(refreshBlock[0], /finally/)
182
+ assert.doesNotMatch(vaultPage, /priorScanning/)
183
+ })
184
+
185
+ test('vault uses compact desktop density without truncating duplicate matches', async () => {
186
+ const vaultPage = await fs.promises.readFile(path.resolve(__dirname, '..', 'server', 'views', 'vault.ejs'), 'utf8')
187
+ assert.match(vaultPage, /--vault-control-height:\s*28px/)
188
+ assert.match(vaultPage, /--vault-row-height:\s*44px/)
189
+ assert.match(vaultPage, /--vault-tree-row:\s*26px/)
190
+ assert.match(vaultPage, /--vault-tree-path-row:\s*34px/)
191
+ assert.match(vaultPage, /\.vault-shell \*,?[\s\S]*?box-sizing:\s*border-box/)
192
+ assert.match(vaultPage, /grid-template-columns:\s*248px minmax\(0, 1fr\)/)
193
+ assert.match(vaultPage, /\.vault-toolbar \{[\s\S]*?min-height:\s*44px/)
194
+ assert.match(vaultPage, /\.vault-match-path \{[\s\S]*?overflow-wrap:\s*anywhere/)
195
+ assert.doesNotMatch(vaultPage, /<header class='task-shell-header'>/)
196
+ assert.doesNotMatch(vaultPage, /id='vault-(?:title|subtitle)'/)
197
+ assert.match(vaultPage, /\.vault-overview \{[\s\S]*?padding:\s*7px var\(--vault-inline\)/)
198
+ assert.match(vaultPage, /\.vault-metric \{[\s\S]*?border:\s*0;[\s\S]*?border-left:\s*1px solid var\(--task-border\)/)
199
+ assert.match(vaultPage, /button\.vault-metric \{[\s\S]*?font:\s*inherit/)
200
+ assert.match(vaultPage, /id='btn-add-source'/)
201
+ assert.match(vaultPage, /<script src="\/Socket\.js"><\/script>/)
202
+ assert.match(vaultPage, /action:\s*"add_source"/)
203
+ assert.match(vaultPage, /identical_contents_at:\s*"Identical contents at"/)
204
+ assert.doesNotMatch(vaultPage, /Matching locations/)
205
+ assert.match(vaultPage, /scan_waiting:\s*"Waiting for scan results"/)
206
+ assert.match(vaultPage, /view === "all" && !scanning \? `<button class="vault-button" type="button" id="btn-empty-scan"/)
207
+ assert.match(vaultPage, /class="vault-progress-track"/)
208
+ assert.match(vaultPage, /role="progressbar"/)
209
+ assert.match(vaultPage, /scan\.walk_duration_ms == null/)
210
+ assert.match(vaultPage, /const hashTotal = scan\.hash_total/)
211
+ assert.match(vaultPage, /const hasEstimate = Number\.isFinite\(scan\.estimated_files\)/)
212
+ assert.match(vaultPage, /scan_estimate_help:\s*"Estimated from your last completed scan"/)
213
+ assert.match(vaultPage, /\$\{estimated \? "~" : ""\}\$\{progressValue\}%/)
214
+ assert.match(vaultPage, /\.vault-progress-bar\.determinate \{[\s\S]*?transform:\s*scaleX/)
215
+ assert.match(vaultPage, /\.vault-progress-bar\.indeterminate \{[\s\S]*?transform:\s*scaleX\(1\)[\s\S]*?animation:\s*vault-progress-pulse/)
216
+ assert.doesNotMatch(vaultPage, /vault-progress-sweep/)
217
+ assert.match(vaultPage, /body\.vault-page \.task-container \{[\s\S]*?overflow:\s*hidden/)
218
+ assert.match(vaultPage, /\.vault-explorer \{[\s\S]*?flex:\s*1 1 auto[\s\S]*?overflow:\s*hidden/)
219
+ assert.match(vaultPage, /\.vault-rail \{[\s\S]*?overflow-y:\s*auto[\s\S]*?overscroll-behavior:\s*contain/)
220
+ assert.match(vaultPage, /\.vault-pane \{[\s\S]*?grid-template-rows:\s*auto minmax\(0, 1fr\) auto/)
221
+ assert.doesNotMatch(vaultPage, /vault-pane-head/)
222
+ assert.match(vaultPage, /const toolbarSummary = \(visibleItems\) =>/)
223
+ assert.match(vaultPage, /id="vault-toolbar-summary"/)
224
+ assert.match(vaultPage, /\.vault-table-wrap \{[\s\S]*?overflow:\s*auto[\s\S]*?overscroll-behavior:\s*contain/)
225
+ assert.match(vaultPage, /#vault-locations \{[\s\S]*?flex-direction:\s*column[\s\S]*?justify-content:\s*flex-start[\s\S]*?gap:\s*0/)
226
+ assert.match(vaultPage, /\.vault-source-line\.depth-2 \{ padding-left:\s*24px; \}/)
227
+ assert.match(vaultPage, /vault-source-line depth-\$\{Math\.min\(depth, 2\)\} \$\{pathText \? "has-path" : ""\}/)
228
+ })
229
+
230
+ test('vault route provisions the dev requirements needed by its folder picker', async () => {
231
+ const serverSource = await fs.promises.readFile(path.resolve(__dirname, '..', 'server', 'index.js'), 'utf8')
232
+ const routeStart = serverSource.indexOf('this.app.get("/vault"')
233
+ const routeEnd = serverSource.indexOf('this.app.post("/vault/action"', routeStart)
234
+ assert.ok(routeStart >= 0 && routeEnd > routeStart, 'vault route must be present')
235
+ const route = serverSource.slice(routeStart, routeEnd)
236
+ assert.match(route, /this\.kernel\.bin\.check\(/)
237
+ assert.match(route, /this\.kernel\.bin\.preset\("dev"\)/)
238
+ assert.match(route, /requirements_pending \|\| install_required/)
239
+ assert.match(route, /\/setup\/dev\?callback=\$\{encodeURIComponent\(req\.originalUrl\)\}/)
240
+ assert.ok(route.indexOf('res.redirect') < route.indexOf('res.render("vault"'), 'requirements redirect must happen before rendering Vault')
241
+ })
242
+
152
243
  test('deduplicate batches share a batch id and are undoable as one', async () => {
153
244
  const { home, vault } = await makeEnv()
154
245
  const c1 = crypto.randomBytes(4096)
@@ -194,6 +285,23 @@ describe('vault dashboard backend (phase 4)', () => {
194
285
  assert.ok(vault.registry.duplicates.has(cacheCopy), 'other non-app scope remains pending')
195
286
  })
196
287
 
288
+ test('unavailable conversions remain pending for review', async () => {
289
+ const { home, vault } = await makeEnv()
290
+ const content = crypto.randomBytes(4096)
291
+ const hash = sha256(content)
292
+ const original = await writeFile(path.resolve(home, 'api', 'appA', 'm.bin'), content)
293
+ const pending = await writeFile(path.resolve(home, 'api', 'appB', 'm.bin'), content)
294
+ await vault.adopt(original, hash, { app: 'appA' })
295
+ vault.registry.duplicates.set(pending, { hash, size: content.length, app: 'appB' })
296
+ const realConvert = vault.convert.bind(vault)
297
+ vault.convert = async () => ({ status: 'unavailable' })
298
+ const result = await vault.sweeper.convertPending('appB')
299
+ vault.convert = realConvert
300
+ assert.strictEqual(result.unavailable, 1)
301
+ assert.ok(vault.registry.duplicates.has(pending))
302
+ assert.strictEqual(vault.registry.links.has(pending), false)
303
+ })
304
+
197
305
  test('status separates app, Pinokio folder, and external source metadata', async (t) => {
198
306
  const { home, vault } = await makeEnv()
199
307
  const external = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-ui-external-'))