From d27e177d619ec583ca410afc37bdbc82190aa9c1 Mon Sep 17 00:00:00 2001 From: ric-yu Date: Tue, 13 Jan 2026 19:38:00 -0800 Subject: [PATCH] feat: Migrate to Jobs API (PR 2 of 3) (#7170) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Migrate frontend from legacy `/history`, `/history_v2`, and `/queue` endpoints to the unified `/jobs` API with memory optimization and lazy loading. **This is PR 2 of 3** - Core migration, depends on PR 1. ## Changes - **What**: - Replace `api.getQueue()` and `api.getHistory()` implementations to use Jobs API fetchers - Implement lazy loading for workflow and full outputs via `/jobs/{id}` endpoint in `useJobMenu` - Add `TaskItemImpl` class wrapping `JobListItem` for queue store compatibility - Rename `reconcileHistory` to `reconcileJobs` for clarity - Use `execution_start_time` and `execution_end_time` from API for execution timing - Use `workflowId` from job instead of nested `workflow.id` - Update `useJobMenu` to fetch job details on demand (`openJobWorkflow`, `exportJobWorkflow`) - **Breaking**: Requires backend Jobs API support (ComfyUI with `/jobs` endpoint) ## Review Focus 1. **Lazy loading in `useJobMenu`**: `openJobWorkflow` and `exportJobWorkflow` now fetch from API on demand instead of accessing `taskRef.workflow` 2. **`TaskItemImpl` wrapper**: Adapts `JobListItem` to existing queue store interface 3. **Error reporting**: Uses `execution_error` field from API for rich error dialogs 4. **Memory optimization**: Only fetches full job details when needed ## Files Changed - `src/scripts/api.ts` - Updated `getQueue()` and `getHistory()` to use Jobs API - `src/stores/queueStore.ts` - Added `TaskItemImpl`, updated to use `JobListItem` - `src/composables/useJobMenu.ts` - Lazy loading for workflow access - `src/composables/useJobList.ts` - Updated types - Various test files updated ## Dependencies - **Depends on**: PR 1 (Jobs API Infrastructure) - #7169 ## Next PR - **PR 3**: Remove legacy history code and unused types ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7170-feat-Migrate-to-Jobs-API-PR-2-of-3-2bf6d73d3650811b94f4fbe69944bba6) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude Co-authored-by: Christian Byrne --- browser_tests/fixtures/ComfyPage.ts | 8 - src/components/queue/QueueProgressOverlay.vue | 2 +- .../queue/job/JobDetailsPopover.stories.ts | 132 ++--- src/components/queue/job/JobGroupsList.vue | 2 +- .../queue/job/useJobErrorReporting.test.ts | 180 +++--- .../queue/job/useJobErrorReporting.ts | 5 +- .../sidebar/tabs/AssetsSidebarTab.vue | 164 ++---- src/composables/queue/useJobList.test.ts | 10 +- src/composables/queue/useJobList.ts | 2 +- src/composables/queue/useJobMenu.test.ts | 103 +++- src/composables/queue/useJobMenu.ts | 109 ++-- .../queue/useResultGallery.test.ts | 134 +++-- src/composables/queue/useResultGallery.ts | 35 +- .../assets/composables/media/assetMappers.ts | 1 - .../history/__fixtures__/historyFixtures.ts | 380 ------------ .../history/adapters/v2ToV1Adapter.test.ts | 434 -------------- .../comfyui/history/adapters/v2ToV1Adapter.ts | 74 --- .../history/fetchers/fetchHistoryV1.test.ts | 52 -- .../history/fetchers/fetchHistoryV1.ts | 51 -- .../history/fetchers/fetchHistoryV2.test.ts | 41 -- .../history/fetchers/fetchHistoryV2.ts | 42 -- src/platform/remote/comfyui/history/index.ts | 29 - .../comfyui/history/reconciliation.test.ts | 335 ----------- .../remote/comfyui/history/reconciliation.ts | 122 ---- .../comfyui/history/types/historyV1Types.ts | 15 - .../comfyui/history/types/historyV2Types.ts | 46 -- .../remote/comfyui/history/types/index.ts | 9 - .../remote/comfyui/jobs/fetchJobs.test.ts | 10 +- src/platform/remote/comfyui/jobs/fetchJobs.ts | 4 +- src/platform/remote/comfyui/jobs/jobTypes.ts | 17 +- .../cloud/getWorkflowFromHistory.test.ts | 124 ++-- .../workflow/cloud/getWorkflowFromHistory.ts | 21 - src/platform/workflow/cloud/index.ts | 10 - src/schemas/apiSchema.ts | 137 +---- src/scripts/api.ts | 118 ++-- src/scripts/ui.ts | 34 +- src/services/jobOutputCache.test.ts | 278 +++++++++ src/services/jobOutputCache.ts | 103 ++++ src/stores/assetsStore.test.ts | 225 +++---- src/stores/assetsStore.ts | 34 +- src/stores/executionStore.ts | 6 +- src/stores/queueStore.loadWorkflow.test.ts | 159 ++--- src/stores/queueStore.test.ts | 547 +++++++++--------- src/stores/queueStore.ts | 318 +++++----- 44 files changed, 1596 insertions(+), 3066 deletions(-) delete mode 100644 src/platform/remote/comfyui/history/__fixtures__/historyFixtures.ts delete mode 100644 src/platform/remote/comfyui/history/adapters/v2ToV1Adapter.test.ts delete mode 100644 src/platform/remote/comfyui/history/adapters/v2ToV1Adapter.ts delete mode 100644 src/platform/remote/comfyui/history/fetchers/fetchHistoryV1.test.ts delete mode 100644 src/platform/remote/comfyui/history/fetchers/fetchHistoryV1.ts delete mode 100644 src/platform/remote/comfyui/history/fetchers/fetchHistoryV2.test.ts delete mode 100644 src/platform/remote/comfyui/history/fetchers/fetchHistoryV2.ts delete mode 100644 src/platform/remote/comfyui/history/index.ts delete mode 100644 src/platform/remote/comfyui/history/reconciliation.test.ts delete mode 100644 src/platform/remote/comfyui/history/reconciliation.ts delete mode 100644 src/platform/remote/comfyui/history/types/historyV1Types.ts delete mode 100644 src/platform/remote/comfyui/history/types/historyV2Types.ts delete mode 100644 src/platform/remote/comfyui/history/types/index.ts delete mode 100644 src/platform/workflow/cloud/getWorkflowFromHistory.ts delete mode 100644 src/platform/workflow/cloud/index.ts create mode 100644 src/services/jobOutputCache.test.ts create mode 100644 src/services/jobOutputCache.ts diff --git a/browser_tests/fixtures/ComfyPage.ts b/browser_tests/fixtures/ComfyPage.ts index 08cdd5ccea..81b482284d 100644 --- a/browser_tests/fixtures/ComfyPage.ts +++ b/browser_tests/fixtures/ComfyPage.ts @@ -21,7 +21,6 @@ import { import { Topbar } from './components/Topbar' import type { Position, Size } from './types' import { NodeReference, SubgraphSlotReference } from './utils/litegraphUtils' -import TaskHistory from './utils/taskHistory' dotenv.config() @@ -146,8 +145,6 @@ class ConfirmDialog { } export class ComfyPage { - private _history: TaskHistory | null = null - public readonly url: string // All canvas position operations are based on default view of canvas. public readonly canvas: Locator @@ -301,11 +298,6 @@ export class ComfyPage { } } - setupHistory(): TaskHistory { - this._history ??= new TaskHistory(this) - return this._history - } - async setup({ clearStorage = true, mockReleases = true diff --git a/src/components/queue/QueueProgressOverlay.vue b/src/components/queue/QueueProgressOverlay.vue index 78efb7a789..d6b3edcd8d 100644 --- a/src/components/queue/QueueProgressOverlay.vue +++ b/src/components/queue/QueueProgressOverlay.vue @@ -262,7 +262,7 @@ const focusAssetInSidebar = async (item: JobListItem) => { const inspectJobAsset = wrapWithErrorHandlingAsync( async (item: JobListItem) => { - openResultGallery(item) + await openResultGallery(item) await focusAssetInSidebar(item) } ) diff --git a/src/components/queue/job/JobDetailsPopover.stories.ts b/src/components/queue/job/JobDetailsPopover.stories.ts index 2343a8fa22..9b79c99e50 100644 --- a/src/components/queue/job/JobDetailsPopover.stories.ts +++ b/src/components/queue/job/JobDetailsPopover.stories.ts @@ -1,6 +1,9 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite' -import type { TaskStatus } from '@/schemas/apiSchema' +import type { + JobListItem, + JobStatus +} from '@/platform/remote/comfyui/jobs/jobTypes' import { useExecutionStore } from '@/stores/executionStore' import { TaskItemImpl, useQueueStore } from '@/stores/queueStore' @@ -37,91 +40,86 @@ function resetStores() { exec.nodeProgressStatesByPrompt = {} } +function makeTask( + id: string, + priority: number, + fields: Partial & { status: JobStatus; create_time: number } +): TaskItemImpl { + const job: JobListItem = { + id, + priority, + last_state_update: null, + update_time: fields.create_time, + ...fields + } + return new TaskItemImpl(job) +} + function makePendingTask( id: string, - index: number, - createTimeMs?: number + priority: number, + createTimeMs: number ): TaskItemImpl { - const extraData = { - client_id: 'c1', - ...(typeof createTimeMs === 'number' ? { create_time: createTimeMs } : {}) - } - return new TaskItemImpl('Pending', [index, id, {}, extraData, []]) + return makeTask(id, priority, { + status: 'pending', + create_time: createTimeMs + }) } function makeRunningTask( id: string, - index: number, - createTimeMs?: number + priority: number, + createTimeMs: number ): TaskItemImpl { - const extraData = { - client_id: 'c1', - ...(typeof createTimeMs === 'number' ? { create_time: createTimeMs } : {}) - } - return new TaskItemImpl('Running', [index, id, {}, extraData, []]) + return makeTask(id, priority, { + status: 'in_progress', + create_time: createTimeMs + }) } function makeRunningTaskWithStart( id: string, - index: number, + priority: number, startedSecondsAgo: number ): TaskItemImpl { const start = Date.now() - startedSecondsAgo * 1000 - const status: TaskStatus = { - status_str: 'success', - completed: false, - messages: [['execution_start', { prompt_id: id, timestamp: start } as any]] - } - return new TaskItemImpl( - 'Running', - [index, id, {}, { client_id: 'c1', create_time: start - 5000 }, []], - status - ) + return makeTask(id, priority, { + status: 'in_progress', + create_time: start - 5000, + update_time: start + }) } function makeHistoryTask( id: string, - index: number, + priority: number, durationSec: number, ok: boolean, errorMessage?: string ): TaskItemImpl { - const start = Date.now() - durationSec * 1000 - 1000 - const end = start + durationSec * 1000 - const messages: TaskStatus['messages'] = ok - ? [ - ['execution_start', { prompt_id: id, timestamp: start } as any], - ['execution_success', { prompt_id: id, timestamp: end } as any] - ] - : [ - ['execution_start', { prompt_id: id, timestamp: start } as any], - [ - 'execution_error', - { - prompt_id: id, - timestamp: end, - node_id: '1', - node_type: 'Node', - executed: [], - exception_message: - errorMessage || 'Demo error: Node failed during execution', - exception_type: 'RuntimeError', - traceback: [], - current_inputs: {}, - current_outputs: {} - } as any - ] - ] - const status: TaskStatus = { - status_str: ok ? 'success' : 'error', - completed: true, - messages - } - return new TaskItemImpl( - 'History', - [index, id, {}, { client_id: 'c1', create_time: start }, []], - status - ) + const now = Date.now() + const executionEndTime = now + const executionStartTime = now - durationSec * 1000 + return makeTask(id, priority, { + status: ok ? 'completed' : 'failed', + create_time: executionStartTime - 5000, + update_time: now, + execution_start_time: executionStartTime, + execution_end_time: executionEndTime, + execution_error: errorMessage + ? { + prompt_id: id, + timestamp: now, + node_id: '1', + node_type: 'ExampleNode', + exception_message: errorMessage, + exception_type: 'RuntimeError', + traceback: [], + current_inputs: {}, + current_outputs: {} + } + : undefined + }) } export const Queued: Story = { @@ -140,8 +138,12 @@ export const Queued: Story = { makePendingTask(jobId, queueIndex, Date.now() - 90_000) ] // Add some other pending jobs to give context - queue.pendingTasks.push(makePendingTask('job-older-1', 100)) - queue.pendingTasks.push(makePendingTask('job-older-2', 101)) + queue.pendingTasks.push( + makePendingTask('job-older-1', 100, Date.now() - 60_000) + ) + queue.pendingTasks.push( + makePendingTask('job-older-2', 101, Date.now() - 30_000) + ) // Queued at (in metadata on prompt[4]) diff --git a/src/components/queue/job/JobGroupsList.vue b/src/components/queue/job/JobGroupsList.vue index 482016d346..3a7c6d6af9 100644 --- a/src/components/queue/job/JobGroupsList.vue +++ b/src/components/queue/job/JobGroupsList.vue @@ -12,7 +12,7 @@ v-for="ji in group.items" :key="ji.id" :job-id="ji.id" - :workflow-id="ji.taskRef?.workflow?.id" + :workflow-id="ji.taskRef?.workflowId" :state="ji.state" :title="ji.title" :right-text="ji.meta" diff --git a/src/components/queue/job/useJobErrorReporting.test.ts b/src/components/queue/job/useJobErrorReporting.test.ts index f6d0f8d360..8c8ac751ec 100644 --- a/src/components/queue/job/useJobErrorReporting.test.ts +++ b/src/components/queue/job/useJobErrorReporting.test.ts @@ -2,64 +2,43 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computed, ref } from 'vue' import type { ComputedRef } from 'vue' -import type { ExecutionErrorWsMessage, TaskStatus } from '@/schemas/apiSchema' -import { TaskItemImpl } from '@/stores/queueStore' +import type { TaskItemImpl } from '@/stores/queueStore' import type { JobErrorDialogService } from '@/components/queue/job/useJobErrorReporting' import { useJobErrorReporting } from '@/components/queue/job/useJobErrorReporting' +import type { ExecutionError } from '@/platform/remote/comfyui/jobs/jobTypes' -const createExecutionErrorMessage = ( - overrides: Partial = {} -): ExecutionErrorWsMessage => ({ - prompt_id: 'prompt', - timestamp: 100, - node_id: 'node-1', - node_type: 'KSampler', - executed: [], - exception_message: 'default failure', - exception_type: 'RuntimeError', - traceback: ['Trace line'], - current_inputs: {}, - current_outputs: {}, - ...overrides -}) - -/** - * Creates a real TaskItemImpl with the given status messages. - * Uses the actual TaskItemImpl class to test the real errorMessage/executionError getters. - */ -function createTaskWithMessages( - messages: TaskStatus['messages'] = [] -): TaskItemImpl { - const status: TaskStatus = { - status_str: 'error', - completed: false, - messages - } - return new TaskItemImpl( - 'History', - [0, 'test-prompt-id', {}, { client_id: 'test-client' }, []], - status - ) -} +const createTaskWithError = ( + promptId: string, + errorMessage?: string, + executionError?: ExecutionError, + createTime?: number +): TaskItemImpl => + ({ + promptId, + errorMessage, + executionError, + createTime: createTime ?? Date.now() + }) as unknown as TaskItemImpl describe('useJobErrorReporting', () => { let taskState = ref(null) let taskForJob: ComputedRef let copyToClipboard: ReturnType - let showExecutionErrorDialog: 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() - showExecutionErrorDialog = vi.fn() showErrorDialog = vi.fn() + showExecutionErrorDialog = vi.fn() dialog = { - showExecutionErrorDialog, - showErrorDialog + showErrorDialog, + showExecutionErrorDialog } as unknown as JobErrorDialogService composable = useJobErrorReporting({ taskForJob, @@ -75,81 +54,112 @@ describe('useJobErrorReporting', () => { }) it('exposes a computed message that reflects the current task error', () => { - taskState.value = createTaskWithMessages([ - [ - 'execution_error', - createExecutionErrorMessage({ exception_message: 'First failure' }) - ] - ]) + taskState.value = createTaskWithError('job-1', 'First failure') expect(composable.errorMessageValue.value).toBe('First failure') - taskState.value = createTaskWithMessages([ - [ - 'execution_error', - createExecutionErrorMessage({ exception_message: 'Second failure' }) - ] - ]) + taskState.value = createTaskWithError('job-2', 'Second failure') expect(composable.errorMessageValue.value).toBe('Second failure') }) - it('returns empty string when task has no error', () => { - taskState.value = null + it('returns empty string when no error message', () => { + taskState.value = createTaskWithError('job-1') expect(composable.errorMessageValue.value).toBe('') + }) - taskState.value = createTaskWithMessages([]) + it('returns empty string when task is null', () => { + taskState.value = null expect(composable.errorMessageValue.value).toBe('') }) it('only calls the copy handler when a message exists', () => { - taskState.value = createTaskWithMessages([ - [ - 'execution_error', - createExecutionErrorMessage({ exception_message: 'Clipboard failure' }) - ] - ]) + taskState.value = createTaskWithError('job-1', 'Clipboard failure') composable.copyErrorMessage() expect(copyToClipboard).toHaveBeenCalledTimes(1) expect(copyToClipboard).toHaveBeenCalledWith('Clipboard failure') copyToClipboard.mockClear() - taskState.value = createTaskWithMessages([]) + taskState.value = createTaskWithError('job-2') composable.copyErrorMessage() expect(copyToClipboard).not.toHaveBeenCalled() }) - it('prefers the detailed execution dialog when detail is available', () => { - const detail = createExecutionErrorMessage({ - exception_message: 'Detailed failure' - }) - taskState.value = createTaskWithMessages([['execution_error', detail]]) + it('shows simple error dialog when only errorMessage present', () => { + taskState.value = createTaskWithError('job-1', 'Queue job error') composable.reportJobError() - expect(showExecutionErrorDialog).toHaveBeenCalledTimes(1) - expect(showExecutionErrorDialog).toHaveBeenCalledWith(detail) - expect(showErrorDialog).not.toHaveBeenCalled() - }) - it('shows a fallback dialog when only a message is available', () => { - const message = 'Queue job error' - taskState.value = createTaskWithMessages([]) - const valueSpy = vi - .spyOn(composable.errorMessageValue, 'value', 'get') - .mockReturnValue(message) - - expect(composable.errorMessageValue.value).toBe(message) - composable.reportJobError() - expect(showExecutionErrorDialog).not.toHaveBeenCalled() expect(showErrorDialog).toHaveBeenCalledTimes(1) const [errorArg, optionsArg] = showErrorDialog.mock.calls[0] expect(errorArg).toBeInstanceOf(Error) - expect(errorArg.message).toBe(message) + expect(errorArg.message).toBe('Queue job error') expect(optionsArg).toEqual({ reportType: 'queueJobError' }) - valueSpy.mockRestore() + expect(showExecutionErrorDialog).not.toHaveBeenCalled() }) - it('does nothing when no error could be extracted', () => { - taskState.value = createTaskWithMessages([]) + it('does nothing when no task exists', () => { + taskState.value = null composable.reportJobError() + expect(showErrorDialog).not.toHaveBeenCalled() expect(showExecutionErrorDialog).not.toHaveBeenCalled() + }) + + 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 + ) + + composable.reportJobError() + + expect(showExecutionErrorDialog).toHaveBeenCalledTimes(1) + expect(showExecutionErrorDialog).toHaveBeenCalledWith(executionError) expect(showErrorDialog).not.toHaveBeenCalled() }) + + 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 + ) + + composable.reportJobError() + + expect(showExecutionErrorDialog).toHaveBeenCalledTimes(1) + expect(showExecutionErrorDialog).toHaveBeenCalledWith(executionError) + }) + + it('does nothing when no error message and no execution_error', () => { + taskState.value = createTaskWithError('job-1') + + composable.reportJobError() + + expect(showErrorDialog).not.toHaveBeenCalled() + expect(showExecutionErrorDialog).not.toHaveBeenCalled() + }) }) diff --git a/src/components/queue/job/useJobErrorReporting.ts b/src/components/queue/job/useJobErrorReporting.ts index 01fd245a9c..b008238a62 100644 --- a/src/components/queue/job/useJobErrorReporting.ts +++ b/src/components/queue/job/useJobErrorReporting.ts @@ -28,9 +28,7 @@ export const useJobErrorReporting = ({ copyToClipboard, dialog }: UseJobErrorReportingOptions) => { - const errorMessageValue = computed(() => { - return taskForJob.value?.executionError?.exception_message ?? '' - }) + const errorMessageValue = computed(() => taskForJob.value?.errorMessage ?? '') const copyErrorMessage = () => { if (errorMessageValue.value) { @@ -44,6 +42,7 @@ export const useJobErrorReporting = ({ dialog.showExecutionErrorDialog(executionError) return } + if (errorMessageValue.value) { dialog.showErrorDialog(new Error(errorMessageValue.value), { reportType: 'queueJobError' diff --git a/src/components/sidebar/tabs/AssetsSidebarTab.vue b/src/components/sidebar/tabs/AssetsSidebarTab.vue index 7da25bb8e1..c3c6fb6504 100644 --- a/src/components/sidebar/tabs/AssetsSidebarTab.vue +++ b/src/components/sidebar/tabs/AssetsSidebarTab.vue @@ -52,37 +52,13 @@ class="pb-1 px-2 2xl:px-4" :show-generation-time-sort="activeTab === 'output'" /> -
- - {{ activeJobsLabel }} - -
- - {{ t('sideToolbar.queueProgressOverlay.clearQueueTooltip') }} - - -
-
- + @@ -146,6 +110,7 @@