use execution_error field

This commit is contained in:
Richard Yu
2025-12-03 17:30:07 -08:00
parent fd137557b2
commit 19b31e5a0e
10 changed files with 162 additions and 199 deletions

View File

@@ -107,7 +107,6 @@ 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'
@@ -355,7 +354,6 @@ const { errorMessageValue, copyErrorMessage, reportJobError } =
useJobErrorReporting({
taskForJob,
copyToClipboard,
dialog,
fetchApi: (url) => api.fetchApi(url)
dialog
})
</script>

View File

@@ -1,14 +1,13 @@
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 = {
showExecutionErrorDialog: (executionError: ExecutionErrorWsMessage) => void
showErrorDialog: (
error: Error,
options?: {
@@ -16,22 +15,18 @@ 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,
fetchApi
dialog
}: UseJobErrorReportingOptions) => {
const errorMessageValue = computed(() => taskForJob.value?.errorMessage ?? '')
@@ -41,23 +36,15 @@ export const useJobErrorReporting = ({
}
}
const reportJobError = async () => {
const reportJobError = () => {
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
}
// Use execution_error from list response if available (includes prompt_id, timestamp)
const executionError = task.executionError
if (executionError) {
dialog.showExecutionErrorDialog(executionError as ExecutionErrorWsMessage)
return
}
// Fall back to simple error dialog

View File

@@ -93,21 +93,15 @@ export function useJobMenu(
if (message) await copyToClipboard(message)
}
const reportError = async () => {
const reportError = () => {
const item = currentMenuItem()
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
// Use execution_error from list response if available
const executionError = item.taskRef?.executionError
if (executionError) {
// Use rich error dialog with traceback, node info, etc.
useDialogService().showExecutionErrorDialog({
prompt_id: item.id,
timestamp: jobDetail?.create_time ?? Date.now(),
...executionError
})
useDialogService().showExecutionErrorDialog(executionError)
return
}

View File

@@ -46,7 +46,7 @@ async function fetchJobsRaw(
return { jobs: [], total: 0, offset: 0 }
}
const data = zJobsListResponse.parse(await res.json())
return { jobs: data.jobs, total: data.total, offset }
return { jobs: data.jobs, total: data.pagination.total, offset }
} catch (error) {
console.error('[Jobs API] Error fetching jobs:', error)
return { jobs: [], total: 0, offset: 0 }
@@ -135,14 +135,17 @@ export async function fetchJobDetail(
}
/**
* Extracts workflow from job detail response
* Extracts workflow from job detail response.
* The workflow is nested at: workflow.extra_data.extra_pnginfo.workflow
*/
export function extractWorkflow(
job: JobDetail | undefined
): ComfyWorkflowJSON | undefined {
// Cast is safe - workflow will be validated by loadGraphData -> validateComfyWorkflow
// Path: extra_data.extra_pnginfo.workflow (at top level of job detail)
return job?.extra_data?.extra_pnginfo?.workflow as
const workflowData = job?.workflow as
| { extra_data?: { extra_pnginfo?: { workflow?: unknown } } }
| undefined
return workflowData?.extra_data?.extra_pnginfo?.workflow as
| ComfyWorkflowJSON
| undefined
}

View File

@@ -30,6 +30,25 @@ const zPreviewOutput = z
})
.passthrough() // Allow extra fields like nodeId, mediaType
/**
* Execution error details for error jobs.
* Contains the same structure as ExecutionErrorWsMessage from WebSocket.
*/
const zExecutionError = z
.object({
node_id: z.string(),
node_type: z.string(),
executed: z.array(z.string()).optional(),
exception_message: z.string(),
exception_type: z.string(),
traceback: z.array(z.string()),
current_inputs: z.unknown(),
current_outputs: z.unknown()
})
.passthrough()
export type ExecutionError = z.infer<typeof zExecutionError>
/**
* Raw job from API - uses passthrough to allow extra fields
*/
@@ -41,9 +60,11 @@ const zRawJobListItem = z
preview_output: zPreviewOutput.nullable().optional(),
outputs_count: z.number().optional(),
error_message: z.string().nullable().optional(),
execution_error: zExecutionError.nullable().optional(),
workflow_id: z.string().nullable().optional(),
priority: z.number().optional()
})
.passthrough() // Allow extra fields like execution_time, workflow_id, update_time
.passthrough()
/**
* Job list item with priority always set (either from server or synthetic)
@@ -52,61 +73,41 @@ const zJobListItem = zRawJobListItem.extend({
priority: z.number() // Always set: server-provided or synthetic (total - offset - index)
})
/**
* Extra data structure containing workflow
* Note: workflow is z.unknown() because it goes through validateComfyWorkflow separately
*/
const zExtraData = z
.object({
extra_pnginfo: z
.object({
workflow: z.unknown()
})
.optional()
})
.passthrough()
/**
* Execution error details for failed jobs.
* Contains the same structure as ExecutionErrorWsMessage from WebSocket.
*/
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)
* Includes full workflow and outputs for re-execution and downloads
*
* Note: workflow is at extra_data.extra_pnginfo.workflow (not in a separate workflow object)
*/
export const zJobDetail = zRawJobListItem
.extend({
extra_data: zExtraData.optional(),
prompt: z.record(z.string(), z.unknown()).optional(),
workflow: z.unknown().optional(),
outputs: zTaskOutput.optional(),
execution_time: z.number().optional(),
workflow_id: z.string().nullable().optional(),
execution_error: zExecutionError.nullable().optional()
update_time: z.number().optional(),
execution_status: z.unknown().optional(),
execution_meta: z.unknown().optional()
})
.passthrough()
/**
* Jobs list response structure - raw from API (before synthetic priority)
* Pagination info from API
*/
const zPaginationInfo = z
.object({
offset: z.number(),
limit: z.number(),
total: z.number(),
has_more: z.boolean()
})
.passthrough()
/**
* Jobs list response structure
*/
export const zJobsListResponse = z
.object({
jobs: z.array(zRawJobListItem),
total: z.number()
pagination: zPaginationInfo
})
.passthrough() // Allow extra fields like has_more, offset, limit
.passthrough()
// ============================================================================
// TypeScript Types (derived from Zod schemas)

View File

@@ -287,6 +287,13 @@ export class TaskItemImpl {
return this.job.error_message ?? undefined
}
/**
* Execution error details if job failed with traceback
*/
get executionError() {
return this.job.execution_error ?? undefined
}
get workflow(): ComfyWorkflowJSON | undefined {
// Workflow is only available after lazy loading via getWorkflowFromHistory
return undefined

View File

@@ -5,16 +5,20 @@ import type { ComputedRef } from 'vue'
import type { TaskItemImpl } from '@/stores/queueStore'
import type { JobErrorDialogService } from '@/components/queue/job/useJobErrorReporting'
import { useJobErrorReporting } from '@/components/queue/job/useJobErrorReporting'
const fetchJobDetailMock = vi.fn()
vi.mock('@/platform/remote/comfyui/jobs', () => ({
fetchJobDetail: (...args: unknown[]) => fetchJobDetailMock(...args)
}))
import type { ExecutionError } from '@/platform/remote/comfyui/jobs/types/jobTypes'
const createTaskWithError = (
promptId: string,
errorMessage?: string
): TaskItemImpl => ({ promptId, errorMessage }) as unknown as TaskItemImpl
errorMessage?: string,
executionError?: ExecutionError,
createTime?: number
): TaskItemImpl =>
({
promptId,
errorMessage,
executionError,
createTime: createTime ?? Date.now()
}) as unknown as TaskItemImpl
describe('useJobErrorReporting', () => {
let taskState = ref<TaskItemImpl | null>(null)
@@ -33,7 +37,6 @@ describe('useJobErrorReporting', () => {
showErrorDialog = vi.fn()
showExecutionErrorDialog = vi.fn()
dialog = { showErrorDialog, showExecutionErrorDialog }
fetchJobDetailMock.mockResolvedValue(undefined)
composable = useJobErrorReporting({
taskForJob,
copyToClipboard,
@@ -70,107 +73,83 @@ describe('useJobErrorReporting', () => {
expect(copyToClipboard).not.toHaveBeenCalled()
})
it('shows simple error dialog when no fetchApi provided', async () => {
it('shows simple error dialog when only errorMessage present', () => {
taskState.value = createTaskWithError('job-1', 'Queue job error')
await composable.reportJobError()
composable.reportJobError()
expect(fetchJobDetailMock).not.toHaveBeenCalled()
expect(showErrorDialog).toHaveBeenCalledTimes(1)
const [errorArg, optionsArg] = showErrorDialog.mock.calls[0]
expect(errorArg).toBeInstanceOf(Error)
expect(errorArg.message).toBe('Queue job error')
expect(optionsArg).toEqual({ reportType: 'queueJobError' })
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
})
it('does nothing when no task exists', async () => {
it('does nothing when no task exists', () => {
taskState.value = null
await composable.reportJobError()
composable.reportJobError()
expect(showErrorDialog).not.toHaveBeenCalled()
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
})
describe('with fetchApi provided', () => {
let fetchApi: ReturnType<typeof vi.fn>
it('shows rich error dialog when execution_error available on task', () => {
const executionError: ExecutionError = {
prompt_id: 'job-1',
timestamp: 12345,
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: {}
}
taskState.value = createTaskWithError(
'job-1',
'CUDA out of memory',
executionError,
12345
)
beforeEach(() => {
fetchApi = vi.fn()
composable = useJobErrorReporting({
taskForJob,
copyToClipboard,
dialog,
fetchApi
})
})
composable.reportJobError()
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')
expect(showExecutionErrorDialog).toHaveBeenCalledTimes(1)
expect(showExecutionErrorDialog).toHaveBeenCalledWith(executionError)
expect(showErrorDialog).not.toHaveBeenCalled()
})
await composable.reportJobError()
it('passes execution_error directly to dialog', () => {
const executionError: ExecutionError = {
prompt_id: 'job-1',
timestamp: 12345,
node_id: '5',
node_type: 'KSampler',
exception_message: 'Error',
exception_type: 'RuntimeError',
traceback: ['line 1'],
current_inputs: {},
current_outputs: {}
}
taskState.value = createTaskWithError(
'job-1',
'Error',
executionError,
12345
)
expect(fetchJobDetailMock).toHaveBeenCalledWith(fetchApi, 'job-1')
expect(showExecutionErrorDialog).toHaveBeenCalledTimes(1)
expect(showExecutionErrorDialog).toHaveBeenCalledWith({
prompt_id: 'job-1',
timestamp: 12345,
...executionError
})
expect(showErrorDialog).not.toHaveBeenCalled()
})
composable.reportJobError()
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')
expect(showExecutionErrorDialog).toHaveBeenCalledTimes(1)
expect(showExecutionErrorDialog).toHaveBeenCalledWith(executionError)
})
await composable.reportJobError()
it('does nothing when no error message and no execution_error', () => {
taskState.value = createTaskWithError('job-1')
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' })
})
composable.reportJobError()
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()
})
expect(showErrorDialog).not.toHaveBeenCalled()
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
})
})

View File

@@ -290,6 +290,8 @@ describe('useJobMenu', () => {
it('reports error via rich dialog when execution_error available', async () => {
const executionError = {
prompt_id: 'job-1',
timestamp: 12345,
node_id: '5',
node_type: 'KSampler',
executed: ['1', '2'],
@@ -299,16 +301,15 @@ describe('useJobMenu', () => {
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
taskRef: {
errorMessage: 'CUDA out of memory',
executionError,
createTime: 12345
} as any
})
)
@@ -316,24 +317,14 @@ describe('useJobMenu', () => {
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.showExecutionErrorDialog).toHaveBeenCalledWith(
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({
@@ -356,7 +347,6 @@ describe('useJobMenu', () => {
})
it('ignores error actions when message missing', async () => {
fetchJobDetailMock.mockResolvedValue({ id: 'job-1', execution_error: null })
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({

View File

@@ -17,17 +17,19 @@ const mockWorkflow: ComfyWorkflowJSON = {
}
// Jobs API detail response structure (matches actual /jobs/{id} response)
// workflow is nested at: workflow.extra_data.extra_pnginfo.workflow
const mockJobDetailResponse = {
id: 'test-prompt-id',
status: 'completed',
create_time: 1234567890,
execution_time: 18.5,
extra_data: {
extra_pnginfo: {
workflow: mockWorkflow
update_time: 1234567900,
workflow: {
extra_data: {
extra_pnginfo: {
workflow: mockWorkflow
}
}
},
prompt: {},
outputs: {
'20': {
images: [
@@ -61,7 +63,7 @@ describe('fetchJobDetail', () => {
expect(result).toBeDefined()
expect(result?.id).toBe('test-prompt-id')
expect(result?.outputs).toEqual(mockJobDetailResponse.outputs)
expect(result?.extra_data).toBeDefined()
expect(result?.workflow).toBeDefined()
})
it('should return undefined when job not found (non-OK response)', async () => {
@@ -110,10 +112,10 @@ describe('extractWorkflow', () => {
expect(result).toBeUndefined()
})
it('should return undefined when workflow is missing extra_pnginfo', () => {
it('should return undefined when workflow is missing', () => {
const jobWithoutWorkflow = {
...mockJobDetailResponse,
extra_data: {}
workflow: {}
}
const result = extractWorkflow(jobWithoutWorkflow as any)

View File

@@ -27,17 +27,19 @@ const mockWorkflow: ComfyWorkflowJSON = {
}
// Mock job detail response (matches actual /jobs/{id} API response structure)
// workflow is nested at: workflow.extra_data.extra_pnginfo.workflow
const mockJobDetail = {
id: 'test-prompt-id',
status: 'completed' as const,
create_time: Date.now(),
execution_time: 10.5,
extra_data: {
extra_pnginfo: {
workflow: mockWorkflow
update_time: Date.now(),
workflow: {
extra_data: {
extra_pnginfo: {
workflow: mockWorkflow
}
}
},
prompt: {},
outputs: {
'1': { images: [{ filename: 'test.png', subfolder: '', type: 'output' }] }
}