lazy fetch exec error for dialog

This commit is contained in:
Richard Yu
2025-12-03 16:03:22 -08:00
parent 5cd07fd91b
commit fd137557b2
6 changed files with 228 additions and 45 deletions

View File

@@ -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)
})
</script>

View File

@@ -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<void>
type FetchApi = (url: string) => Promise<Response>
export type JobErrorDialogService = {
showErrorDialog: (
@@ -13,18 +16,22 @@ export type JobErrorDialogService = {
[key: string]: unknown
}
) => void
showExecutionErrorDialog?: (executionError: ExecutionErrorWsMessage) => void
}
type UseJobErrorReportingOptions = {
taskForJob: ComputedRef<TaskItemImpl | null>
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'

View File

@@ -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'

View File

@@ -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<typeof zJobStatus>
export type RawJobListItem = z.infer<typeof zRawJobListItem>
export type JobListItem = z.infer<typeof zJobListItem>
export type JobDetail = z.infer<typeof zJobDetail>
export type ExecutionStatus = z.infer<typeof zExecutionStatus>

View File

@@ -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<TaskItemImpl | null>(null)
let taskForJob: ComputedRef<TaskItemImpl | null>
let copyToClipboard: ReturnType<typeof vi.fn>
let showErrorDialog: ReturnType<typeof vi.fn>
let showExecutionErrorDialog: ReturnType<typeof vi.fn>
let dialog: JobErrorDialogService
let composable: ReturnType<typeof useJobErrorReporting>
beforeEach(() => {
vi.clearAllMocks()
taskState = ref<TaskItemImpl | null>(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<typeof vi.fn>
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()
})
})
})

View File

@@ -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 = [