refactor: encapsulate error extraction in TaskItemImpl getters (#7650)

## Summary
- Add `errorMessage` and `executionError` getters to `TaskItemImpl` that
extract error info from status messages
- Update `useJobErrorReporting` composable to use these getters instead
of standalone function
- Remove the standalone `extractExecutionError` function

This encapsulates error extraction within `TaskItemImpl`, preparing for
the Jobs API migration where the underlying data format will change but
the getter interface will remain stable.

## Test plan
- [x] All existing tests pass
- [x] New tests added for `TaskItemImpl.errorMessage` and
`TaskItemImpl.executionError` getters
- [x] TypeScript, lint, and knip checks pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7650-refactor-encapsulate-error-extraction-in-TaskItemImpl-getters-2ce6d73d365081caae33dcc7e1e07720)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
This commit is contained in:
ric-yu
2026-01-15 20:11:22 -08:00
committed by GitHub
parent 089295606a
commit c0a649ef43
46 changed files with 1650 additions and 3067 deletions

View File

@@ -19,7 +19,6 @@ import { useTelemetry } from '@/platform/telemetry'
import { isCloud } from '@/platform/distribution/types'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import { useDialogStore } from '@/stores/dialogStore'
import type {
DialogComponentProps,
@@ -45,6 +44,18 @@ export type ConfirmationDialogType =
| 'dirtyClose'
| 'reinstall'
/**
* Minimal interface for execution error dialogs.
* Satisfied by both ExecutionErrorWsMessage (WebSocket) and ExecutionError (Jobs API).
*/
export interface ExecutionErrorDialogInput {
exception_type: string
exception_message: string
node_id: string | number
node_type: string
traceback: string[]
}
export const useDialogService = () => {
const dialogStore = useDialogStore()
@@ -115,7 +126,7 @@ export const useDialogService = () => {
})
}
function showExecutionErrorDialog(executionError: ExecutionErrorWsMessage) {
function showExecutionErrorDialog(executionError: ExecutionErrorDialogInput) {
const props: ComponentAttrs<typeof ErrorDialogContent> = {
error: {
exceptionType: executionError.exception_type,

View File

@@ -0,0 +1,278 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
JobDetail,
JobListItem
} from '@/platform/remote/comfyui/jobs/jobTypes'
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
vi.mock('@/platform/remote/comfyui/jobs/fetchJobs', () => ({
fetchJobDetail: vi.fn(),
extractWorkflow: vi.fn()
}))
function createResultItem(url: string, supportsPreview = true): ResultItemImpl {
const item = new ResultItemImpl({
filename: url,
subfolder: '',
type: 'output',
nodeId: 'node-1',
mediaType: supportsPreview ? 'images' : 'unknown'
})
Object.defineProperty(item, 'url', { get: () => url })
Object.defineProperty(item, 'supportsPreview', { get: () => supportsPreview })
return item
}
function createMockJob(id: string, outputsCount = 1): JobListItem {
return {
id,
status: 'completed',
create_time: Date.now(),
preview_output: null,
outputs_count: outputsCount,
priority: 0
}
}
function createTask(
preview?: ResultItemImpl,
allOutputs?: ResultItemImpl[],
outputsCount = 1
): TaskItemImpl {
const job = createMockJob(
`task-${Math.random().toString(36).slice(2)}`,
outputsCount
)
const flatOutputs = allOutputs ?? (preview ? [preview] : [])
return new TaskItemImpl(job, {}, flatOutputs)
}
describe('jobOutputCache', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
})
describe('findActiveIndex', () => {
it('returns index of matching URL', async () => {
const { findActiveIndex } = await import('@/services/jobOutputCache')
const items = [
createResultItem('a'),
createResultItem('b'),
createResultItem('c')
]
expect(findActiveIndex(items, 'b')).toBe(1)
})
it('returns 0 when URL not found', async () => {
const { findActiveIndex } = await import('@/services/jobOutputCache')
const items = [createResultItem('a'), createResultItem('b')]
expect(findActiveIndex(items, 'missing')).toBe(0)
})
it('returns 0 when URL is undefined', async () => {
const { findActiveIndex } = await import('@/services/jobOutputCache')
const items = [createResultItem('a'), createResultItem('b')]
expect(findActiveIndex(items, undefined)).toBe(0)
})
})
describe('getOutputsForTask', () => {
it('returns previewable outputs directly when no lazy load needed', async () => {
const { getOutputsForTask } = await import('@/services/jobOutputCache')
const outputs = [createResultItem('p-1'), createResultItem('p-2')]
const task = createTask(undefined, outputs, 1)
const result = await getOutputsForTask(task)
expect(result).toEqual(outputs)
})
it('lazy loads when outputsCount > 1', async () => {
const { getOutputsForTask } = await import('@/services/jobOutputCache')
const previewOutput = createResultItem('preview')
const fullOutputs = [
createResultItem('full-1'),
createResultItem('full-2')
]
const job = createMockJob('task-1', 3)
const task = new TaskItemImpl(job, {}, [previewOutput])
const loadedTask = new TaskItemImpl(job, {}, fullOutputs)
task.loadFullOutputs = vi.fn().mockResolvedValue(loadedTask)
const result = await getOutputsForTask(task)
expect(result).toEqual(fullOutputs)
expect(task.loadFullOutputs).toHaveBeenCalled()
})
it('caches loaded tasks', async () => {
const { getOutputsForTask } = await import('@/services/jobOutputCache')
const fullOutputs = [createResultItem('full-1')]
const job = createMockJob('task-1', 3)
const task = new TaskItemImpl(job, {}, [createResultItem('preview')])
const loadedTask = new TaskItemImpl(job, {}, fullOutputs)
task.loadFullOutputs = vi.fn().mockResolvedValue(loadedTask)
// First call should load
await getOutputsForTask(task)
expect(task.loadFullOutputs).toHaveBeenCalledTimes(1)
// Second call should use cache
await getOutputsForTask(task)
expect(task.loadFullOutputs).toHaveBeenCalledTimes(1)
})
it('falls back to preview outputs on load error', async () => {
const { getOutputsForTask } = await import('@/services/jobOutputCache')
const previewOutput = createResultItem('preview')
const job = createMockJob('task-1', 3)
const task = new TaskItemImpl(job, {}, [previewOutput])
task.loadFullOutputs = vi
.fn()
.mockRejectedValue(new Error('Network error'))
const result = await getOutputsForTask(task)
expect(result).toEqual([previewOutput])
})
it('returns null when request is superseded', async () => {
const { getOutputsForTask } = await import('@/services/jobOutputCache')
const job1 = createMockJob('task-1', 3)
const job2 = createMockJob('task-2', 3)
const task1 = new TaskItemImpl(job1, {}, [createResultItem('preview-1')])
const task2 = new TaskItemImpl(job2, {}, [createResultItem('preview-2')])
const loadedTask1 = new TaskItemImpl(job1, {}, [
createResultItem('full-1')
])
const loadedTask2 = new TaskItemImpl(job2, {}, [
createResultItem('full-2')
])
// Task1 loads slowly, task2 loads quickly
task1.loadFullOutputs = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve(loadedTask1), 50)
})
)
task2.loadFullOutputs = vi.fn().mockResolvedValue(loadedTask2)
// Start task1, then immediately start task2
const promise1 = getOutputsForTask(task1)
const promise2 = getOutputsForTask(task2)
const [result1, result2] = await Promise.all([promise1, promise2])
// Task2 should succeed, task1 should return null (superseded)
expect(result1).toBeNull()
expect(result2).toEqual([createResultItem('full-2')])
})
})
describe('getJobDetail', () => {
it('fetches and caches job detail', async () => {
const { getJobDetail } = await import('@/services/jobOutputCache')
const { fetchJobDetail } =
await import('@/platform/remote/comfyui/jobs/fetchJobs')
const mockDetail: JobDetail = {
id: 'job-1',
status: 'completed',
create_time: Date.now(),
priority: 0,
outputs: {}
}
vi.mocked(fetchJobDetail).mockResolvedValue(mockDetail)
const result = await getJobDetail('job-1')
expect(result).toEqual(mockDetail)
expect(fetchJobDetail).toHaveBeenCalledWith(expect.any(Function), 'job-1')
})
it('returns cached job detail on subsequent calls', async () => {
const { getJobDetail } = await import('@/services/jobOutputCache')
const { fetchJobDetail } =
await import('@/platform/remote/comfyui/jobs/fetchJobs')
const mockDetail: JobDetail = {
id: 'job-2',
status: 'completed',
create_time: Date.now(),
priority: 0,
outputs: {}
}
vi.mocked(fetchJobDetail).mockResolvedValue(mockDetail)
// First call
await getJobDetail('job-2')
expect(fetchJobDetail).toHaveBeenCalledTimes(1)
// Second call should use cache
const result = await getJobDetail('job-2')
expect(result).toEqual(mockDetail)
expect(fetchJobDetail).toHaveBeenCalledTimes(1)
})
it('returns undefined on fetch error', async () => {
const { getJobDetail } = await import('@/services/jobOutputCache')
const { fetchJobDetail } =
await import('@/platform/remote/comfyui/jobs/fetchJobs')
vi.mocked(fetchJobDetail).mockRejectedValue(new Error('Network error'))
const result = await getJobDetail('job-error')
expect(result).toBeUndefined()
})
})
describe('getJobWorkflow', () => {
it('fetches job detail and extracts workflow', async () => {
const { getJobWorkflow } = await import('@/services/jobOutputCache')
const { fetchJobDetail, extractWorkflow } =
await import('@/platform/remote/comfyui/jobs/fetchJobs')
const mockDetail: JobDetail = {
id: 'job-wf',
status: 'completed',
create_time: Date.now(),
priority: 0,
outputs: {}
}
const mockWorkflow = { version: 1 }
vi.mocked(fetchJobDetail).mockResolvedValue(mockDetail)
vi.mocked(extractWorkflow).mockResolvedValue(mockWorkflow as any)
const result = await getJobWorkflow('job-wf')
expect(result).toEqual(mockWorkflow)
expect(extractWorkflow).toHaveBeenCalledWith(mockDetail)
})
it('returns undefined when job detail not found', async () => {
const { getJobWorkflow } = await import('@/services/jobOutputCache')
const { fetchJobDetail, extractWorkflow } =
await import('@/platform/remote/comfyui/jobs/fetchJobs')
vi.mocked(fetchJobDetail).mockResolvedValue(undefined)
vi.mocked(extractWorkflow).mockResolvedValue(undefined)
const result = await getJobWorkflow('missing')
expect(result).toBeUndefined()
})
})
})

View File

@@ -0,0 +1,103 @@
/**
* @fileoverview Job output cache for caching and managing job data
* @module services/jobOutputCache
*
* Centralizes job output and detail caching with LRU eviction.
* Provides helpers for working with previewable outputs and workflows.
*/
import QuickLRU from '@alloc/quick-lru'
import type { JobDetail } from '@/platform/remote/comfyui/jobs/jobTypes'
import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { api } from '@/scripts/api'
import { ResultItemImpl } from '@/stores/queueStore'
import type { TaskItemImpl } from '@/stores/queueStore'
const MAX_TASK_CACHE_SIZE = 50
const MAX_JOB_DETAIL_CACHE_SIZE = 50
const taskCache = new QuickLRU<string, TaskItemImpl>({
maxSize: MAX_TASK_CACHE_SIZE
})
const jobDetailCache = new QuickLRU<string, JobDetail>({
maxSize: MAX_JOB_DETAIL_CACHE_SIZE
})
// Track latest request to dedupe stale responses
let latestTaskRequestId: string | null = null
// ===== Task Output Caching =====
export function findActiveIndex(
items: readonly ResultItemImpl[],
url?: string
): number {
return ResultItemImpl.findByUrl(items, url)
}
/**
* Gets previewable outputs for a task, with lazy loading, caching, and request deduping.
* Returns null if a newer request superseded this one while loading.
*/
export async function getOutputsForTask(
task: TaskItemImpl
): Promise<ResultItemImpl[] | null> {
const requestId = String(task.promptId)
latestTaskRequestId = requestId
const outputsCount = task.outputsCount ?? 0
const needsLazyLoad = outputsCount > 1
if (!needsLazyLoad) {
return [...task.previewableOutputs]
}
const cached = taskCache.get(requestId)
if (cached) {
return [...cached.previewableOutputs]
}
try {
const loadedTask = await task.loadFullOutputs()
// Check if request was superseded while loading
if (latestTaskRequestId !== requestId) {
return null
}
taskCache.set(requestId, loadedTask)
return [...loadedTask.previewableOutputs]
} catch (error) {
console.warn('Failed to load full outputs, using preview:', error)
return [...task.previewableOutputs]
}
}
// ===== Job Detail Caching =====
export async function getJobDetail(
jobId: string
): Promise<JobDetail | undefined> {
const cached = jobDetailCache.get(jobId)
if (cached) return cached
try {
const detail = await api.getJobDetail(jobId)
if (detail) {
jobDetailCache.set(jobId, detail)
}
return detail
} catch (error) {
console.warn('Failed to fetch job detail:', error)
return undefined
}
}
export async function getJobWorkflow(
jobId: string
): Promise<ComfyWorkflowJSON | undefined> {
const detail = await getJobDetail(jobId)
return await extractWorkflow(detail)
}