From c8c0e53865ab5f9ee8ca5023dfd2fedf02067350 Mon Sep 17 00:00:00 2001 From: jaeone94 <89377375+jaeone94@users.noreply.github.com> Date: Fri, 8 May 2026 10:56:19 +0900 Subject: [PATCH] fix: remove asset hash verification (#12061) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR removes the `/api/assets/hash/:hash` verification path from missing media/model detection. I decided to remove this path for two reasons: 1. The Cloud runtime implementation and the OpenAPI/generated FE contract do not agree on the hash format that this endpoint represents. In current Cloud data, the dominant asset_hash shape is <64-hex>. (for example, abc123....png), while the OpenAPI/generated FE contract expects a blake3: style value. That makes this path either dead code that should never be reached, or, when it is reached, a request that always returns 400 and only adds unnecessary noise. 2. Even if the format is reconciled, the Cloud implementation is a global deduplication-oriented lookup, not an access-aware check for whether the current workflow can use a resource. In theory, it can return success for another user's personal asset, so it is the wrong primitive for missing asset detection. Because of that, this PR makes the existing asset list/store based checks the primary verification path and removes the hash-specific helpers, service method, and tests. ## Known follow-ups These are known issues that are intentionally not solved in this PR: 1. Published assets are not exposed through `/api/assets?...include_public=true`. This is a backend issue and can still cause mismatch between missing-asset detection and resources that preview/run successfully. 2. Shared workflow import has an ordering issue. The API contract issue is being hotfixed separately under FE-603. 3. Annotated media paths can still be detected incorrectly. I will prepare follow-up PRs for these, starting with the annotated media path issue because that is the most critical frontend-side gap. ## Validation - `pnpm exec vitest run src/platform/assets/services/assetService.test.ts src/platform/missingMedia/missingMediaScan.test.ts src/platform/missingModel/missingModelScan.test.ts` - `pnpm lint:unstaged` - `pnpm typecheck` - `pnpm knip` ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-12061-fix-remove-asset-hash-verification-3596d73d365081a088f8dfc874724c1d) by [Unito](https://www.unito.io) --- .../assets/services/assetService.test.ts | 61 +----- src/platform/assets/services/assetService.ts | 42 ---- .../missingMedia/missingMediaScan.test.ts | 189 +++++------------ src/platform/missingMedia/missingMediaScan.ts | 110 ++-------- .../missingModel/missingModelScan.test.ts | 194 ++++++------------ src/platform/missingModel/missingModelScan.ts | 73 +------ 6 files changed, 139 insertions(+), 530 deletions(-) diff --git a/src/platform/assets/services/assetService.test.ts b/src/platform/assets/services/assetService.test.ts index e705419945..b073bcce71 100644 --- a/src/platform/assets/services/assetService.test.ts +++ b/src/platform/assets/services/assetService.test.ts @@ -6,9 +6,7 @@ import type { } from '@/platform/assets/schemas/assetSchema' import { MISSING_TAG, - assetService, - isBlake3AssetHash, - toBlake3AssetHash + assetService } from '@/platform/assets/services/assetService' import { api } from '@/scripts/api' @@ -52,10 +50,6 @@ vi.mock('@/i18n', () => ({ const fetchApiMock = vi.mocked(api.fetchApi) -const validBlake3Hash = - '1111111111111111111111111111111111111111111111111111111111111111' -const validBlake3AssetHash = `blake3:${validBlake3Hash}` - type AssetListResponseOptions = { hasMore?: AssetResponse['has_more'] total?: AssetResponse['total'] @@ -204,25 +198,6 @@ describe(assetService.getAssetMetadata, () => { }) }) -describe(isBlake3AssetHash, () => { - it('accepts only prefixed 64-character blake3 hashes', () => { - expect(isBlake3AssetHash(validBlake3AssetHash)).toBe(true) - expect(isBlake3AssetHash('BLAKE3:' + validBlake3Hash.toUpperCase())).toBe( - true - ) - expect(isBlake3AssetHash('blake3:abc')).toBe(false) - expect(isBlake3AssetHash(validBlake3Hash)).toBe(false) - }) -}) - -describe(toBlake3AssetHash, () => { - it('normalizes 64-character blake3 hex values to asset hashes', () => { - expect(toBlake3AssetHash(validBlake3Hash)).toBe(validBlake3AssetHash) - expect(toBlake3AssetHash('abc')).toBeNull() - expect(toBlake3AssetHash(undefined)).toBeNull() - }) -}) - describe(assetService.uploadAssetFromUrl, () => { beforeEach(() => { vi.clearAllMocks() @@ -884,37 +859,3 @@ describe(assetService.getInputAssetsIncludingPublic, () => { expect(fetchApiMock).toHaveBeenCalledTimes(2) }) }) - -describe(assetService.checkAssetHash, () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it.each([ - [200, 'exists'], - [404, 'missing'], - [400, 'invalid'] - ] as const)('maps %s responses to %s', async (status, expected) => { - const hash = - 'blake3:1111111111111111111111111111111111111111111111111111111111111111' - fetchApiMock.mockResolvedValueOnce(buildResponse(null, { status })) - - await expect(assetService.checkAssetHash(hash)).resolves.toBe(expected) - - expect(fetchApiMock).toHaveBeenCalledWith( - `/assets/hash/${encodeURIComponent(hash)}`, - { - method: 'HEAD', - signal: undefined - } - ) - }) - - it('throws for unexpected responses', async () => { - fetchApiMock.mockResolvedValueOnce(buildResponse(null, { status: 500 })) - - await expect(assetService.checkAssetHash('blake3:abc')).rejects.toThrow( - 'Unexpected asset hash check status: 500' - ) - }) -}) diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts index 7c2bc4bd8d..135303778d 100644 --- a/src/platform/assets/services/assetService.ts +++ b/src/platform/assets/services/assetService.ts @@ -184,26 +184,10 @@ export const MODELS_TAG = 'models' export const MISSING_TAG = 'missing' const DEFAULT_EXCLUDED_ASSET_TAGS = [MISSING_TAG] -/** Result of a HEAD lookup against an exact asset hash. */ -export type AssetHashStatus = 'exists' | 'missing' | 'invalid' - -const BLAKE3_ASSET_HASH_PATTERN = /^blake3:[0-9a-f]{64}$/i -const BLAKE3_HEX_PATTERN = /^[0-9a-f]{64}$/i const uploadedAssetResponseSchema = assetItemSchema.extend({ created_new: z.boolean() }) -/** Returns true for a prefixed BLAKE3 asset hash: `blake3:<64 hex>`. */ -export function isBlake3AssetHash(value: string): boolean { - return BLAKE3_ASSET_HASH_PATTERN.test(value) -} - -/** Converts a raw 64-character BLAKE3 hex digest into an asset hash. */ -export function toBlake3AssetHash(hash: string | undefined): string | null { - if (!hash || !BLAKE3_HEX_PATTERN.test(hash)) return null - return `blake3:${hash}` -} - function createAbortError(): DOMException { return new DOMException('Aborted', 'AbortError') } @@ -591,31 +575,6 @@ function createAssetService() { return await withCallerAbort(request, signal) } - /** - * Checks whether an asset exists for an exact asset hash. - * - * Uses the HEAD /assets/hash/{hash} endpoint and maps status-only responses: - * 200 -> exists, 404 -> missing, and 400 -> invalid hash format. - */ - async function checkAssetHash( - assetHash: string, - signal?: AbortSignal - ): Promise { - const response = await api.fetchApi( - `${ASSETS_ENDPOINT}/hash/${encodeURIComponent(assetHash)}`, - { - method: 'HEAD', - signal - } - ) - - if (response.status === 200) return 'exists' - if (response.status === 404) return 'missing' - if (response.status === 400) return 'invalid' - - throw new Error(`Unexpected asset hash check status: ${response.status}`) - } - /** * Deletes an asset by ID * Only available in cloud environment @@ -979,7 +938,6 @@ function createAssetService() { getAllAssetsByTag, getInputAssetsIncludingPublic, invalidateInputAssetsIncludingPublic, - checkAssetHash, deleteAsset, updateAsset, addAssetTags, diff --git a/src/platform/missingMedia/missingMediaScan.test.ts b/src/platform/missingMedia/missingMediaScan.test.ts index 8e77aae88c..275e2450d9 100644 --- a/src/platform/missingMedia/missingMediaScan.test.ts +++ b/src/platform/missingMedia/missingMediaScan.test.ts @@ -15,12 +15,9 @@ import { } from './missingMediaScan' import type { MissingMediaCandidate } from './types' -const { mockCheckAssetHash, mockGetInputAssetsIncludingPublic } = vi.hoisted( - () => ({ - mockCheckAssetHash: vi.fn(), - mockGetInputAssetsIncludingPublic: vi.fn() - }) -) +const { mockGetInputAssetsIncludingPublic } = vi.hoisted(() => ({ + mockGetInputAssetsIncludingPublic: vi.fn() +})) vi.mock('@/utils/graphTraversalUtil', () => ({ collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes, @@ -39,7 +36,6 @@ vi.mock('@/platform/assets/services/assetService', async () => { ...actual, assetService: { ...actual.assetService, - checkAssetHash: mockCheckAssetHash, getInputAssetsIncludingPublic: mockGetInputAssetsIncludingPublic } } @@ -273,36 +269,64 @@ describe('verifyCloudMediaCandidates', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckAssetHash.mockResolvedValue('missing') mockGetInputAssetsIncludingPublic.mockResolvedValue([]) }) - it('marks candidates missing when the asset hash is not found', async () => { + it('matches candidates by available input asset name or hash', async () => { const candidates = [ - makeCandidate('1', missingHash, { isMissing: undefined }), - makeCandidate('2', existingHash, { isMissing: undefined }) + makeCandidate('1', 'photo.png', { isMissing: undefined }), + makeCandidate('2', existingHash, { isMissing: undefined }), + makeCandidate('3', missingHash, { isMissing: undefined }) ] + const fetchInputAssets = vi.fn(async () => [ + makeAsset('photo.png', existingHash) + ]) - const checkAssetHash = vi.fn(async (assetHash: string) => - assetHash === existingHash ? ('exists' as const) : ('missing' as const) - ) + await verifyCloudMediaCandidates(candidates, undefined, fetchInputAssets) - await verifyCloudMediaCandidates(candidates, undefined, checkAssetHash) - - expect(candidates[0].isMissing).toBe(true) + expect(candidates[0].isMissing).toBe(false) expect(candidates[1].isMissing).toBe(false) + expect(candidates[2].isMissing).toBe(true) + expect(fetchInputAssets).toHaveBeenCalledOnce() }) - it('uses assetService.checkAssetHash by default', async () => { + it('matches asset names when asset_hash is null', async () => { + const candidates = [ + makeCandidate('1', 'legacy-photo.png', { isMissing: undefined }), + makeCandidate('2', 'missing-photo.png', { isMissing: undefined }) + ] + const fetchInputAssets = vi.fn(async () => [ + makeAsset('legacy-photo.png', null) + ]) + + await verifyCloudMediaCandidates(candidates, undefined, fetchInputAssets) + + expect(candidates[0].isMissing).toBe(false) + expect(candidates[1].isMissing).toBe(true) + }) + + it('marks pending candidates missing when no input assets are available', async () => { + const candidates = [ + makeCandidate('1', 'photo.png', { isMissing: undefined }) + ] + + await verifyCloudMediaCandidates(candidates, undefined, async () => []) + + expect(candidates[0].isMissing).toBe(true) + }) + + it('uses public input assets by default', async () => { const candidates = [ makeCandidate('1', existingHash, { isMissing: undefined }) ] - mockCheckAssetHash.mockResolvedValue('exists') + mockGetInputAssetsIncludingPublic.mockResolvedValue([ + makeAsset('stored-photo.png', existingHash) + ]) await verifyCloudMediaCandidates(candidates) expect(candidates[0].isMissing).toBe(false) - expect(mockCheckAssetHash).toHaveBeenCalledWith(existingHash, undefined) + expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(undefined) }) it('respects abort signal before execution', async () => { @@ -316,23 +340,23 @@ describe('verifyCloudMediaCandidates', () => { await verifyCloudMediaCandidates(candidates, controller.signal) expect(candidates[0].isMissing).toBeUndefined() - expect(mockCheckAssetHash).not.toHaveBeenCalled() + expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled() }) - it('respects abort signal after hash verification', async () => { + it('respects abort signal after loading input assets', async () => { const controller = new AbortController() const candidates = [ makeCandidate('1', existingHash, { isMissing: undefined }) ] - const checkAssetHash = vi.fn(async () => { + const fetchInputAssets = vi.fn(async () => { controller.abort() - return 'exists' as const + return [makeAsset('stored-photo.png', existingHash)] }) await verifyCloudMediaCandidates( candidates, controller.signal, - checkAssetHash + fetchInputAssets ) expect(candidates[0].isMissing).toBeUndefined() @@ -344,7 +368,7 @@ describe('verifyCloudMediaCandidates', () => { await verifyCloudMediaCandidates(candidates) expect(candidates[0].isMissing).toBe(true) - expect(mockCheckAssetHash).not.toHaveBeenCalled() + expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled() }) it('skips candidates already resolved as false', async () => { @@ -353,7 +377,7 @@ describe('verifyCloudMediaCandidates', () => { await verifyCloudMediaCandidates(candidates) expect(candidates[0].isMissing).toBe(false) - expect(mockCheckAssetHash).not.toHaveBeenCalled() + expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled() }) it('skips entirely when no pending candidates', async () => { @@ -361,32 +385,10 @@ describe('verifyCloudMediaCandidates', () => { await verifyCloudMediaCandidates(candidates) - expect(mockCheckAssetHash).not.toHaveBeenCalled() + expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled() }) - it('falls back to input assets for non-blake3 candidate names', async () => { - const candidates = [ - makeCandidate('1', 'photo.png', { isMissing: undefined }), - makeCandidate('2', 'missing.png', { isMissing: undefined }) - ] - const fetchInputAssets = vi.fn(async () => [ - makeAsset('stored-photo.png', 'photo.png') - ]) - - await verifyCloudMediaCandidates( - candidates, - undefined, - undefined, - fetchInputAssets - ) - - expect(mockCheckAssetHash).not.toHaveBeenCalled() - expect(fetchInputAssets).toHaveBeenCalledOnce() - expect(candidates[0].isMissing).toBe(false) - expect(candidates[1].isMissing).toBe(true) - }) - - it('uses public input assets for default legacy fallback', async () => { + it('loads public input assets for default verification', async () => { const candidates = [ makeCandidate('1', 'public-photo.png', { isMissing: undefined }) ] @@ -402,7 +404,7 @@ describe('verifyCloudMediaCandidates', () => { expect(candidates[0].isMissing).toBe(false) }) - it('silences aborts while loading legacy fallback input assets', async () => { + it('silences aborts while loading input assets', async () => { const abortError = new Error('aborted') abortError.name = 'AbortError' const controller = new AbortController() @@ -418,7 +420,6 @@ describe('verifyCloudMediaCandidates', () => { verifyCloudMediaCandidates( candidates, controller.signal, - undefined, fetchInputAssets ) ).resolves.toBeUndefined() @@ -426,7 +427,7 @@ describe('verifyCloudMediaCandidates', () => { expect(candidates[0].isMissing).toBeUndefined() }) - it('silences aborts from the default legacy fallback input asset store path', async () => { + it('forwards the signal to the default input asset fetcher and silences aborts', async () => { const abortError = new Error('aborted') abortError.name = 'AbortError' const controller = new AbortController() @@ -447,84 +448,4 @@ describe('verifyCloudMediaCandidates', () => { ) expect(candidates[0].isMissing).toBeUndefined() }) - - it('falls back to input assets when the hash endpoint returns 400', async () => { - const candidates = [ - makeCandidate('1', existingHash, { isMissing: undefined }) - ] - mockCheckAssetHash.mockResolvedValue('invalid') - const fetchInputAssets = vi.fn(async () => [ - makeAsset('photo.png', existingHash) - ]) - - await verifyCloudMediaCandidates( - candidates, - undefined, - undefined, - fetchInputAssets - ) - - expect(mockCheckAssetHash).toHaveBeenCalledWith(existingHash, undefined) - expect(fetchInputAssets).toHaveBeenCalledOnce() - expect(candidates[0].isMissing).toBe(false) - }) - - it('falls back to input assets when hash verification fails', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const candidates = [ - makeCandidate('1', existingHash, { isMissing: undefined }) - ] - const checkAssetHash = vi.fn(async () => { - throw new Error('network failed') - }) - const fetchInputAssets = vi.fn(async () => [ - makeAsset('photo.png', existingHash) - ]) - - await verifyCloudMediaCandidates( - candidates, - undefined, - checkAssetHash, - fetchInputAssets - ) - - expect(fetchInputAssets).toHaveBeenCalledOnce() - expect(candidates[0].isMissing).toBe(false) - expect(warn).toHaveBeenCalledOnce() - warn.mockRestore() - }) - - it('does not call the hash endpoint for malformed blake3-looking values', async () => { - const malformedHash = 'blake3:abc' - const candidates = [ - makeCandidate('1', malformedHash, { isMissing: undefined }) - ] - const fetchInputAssets = vi.fn(async () => [ - makeAsset('legacy.png', malformedHash) - ]) - - await verifyCloudMediaCandidates( - candidates, - undefined, - undefined, - fetchInputAssets - ) - - expect(mockCheckAssetHash).not.toHaveBeenCalled() - expect(fetchInputAssets).toHaveBeenCalledOnce() - expect(candidates[0].isMissing).toBe(false) - }) - - it('deduplicates checks for repeated candidate names', async () => { - const candidates = [ - makeCandidate('1', missingHash, { isMissing: undefined }), - makeCandidate('2', missingHash, { isMissing: undefined }) - ] - - await verifyCloudMediaCandidates(candidates) - - expect(mockCheckAssetHash).toHaveBeenCalledOnce() - expect(candidates[0].isMissing).toBe(true) - expect(candidates[1].isMissing).toBe(true) - }) }) diff --git a/src/platform/missingMedia/missingMediaScan.ts b/src/platform/missingMedia/missingMediaScan.ts index 5050996e06..b8a2257c64 100644 --- a/src/platform/missingMedia/missingMediaScan.ts +++ b/src/platform/missingMedia/missingMediaScan.ts @@ -19,11 +19,8 @@ import { import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums' import { resolveComboValues } from '@/utils/litegraphUtil' import type { AssetItem } from '@/platform/assets/schemas/assetSchema' -import type { AssetHashStatus } from '@/platform/assets/services/assetService' -import { - assetService, - isBlake3AssetHash -} from '@/platform/assets/services/assetService' +import { assetService } from '@/platform/assets/services/assetService' +import { isAbortError } from '@/utils/typeGuardUtil' /** Map of node types to their media widget name and media type. */ const MEDIA_NODE_WIDGETS: Record< @@ -112,78 +109,19 @@ export function scanNodeMediaCandidates( return candidates } -type AssetHashVerifier = ( - assetHash: string, - signal?: AbortSignal -) => Promise - type InputAssetFetcher = (signal?: AbortSignal) => Promise -function groupCandidatesForHashLookup(candidates: MissingMediaCandidate[]): { - candidatesByHash: Map - legacyCandidates: MissingMediaCandidate[] -} { - const candidatesByHash = new Map() - const legacyCandidates: MissingMediaCandidate[] = [] - - for (const candidate of candidates) { - if (!isBlake3AssetHash(candidate.name)) { - legacyCandidates.push(candidate) - continue - } - - const hashCandidates = candidatesByHash.get(candidate.name) - if (hashCandidates) hashCandidates.push(candidate) - else candidatesByHash.set(candidate.name, [candidate]) - } - - return { candidatesByHash, legacyCandidates } -} - -async function verifyCandidatesByHash( - candidatesByHash: Map, - legacyCandidates: MissingMediaCandidate[], - signal: AbortSignal | undefined, - checkAssetHash: AssetHashVerifier -): Promise { - await Promise.all( - Array.from(candidatesByHash, async ([assetHash, hashCandidates]) => { - if (signal?.aborted) return - - let status: AssetHashStatus - try { - status = await checkAssetHash(assetHash, signal) - if (signal?.aborted) return - } catch (err) { - if (signal?.aborted || isAbortError(err)) return - console.warn( - '[Missing Media Pipeline] Failed to verify asset hash:', - err - ) - legacyCandidates.push(...hashCandidates) - return - } - - if (status === 'invalid') { - legacyCandidates.push(...hashCandidates) - return - } - - for (const candidate of hashCandidates) { - candidate.isMissing = status === 'missing' - } - }) - ) -} - /** - * Verify cloud media candidates by probing the asset hash endpoint first. - * Invalid hash values fall back to the legacy input asset list check. + * Verify cloud media candidates against input assets available to the user, + * including public assets returned by the asset list API. + * + * A candidate's `name` may be either a filename or an opaque asset hash. + * Cloud-side `asset_hash` is not guaranteed to follow a single shape, so we + * match against the union of `asset.name` and `asset.asset_hash`. */ export async function verifyCloudMediaCandidates( candidates: MissingMediaCandidate[], signal?: AbortSignal, - checkAssetHash: AssetHashVerifier = assetService.checkAssetHash, fetchInputAssets: InputAssetFetcher = fetchMissingInputAssets ): Promise { if (signal?.aborted) return @@ -191,17 +129,6 @@ export async function verifyCloudMediaCandidates( const pending = candidates.filter((c) => c.isMissing === undefined) if (pending.length === 0) return - const { candidatesByHash, legacyCandidates } = - groupCandidatesForHashLookup(pending) - await verifyCandidatesByHash( - candidatesByHash, - legacyCandidates, - signal, - checkAssetHash - ) - - if (signal?.aborted || legacyCandidates.length === 0) return - let inputAssets: AssetItem[] try { inputAssets = await fetchInputAssets(signal) @@ -212,12 +139,14 @@ export async function verifyCloudMediaCandidates( if (signal?.aborted) return - const assetHashes = new Set( - inputAssets.map((a) => a.asset_hash).filter((h): h is string => !!h) - ) + const assetIdentifiers = new Set() + for (const asset of inputAssets) { + if (asset.asset_hash) assetIdentifiers.add(asset.asset_hash) + if (asset.name) assetIdentifiers.add(asset.name) + } - for (const candidate of legacyCandidates) { - candidate.isMissing = !assetHashes.has(candidate.name) + for (const candidate of pending) { + candidate.isMissing = !assetIdentifiers.has(candidate.name) } } @@ -227,15 +156,6 @@ async function fetchMissingInputAssets( return await assetService.getInputAssetsIncludingPublic(signal) } -function isAbortError(err: unknown): boolean { - return ( - typeof err === 'object' && - err !== null && - 'name' in err && - err.name === 'AbortError' - ) -} - /** Group confirmed-missing candidates by file name into view models. */ export function groupCandidatesByName( candidates: MissingMediaCandidate[] diff --git a/src/platform/missingModel/missingModelScan.test.ts b/src/platform/missingModel/missingModelScan.test.ts index 05326f8bb0..e365718d28 100644 --- a/src/platform/missingModel/missingModelScan.test.ts +++ b/src/platform/missingModel/missingModelScan.test.ts @@ -19,11 +19,6 @@ import activeSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/a import bypassedSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/bypassedSubgraphUnmatchedModel.json' with { type: 'json' } import type { MissingModelCandidate } from '@/platform/missingModel/types' import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' -import type * as AssetServiceModule from '@/platform/assets/services/assetService' - -const { mockCheckAssetHash } = vi.hoisted(() => ({ - mockCheckAssetHash: vi.fn() -})) vi.mock('@/utils/graphTraversalUtil', () => ({ collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes, @@ -33,20 +28,6 @@ vi.mock('@/utils/graphTraversalUtil', () => ({ ) => node._testExecutionId ?? String(node.id) })) -vi.mock('@/platform/assets/services/assetService', async () => { - const actual = await vi.importActual( - '@/platform/assets/services/assetService' - ) - - return { - ...actual, - assetService: { - ...actual.assetService, - checkAssetHash: mockCheckAssetHash - } - } -}) - /** Helper: create a combo widget mock */ function makeComboWidget( name: string, @@ -1391,23 +1372,14 @@ describe('OSS missing model detection (non-Cloud path)', () => { }) }) -const { - mockUpdateModelsForNodeType, - mockIsModelLoading, - mockHasMore, - mockGetAssets -} = vi.hoisted(() => ({ +const { mockUpdateModelsForNodeType, mockGetAssets } = vi.hoisted(() => ({ mockUpdateModelsForNodeType: vi.fn().mockResolvedValue(undefined), - mockIsModelLoading: vi.fn().mockReturnValue(false), - mockHasMore: vi.fn().mockReturnValue(false), mockGetAssets: vi.fn().mockReturnValue([]) })) vi.mock('@/stores/assetsStore', () => ({ useAssetsStore: () => ({ updateModelsForNodeType: mockUpdateModelsForNodeType, - isModelLoading: mockIsModelLoading, - hasMore: mockHasMore, getAssets: mockGetAssets }) })) @@ -1440,9 +1412,7 @@ function makeAssetCandidate( describe('verifyAssetSupportedCandidates', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckAssetHash.mockResolvedValue('missing') - mockIsModelLoading.mockReturnValue(false) - mockHasMore.mockReturnValue(false) + mockUpdateModelsForNodeType.mockResolvedValue(undefined) mockGetAssets.mockReturnValue([]) }) @@ -1458,84 +1428,15 @@ describe('verifyAssetSupportedCandidates', () => { ) }) - it('should resolve isMissing=false when the blake3 hash endpoint finds the asset', async () => { - const hash = - '1111111111111111111111111111111111111111111111111111111111111111' - const candidates = [ - makeAssetCandidate('model.safetensors', { - hash, - hashType: 'blake3' - }) - ] - mockCheckAssetHash.mockResolvedValue('exists') - - await verifyAssetSupportedCandidates(candidates) - - expect(candidates[0].isMissing).toBe(false) - expect(mockCheckAssetHash).toHaveBeenCalledWith(`blake3:${hash}`, undefined) - expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled() - }) - - it('should fall back to asset store matching when the blake3 hash is not found', async () => { + it('should match filenames regardless of hash metadata shape', async () => { const hash = '2222222222222222222222222222222222222222222222222222222222222222' const candidates = [ makeAssetCandidate('my_model.safetensors', { hash, hashType: 'blake3' - }) - ] - mockCheckAssetHash.mockResolvedValue('missing') - mockGetAssets.mockReturnValue([ - { - id: '1', - name: 'my_model.safetensors', - asset_hash: null, - metadata: { filename: 'my_model.safetensors' } - } - ]) - - await verifyAssetSupportedCandidates(candidates) - - expect(candidates[0].isMissing).toBe(false) - expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith( - 'CheckpointLoaderSimple' - ) - }) - - it('should fall back to asset store matching when hash verification fails', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const hash = - '3333333333333333333333333333333333333333333333333333333333333333' - const candidates = [ - makeAssetCandidate('my_model.safetensors', { - hash, - hashType: 'blake3' - }) - ] - mockCheckAssetHash.mockRejectedValue(new Error('network failed')) - mockGetAssets.mockReturnValue([ - { - id: '1', - name: 'my_model.safetensors', - asset_hash: null, - metadata: { filename: 'my_model.safetensors' } - } - ]) - - await verifyAssetSupportedCandidates(candidates) - - expect(candidates[0].isMissing).toBe(false) - expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith( - 'CheckpointLoaderSimple' - ) - expect(warn).toHaveBeenCalledOnce() - warn.mockRestore() - }) - - it('should skip malformed blake3 hashes and use asset store matching', async () => { - const candidates = [ - makeAssetCandidate('my_model.safetensors', { + }), + makeAssetCandidate('other_model.safetensors', { hash: 'abc123', hashType: 'blake3' }) @@ -1546,38 +1447,25 @@ describe('verifyAssetSupportedCandidates', () => { name: 'my_model.safetensors', asset_hash: null, metadata: { filename: 'my_model.safetensors' } + }, + { + id: '2', + name: 'other_model.safetensors', + asset_hash: null, + metadata: { filename: 'other_model.safetensors' } } ]) await verifyAssetSupportedCandidates(candidates) - expect(mockCheckAssetHash).not.toHaveBeenCalled() expect(candidates[0].isMissing).toBe(false) + expect(candidates[1].isMissing).toBe(false) + expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith( + 'CheckpointLoaderSimple' + ) }) - it('should not warn or fall back when hash verification is aborted', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const abortError = new Error('aborted') - abortError.name = 'AbortError' - const hash = - '4444444444444444444444444444444444444444444444444444444444444444' - const candidates = [ - makeAssetCandidate('my_model.safetensors', { - hash, - hashType: 'blake3' - }) - ] - mockCheckAssetHash.mockRejectedValue(abortError) - - await verifyAssetSupportedCandidates(candidates) - - expect(candidates[0].isMissing).toBeUndefined() - expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled() - expect(warn).not.toHaveBeenCalled() - warn.mockRestore() - }) - - it('should resolve isMissing=false when asset with matching hash exists', async () => { + it('should resolve isMissing=false when asset with matching asset_hash exists', async () => { const candidates = [ makeAssetCandidate('model.safetensors', { hash: 'abc123', @@ -1591,7 +1479,6 @@ describe('verifyAssetSupportedCandidates', () => { await verifyAssetSupportedCandidates(candidates) expect(candidates[0].isMissing).toBe(false) - expect(mockCheckAssetHash).not.toHaveBeenCalled() }) it('should resolve isMissing=false when asset with matching filename exists', async () => { @@ -1675,6 +1562,55 @@ describe('verifyAssetSupportedCandidates', () => { expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith('LoraLoader') }) + it('should leave candidates unresolved when their node type fails to load', async () => { + const candidates = [ + makeAssetCandidate('checkpoint.safetensors', { + nodeType: 'CheckpointLoaderSimple' + }), + makeAssetCandidate('lora.safetensors', { nodeType: 'LoraLoader' }) + ] + mockUpdateModelsForNodeType.mockImplementation(async (nodeType: string) => { + if (nodeType === 'LoraLoader') throw new Error('load failed') + }) + mockGetAssets.mockImplementation((nodeType: string) => + nodeType === 'CheckpointLoaderSimple' + ? [ + { + id: '1', + name: 'checkpoint.safetensors', + asset_hash: null, + metadata: { filename: 'checkpoint.safetensors' } + } + ] + : [] + ) + + await verifyAssetSupportedCandidates(candidates) + + expect(candidates[0].isMissing).toBe(false) + expect(candidates[1].isMissing).toBeUndefined() + }) + + it('should leave candidates unresolved when aborted after asset loads settle', async () => { + const controller = new AbortController() + const candidates = [makeAssetCandidate('model.safetensors')] + mockUpdateModelsForNodeType.mockImplementation(async () => { + controller.abort() + }) + mockGetAssets.mockReturnValue([ + { + id: '1', + name: 'model.safetensors', + asset_hash: null, + metadata: { filename: 'model.safetensors' } + } + ]) + + await verifyAssetSupportedCandidates(candidates, controller.signal) + + expect(candidates[0].isMissing).toBeUndefined() + }) + it('should match filename with path prefix normalization', async () => { const candidates = [makeAssetCandidate('subfolder/my_model.safetensors')] mockGetAssets.mockReturnValue([ diff --git a/src/platform/missingModel/missingModelScan.ts b/src/platform/missingModel/missingModelScan.ts index bef803112a..85f2b6b56c 100644 --- a/src/platform/missingModel/missingModelScan.ts +++ b/src/platform/missingModel/missingModelScan.ts @@ -24,11 +24,6 @@ import { } from '@/utils/graphTraversalUtil' import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums' import { resolveComboValues } from '@/utils/litegraphUtil' -import type { AssetHashStatus } from '@/platform/assets/services/assetService' -import { - assetService, - toBlake3AssetHash -} from '@/platform/assets/services/assetService' export type MissingModelWorkflowData = FlattenableWorkflowGraph & { models?: ModelFile[] @@ -450,16 +445,10 @@ interface AssetVerifier { getAssets: (nodeType: string) => AssetItem[] | undefined } -type AssetHashVerifier = ( - assetHash: string, - signal?: AbortSignal -) => Promise - export async function verifyAssetSupportedCandidates( candidates: MissingModelCandidate[], signal?: AbortSignal, - assetsStore?: AssetVerifier, - checkAssetHash: AssetHashVerifier = assetService.checkAssetHash + assetsStore?: AssetVerifier ): Promise { if (signal?.aborted) return @@ -468,52 +457,10 @@ export async function verifyAssetSupportedCandidates( ) if (pendingCandidates.length === 0) return - const pendingNodeTypes = new Set() - const candidatesByHash = new Map() - - for (const candidate of pendingCandidates) { - const assetHash = getBlake3AssetHash(candidate) - if (!assetHash) { - pendingNodeTypes.add(candidate.nodeType) - continue - } - - const hashCandidates = candidatesByHash.get(assetHash) - if (hashCandidates) hashCandidates.push(candidate) - else candidatesByHash.set(assetHash, [candidate]) - } - - await Promise.all( - Array.from(candidatesByHash, async ([assetHash, hashCandidates]) => { - if (signal?.aborted) return - - try { - const status = await checkAssetHash(assetHash, signal) - if (signal?.aborted) return - - if (status === 'exists') { - for (const candidate of hashCandidates) { - candidate.isMissing = false - } - return - } - } catch (err) { - if (signal?.aborted || isAbortError(err)) return - console.warn( - '[Missing Model Pipeline] Failed to verify asset hash:', - err - ) - } - - for (const candidate of hashCandidates) { - pendingNodeTypes.add(candidate.nodeType) - } - }) + const pendingNodeTypes = new Set( + pendingCandidates.map((candidate) => candidate.nodeType) ) - if (signal?.aborted) return - if (pendingNodeTypes.size === 0) return - const store = assetsStore ?? (await import('@/stores/assetsStore')).useAssetsStore() @@ -544,20 +491,6 @@ export async function verifyAssetSupportedCandidates( } } -function getBlake3AssetHash(candidate: MissingModelCandidate): string | null { - if (candidate.hashType?.toLowerCase() !== 'blake3') return null - return toBlake3AssetHash(candidate.hash) -} - -function isAbortError(err: unknown): boolean { - return ( - typeof err === 'object' && - err !== null && - 'name' in err && - err.name === 'AbortError' - ) -} - function normalizePath(path: string): string { return path.replace(/\\/g, '/') }