diff --git a/browser_tests/assets/missing/fe746_load_image_bare_filename.json b/browser_tests/assets/missing/fe746_load_image_bare_filename.json new file mode 100644 index 00000000000..6aedef0cca0 --- /dev/null +++ b/browser_tests/assets/missing/fe746_load_image_bare_filename.json @@ -0,0 +1,42 @@ +{ + "last_node_id": 10, + "last_link_id": 0, + "nodes": [ + { + "id": 10, + "type": "LoadImage", + "pos": [50, 200], + "size": [315, 314], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": null + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "Node name for S&R": "LoadImage" + }, + "widgets_values": ["fe746_photo.png", "image"] + } + ], + "links": [], + "groups": [], + "config": {}, + "extra": { + "ds": { + "offset": [0, 0], + "scale": 1 + } + }, + "version": 0.4 +} diff --git a/browser_tests/tests/missingMediaAssetUnion.spec.ts b/browser_tests/tests/missingMediaAssetUnion.spec.ts new file mode 100644 index 00000000000..1b3b3fe92df --- /dev/null +++ b/browser_tests/tests/missingMediaAssetUnion.spec.ts @@ -0,0 +1,197 @@ +import type { Page } from '@playwright/test' +import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types' + +import { + comfyExpect as expect, + comfyPageFixture as test +} from '@e2e/fixtures/ComfyPage' +import type { ComfyPage } from '@e2e/fixtures/ComfyPage' +import { TestIds } from '@e2e/fixtures/selectors' +import type { WorkspaceStore } from '@e2e/types/globals' + +// BE-933 / BE-934 add `file_path` (and BE-933 also `display_name`) to the asset +// wire shape. `@comfyorg/ingest-types` is not yet regenerated from the updated +// OpenAPI (tracked under BE-932); extend the local type so mocks can carry the +// post-BE field without an `any` cast. +type PostBEAsset = Asset & { + file_path?: string | null + display_name?: string | null +} + +const WORKFLOW_WIDGET_VALUE = 'fe746_photo.png' + +async function mockAssetListing( + page: Page, + assets: PostBEAsset[] +): Promise { + await page.route(/\/api\/assets(?=\?|$)/, async (route) => { + const response: ListAssetsResponse = { + assets: assets as Asset[], + total: assets.length, + has_more: false + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(response) + }) + }) +} + +async function mockAssetListingFailure( + page: Page, + status: number +): Promise { + await page.route(/\/api\/assets(?=\?|$)/, async (route) => { + await route.fulfill({ + status, + contentType: 'application/json', + body: JSON.stringify({ detail: `forced ${status} for FE-746 test` }) + }) + }) +} + +async function getCachedMissingMediaNames( + comfyPage: ComfyPage +): Promise { + return await comfyPage.page.evaluate(() => { + const workflow = (window.app!.extensionManager as WorkspaceStore).workflow + .activeWorkflow + if (!workflow) return null + return ( + workflow.pendingWarnings?.missingMediaCandidates?.map( + (candidate) => candidate.name + ) ?? [] + ) + }) +} + +test.describe( + 'Missing media detection — file_path union (FE-746)', + { tag: '@cloud' }, + () => { + test.use({ + initialSettings: { + 'Comfy.RightSidePanel.ShowErrorsTab': true + } + }) + + test('does not surface missing media when a post-BE asset emits file_path that diverges from the workflow widget value (Case B regression)', async ({ + comfyPage + }) => { + // BE-933 / BE-934 post-deploy shape: asset emits a namespace-rooted + // file_path that differs from the bare `name` the user originally chose. + // The workflow widget value (`fe746_photo.png`) predates the rollout, so + // it must still match via the `name` arm of the detection-key union. + // Case A (file_path-only early return) would mark this as missing. + await mockAssetListing(comfyPage.page, [ + { + id: 'fe746-asset-1', + name: WORKFLOW_WIDGET_VALUE, + hash: 'blake3:fe7460000000000000000000000000000', + file_path: 'input/sub/fe746_photo.png', + size: 1024, + mime_type: 'image/png', + tags: ['input'], + created_at: '2026-05-22T00:00:00Z', + updated_at: '2026-05-22T00:00:00Z', + last_access_time: '2026-05-22T00:00:00Z' + } + ]) + + await comfyPage.workflow.loadWorkflow( + 'missing/fe746_load_image_bare_filename' + ) + + await expect( + comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay) + ).toBeHidden() + await expect.poll(() => getCachedMissingMediaNames(comfyPage)).toEqual([]) + }) + + test('matches via legacy `name` fallback when the asset has no file_path (BE-933 hash-only registration)', async ({ + comfyPage + }) => { + // BE-933 hash-only null case: asset registered via POST /assets/from-hash + // has no on-disk path, so `file_path` (and `display_name`) come back null. + // Detection must still succeed via the legacy `name` arm. + await mockAssetListing(comfyPage.page, [ + { + id: 'fe746-asset-hash-only', + name: WORKFLOW_WIDGET_VALUE, + hash: 'blake3:fe7460000000000000000000000000001', + file_path: null, + display_name: null, + size: 1024, + mime_type: 'image/png', + tags: ['input'], + created_at: '2026-05-22T00:00:00Z', + updated_at: '2026-05-22T00:00:00Z', + last_access_time: '2026-05-22T00:00:00Z' + } + ]) + + await comfyPage.workflow.loadWorkflow( + 'missing/fe746_load_image_bare_filename' + ) + + await expect( + comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay) + ).toBeHidden() + await expect.poll(() => getCachedMissingMediaNames(comfyPage)).toEqual([]) + }) + + test('surfaces missing media when no asset in the listing covers the widget value', async ({ + comfyPage + }) => { + // Sanity: with the union still in place, an asset listing that does not + // include the widget value via any key (file_path / hash / name) + // must still report missing. Guards against accidental "match + // everything" regressions when the early-return was removed. + await mockAssetListing(comfyPage.page, [ + { + id: 'fe746-unrelated-asset', + name: 'unrelated.png', + hash: 'blake3:fe7460000000000000000000000000002', + file_path: 'input/unrelated.png', + size: 1024, + mime_type: 'image/png', + tags: ['input'], + created_at: '2026-05-22T00:00:00Z', + updated_at: '2026-05-22T00:00:00Z', + last_access_time: '2026-05-22T00:00:00Z' + } + ]) + + await comfyPage.workflow.loadWorkflow( + 'missing/fe746_load_image_bare_filename' + ) + + await expect + .poll(() => getCachedMissingMediaNames(comfyPage)) + .toContain(WORKFLOW_WIDGET_VALUE) + await expect( + comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay) + ).toBeVisible() + }) + + test('soft-degrades when /api/assets fails so verification does not deadlock pending candidates', async ({ + comfyPage + }) => { + // Promise.allSettled + per-branch soft-degrade (Finding 2): when the + // input-asset oracle fails (pre-BE-786 OSS without /api/assets, partial + // BE-934 deploys, transient network errors), the verifier must finish + // — marking the candidate missing — rather than leaving isMissing + // stuck at undefined behind a silent toast. + await mockAssetListingFailure(comfyPage.page, 500) + + await comfyPage.workflow.loadWorkflow( + 'missing/fe746_load_image_bare_filename' + ) + + await expect + .poll(() => getCachedMissingMediaNames(comfyPage)) + .toContain(WORKFLOW_WIDGET_VALUE) + }) + } +) diff --git a/src/composables/graph/useErrorClearingHooks.test.ts b/src/composables/graph/useErrorClearingHooks.test.ts index fd61e2e9c05..d1a6d075f79 100644 --- a/src/composables/graph/useErrorClearingHooks.test.ts +++ b/src/composables/graph/useErrorClearingHooks.test.ts @@ -937,12 +937,8 @@ describe('scan skips interior of bypassed subgraph containers', () => { expect.any(Function), expect.any(Function) ) - expect(mediaScanSpy).toHaveBeenCalledWith(rootGraph, leafNode, false) - expect(mediaScanSpy).not.toHaveBeenCalledWith( - rootGraph, - innerSubgraphNode, - false - ) + expect(mediaScanSpy).toHaveBeenCalledWith(rootGraph, leafNode) + expect(mediaScanSpy).not.toHaveBeenCalledWith(rootGraph, innerSubgraphNode) }) }) diff --git a/src/composables/graph/useErrorClearingHooks.ts b/src/composables/graph/useErrorClearingHooks.ts index f60789c966b..726204e658c 100644 --- a/src/composables/graph/useErrorClearingHooks.ts +++ b/src/composables/graph/useErrorClearingHooks.ts @@ -220,7 +220,7 @@ function scanSingleNodeMedia(node: LGraphNode): void { if (!app.rootGraph) return if (!getActiveExecutionId(node)) return - const mediaCandidates = scanNodeMediaCandidates(app.rootGraph, node, isCloud) + const mediaCandidates = scanNodeMediaCandidates(app.rootGraph, node) const confirmedMedia = mediaCandidates.filter((c) => c.isMissing === true) if (confirmedMedia.length) { useMissingMediaStore().addMissingMedia(confirmedMedia) @@ -276,7 +276,7 @@ async function verifyAndAddPendingMedia( ): Promise { const rootGraphAtScan = app.rootGraph try { - await verifyMediaCandidates(pending, { isCloud }) + await verifyMediaCandidates(pending, { allowCompactSuffix: isCloud }) if (app.rootGraph !== rootGraphAtScan) return const verified = pending.filter( (c) => c.isMissing === true && isCandidateStillActive(c.nodeId) diff --git a/src/platform/assets/schemas/assetSchema.ts b/src/platform/assets/schemas/assetSchema.ts index 9636f1a1573..40ddb819339 100644 --- a/src/platform/assets/schemas/assetSchema.ts +++ b/src/platform/assets/schemas/assetSchema.ts @@ -6,11 +6,17 @@ const zAsset = z.object({ id: z.string(), name: z.string(), hash: z.string().nullish(), + // Namespace-rooted locator/display string, e.g. `input/sub/image.png` or + // `models/checkpoints/flux.safetensors`. Emitted on a best-effort basis + // (nullable). Identity is `id`, not `file_path`. Consumers must not assume + // `file_path` is populated and must degrade gracefully — see + // missingMediaAssetResolver.getAssetDetectionNames. + file_path: z.string().nullish(), size: z.number().optional(), // TBD: Will be provided by history API in the future mime_type: z.string().nullish(), tags: z.array(z.string()).optional().default([]), preview_id: z.string().nullable().optional(), - display_name: z.string().optional(), + display_name: z.string().nullish(), preview_url: z.string().optional(), thumbnail_url: z.string().optional(), created_at: z.string().optional(), diff --git a/src/platform/assets/services/assetService.test.ts b/src/platform/assets/services/assetService.test.ts index 0721bd33567..73ea9df4f98 100644 --- a/src/platform/assets/services/assetService.test.ts +++ b/src/platform/assets/services/assetService.test.ts @@ -208,6 +208,29 @@ describe(assetService.getAssetMetadata, () => { }) }) +describe(assetService.getInputAssetsIncludingPublic, () => { + beforeEach(() => { + vi.clearAllMocks() + assetService.invalidateInputAssetsIncludingPublic() + }) + + it('keeps hash-only assets whose display_name comes back null (BE-933)', async () => { + const hashOnlyAsset = validAsset({ + id: 'hash-only-input', + name: 'fe746_photo.png', + tags: ['input'], + hash: 'blake3:fe746', + file_path: null, + display_name: null + }) + fetchApiMock.mockResolvedValueOnce(buildAssetListResponse([hashOnlyAsset])) + + const assets = await assetService.getInputAssetsIncludingPublic() + + expect(assets).toEqual([hashOnlyAsset]) + }) +}) + describe(assetService.uploadAssetFromUrl, () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.test.ts b/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.test.ts index 705d65499ef..51c7e29b1d7 100644 --- a/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.test.ts +++ b/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.test.ts @@ -6,10 +6,6 @@ import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore' import { markDeletedAssetsAsMissingMedia } from './markDeletedAssetsAsMissingMedia' -vi.mock('@/platform/distribution/types', () => ({ - isCloud: true -})) - const mockScanNodeMediaCandidates = vi.hoisted(() => vi.fn()) vi.mock('@/platform/missingMedia/missingMediaScan', () => ({ scanNodeMediaCandidates: mockScanNodeMediaCandidates @@ -94,8 +90,7 @@ describe('FE-230 markDeletedAssetsAsMissingMedia', () => { expect(mockScanNodeMediaCandidates).toHaveBeenCalledTimes(1) expect(mockScanNodeMediaCandidates).toHaveBeenCalledWith( expect.anything(), - inputNode, - true + inputNode ) }) diff --git a/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.ts b/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.ts index 800e6851472..d4723989c4d 100644 --- a/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.ts +++ b/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.ts @@ -1,6 +1,5 @@ import type { LGraph } from '@/lib/litegraph/src/litegraph' import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums' -import { isCloud } from '@/platform/distribution/types' import { scanNodeMediaCandidates } from '@/platform/missingMedia/missingMediaScan' import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore' import type { MissingMediaCandidate } from '@/platform/missingMedia/types' @@ -38,7 +37,7 @@ export function markDeletedAssetsAsMissingMedia( node.mode === LGraphEventMode.BYPASS ) continue - for (const candidate of scanNodeMediaCandidates(rootGraph, node, isCloud)) { + for (const candidate of scanNodeMediaCandidates(rootGraph, node)) { if (!deletedValues.has(candidate.name)) continue candidates.push({ ...candidate, isMissing: true }) } diff --git a/src/platform/missingMedia/missingMediaAssetResolver.test.ts b/src/platform/missingMedia/missingMediaAssetResolver.test.ts index 632320759c1..d6f035b1380 100644 --- a/src/platform/missingMedia/missingMediaAssetResolver.test.ts +++ b/src/platform/missingMedia/missingMediaAssetResolver.test.ts @@ -20,6 +20,18 @@ const { mockFetchHistoryPage } = vi.hoisted(() => ({ mockFetchHistoryPage: vi.fn() })) +// Mutable holder so each test can flip the runtime `isCloud` to drive the +// resolver's generated-assets oracle selection (Cloud /api/assets vs OSS +// job history). The named-import binding into the resolver re-reads the +// getter on each access (ESM live binding semantics). +const isCloudHolder = vi.hoisted(() => ({ value: false })) + +vi.mock('@/platform/distribution/types', () => ({ + get isCloud() { + return isCloudHolder.value + } +})) + vi.mock('@/platform/assets/services/assetService', async () => { const actual = await vi.importActual( '@/platform/assets/services/assetService' @@ -102,17 +114,17 @@ function makeAssetPage( describe('resolveMissingMediaAssetSources', () => { beforeEach(() => { vi.clearAllMocks() + isCloudHolder.value = false mockGetInputAssetsIncludingPublic.mockResolvedValue([]) mockGetAssetsPageByTag.mockResolvedValue(makeAssetPage([])) mockFetchHistoryPage.mockResolvedValue(makeHistoryPage([])) }) - it('loads cloud input assets when requested', async () => { + it('loads input assets from the unified listing on both backends', async () => { const inputAsset = makeAsset('photo.png') mockGetInputAssetsIncludingPublic.mockResolvedValue([inputAsset]) const result = await resolveMissingMediaAssetSources({ - isCloud: true, includeGeneratedAssets: false, generatedMatchNames: new Set(), allowCompactSuffix: true @@ -127,11 +139,11 @@ describe('resolveMissingMediaAssetSources', () => { }) it('loads cloud output assets by tag when generated candidates need verification', async () => { + isCloudHolder.value = true const outputAsset = makeAsset('output.png') mockGetAssetsPageByTag.mockResolvedValue(makeAssetPage([outputAsset])) const result = await resolveMissingMediaAssetSources({ - isCloud: true, includeGeneratedAssets: true, generatedMatchNames: new Set(['output.png']), allowCompactSuffix: true @@ -151,13 +163,13 @@ describe('resolveMissingMediaAssetSources', () => { }) it('stops reading cloud output asset pages once all requested names are found', async () => { + isCloudHolder.value = true const target = 'target-output.png' mockGetAssetsPageByTag.mockResolvedValueOnce( makeAssetPage([makeAsset(target)], { hasMore: true, total: 501 }) ) const result = await resolveMissingMediaAssetSources({ - isCloud: true, includeGeneratedAssets: true, generatedMatchNames: new Set([target]), allowCompactSuffix: true @@ -167,41 +179,40 @@ describe('resolveMissingMediaAssetSources', () => { expect(mockGetAssetsPageByTag).toHaveBeenCalledOnce() }) - it('aborts cloud output asset loading when input asset loading fails', async () => { - const inputError = new Error('input failed') - let rejectInputAssets!: (err: Error) => void - let resolveOutputAssets!: (page: ReturnType) => void - mockGetInputAssetsIncludingPublic.mockReturnValueOnce( - new Promise((_, reject) => { - rejectInputAssets = reject - }) - ) - mockGetAssetsPageByTag.mockReturnValueOnce( - new Promise((resolve) => { - resolveOutputAssets = resolve - }) + it('returns empty inputAssets and keeps generated fetch alive when input fails (soft degrade)', async () => { + isCloudHolder.value = true + const inputError = new Error('GET /api/assets 404') + mockGetInputAssetsIncludingPublic.mockRejectedValueOnce(inputError) + mockGetAssetsPageByTag.mockResolvedValueOnce( + makeAssetPage([makeAsset('survivor.png')]) ) - const promise = resolveMissingMediaAssetSources({ - isCloud: true, + const result = await resolveMissingMediaAssetSources({ includeGeneratedAssets: true, - generatedMatchNames: new Set(['target.png']), + generatedMatchNames: new Set(['survivor.png']), allowCompactSuffix: true }) - await Promise.resolve() - expect(mockGetAssetsPageByTag).toHaveBeenCalledOnce() + // Input oracle failed: degrade to empty. Generated oracle is independent + // and must keep running so output candidates can still verify. + expect(result.inputAssets).toEqual([]) + expect(result.generatedAssets).toEqual([makeAsset('survivor.png')]) + expect(mockFetchHistoryPage).not.toHaveBeenCalled() + }) - rejectInputAssets(inputError) - await expect(promise).rejects.toBe(inputError) + it('returns empty generatedAssets when history fetch fails but inputs succeed', async () => { + const inputAsset = makeAsset('local-photo.png') + mockGetInputAssetsIncludingPublic.mockResolvedValueOnce([inputAsset]) + mockFetchHistoryPage.mockRejectedValueOnce(new Error('500 history')) - resolveOutputAssets(makeAssetPage([makeAsset('other.png')])) - await Promise.resolve() + const result = await resolveMissingMediaAssetSources({ + includeGeneratedAssets: true, + generatedMatchNames: new Set(['rendered.png']), + allowCompactSuffix: true + }) - const outputSignal = mockGetAssetsPageByTag.mock.calls[0]?.[2]?.signal - expect(outputSignal).toBeInstanceOf(AbortSignal) - expect(outputSignal.aborted).toBe(true) - expect(mockFetchHistoryPage).not.toHaveBeenCalled() + expect(result.inputAssets).toEqual([inputAsset]) + expect(result.generatedAssets).toEqual([]) }) it('stops reading generated history once all requested names are found', async () => { @@ -214,7 +225,6 @@ describe('resolveMissingMediaAssetSources', () => { ) const result = await resolveMissingMediaAssetSources({ - isCloud: false, includeGeneratedAssets: true, generatedMatchNames: new Set([target]), allowCompactSuffix: true @@ -245,7 +255,6 @@ describe('resolveMissingMediaAssetSources', () => { ) await resolveMissingMediaAssetSources({ - isCloud: false, includeGeneratedAssets: true, generatedMatchNames: new Set([target]), allowCompactSuffix: true @@ -271,7 +280,6 @@ describe('resolveMissingMediaAssetSources', () => { ) const result = await resolveMissingMediaAssetSources({ - isCloud: false, includeGeneratedAssets: true, generatedMatchNames: new Set(['missing.png']), allowCompactSuffix: true @@ -292,7 +300,6 @@ describe('resolveMissingMediaAssetSources', () => { ) const result = await resolveMissingMediaAssetSources({ - isCloud: false, includeGeneratedAssets: true, generatedMatchNames: new Set(['missing.png']), allowCompactSuffix: true @@ -301,8 +308,69 @@ describe('resolveMissingMediaAssetSources', () => { expect(result.generatedAssets).toHaveLength(1) expect(mockFetchHistoryPage).toHaveBeenCalledTimes(2) }) +}) + +describe('getAssetDetectionNames', () => { + it('unions file_path with legacy keys so deprecation-window widget values keep matching', () => { + const names = getAssetDetectionNames( + { + id: 'a1', + name: 'legacy.png', + hash: 'blake3:abc', + file_path: 'input/sub/photo.png', + mime_type: null, + tags: ['input'], + user_metadata: { subfolder: 'old-subfolder' } + }, + { allowCompactSuffix: true } + ) + + // A widget value in any of these legacy shapes (or the new file_path + // shape) must match; file_path is a locator, not the identity, and + // workflow widget values do not auto-upgrade. + expect(names).toEqual( + expect.arrayContaining([ + 'input/sub/photo.png', + 'blake3:abc', + 'legacy.png', + 'old-subfolder/legacy.png' + ]) + ) + }) + + it('falls back to the legacy union when file_path is null', () => { + const names = getAssetDetectionNames( + { + id: 'a1', + name: 'legacy.png', + hash: 'blake3:abc', + file_path: null, + mime_type: null, + tags: ['input'] + }, + { allowCompactSuffix: true } + ) + + expect(names).toEqual(expect.arrayContaining(['legacy.png', 'blake3:abc'])) + }) + + it('returns an empty list when file_path, hash, and name are all absent', () => { + const names = getAssetDetectionNames( + { + id: 'a1', + name: '', + hash: null, + file_path: null, + mime_type: null, + tags: [] + }, + { allowCompactSuffix: true } + ) + + expect(names).toEqual([]) + }) - it('includes slash and backslash subfolder identifiers for detection', () => { + it('includes slash and backslash subfolder identifiers when file_path is null', () => { const names = getAssetDetectionNames( { ...makeAsset('child\\photo.png', 'hash.png'), diff --git a/src/platform/missingMedia/missingMediaAssetResolver.ts b/src/platform/missingMedia/missingMediaAssetResolver.ts index 20f803c51ab..3601017e997 100644 --- a/src/platform/missingMedia/missingMediaAssetResolver.ts +++ b/src/platform/missingMedia/missingMediaAssetResolver.ts @@ -1,9 +1,11 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import { assetService } from '@/platform/assets/services/assetService' +import { isCloud } from '@/platform/distribution/types' import { fetchHistoryPage } from '@/platform/remote/comfyui/jobs/fetchJobs' import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes' import { api } from '@/scripts/api' import { getFilePathSeparatorVariants, joinFilePath } from '@/utils/formatUtil' +import { isAbortError } from '@/utils/typeGuardUtil' import { getMediaPathDetectionNames } from './mediaPathDetectionUtil' const HISTORY_MEDIA_ASSETS_PAGE_SIZE = 200 @@ -20,7 +22,6 @@ export interface MissingMediaAssetSources { export interface ResolveMissingMediaAssetSourcesOptions { signal?: AbortSignal - isCloud: boolean includeGeneratedAssets: boolean generatedMatchNames: ReadonlySet allowCompactSuffix: boolean @@ -32,7 +33,6 @@ export type MissingMediaAssetResolver = ( export async function resolveMissingMediaAssetSources({ signal, - isCloud, includeGeneratedAssets, generatedMatchNames, allowCompactSuffix @@ -48,43 +48,72 @@ export async function resolveMissingMediaAssetSources({ } try { - const [inputAssets, generatedAssets] = await Promise.all([ - abortSiblingsOnFailure( - isCloud - ? assetService.getInputAssetsIncludingPublic(controller.signal) - : Promise.resolve([]), - controller - ), - abortSiblingsOnFailure( - includeGeneratedAssets - ? fetchGeneratedAssets(controller.signal, { - isCloud, - generatedMatchNames, - pathOptions - }) - : Promise.resolve([]), - controller - ) + // Input assets (`/api/assets`) and generated assets (Cloud asset API or + // OSS `/history`) are independent oracles. Use `allSettled` so a failure + // in one — e.g. `/api/assets` 404 on a pre-BE-786 OSS instance, or zod + // schema skew during a BE-934 partial deploy — doesn't take down the + // other path. Each branch soft-degrades to an empty list; the caller + // then marks affected candidates missing instead of swallowing the + // whole verification with a toast. + const [inputResult, generatedResult] = await Promise.allSettled([ + assetService.getInputAssetsIncludingPublic(controller.signal), + includeGeneratedAssets + ? fetchGeneratedAssets(controller.signal, { + generatedMatchNames, + pathOptions + }) + : Promise.resolve([]) ]) - return { inputAssets, generatedAssets } + return { + inputAssets: unwrapAssetFetchResult(inputResult, 'inputAssets'), + generatedAssets: unwrapAssetFetchResult( + generatedResult, + 'generatedAssets' + ) + } } finally { signal?.removeEventListener('abort', abortFromCaller) } } +function unwrapAssetFetchResult( + result: PromiseSettledResult, + label: 'inputAssets' | 'generatedAssets' +): AssetItem[] { + if (result.status === 'fulfilled') return result.value + if (isAbortError(result.reason)) return [] + console.warn( + `[missingMedia] ${label} fetch failed; degrading to empty list.`, + result.reason + ) + return [] +} + interface FetchGeneratedAssetsOptions { - isCloud: boolean generatedMatchNames: ReadonlySet pathOptions: MediaPathDetectionOptions } +/** + * Derive comparison keys for matching workflow widget values against an asset. + * + * `id` is the identity field; `file_path` is a namespace-rooted locator emitted + * on a best-effort basis. Workflow widget values predate the `file_path` rollout + * and may still be bare filenames, hashes, or annotated paths, so detection keys + * union `file_path`, `hash`, `name`, and `subfolder + name` variants — a widget + * value in any of those shapes must keep matching once an asset starts emitting + * `file_path`. + */ export function getAssetDetectionNames( asset: AssetItem, options: MediaPathDetectionOptions ): string[] { const names = new Set() - // Treat names and hashes as opaque match keys because Cloud may use either in widget values. + + // Treat file_path, hashes, and names as opaque match keys because widget + // values may carry any of them. + addPathDetectionNames(names, asset.file_path, options) addPathDetectionNames(names, asset.hash, options) addPathDetectionNames(names, asset.name, options) @@ -96,9 +125,16 @@ export function getAssetDetectionNames( return Array.from(names) } +/** + * Pick the generated-assets oracle by runtime. Cloud queries + * `/api/assets?include_tags=output`; Core synthesizes `AssetItem` shells + * from job-execution history because OSS does not auto-register output + * files as assets (pre-BE-786). Unifying this oracle is a separate + * concern — track as a follow-up to FE-746. + */ async function fetchGeneratedAssets( signal: AbortSignal | undefined, - { isCloud, generatedMatchNames, pathOptions }: FetchGeneratedAssetsOptions + { generatedMatchNames, pathOptions }: FetchGeneratedAssetsOptions ): Promise { if (isCloud) { return await fetchCloudGeneratedAssets( @@ -212,18 +248,6 @@ async function fetchGeneratedHistoryAssets( } } -async function abortSiblingsOnFailure( - promise: Promise, - controller: AbortController -): Promise { - try { - return await promise - } catch (err) { - if (!controller.signal.aborted) controller.abort(err) - throw err - } -} - function addPathDetectionNames( names: Set, value: string | null | undefined, diff --git a/src/platform/missingMedia/missingMediaScan.test.ts b/src/platform/missingMedia/missingMediaScan.test.ts index 6e2236533eb..ee42c21f329 100644 --- a/src/platform/missingMedia/missingMediaScan.test.ts +++ b/src/platform/missingMedia/missingMediaScan.test.ts @@ -32,6 +32,17 @@ const { mockFetchHistoryPage } = vi.hoisted(() => ({ mockFetchHistoryPage: vi.fn() })) +// Mutable runtime `isCloud` holder for tests that exercise the default +// resolver's generated-assets oracle (Cloud /api/assets vs OSS history). +// Tests with their own `resolveAssetSources` mock can ignore this. +const isCloudHolder = vi.hoisted(() => ({ value: false })) + +vi.mock('@/platform/distribution/types', () => ({ + get isCloud() { + return isCloudHolder.value + } +})) + vi.mock('@/utils/graphTraversalUtil', () => ({ collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes, getExecutionIdByNode: ( @@ -163,7 +174,7 @@ function makeHistoryJob( } describe('scanNodeMediaCandidates', () => { - it('returns candidate for a LoadImage node with missing image', () => { + it('returns a candidate for a LoadImage node and defers missingness to the verifier', () => { const graph = makeGraph([]) const node = makeMediaNode( 1, @@ -172,17 +183,18 @@ describe('scanNodeMediaCandidates', () => { 0 ) - const result = scanNodeMediaCandidates(graph, node, false) + const result = scanNodeMediaCandidates(graph, node) - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - nodeId: '1', - nodeType: 'LoadImage', - widgetName: 'image', - mediaType: 'image', - name: 'photo.png', - isMissing: true - }) + expect(result).toEqual([ + { + nodeId: '1', + nodeType: 'LoadImage', + widgetName: 'image', + mediaType: 'image', + name: 'photo.png', + isMissing: undefined + } + ]) }) it('returns empty for non-media node types', () => { @@ -194,39 +206,30 @@ describe('scanNodeMediaCandidates', () => { 0 ) - const result = scanNodeMediaCandidates(graph, node, false) - - expect(result).toEqual([]) + expect(scanNodeMediaCandidates(graph, node)).toEqual([]) }) it('returns empty for node with no widgets', () => { const graph = makeGraph([]) const node = makeMediaNode(1, 'LoadImage', [], 0) - const result = scanNodeMediaCandidates(graph, node, false) - - expect(result).toEqual([]) + expect(scanNodeMediaCandidates(graph, node)).toEqual([]) }) - it.for([false, true])( - 'returns empty while a media upload is pending on the node (isCloud: %s)', - (isCloud) => { - const graph = makeGraph([]) - const node = makeMediaNode( - 1, - 'LoadVideo', - [makeMediaCombo('file', 'clip.mp4', [])], - 0 - ) - node.isUploading = true - - const result = scanNodeMediaCandidates(graph, node, isCloud) + it('returns empty while a media upload is pending on the node', () => { + const graph = makeGraph([]) + const node = makeMediaNode( + 1, + 'LoadVideo', + [makeMediaCombo('file', 'clip.mp4', [])], + 0 + ) + node.isUploading = true - expect(result).toEqual([]) - } - ) + expect(scanNodeMediaCandidates(graph, node)).toEqual([]) + }) - it('detects missing media again after upload state clears', () => { + it('emits the candidate again after upload state clears', () => { const graph = makeGraph([]) const node = makeMediaNode( 1, @@ -236,16 +239,16 @@ describe('scanNodeMediaCandidates', () => { ) node.isUploading = true - expect(scanNodeMediaCandidates(graph, node, false)).toEqual([]) + expect(scanNodeMediaCandidates(graph, node)).toEqual([]) node.isUploading = false - expect(scanNodeMediaCandidates(graph, node, false)).toEqual([ + expect(scanNodeMediaCandidates(graph, node)).toEqual([ expect.objectContaining({ nodeType: 'LoadVideo', widgetName: 'file', mediaType: 'video', name: 'clip.mp4', - isMissing: true + isMissing: undefined }) ]) }) @@ -254,126 +257,51 @@ describe('scanNodeMediaCandidates', () => { { nodeType: 'LoadImage', widgetName: 'image', - mediaType: 'image', - value: 'photo.png [input]', - option: 'photo.png' + mediaType: 'image' as const, + value: 'photo.png [input]' }, { nodeType: 'LoadImageMask', widgetName: 'image', - mediaType: 'image', - value: 'mask.png [input]', - option: 'mask.png' + mediaType: 'image' as const, + value: 'mask.png [input]' }, { nodeType: 'LoadVideo', widgetName: 'file', - mediaType: 'video', - value: 'clip.mp4 [input]', - option: 'clip.mp4' + mediaType: 'video' as const, + value: 'clip.mp4 [input]' }, { nodeType: 'LoadAudio', widgetName: 'audio', - mediaType: 'audio', - value: 'sound.wav [input]', - option: 'sound.wav' + mediaType: 'audio' as const, + value: 'sound.wav [input]' } ])( - 'matches annotated $nodeType values against clean OSS options', - ({ nodeType, widgetName, mediaType, value, option }) => { + 'passes annotated $nodeType values through unchanged for async verification', + ({ nodeType, widgetName, mediaType, value }) => { const graph = makeGraph([]) const node = makeMediaNode( 1, nodeType, - [makeMediaCombo(widgetName, value, [option])], + [makeMediaCombo(widgetName, value, [])], 0 ) - const result = scanNodeMediaCandidates(graph, node, false) - - expect(result).toHaveLength(1) - expect(result[0]).toMatchObject({ - nodeType, - widgetName, - mediaType, - name: value, - isMissing: false - }) - } - ) + const result = scanNodeMediaCandidates(graph, node) - it.for([ - { - nodeType: 'LoadImage', - widgetName: 'image', - value: 'photo.png [output]' - }, - { - nodeType: 'LoadVideo', - widgetName: 'file', - value: 'clip.mp4 [output]' - }, - { - nodeType: 'LoadAudio', - widgetName: 'audio', - value: 'sound.wav [output]' - } - ])( - 'leaves OSS $nodeType output annotations pending when not in options', - ({ nodeType, widgetName, value }) => { - const graph = makeGraph([]) - const node = makeMediaNode( - 1, - nodeType, - [makeMediaCombo(widgetName, value, ['other-file.png', value])], - 0 - ) - - const result = scanNodeMediaCandidates(graph, node, false) - - expect(result[0]).toMatchObject({ - nodeType, - widgetName, - name: value, - isMissing: undefined - }) + expect(result).toEqual([ + expect.objectContaining({ + nodeType, + widgetName, + mediaType, + name: value, + isMissing: undefined + }) + ]) } ) - - it('marks OSS input annotations missing when the clean option is absent', () => { - const graph = makeGraph([]) - const node = makeMediaNode( - 1, - 'LoadImage', - [makeMediaCombo('image', 'photo.png [input]', ['other.png'])], - 0 - ) - - const result = scanNodeMediaCandidates(graph, node, false) - - expect(result[0]).toMatchObject({ - name: 'photo.png [input]', - isMissing: true - }) - }) - - it('does not treat compact Cloud annotations as valid OSS options', () => { - const graph = makeGraph([]) - const node = makeMediaNode( - 1, - 'LoadImage', - [makeMediaCombo('image', 'photo.png[input]', ['photo.png'])], - 0 - ) - - const result = scanNodeMediaCandidates(graph, node, false) - - expect(result[0]).toMatchObject({ - name: 'photo.png[input]', - isMissing: true - }) - }) }) describe('scanAllMediaCandidates', () => { @@ -384,8 +312,7 @@ describe('scanAllMediaCandidates', () => { [makeMediaCombo('image', 'photo.png', ['other.png'])], 2 // NEVER ) - const result = scanAllMediaCandidates(makeGraph([node]), false) - expect(result).toHaveLength(0) + expect(scanAllMediaCandidates(makeGraph([node]))).toEqual([]) }) it('skips bypassed nodes (mode === BYPASS)', () => { @@ -395,20 +322,25 @@ describe('scanAllMediaCandidates', () => { [makeMediaCombo('image', 'photo.png', ['other.png'])], 4 // BYPASS ) - const result = scanAllMediaCandidates(makeGraph([node]), false) - expect(result).toHaveLength(0) + expect(scanAllMediaCandidates(makeGraph([node]))).toEqual([]) }) - it('includes active nodes (mode === ALWAYS)', () => { + it('includes active nodes (mode === ALWAYS) with isMissing deferred to the verifier', () => { const node = makeMediaNode( 3, 'LoadImage', [makeMediaCombo('image', 'photo.png', ['other.png'])], 0 // ALWAYS ) - const result = scanAllMediaCandidates(makeGraph([node]), false) - expect(result).toHaveLength(1) - expect(result[0].isMissing).toBe(true) + const result = scanAllMediaCandidates(makeGraph([node])) + expect(result).toEqual([ + expect.objectContaining({ + nodeId: '3', + nodeType: 'LoadImage', + name: 'photo.png', + isMissing: undefined + }) + ]) }) }) @@ -525,6 +457,7 @@ describe('verifyMediaCandidates', () => { beforeEach(() => { vi.clearAllMocks() + isCloudHolder.value = false mockGetInputAssetsIncludingPublic.mockResolvedValue([]) mockGetAssetsPageByTag.mockResolvedValue(makeAssetPage([])) mockFetchHistoryPage.mockResolvedValue({ @@ -547,7 +480,7 @@ describe('verifyMediaCandidates', () => { ]) await verifyMediaCandidates(candidates, { - isCloud: true, + allowCompactSuffix: true, resolveAssetSources }) @@ -556,7 +489,6 @@ describe('verifyMediaCandidates', () => { expect(candidates[2].isMissing).toBe(true) expect(resolveAssetSources).toHaveBeenCalledWith({ signal: undefined, - isCloud: true, includeGeneratedAssets: false, generatedMatchNames: new Set(), allowCompactSuffix: true @@ -573,7 +505,7 @@ describe('verifyMediaCandidates', () => { ]) await verifyMediaCandidates(candidates, { - isCloud: true, + allowCompactSuffix: true, resolveAssetSources }) @@ -581,6 +513,58 @@ describe('verifyMediaCandidates', () => { expect(candidates[1].isMissing).toBe(true) }) + it('matches widget values against file_path when the asset emits it (post BE-933 / BE-934)', async () => { + const candidates = [ + makeCandidate('1', 'input/sub/photo.png', { isMissing: undefined }), + makeCandidate('2', 'input/sub/missing.png', { isMissing: undefined }) + ] + const assetWithFilePath: AssetItem = { + id: 'asset-1', + // Legacy `name` and `hash` deliberately diverge from the widget value; + // `file_path` is the sole reason the match succeeds. + name: 'unrelated.png', + hash: 'blake3:abc', + file_path: 'input/sub/photo.png', + mime_type: null, + tags: ['input'] + } + const resolveAssetSources = makeAssetResolver([assetWithFilePath]) + + await verifyMediaCandidates(candidates, { + allowCompactSuffix: true, + resolveAssetSources + }) + + expect(candidates[0].isMissing).toBe(false) + expect(candidates[1].isMissing).toBe(true) + }) + + it('matches a bare-filename widget value against a file_path-emitting asset (BE-808 deprecation window)', async () => { + // Pre-BE-933/934 workflow: widget value is the bare filename the user + // originally picked. Post-BE-933/934 asset: emits a namespace-rooted + // `file_path`. The two shapes must still match — workflow JSON does + // not auto-upgrade when the backend response shape changes. + const candidates = [ + makeCandidate('1', 'photo.png', { isMissing: undefined }) + ] + const assetPostBE: AssetItem = { + id: 'asset-1', + name: 'photo.png', + hash: null, + file_path: 'input/sub/photo.png', + mime_type: null, + tags: ['input'] + } + const resolveAssetSources = makeAssetResolver([assetPostBE]) + + await verifyMediaCandidates(candidates, { + allowCompactSuffix: true, + resolveAssetSources + }) + + expect(candidates[0].isMissing).toBe(false) + }) + it('matches annotated candidate names against clean asset names', async () => { const candidates = [ makeCandidate('1', 'photo.png [input]', { isMissing: undefined }), @@ -603,7 +587,7 @@ describe('verifyMediaCandidates', () => { ) await verifyMediaCandidates(candidates, { - isCloud: true, + allowCompactSuffix: true, resolveAssetSources }) @@ -641,13 +625,12 @@ describe('verifyMediaCandidates', () => { ) await verifyMediaCandidates(candidates, { - isCloud: true, + allowCompactSuffix: true, resolveAssetSources }) expect(resolveAssetSources).toHaveBeenCalledWith({ signal: undefined, - isCloud: true, includeGeneratedAssets: true, generatedMatchNames: new Set([ '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png' @@ -667,7 +650,7 @@ describe('verifyMediaCandidates', () => { const resolveAssetSources = makeAssetResolver([makeAsset('photo.png')]) await verifyMediaCandidates(candidates, { - isCloud: true, + allowCompactSuffix: true, resolveAssetSources }) @@ -681,14 +664,14 @@ describe('verifyMediaCandidates', () => { const resolveAssetSources = makeAssetResolver([], [makeAsset('photo.png')]) await verifyMediaCandidates(candidates, { - isCloud: true, + allowCompactSuffix: true, resolveAssetSources }) expect(candidates[0].isMissing).toBe(true) }) - it('verifies OSS output candidates against generated history without cloud assets', async () => { + it('verifies OSS output candidates against generated history alongside the unified input listing', async () => { const candidates = [ makeCandidate('1', 'subfolder/photo.png [output]', { isMissing: undefined @@ -703,9 +686,11 @@ describe('verifyMediaCandidates', () => { hasMore: false }) - await verifyMediaCandidates(candidates, { isCloud: false }) + await verifyMediaCandidates(candidates, { allowCompactSuffix: false }) - expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled() + expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith( + expect.any(AbortSignal) + ) expect(mockFetchHistoryPage).toHaveBeenCalledWith( expect.any(Function), 200, @@ -724,13 +709,12 @@ describe('verifyMediaCandidates', () => { const resolveAssetSources = makeAssetResolver([makeAsset('photo.png')]) await verifyMediaCandidates(candidates, { - isCloud: false, + allowCompactSuffix: false, resolveAssetSources }) expect(resolveAssetSources).toHaveBeenCalledWith({ signal: undefined, - isCloud: false, includeGeneratedAssets: false, generatedMatchNames: new Set(), allowCompactSuffix: false @@ -748,7 +732,7 @@ describe('verifyMediaCandidates', () => { ) await verifyMediaCandidates(candidates, { - isCloud: true, + allowCompactSuffix: true, resolveAssetSources }) @@ -761,7 +745,7 @@ describe('verifyMediaCandidates', () => { ] await verifyMediaCandidates(candidates, { - isCloud: true, + allowCompactSuffix: true, resolveAssetSources: makeAssetResolver([]) }) @@ -776,7 +760,7 @@ describe('verifyMediaCandidates', () => { makeAsset('stored-photo.png', existingHash) ]) - await verifyMediaCandidates(candidates, { isCloud: true }) + await verifyMediaCandidates(candidates, { allowCompactSuffix: true }) expect(candidates[0].isMissing).toBe(false) expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith( @@ -786,6 +770,7 @@ describe('verifyMediaCandidates', () => { }) it('reads cloud output assets by tag for output candidates', async () => { + isCloudHolder.value = true const outputHash = '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png' const candidates = [ @@ -795,7 +780,7 @@ describe('verifyMediaCandidates', () => { makeAssetPage([makeAsset(outputHash)]) ) - await verifyMediaCandidates(candidates, { isCloud: true }) + await verifyMediaCandidates(candidates, { allowCompactSuffix: true }) expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith( expect.any(AbortSignal) @@ -837,7 +822,7 @@ describe('verifyMediaCandidates', () => { hasMore: false }) - await verifyMediaCandidates(candidates, { isCloud: false }) + await verifyMediaCandidates(candidates, { allowCompactSuffix: false }) expect(mockFetchHistoryPage).toHaveBeenNthCalledWith( 1, @@ -870,7 +855,7 @@ describe('verifyMediaCandidates', () => { hasMore: false }) - await verifyMediaCandidates(candidates, { isCloud: false }) + await verifyMediaCandidates(candidates, { allowCompactSuffix: false }) expect(mockFetchHistoryPage).toHaveBeenCalledOnce() expect(candidates[0].isMissing).toBe(true) @@ -885,7 +870,7 @@ describe('verifyMediaCandidates', () => { ] await verifyMediaCandidates(candidates, { - isCloud: true, + allowCompactSuffix: true, signal: controller.signal }) @@ -907,7 +892,7 @@ describe('verifyMediaCandidates', () => { }) await verifyMediaCandidates(candidates, { - isCloud: true, + allowCompactSuffix: true, signal: controller.signal, resolveAssetSources }) @@ -918,7 +903,7 @@ describe('verifyMediaCandidates', () => { it('skips candidates already resolved as true', async () => { const candidates = [makeCandidate('1', missingHash, { isMissing: true })] - await verifyMediaCandidates(candidates, { isCloud: true }) + await verifyMediaCandidates(candidates, { allowCompactSuffix: true }) expect(candidates[0].isMissing).toBe(true) expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled() @@ -927,7 +912,7 @@ describe('verifyMediaCandidates', () => { it('skips candidates already resolved as false', async () => { const candidates = [makeCandidate('1', existingHash, { isMissing: false })] - await verifyMediaCandidates(candidates, { isCloud: true }) + await verifyMediaCandidates(candidates, { allowCompactSuffix: true }) expect(candidates[0].isMissing).toBe(false) expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled() @@ -936,7 +921,7 @@ describe('verifyMediaCandidates', () => { it('skips entirely when no pending candidates', async () => { const candidates = [makeCandidate('1', missingHash, { isMissing: true })] - await verifyMediaCandidates(candidates, { isCloud: true }) + await verifyMediaCandidates(candidates, { allowCompactSuffix: true }) expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled() }) @@ -951,7 +936,7 @@ describe('verifyMediaCandidates', () => { inputAssets[42] = makeAsset('public-asset-record', 'public-photo.png') mockGetInputAssetsIncludingPublic.mockResolvedValue(inputAssets) - await verifyMediaCandidates(candidates, { isCloud: true }) + await verifyMediaCandidates(candidates, { allowCompactSuffix: true }) expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith( expect.any(AbortSignal) @@ -973,7 +958,7 @@ describe('verifyMediaCandidates', () => { await expect( verifyMediaCandidates(candidates, { - isCloud: true, + allowCompactSuffix: true, signal: controller.signal, resolveAssetSources }) @@ -1000,7 +985,7 @@ describe('verifyMediaCandidates', () => { await expect( verifyMediaCandidates(candidates, { - isCloud: true, + allowCompactSuffix: true, signal: controller.signal }) ).resolves.toBeUndefined() diff --git a/src/platform/missingMedia/missingMediaScan.ts b/src/platform/missingMedia/missingMediaScan.ts index bf8bb7b3a26..17c9c270a18 100644 --- a/src/platform/missingMedia/missingMediaScan.ts +++ b/src/platform/missingMedia/missingMediaScan.ts @@ -17,7 +17,6 @@ import { getExecutionIdByNode } from '@/utils/graphTraversalUtil' import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums' -import { resolveComboValues } from '@/utils/litegraphUtil' import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import { isAbortError } from '@/utils/typeGuardUtil' import { @@ -49,13 +48,13 @@ function isComboWidget(widget: IBaseWidget): widget is IComboWidget { /** * Scan combo widgets on media nodes for file values that may be missing. * - * OSS: `isMissing` is resolved immediately via widget options unless an - * output annotation needs generated-history verification. - * Cloud: `isMissing` left `undefined` for async verification. + * Candidates leave `isMissing` as `undefined`; resolution happens + * asynchronously in `verifyMediaCandidates` against the unified asset + * listing. Both backends consult the same oracle (per RFC BE-808 v2 / + * BE-933 + BE-934). */ export function scanAllMediaCandidates( - rootGraph: LGraph, - isCloud: boolean + rootGraph: LGraph ): MissingMediaCandidate[] { if (!rootGraph) return [] @@ -71,17 +70,16 @@ export function scanAllMediaCandidates( ) continue - candidates.push(...scanNodeMediaCandidates(rootGraph, node, isCloud)) + candidates.push(...scanNodeMediaCandidates(rootGraph, node)) } return candidates } -/** Scan a single node for missing media candidates (OSS immediate resolution). */ +/** Scan a single node for missing media candidates (async resolution). */ export function scanNodeMediaCandidates( rootGraph: LGraph, - node: LGraphNode, - isCloud: boolean + node: LGraphNode ): MissingMediaCandidate[] { if (!node.widgets?.length) return [] @@ -100,30 +98,13 @@ export function scanNodeMediaCandidates( const value = widget.value if (typeof value !== 'string' || !value.trim()) continue - let isMissing: boolean | undefined - if (isCloud) { - isMissing = undefined - } else { - const type = getAnnotatedMediaPathTypeForDetection(value) - if (type === 'output') { - isMissing = undefined - } else { - const options = resolveComboValues(widget) - const detectionNames = getMediaPathDetectionNames(value) - const existsInOptions = detectionNames.some((name) => - options.includes(name) - ) - isMissing = !existsInOptions - } - } - candidates.push({ nodeId: executionId as NodeId, nodeType: node.type, widgetName: widget.name, mediaType: mediaInfo.mediaType, name: value, - isMissing + isMissing: undefined }) } @@ -131,27 +112,33 @@ export function scanNodeMediaCandidates( } interface MediaVerificationOptions { - isCloud: boolean + /** + * Whether to accept compact `file.png[input]` suffix annotations in + * addition to the canonical spaced `file.png [input]` form. Cloud emits + * compact annotations on legacy widget values. Tracked as N1 in the + * RFC; retained until widget values stop being filenames. + */ + allowCompactSuffix: boolean signal?: AbortSignal resolveAssetSources?: MissingMediaAssetResolver } /** - * Verify media candidates against assets available to the current runtime. + * Verify media candidates against the unified asset listing. * - * A candidate's `name` may be either a filename or an opaque asset hash. - * Cloud-side `hash` is not guaranteed to follow a single shape, so we - * match against the union of `asset.name` and `asset.hash`. Output - * candidates are matched against Cloud output assets or Core generated-history - * assets because Core resolves those annotations against output folders, not - * input files. + * A candidate's `name` is the widget-value string (filename or annotated + * path). It is matched against each asset's `file_path` (canonical key) and, + * for assets where `file_path` is null, the legacy union of `hash` / `name` / + * `subfolder + name`. Output candidates are matched against Cloud output assets + * or Core generated-history assets because Core resolves those annotations + * against output folders, not input files; everything else against input assets. * Cloud accepts compact annotated media paths, so only Cloud verification * normalizes compact suffixes. */ export async function verifyMediaCandidates( candidates: MissingMediaCandidate[], { - isCloud, + allowCompactSuffix, signal, resolveAssetSources = resolveMissingMediaAssetSources }: MediaVerificationOptions @@ -161,9 +148,7 @@ export async function verifyMediaCandidates( const pending = candidates.filter((c) => c.isMissing === undefined) if (pending.length === 0) return - // Core stores spaced annotations such as `file.png [output]`; Cloud also - // accepts compact forms such as `file.png[output]`. - const pathOptions = { allowCompactSuffix: isCloud } + const pathOptions = { allowCompactSuffix } const generatedMatchNames = getGeneratedCandidateMatchNames( pending, pathOptions @@ -174,10 +159,9 @@ export async function verifyMediaCandidates( try { const assetSources = await resolveAssetSources({ signal, - isCloud, includeGeneratedAssets: generatedMatchNames.size > 0, generatedMatchNames, - allowCompactSuffix: isCloud + allowCompactSuffix }) inputAssets = assetSources.inputAssets generatedAssets = assetSources.generatedAssets diff --git a/src/platform/missingModel/missingModelPipeline.test.ts b/src/platform/missingModel/missingModelPipeline.test.ts index b6c1b1cda3d..c037408388a 100644 --- a/src/platform/missingModel/missingModelPipeline.test.ts +++ b/src/platform/missingModel/missingModelPipeline.test.ts @@ -402,6 +402,36 @@ describe('missingModelPipeline', () => { }) }) + it('soft-degrades when loading model folders fails so graph load is not aborted', async () => { + mockHandles.modelStore.loadModelFolders.mockRejectedValueOnce( + new Error('Unable to load model folders: Server returned 500.') + ) + mockHandles.state.enrichedCandidates = [] + + await expect( + runMissingModelPipeline({ + graph: createGraph(), + graphData: createWorkflowGraphData(), + missingModelStore: mockHandles.missingModelStore + }) + ).resolves.toEqual({ missingModels: [], confirmedCandidates: [] }) + + expect(mockHandles.enrichWithEmbeddedMetadata).toHaveBeenCalled() + }) + + it('re-throws abort errors from model folder loading', async () => { + const abortError = new DOMException('Aborted', 'AbortError') + mockHandles.modelStore.loadModelFolders.mockRejectedValueOnce(abortError) + + await expect( + runMissingModelPipeline({ + graph: createGraph(), + graphData: createWorkflowGraphData(), + missingModelStore: mockHandles.missingModelStore + }) + ).rejects.toBe(abortError) + }) + it('does not expose downloadable model metadata without a directory', async () => { const confirmedCandidate = { nodeType: 'CheckpointLoaderSimple', diff --git a/src/platform/missingModel/missingModelPipeline.ts b/src/platform/missingModel/missingModelPipeline.ts index 8ccb71018ed..548a0da90e7 100644 --- a/src/platform/missingModel/missingModelPipeline.ts +++ b/src/platform/missingModel/missingModelPipeline.ts @@ -23,6 +23,26 @@ import { isAncestorPathActive, isMissingCandidateActive } from '@/utils/graphTraversalUtil' +import { isAbortError } from '@/utils/typeGuardUtil' + +type ModelStore = ReturnType + +/** + * Load model folders without letting an asset-listing failure abort graph + * load. A failed `/api/assets` oracle degrades to no enumerated folders so + * the rest of the asset-scan phase (including missing media) still runs. + */ +async function loadModelFoldersSoftly(modelStore: ModelStore): Promise { + try { + await modelStore.loadModelFolders() + } catch (err) { + if (isAbortError(err)) throw err + console.warn( + '[Missing Model Pipeline] Failed to load model folders; degrading to empty.', + err + ) + } +} export interface MissingModelPipelineResult { missingModels: ModelFile[] @@ -122,7 +142,7 @@ export async function runMissingModelPipeline({ ) const modelStore = useModelStore() - await modelStore.loadModelFolders() + await loadModelFoldersSoftly(modelStore) const enrichedAll = await enrichWithEmbeddedMetadata( candidates, graphData, diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 59d3ef4b076..6288061c9fb 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -1543,7 +1543,7 @@ export class ComfyApp { ): Promise { const missingMediaStore = useMissingMediaStore() const activeWf = useWorkspaceStore().workflow.activeWorkflow - const allCandidates = scanAllMediaCandidates(this.rootGraph, isCloud) + const allCandidates = scanAllMediaCandidates(this.rootGraph) // Drop candidates whose enclosing subgraph is muted/bypassed. const candidates = allCandidates.filter((c) => isAncestorPathActive(this.rootGraph, String(c.nodeId)) @@ -1558,7 +1558,7 @@ export class ComfyApp { if (pending) { const controller = missingMediaStore.createVerificationAbortController() void verifyMediaCandidates(candidates, { - isCloud, + allowCompactSuffix: isCloud, signal: controller.signal }) .then(() => {