fix: detect missing nodes when registry API fails to resolve packs (#9697)

This commit is contained in:
Jin Yi
2026-03-11 07:56:46 +09:00
committed by GitHub
parent e89a0f96cd
commit f97c38e6ee
8 changed files with 178 additions and 10 deletions

View File

@@ -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",

View File

@@ -128,6 +128,10 @@
<div v-if="isLoading" class="size-full scrollbar-hide overflow-auto">
<GridSkeleton :grid-style="GRID_STYLE" :skeleton-card-count />
</div>
<UnresolvedNodesMessage
v-else-if="isUnresolvedTab"
:node-names="unresolvedNodeNames"
/>
<NoResultsPlaceholder
v-else-if="displayPacks.length === 0"
:title="emptyStateTitle"
@@ -199,6 +203,7 @@ import InfoPanel from '@/workbench/extensions/manager/components/manager/infoPan
import InfoPanelMultiItem from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanelMultiItem.vue'
import PackCard from '@/workbench/extensions/manager/components/manager/packCard/PackCard.vue'
import GridSkeleton from '@/workbench/extensions/manager/components/manager/skeleton/GridSkeleton.vue'
import UnresolvedNodesMessage from '@/workbench/extensions/manager/components/manager/UnresolvedNodesMessage.vue'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useUpdateAvailableNodes } from '@/workbench/extensions/manager/composables/nodePack/useUpdateAvailableNodes'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
@@ -246,6 +251,7 @@ const {
// Missing nodes composable
const {
missingNodePacks,
unresolvedNodeNames,
isLoading: isMissingLoading,
error: missingError
} = useMissingNodes()
@@ -309,7 +315,17 @@ const navItems = computed<(NavItemData | NavGroupData)[]>(() => [
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<Record<ManagerTab, string>> = {

View File

@@ -0,0 +1,26 @@
<template>
<div class="flex flex-col items-center gap-3 p-8">
<i class="icon-[lucide--triangle-alert] text-4xl text-warning-background" />
<h3 class="text-base font-semibold">
{{ $t('manager.unresolvedNodes.title') }}
</h3>
<p class="text-center text-sm text-muted-foreground">
{{ $t('manager.unresolvedNodes.message') }}
</p>
<ul class="mt-2 flex flex-col gap-1 rounded-lg bg-secondary-background p-2">
<li
v-for="name in nodeNames"
:key="name"
class="px-3 py-1.5 font-mono text-sm"
>
{{ name }}
</li>
</ul>
</div>
</template>
<script setup lang="ts">
defineProps<{
nodeNames: string[]
}>()
</script>

View File

@@ -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<WorkflowPack[]>([])
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<string[]>([])
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({

View File

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

View File

@@ -31,6 +31,7 @@ const _useWorkflowPacks = () => {
const { inferPackFromNodeName } = useComfyRegistryStore()
const workflowPacks = ref<WorkflowPack[]>([])
const unresolvedNodeNames = ref<string[]>([])
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

View File

@@ -176,6 +176,9 @@ export function useManagerDisplayPacks(
return sortPacks(filterNotInstalled(base))
}
case ManagerTab.Unresolved:
return []
default:
return searchResults.value
}

View File

@@ -19,7 +19,8 @@ export enum ManagerTab {
UpdateAvailable = 'updateAvailable',
Conflicting = 'conflicting',
Workflow = 'workflow',
Missing = 'missing'
Missing = 'missing',
Unresolved = 'unresolved'
}
export type TaskLog = {