mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-27 01:39:47 +00:00
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:
@@ -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,
|
||||
|
||||
278
src/services/jobOutputCache.test.ts
Normal file
278
src/services/jobOutputCache.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
103
src/services/jobOutputCache.ts
Normal file
103
src/services/jobOutputCache.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user