Compare commits

...

2 Commits

Author SHA1 Message Date
jaeone94
160c615bc4 fix(i18n): add g.inSubgraph locale key 2026-02-25 22:36:01 +09:00
jaeone94
eb61c0bb4d 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.
2026-02-25 22:36:00 +09:00
7 changed files with 230 additions and 74 deletions

View File

@@ -7,6 +7,7 @@
"empty": "Empty", "empty": "Empty",
"noWorkflowsFound": "No workflows found.", "noWorkflowsFound": "No workflows found.",
"comingSoon": "Coming Soon", "comingSoon": "Coming Soon",
"inSubgraph": "in subgraph {name}",
"download": "Download", "download": "Download",
"downloadImage": "Download image", "downloadImage": "Download image",
"downloadVideo": "Download video", "downloadVideo": "Download video",

View File

@@ -21,6 +21,7 @@ import { useMissingModelsDialog } from '@/composables/useMissingModelsDialog'
import { useMissingNodesDialog } from '@/composables/useMissingNodesDialog' import { useMissingNodesDialog } from '@/composables/useMissingNodesDialog'
import { useDialogService } from '@/services/dialogService' import { useDialogService } from '@/services/dialogService'
import { useDomWidgetStore } from '@/stores/domWidgetStore' import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useWorkspaceStore } from '@/stores/workspaceStore' import { useWorkspaceStore } from '@/stores/workspaceStore'
import { appendJsonExt } from '@/utils/formatUtil' import { appendJsonExt } from '@/utils/formatUtil'
@@ -33,6 +34,7 @@ export const useWorkflowService = () => {
const missingNodesDialog = useMissingNodesDialog() const missingNodesDialog = useMissingNodesDialog()
const workflowThumbnail = useWorkflowThumbnail() const workflowThumbnail = useWorkflowThumbnail()
const domWidgetStore = useDomWidgetStore() const domWidgetStore = useDomWidgetStore()
const executionErrorStore = useExecutionErrorStore()
const workflowDraftStore = useWorkflowDraftStore() const workflowDraftStore = useWorkflowDraftStore()
async function getFilename(defaultName: string): Promise<string | null> { async function getFilename(defaultName: string): Promise<string | null> {
@@ -472,7 +474,15 @@ export const useWorkflowService = () => {
settingStore.get('Comfy.Workflow.ShowMissingNodesWarning') settingStore.get('Comfy.Workflow.ShowMissingNodesWarning')
) { ) {
missingNodesDialog.show({ missingNodeTypes }) 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 ( if (
missingModels && missingModels &&
settingStore.get('Comfy.Workflow.ShowMissingModelsWarning') settingStore.get('Comfy.Workflow.ShowMissingModelsWarning')

View File

@@ -547,7 +547,6 @@ export type ComfyApiWorkflow = z.infer<typeof zComfyApiWorkflow>
* where that definition is instantiated in the workflow. * where that definition is instantiated in the workflow.
* *
* "def-A" → ["5", "10"] for each container node instantiating that subgraph definition. * "def-A" → ["5", "10"] for each container node instantiating that subgraph definition.
* @knipIgnoreUsedByStackedPR
*/ */
export function buildSubgraphExecutionPaths( export function buildSubgraphExecutionPaths(
rootNodes: ComfyNode[], rootNodes: ComfyNode[],

View File

@@ -32,7 +32,8 @@ import {
type ComfyWorkflowJSON, type ComfyWorkflowJSON,
type ModelFile, type ModelFile,
type NodeId, type NodeId,
isSubgraphDefinition isSubgraphDefinition,
buildSubgraphExecutionPaths
} from '@/platform/workflow/validation/schemas/workflowSchema' } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { import type {
ExecutionErrorWsMessage, ExecutionErrorWsMessage,
@@ -1099,6 +1100,15 @@ export class ComfyApp {
private showMissingNodesError(missingNodeTypes: MissingNodeType[]) { private showMissingNodesError(missingNodeTypes: MissingNodeType[]) {
if (useSettingStore().get('Comfy.Workflow.ShowMissingNodesWarning')) { if (useSettingStore().get('Comfy.Workflow.ShowMissingNodesWarning')) {
useMissingNodesDialog().show({ missingNodeTypes }) 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 = ( const collectMissingNodesAndModels = (
nodes: ComfyWorkflowJSON['nodes'], nodes: ComfyWorkflowJSON['nodes'],
path: string = '' pathPrefix: string = '',
displayName: string = ''
) => { ) => {
if (!Array.isArray(nodes)) { if (!Array.isArray(nodes)) {
console.warn( console.warn(
'Workflow nodes data is missing or invalid, skipping node processing', 'Workflow nodes data is missing or invalid, skipping node processing',
{ nodes, path } { nodes, pathPrefix }
) )
return return
} }
@@ -1195,9 +1206,26 @@ export class ComfyApp {
if (!(n.type in LiteGraph.registered_node_types)) { if (!(n.type in LiteGraph.registered_node_types)) {
const replacement = nodeReplacementStore.getReplacementFor(n.type) 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({ missingNodeTypes.push({
type: n.type, type: n.type,
...(path && { hint: `in subgraph '${path}'` }), nodeId: executionId,
cnrId,
...(displayName && {
hint: t('g.inSubgraph', { name: displayName })
}),
isReplaceable: replacement !== null, isReplaceable: replacement !== null,
replacement: replacement ?? undefined replacement: replacement ?? undefined
}) })
@@ -1216,14 +1244,25 @@ export class ComfyApp {
// Process nodes at the top level // Process nodes at the top level
collectMissingNodesAndModels(graphData.nodes) 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 // Process nodes in subgraphs
if (graphData.definitions?.subgraphs) { if (graphData.definitions?.subgraphs) {
for (const subgraph of graphData.definitions.subgraphs) { for (const subgraph of graphData.definitions.subgraphs) {
if (isSubgraphDefinition(subgraph)) { if (isSubgraphDefinition(subgraph)) {
collectMissingNodesAndModels( const paths = subgraphContainerIdMap.get(subgraph.id) ?? []
subgraph.nodes, for (const pathPrefix of paths) {
subgraph.name || subgraph.id collectMissingNodesAndModels(
) subgraph.nodes,
pathPrefix,
subgraph.name || subgraph.id
)
}
} }
} }
} }

View File

@@ -1,6 +1,8 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { st } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
@@ -10,8 +12,13 @@ import type {
PromptError PromptError
} from '@/schemas/apiSchema' } from '@/schemas/apiSchema'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema' 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 { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import type { MissingNodeType } from '@/types/comfy'
import { import {
executionIdToNodeLocatorId, executionIdToNodeLocatorId,
forEachNode, forEachNode,
@@ -20,12 +27,52 @@ import {
} from '@/utils/graphTraversalUtil' } from '@/utils/graphTraversalUtil'
/** /**
* Store dedicated to execution error state management. * @knipIgnoreUsedByStackedPR
*
* 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', () => { export const useExecutionErrorStore = defineStore('executionError', () => {
const workflowStore = useWorkflowStore() const workflowStore = useWorkflowStore()
const canvasStore = useCanvasStore() const canvasStore = useCanvasStore()
@@ -33,6 +80,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
const lastNodeErrors = ref<Record<NodeId, NodeError> | null>(null) const lastNodeErrors = ref<Record<NodeId, NodeError> | null>(null)
const lastExecutionError = ref<ExecutionErrorWsMessage | null>(null) const lastExecutionError = ref<ExecutionErrorWsMessage | null>(null)
const lastPromptError = ref<PromptError | null>(null) const lastPromptError = ref<PromptError | null>(null)
const missingNodesError = ref<MissingNodesError | null>(null)
const isErrorOverlayOpen = ref(false) const isErrorOverlayOpen = ref(false)
@@ -49,6 +97,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
lastExecutionError.value = null lastExecutionError.value = null
lastPromptError.value = null lastPromptError.value = null
lastNodeErrors.value = null lastNodeErrors.value = null
missingNodesError.value = null
isErrorOverlayOpen.value = false isErrorOverlayOpen.value = false
} }
@@ -57,6 +106,40 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
lastPromptError.value = null lastPromptError.value = null
} }
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 lastExecutionErrorNodeLocatorId = computed(() => {
const err = lastExecutionError.value const err = lastExecutionError.value
if (!err) return null if (!err) return null
@@ -81,9 +164,18 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
() => !!lastNodeErrors.value && Object.keys(lastNodeErrors.value).length > 0 () => !!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( const hasAnyError = computed(
() => hasExecutionError.value || hasPromptError.value || hasNodeError.value () =>
hasExecutionError.value ||
hasPromptError.value ||
hasNodeError.value ||
hasMissingNodes.value
) )
const allErrorExecutionIds = computed<string[]>(() => { const allErrorExecutionIds = computed<string[]>(() => {
@@ -116,13 +208,19 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
/** Count of runtime execution errors (0 or 1) */ /** Count of runtime execution errors (0 or 1) */
const executionErrorCount = computed(() => (lastExecutionError.value ? 1 : 0)) 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 */ /** Total count of all individual errors */
const totalErrorCount = computed( 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 activeGraphErrorNodeIds = computed<Set<string>>(() => {
const ids = new Set<string>() const ids = new Set<string>()
if (!app.rootGraph) return ids if (!app.rootGraph) return ids
@@ -150,6 +248,44 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
return ids 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. */ /** Map of node errors indexed by locator ID. */
const nodeErrorsByLocatorId = computed<Record<NodeLocatorId, NodeError>>( const nodeErrorsByLocatorId = computed<Record<NodeLocatorId, NodeError>>(
() => { () => {
@@ -196,15 +332,11 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
*/ */
const errorAncestorExecutionIds = computed<Set<NodeExecutionId>>(() => { const errorAncestorExecutionIds = computed<Set<NodeExecutionId>>(() => {
const ids = new Set<NodeExecutionId>() const ids = new Set<NodeExecutionId>()
for (const executionId of allErrorExecutionIds.value) { for (const executionId of allErrorExecutionIds.value) {
const parts = executionId.split(':') for (const id of getAncestorExecutionIds(executionId)) {
// Add every prefix including the full ID (error leaf node itself) ids.add(id)
for (let i = 1; i <= parts.length; i++) {
ids.add(parts.slice(0, i).join(':'))
} }
} }
return ids return ids
}) })
@@ -216,59 +348,28 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
return errorAncestorExecutionIds.value.has(execId) return errorAncestorExecutionIds.value.has(execId)
} }
/** /** True if the node has a missing node inside it at any nesting depth.
* Update node and slot error flags when validation errors change. * @knipIgnoreUsedByStackedPR
* Propagates errors up subgraph chains.
*/ */
watch(lastNodeErrors, () => { function isContainerWithMissingNode(node: LGraphNode): boolean {
if (!app.rootGraph) return if (!app.rootGraph) return false
const execId = getExecutionIdByNode(app.rootGraph, node)
if (!execId) return false
return missingAncestorExecutionIds.value.has(execId)
}
// Clear all error flags watch(lastNodeErrors, () => {
forEachNode(app.rootGraph, (node) => { const rootGraph = app.rootGraph
node.has_errors = false if (!rootGraph) return
if (node.inputs) {
for (const slot of node.inputs) { clearAllNodeErrorFlags(rootGraph)
slot.hasErrors = false
}
}
})
if (!lastNodeErrors.value) return if (!lastNodeErrors.value) return
// Set error flags on nodes and slots
for (const [executionId, nodeError] of Object.entries( for (const [executionId, nodeError] of Object.entries(
lastNodeErrors.value lastNodeErrors.value
)) { )) {
const node = getNodeByExecutionId(app.rootGraph, executionId) applyNodeError(rootGraph, executionId, nodeError)
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
}
}
} }
}) })
@@ -277,6 +378,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
lastNodeErrors, lastNodeErrors,
lastExecutionError, lastExecutionError,
lastPromptError, lastPromptError,
missingNodesError,
// Clearing // Clearing
clearAllErrors, clearAllErrors,
@@ -291,16 +393,21 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
hasExecutionError, hasExecutionError,
hasPromptError, hasPromptError,
hasNodeError, hasNodeError,
hasMissingNodes,
hasAnyError, hasAnyError,
allErrorExecutionIds, allErrorExecutionIds,
totalErrorCount, totalErrorCount,
lastExecutionErrorNodeId, lastExecutionErrorNodeId,
activeGraphErrorNodeIds, activeGraphErrorNodeIds,
activeMissingNodeGraphIds,
// Missing node actions
setMissingNodeTypes,
// Lookup helpers // Lookup helpers
getNodeErrors, getNodeErrors,
slotHasError, slotHasError,
errorAncestorExecutionIds, isContainerWithInternalError,
isContainerWithInternalError isContainerWithMissingNode
} }
}) })

View File

@@ -90,6 +90,8 @@ export type MissingNodeType =
// Primarily used by group nodes. // Primarily used by group nodes.
| { | {
type: string type: string
nodeId?: string | number
cnrId?: string
hint?: string hint?: string
action?: { action?: {
text: string text: string

View File

@@ -126,7 +126,6 @@ export function createNodeExecutionId(nodeIds: NodeId[]): NodeExecutionId {
* Returns all ancestor execution IDs for a given execution ID, including itself. * Returns all ancestor execution IDs for a given execution ID, including itself.
* *
* Example: "65:70:63" → ["65", "65:70", "65:70:63"] * Example: "65:70:63" → ["65", "65:70", "65:70:63"]
* @knipIgnoreUsedByStackedPR
*/ */
export function getAncestorExecutionIds( export function getAncestorExecutionIds(
executionId: string | NodeExecutionId executionId: string | NodeExecutionId
@@ -141,7 +140,6 @@ export function getAncestorExecutionIds(
* Returns all ancestor execution IDs for a given execution ID, excluding itself. * Returns all ancestor execution IDs for a given execution ID, excluding itself.
* *
* Example: "65:70:63" → ["65", "65:70"] * Example: "65:70:63" → ["65", "65:70"]
* @knipIgnoreUsedByStackedPR
*/ */
export function getParentExecutionIds( export function getParentExecutionIds(
executionId: string | NodeExecutionId executionId: string | NodeExecutionId