fix: remove asset hash verification (#12061)

## 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>.<extension> (for example, abc123....png), while the
OpenAPI/generated FE contract expects a blake3:<hash> 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)
This commit is contained in:
jaeone94
2026-05-08 10:56:19 +09:00
committed by GitHub
parent c8360a092f
commit c8c0e53865
6 changed files with 139 additions and 530 deletions

View File

@@ -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'
)
})
})

View File

@@ -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<AssetHashStatus> {
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,

View File

@@ -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)
})
})

View File

@@ -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<AssetHashStatus>
type InputAssetFetcher = (signal?: AbortSignal) => Promise<AssetItem[]>
function groupCandidatesForHashLookup(candidates: MissingMediaCandidate[]): {
candidatesByHash: Map<string, MissingMediaCandidate[]>
legacyCandidates: MissingMediaCandidate[]
} {
const candidatesByHash = new Map<string, MissingMediaCandidate[]>()
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<string, MissingMediaCandidate[]>,
legacyCandidates: MissingMediaCandidate[],
signal: AbortSignal | undefined,
checkAssetHash: AssetHashVerifier
): Promise<void> {
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<void> {
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<string>()
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[]

View File

@@ -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<typeof AssetServiceModule>(
'@/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([

View File

@@ -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<AssetHashStatus>
export async function verifyAssetSupportedCandidates(
candidates: MissingModelCandidate[],
signal?: AbortSignal,
assetsStore?: AssetVerifier,
checkAssetHash: AssetHashVerifier = assetService.checkAssetHash
assetsStore?: AssetVerifier
): Promise<void> {
if (signal?.aborted) return
@@ -468,52 +457,10 @@ export async function verifyAssetSupportedCandidates(
)
if (pendingCandidates.length === 0) return
const pendingNodeTypes = new Set<string>()
const candidatesByHash = new Map<string, MissingModelCandidate[]>()
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, '/')
}