From f97c38e6ee2d6247e2948a6627c857a16802a879 Mon Sep 17 00:00:00 2001 From: Jin Yi Date: Wed, 11 Mar 2026 07:56:46 +0900 Subject: [PATCH] fix: detect missing nodes when registry API fails to resolve packs (#9697) --- src/locales/en/main.json | 7 +- .../components/manager/ManagerDialog.vue | 27 ++++++- .../manager/UnresolvedNodesMessage.vue | 26 +++++++ .../nodePack/useMissingNodes.test.ts | 72 +++++++++++++++++++ .../composables/nodePack/useMissingNodes.ts | 13 +++- .../composables/nodePack/useWorkflowPacks.ts | 37 ++++++++-- .../composables/useManagerDisplayPacks.ts | 3 + .../manager/types/comfyManagerTypes.ts | 3 +- 8 files changed, 178 insertions(+), 10 deletions(-) create mode 100644 src/workbench/extensions/manager/components/manager/UnresolvedNodesMessage.vue diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 9d3b7f1baa..39d4d09444 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -340,7 +340,8 @@ "conflicting": "Conflicting", "inWorkflowSection": "IN WORKFLOW", "allInWorkflow": "All in: {workflowName}", - "missingNodes": "Missing Nodes" + "missingNodes": "Missing Nodes", + "unresolvedNodes": "Unresolved Nodes" }, "infoPanelEmpty": "Click an item to see the info", "applyChanges": "Apply Changes", @@ -405,6 +406,10 @@ "noDescription": "No description available", "installSelected": "Install Selected", "installAllMissingNodes": "Install All", + "unresolvedNodes": { + "title": "Unresolved Missing Nodes", + "message": "The following nodes are not installed and could not be found in the registry." + }, "allMissingNodesInstalled": "All missing nodes have been successfully installed", "packsSelected": "packs selected", "mixedSelectionMessage": "Cannot perform bulk action on mixed selection", diff --git a/src/workbench/extensions/manager/components/manager/ManagerDialog.vue b/src/workbench/extensions/manager/components/manager/ManagerDialog.vue index 8bdf696a0b..f676818aab 100644 --- a/src/workbench/extensions/manager/components/manager/ManagerDialog.vue +++ b/src/workbench/extensions/manager/components/manager/ManagerDialog.vue @@ -128,6 +128,10 @@
+ (() => [ id: ManagerTab.Missing, label: t('manager.nav.missingNodes'), icon: 'icon-[lucide--triangle-alert]' - } + }, + ...(unresolvedNodeNames.value.length > 0 + ? [ + { + id: ManagerTab.Unresolved, + label: t('manager.nav.unresolvedNodes'), + icon: 'icon-[lucide--help-circle]', + badge: unresolvedNodeNames.value.length + } + ] + : []) ] } ]) @@ -337,6 +353,12 @@ const selectedTab = computed(() => findNavItemById(navItems.value, selectedNavId.value) ) +watch(navItems, (items) => { + if (selectedNavId.value && !findNavItemById(items, selectedNavId.value)) { + selectedNavId.value = ManagerTab.Missing + } +}) + const { searchQuery, pageNumber, @@ -403,6 +425,9 @@ const isUpdateAvailableTab = computed( const isMissingTab = computed( () => selectedTab.value?.id === ManagerTab.Missing ) +const isUnresolvedTab = computed( + () => selectedTab.value?.id === ManagerTab.Unresolved +) // Map of tab IDs to their empty state i18n key suffixes const tabEmptyStateKeys: Partial> = { diff --git a/src/workbench/extensions/manager/components/manager/UnresolvedNodesMessage.vue b/src/workbench/extensions/manager/components/manager/UnresolvedNodesMessage.vue new file mode 100644 index 0000000000..e766eb5c88 --- /dev/null +++ b/src/workbench/extensions/manager/components/manager/UnresolvedNodesMessage.vue @@ -0,0 +1,26 @@ + + + diff --git a/src/workbench/extensions/manager/composables/nodePack/useMissingNodes.test.ts b/src/workbench/extensions/manager/composables/nodePack/useMissingNodes.test.ts index a5da7d5245..6a9d2b645a 100644 --- a/src/workbench/extensions/manager/composables/nodePack/useMissingNodes.test.ts +++ b/src/workbench/extensions/manager/composables/nodePack/useMissingNodes.test.ts @@ -94,6 +94,7 @@ describe('useMissingNodes', () => { mockUseWorkflowPacks.mockReturnValue({ workflowPacks: ref([]), + unresolvedNodeNames: ref([]), isLoading: ref(false), error: ref(null), startFetchWorkflowPacks: mockStartFetchWorkflowPacks, @@ -119,6 +120,7 @@ describe('useMissingNodes', () => { it('filters out installed packs correctly', () => { mockUseWorkflowPacks.mockReturnValue({ workflowPacks: ref(mockWorkflowPacks), + unresolvedNodeNames: ref([]), isLoading: ref(false), error: ref(null), startFetchWorkflowPacks: mockStartFetchWorkflowPacks, @@ -140,6 +142,7 @@ describe('useMissingNodes', () => { it('returns empty array when all packs are installed', () => { mockUseWorkflowPacks.mockReturnValue({ workflowPacks: ref(mockWorkflowPacks), + unresolvedNodeNames: ref([]), isLoading: ref(false), error: ref(null), startFetchWorkflowPacks: mockStartFetchWorkflowPacks, @@ -158,6 +161,7 @@ describe('useMissingNodes', () => { it('returns all packs when none are installed', () => { mockUseWorkflowPacks.mockReturnValue({ workflowPacks: ref(mockWorkflowPacks), + unresolvedNodeNames: ref([]), isLoading: ref(false), error: ref(null), startFetchWorkflowPacks: mockStartFetchWorkflowPacks, @@ -191,6 +195,7 @@ describe('useMissingNodes', () => { it('fetches even when packs already exist (watch always fires with immediate:true)', async () => { mockUseWorkflowPacks.mockReturnValue({ workflowPacks: ref(mockWorkflowPacks), + unresolvedNodeNames: ref([]), isLoading: ref(false), error: ref(null), startFetchWorkflowPacks: mockStartFetchWorkflowPacks, @@ -206,6 +211,7 @@ describe('useMissingNodes', () => { it('fetches even when already loading (watch fires regardless of loading state)', async () => { mockUseWorkflowPacks.mockReturnValue({ workflowPacks: ref([]), + unresolvedNodeNames: ref([]), isLoading: ref(true), error: ref(null), startFetchWorkflowPacks: mockStartFetchWorkflowPacks, @@ -223,6 +229,7 @@ describe('useMissingNodes', () => { it('exposes loading state from useWorkflowPacks', () => { mockUseWorkflowPacks.mockReturnValue({ workflowPacks: ref([]), + unresolvedNodeNames: ref([]), isLoading: ref(true), error: ref(null), startFetchWorkflowPacks: mockStartFetchWorkflowPacks, @@ -239,6 +246,7 @@ describe('useMissingNodes', () => { const testError = 'Failed to fetch workflow packs' mockUseWorkflowPacks.mockReturnValue({ workflowPacks: ref([]), + unresolvedNodeNames: ref([]), isLoading: ref(false), error: ref(testError), startFetchWorkflowPacks: mockStartFetchWorkflowPacks, @@ -257,6 +265,7 @@ describe('useMissingNodes', () => { const workflowPacksRef = ref([]) mockUseWorkflowPacks.mockReturnValue({ workflowPacks: workflowPacksRef, + unresolvedNodeNames: ref([]), isLoading: ref(false), error: ref(null), startFetchWorkflowPacks: mockStartFetchWorkflowPacks, @@ -283,6 +292,7 @@ describe('useMissingNodes', () => { const workflowPacksRef = ref(mockWorkflowPacks) mockUseWorkflowPacks.mockReturnValue({ workflowPacks: workflowPacksRef, + unresolvedNodeNames: ref([]), isLoading: ref(false), error: ref(null), startFetchWorkflowPacks: mockStartFetchWorkflowPacks, @@ -306,6 +316,68 @@ describe('useMissingNodes', () => { }) }) + describe('unresolved nodes', () => { + it('reports hasMissingNodes when unresolvedNodeNames is non-empty', () => { + mockUseWorkflowPacks.mockReturnValue({ + workflowPacks: ref([]), + unresolvedNodeNames: ref(['UnknownNode1', 'UnknownNode2']), + isLoading: ref(false), + error: ref(null), + startFetchWorkflowPacks: mockStartFetchWorkflowPacks, + isReady: ref(true), + filterWorkflowPack: vi.fn() + }) + + const { hasMissingNodes, unresolvedNodeNames } = useMissingNodes() + + expect(hasMissingNodes.value).toBe(true) + expect(unresolvedNodeNames.value).toEqual([ + 'UnknownNode1', + 'UnknownNode2' + ]) + }) + + it('does not report hasMissingNodes when unresolvedNodeNames is empty', () => { + mockUseWorkflowPacks.mockReturnValue({ + workflowPacks: ref([]), + unresolvedNodeNames: ref([]), + isLoading: ref(false), + error: ref(null), + startFetchWorkflowPacks: mockStartFetchWorkflowPacks, + isReady: ref(true), + filterWorkflowPack: vi.fn() + }) + + const { hasMissingNodes } = useMissingNodes() + + expect(hasMissingNodes.value).toBe(false) + }) + + it('updates reactively when unresolvedNodeNames changes', async () => { + const unresolvedRef = ref([]) + mockUseWorkflowPacks.mockReturnValue({ + workflowPacks: ref([]), + unresolvedNodeNames: unresolvedRef, + isLoading: ref(false), + error: ref(null), + startFetchWorkflowPacks: mockStartFetchWorkflowPacks, + isReady: ref(true), + filterWorkflowPack: vi.fn() + }) + + const { hasMissingNodes, unresolvedNodeNames } = useMissingNodes() + + expect(hasMissingNodes.value).toBe(false) + expect(unresolvedNodeNames.value).toEqual([]) + + unresolvedRef.value = ['NewMissingNode'] + await nextTick() + + expect(hasMissingNodes.value).toBe(true) + expect(unresolvedNodeNames.value).toEqual(['NewMissingNode']) + }) + }) + describe('missing core nodes detection', () => { const createMockNode = (type: string, packId?: string, version?: string) => createMockLGraphNode({ diff --git a/src/workbench/extensions/manager/composables/nodePack/useMissingNodes.ts b/src/workbench/extensions/manager/composables/nodePack/useMissingNodes.ts index 24daee3090..45760622dd 100644 --- a/src/workbench/extensions/manager/composables/nodePack/useMissingNodes.ts +++ b/src/workbench/extensions/manager/composables/nodePack/useMissingNodes.ts @@ -21,8 +21,13 @@ export const useMissingNodes = createSharedComposable(() => { const nodeDefStore = useNodeDefStore() const comfyManagerStore = useComfyManagerStore() const workflowStore = useWorkflowStore() - const { workflowPacks, isLoading, error, startFetchWorkflowPacks } = - useWorkflowPacks() + const { + workflowPacks, + unresolvedNodeNames, + isLoading, + error, + startFetchWorkflowPacks + } = useWorkflowPacks() const filterMissingPacks = (packs: components['schemas']['Node'][]) => packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id)) @@ -67,7 +72,8 @@ export const useMissingNodes = createSharedComposable(() => { const hasMissingNodes = computed(() => { return ( missingNodePacks.value.length > 0 || - Object.keys(missingCoreNodes.value).length > 0 + Object.keys(missingCoreNodes.value).length > 0 || + unresolvedNodeNames.value.length > 0 ) }) @@ -83,6 +89,7 @@ export const useMissingNodes = createSharedComposable(() => { return { missingNodePacks, missingCoreNodes, + unresolvedNodeNames, hasMissingNodes, isLoading, error diff --git a/src/workbench/extensions/manager/composables/nodePack/useWorkflowPacks.ts b/src/workbench/extensions/manager/composables/nodePack/useWorkflowPacks.ts index 5fa3fcf385..0c45c2d493 100644 --- a/src/workbench/extensions/manager/composables/nodePack/useWorkflowPacks.ts +++ b/src/workbench/extensions/manager/composables/nodePack/useWorkflowPacks.ts @@ -31,6 +31,7 @@ const _useWorkflowPacks = () => { const { inferPackFromNodeName } = useComfyRegistryStore() const workflowPacks = ref([]) + const unresolvedNodeNames = ref([]) const getWorkflowNodePackId = (node: LGraphNode): string | undefined => { if (typeof node.properties?.cnr_id === 'string') { @@ -111,12 +112,39 @@ const _useWorkflowPacks = () => { /** * Get the node packs for all nodes in the workflow (including subgraphs). + * Nodes that have no local definition and no registry match are tracked + * as unresolved so downstream consumers can surface them to the user. */ const getWorkflowPacks = async () => { - if (!app.rootGraph) return [] - const packPromises = mapAllNodes(app.rootGraph, workflowNodeToPack) - const packs = await Promise.all(packPromises) - workflowPacks.value = packs.filter((pack) => pack !== undefined) + if (!app.rootGraph) { + workflowPacks.value = [] + unresolvedNodeNames.value = [] + return + } + + const resolvedPacks: WorkflowPack[] = [] + const unresolved: string[] = [] + + await Promise.all( + mapAllNodes(app.rootGraph, async (node) => { + const pack = await workflowNodeToPack(node) + if (pack) { + resolvedPacks.push(pack) + } else { + const nodeName = node.type + if ( + nodeName && + getWorkflowNodePackId(node) === undefined && + !nodeDefStore.nodeDefsByName[nodeName] + ) { + unresolved.push(nodeName) + } + } + }) + ) + + workflowPacks.value = resolvedPacks + unresolvedNodeNames.value = [...new Set(unresolved)] } const packsToUniqueIds = (packs: WorkflowPack[]) => @@ -147,6 +175,7 @@ const _useWorkflowPacks = () => { isLoading, isReady, workflowPacks: nodePacks, + unresolvedNodeNames, startFetchWorkflowPacks: async () => { await getWorkflowPacks() // Parse the packs from the workflow nodes await startFetch() // Fetch the packs infos from the registry diff --git a/src/workbench/extensions/manager/composables/useManagerDisplayPacks.ts b/src/workbench/extensions/manager/composables/useManagerDisplayPacks.ts index fe725ba1cc..bab85c33d3 100644 --- a/src/workbench/extensions/manager/composables/useManagerDisplayPacks.ts +++ b/src/workbench/extensions/manager/composables/useManagerDisplayPacks.ts @@ -176,6 +176,9 @@ export function useManagerDisplayPacks( return sortPacks(filterNotInstalled(base)) } + case ManagerTab.Unresolved: + return [] + default: return searchResults.value } diff --git a/src/workbench/extensions/manager/types/comfyManagerTypes.ts b/src/workbench/extensions/manager/types/comfyManagerTypes.ts index b009ec64d7..a9327b4572 100644 --- a/src/workbench/extensions/manager/types/comfyManagerTypes.ts +++ b/src/workbench/extensions/manager/types/comfyManagerTypes.ts @@ -19,7 +19,8 @@ export enum ManagerTab { UpdateAvailable = 'updateAvailable', Conflicting = 'conflicting', Workflow = 'workflow', - Missing = 'missing' + Missing = 'missing', + Unresolved = 'unresolved' } export type TaskLog = {