Skip to content

Commit fd13755

Browse files
committed
lazy fetch exec error for dialog
1 parent 5cd07fd commit fd13755

File tree

6 files changed

+228
-45
lines changed

6 files changed

+228
-45
lines changed

src/components/queue/job/JobDetailsPopover.vue

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
107107
import { t } from '@/i18n'
108108
import { isCloud } from '@/platform/distribution/types'
109109
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
110+
import { api } from '@/scripts/api'
110111
import { useDialogService } from '@/services/dialogService'
111112
import { useExecutionStore } from '@/stores/executionStore'
112113
import { useQueueStore } from '@/stores/queueStore'
@@ -354,6 +355,7 @@ const { errorMessageValue, copyErrorMessage, reportJobError } =
354355
useJobErrorReporting({
355356
taskForJob,
356357
copyToClipboard,
357-
dialog
358+
dialog,
359+
fetchApi: (url) => api.fetchApi(url)
358360
})
359361
</script>

src/components/queue/job/useJobErrorReporting.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { computed } from 'vue'
22
import type { ComputedRef } from 'vue'
33

4+
import { fetchJobDetail } from '@/platform/remote/comfyui/jobs'
5+
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
46
import type { TaskItemImpl } from '@/stores/queueStore'
57

68
type CopyHandler = (value: string) => void | Promise<void>
9+
type FetchApi = (url: string) => Promise<Response>
710

811
export type JobErrorDialogService = {
912
showErrorDialog: (
@@ -13,18 +16,22 @@ export type JobErrorDialogService = {
1316
[key: string]: unknown
1417
}
1518
) => void
19+
showExecutionErrorDialog?: (executionError: ExecutionErrorWsMessage) => void
1620
}
1721

1822
type UseJobErrorReportingOptions = {
1923
taskForJob: ComputedRef<TaskItemImpl | null>
2024
copyToClipboard: CopyHandler
2125
dialog: JobErrorDialogService
26+
/** Optional fetch function to enable rich error dialogs with traceback */
27+
fetchApi?: FetchApi
2228
}
2329

2430
export const useJobErrorReporting = ({
2531
taskForJob,
2632
copyToClipboard,
27-
dialog
33+
dialog,
34+
fetchApi
2835
}: UseJobErrorReportingOptions) => {
2936
const errorMessageValue = computed(() => taskForJob.value?.errorMessage ?? '')
3037

@@ -34,7 +41,26 @@ export const useJobErrorReporting = ({
3441
}
3542
}
3643

37-
const reportJobError = () => {
44+
const reportJobError = async () => {
45+
const task = taskForJob.value
46+
if (!task) return
47+
48+
// Try to fetch rich error details if fetchApi is provided
49+
if (fetchApi && dialog.showExecutionErrorDialog) {
50+
const jobDetail = await fetchJobDetail(fetchApi, task.promptId)
51+
const executionError = jobDetail?.execution_error
52+
53+
if (executionError) {
54+
dialog.showExecutionErrorDialog({
55+
prompt_id: task.promptId,
56+
timestamp: jobDetail?.create_time ?? Date.now(),
57+
...executionError
58+
})
59+
return
60+
}
61+
}
62+
63+
// Fall back to simple error dialog
3864
if (errorMessageValue.value) {
3965
dialog.showErrorDialog(new Error(errorMessageValue.value), {
4066
reportType: 'queueJobError'

src/composables/queue/useJobMenu.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,26 @@ export function useJobMenu(
9393
if (message) await copyToClipboard(message)
9494
}
9595

96-
const reportError = () => {
96+
const reportError = async () => {
9797
const item = currentMenuItem()
98-
const message = item?.taskRef?.errorMessage
98+
if (!item) return
99+
100+
// Try to fetch rich error details from job detail
101+
const jobDetail = await fetchJobDetail((url) => api.fetchApi(url), item.id)
102+
const executionError = jobDetail?.execution_error
103+
104+
if (executionError) {
105+
// Use rich error dialog with traceback, node info, etc.
106+
useDialogService().showExecutionErrorDialog({
107+
prompt_id: item.id,
108+
timestamp: jobDetail?.create_time ?? Date.now(),
109+
...executionError
110+
})
111+
return
112+
}
113+
114+
// Fall back to simple error dialog
115+
const message = item.taskRef?.errorMessage
99116
if (message) {
100117
useDialogService().showErrorDialog(new Error(message), {
101118
reportType: 'queueJobError'

src/platform/remote/comfyui/jobs/types/jobTypes.ts

Lines changed: 15 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
import { z } from 'zod'
1010

11-
import { zNodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
1211
import { resultItemType, zTaskOutput } from '@/schemas/apiSchema'
1312

1413
// ============================================================================
@@ -68,27 +67,19 @@ const zExtraData = z
6867
.passthrough()
6968

7069
/**
71-
* Execution status information
70+
* Execution error details for failed jobs.
71+
* Contains the same structure as ExecutionErrorWsMessage from WebSocket.
7272
*/
73-
const zExecutionStatus = z
74-
.object({
75-
completed: z.boolean(),
76-
messages: z.array(z.tuple([z.string(), z.unknown()])),
77-
status_str: z.string()
78-
})
79-
.passthrough()
80-
81-
/**
82-
* Execution metadata for a node
83-
*/
84-
const zExecutionNodeMeta = z
85-
.object({
86-
node_id: zNodeId,
87-
display_node: zNodeId,
88-
parent_node: zNodeId.nullable(),
89-
real_node_id: zNodeId
90-
})
91-
.passthrough()
73+
const zExecutionError = z.object({
74+
node_id: z.string(),
75+
node_type: z.string(),
76+
executed: z.array(z.string()),
77+
exception_message: z.string(),
78+
exception_type: z.string(),
79+
traceback: z.array(z.string()),
80+
current_inputs: z.unknown(),
81+
current_outputs: z.unknown()
82+
})
9283

9384
/**
9485
* Job detail - returned by GET /api/jobs/{job_id} (detail endpoint)
@@ -101,8 +92,9 @@ export const zJobDetail = zRawJobListItem
10192
extra_data: zExtraData.optional(),
10293
prompt: z.record(z.string(), z.unknown()).optional(),
10394
outputs: zTaskOutput.optional(),
104-
execution_status: zExecutionStatus.optional(),
105-
execution_meta: z.record(z.string(), zExecutionNodeMeta).optional()
95+
execution_time: z.number().optional(),
96+
workflow_id: z.string().nullable().optional(),
97+
execution_error: zExecutionError.nullable().optional()
10698
})
10799
.passthrough()
108100

@@ -124,4 +116,3 @@ export type JobStatus = z.infer<typeof zJobStatus>
124116
export type RawJobListItem = z.infer<typeof zRawJobListItem>
125117
export type JobListItem = z.infer<typeof zJobListItem>
126118
export type JobDetail = z.infer<typeof zJobDetail>
127-
export type ExecutionStatus = z.infer<typeof zExecutionStatus>

tests-ui/tests/components/queue/useJobErrorReporting.test.ts

Lines changed: 112 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,34 @@ import type { TaskItemImpl } from '@/stores/queueStore'
66
import type { JobErrorDialogService } from '@/components/queue/job/useJobErrorReporting'
77
import { useJobErrorReporting } from '@/components/queue/job/useJobErrorReporting'
88

9-
const createTaskWithError = (errorMessage?: string): TaskItemImpl =>
10-
({ errorMessage }) as unknown as TaskItemImpl
9+
const fetchJobDetailMock = vi.fn()
10+
vi.mock('@/platform/remote/comfyui/jobs', () => ({
11+
fetchJobDetail: (...args: unknown[]) => fetchJobDetailMock(...args)
12+
}))
13+
14+
const createTaskWithError = (
15+
promptId: string,
16+
errorMessage?: string
17+
): TaskItemImpl => ({ promptId, errorMessage }) as unknown as TaskItemImpl
1118

1219
describe('useJobErrorReporting', () => {
1320
let taskState = ref<TaskItemImpl | null>(null)
1421
let taskForJob: ComputedRef<TaskItemImpl | null>
1522
let copyToClipboard: ReturnType<typeof vi.fn>
1623
let showErrorDialog: ReturnType<typeof vi.fn>
24+
let showExecutionErrorDialog: ReturnType<typeof vi.fn>
1725
let dialog: JobErrorDialogService
1826
let composable: ReturnType<typeof useJobErrorReporting>
1927

2028
beforeEach(() => {
29+
vi.clearAllMocks()
2130
taskState = ref<TaskItemImpl | null>(null)
2231
taskForJob = computed(() => taskState.value)
2332
copyToClipboard = vi.fn()
2433
showErrorDialog = vi.fn()
25-
dialog = { showErrorDialog }
34+
showExecutionErrorDialog = vi.fn()
35+
dialog = { showErrorDialog, showExecutionErrorDialog }
36+
fetchJobDetailMock.mockResolvedValue(undefined)
2637
composable = useJobErrorReporting({
2738
taskForJob,
2839
copyToClipboard,
@@ -35,44 +46,131 @@ describe('useJobErrorReporting', () => {
3546
})
3647

3748
it('exposes a computed message that reflects the current task error', () => {
38-
taskState.value = createTaskWithError('First failure')
49+
taskState.value = createTaskWithError('job-1', 'First failure')
3950
expect(composable.errorMessageValue.value).toBe('First failure')
4051

41-
taskState.value = createTaskWithError('Second failure')
52+
taskState.value = createTaskWithError('job-2', 'Second failure')
4253
expect(composable.errorMessageValue.value).toBe('Second failure')
4354
})
4455

4556
it('returns empty string when no error message', () => {
46-
taskState.value = createTaskWithError()
57+
taskState.value = createTaskWithError('job-1')
4758
expect(composable.errorMessageValue.value).toBe('')
4859
})
4960

5061
it('only calls the copy handler when a message exists', () => {
51-
taskState.value = createTaskWithError('Clipboard failure')
62+
taskState.value = createTaskWithError('job-1', 'Clipboard failure')
5263
composable.copyErrorMessage()
5364
expect(copyToClipboard).toHaveBeenCalledTimes(1)
5465
expect(copyToClipboard).toHaveBeenCalledWith('Clipboard failure')
5566

5667
copyToClipboard.mockClear()
57-
taskState.value = createTaskWithError()
68+
taskState.value = createTaskWithError('job-2')
5869
composable.copyErrorMessage()
5970
expect(copyToClipboard).not.toHaveBeenCalled()
6071
})
6172

62-
it('shows error dialog with the error message', () => {
63-
taskState.value = createTaskWithError('Queue job error')
64-
composable.reportJobError()
73+
it('shows simple error dialog when no fetchApi provided', async () => {
74+
taskState.value = createTaskWithError('job-1', 'Queue job error')
75+
await composable.reportJobError()
6576

77+
expect(fetchJobDetailMock).not.toHaveBeenCalled()
6678
expect(showErrorDialog).toHaveBeenCalledTimes(1)
6779
const [errorArg, optionsArg] = showErrorDialog.mock.calls[0]
6880
expect(errorArg).toBeInstanceOf(Error)
6981
expect(errorArg.message).toBe('Queue job error')
7082
expect(optionsArg).toEqual({ reportType: 'queueJobError' })
7183
})
7284

73-
it('does nothing when no error message exists', () => {
74-
taskState.value = createTaskWithError()
75-
composable.reportJobError()
85+
it('does nothing when no task exists', async () => {
86+
taskState.value = null
87+
await composable.reportJobError()
7688
expect(showErrorDialog).not.toHaveBeenCalled()
89+
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
90+
})
91+
92+
describe('with fetchApi provided', () => {
93+
let fetchApi: ReturnType<typeof vi.fn>
94+
95+
beforeEach(() => {
96+
fetchApi = vi.fn()
97+
composable = useJobErrorReporting({
98+
taskForJob,
99+
copyToClipboard,
100+
dialog,
101+
fetchApi
102+
})
103+
})
104+
105+
it('shows rich error dialog when execution_error available', async () => {
106+
const executionError = {
107+
node_id: '5',
108+
node_type: 'KSampler',
109+
executed: ['1', '2'],
110+
exception_message: 'CUDA out of memory',
111+
exception_type: 'RuntimeError',
112+
traceback: ['line 1', 'line 2'],
113+
current_inputs: {},
114+
current_outputs: {}
115+
}
116+
fetchJobDetailMock.mockResolvedValue({
117+
id: 'job-1',
118+
create_time: 12345,
119+
execution_error: executionError
120+
})
121+
taskState.value = createTaskWithError('job-1', 'CUDA out of memory')
122+
123+
await composable.reportJobError()
124+
125+
expect(fetchJobDetailMock).toHaveBeenCalledWith(fetchApi, 'job-1')
126+
expect(showExecutionErrorDialog).toHaveBeenCalledTimes(1)
127+
expect(showExecutionErrorDialog).toHaveBeenCalledWith({
128+
prompt_id: 'job-1',
129+
timestamp: 12345,
130+
...executionError
131+
})
132+
expect(showErrorDialog).not.toHaveBeenCalled()
133+
})
134+
135+
it('falls back to simple error dialog when no execution_error', async () => {
136+
fetchJobDetailMock.mockResolvedValue({
137+
id: 'job-1',
138+
execution_error: null
139+
})
140+
taskState.value = createTaskWithError('job-1', 'Job failed')
141+
142+
await composable.reportJobError()
143+
144+
expect(fetchJobDetailMock).toHaveBeenCalledWith(fetchApi, 'job-1')
145+
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
146+
expect(showErrorDialog).toHaveBeenCalledTimes(1)
147+
const [errorArg, optionsArg] = showErrorDialog.mock.calls[0]
148+
expect(errorArg).toBeInstanceOf(Error)
149+
expect(errorArg.message).toBe('Job failed')
150+
expect(optionsArg).toEqual({ reportType: 'queueJobError' })
151+
})
152+
153+
it('falls back to simple error dialog when fetch fails', async () => {
154+
fetchJobDetailMock.mockResolvedValue(undefined)
155+
taskState.value = createTaskWithError('job-1', 'Job failed')
156+
157+
await composable.reportJobError()
158+
159+
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
160+
expect(showErrorDialog).toHaveBeenCalledTimes(1)
161+
})
162+
163+
it('does nothing when no error message and no execution_error', async () => {
164+
fetchJobDetailMock.mockResolvedValue({
165+
id: 'job-1',
166+
execution_error: null
167+
})
168+
taskState.value = createTaskWithError('job-1')
169+
170+
await composable.reportJobError()
171+
172+
expect(showErrorDialog).not.toHaveBeenCalled()
173+
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
174+
})
77175
})
78176
})

0 commit comments

Comments
 (0)