diff --git a/src/components/queue/job/JobDetailsPopover.vue b/src/components/queue/job/JobDetailsPopover.vue index 73024f59b..9db0e69c8 100644 --- a/src/components/queue/job/JobDetailsPopover.vue +++ b/src/components/queue/job/JobDetailsPopover.vue @@ -107,6 +107,7 @@ import { useCopyToClipboard } from '@/composables/useCopyToClipboard' import { t } from '@/i18n' import { isCloud } from '@/platform/distribution/types' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' +import { api } from '@/scripts/api' import { useDialogService } from '@/services/dialogService' import { useExecutionStore } from '@/stores/executionStore' import { useQueueStore } from '@/stores/queueStore' @@ -354,6 +355,7 @@ const { errorMessageValue, copyErrorMessage, reportJobError } = useJobErrorReporting({ taskForJob, copyToClipboard, - dialog + dialog, + fetchApi: (url) => api.fetchApi(url) }) diff --git a/src/components/queue/job/useJobErrorReporting.ts b/src/components/queue/job/useJobErrorReporting.ts index 30eca078f..dc36698b7 100644 --- a/src/components/queue/job/useJobErrorReporting.ts +++ b/src/components/queue/job/useJobErrorReporting.ts @@ -1,9 +1,12 @@ import { computed } from 'vue' import type { ComputedRef } from 'vue' +import { fetchJobDetail } from '@/platform/remote/comfyui/jobs' +import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema' import type { TaskItemImpl } from '@/stores/queueStore' type CopyHandler = (value: string) => void | Promise +type FetchApi = (url: string) => Promise export type JobErrorDialogService = { showErrorDialog: ( @@ -13,18 +16,22 @@ export type JobErrorDialogService = { [key: string]: unknown } ) => void + showExecutionErrorDialog?: (executionError: ExecutionErrorWsMessage) => void } type UseJobErrorReportingOptions = { taskForJob: ComputedRef copyToClipboard: CopyHandler dialog: JobErrorDialogService + /** Optional fetch function to enable rich error dialogs with traceback */ + fetchApi?: FetchApi } export const useJobErrorReporting = ({ taskForJob, copyToClipboard, - dialog + dialog, + fetchApi }: UseJobErrorReportingOptions) => { const errorMessageValue = computed(() => taskForJob.value?.errorMessage ?? '') @@ -34,7 +41,26 @@ export const useJobErrorReporting = ({ } } - const reportJobError = () => { + const reportJobError = async () => { + const task = taskForJob.value + if (!task) return + + // Try to fetch rich error details if fetchApi is provided + if (fetchApi && dialog.showExecutionErrorDialog) { + const jobDetail = await fetchJobDetail(fetchApi, task.promptId) + const executionError = jobDetail?.execution_error + + if (executionError) { + dialog.showExecutionErrorDialog({ + prompt_id: task.promptId, + timestamp: jobDetail?.create_time ?? Date.now(), + ...executionError + }) + return + } + } + + // Fall back to simple error dialog if (errorMessageValue.value) { dialog.showErrorDialog(new Error(errorMessageValue.value), { reportType: 'queueJobError' diff --git a/src/composables/queue/useJobMenu.ts b/src/composables/queue/useJobMenu.ts index 87fe0d4a6..bf0d5708f 100644 --- a/src/composables/queue/useJobMenu.ts +++ b/src/composables/queue/useJobMenu.ts @@ -93,9 +93,26 @@ export function useJobMenu( if (message) await copyToClipboard(message) } - const reportError = () => { + const reportError = async () => { const item = currentMenuItem() - const message = item?.taskRef?.errorMessage + if (!item) return + + // Try to fetch rich error details from job detail + const jobDetail = await fetchJobDetail((url) => api.fetchApi(url), item.id) + const executionError = jobDetail?.execution_error + + if (executionError) { + // Use rich error dialog with traceback, node info, etc. + useDialogService().showExecutionErrorDialog({ + prompt_id: item.id, + timestamp: jobDetail?.create_time ?? Date.now(), + ...executionError + }) + return + } + + // Fall back to simple error dialog + const message = item.taskRef?.errorMessage if (message) { useDialogService().showErrorDialog(new Error(message), { reportType: 'queueJobError' diff --git a/src/platform/remote/comfyui/jobs/types/jobTypes.ts b/src/platform/remote/comfyui/jobs/types/jobTypes.ts index 2f872ccc9..cf9e1b44a 100644 --- a/src/platform/remote/comfyui/jobs/types/jobTypes.ts +++ b/src/platform/remote/comfyui/jobs/types/jobTypes.ts @@ -8,7 +8,6 @@ import { z } from 'zod' -import { zNodeId } from '@/platform/workflow/validation/schemas/workflowSchema' import { resultItemType, zTaskOutput } from '@/schemas/apiSchema' // ============================================================================ @@ -68,27 +67,19 @@ const zExtraData = z .passthrough() /** - * Execution status information + * Execution error details for failed jobs. + * Contains the same structure as ExecutionErrorWsMessage from WebSocket. */ -const zExecutionStatus = z - .object({ - completed: z.boolean(), - messages: z.array(z.tuple([z.string(), z.unknown()])), - status_str: z.string() - }) - .passthrough() - -/** - * Execution metadata for a node - */ -const zExecutionNodeMeta = z - .object({ - node_id: zNodeId, - display_node: zNodeId, - parent_node: zNodeId.nullable(), - real_node_id: zNodeId - }) - .passthrough() +const zExecutionError = z.object({ + node_id: z.string(), + node_type: z.string(), + executed: z.array(z.string()), + exception_message: z.string(), + exception_type: z.string(), + traceback: z.array(z.string()), + current_inputs: z.unknown(), + current_outputs: z.unknown() +}) /** * Job detail - returned by GET /api/jobs/{job_id} (detail endpoint) @@ -101,8 +92,9 @@ export const zJobDetail = zRawJobListItem extra_data: zExtraData.optional(), prompt: z.record(z.string(), z.unknown()).optional(), outputs: zTaskOutput.optional(), - execution_status: zExecutionStatus.optional(), - execution_meta: z.record(z.string(), zExecutionNodeMeta).optional() + execution_time: z.number().optional(), + workflow_id: z.string().nullable().optional(), + execution_error: zExecutionError.nullable().optional() }) .passthrough() @@ -124,4 +116,3 @@ export type JobStatus = z.infer export type RawJobListItem = z.infer export type JobListItem = z.infer export type JobDetail = z.infer -export type ExecutionStatus = z.infer diff --git a/tests-ui/tests/components/queue/useJobErrorReporting.test.ts b/tests-ui/tests/components/queue/useJobErrorReporting.test.ts index 17936b9cd..80e2b1222 100644 --- a/tests-ui/tests/components/queue/useJobErrorReporting.test.ts +++ b/tests-ui/tests/components/queue/useJobErrorReporting.test.ts @@ -6,23 +6,34 @@ import type { TaskItemImpl } from '@/stores/queueStore' import type { JobErrorDialogService } from '@/components/queue/job/useJobErrorReporting' import { useJobErrorReporting } from '@/components/queue/job/useJobErrorReporting' -const createTaskWithError = (errorMessage?: string): TaskItemImpl => - ({ errorMessage }) as unknown as TaskItemImpl +const fetchJobDetailMock = vi.fn() +vi.mock('@/platform/remote/comfyui/jobs', () => ({ + fetchJobDetail: (...args: unknown[]) => fetchJobDetailMock(...args) +})) + +const createTaskWithError = ( + promptId: string, + errorMessage?: string +): TaskItemImpl => ({ promptId, errorMessage }) as unknown as TaskItemImpl describe('useJobErrorReporting', () => { let taskState = ref(null) let taskForJob: ComputedRef let copyToClipboard: ReturnType let showErrorDialog: ReturnType + let showExecutionErrorDialog: ReturnType let dialog: JobErrorDialogService let composable: ReturnType beforeEach(() => { + vi.clearAllMocks() taskState = ref(null) taskForJob = computed(() => taskState.value) copyToClipboard = vi.fn() showErrorDialog = vi.fn() - dialog = { showErrorDialog } + showExecutionErrorDialog = vi.fn() + dialog = { showErrorDialog, showExecutionErrorDialog } + fetchJobDetailMock.mockResolvedValue(undefined) composable = useJobErrorReporting({ taskForJob, copyToClipboard, @@ -35,34 +46,35 @@ describe('useJobErrorReporting', () => { }) it('exposes a computed message that reflects the current task error', () => { - taskState.value = createTaskWithError('First failure') + taskState.value = createTaskWithError('job-1', 'First failure') expect(composable.errorMessageValue.value).toBe('First failure') - taskState.value = createTaskWithError('Second failure') + taskState.value = createTaskWithError('job-2', 'Second failure') expect(composable.errorMessageValue.value).toBe('Second failure') }) it('returns empty string when no error message', () => { - taskState.value = createTaskWithError() + taskState.value = createTaskWithError('job-1') expect(composable.errorMessageValue.value).toBe('') }) it('only calls the copy handler when a message exists', () => { - taskState.value = createTaskWithError('Clipboard failure') + taskState.value = createTaskWithError('job-1', 'Clipboard failure') composable.copyErrorMessage() expect(copyToClipboard).toHaveBeenCalledTimes(1) expect(copyToClipboard).toHaveBeenCalledWith('Clipboard failure') copyToClipboard.mockClear() - taskState.value = createTaskWithError() + taskState.value = createTaskWithError('job-2') composable.copyErrorMessage() expect(copyToClipboard).not.toHaveBeenCalled() }) - it('shows error dialog with the error message', () => { - taskState.value = createTaskWithError('Queue job error') - composable.reportJobError() + it('shows simple error dialog when no fetchApi provided', async () => { + taskState.value = createTaskWithError('job-1', 'Queue job error') + await composable.reportJobError() + expect(fetchJobDetailMock).not.toHaveBeenCalled() expect(showErrorDialog).toHaveBeenCalledTimes(1) const [errorArg, optionsArg] = showErrorDialog.mock.calls[0] expect(errorArg).toBeInstanceOf(Error) @@ -70,9 +82,95 @@ describe('useJobErrorReporting', () => { expect(optionsArg).toEqual({ reportType: 'queueJobError' }) }) - it('does nothing when no error message exists', () => { - taskState.value = createTaskWithError() - composable.reportJobError() + it('does nothing when no task exists', async () => { + taskState.value = null + await composable.reportJobError() expect(showErrorDialog).not.toHaveBeenCalled() + expect(showExecutionErrorDialog).not.toHaveBeenCalled() + }) + + describe('with fetchApi provided', () => { + let fetchApi: ReturnType + + beforeEach(() => { + fetchApi = vi.fn() + composable = useJobErrorReporting({ + taskForJob, + copyToClipboard, + dialog, + fetchApi + }) + }) + + it('shows rich error dialog when execution_error available', async () => { + const executionError = { + node_id: '5', + node_type: 'KSampler', + executed: ['1', '2'], + exception_message: 'CUDA out of memory', + exception_type: 'RuntimeError', + traceback: ['line 1', 'line 2'], + current_inputs: {}, + current_outputs: {} + } + fetchJobDetailMock.mockResolvedValue({ + id: 'job-1', + create_time: 12345, + execution_error: executionError + }) + taskState.value = createTaskWithError('job-1', 'CUDA out of memory') + + await composable.reportJobError() + + expect(fetchJobDetailMock).toHaveBeenCalledWith(fetchApi, 'job-1') + expect(showExecutionErrorDialog).toHaveBeenCalledTimes(1) + expect(showExecutionErrorDialog).toHaveBeenCalledWith({ + prompt_id: 'job-1', + timestamp: 12345, + ...executionError + }) + expect(showErrorDialog).not.toHaveBeenCalled() + }) + + it('falls back to simple error dialog when no execution_error', async () => { + fetchJobDetailMock.mockResolvedValue({ + id: 'job-1', + execution_error: null + }) + taskState.value = createTaskWithError('job-1', 'Job failed') + + await composable.reportJobError() + + expect(fetchJobDetailMock).toHaveBeenCalledWith(fetchApi, 'job-1') + expect(showExecutionErrorDialog).not.toHaveBeenCalled() + expect(showErrorDialog).toHaveBeenCalledTimes(1) + const [errorArg, optionsArg] = showErrorDialog.mock.calls[0] + expect(errorArg).toBeInstanceOf(Error) + expect(errorArg.message).toBe('Job failed') + expect(optionsArg).toEqual({ reportType: 'queueJobError' }) + }) + + it('falls back to simple error dialog when fetch fails', async () => { + fetchJobDetailMock.mockResolvedValue(undefined) + taskState.value = createTaskWithError('job-1', 'Job failed') + + await composable.reportJobError() + + expect(showExecutionErrorDialog).not.toHaveBeenCalled() + expect(showErrorDialog).toHaveBeenCalledTimes(1) + }) + + it('does nothing when no error message and no execution_error', async () => { + fetchJobDetailMock.mockResolvedValue({ + id: 'job-1', + execution_error: null + }) + taskState.value = createTaskWithError('job-1') + + await composable.reportJobError() + + expect(showErrorDialog).not.toHaveBeenCalled() + expect(showExecutionErrorDialog).not.toHaveBeenCalled() + }) }) }) diff --git a/tests-ui/tests/composables/useJobMenu.test.ts b/tests-ui/tests/composables/useJobMenu.test.ts index ce8a0c6d0..a03a90bd7 100644 --- a/tests-ui/tests/composables/useJobMenu.test.ts +++ b/tests-ui/tests/composables/useJobMenu.test.ts @@ -83,6 +83,7 @@ vi.mock('@/scripts/utils', () => ({ const dialogServiceMock = { showErrorDialog: vi.fn(), + showExecutionErrorDialog: vi.fn(), prompt: vi.fn() } vi.mock('@/services/dialogService', () => ({ @@ -287,7 +288,52 @@ describe('useJobMenu', () => { expect(copyToClipboardMock).toHaveBeenCalledWith('Something went wrong') }) - it('reports error via dialog when entry triggered', async () => { + it('reports error via rich dialog when execution_error available', async () => { + const executionError = { + node_id: '5', + node_type: 'KSampler', + executed: ['1', '2'], + exception_message: 'CUDA out of memory', + exception_type: 'RuntimeError', + traceback: ['line 1', 'line 2'], + current_inputs: {}, + current_outputs: {} + } + fetchJobDetailMock.mockResolvedValue({ + id: 'job-1', + create_time: 12345, + execution_error: executionError + }) + const { jobMenuEntries } = mountJobMenu() + setCurrentItem( + createJobItem({ + state: 'failed', + taskRef: { errorMessage: 'CUDA out of memory' } as any + }) + ) + + await nextTick() + const entry = findActionEntry(jobMenuEntries.value, 'report-error') + await entry?.onClick?.() + + expect(fetchJobDetailMock).toHaveBeenCalledWith( + expect.any(Function), + 'job-1' + ) + expect(dialogServiceMock.showExecutionErrorDialog).toHaveBeenCalledTimes(1) + expect(dialogServiceMock.showExecutionErrorDialog).toHaveBeenCalledWith({ + prompt_id: 'job-1', + timestamp: 12345, + ...executionError + }) + expect(dialogServiceMock.showErrorDialog).not.toHaveBeenCalled() + }) + + it('falls back to simple error dialog when no execution_error', async () => { + fetchJobDetailMock.mockResolvedValue({ + id: 'job-1', + execution_error: null + }) const { jobMenuEntries } = mountJobMenu() setCurrentItem( createJobItem({ @@ -298,8 +344,9 @@ describe('useJobMenu', () => { await nextTick() const entry = findActionEntry(jobMenuEntries.value, 'report-error') - entry?.onClick?.() + await entry?.onClick?.() + expect(dialogServiceMock.showExecutionErrorDialog).not.toHaveBeenCalled() expect(dialogServiceMock.showErrorDialog).toHaveBeenCalledTimes(1) const [errorArg, optionsArg] = dialogServiceMock.showErrorDialog.mock.calls[0] @@ -309,6 +356,7 @@ describe('useJobMenu', () => { }) it('ignores error actions when message missing', async () => { + fetchJobDetailMock.mockResolvedValue({ id: 'job-1', execution_error: null }) const { jobMenuEntries } = mountJobMenu() setCurrentItem( createJobItem({ @@ -325,6 +373,7 @@ describe('useJobMenu', () => { expect(copyToClipboardMock).not.toHaveBeenCalled() expect(dialogServiceMock.showErrorDialog).not.toHaveBeenCalled() + expect(dialogServiceMock.showExecutionErrorDialog).not.toHaveBeenCalled() }) const previewCases = [