feat: add centralized graphErrorStateStore for multi-source error handling

- Create graphErrorStateStore with command-based API (REPLACE_SOURCE, CLEAR_SOURCE, CLEAR_ALL)
- Support multiple error sources: 'frontend' (proactive validation) and 'backend' (execution errors)
- Add useGraphErrorState projection hook that applies errors to graph nodes and propagates up subgraph hierarchy
- Migrate executionStore to use new store instead of direct node.has_errors/slot.hasErrors mutation

Part of COM-12907: Missing workflow connection doesn't highlight

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019c0c91-73e0-7188-9770-c315279fadd8
This commit is contained in:
bymyself
2026-01-29 18:11:25 -08:00
parent 65ff23c5af
commit ed10909f38
5 changed files with 510 additions and 41 deletions

View File

@@ -0,0 +1,76 @@
import { watch } from 'vue'
import type { LGraphNode, Subgraph } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { useGraphErrorStateStore } from '@/stores/graphErrorStateStore'
import { parseNodeLocatorId } from '@/types/nodeIdentification'
import {
findSubgraphByUuid,
forEachNode,
forEachSubgraphNode
} from '@/utils/graphTraversalUtil'
export function useGraphErrorState(): void {
const store = useGraphErrorStateStore()
watch(
() => store.version,
() => {
const rootGraph = app.rootGraph
if (!rootGraph) return
forEachNode(rootGraph, (node) => {
node.has_errors = false
if (node.inputs) {
for (const slot of node.inputs) {
slot.hasErrors = false
}
}
})
for (const [nodeId, keys] of store.keysByNode) {
if (keys.size === 0) continue
const parsed = parseNodeLocatorId(nodeId)
if (!parsed) continue
const targetGraph = parsed.subgraphUuid
? findSubgraphByUuid(rootGraph, parsed.subgraphUuid)
: rootGraph
if (!targetGraph) continue
const node = targetGraph.getNodeById(parsed.localNodeId)
if (!node) continue
node.has_errors = true
for (const key of keys) {
const error = store.errorsByKey.get(key)
if (error && error.target.kind === 'slot' && node.inputs) {
const slotName = error.target.slotName
const slot = node.inputs.find((s) => s.name === slotName)
if (slot) {
slot.hasErrors = true
}
}
}
propagateErrorToParents(node)
}
},
{ immediate: true }
)
}
function propagateErrorToParents(node: LGraphNode): void {
const subgraph = node.graph as Subgraph | undefined
if (!subgraph || subgraph.isRootGraph) return
const subgraphId = subgraph.id
if (!subgraphId) return
forEachSubgraphNode(app.rootGraph, subgraphId, (subgraphNode) => {
subgraphNode.has_errors = true
propagateErrorToParents(subgraphNode)
})
}

View File

@@ -29,10 +29,11 @@ import type {
} from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useGraphErrorStateStore } from '@/stores/graphErrorStateStore'
import type { GraphError } from '@/stores/graphErrorStateStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { forEachNode, getNodeByExecutionId } from '@/utils/graphTraversalUtil'
interface QueuedPrompt {
/**
@@ -584,59 +585,47 @@ export const useExecutionStore = defineStore('execution', () => {
}
/**
* Update node and slot error flags when validation errors change.
* Propagates errors up subgraph chains.
* Sync backend validation errors to centralized graph error store.
* The store handles flag updates and subgraph propagation.
*/
watch(lastNodeErrors, () => {
if (!app.rootGraph) return
const errorStore = useGraphErrorStateStore()
// Clear all error flags
forEachNode(app.rootGraph, (node) => {
node.has_errors = false
if (node.inputs) {
for (const slot of node.inputs) {
slot.hasErrors = false
}
}
})
if (!lastNodeErrors.value) {
errorStore.execute({ type: 'CLEAR_SOURCE', source: 'backend' })
return
}
if (!lastNodeErrors.value) return
const errors: GraphError[] = []
// 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
const locatorId = executionIdToNodeLocatorId(executionId)
if (!locatorId) continue
node.has_errors = true
errors.push({
key: `backend:node:${locatorId}`,
source: 'backend',
target: { kind: 'node', nodeId: locatorId },
message: nodeError.errors[0]?.message
})
// 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
for (const error of nodeError.errors) {
const slotName = error.extra_info?.input_name
if (slotName) {
errors.push({
key: `backend:slot:${locatorId}:${slotName}`,
source: 'backend',
target: { kind: 'slot', nodeId: locatorId, slotName },
code: 'VALIDATION_ERROR',
message: error.message
})
}
}
}
errorStore.execute({ type: 'REPLACE_SOURCE', source: 'backend', errors })
})
return {

View File

@@ -0,0 +1,257 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useGraphErrorStateStore } from './graphErrorStateStore'
import type { GraphError } from './graphErrorStateStore'
describe('graphErrorStateStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('REPLACE_SOURCE command', () => {
it('adds errors for a source', () => {
const store = useGraphErrorStateStore()
const errors: GraphError[] = [
{
key: 'frontend:slot:123:model',
source: 'frontend',
target: { kind: 'slot', nodeId: '123', slotName: 'model' },
code: 'MISSING_REQUIRED_INPUT'
}
]
store.execute({ type: 'REPLACE_SOURCE', source: 'frontend', errors })
expect(store.hasErrorsForNode('123')).toBe(true)
expect(store.hasSlotError('123', 'model')).toBe(true)
expect(store.version).toBe(1)
})
it('replaces all errors for a source', () => {
const store = useGraphErrorStateStore()
store.execute({
type: 'REPLACE_SOURCE',
source: 'frontend',
errors: [
{
key: 'frontend:slot:1:a',
source: 'frontend',
target: { kind: 'slot', nodeId: '1', slotName: 'a' }
},
{
key: 'frontend:slot:2:b',
source: 'frontend',
target: { kind: 'slot', nodeId: '2', slotName: 'b' }
}
]
})
expect(store.hasErrorsForNode('1')).toBe(true)
expect(store.hasErrorsForNode('2')).toBe(true)
store.execute({
type: 'REPLACE_SOURCE',
source: 'frontend',
errors: [
{
key: 'frontend:slot:3:c',
source: 'frontend',
target: { kind: 'slot', nodeId: '3', slotName: 'c' }
}
]
})
expect(store.hasErrorsForNode('1')).toBe(false)
expect(store.hasErrorsForNode('2')).toBe(false)
expect(store.hasErrorsForNode('3')).toBe(true)
})
it('preserves errors from other sources', () => {
const store = useGraphErrorStateStore()
store.execute({
type: 'REPLACE_SOURCE',
source: 'backend',
errors: [
{
key: 'backend:node:1',
source: 'backend',
target: { kind: 'node', nodeId: '1' }
}
]
})
store.execute({
type: 'REPLACE_SOURCE',
source: 'frontend',
errors: [
{
key: 'frontend:slot:2:a',
source: 'frontend',
target: { kind: 'slot', nodeId: '2', slotName: 'a' }
}
]
})
expect(store.hasErrorsForNode('1')).toBe(true)
expect(store.hasErrorsForNode('2')).toBe(true)
})
})
describe('CLEAR_SOURCE command', () => {
it('clears errors for a source', () => {
const store = useGraphErrorStateStore()
store.execute({
type: 'REPLACE_SOURCE',
source: 'frontend',
errors: [
{
key: 'frontend:slot:1:a',
source: 'frontend',
target: { kind: 'slot', nodeId: '1', slotName: 'a' }
}
]
})
store.execute({ type: 'CLEAR_SOURCE', source: 'frontend' })
expect(store.hasErrorsForNode('1')).toBe(false)
})
it('preserves other sources', () => {
const store = useGraphErrorStateStore()
store.execute({
type: 'REPLACE_SOURCE',
source: 'backend',
errors: [
{
key: 'backend:node:1',
source: 'backend',
target: { kind: 'node', nodeId: '1' }
}
]
})
store.execute({
type: 'REPLACE_SOURCE',
source: 'frontend',
errors: [
{
key: 'frontend:slot:2:a',
source: 'frontend',
target: { kind: 'slot', nodeId: '2', slotName: 'a' }
}
]
})
store.execute({ type: 'CLEAR_SOURCE', source: 'frontend' })
expect(store.hasErrorsForNode('1')).toBe(true)
expect(store.hasErrorsForNode('2')).toBe(false)
})
})
describe('CLEAR_ALL command', () => {
it('clears all errors', () => {
const store = useGraphErrorStateStore()
store.execute({
type: 'REPLACE_SOURCE',
source: 'backend',
errors: [
{
key: 'backend:node:1',
source: 'backend',
target: { kind: 'node', nodeId: '1' }
}
]
})
store.execute({
type: 'REPLACE_SOURCE',
source: 'frontend',
errors: [
{
key: 'frontend:slot:2:a',
source: 'frontend',
target: { kind: 'slot', nodeId: '2', slotName: 'a' }
}
]
})
store.execute({ type: 'CLEAR_ALL' })
expect(store.hasErrorsForNode('1')).toBe(false)
expect(store.hasErrorsForNode('2')).toBe(false)
})
})
describe('getErrorsForNode', () => {
it('returns all errors for a node', () => {
const store = useGraphErrorStateStore()
store.execute({
type: 'REPLACE_SOURCE',
source: 'frontend',
errors: [
{
key: 'frontend:slot:1:a',
source: 'frontend',
target: { kind: 'slot', nodeId: '1', slotName: 'a' }
},
{
key: 'frontend:slot:1:b',
source: 'frontend',
target: { kind: 'slot', nodeId: '1', slotName: 'b' }
}
]
})
const errors = store.getErrorsForNode('1')
expect(errors).toHaveLength(2)
})
it('returns empty array for node without errors', () => {
const store = useGraphErrorStateStore()
expect(store.getErrorsForNode('999')).toEqual([])
})
})
describe('getSlotErrors', () => {
it('returns only slot errors for specific slot', () => {
const store = useGraphErrorStateStore()
store.execute({
type: 'REPLACE_SOURCE',
source: 'frontend',
errors: [
{
key: 'frontend:node:1',
source: 'frontend',
target: { kind: 'node', nodeId: '1' }
},
{
key: 'frontend:slot:1:model',
source: 'frontend',
target: { kind: 'slot', nodeId: '1', slotName: 'model' }
},
{
key: 'frontend:slot:1:clip',
source: 'frontend',
target: { kind: 'slot', nodeId: '1', slotName: 'clip' }
}
]
})
const slotErrors = store.getSlotErrors('1', 'model')
expect(slotErrors).toHaveLength(1)
expect(slotErrors[0].target).toEqual({
kind: 'slot',
nodeId: '1',
slotName: 'model'
})
})
})
})

View File

@@ -0,0 +1,145 @@
import { defineStore } from 'pinia'
import { shallowRef } from 'vue'
import type { NodeLocatorId } from '@/types/nodeIdentification'
type GraphErrorSource = 'frontend' | 'backend'
type GraphErrorTarget =
| { kind: 'node'; nodeId: NodeLocatorId }
| { kind: 'slot'; nodeId: NodeLocatorId; slotName: string }
export interface GraphError {
key: string
source: GraphErrorSource
target: GraphErrorTarget
code?: string
message?: string
}
type GraphErrorCommand =
| { type: 'REPLACE_SOURCE'; source: GraphErrorSource; errors: GraphError[] }
| { type: 'CLEAR_SOURCE'; source: GraphErrorSource }
| { type: 'CLEAR_ALL' }
export const useGraphErrorStateStore = defineStore('graphErrorState', () => {
const errorsByKey = shallowRef(new Map<string, GraphError>())
const keysBySource = shallowRef(new Map<GraphErrorSource, Set<string>>())
const keysByNode = shallowRef(new Map<NodeLocatorId, Set<string>>())
const version = shallowRef(0)
function addErrorInternal(error: GraphError): void {
const newErrorsByKey = new Map(errorsByKey.value)
newErrorsByKey.set(error.key, error)
errorsByKey.value = newErrorsByKey
const newKeysBySource = new Map(keysBySource.value)
if (!newKeysBySource.has(error.source)) {
newKeysBySource.set(error.source, new Set())
}
newKeysBySource.get(error.source)!.add(error.key)
keysBySource.value = newKeysBySource
const nodeId = error.target.nodeId
const newKeysByNode = new Map(keysByNode.value)
if (!newKeysByNode.has(nodeId)) {
newKeysByNode.set(nodeId, new Set())
}
newKeysByNode.get(nodeId)!.add(error.key)
keysByNode.value = newKeysByNode
}
function clearSourceInternal(source: GraphErrorSource): void {
const keys = keysBySource.value.get(source)
if (!keys || keys.size === 0) return
const newErrorsByKey = new Map(errorsByKey.value)
const newKeysByNode = new Map(keysByNode.value)
for (const key of keys) {
const error = newErrorsByKey.get(key)
if (error) {
const nodeId = error.target.nodeId
const nodeKeys = newKeysByNode.get(nodeId)
if (nodeKeys) {
const newNodeKeys = new Set(nodeKeys)
newNodeKeys.delete(key)
if (newNodeKeys.size === 0) {
newKeysByNode.delete(nodeId)
} else {
newKeysByNode.set(nodeId, newNodeKeys)
}
}
newErrorsByKey.delete(key)
}
}
errorsByKey.value = newErrorsByKey
keysByNode.value = newKeysByNode
const newKeysBySource = new Map(keysBySource.value)
newKeysBySource.delete(source)
keysBySource.value = newKeysBySource
}
function execute(command: GraphErrorCommand): void {
switch (command.type) {
case 'REPLACE_SOURCE': {
clearSourceInternal(command.source)
for (const error of command.errors) {
addErrorInternal(error)
}
break
}
case 'CLEAR_SOURCE': {
clearSourceInternal(command.source)
break
}
case 'CLEAR_ALL': {
errorsByKey.value = new Map()
keysBySource.value = new Map()
keysByNode.value = new Map()
break
}
}
version.value++
}
function getErrorsForNode(nodeId: NodeLocatorId): GraphError[] {
const keys = keysByNode.value.get(nodeId)
if (!keys) return []
return [...keys]
.map((k) => errorsByKey.value.get(k))
.filter((e): e is GraphError => e !== undefined)
}
function hasErrorsForNode(nodeId: NodeLocatorId): boolean {
const keys = keysByNode.value.get(nodeId)
return keys !== undefined && keys.size > 0
}
function getSlotErrors(
nodeId: NodeLocatorId,
slotName: string
): GraphError[] {
return getErrorsForNode(nodeId).filter(
(e) => e.target.kind === 'slot' && e.target.slotName === slotName
)
}
function hasSlotError(nodeId: NodeLocatorId, slotName: string): boolean {
return getSlotErrors(nodeId, slotName).length > 0
}
return {
version,
errorsByKey,
keysByNode,
keysBySource,
execute,
getErrorsForNode,
hasErrorsForNode,
getSlotErrors,
hasSlotError
}
})

View File

@@ -45,6 +45,7 @@ import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDi
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
import GlobalToast from '@/components/toast/GlobalToast.vue'
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'
import { useGraphErrorState } from '@/composables/graph/useGraphErrorState'
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
import { useCoreCommands } from '@/composables/useCoreCommands'
import { useErrorHandling } from '@/composables/useErrorHandling'
@@ -85,6 +86,7 @@ import ManagerProgressToast from '@/workbench/extensions/manager/components/Mana
setupAutoQueueHandler()
useProgressFavicon()
useBrowserTabTitle()
useGraphErrorState()
const { t } = useI18n()
const toast = useToast()