diff --git a/app/electron/agent/engine/library.test.ts b/app/electron/agent/engine/library.test.ts index 2eb8b8902a..46c0e6b743 100644 --- a/app/electron/agent/engine/library.test.ts +++ b/app/electron/agent/engine/library.test.ts @@ -155,6 +155,43 @@ try { const large = await lowMemory.scan() assert.equal(large.models.every((model) => !model.loadable), true) + + // AppleDouble companions (._file) are resource forks, never checkpoint assets. + const adModels = path.join(root, 'AppleDouble Models') + const adSharded = path.join(adModels, 'sharded-checkpoint') + await fs.mkdir(adSharded, { recursive: true }) + await fs.writeFile(path.join(adSharded, 'config.json'), JSON.stringify({ model_type: 'qwen3_5', quantization: { bits: 4, group_size: 64 } })) + await fs.writeFile(path.join(adSharded, 'tokenizer.json'), JSON.stringify({ model: { type: 'BPE', vocab: {} } })) + await fs.writeFile(path.join(adSharded, 'model-00001-of-00002.safetensors'), weights()) + await fs.writeFile(path.join(adSharded, 'model-00002-of-00002.safetensors'), weights()) + // Stray invalid companions beside the real shards: not counted as shards, weights, or metadata. + await fs.writeFile(path.join(adSharded, '._model-00001-of-00002.safetensors'), Buffer.alloc(4096, 0xff)) + await fs.writeFile(path.join(adSharded, '._model-00002-of-00002.safetensors'), Buffer.alloc(4096, 0xff)) + const adLib = library({ appHome: path.join(root, 'appledouble-app') }) + const adFound = await adLib.addFolder(adModels) + const adModel = adFound.models.find((model) => model.path === adSharded)! + assert.equal(adModel.complete, true, adModel.reason ?? '') + assert.equal(adModel.loadable, true) + assert.equal(adModel.weightsBytes, 2 * weights().length, 'companions add nothing to the weight total') + assert.equal(adModel.memoryFloorBytes, 2 * weights().length + 2 * 1024 ** 3) + assert.equal((await adLib.resolve(adModel.id)).path, adSharded) + + // A stray companion in the selected parent must not stop discovery of the nested checkpoint below it. + const nested = path.join(adModels, 'nested', 'real-checkpoint') + await fs.mkdir(nested, { recursive: true }) + await checkpoint(nested) + await fs.writeFile(path.join(adModels, '._old.safetensors'), Buffer.alloc(4096, 0xff)) + const rediscovered = await library({ appHome: path.join(root, 'appledouble-nested-app') }).addFolder(adModels) + assert.equal(rediscovered.models.find((model) => model.path === nested)?.loadable, true, 'a stray parent companion does not block nested discovery') + assert.equal(rediscovered.models.some((model) => model.path === adModels), false, 'the parent is not mistaken for a checkpoint') + + // A real corrupt shard still fails the checkpoint. + const corruptShard = path.join(adModels, 'corrupt-shard') + await checkpoint(corruptShard) + await fs.truncate(path.join(corruptShard, 'model.safetensors'), 12) + const corruptShardFound = await library({ appHome: path.join(root, 'appledouble-corrupt-app') }).addFolder(adModels) + assert.match(corruptShardFound.models.find((model) => model.path === corruptShard)!.reason!, /safetensors/) + const validRegistry = await fs.readFile(lib.file, 'utf8') await fs.writeFile(lib.file, '{') await assert.rejects(() => library().snapshot(), /library is invalid/) diff --git a/app/electron/agent/engine/library.ts b/app/electron/agent/engine/library.ts index 37b12691b1..5b06cc5809 100644 --- a/app/electron/agent/engine/library.ts +++ b/app/electron/agent/engine/library.ts @@ -217,7 +217,9 @@ export class ModelLibrary { } finally { await handle.close() } } - private async inspect(root: Root, directory: string, names: string[], caps: EngineCapabilities, budget: ScanBudget, repo: string | null = null, revision: string | null = null, blobRoot: string | null = null): Promise<{ model: EngineLibraryModel; fingerprint: string }> { + private async inspect(root: Root, directory: string, rawNames: string[], caps: EngineCapabilities, budget: ScanBudget, repo: string | null = null, revision: string | null = null, blobRoot: string | null = null): Promise<{ model: EngineLibraryModel; fingerprint: string }> { + // AppleDouble companions (._file) hold resource forks, never checkpoint assets, so drop them before inspection. + const names = rawNames.filter((name) => !name.startsWith('._')) const model: EngineLibraryModel = { id: `local/${digest(directory)}`, repo, title: repo ?? path.basename(directory), path: directory, source: blobRoot ? 'huggingface' : 'folder', folderId: root.id, revision, modelType: null, format: null, complete: false, compatible: false, loadable: false, reason: null, weightsBytes: null, memoryFloorBytes: null, fitsMemory: null } let fingerprint = directory try { @@ -298,7 +300,8 @@ export class ModelLibrary { visited.add(directory) if (await fs.realpath(directory) !== directory || !(await fs.lstat(directory)).isDirectory()) throw new Error('A model search folder is missing or has been redirected; select it again.') const entries = await this.list(directory, budget) - if (entries.some((entry) => entry.name === 'config.json' || /\.(safetensors|gguf|bin|pt|pth)$/i.test(entry.name))) { await collect(root, directory, entries); return } + const assets = entries.filter((entry) => !entry.name.startsWith('._')) + if (assets.some((entry) => entry.name === 'config.json' || /\.(safetensors|gguf|bin|pt|pth)$/i.test(entry.name))) { await collect(root, directory, assets); return } for (const entry of entries) { this.check(budget) if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name.startsWith('.') || ['blobs', 'node_modules', 'venv', '__pycache__'].includes(entry.name)) continue