From c2defb49c5cbc6b542c5fabf2d387241a0462fd3 Mon Sep 17 00:00:00 2001
From: jaeone94
Date: Wed, 25 Feb 2026 18:07:00 +0900
Subject: [PATCH 01/12] refactor: improve missing node error handling and add
roadmap documentation
- App & WorkflowService: Document the temporary coexistence of the Missing Nodes Modal and Errors Tab, noting that the modal will be removed once Node Replacement is implemented.
- Error Handling: Collect `cnr_id` and `execution_id` when processing missing nodes to provide sufficient context for the Errors Tab.
- ExecutionErrorStore: Enforce strict `NodeExecutionId` typing in `applyNodeError`.
- Clean up obsolete comments and reorganize imports across error stores and app script.
---
.../workflow/core/services/workflowService.ts | 10 +
.../validation/schemas/workflowSchema.ts | 1 -
src/scripts/app.ts | 55 ++++-
src/stores/executionErrorStore.ts | 233 +++++++++++++-----
src/types/comfy.ts | 2 +
src/types/nodeIdentification.ts | 2 -
6 files changed, 229 insertions(+), 74 deletions(-)
diff --git a/src/platform/workflow/core/services/workflowService.ts b/src/platform/workflow/core/services/workflowService.ts
index 28255518176..53900f6b74a 100644
--- a/src/platform/workflow/core/services/workflowService.ts
+++ b/src/platform/workflow/core/services/workflowService.ts
@@ -21,6 +21,7 @@ import { useMissingModelsDialog } from '@/composables/useMissingModelsDialog'
import { useMissingNodesDialog } from '@/composables/useMissingNodesDialog'
import { useDialogService } from '@/services/dialogService'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
+import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { appendJsonExt } from '@/utils/formatUtil'
@@ -33,6 +34,7 @@ export const useWorkflowService = () => {
const missingNodesDialog = useMissingNodesDialog()
const workflowThumbnail = useWorkflowThumbnail()
const domWidgetStore = useDomWidgetStore()
+ const executionErrorStore = useExecutionErrorStore()
const workflowDraftStore = useWorkflowDraftStore()
async function getFilename(defaultName: string): Promise {
@@ -472,7 +474,15 @@ export const useWorkflowService = () => {
settingStore.get('Comfy.Workflow.ShowMissingNodesWarning')
) {
missingNodesDialog.show({ missingNodeTypes })
+
+ // For now, we'll make them coexist.
+ // Once the Node Replacement feature is implemented in TabErrors
+ // we'll remove the modal display and direct users to the error tab.
+ if (settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')) {
+ executionErrorStore.showErrorOverlay()
+ }
}
+
if (
missingModels &&
settingStore.get('Comfy.Workflow.ShowMissingModelsWarning')
diff --git a/src/platform/workflow/validation/schemas/workflowSchema.ts b/src/platform/workflow/validation/schemas/workflowSchema.ts
index e697181ed10..921a23b47d5 100644
--- a/src/platform/workflow/validation/schemas/workflowSchema.ts
+++ b/src/platform/workflow/validation/schemas/workflowSchema.ts
@@ -547,7 +547,6 @@ export type ComfyApiWorkflow = z.infer
* where that definition is instantiated in the workflow.
*
* "def-A" → ["5", "10"] for each container node instantiating that subgraph definition.
- * @knipIgnoreUsedByStackedPR
*/
export function buildSubgraphExecutionPaths(
rootNodes: ComfyNode[],
diff --git a/src/scripts/app.ts b/src/scripts/app.ts
index 90a367ce4dd..963ae6d60f6 100644
--- a/src/scripts/app.ts
+++ b/src/scripts/app.ts
@@ -32,7 +32,8 @@ import {
type ComfyWorkflowJSON,
type ModelFile,
type NodeId,
- isSubgraphDefinition
+ isSubgraphDefinition,
+ buildSubgraphExecutionPaths
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type {
ExecutionErrorWsMessage,
@@ -1099,6 +1100,15 @@ export class ComfyApp {
private showMissingNodesError(missingNodeTypes: MissingNodeType[]) {
if (useSettingStore().get('Comfy.Workflow.ShowMissingNodesWarning')) {
useMissingNodesDialog().show({ missingNodeTypes })
+
+ // For now, we'll make them coexist.
+ // Once the Node Replacement feature is implemented in TabErrors
+ // we'll remove the modal display and direct users to the error tab.
+ const executionErrorStore = useExecutionErrorStore()
+ executionErrorStore.setMissingNodeTypes(missingNodeTypes)
+ if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
+ executionErrorStore.showErrorOverlay()
+ }
}
}
@@ -1181,12 +1191,13 @@ export class ComfyApp {
const collectMissingNodesAndModels = (
nodes: ComfyWorkflowJSON['nodes'],
- path: string = ''
+ pathPrefix: string = '',
+ displayName: string = ''
) => {
if (!Array.isArray(nodes)) {
console.warn(
'Workflow nodes data is missing or invalid, skipping node processing',
- { nodes, path }
+ { nodes, pathPrefix }
)
return
}
@@ -1195,9 +1206,26 @@ export class ComfyApp {
if (!(n.type in LiteGraph.registered_node_types)) {
const replacement = nodeReplacementStore.getReplacementFor(n.type)
+ // To access missing node information in the error tab
+ // we collect the cnr_id and execution_id here.
+ let cnrId: string | undefined
+ if (typeof n.properties?.cnr_id === 'string') {
+ cnrId = n.properties.cnr_id
+ } else if (typeof n.properties?.aux_id === 'string') {
+ cnrId = n.properties.aux_id
+ }
+
+ const executionId = pathPrefix
+ ? `${pathPrefix}:${n.id}`
+ : String(n.id)
+
missingNodeTypes.push({
type: n.type,
- ...(path && { hint: `in subgraph '${path}'` }),
+ nodeId: executionId,
+ cnrId,
+ ...(displayName && {
+ hint: t('g.inSubgraph', { name: displayName })
+ }),
isReplaceable: replacement !== null,
replacement: replacement ?? undefined
})
@@ -1216,14 +1244,25 @@ export class ComfyApp {
// Process nodes at the top level
collectMissingNodesAndModels(graphData.nodes)
+ // Build map: subgraph definition UUID → full execution path prefix.
+ // Handles arbitrary nesting depth (e.g. root node 11 → "11", node 14 in sg 11 → "11:14").
+ const subgraphContainerIdMap = buildSubgraphExecutionPaths(
+ graphData.nodes,
+ graphData.definitions?.subgraphs ?? []
+ )
+
// Process nodes in subgraphs
if (graphData.definitions?.subgraphs) {
for (const subgraph of graphData.definitions.subgraphs) {
if (isSubgraphDefinition(subgraph)) {
- collectMissingNodesAndModels(
- subgraph.nodes,
- subgraph.name || subgraph.id
- )
+ const paths = subgraphContainerIdMap.get(subgraph.id) ?? []
+ for (const pathPrefix of paths) {
+ collectMissingNodesAndModels(
+ subgraph.nodes,
+ pathPrefix,
+ subgraph.name || subgraph.id
+ )
+ }
}
}
}
diff --git a/src/stores/executionErrorStore.ts b/src/stores/executionErrorStore.ts
index e93335bbf9a..d539c876492 100644
--- a/src/stores/executionErrorStore.ts
+++ b/src/stores/executionErrorStore.ts
@@ -1,6 +1,8 @@
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 { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
@@ -10,8 +12,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,
@@ -20,12 +27,52 @@ import {
} 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).
+ * @knipIgnoreUsedByStackedPR
*/
+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 +80,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
const lastNodeErrors = ref | null>(null)
const lastExecutionError = ref(null)
const lastPromptError = ref(null)
+ const missingNodesError = ref(null)
const isErrorOverlayOpen = ref(false)
@@ -49,6 +97,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
lastExecutionError.value = null
lastPromptError.value = null
lastNodeErrors.value = null
+ missingNodesError.value = null
isErrorOverlayOpen.value = false
}
@@ -57,6 +106,40 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
lastPromptError.value = null
}
+ function setMissingNodeTypes(types: MissingNodeType[]) {
+ if (!types.length) {
+ missingNodesError.value = null
+ return
+ }
+ const seen = new Set()
+ 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 +164,18 @@ 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
+ * @knipIgnoreUsedByStackedPR
+ */
+ 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(() => {
@@ -116,13 +208,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>(() => {
const ids = new Set()
if (!app.rootGraph) return ids
@@ -150,6 +248,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>(() => {
+ const ids = new Set()
+ 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>(() => {
+ const ids = new Set()
+ 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>(
() => {
@@ -196,15 +332,11 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
*/
const errorAncestorExecutionIds = computed>(() => {
const ids = new Set()
-
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 +348,28 @@ 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.
+ /** True if the node has a missing node inside it at any nesting depth.
+ * @knipIgnoreUsedByStackedPR
*/
+ 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)
+ }
+
watch(lastNodeErrors, () => {
- if (!app.rootGraph) return
-
- // Clear all error flags
- forEachNode(app.rootGraph, (node) => {
- node.has_errors = false
- if (node.inputs) {
- for (const slot of node.inputs) {
- slot.hasErrors = false
- }
- }
- })
+ 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 +378,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
lastNodeErrors,
lastExecutionError,
lastPromptError,
+ missingNodesError,
// Clearing
clearAllErrors,
@@ -291,16 +393,21 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
hasExecutionError,
hasPromptError,
hasNodeError,
+ hasMissingNodes,
hasAnyError,
allErrorExecutionIds,
totalErrorCount,
lastExecutionErrorNodeId,
activeGraphErrorNodeIds,
+ activeMissingNodeGraphIds,
+
+ // Missing node actions
+ setMissingNodeTypes,
// Lookup helpers
getNodeErrors,
slotHasError,
- errorAncestorExecutionIds,
- isContainerWithInternalError
+ isContainerWithInternalError,
+ isContainerWithMissingNode
}
})
diff --git a/src/types/comfy.ts b/src/types/comfy.ts
index f4965407c20..7249f64718c 100644
--- a/src/types/comfy.ts
+++ b/src/types/comfy.ts
@@ -90,6 +90,8 @@ export type MissingNodeType =
// Primarily used by group nodes.
| {
type: string
+ nodeId?: string | number
+ cnrId?: string
hint?: string
action?: {
text: string
diff --git a/src/types/nodeIdentification.ts b/src/types/nodeIdentification.ts
index 6ee14c46951..fd184fdeadb 100644
--- a/src/types/nodeIdentification.ts
+++ b/src/types/nodeIdentification.ts
@@ -126,7 +126,6 @@ export function createNodeExecutionId(nodeIds: NodeId[]): NodeExecutionId {
* Returns all ancestor execution IDs for a given execution ID, including itself.
*
* Example: "65:70:63" → ["65", "65:70", "65:70:63"]
- * @knipIgnoreUsedByStackedPR
*/
export function getAncestorExecutionIds(
executionId: string | NodeExecutionId
@@ -141,7 +140,6 @@ export function getAncestorExecutionIds(
* Returns all ancestor execution IDs for a given execution ID, excluding itself.
*
* Example: "65:70:63" → ["65", "65:70"]
- * @knipIgnoreUsedByStackedPR
*/
export function getParentExecutionIds(
executionId: string | NodeExecutionId
From a81307842bbfa138c4f784d48b6d0333413ed565 Mon Sep 17 00:00:00 2001
From: jaeone94
Date: Wed, 25 Feb 2026 19:48:26 +0900
Subject: [PATCH 02/12] fix(i18n): add g.inSubgraph locale key
---
src/locales/en/main.json | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/locales/en/main.json b/src/locales/en/main.json
index 5c3f2412afc..6ce304f320e 100644
--- a/src/locales/en/main.json
+++ b/src/locales/en/main.json
@@ -7,6 +7,7 @@
"empty": "Empty",
"noWorkflowsFound": "No workflows found.",
"comingSoon": "Coming Soon",
+ "inSubgraph": "in subgraph {name}",
"download": "Download",
"downloadImage": "Download image",
"downloadVideo": "Download video",
From e7f0704abef59c0388590f509d00397e0e7cefdd Mon Sep 17 00:00:00 2001
From: jaeone94
Date: Wed, 25 Feb 2026 22:59:54 +0900
Subject: [PATCH 03/12] fix: call setMissingNodeTypes in deferred-warnings path
---
src/platform/workflow/core/services/workflowService.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/platform/workflow/core/services/workflowService.ts b/src/platform/workflow/core/services/workflowService.ts
index 53900f6b74a..d8213a2e775 100644
--- a/src/platform/workflow/core/services/workflowService.ts
+++ b/src/platform/workflow/core/services/workflowService.ts
@@ -478,6 +478,7 @@ export const useWorkflowService = () => {
// For now, we'll make them coexist.
// Once the Node Replacement feature is implemented in TabErrors
// we'll remove the modal display and direct users to the error tab.
+ executionErrorStore.setMissingNodeTypes(missingNodeTypes)
if (settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')) {
executionErrorStore.showErrorOverlay()
}
From 0a04f3bbe149ca831ce1096637e1b614be88be6a Mon Sep 17 00:00:00 2001
From: jaeone94
Date: Wed, 25 Feb 2026 23:35:40 +0900
Subject: [PATCH 04/12] feat: show missing node packs in the Errors Tab with
install support
- Add MissingPackGroupRow component to display missing node packs as
grouped, collapsible rows with per-pack install and manager-search
buttons.
- Implement buildMissingNodeGroups() in useErrorGroups.ts to aggregate
missing node types by cnrId and resolve pack info asynchronously.
- Add locate-on-canvas support for individual missing node instances.
- Fix deferred-warnings path: call setMissingNodeTypes before
showErrorOverlay so the Errors Tab has data to display.
- Add required i18n keys for missing node pack UI strings.
---
.../rightSidePanel/RightSidePanel.vue | 17 +-
.../rightSidePanel/errors/ErrorNodeCard.vue | 38 +--
.../rightSidePanel/errors/MissingNodeCard.vue | 79 ++++++
.../errors/MissingPackGroupRow.vue | 240 ++++++++++++++++++
.../rightSidePanel/errors/TabErrors.test.ts | 3 +-
.../rightSidePanel/errors/TabErrors.vue | 64 ++++-
src/components/rightSidePanel/errors/types.ts | 3 +
.../rightSidePanel/errors/useErrorGroups.ts | 172 ++++++++++++-
.../layout/PropertiesAccordionItem.vue | 5 +-
.../parameters/SectionWidgets.vue | 14 +-
src/locales/en/main.json | 20 +-
.../vueNodes/components/LGraphNode.vue | 3 +-
12 files changed, 616 insertions(+), 42 deletions(-)
create mode 100644 src/components/rightSidePanel/errors/MissingNodeCard.vue
create mode 100644 src/components/rightSidePanel/errors/MissingPackGroupRow.vue
diff --git a/src/components/rightSidePanel/RightSidePanel.vue b/src/components/rightSidePanel/RightSidePanel.vue
index d55af6b2e18..cb46923e0d4 100644
--- a/src/components/rightSidePanel/RightSidePanel.vue
+++ b/src/components/rightSidePanel/RightSidePanel.vue
@@ -40,7 +40,8 @@ const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
-const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionErrorStore)
+const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } =
+ storeToRefs(executionErrorStore)
const { findParentGroup } = useGraphHierarchy()
@@ -109,9 +110,21 @@ const hasContainerInternalError = computed(() => {
})
})
+const hasMissingNodeSelected = computed(
+ () =>
+ hasSelection.value &&
+ selectedNodes.value.some((node) =>
+ activeMissingNodeGraphIds.value.has(String(node.id))
+ )
+)
+
const hasRelevantErrors = computed(() => {
if (!hasSelection.value) return hasAnyError.value
- return hasDirectNodeError.value || hasContainerInternalError.value
+ return (
+ hasDirectNodeError.value ||
+ hasContainerInternalError.value ||
+ hasMissingNodeSelected.value
+ )
})
const tabs = computed(() => {
diff --git a/src/components/rightSidePanel/errors/ErrorNodeCard.vue b/src/components/rightSidePanel/errors/ErrorNodeCard.vue
index 6e62589d59e..919996412e7 100644
--- a/src/components/rightSidePanel/errors/ErrorNodeCard.vue
+++ b/src/components/rightSidePanel/errors/ErrorNodeCard.vue
@@ -17,24 +17,26 @@
>
{{ card.nodeTitle }}
-
-
+
+
+
+
diff --git a/src/components/rightSidePanel/errors/MissingNodeCard.vue b/src/components/rightSidePanel/errors/MissingNodeCard.vue
new file mode 100644
index 00000000000..a5e289fa6e6
--- /dev/null
+++ b/src/components/rightSidePanel/errors/MissingNodeCard.vue
@@ -0,0 +1,79 @@
+
+
+
+
+ {{
+ isCloud
+ ? t('rightSidePanel.missingNodePacks.cloudMessage')
+ : t('rightSidePanel.missingNodePacks.ossMessage')
+ }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/rightSidePanel/errors/MissingPackGroupRow.vue b/src/components/rightSidePanel/errors/MissingPackGroupRow.vue
new file mode 100644
index 00000000000..c236c466305
--- /dev/null
+++ b/src/components/rightSidePanel/errors/MissingPackGroupRow.vue
@@ -0,0 +1,240 @@
+
+
+
+
+
+
+
+
+ {{ t('g.loading') }}...
+
+
+ {{ group.packId ?? t('rightSidePanel.missingNodePacks.unknownPack') }}
+
+
+
+
+
+
+
+
+
+
+
+ #{{ nodeType.nodeId }}
+
+
+ {{ getLabel(nodeType) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ isInstalling
+ ? t('rightSidePanel.missingNodePacks.installing')
+ : comfyManagerStore.isPackInstalled(group.packId)
+ ? t('rightSidePanel.missingNodePacks.installed')
+ : t('rightSidePanel.missingNodePacks.installNodePack')
+ }}
+
+
+
+
+
+
+
+
+
+ {{ t('g.loading') }}
+
+
+
+
+
+
+
+
+
+ {{ t('rightSidePanel.missingNodePacks.searchInManager') }}
+
+
+
+
+
+
+
diff --git a/src/components/rightSidePanel/errors/TabErrors.test.ts b/src/components/rightSidePanel/errors/TabErrors.test.ts
index f4e60876215..6c5222a4921 100644
--- a/src/components/rightSidePanel/errors/TabErrors.test.ts
+++ b/src/components/rightSidePanel/errors/TabErrors.test.ts
@@ -18,7 +18,8 @@ vi.mock('@/scripts/app', () => ({
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: vi.fn(),
getRootParentNode: vi.fn(() => null),
- forEachNode: vi.fn()
+ forEachNode: vi.fn(),
+ mapAllNodes: vi.fn(() => [])
}))
vi.mock('@/composables/useCopyToClipboard', () => ({
diff --git a/src/components/rightSidePanel/errors/TabErrors.vue b/src/components/rightSidePanel/errors/TabErrors.vue
index ea7a4ef3785..5ab472b80a7 100644
--- a/src/components/rightSidePanel/errors/TabErrors.vue
+++ b/src/components/rightSidePanel/errors/TabErrors.vue
@@ -27,6 +27,7 @@
:key="group.title"
:collapse="collapseState[group.title] ?? false"
class="border-b border-interface-stroke"
+ :size="group.type === 'missing_node' ? 'lg' : 'default'"
@update:collapse="collapseState[group.title] = $event"
>
@@ -39,17 +40,46 @@
{{ group.title }}
({{ group.cards.length }})
+
-
-
+
+
+
+
+
missingNodePacks.value)
const searchQuery = ref('')
@@ -136,7 +177,9 @@ const {
filteredGroups,
collapseState,
isSingleNodeSelected,
- errorNodeCache
+ errorNodeCache,
+ missingNodeCache,
+ missingPackGroups
} = useErrorGroups(searchQuery, t)
/**
@@ -167,6 +210,19 @@ function handleLocateNode(nodeId: string) {
focusNode(nodeId, errorNodeCache.value)
}
+function handleLocateMissingNode(nodeId: string) {
+ focusNode(nodeId, missingNodeCache.value)
+}
+
+function handleOpenManagerInfo(packId: string) {
+ const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
+ if (isKnownToRegistry) {
+ openManager({ initialTab: ManagerTab.Missing, initialPackId: packId })
+ } else {
+ openManager({ initialTab: ManagerTab.All, initialPackId: packId })
+ }
+}
+
function handleEnterSubgraph(nodeId: string) {
enterSubgraph(nodeId, errorNodeCache.value)
}
diff --git a/src/components/rightSidePanel/errors/types.ts b/src/components/rightSidePanel/errors/types.ts
index b5729fd0323..6796971070d 100644
--- a/src/components/rightSidePanel/errors/types.ts
+++ b/src/components/rightSidePanel/errors/types.ts
@@ -14,7 +14,10 @@ export interface ErrorCardData {
errors: ErrorItem[]
}
+export type ErrorGroupType = 'execution' | 'missing_node' | 'missing_model'
+
export interface ErrorGroup {
+ type: ErrorGroupType
title: string
cards: ErrorCardData[]
priority: number
diff --git a/src/components/rightSidePanel/errors/useErrorGroups.ts b/src/components/rightSidePanel/errors/useErrorGroups.ts
index ace8299d4fb..6392babf754 100644
--- a/src/components/rightSidePanel/errors/useErrorGroups.ts
+++ b/src/components/rightSidePanel/errors/useErrorGroups.ts
@@ -1,11 +1,11 @@
-import { computed, reactive } from 'vue'
+import { computed, reactive, ref, watch } from 'vue'
import type { Ref } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
+import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
-
import { app } from '@/scripts/app'
import { isCloud } from '@/platform/distribution/types'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
@@ -20,7 +20,13 @@ import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { isGroupNode } from '@/utils/executableGroupNodeDto'
import { st } from '@/i18n'
-import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
+import type { MissingNodeType } from '@/types/comfy'
+import type {
+ ErrorCardData,
+ ErrorGroup,
+ ErrorGroupType,
+ ErrorItem
+} from './types'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { isNodeExecutionId } from '@/types/nodeIdentification'
@@ -32,7 +38,17 @@ const KNOWN_PROMPT_ERROR_TYPES = new Set([
'server_error'
])
+/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
+const RESOLVING = '__RESOLVING__'
+
+export interface MissingPackGroup {
+ packId: string | null
+ nodeTypes: MissingNodeType[]
+ isResolving: boolean
+}
+
interface GroupEntry {
+ type: ErrorGroupType
priority: number
cards: Map
}
@@ -72,11 +88,12 @@ function resolveNodeInfo(nodeId: string) {
function getOrCreateGroup(
groupsMap: Map,
title: string,
- priority = 1
+ priority = 1,
+ type: ErrorGroupType = 'execution'
): Map {
let entry = groupsMap.get(title)
if (!entry) {
- entry = { priority, cards: new Map() }
+ entry = { type, priority, cards: new Map() }
groupsMap.set(title, entry)
}
return entry.cards
@@ -137,6 +154,7 @@ function addCardErrorToGroup(
function toSortedGroups(groupsMap: Map): ErrorGroup[] {
return Array.from(groupsMap.entries())
.map(([title, groupData]) => ({
+ type: groupData.type,
title,
cards: Array.from(groupData.cards.values()),
priority: groupData.priority
@@ -197,6 +215,7 @@ export function useErrorGroups(
) {
const executionErrorStore = useExecutionErrorStore()
const canvasStore = useCanvasStore()
+ const { inferPackFromNodeName } = useComfyRegistryStore()
const collapseState = reactive>({})
const selectedNodeInfo = computed(() => {
@@ -237,6 +256,19 @@ export function useErrorGroups(
return map
})
+ const missingNodeCache = computed(() => {
+ const map = new Map()
+ const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
+ for (const nodeType of nodeTypes) {
+ if (typeof nodeType === 'string') continue
+ if (nodeType.nodeId == null) continue
+ const nodeId = String(nodeType.nodeId)
+ const node = getNodeByExecutionId(app.rootGraph, nodeId)
+ if (node) map.set(nodeId, node)
+ }
+ return map
+ })
+
function isErrorInSelection(executionNodeId: string): boolean {
const nodeIds = selectedNodeInfo.value.nodeIds
if (!nodeIds) return true
@@ -343,6 +375,126 @@ export function useErrorGroups(
)
}
+ // Async pack-ID resolution for missing node types that lack a cnrId
+ const asyncResolvedIds = ref
From d3512fa6f0b9f6ecb7b20870a6a359296b5775ad Mon Sep 17 00:00:00 2001
From: jaeone94
Date: Wed, 25 Feb 2026 23:58:55 +0900
Subject: [PATCH 06/12] feat: show node/pack count inline in missing node error
UI
---
.../rightSidePanel/errors/MissingPackGroupRow.vue | 4 +++-
src/components/rightSidePanel/errors/TabErrors.vue | 6 +++++-
2 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/src/components/rightSidePanel/errors/MissingPackGroupRow.vue b/src/components/rightSidePanel/errors/MissingPackGroupRow.vue
index 44486aade85..84700445350 100644
--- a/src/components/rightSidePanel/errors/MissingPackGroupRow.vue
+++ b/src/components/rightSidePanel/errors/MissingPackGroupRow.vue
@@ -19,7 +19,9 @@
{{ t('g.loading') }}...
- {{ group.packId ?? t('rightSidePanel.missingNodePacks.unknownPack') }}
+ {{
+ `${group.packId ?? t('rightSidePanel.missingNodePacks.unknownPack')} (${group.nodeTypes.length})`
+ }}
- {{ group.title }}
+ {{
+ group.type === 'missing_node'
+ ? `${group.title} (${missingPackGroups.filter((g) => g.packId !== null).length})`
+ : group.title
+ }}
Date: Thu, 26 Feb 2026 00:27:02 +0900
Subject: [PATCH 07/12] fix: address CodeRabbit review on MissingPackGroupRow
---
.../errors/MissingPackGroupRow.vue | 8 ++++++++
src/locales/en/main.json | 5 ++++-
.../workflow/core/services/workflowService.ts | 9 ++++-----
src/scripts/app.ts | 16 ++++++++--------
4 files changed, 24 insertions(+), 14 deletions(-)
diff --git a/src/components/rightSidePanel/errors/MissingPackGroupRow.vue b/src/components/rightSidePanel/errors/MissingPackGroupRow.vue
index 84700445350..03bfa3468ed 100644
--- a/src/components/rightSidePanel/errors/MissingPackGroupRow.vue
+++ b/src/components/rightSidePanel/errors/MissingPackGroupRow.vue
@@ -29,6 +29,7 @@
variant="textonly"
size="icon-sm"
class="size-8 text-muted-foreground hover:text-base-foreground shrink-0"
+ :aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
@click="emit('openManagerInfo', group.packId ?? '')"
>
@@ -42,6 +43,11 @@
{ 'rotate-180': expanded }
)
"
+ :aria-label="
+ expanded
+ ? t('rightSidePanel.missingNodePacks.collapse')
+ : t('rightSidePanel.missingNodePacks.expand')
+ "
@click="toggleExpand"
>