Compare commits

...

5 Commits

Author SHA1 Message Date
jaeone94
e5975ddccb refactor: merge subgraph error entries and drop read-only guard tests 2026-07-16 04:39:12 +09:00
jaeone94
9b140ca405 test: add null input guard for recordNodeErrors normalization 2026-07-16 02:42:41 +09:00
jaeone94
56f5e7c0a7 test: guard read-only execution error state and drop redundant queuePrompt case
Add read-only boundary tests asserting direct writes to lastNodeErrors,
lastExecutionError, and lastPromptError are ignored, so re-widening the
store surface back to writable refs fails both typecheck and runtime.

Remove the null node_errors queuePrompt case that duplicated the undefined
branch through a fromAny cast, along with its now-unused imports; the empty
record and omitted cases keep the discriminating coverage.
2026-07-16 01:56:34 +09:00
jaeone94
e9841e6564 Merge branch 'main' into jaeone/refactor-execution-error-store-encapsulation 2026-07-15 21:17:19 +09:00
jaeone94
23e2882f21 refactor: encapsulate execution error store writes behind record actions
Raw error state (lastNodeErrors/lastExecutionError/lastPromptError) was
directly assigned from app.ts, executionStore, and subgraphStore, with the
empty-record normalization and PromptError shape construction copy-pasted
at each site. Introduce recordNodeErrors/recordExecutionError/
recordPromptError actions, expose the state as read-only computeds, and
extract normalizePromptError plus shared errorsForSlot/hasErrorForSlot
slot-matching predicates. queuePrompt's public boolean result is preserved
byte-for-byte (including empty/null/absent node_errors and multi-item
queue runs) and pinned by regression tests.
2026-07-15 00:05:21 +09:00
20 changed files with 608 additions and 454 deletions

View File

@@ -152,9 +152,9 @@ describe('ErrorOverlay', () => {
renderOverlay()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
executionErrorStore.recordNodeErrors({
'1': makeNodeError(['Only error'])
}
})
executionErrorStore.showErrorOverlay()
await nextTick()
@@ -189,9 +189,9 @@ describe('ErrorOverlay', () => {
renderOverlay({ appMode: true })
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
executionErrorStore.recordNodeErrors({
'1': makeNodeError(['Only error'])
}
})
executionErrorStore.showErrorOverlay()
await nextTick()

View File

@@ -131,9 +131,9 @@ describe('useErrorOverlayState', () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
executionErrorStore.recordNodeErrors({
'1': makeNodeError(['Only error'])
}
})
executionErrorStore.showErrorOverlay()
await nextTick()
@@ -168,9 +168,9 @@ describe('useErrorOverlayState', () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
executionErrorStore.recordNodeErrors({
'1': makeNodeError(['Required input is missing'])
}
})
executionErrorStore.showErrorOverlay()
await nextTick()
@@ -207,9 +207,9 @@ describe('useErrorOverlayState', () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
executionErrorStore.recordNodeErrors({
'1': makeNodeError(['Raw validation error'])
}
})
executionErrorStore.showErrorOverlay()
await nextTick()
@@ -248,7 +248,7 @@ describe('useErrorOverlayState', () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastExecutionError = {
executionErrorStore.recordExecutionError({
prompt_id: 'prompt',
node_id: 1,
node_type: 'KSampler',
@@ -257,7 +257,7 @@ describe('useErrorOverlayState', () => {
exception_type: 'torch.OutOfMemoryError',
traceback: [],
timestamp: Date.now()
}
})
executionErrorStore.showErrorOverlay()
await nextTick()
@@ -474,9 +474,9 @@ describe('useErrorOverlayState', () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
executionErrorStore.recordNodeErrors({
'1': makeNodeError(['Only error'])
}
})
executionErrorStore.showErrorOverlay()
await nextTick()

View File

@@ -63,10 +63,7 @@ const LOADER_NODE = { id: '2', title: 'LoaderNode' }
function seedTwoErrorGroups(pinia: TestingPinia) {
const executionErrorStore = useExecutionErrorStore(pinia)
executionErrorStore.lastNodeErrors = fromAny<
typeof executionErrorStore.lastNodeErrors,
unknown
>({
executionErrorStore.recordNodeErrors({
'1': {
class_type: 'KSampler',
dependent_outputs: [],
@@ -83,7 +80,11 @@ function seedTwoErrorGroups(pinia: TestingPinia) {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [
{ type: 'weird_error', message: 'Something odd happened', details: '' }
{
type: 'weird_error',
message: 'Something odd happened',
details: ''
}
]
}
})

View File

@@ -1,19 +1,28 @@
import { createTestingPinia } from '@pinia/testing'
import type { TestingPinia } from '@pinia/testing'
import { render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import TabErrors from './TabErrors.vue'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { MissingNodeType } from '@/types/comfy'
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
const mockFocusNode = vi.hoisted(() => vi.fn())
const { mockFocusNode, mockRefreshMissingModels } = vi.hoisted(() => ({
mockFocusNode: vi.fn(),
mockRefreshMissingModels: vi.fn()
}))
vi.mock('@/scripts/app', () => ({
app: {
refreshMissingModels: mockRefreshMissingModels,
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
@@ -97,18 +106,16 @@ describe('TabErrors.vue', () => {
})
})
function renderComponent(initialState = {}) {
function renderComponent(seed?: (pinia: TestingPinia) => void) {
const user = userEvent.setup()
const pinia = createTestingPinia({
createSpy: vi.fn,
stubActions: false
})
seed?.(pinia)
render(TabErrors, {
global: {
plugins: [
PrimeVue,
i18n,
createTestingPinia({
createSpy: vi.fn,
initialState
})
],
plugins: [PrimeVue, i18n, pinia],
stubs: {
AsyncSearchInput: {
template:
@@ -129,14 +136,12 @@ describe('TabErrors.vue', () => {
})
it('renders prompt-level errors with resolved display message', async () => {
renderComponent({
executionError: {
lastPromptError: {
type: 'prompt_no_outputs',
message: 'Server Error: No outputs',
details: 'Error details'
}
}
renderComponent((pinia) => {
useExecutionErrorStore(pinia).recordPromptError({
type: 'prompt_no_outputs',
message: 'Server Error: No outputs',
details: 'Error details'
})
})
expect(screen.getAllByText('Prompt has no outputs').length).toBeGreaterThan(
@@ -162,45 +167,40 @@ describe('TabErrors.vue', () => {
} as ReturnType<typeof getNodeByExecutionId>
})
const { user } = renderComponent({
executionError: {
lastNodeErrors: {
'2': {
class_type: 'CLIPTextEncode',
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'Input: clip',
extra_info: {
input_name: 'clip'
}
}
]
},
'1': {
class_type: 'KSampler',
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'Input: positive',
extra_info: {
input_name: 'positive'
}
},
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'Input: model',
extra_info: {
input_name: 'model'
}
}
]
}
}
}
const { user } = renderComponent((pinia) => {
useExecutionErrorStore(pinia).recordNodeErrors({
'2': nodeError(
[
validationError(
'required_input_missing',
'clip',
{},
'Required input is missing',
'Input: clip'
)
],
'CLIPTextEncode'
),
'1': nodeError(
[
validationError(
'required_input_missing',
'positive',
{},
'Required input is missing',
'Input: positive'
),
validationError(
'required_input_missing',
'model',
{},
'Required input is missing',
'Input: model'
)
],
'KSampler'
)
})
})
expect(screen.getByText('Missing connection')).toBeInTheDocument()
@@ -269,18 +269,17 @@ describe('TabErrors.vue', () => {
title: 'KSampler'
} as ReturnType<typeof getNodeByExecutionId>)
const { user } = renderComponent({
executionError: {
lastExecutionError: {
prompt_id: 'abc',
node_id: '10',
node_type: 'KSampler',
exception_message: 'Out of memory',
exception_type: 'RuntimeError',
traceback: ['Line 1', 'Line 2'],
timestamp: Date.now()
}
}
const { user } = renderComponent((pinia) => {
useExecutionErrorStore(pinia).recordExecutionError({
prompt_id: 'abc',
node_id: '10',
node_type: 'KSampler',
executed: [],
exception_message: 'Out of memory',
exception_type: 'RuntimeError',
traceback: ['Line 1', 'Line 2'],
timestamp: Date.now()
})
})
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
@@ -300,19 +299,17 @@ describe('TabErrors.vue', () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
const { user } = renderComponent({
executionError: {
lastNodeErrors: {
'1': {
class_type: 'CLIPTextEncode',
errors: [{ message: 'Missing text input' }]
},
'2': {
class_type: 'KSampler',
errors: [{ message: 'Out of memory' }]
}
}
}
const { user } = renderComponent((pinia) => {
useExecutionErrorStore(pinia).recordNodeErrors({
'1': nodeError(
[validationError('unknown', undefined, {}, 'Missing text input', '')],
'CLIPTextEncode'
),
'2': nodeError(
[validationError('unknown', undefined, {}, 'Out of memory', '')],
'KSampler'
)
})
})
expect(screen.getAllByText('CLIPTextEncode').length).toBeGreaterThanOrEqual(
@@ -337,18 +334,17 @@ describe('TabErrors.vue', () => {
const mockCopy = vi.fn()
vi.mocked(useCopyToClipboard).mockReturnValue({ copyToClipboard: mockCopy })
const { user } = renderComponent({
executionError: {
lastExecutionError: {
prompt_id: 'abc',
node_id: '1',
node_type: 'TestNode',
exception_message: 'Test message',
exception_type: 'RuntimeError',
traceback: ['Test details'],
timestamp: Date.now()
}
}
const { user } = renderComponent((pinia) => {
useExecutionErrorStore(pinia).recordExecutionError({
prompt_id: 'abc',
node_id: '1',
node_type: 'TestNode',
executed: [],
exception_message: 'Test message',
exception_type: 'RuntimeError',
traceback: ['Test details'],
timestamp: Date.now()
})
})
await user.click(screen.getByTestId('error-card-copy'))
@@ -364,18 +360,17 @@ describe('TabErrors.vue', () => {
title: 'KSampler'
} as ReturnType<typeof getNodeByExecutionId>)
renderComponent({
executionError: {
lastExecutionError: {
prompt_id: 'abc',
node_id: '10',
node_type: 'KSampler',
exception_message: 'Out of memory',
exception_type: 'RuntimeError',
traceback: ['Line 1', 'Line 2'],
timestamp: Date.now()
}
}
renderComponent((pinia) => {
useExecutionErrorStore(pinia).recordExecutionError({
prompt_id: 'abc',
node_id: '10',
node_type: 'KSampler',
executed: [],
exception_message: 'Out of memory',
exception_type: 'RuntimeError',
traceback: ['Line 1', 'Line 2'],
timestamp: Date.now()
})
})
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
@@ -399,12 +394,9 @@ describe('TabErrors.vue', () => {
isAssetSupported: true
} satisfies MissingModelCandidate
const { user } = renderComponent({
missingModel: {
missingModelCandidates: [missingModel]
}
const { user } = renderComponent((pinia) => {
useMissingModelStore(pinia).setMissingModels([missingModel])
})
const missingModelStore = useMissingModelStore()
expect(screen.getByText('Missing Models')).toBeInTheDocument()
expect(
@@ -413,33 +405,31 @@ describe('TabErrors.vue', () => {
await user.click(screen.getByTestId('missing-model-header-refresh'))
expect(missingModelStore.refreshMissingModels).toHaveBeenCalled()
expect(mockRefreshMissingModels).toHaveBeenCalledWith({ silent: true })
})
it('counts missing models per file when several share one directory', () => {
renderComponent({
missingModel: {
missingModelCandidates: [
{
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'model-a.safetensors',
directory: 'checkpoints',
isMissing: true,
isAssetSupported: true
},
{
nodeId: '2',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'model-b.safetensors',
directory: 'checkpoints',
isMissing: true,
isAssetSupported: true
}
] satisfies MissingModelCandidate[]
}
renderComponent((pinia) => {
useMissingModelStore(pinia).setMissingModels([
{
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'model-a.safetensors',
directory: 'checkpoints',
isMissing: true,
isAssetSupported: true
},
{
nodeId: '2',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'model-b.safetensors',
directory: 'checkpoints',
isMissing: true,
isAssetSupported: true
}
])
})
expect(
@@ -461,10 +451,8 @@ describe('TabErrors.vue', () => {
isAssetSupported: true
} satisfies MissingModelCandidate
renderComponent({
missingModel: {
missingModelCandidates: [missingModel]
}
renderComponent((pinia) => {
useMissingModelStore(pinia).setMissingModels([missingModel])
})
expect(screen.getByText('Missing Models')).toBeInTheDocument()
@@ -483,10 +471,8 @@ describe('TabErrors.vue', () => {
isMissing: true
} satisfies MissingMediaCandidate
renderComponent({
missingMedia: {
missingMediaCandidates: [missingMedia]
}
renderComponent((pinia) => {
useMissingMediaStore(pinia).setMissingMedia([missingMedia])
})
expect(screen.getByText('Missing Inputs')).toBeInTheDocument()
@@ -507,27 +493,25 @@ describe('TabErrors.vue', () => {
} as ReturnType<typeof getNodeByExecutionId>
})
const { user } = renderComponent({
missingMedia: {
missingMediaCandidates: [
{
nodeId: '3',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'shared.png',
isMissing: true
},
{
nodeId: '4',
nodeType: 'PreviewImage',
widgetName: 'image',
mediaType: 'image',
name: 'shared.png',
isMissing: true
}
] satisfies MissingMediaCandidate[]
}
const { user } = renderComponent((pinia) => {
useMissingMediaStore(pinia).setMissingMedia([
{
nodeId: '3',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'shared.png',
isMissing: true
},
{
nodeId: '4',
nodeType: 'PreviewImage',
widgetName: 'image',
mediaType: 'image',
name: 'shared.png',
isMissing: true
}
])
})
expect(screen.getAllByTestId('missing-media-row')).toHaveLength(2)
@@ -551,59 +535,58 @@ describe('TabErrors.vue', () => {
title: 'Node'
} as ReturnType<typeof getNodeByExecutionId>)
renderComponent({
executionError: {
lastNodeErrors: {
'1': {
class_type: 'KSampler',
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'Input: model',
extra_info: { input_name: 'model' }
},
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'Input: positive',
extra_info: { input_name: 'positive' }
}
]
},
'2': {
class_type: 'CLIPTextEncode',
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'Input: clip',
extra_info: { input_name: 'clip' }
}
]
}
renderComponent((pinia) => {
useExecutionErrorStore(pinia).recordNodeErrors({
'1': nodeError(
[
validationError(
'required_input_missing',
'model',
{},
'Required input is missing',
'Input: model'
),
validationError(
'required_input_missing',
'positive',
{},
'Required input is missing',
'Input: positive'
)
],
'KSampler'
),
'2': nodeError(
[
validationError(
'required_input_missing',
'clip',
{},
'Required input is missing',
'Input: clip'
)
],
'CLIPTextEncode'
)
})
useMissingMediaStore(pinia).setMissingMedia([
{
nodeId: '3',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'a.png',
isMissing: true
},
{
nodeId: '4',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'b.png',
isMissing: true
}
},
missingMedia: {
missingMediaCandidates: [
{
nodeId: '3',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'a.png',
isMissing: true
},
{
nodeId: '4',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'b.png',
isMissing: true
}
]
} satisfies { missingMediaCandidates: MissingMediaCandidate[] }
])
})
// 3 validation items + 2 missing media references
@@ -626,13 +609,8 @@ describe('TabErrors.vue', () => {
}
} satisfies MissingNodeType
renderComponent({
missingNodesError: {
missingNodesError: {
message: 'Missing Node Packs',
nodeTypes: [swapNode]
}
}
renderComponent((pinia) => {
useMissingNodesErrorStore(pinia).setMissingNodeTypes([swapNode])
})
expect(screen.getByText('Swap Nodes')).toBeInTheDocument()
@@ -660,10 +638,8 @@ describe('TabErrors.vue', () => {
isAssetSupported: true
} satisfies MissingModelCandidate
renderComponent({
missingModel: {
missingModelCandidates: [missingModel]
}
renderComponent((pinia) => {
useMissingModelStore(pinia).setMissingModels([missingModel])
})
expect(screen.getByTestId('missing-model-header-refresh')).toBeVisible()

View File

@@ -428,7 +428,7 @@ describe('useErrorGroups', () => {
it('uses fallback catalog grouping for unknown node validation errors', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
store.recordNodeErrors({
'1': {
class_type: 'KSampler',
dependent_outputs: [],
@@ -440,7 +440,7 @@ describe('useErrorGroups', () => {
}
]
}
}
})
await nextTick()
const execGroups = groups.allErrorGroups.value.filter(
@@ -453,7 +453,7 @@ describe('useErrorGroups', () => {
it('resolves required_input_missing item display copy', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
store.recordNodeErrors({
'1': {
class_type: 'KSampler',
dependent_outputs: [],
@@ -468,7 +468,7 @@ describe('useErrorGroups', () => {
}
]
}
}
})
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
@@ -509,7 +509,7 @@ describe('useErrorGroups', () => {
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
return actualGetNodeByExecutionId(rootGraph, String(nodeId))
})
store.lastNodeErrors = {
store.recordNodeErrors({
'12:5': nodeError(
[
validationError(
@@ -521,7 +521,7 @@ describe('useErrorGroups', () => {
],
'InteriorClass'
)
}
})
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
@@ -540,7 +540,7 @@ describe('useErrorGroups', () => {
it('groups node validation errors by catalog id across node types', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
store.recordNodeErrors({
'1': {
class_type: 'KSampler',
dependent_outputs: [],
@@ -569,7 +569,7 @@ describe('useErrorGroups', () => {
}
]
}
}
})
await nextTick()
const execGroups = groups.allErrorGroups.value.filter(
@@ -590,7 +590,7 @@ describe('useErrorGroups', () => {
it('uses general execution_failed display fields for unrecognized runtime execution errors', async () => {
mockIsCloud.value = true
const { store, groups } = createErrorGroups()
store.lastExecutionError = {
store.recordExecutionError({
prompt_id: 'test-prompt',
timestamp: Date.now(),
node_id: 5,
@@ -601,7 +601,7 @@ describe('useErrorGroups', () => {
traceback: ['line 1', 'line 2'],
current_inputs: {},
current_outputs: {}
}
})
await nextTick()
const execGroups = groups.allErrorGroups.value.filter(
@@ -627,7 +627,7 @@ describe('useErrorGroups', () => {
it('adds display fields for targeted runtime execution errors', async () => {
mockIsCloud.value = true
const { store, groups } = createErrorGroups()
store.lastExecutionError = {
store.recordExecutionError({
prompt_id: 'test-prompt',
timestamp: Date.now(),
node_id: 5,
@@ -639,7 +639,7 @@ describe('useErrorGroups', () => {
traceback: ['line 1', 'line 2'],
current_inputs: {},
current_outputs: {}
}
})
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
@@ -660,11 +660,11 @@ describe('useErrorGroups', () => {
it('includes prompt error when present', async () => {
const { store, groups } = createErrorGroups()
store.lastPromptError = {
store.recordPromptError({
type: 'prompt_no_outputs',
message: 'No outputs',
details: ''
}
})
await nextTick()
const promptGroup = groups.allErrorGroups.value.find(
@@ -682,11 +682,11 @@ describe('useErrorGroups', () => {
typeof canvasStore.selectedItems,
unknown
>([{ id: '1' }])
store.lastPromptError = {
store.recordPromptError({
type: 'prompt_no_outputs',
message: 'No outputs',
details: ''
}
})
await nextTick()
const promptGroup = groups.allErrorGroups.value.find(
@@ -698,7 +698,7 @@ describe('useErrorGroups', () => {
it('sorts cards within an execution group by nodeId numerically', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
store.recordNodeErrors({
'10': {
class_type: 'KSampler',
dependent_outputs: [],
@@ -714,7 +714,7 @@ describe('useErrorGroups', () => {
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
}
}
})
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
@@ -726,7 +726,7 @@ describe('useErrorGroups', () => {
it('sorts cards with subpath nodeIds before higher root IDs', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
store.recordNodeErrors({
'2': {
class_type: 'KSampler',
dependent_outputs: [],
@@ -742,7 +742,7 @@ describe('useErrorGroups', () => {
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
}
}
})
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
@@ -754,7 +754,7 @@ describe('useErrorGroups', () => {
it('sorts deeply nested nodeIds by each segment numerically', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
store.recordNodeErrors({
'10:11:99': {
class_type: 'KSampler',
dependent_outputs: [],
@@ -770,7 +770,7 @@ describe('useErrorGroups', () => {
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
}
}
})
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
@@ -784,13 +784,13 @@ describe('useErrorGroups', () => {
describe('filteredGroups', () => {
it('returns all groups when search query is empty', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
store.recordNodeErrors({
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
}
}
})
await nextTick()
expect(groups.filteredGroups.value.length).toBeGreaterThan(0)
@@ -798,7 +798,7 @@ describe('useErrorGroups', () => {
it('filters groups based on search query', async () => {
const { store, groups, searchQuery } = createErrorGroups()
store.lastNodeErrors = {
store.recordNodeErrors({
'1': {
class_type: 'KSampler',
dependent_outputs: [],
@@ -821,7 +821,7 @@ describe('useErrorGroups', () => {
}
]
}
}
})
await nextTick()
searchQuery.value = 'sampler'
@@ -1097,11 +1097,11 @@ describe('useErrorGroups', () => {
typeof canvasStore.selectedItems,
unknown
>([{ id: '1' }])
store.lastPromptError = {
store.recordPromptError({
type: 'prompt_no_outputs',
message: 'No outputs',
details: ''
}
})
await nextTick()
const promptGroup = groups.allErrorGroups.value.find(
@@ -1116,13 +1116,13 @@ describe('useErrorGroups', () => {
it('reports no selection state when nothing is selected', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
store.recordNodeErrors({
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
}
}
})
await nextTick()
expect(groups.hasSelection.value).toBe(false)
@@ -1145,7 +1145,7 @@ describe('useErrorGroups', () => {
typeof canvasStore.selectedItems,
unknown
>([selectedNode])
store.lastNodeErrors = {
store.recordNodeErrors({
'1': {
class_type: 'KSampler',
dependent_outputs: [],
@@ -1158,7 +1158,7 @@ describe('useErrorGroups', () => {
{ type: 'file_not_found', message: 'File not found', details: '' }
]
}
}
})
await nextTick()
expect(groups.hasSelection.value).toBe(true)
@@ -1254,13 +1254,13 @@ describe('useErrorGroups', () => {
typeof canvasStore.selectedItems,
unknown
>([selectedNode])
store.lastNodeErrors = {
store.recordNodeErrors({
'2:5': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
}
}
})
await nextTick()
expect(groups.selectionErrorCount.value).toBe(1)
@@ -1284,7 +1284,7 @@ describe('useErrorGroups', () => {
typeof canvasStore.selectedItems,
unknown
>([containerNode])
store.lastNodeErrors = {
store.recordNodeErrors({
'2:5': {
class_type: 'KSampler',
dependent_outputs: [],
@@ -1297,7 +1297,7 @@ describe('useErrorGroups', () => {
{ type: 'file_not_found', message: 'File not found', details: '' }
]
}
}
})
await nextTick()
expect(groups.selectionErrorCount.value).toBe(1)

View File

@@ -169,7 +169,7 @@ describe('Widget change error clearing via onWidgetChanged', () => {
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
store.lastNodeErrors = {
store.recordNodeErrors({
[String(node.id)]: {
errors: [
{
@@ -182,7 +182,7 @@ describe('Widget change error clearing via onWidgetChanged', () => {
dependent_outputs: [],
class_type: 'TestNode'
}
}
})
node.onWidgetChanged!.call(node, 'steps', 50, 20, node.widgets![0])
@@ -201,7 +201,7 @@ describe('Widget change error clearing via onWidgetChanged', () => {
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
store.lastNodeErrors = {
store.recordNodeErrors({
[String(node.id)]: {
errors: [
{
@@ -214,7 +214,7 @@ describe('Widget change error clearing via onWidgetChanged', () => {
dependent_outputs: [],
class_type: 'TestNode'
}
}
})
node.onWidgetChanged!.call(node, 'steps', 150, 20, node.widgets![0])
@@ -232,7 +232,7 @@ describe('Widget change error clearing via onWidgetChanged', () => {
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(
fromAny<LGraph, unknown>(undefined)
)
store.lastNodeErrors = {
store.recordNodeErrors({
[String(node.id)]: {
errors: [
{
@@ -245,7 +245,7 @@ describe('Widget change error clearing via onWidgetChanged', () => {
dependent_outputs: [],
class_type: 'TestNode'
}
}
})
node.onWidgetChanged!.call(node, 'steps', 50, 20, node.widgets![0])

View File

@@ -514,7 +514,7 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
it('sets has_errors on nodes referenced in lastNodeErrors', async () => {
const { nodeA, nodeB, store } = setupGraphWithStore()
store.lastNodeErrors = {
store.recordNodeErrors({
[String(nodeA.id)]: {
errors: [
{
@@ -527,7 +527,7 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
dependent_outputs: [],
class_type: 'KSampler'
}
}
})
await nextTick()
expect(nodeA.has_errors).toBe(true)
@@ -537,7 +537,7 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
it('sets slot hasErrors for inputs matching error input_name', async () => {
const { nodeA, store } = setupGraphWithStore()
store.lastNodeErrors = {
store.recordNodeErrors({
[String(nodeA.id)]: {
errors: [
{
@@ -550,7 +550,7 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
dependent_outputs: [],
class_type: 'KSampler'
}
}
})
await nextTick()
expect(nodeA.inputs[0].hasErrors).toBe(true)
@@ -560,7 +560,7 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
it('clears has_errors and slot hasErrors when errors are removed', async () => {
const { nodeA, store } = setupGraphWithStore()
store.lastNodeErrors = {
store.recordNodeErrors({
[String(nodeA.id)]: {
errors: [
{
@@ -573,12 +573,12 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
dependent_outputs: [],
class_type: 'KSampler'
}
}
})
await nextTick()
expect(nodeA.has_errors).toBe(true)
expect(nodeA.inputs[1].hasErrors).toBe(true)
store.lastNodeErrors = null
store.recordNodeErrors(null)
await nextTick()
expect(nodeA.has_errors).toBeFalsy()
@@ -603,7 +603,7 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
// Error on interior node: execution ID = "50:<interiorNodeId>"
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
store.lastNodeErrors = {
store.recordNodeErrors({
[interiorExecId]: {
errors: [
{
@@ -616,7 +616,7 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
dependent_outputs: [],
class_type: 'InnerNode'
}
}
})
await nextTick()
// Interior node should have the error
@@ -626,6 +626,56 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
expect(subgraphNode.has_errors).toBe(true)
})
it('merges slot errors when execution IDs resolve to the same node', async () => {
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('InnerNode')
interiorNode.addInput('first', 'INT')
interiorNode.addInput('second', 'INT')
subgraph.add(interiorNode)
const firstInstance = createTestSubgraphNode(subgraph, { id: 50 })
const secondInstance = createTestSubgraphNode(subgraph, { id: 51 })
const graph = firstInstance.graph as LGraph
graph.add(firstInstance)
graph.add(secondInstance)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(true)
useGraphNodeManager(graph)
const store = useExecutionErrorStore()
store.recordNodeErrors({
[`${firstInstance.id}:${interiorNode.id}`]: {
errors: [
{
type: 'required_input_missing',
message: 'Missing first',
details: '',
extra_info: { input_name: 'first' }
}
],
dependent_outputs: [],
class_type: 'InnerNode'
},
[`${secondInstance.id}:${interiorNode.id}`]: {
errors: [
{
type: 'required_input_missing',
message: 'Missing second',
details: '',
extra_info: { input_name: 'second' }
}
],
dependent_outputs: [],
class_type: 'InnerNode'
}
})
await nextTick()
expect(interiorNode.inputs[0].hasErrors).toBe(true)
expect(interiorNode.inputs[1].hasErrors).toBe(true)
})
it('sets has_errors on nodes with missing models', async () => {
const { nodeA, nodeB } = setupGraphWithStore()
const missingModelStore = useMissingModelStore()

View File

@@ -8,6 +8,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import type { NodeError } from '@/schemas/apiSchema'
import { getParentExecutionIds } from '@/types/nodeIdentification'
import { hasErrorForSlot } from '@/utils/executionErrorUtil'
import { forEachNode, getNodeByExecutionId } from '@/utils/graphTraversalUtil'
function setNodeHasErrors(node: LGraphNode, hasErrors: boolean): void {
@@ -39,7 +40,7 @@ function reconcileNodeErrorFlags(
// Collect nodes and slot info that should be flagged
// Includes both error-owning nodes and their ancestor containers
const flaggedNodes = new Set<LGraphNode>()
const errorSlots = new Map<LGraphNode, Set<string>>()
const errorsByNode = new Map<LGraphNode, NodeError['errors']>()
if (nodeErrors) {
for (const [executionId, nodeError] of Object.entries(nodeErrors)) {
@@ -47,12 +48,10 @@ function reconcileNodeErrorFlags(
if (!node) continue
flaggedNodes.add(node)
const slotNames = new Set<string>()
for (const error of nodeError.errors) {
const name = error.extra_info?.input_name
if (name) slotNames.add(name)
}
if (slotNames.size > 0) errorSlots.set(node, slotNames)
errorsByNode.set(node, [
...(errorsByNode.get(node) ?? []),
...nodeError.errors
])
for (const parentId of getParentExecutionIds(executionId)) {
const parentNode = getNodeByExecutionId(rootGraph, parentId)
@@ -75,9 +74,10 @@ function reconcileNodeErrorFlags(
setNodeHasErrors(node, flaggedNodes.has(node))
if (node.inputs) {
const nodeSlotNames = errorSlots.get(node)
const ownErrors = errorsByNode.get(node)
for (const slot of node.inputs) {
slot.hasErrors = !!nodeSlotNames?.has(slot.name)
slot.hasErrors =
!!slot.name && !!ownErrors && hasErrorForSlot(ownErrors, slot.name)
}
}
})

View File

@@ -94,7 +94,7 @@ function renderControls({
useAppModeStore().selectedOutputs = [toNodeId(1)]
if (hasError) {
useExecutionErrorStore().lastNodeErrors = nodeErrors
useExecutionErrorStore().recordNodeErrors(nodeErrors)
}
const toastTarget = document.createElement('div')

View File

@@ -20,6 +20,7 @@ import {
createNodeLocatorId
} from '@/types/nodeIdentification'
import { widgetId } from '@/types/widgetId'
import { validationError } from '@/utils/__tests__/nodeErrorHelpers'
const GRAPH_ID = 'graph-test'
@@ -156,7 +157,7 @@ describe('hasWidgetError', () => {
it('returns true when node has matching input error', () => {
const widget = createMockWidget({ name: 'seed' })
const nodeErrors = {
errors: [{ extra_info: { input_name: 'seed' } }]
errors: [validationError('required_input_missing', 'seed')]
}
expect(
hasWidgetError(
@@ -174,7 +175,7 @@ describe('hasWidgetError', () => {
name: 'seed',
sourceExecutionId: createNodeExecutionId([toNodeId(65), toNodeId(18)])
})
executionErrorStore.lastNodeErrors = {
executionErrorStore.recordNodeErrors({
'65:18': {
errors: [
{
@@ -187,7 +188,7 @@ describe('hasWidgetError', () => {
class_type: 'TestNode',
dependent_outputs: []
}
}
})
expect(
hasWidgetError(
widget,
@@ -219,7 +220,7 @@ describe('hasWidgetError', () => {
sourceWidgetName: 'internal_name'
})
const nodeErrors = {
errors: [{ extra_info: { input_name: 'display_slot' } }]
errors: [validationError('required_input_missing', 'display_slot')]
}
expect(
hasWidgetError(
@@ -263,7 +264,7 @@ describe('hasWidgetError', () => {
sourceExecutionId,
sourceWidgetName: 'ckpt_name'
})
executionErrorStore.lastNodeErrors = {
executionErrorStore.recordNodeErrors({
[sourceExecutionId]: {
errors: [
{
@@ -276,7 +277,7 @@ describe('hasWidgetError', () => {
class_type: 'CheckpointLoaderSimple',
dependent_outputs: []
}
}
})
expect(
hasWidgetError(
widget,
@@ -711,7 +712,7 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
sourceWidgetName: 'ckpt_name'
})
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
executionErrorStore.recordNodeErrors({
[sourceExecutionId]: {
errors: [
{
@@ -724,7 +725,7 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
class_type: 'CheckpointLoaderSimple',
dependent_outputs: []
}
}
})
const [processed] = processWidgets([widget])
processed.updateHandler('real_model.safetensors')
@@ -741,7 +742,7 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
executionErrorStore.lastNodeErrors = {
executionErrorStore.recordNodeErrors({
[NODE_ID]: {
errors: [
{
@@ -754,7 +755,7 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
class_type: 'TestNode',
dependent_outputs: []
}
}
})
const [processed] = processWidgets([widget])
@@ -762,7 +763,7 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
hasWidgetError(
widget,
createNodeExecutionId([NODE_ID]),
executionErrorStore.lastNodeErrors[NODE_ID],
executionErrorStore.lastNodeErrors?.[NODE_ID],
executionErrorStore,
missingModelStore
)

View File

@@ -14,6 +14,7 @@ import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import type { NodeError } from '@/schemas/apiSchema'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
@@ -39,6 +40,7 @@ import type { NodeId } from '@/types/nodeId'
import type { WidgetId } from '@/types/widgetId'
import { widgetId } from '@/types/widgetId'
import type { WidgetState } from '@/types/widgetState'
import { hasErrorForSlot } from '@/utils/executionErrorUtil'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type {
LinkedUpstreamInfo,
@@ -121,9 +123,7 @@ function createWidgetUpdateHandler(
export function hasWidgetError(
widget: SafeWidgetData,
nodeExecId: NodeExecutionId,
nodeErrors:
| { errors: { extra_info?: { input_name?: string } }[] }
| undefined,
nodeErrors: Pick<NodeError, 'errors'> | undefined,
executionErrorStore: ReturnType<typeof useExecutionErrorStore>,
missingModelStore: ReturnType<typeof useMissingModelStore>
): boolean {
@@ -135,7 +135,7 @@ export function hasWidgetError(
? (widget.sourceWidgetName ?? widget.name)
: widget.name
return (
!!errors?.some((e) => e.extra_info?.input_name === errorInputName) ||
(!!errors && hasErrorForSlot(errors, errorInputName)) ||
missingModelStore.isWidgetMissingModel(nodeExecId, widget.name)
)
}

View File

@@ -21,7 +21,8 @@ import {
} from '@/composables/usePaste'
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { api } from '@/scripts/api'
import { PromptExecutionError, api } from '@/scripts/api'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useExecutionStore } from '@/stores/executionStore'
import type { NodeError } from '@/schemas/apiSchema'
@@ -189,6 +190,21 @@ describe('ComfyApp', () => {
})
describe('queuePrompt', () => {
function prepareEmptyPromptQueue() {
const workflow = new ComfyWorkflow({
path: 'workflows/review.json',
modified: 0,
size: 0
})
Reflect.set(app, 'rootGraphInternal', new LGraph())
mockWorkspaceWorkflow.activeWorkflow = workflow
vi.spyOn(app, 'graphToPrompt').mockResolvedValue({
output: {},
workflow: createWorkflowGraphData()
})
vi.spyOn(api, 'dispatchCustomEvent').mockImplementation(() => true)
}
it('shows the error overlay for successful prompt responses with node errors', async () => {
const graph = new LGraph()
const workflow = new ComfyWorkflow({
@@ -242,6 +258,77 @@ describe('ComfyApp', () => {
)
expect(mockCanvas.draw).toHaveBeenCalledWith(true, true)
})
it('preserves a failed result when prompt errors include an empty node error record', async () => {
prepareEmptyPromptQueue()
vi.spyOn(api, 'queuePrompt').mockRejectedValue(
new PromptExecutionError({
node_errors: {},
error: {
type: 'prompt_no_outputs',
message: 'Prompt has no outputs',
details: ''
}
})
)
await expect(app.queuePrompt(0)).resolves.toBe(false)
const errorStore = useExecutionErrorStore()
expect(errorStore.lastNodeErrors).toBeNull()
expect(errorStore.lastPromptError).toMatchObject({
type: 'prompt_no_outputs'
})
})
it('preserves a successful result when prompt errors omit node errors', async () => {
prepareEmptyPromptQueue()
vi.spyOn(api, 'queuePrompt').mockRejectedValue(
new PromptExecutionError({
error: {
type: 'prompt_no_outputs',
message: 'Prompt has no outputs',
details: ''
}
})
)
await expect(app.queuePrompt(0)).resolves.toBe(true)
})
it('uses the last processed queue item result after an earlier failure', async () => {
prepareEmptyPromptQueue()
let rejectFirst!: (reason?: unknown) => void
const firstResponse = new Promise<never>((_, reject) => {
rejectFirst = reject
})
vi.spyOn(api, 'queuePrompt')
.mockImplementationOnce(() => firstResponse)
.mockResolvedValueOnce({
prompt_id: 'job-2',
error: ''
})
const firstQueue = app.queuePrompt(0)
await vi.waitFor(() => {
expect(api.queuePrompt).toHaveBeenCalledTimes(1)
})
await expect(app.queuePrompt(0)).resolves.toBe(false)
rejectFirst(
new PromptExecutionError({
node_errors: {},
error: {
type: 'prompt_no_outputs',
message: 'Prompt has no outputs',
details: ''
}
})
)
await expect(firstQueue).resolves.toBe(true)
expect(useExecutionErrorStore().lastNodeErrors).toBeNull()
})
})
describe('refreshComboInNodes', () => {

View File

@@ -95,6 +95,7 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
import type { ExtensionManager } from '@/types/extensionTypes'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { normalizePromptError } from '@/utils/executionErrorUtil'
import { graphToPrompt } from '@/utils/executionUtil'
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
import { getCnrIdFromProperties } from '@/platform/nodeReplacement/cnrIdUtil'
@@ -1631,6 +1632,7 @@ export class ComfyApp {
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.clearAllErrors()
let queueResultOverride: boolean | null = null
// Get auth token for backend nodes - uses workspace token if enabled, otherwise Firebase token
const comfyOrgAuthToken = await useAuthStore().getAuthToken()
@@ -1673,12 +1675,8 @@ export class ComfyApp {
})
delete api.authToken
delete api.apiKey
const nodeErrors = res.node_errors
const hasNodeErrors =
nodeErrors && Object.keys(nodeErrors).length > 0
executionErrorStore.lastNodeErrors = hasNodeErrors
? nodeErrors
: null
executionErrorStore.recordNodeErrors(res.node_errors ?? null)
queueResultOverride = null
try {
if (res.prompt_id) {
executionStore.storeJob({
@@ -1694,7 +1692,7 @@ export class ComfyApp {
error
})
}
if (hasNodeErrors) {
if (executionErrorStore.hasNodeError) {
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
executionErrorStore.showErrorOverlay()
}
@@ -1766,30 +1764,18 @@ export class ComfyApp {
console.error(error)
if (error instanceof PromptExecutionError) {
executionErrorStore.lastNodeErrors =
error.response.node_errors ?? null
// Keep the legacy result before empty node errors are normalized.
const nodeErrors = error.response.node_errors
queueResultOverride = !nodeErrors
executionErrorStore.recordNodeErrors(nodeErrors ?? null)
// Store prompt-level error separately only when no node-specific errors exist,
// because node errors already carry the full context. Prompt-level errors
// (e.g. prompt_no_outputs, no_prompt) lack node IDs and need their own path.
const nodeErrors = error.response.node_errors
const hasNodeErrors =
nodeErrors && Object.keys(nodeErrors).length > 0
if (!hasNodeErrors) {
const respError = error.response.error
if (respError && typeof respError === 'object') {
executionErrorStore.lastPromptError = {
type: respError.type,
message: respError.message,
details: respError.details ?? ''
}
} else if (typeof respError === 'string') {
executionErrorStore.lastPromptError = {
type: 'error',
message: respError,
details: ''
}
if (!executionErrorStore.hasNodeError) {
const promptError = normalizePromptError(error.response.error)
if (promptError) {
executionErrorStore.recordPromptError(promptError)
}
}
@@ -1828,7 +1814,7 @@ export class ComfyApp {
} finally {
this.processingQueue = false
}
return !executionErrorStore.lastNodeErrors
return queueResultOverride ?? !executionErrorStore.lastNodeErrors
}
showErrorOnFileLoad(file: File) {

View File

@@ -68,7 +68,7 @@ describe('executionErrorStore — node error operations', () => {
describe('clearSimpleNodeErrors', () => {
it('does nothing if lastNodeErrors is null', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = null
store.recordNodeErrors(null)
// Should not error
store.clearSimpleNodeErrors(
createNodeExecutionId([toNodeId(123)]),
@@ -79,7 +79,7 @@ describe('executionErrorStore — node error operations', () => {
it('clears entirely if there are only simple errors for the same slot', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -92,7 +92,7 @@ describe('executionErrorStore — node error operations', () => {
dependent_outputs: [],
class_type: 'TestNode'
}
}
})
store.clearSimpleNodeErrors(
createNodeExecutionId([toNodeId(123)]),
@@ -105,7 +105,7 @@ describe('executionErrorStore — node error operations', () => {
it('clears only the specific slot errors, leaving other errors alone', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -124,7 +124,7 @@ describe('executionErrorStore — node error operations', () => {
dependent_outputs: [],
class_type: 'TestNode'
}
}
})
store.clearSimpleNodeErrors(
createNodeExecutionId([toNodeId(123)]),
@@ -141,7 +141,7 @@ describe('executionErrorStore — node error operations', () => {
it('does nothing if executionId is not found in lastNodeErrors', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -154,7 +154,7 @@ describe('executionErrorStore — node error operations', () => {
dependent_outputs: [],
class_type: 'TestNode'
}
}
})
store.clearSimpleNodeErrors(
createNodeExecutionId([toNodeId(999)]),
@@ -167,7 +167,7 @@ describe('executionErrorStore — node error operations', () => {
it('preserves complex errors when slot has both simple and complex errors', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -186,7 +186,7 @@ describe('executionErrorStore — node error operations', () => {
dependent_outputs: [],
class_type: 'TestNode'
}
}
})
store.clearSimpleNodeErrors(
createNodeExecutionId([toNodeId(123)]),
@@ -199,7 +199,7 @@ describe('executionErrorStore — node error operations', () => {
it('clears one node while preserving another in multi-node errors', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -224,7 +224,7 @@ describe('executionErrorStore — node error operations', () => {
dependent_outputs: [],
class_type: 'LoadModel'
}
}
})
store.clearSimpleNodeErrors(
createNodeExecutionId([toNodeId(123)]),
@@ -238,7 +238,7 @@ describe('executionErrorStore — node error operations', () => {
it('clears entire node when no slotName and all errors are simple', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -257,7 +257,7 @@ describe('executionErrorStore — node error operations', () => {
dependent_outputs: [],
class_type: 'TestNode'
}
}
})
store.clearSimpleNodeErrors(createNodeExecutionId([toNodeId(123)]))
@@ -266,7 +266,7 @@ describe('executionErrorStore — node error operations', () => {
it('does not clear when no slotName and some errors are not simple', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -285,7 +285,7 @@ describe('executionErrorStore — node error operations', () => {
dependent_outputs: [],
class_type: 'TestNode'
}
}
})
store.clearSimpleNodeErrors(createNodeExecutionId([toNodeId(123)]))
@@ -294,7 +294,7 @@ describe('executionErrorStore — node error operations', () => {
it('does not clear if the error is not simple', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -307,7 +307,7 @@ describe('executionErrorStore — node error operations', () => {
dependent_outputs: [],
class_type: 'TestNode'
}
}
})
store.clearSimpleNodeErrors(
createNodeExecutionId([toNodeId(123)]),
@@ -323,11 +323,11 @@ describe('executionErrorStore — node error operations', () => {
mockGraphReady(rootGraph)
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'12:5': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
})
expect(store.surfacedNodeErrors).toHaveProperty('12')
@@ -342,7 +342,7 @@ describe('executionErrorStore — node error operations', () => {
mockGraphReady(rootGraph)
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'12:5': nodeError([
validationError(
'custom_validation_failed',
@@ -351,7 +351,7 @@ describe('executionErrorStore — node error operations', () => {
'Custom validation failed'
)
])
}
})
expect(store.surfacedNodeErrors).toHaveProperty('12')
@@ -389,11 +389,11 @@ describe('executionErrorStore — node error operations', () => {
mockGraphReady(rootGraph)
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'1:2:3': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
})
expect(store.surfacedNodeErrors).toHaveProperty('1')
@@ -413,7 +413,7 @@ describe('executionErrorStore — node error operations', () => {
describe('clearWidgetRelatedErrors', () => {
it('clears error if value is valid (isValueStillOutOfRange is false)', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -426,7 +426,7 @@ describe('executionErrorStore — node error operations', () => {
dependent_outputs: [],
class_type: 'TestNode'
}
}
})
// Valid value (5 < 10)
store.clearWidgetRelatedErrors(
@@ -444,7 +444,7 @@ describe('executionErrorStore — node error operations', () => {
it('optimistically clears value_not_in_list error for string combo values', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -457,7 +457,7 @@ describe('executionErrorStore — node error operations', () => {
dependent_outputs: [],
class_type: 'KSampler'
}
}
})
store.clearWidgetRelatedErrors(
createNodeExecutionId([toNodeId(123)]),
@@ -471,7 +471,7 @@ describe('executionErrorStore — node error operations', () => {
it('does not clear error if value is still out of range', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -484,7 +484,7 @@ describe('executionErrorStore — node error operations', () => {
dependent_outputs: [],
class_type: 'TestNode'
}
}
})
// Invalid value (15 > 10)
store.clearWidgetRelatedErrors(
@@ -503,13 +503,13 @@ describe('executionErrorStore — node error operations', () => {
it('validates the base target against live widget bounds, not recorded ones', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'123': nodeError([
validationError('value_bigger_than_max', 'testWidget', {
input_config: ['INT', { max: 100 }]
})
])
}
})
store.clearWidgetRelatedErrors(
createNodeExecutionId([toNodeId(123)]),
@@ -530,11 +530,11 @@ describe('executionErrorStore — node error operations', () => {
mockGraphReady(rootGraph)
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'12:5': nodeError([
validationError('value_bigger_than_max', 'seed_input', {}, 'Too high')
])
}
})
expect(store.surfacedNodeErrors).toHaveProperty('12')
@@ -570,7 +570,7 @@ describe('executionErrorStore — node error operations', () => {
mockGraphReady(rootGraph)
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'12:5': nodeError([
validationError('value_bigger_than_max', 'seed_input', {
input_config: ['INT', { max: 100 }]
@@ -581,7 +581,7 @@ describe('executionErrorStore — node error operations', () => {
input_config: ['INT', { max: 50 }]
})
])
}
})
expect(store.surfacedNodeErrors?.['12'].errors).toHaveLength(2)
@@ -610,11 +610,11 @@ describe('executionErrorStore — node error operations', () => {
mockGraphReady(rootGraph)
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'12:5': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
})
const hostLocatorId = createNodeLocatorId(null, toNodeId(12))
@@ -770,6 +770,28 @@ describe('surfaceMissingMedia — silent option', () => {
})
})
describe('recordNodeErrors', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('normalizes an empty error record to null', () => {
const store = useExecutionErrorStore()
store.recordNodeErrors({})
expect(store.lastNodeErrors).toBeNull()
})
it('keeps a null error record as null', () => {
const store = useExecutionErrorStore()
store.recordNodeErrors(null)
expect(store.lastNodeErrors).toBeNull()
})
})
describe('clearAllErrors', () => {
let executionErrorStore: ReturnType<typeof useExecutionErrorStore>
let missingNodesStore: ReturnType<typeof useMissingNodesErrorStore>
@@ -782,7 +804,7 @@ describe('clearAllErrors', () => {
})
it('resets all error categories and closes error overlay', () => {
executionErrorStore.lastExecutionError = {
executionErrorStore.recordExecutionError({
prompt_id: 'test',
timestamp: 0,
node_id: '1',
@@ -791,13 +813,13 @@ describe('clearAllErrors', () => {
exception_message: 'fail',
exception_type: 'RuntimeError',
traceback: []
}
executionErrorStore.lastPromptError = {
})
executionErrorStore.recordPromptError({
type: 'execution',
message: 'fail',
details: ''
}
executionErrorStore.lastNodeErrors = {
})
executionErrorStore.recordNodeErrors({
'1': {
errors: [
{
@@ -810,7 +832,7 @@ describe('clearAllErrors', () => {
dependent_outputs: [],
class_type: 'Test'
}
}
})
missingNodesStore.setMissingNodeTypes(
fromAny<MissingNodeType[], unknown>([{ type: 'MissingNode', hint: '' }])
)

View File

@@ -33,7 +33,9 @@ import {
} from '@/utils/graphTraversalUtil'
import {
SIMPLE_ERROR_TYPES,
errorsForSlot,
getInputConfigBounds,
hasErrorForSlot,
isValueStillOutOfRange
} from '@/utils/executionErrorUtil'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
@@ -59,6 +61,20 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
const isErrorOverlayOpen = ref(false)
/** Replaces the full record; empty or null means the run produced no errors. */
function recordNodeErrors(nodeErrors: Record<string, NodeError> | null) {
lastNodeErrors.value =
nodeErrors && Object.keys(nodeErrors).length > 0 ? nodeErrors : null
}
function recordExecutionError(detail: ExecutionErrorWsMessage) {
lastExecutionError.value = detail
}
function recordPromptError(promptError: PromptError) {
lastPromptError.value = promptError
}
function showErrorOverlay() {
isErrorOverlayOpen.value = true
}
@@ -82,10 +98,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
function clearExecutionStartErrors() {
lastExecutionError.value = null
lastPromptError.value = null
if (
!lastNodeErrors.value ||
Object.keys(lastNodeErrors.value).length === 0
) {
if (!lastNodeErrors.value) {
isErrorOverlayOpen.value = false
}
}
@@ -105,7 +118,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
const isSlotScoped = slotName !== undefined
const relevantErrors = isSlotScoped
? nodeError.errors.filter((e) => e.extra_info?.input_name === slotName)
? errorsForSlot(nodeError.errors, slotName)
: nodeError.errors
if (relevantErrors.length === 0) return null
@@ -117,7 +130,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
if (isSlotScoped) {
const remainingErrors = nodeError.errors.filter(
(e) => e.extra_info?.input_name !== slotName
(error) => !relevantErrors.includes(error)
)
if (remainingErrors.length === 0) {
delete updated[executionId]
@@ -218,9 +231,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
const nodeError = nodeErrors[target.executionId]
if (!nodeError) return false
const errors = nodeError.errors.filter(
(error) => error.extra_info?.input_name === target.slotName
)
const errors = errorsForSlot(nodeError.errors, target.slotName)
const options = target.useRecordedBounds
? getTargetRangeOptions(errors, callerOptions)
: callerOptions
@@ -374,9 +385,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
const hasPromptError = computed(() => !!lastPromptError.value)
const hasNodeError = computed(
() => !!lastNodeErrors.value && Object.keys(lastNodeErrors.value).length > 0
)
const hasNodeError = computed(() => lastNodeErrors.value !== null)
// Re-lifts only when the record changes; topology is assumed stable while errors are displayed.
const surfacedNodeErrors = computed(() =>
@@ -495,7 +504,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
const nodeError = getNodeErrors(nodeLocatorId)
if (!nodeError) return false
return nodeError.errors.some((e) => e.extra_info?.input_name === slotName)
return hasErrorForSlot(nodeError.errors, slotName)
}
/**
@@ -525,10 +534,15 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
useNodeErrorFlagSync(surfacedNodeErrors, missingModelStore, missingMediaStore)
return {
// Raw state
lastNodeErrors,
lastExecutionError,
lastPromptError,
// Read-only state
lastNodeErrors: computed(() => lastNodeErrors.value),
lastExecutionError: computed(() => lastExecutionError.value),
lastPromptError: computed(() => lastPromptError.value),
// Recording
recordNodeErrors,
recordExecutionError,
recordPromptError,
// Clearing
clearAllErrors,

View File

@@ -935,7 +935,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
})
it('should return node error by locator ID for root graph node', () => {
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -948,7 +948,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
class_type: 'TestNode',
dependent_outputs: []
}
}
})
const result = store.getNodeErrors(
createNodeLocatorId(null, toNodeId(123))
@@ -974,7 +974,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
vi.mocked(app.rootGraph.getNodeById).mockReturnValue(mockNode)
store.lastNodeErrors = {
store.recordNodeErrors({
'123:456': {
errors: [
{
@@ -987,7 +987,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
class_type: 'SubgraphNode',
dependent_outputs: []
}
}
})
const locatorId = createNodeLocatorId(subgraphUuid, toNodeId(456))
const result = store.getNodeErrors(locatorId)
@@ -1006,7 +1006,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
})
it('should return false when node has errors but slot is not mentioned', () => {
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -1019,7 +1019,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
class_type: 'TestNode',
dependent_outputs: []
}
}
})
const result = store.slotHasError(
createNodeLocatorId(null, toNodeId(123)),
@@ -1029,7 +1029,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
})
it('should return true when slot has error', () => {
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -1042,7 +1042,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
class_type: 'TestNode',
dependent_outputs: []
}
}
})
const result = store.slotHasError(
createNodeLocatorId(null, toNodeId(123)),
@@ -1052,7 +1052,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
})
it('should return true when multiple errors exist for the same slot', () => {
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -1071,7 +1071,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
class_type: 'TestNode',
dependent_outputs: []
}
}
})
const result = store.slotHasError(
createNodeLocatorId(null, toNodeId(123)),
@@ -1081,7 +1081,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
})
it('should handle errors without extra_info', () => {
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -1093,7 +1093,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
class_type: 'TestNode',
dependent_outputs: []
}
}
})
const result = store.slotHasError(
createNodeLocatorId(null, toNodeId(123)),
@@ -1339,7 +1339,7 @@ describe('useExecutionStore - WebSocket event handlers', () => {
]
}
}
errorStore.lastExecutionError = {
errorStore.recordExecutionError({
prompt_id: 'old-job',
timestamp: 0,
node_id: '1',
@@ -1348,13 +1348,13 @@ describe('useExecutionStore - WebSocket event handlers', () => {
exception_message: 'boom',
exception_type: 'RuntimeError',
traceback: []
}
errorStore.lastPromptError = {
})
errorStore.recordPromptError({
type: 'old-error',
message: 'old prompt error',
details: ''
}
errorStore.lastNodeErrors = nodeErrors
})
errorStore.recordNodeErrors(nodeErrors)
errorStore.showErrorOverlay()
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })

View File

@@ -554,7 +554,7 @@ export const useExecutionStore = defineStore('execution', () => {
}
setWorkflowStatus(e.detail.prompt_id, 'failed')
executionErrorStore.lastExecutionError = e.detail
executionErrorStore.recordExecutionError(e.detail)
clearInitializationByJobId(e.detail.prompt_id)
resetExecutionState(e.detail.prompt_id)
}
@@ -580,13 +580,13 @@ export const useExecutionStore = defineStore('execution', () => {
clearInitializationByJobId(detail.prompt_id)
resetExecutionState(detail.prompt_id)
executionErrorStore.lastPromptError = {
executionErrorStore.recordPromptError({
type: detail.exception_type ?? 'error',
message: detail.exception_type
? `${detail.exception_type}: ${detail.exception_message}`
: (detail.exception_message ?? ''),
details: detail.traceback?.join('\n') ?? ''
}
})
return true
}
@@ -600,9 +600,9 @@ export const useExecutionStore = defineStore('execution', () => {
resetExecutionState(detail.prompt_id)
if (result.kind === 'nodeErrors') {
executionErrorStore.lastNodeErrors = result.nodeErrors
executionErrorStore.recordNodeErrors(result.nodeErrors)
} else {
executionErrorStore.lastPromptError = result.promptError
executionErrorStore.recordPromptError(result.promptError)
}
return true
}

View File

@@ -80,7 +80,7 @@ export const useSubgraphStore = defineStore('subgraph', () => {
dependent_outputs: []
}
}
useExecutionErrorStore().lastNodeErrors = errors
useExecutionErrorStore().recordNodeErrors(errors)
useCanvasStore().getCanvas().draw(true, true)
throw new Error(
'The root graph of a subgraph blueprint must consist of only a single subgraph node'

View File

@@ -9,10 +9,10 @@ export function seedRequiredInputMissingNodeError(
executionId: NodeExecutionId,
inputName: string
): void {
store.lastNodeErrors = {
store.recordNodeErrors({
[executionId]: nodeError(
[validationError('required_input_missing', inputName, {}, 'Missing', '')],
'TestNode'
)
}
})
}

View File

@@ -1,6 +1,10 @@
import type { NodeError, PromptError } from '@/schemas/apiSchema'
import type { SerializedNodeId } from '@/types/nodeId'
type RawPromptError =
| string
| { type?: string; message?: string; details?: string }
/**
* The standard prompt validation response shape (`{ error, node_errors }`).
* In cloud, this is embedded as JSON inside `execution_error.exception_message`
@@ -8,7 +12,7 @@ import type { SerializedNodeId } from '@/types/nodeId'
* rather than as direct HTTP responses.
*/
interface CloudValidationError {
error?: { type?: string; message?: string; details?: string } | string
error?: RawPromptError
node_errors?: Record<SerializedNodeId, NodeError>
}
@@ -51,6 +55,22 @@ type CloudValidationResult =
| { kind: 'nodeErrors'; nodeErrors: Record<SerializedNodeId, NodeError> }
| { kind: 'promptError'; promptError: PromptError }
export function normalizePromptError(
error: RawPromptError | undefined
): PromptError | null {
if (error && typeof error === 'object') {
return {
type: error.type ?? 'error',
message: error.message ?? '',
details: error.details ?? ''
}
}
return typeof error === 'string'
? { type: 'error', message: error, details: '' }
: null
}
/**
* Classifies an embedded cloud validation error from `exception_message`
* as either node-level errors or a prompt-level error.
@@ -70,25 +90,22 @@ export function classifyCloudValidationError(
return { kind: 'nodeErrors', nodeErrors: node_errors }
}
if (error && typeof error === 'object') {
return {
kind: 'promptError',
promptError: {
type: error.type ?? 'error',
message: error.message ?? '',
details: error.details ?? ''
}
}
}
const promptError = normalizePromptError(error)
return promptError ? { kind: 'promptError', promptError } : null
}
if (typeof error === 'string') {
return {
kind: 'promptError',
promptError: { type: 'error', message: error, details: '' }
}
}
export function errorsForSlot(
errors: NodeError['errors'],
slotName: string
): NodeError['errors'] {
return errors.filter((error) => error.extra_info?.input_name === slotName)
}
return null
export function hasErrorForSlot(
errors: NodeError['errors'],
slotName: string
): boolean {
return errors.some((error) => error.extra_info?.input_name === slotName)
}
/**