diff --git a/src/components/sidebar/tabs/QueueSidebarTab.vue b/src/components/sidebar/tabs/QueueSidebarTab.vue index 9e1219c2f..cbe4980af 100644 --- a/src/components/sidebar/tabs/QueueSidebarTab.vue +++ b/src/components/sidebar/tabs/QueueSidebarTab.vue @@ -104,6 +104,7 @@ import { useI18n } from 'vue-i18n' import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue' import VirtualGrid from '@/components/common/VirtualGrid.vue' +import { isCloud } from '@/platform/distribution/types' import { useSettingStore } from '@/platform/settings/settingStore' import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema' import { api } from '@/scripts/api' @@ -206,7 +207,9 @@ const menuItems = computed(() => { label: t('g.loadWorkflow'), icon: 'pi pi-file-export', command: () => menuTargetTask.value?.loadWorkflow(app), - disabled: !menuTargetTask.value?.workflow + disabled: isCloud + ? !menuTargetTask.value?.isHistory + : !menuTargetTask.value?.workflow }, { label: t('g.goToNode'), diff --git a/src/platform/remote/comfyui/history/reconciliation.ts b/src/platform/remote/comfyui/history/reconciliation.ts index 969566879..aaf445775 100644 --- a/src/platform/remote/comfyui/history/reconciliation.ts +++ b/src/platform/remote/comfyui/history/reconciliation.ts @@ -13,11 +13,6 @@ import { isCloud } from '@/platform/distribution/types' import type { TaskItem } from '@/schemas/apiSchema' -interface ReconciliationResult { - /** All items to display, sorted by queueIndex descending (newest first) */ - items: TaskItem[] -} - /** * V1 reconciliation: QueueIndex-based filtering works because V1 has stable, * monotonically increasing queue indices. @@ -25,13 +20,15 @@ interface ReconciliationResult { * Sort order: Sorts serverHistory by queueIndex descending (newest first) to ensure * consistent ordering. JavaScript .filter() maintains iteration order, so filtered * results remain sorted. clientHistory is assumed already sorted from previous update. + * + * @returns All items to display, sorted by queueIndex descending (newest first) */ function reconcileHistoryV1( serverHistory: TaskItem[], clientHistory: TaskItem[], maxItems: number, lastKnownQueueIndex: number | undefined -): ReconciliationResult { +): TaskItem[] { const sortedServerHistory = serverHistory.sort( (a, b) => b.prompt[0] - a.prompt[0] ) @@ -53,13 +50,9 @@ function reconcileHistoryV1( ) // Merge new and reused items, sort by queueIndex descending, limit to maxItems - const allItems = [...itemsAddedSinceLastSync, ...clientItemsStillOnServer] + return [...itemsAddedSinceLastSync, ...clientItemsStillOnServer] .sort((a, b) => b.prompt[0] - a.prompt[0]) .slice(0, maxItems) - - return { - items: allItems - } } /** @@ -69,12 +62,14 @@ function reconcileHistoryV1( * Sort order: Sorts serverHistory by queueIndex descending (newest first) to ensure * consistent ordering. JavaScript .filter() maintains iteration order, so filtered * results remain sorted. clientHistory is assumed already sorted from previous update. + * + * @returns All items to display, sorted by queueIndex descending (newest first) */ function reconcileHistoryV2( serverHistory: TaskItem[], clientHistory: TaskItem[], maxItems: number -): ReconciliationResult { +): TaskItem[] { const sortedServerHistory = serverHistory.sort( (a, b) => b.prompt[0] - a.prompt[0] ) @@ -84,29 +79,18 @@ function reconcileHistoryV2( ) const clientPromptIds = new Set(clientHistory.map((item) => item.prompt[1])) - const newPromptIds = new Set( - [...serverPromptIds].filter((id) => !clientPromptIds.has(id)) + const newItems = sortedServerHistory.filter( + (item) => !clientPromptIds.has(item.prompt[1]) ) - const newItems = sortedServerHistory.filter((item) => - newPromptIds.has(item.prompt[1]) - ) - - const retainedPromptIds = new Set( - [...serverPromptIds].filter((id) => clientPromptIds.has(id)) - ) const clientItemsStillOnServer = clientHistory.filter((item) => - retainedPromptIds.has(item.prompt[1]) + serverPromptIds.has(item.prompt[1]) ) // Merge new and reused items, sort by queueIndex descending, limit to maxItems - const allItems = [...newItems, ...clientItemsStillOnServer] + return [...newItems, ...clientItemsStillOnServer] .sort((a, b) => b.prompt[0] - a.prompt[0]) .slice(0, maxItems) - - return { - items: allItems - } } /** @@ -125,7 +109,7 @@ export function reconcileHistory( clientHistory: TaskItem[], maxItems: number, lastKnownQueueIndex?: number -): ReconciliationResult { +): TaskItem[] { if (isCloud) { return reconcileHistoryV2(serverHistory, clientHistory, maxItems) } diff --git a/src/platform/workflow/cloud/getWorkflowFromHistory.ts b/src/platform/workflow/cloud/getWorkflowFromHistory.ts new file mode 100644 index 000000000..8c9027e30 --- /dev/null +++ b/src/platform/workflow/cloud/getWorkflowFromHistory.ts @@ -0,0 +1,21 @@ +import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' +import type { PromptId } from '@/schemas/apiSchema' + +export async function getWorkflowFromHistory( + fetchApi: (url: string) => Promise, + promptId: PromptId +): Promise { + try { + const res = await fetchApi(`/history_v2/${promptId}`) + const json = await res.json() + + const historyItem = json[promptId] + if (!historyItem) return undefined + + const workflow = historyItem.prompt?.extra_data?.extra_pnginfo?.workflow + return workflow ?? undefined + } catch (error) { + console.error(`Failed to fetch workflow for prompt ${promptId}:`, error) + return undefined + } +} diff --git a/src/platform/workflow/cloud/index.ts b/src/platform/workflow/cloud/index.ts new file mode 100644 index 000000000..1f5422402 --- /dev/null +++ b/src/platform/workflow/cloud/index.ts @@ -0,0 +1,10 @@ +/** + * Cloud: Fetches workflow by prompt_id. Desktop: Returns undefined (workflows already in history). + */ +import { isCloud } from '@/platform/distribution/types' + +import { getWorkflowFromHistory as cloudImpl } from './getWorkflowFromHistory' + +export const getWorkflowFromHistory = isCloud + ? cloudImpl + : async () => undefined diff --git a/src/schemas/apiSchema.ts b/src/schemas/apiSchema.ts index 30b0e57eb..f41d72020 100644 --- a/src/schemas/apiSchema.ts +++ b/src/schemas/apiSchema.ts @@ -13,6 +13,7 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes' const zNodeType = z.string() export const zQueueIndex = z.number() export const zPromptId = z.string() +export type PromptId = z.infer export const resultItemType = z.enum(['input', 'output', 'temp']) export type ResultItemType = z.infer diff --git a/src/stores/queueStore.ts b/src/stores/queueStore.ts index 17483c04b..28262a181 100644 --- a/src/stores/queueStore.ts +++ b/src/stores/queueStore.ts @@ -2,7 +2,9 @@ import _ from 'es-toolkit/compat' import { defineStore } from 'pinia' import { computed, ref, shallowRef, toRaw, toValue } from 'vue' +import { isCloud } from '@/platform/distribution/types' import { reconcileHistory } from '@/platform/remote/comfyui/history/reconciliation' +import { getWorkflowFromHistory } from '@/platform/workflow/cloud' import type { ComfyWorkflowJSON, NodeId @@ -379,24 +381,37 @@ export class TaskItemImpl { } public async loadWorkflow(app: ComfyApp) { - if (!this.workflow) { - return - } - await app.loadGraphData(toRaw(this.workflow)) - if (this.outputs) { - const nodeOutputsStore = useNodeOutputStore() - const rawOutputs = toRaw(this.outputs) - for (const nodeExecutionId in rawOutputs) { - nodeOutputsStore.setNodeOutputsByExecutionId( - nodeExecutionId, - rawOutputs[nodeExecutionId] - ) - } - useExtensionService().invokeExtensions( - 'onNodeOutputsUpdated', - app.nodeOutputs + let workflowData = this.workflow + + if (isCloud && !workflowData && this.isHistory) { + workflowData = await getWorkflowFromHistory( + (url) => app.api.fetchApi(url), + this.promptId ) } + + if (!workflowData) { + return + } + + await app.loadGraphData(toRaw(workflowData)) + + if (!this.outputs) { + return + } + + const nodeOutputsStore = useNodeOutputStore() + const rawOutputs = toRaw(this.outputs) + for (const nodeExecutionId in rawOutputs) { + nodeOutputsStore.setNodeOutputsByExecutionId( + nodeExecutionId, + rawOutputs[nodeExecutionId] + ) + } + useExtensionService().invokeExtensions( + 'onNodeOutputsUpdated', + app.nodeOutputs + ) } public flatten(): TaskItemImpl[] { @@ -492,7 +507,7 @@ export const useQueueStore = defineStore('queue', () => { const currentHistory = toValue(historyTasks) - const { items } = reconcileHistory( + const items = reconcileHistory( history.History, currentHistory.map((impl) => impl.toTaskItem()), toValue(maxHistoryItems), diff --git a/tests-ui/tests/platform/remote/comfyui/history/reconciliation.test.ts b/tests-ui/tests/platform/remote/comfyui/history/reconciliation.test.ts index cbfe30ba8..d7ada5971 100644 --- a/tests-ui/tests/platform/remote/comfyui/history/reconciliation.test.ts +++ b/tests-ui/tests/platform/remote/comfyui/history/reconciliation.test.ts @@ -21,8 +21,8 @@ function createHistoryItem(promptId: string, queueIndex = 0): TaskItem { } } -function getAllPromptIds(result: { items: TaskItem[] }): string[] { - return result.items.map((item) => item.prompt[1]) +function getAllPromptIds(result: TaskItem[]): string[] { + return result.map((item) => item.prompt[1]) } describe('reconcileHistory (V1)', () => { @@ -74,9 +74,9 @@ describe('reconcileHistory (V1)', () => { const result = reconcileHistory(serverHistory, [], 10, undefined) - expect(result.items).toHaveLength(2) - expect(result.items[0].prompt[1]).toBe('item-1') - expect(result.items[1].prompt[1]).toBe('item-2') + expect(result).toHaveLength(2) + expect(result[0].prompt[1]).toBe('item-1') + expect(result[1].prompt[1]).toBe('item-2') }) }) @@ -144,9 +144,9 @@ describe('reconcileHistory (V1)', () => { const result = reconcileHistory(serverHistory, clientHistory, 2, 10) - expect(result.items).toHaveLength(2) - expect(result.items[0].prompt[1]).toBe('new-1') - expect(result.items[1].prompt[1]).toBe('new-2') + expect(result).toHaveLength(2) + expect(result[0].prompt[1]).toBe('new-1') + expect(result[1].prompt[1]).toBe('new-2') }) }) @@ -168,13 +168,13 @@ describe('reconcileHistory (V1)', () => { const result = reconcileHistory([], clientHistory, 10, 5) - expect(result.items).toHaveLength(0) + expect(result).toHaveLength(0) }) it('should return empty result when both collections are empty', () => { const result = reconcileHistory([], [], 10, undefined) - expect(result.items).toHaveLength(0) + expect(result).toHaveLength(0) }) }) }) @@ -295,9 +295,9 @@ describe('reconcileHistory (V2/Cloud)', () => { const result = reconcileHistory(serverHistory, clientHistory, 2) - expect(result.items).toHaveLength(2) - expect(result.items[0].prompt[1]).toBe('new-1') - expect(result.items[1].prompt[1]).toBe('new-2') + expect(result).toHaveLength(2) + expect(result[0].prompt[1]).toBe('new-1') + expect(result[1].prompt[1]).toBe('new-2') }) }) @@ -310,9 +310,9 @@ describe('reconcileHistory (V2/Cloud)', () => { const result = reconcileHistory(serverHistory, [], 10) - expect(result.items).toHaveLength(2) - expect(result.items[0].prompt[1]).toBe('item-1') - expect(result.items[1].prompt[1]).toBe('item-2') + expect(result).toHaveLength(2) + expect(result[0].prompt[1]).toBe('item-1') + expect(result[1].prompt[1]).toBe('item-2') }) it('should return empty result when server history is empty', () => { @@ -323,13 +323,13 @@ describe('reconcileHistory (V2/Cloud)', () => { const result = reconcileHistory([], clientHistory, 10) - expect(result.items).toHaveLength(0) + expect(result).toHaveLength(0) }) it('should return empty result when both collections are empty', () => { const result = reconcileHistory([], [], 10) - expect(result.items).toHaveLength(0) + expect(result).toHaveLength(0) }) }) }) diff --git a/tests-ui/tests/platform/workflow/cloud/getWorkflowFromHistory.test.ts b/tests-ui/tests/platform/workflow/cloud/getWorkflowFromHistory.test.ts new file mode 100644 index 000000000..fede0e864 --- /dev/null +++ b/tests-ui/tests/platform/workflow/cloud/getWorkflowFromHistory.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' +import { getWorkflowFromHistory } from '@/platform/workflow/cloud/getWorkflowFromHistory' + +const mockWorkflow: ComfyWorkflowJSON = { + id: 'test-workflow-id', + revision: 0, + last_node_id: 5, + last_link_id: 3, + nodes: [], + links: [], + groups: [], + config: {}, + extra: {}, + version: 0.4 +} + +const mockHistoryResponse = { + 'test-prompt-id': { + prompt: { + priority: 1, + prompt_id: 'test-prompt-id', + extra_data: { + client_id: 'test-client', + extra_pnginfo: { + workflow: mockWorkflow + } + } + }, + outputs: {}, + status: { + status_str: 'success', + completed: true, + messages: [] + } + } +} + +describe('getWorkflowFromHistory', () => { + it('should fetch workflow from /history_v2/{prompt_id} endpoint', async () => { + const mockFetchApi = vi.fn().mockResolvedValue({ + json: async () => mockHistoryResponse + }) + + await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id') + + expect(mockFetchApi).toHaveBeenCalledWith('/history_v2/test-prompt-id') + }) + + it('should extract and return workflow from response', async () => { + const mockFetchApi = vi.fn().mockResolvedValue({ + json: async () => mockHistoryResponse + }) + + const result = await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id') + + expect(result).toEqual(mockWorkflow) + }) + + it('should return undefined when prompt_id not found in response', async () => { + const mockFetchApi = vi.fn().mockResolvedValue({ + json: async () => ({}) + }) + + const result = await getWorkflowFromHistory(mockFetchApi, 'nonexistent-id') + + expect(result).toBeUndefined() + }) + + it('should return undefined when workflow is missing from extra_pnginfo', async () => { + const mockFetchApi = vi.fn().mockResolvedValue({ + json: async () => ({ + 'test-prompt-id': { + prompt: { + priority: 1, + prompt_id: 'test-prompt-id', + extra_data: { + client_id: 'test-client' + } + }, + outputs: {} + } + }) + }) + + const result = await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id') + + expect(result).toBeUndefined() + }) + + it('should handle fetch errors gracefully', async () => { + const mockFetchApi = vi.fn().mockRejectedValue(new Error('Network error')) + + const result = await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id') + + expect(result).toBeUndefined() + }) + + it('should handle malformed JSON responses', async () => { + const mockFetchApi = vi.fn().mockResolvedValue({ + json: async () => { + throw new Error('Invalid JSON') + } + }) + + const result = await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id') + + expect(result).toBeUndefined() + }) +}) diff --git a/tests-ui/tests/stores/queueStore.loadWorkflow.test.ts b/tests-ui/tests/stores/queueStore.loadWorkflow.test.ts new file mode 100644 index 000000000..3ff57f593 --- /dev/null +++ b/tests-ui/tests/stores/queueStore.loadWorkflow.test.ts @@ -0,0 +1,175 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ComfyApp } from '@/scripts/app' +import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' +import { TaskItemImpl } from '@/stores/queueStore' +import * as getWorkflowModule from '@/platform/workflow/cloud' + +vi.mock('@/platform/distribution/types', () => ({ + isCloud: true +})) + +vi.mock('@/services/extensionService', () => ({ + useExtensionService: vi.fn(() => ({ + invokeExtensions: vi.fn() + })) +})) + +const mockWorkflow: ComfyWorkflowJSON = { + id: 'test-workflow-id', + revision: 0, + last_node_id: 5, + last_link_id: 3, + nodes: [], + links: [], + groups: [], + config: {}, + extra: {}, + version: 0.4 +} + +const createHistoryTaskWithWorkflow = (): TaskItemImpl => { + return new TaskItemImpl( + 'History', + [ + 0, // queueIndex + 'test-prompt-id', // promptId + {}, // promptInputs + { + client_id: 'test-client', + extra_pnginfo: { + workflow: mockWorkflow + } + }, + [] // outputsToExecute + ], + { + status_str: 'success', + completed: true, + messages: [] + }, + {} // outputs + ) +} + +const createHistoryTaskWithoutWorkflow = (): TaskItemImpl => { + return new TaskItemImpl( + 'History', + [ + 0, + 'test-prompt-id', + {}, + { + client_id: 'test-client' + // No extra_pnginfo.workflow + }, + [] + ], + { + status_str: 'success', + completed: true, + messages: [] + }, + {} + ) +} + +describe('TaskItemImpl.loadWorkflow - cloud history workflow fetching', () => { + let mockApp: ComfyApp + let mockFetchApi: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + + mockFetchApi = vi.fn() + mockApp = { + loadGraphData: vi.fn(), + nodeOutputs: {}, + api: { + fetchApi: mockFetchApi + } + } as unknown as ComfyApp + + vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory') + }) + + it('should load workflow directly when workflow is in extra_pnginfo', async () => { + const task = createHistoryTaskWithWorkflow() + + await task.loadWorkflow(mockApp) + + expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow) + expect(mockFetchApi).not.toHaveBeenCalled() + }) + + it('should fetch workflow from cloud when workflow is missing from history task', async () => { + const task = createHistoryTaskWithoutWorkflow() + + // Mock getWorkflowFromHistory to return workflow + vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory').mockResolvedValue( + mockWorkflow + ) + + await task.loadWorkflow(mockApp) + + expect(getWorkflowModule.getWorkflowFromHistory).toHaveBeenCalledWith( + expect.any(Function), + 'test-prompt-id' + ) + expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow) + }) + + it('should not load workflow when fetch returns undefined', async () => { + const task = createHistoryTaskWithoutWorkflow() + + vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory').mockResolvedValue( + undefined + ) + + await task.loadWorkflow(mockApp) + + expect(getWorkflowModule.getWorkflowFromHistory).toHaveBeenCalled() + expect(mockApp.loadGraphData).not.toHaveBeenCalled() + }) + + it('should only fetch for history tasks, not running tasks', async () => { + const runningTask = new TaskItemImpl( + 'Running', + [ + 0, + 'test-prompt-id', + {}, + { + client_id: 'test-client' + }, + [] + ], + undefined, + {} + ) + + vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory').mockResolvedValue( + mockWorkflow + ) + + await runningTask.loadWorkflow(mockApp) + + expect(getWorkflowModule.getWorkflowFromHistory).not.toHaveBeenCalled() + expect(mockApp.loadGraphData).not.toHaveBeenCalled() + }) + + it('should handle fetch errors gracefully by returning undefined', async () => { + const task = createHistoryTaskWithoutWorkflow() + + vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory').mockResolvedValue( + undefined + ) + + await task.loadWorkflow(mockApp) + + expect(getWorkflowModule.getWorkflowFromHistory).toHaveBeenCalled() + expect(mockApp.loadGraphData).not.toHaveBeenCalled() + }) +})