feat: show missing node packs in Errors Tab with install support (#9213)

## Summary

Surfaces missing node pack information in the Errors Tab, grouped by
registry pack, with one-click install support via ComfyUI Manager.

## Changes

- **What**: Errors Tab now groups missing nodes by their registry pack
and shows a `MissingPackGroupRow` with pack name, node/pack counts, and
an Install button that triggers Manager installation. A
`MissingNodeCard` shows individual unresolvable nodes that have no
associated pack. `useErrorGroups` was extended to resolve missing node
types to their registry packs using the `/api/workflow/missing_nodes`
endpoint. `executionErrorStore` was refactored to track missing node
types separately from execution errors and expose them reactively.
- **Breaking**: None

## Review Focus

- `useErrorGroups.ts` — the new `resolveMissingNodePacks` logic fetches
pack metadata and maps node types to pack IDs; edge cases around partial
resolution (some nodes have a pack, some don't) produce both
`MissingPackGroupRow` and `MissingNodeCard` entries
- `executionErrorStore.ts` — the store now separates `missingNodeTypes`
state from `errors`; the deferred-warnings path in `app.ts` now calls
`setMissingNodeTypes` so the Errors Tab is populated even when a
workflow loads without executing

## Screenshots (if applicable)


https://github.com/user-attachments/assets/97f8d009-0cac-4739-8740-fd3333b5a85b


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9213-feat-show-missing-node-packs-in-Errors-Tab-with-install-support-3126d73d36508197bc4bf8ebfd2125c8)
by [Unito](https://www.unito.io)
This commit is contained in:
jaeone94
2026-02-26 13:25:47 +09:00
committed by GitHub
parent c24c4ab607
commit 80fe51bb8c
21 changed files with 1075 additions and 151 deletions

View File

@@ -1,7 +1,10 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { st } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import type {
@@ -10,8 +13,13 @@ import type {
PromptError
} from '@/schemas/apiSchema'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
getAncestorExecutionIds,
getParentExecutionIds
} from '@/types/nodeIdentification'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import type { MissingNodeType } from '@/types/comfy'
import {
executionIdToNodeLocatorId,
forEachNode,
@@ -19,13 +27,50 @@ import {
getExecutionIdByNode
} from '@/utils/graphTraversalUtil'
/**
* Store dedicated to execution error state management.
*
* Extracted from executionStore to separate error-related concerns
* (state, computed properties, graph flag propagation, overlay UI)
* from execution flow management (progress, queuing, events).
*/
interface MissingNodesError {
message: string
nodeTypes: MissingNodeType[]
}
function clearAllNodeErrorFlags(rootGraph: LGraph): void {
forEachNode(rootGraph, (node) => {
node.has_errors = false
if (node.inputs) {
for (const slot of node.inputs) {
slot.hasErrors = false
}
}
})
}
function markNodeSlotErrors(node: LGraphNode, nodeError: NodeError): void {
if (!node.inputs) return
for (const error of nodeError.errors) {
const slotName = error.extra_info?.input_name
if (!slotName) continue
const slot = node.inputs.find((s) => s.name === slotName)
if (slot) slot.hasErrors = true
}
}
function applyNodeError(
rootGraph: LGraph,
executionId: NodeExecutionId,
nodeError: NodeError
): void {
const node = getNodeByExecutionId(rootGraph, executionId)
if (!node) return
node.has_errors = true
markNodeSlotErrors(node, nodeError)
for (const parentId of getParentExecutionIds(executionId)) {
const parentNode = getNodeByExecutionId(rootGraph, parentId)
if (parentNode) parentNode.has_errors = true
}
}
/** Execution error state: node errors, runtime errors, prompt errors, and missing nodes. */
export const useExecutionErrorStore = defineStore('executionError', () => {
const workflowStore = useWorkflowStore()
const canvasStore = useCanvasStore()
@@ -33,6 +78,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
const lastNodeErrors = ref<Record<NodeId, NodeError> | null>(null)
const lastExecutionError = ref<ExecutionErrorWsMessage | null>(null)
const lastPromptError = ref<PromptError | null>(null)
const missingNodesError = ref<MissingNodesError | null>(null)
const isErrorOverlayOpen = ref(false)
@@ -49,6 +95,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
lastExecutionError.value = null
lastPromptError.value = null
lastNodeErrors.value = null
missingNodesError.value = null
isErrorOverlayOpen.value = false
}
@@ -57,6 +104,48 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
lastPromptError.value = null
}
/** Set missing node types and open the error overlay if the Errors tab is enabled. */
function surfaceMissingNodes(types: MissingNodeType[]) {
setMissingNodeTypes(types)
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
showErrorOverlay()
}
}
function setMissingNodeTypes(types: MissingNodeType[]) {
if (!types.length) {
missingNodesError.value = null
return
}
const seen = new Set<string>()
const uniqueTypes = types.filter((node) => {
// For string entries (group nodes), deduplicate by the string itself.
// For object entries, prefer nodeId so multiple instances of the same
// type are kept as separate rows; fall back to type if nodeId is absent.
const isString = typeof node === 'string'
let key: string
if (isString) {
key = node
} else if (node.nodeId != null) {
key = String(node.nodeId)
} else {
key = node.type
}
if (seen.has(key)) return false
seen.add(key)
return true
})
missingNodesError.value = {
message: isCloud
? st(
'rightSidePanel.missingNodePacks.unsupportedTitle',
'Unsupported Node Packs'
)
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
nodeTypes: uniqueTypes
}
}
const lastExecutionErrorNodeLocatorId = computed(() => {
const err = lastExecutionError.value
if (!err) return null
@@ -81,9 +170,16 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
() => !!lastNodeErrors.value && Object.keys(lastNodeErrors.value).length > 0
)
/** Whether any error (node validation, runtime execution, or prompt-level) is present */
/** Whether any missing node types are present in the current workflow */
const hasMissingNodes = computed(() => !!missingNodesError.value)
/** Whether any error (node validation, runtime execution, prompt-level, or missing nodes) is present */
const hasAnyError = computed(
() => hasExecutionError.value || hasPromptError.value || hasNodeError.value
() =>
hasExecutionError.value ||
hasPromptError.value ||
hasNodeError.value ||
hasMissingNodes.value
)
const allErrorExecutionIds = computed<string[]>(() => {
@@ -116,13 +212,19 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
/** Count of runtime execution errors (0 or 1) */
const executionErrorCount = computed(() => (lastExecutionError.value ? 1 : 0))
/** Count of missing node errors (0 or 1) */
const missingNodeCount = computed(() => (missingNodesError.value ? 1 : 0))
/** Total count of all individual errors */
const totalErrorCount = computed(
() =>
promptErrorCount.value + nodeErrorCount.value + executionErrorCount.value
promptErrorCount.value +
nodeErrorCount.value +
executionErrorCount.value +
missingNodeCount.value
)
/** Pre-computed Set of graph node IDs (as strings) that have errors in the current graph scope. */
/** Graph node IDs (as strings) that have errors in the current graph scope. */
const activeGraphErrorNodeIds = computed<Set<string>>(() => {
const ids = new Set<string>()
if (!app.rootGraph) return ids
@@ -150,6 +252,44 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
return ids
})
/**
* Set of all execution ID prefixes derived from missing node execution IDs,
* including the missing nodes themselves.
*
* Example: missing node at "65:70:63" → Set { "65", "65:70", "65:70:63" }
*/
const missingAncestorExecutionIds = computed<Set<NodeExecutionId>>(() => {
const ids = new Set<NodeExecutionId>()
const error = missingNodesError.value
if (!error) return ids
for (const nodeType of error.nodeTypes) {
if (typeof nodeType === 'string') continue
if (nodeType.nodeId == null) continue
for (const id of getAncestorExecutionIds(String(nodeType.nodeId))) {
ids.add(id)
}
}
return ids
})
const activeMissingNodeGraphIds = computed<Set<string>>(() => {
const ids = new Set<string>()
if (!app.rootGraph) return ids
const activeGraph = canvasStore.currentGraph ?? app.rootGraph
for (const executionId of missingAncestorExecutionIds.value) {
const graphNode = getNodeByExecutionId(app.rootGraph, executionId)
if (graphNode?.graph === activeGraph) {
ids.add(String(graphNode.id))
}
}
return ids
})
/** Map of node errors indexed by locator ID. */
const nodeErrorsByLocatorId = computed<Record<NodeLocatorId, NodeError>>(
() => {
@@ -196,15 +336,11 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
*/
const errorAncestorExecutionIds = computed<Set<NodeExecutionId>>(() => {
const ids = new Set<NodeExecutionId>()
for (const executionId of allErrorExecutionIds.value) {
const parts = executionId.split(':')
// Add every prefix including the full ID (error leaf node itself)
for (let i = 1; i <= parts.length; i++) {
ids.add(parts.slice(0, i).join(':'))
for (const id of getAncestorExecutionIds(executionId)) {
ids.add(id)
}
}
return ids
})
@@ -216,59 +352,26 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
return errorAncestorExecutionIds.value.has(execId)
}
/**
* Update node and slot error flags when validation errors change.
* Propagates errors up subgraph chains.
*/
watch(lastNodeErrors, () => {
if (!app.rootGraph) return
/** True if the node has a missing node inside it at any nesting depth. */
function isContainerWithMissingNode(node: LGraphNode): boolean {
if (!app.rootGraph) return false
const execId = getExecutionIdByNode(app.rootGraph, node)
if (!execId) return false
return missingAncestorExecutionIds.value.has(execId)
}
// Clear all error flags
forEachNode(app.rootGraph, (node) => {
node.has_errors = false
if (node.inputs) {
for (const slot of node.inputs) {
slot.hasErrors = false
}
}
})
watch(lastNodeErrors, () => {
const rootGraph = app.rootGraph
if (!rootGraph) return
clearAllNodeErrorFlags(rootGraph)
if (!lastNodeErrors.value) return
// Set error flags on nodes and slots
for (const [executionId, nodeError] of Object.entries(
lastNodeErrors.value
)) {
const node = getNodeByExecutionId(app.rootGraph, executionId)
if (!node) continue
node.has_errors = true
// Mark input slots with errors
if (node.inputs) {
for (const error of nodeError.errors) {
const slotName = error.extra_info?.input_name
if (!slotName) continue
const slot = node.inputs.find((s) => s.name === slotName)
if (slot) {
slot.hasErrors = true
}
}
}
// Propagate errors to parent subgraph nodes
const parts = executionId.split(':')
for (let i = parts.length - 1; i > 0; i--) {
const parentExecutionId = parts.slice(0, i).join(':')
const parentNode = getNodeByExecutionId(
app.rootGraph,
parentExecutionId
)
if (parentNode) {
parentNode.has_errors = true
}
}
applyNodeError(rootGraph, executionId, nodeError)
}
})
@@ -277,6 +380,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
lastNodeErrors,
lastExecutionError,
lastPromptError,
missingNodesError,
// Clearing
clearAllErrors,
@@ -291,16 +395,22 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
hasExecutionError,
hasPromptError,
hasNodeError,
hasMissingNodes,
hasAnyError,
allErrorExecutionIds,
totalErrorCount,
lastExecutionErrorNodeId,
activeGraphErrorNodeIds,
activeMissingNodeGraphIds,
// Missing node actions
setMissingNodeTypes,
surfaceMissingNodes,
// Lookup helpers
getNodeErrors,
slotHasError,
errorAncestorExecutionIds,
isContainerWithInternalError
isContainerWithInternalError,
isContainerWithMissingNode
}
})

View File

@@ -345,3 +345,100 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
})
})
})
describe('useExecutionErrorStore - setMissingNodeTypes', () => {
let store: ReturnType<typeof useExecutionErrorStore>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
store = useExecutionErrorStore()
})
it('clears missingNodesError when called with an empty array', () => {
store.setMissingNodeTypes([{ type: 'NodeA' }])
store.setMissingNodeTypes([])
expect(store.missingNodesError).toBeNull()
})
it('hasMissingNodes is false when error is null', () => {
store.setMissingNodeTypes([])
expect(store.hasMissingNodes).toBe(false)
})
it('hasMissingNodes is true after setting non-empty types', () => {
store.setMissingNodeTypes([{ type: 'NodeA' }])
expect(store.hasMissingNodes).toBe(true)
})
it('deduplicates string entries by value', () => {
store.setMissingNodeTypes(['GroupNode', 'GroupNode', 'OtherGroup'])
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
expect(store.missingNodesError?.nodeTypes).toEqual([
'GroupNode',
'OtherGroup'
])
})
it('keeps a single string entry unchanged', () => {
store.setMissingNodeTypes(['GroupNode'])
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
})
it('deduplicates object entries with the same nodeId', () => {
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: 1 },
{ type: 'NodeA', nodeId: 1 }
])
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
})
it('keeps object entries with different nodeIds even if same type', () => {
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: 1 },
{ type: 'NodeA', nodeId: 2 }
])
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
})
it('deduplicates object entries by type when nodeId is absent', () => {
store.setMissingNodeTypes([{ type: 'NodeB' }, { type: 'NodeB' }])
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
})
it('keeps distinct types when nodeId is absent', () => {
store.setMissingNodeTypes([{ type: 'NodeB' }, { type: 'NodeC' }])
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
})
it('treats absent nodeId the same as type-only key (falls back to type)', () => {
store.setMissingNodeTypes([{ type: 'NodeD' }, { type: 'NodeD' }])
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
})
it('handles a mix of string and object entries correctly', () => {
store.setMissingNodeTypes([
'GroupNode',
'GroupNode', // string dup
{ type: 'NodeA', nodeId: 1 },
{ type: 'NodeA', nodeId: 1 }, // object dup by nodeId
{ type: 'NodeA', nodeId: 2 }, // same type, different nodeId → kept
{ type: 'NodeB' },
{ type: 'NodeB' } // object dup by type
])
// Unique: 'GroupNode', {NodeA,1}, {NodeA,2}, {NodeB} → 4
expect(store.missingNodesError?.nodeTypes).toHaveLength(4)
})
it('stores a non-empty message string in missingNodesError', () => {
store.setMissingNodeTypes([{ type: 'NodeA' }])
expect(typeof store.missingNodesError?.message).toBe('string')
expect(store.missingNodesError!.message.length).toBeGreaterThan(0)
})
it('stores the deduplicated nodeTypes array in missingNodesError', () => {
const input = [{ type: 'NodeA' }, { type: 'NodeB' }]
store.setMissingNodeTypes(input)
expect(store.missingNodesError?.nodeTypes).toEqual(input)
})
})