[feat] Add Jobs API integration with memory optimization and lazy loading

Implements Jobs API endpoints (/jobs) for cloud distribution to replace
history_v2 API, providing 99.998% memory reduction per item.

Key changes:
- Jobs API types, schemas, and fetchers for list and detail endpoints
- Adapter to convert Jobs API format to TaskItem format
- Lazy loading for full outputs when loading workflows
- hasOnlyPreviewOutputs() detection for preview-only tasks
- Feature flag to toggle between Jobs API and history_v2

Implementation details:
- List endpoint: Returns preview_output only (100-200 bytes per job)
- Detail endpoint: Returns full workflow and outputs on demand
- Cloud builds use /jobs?status=completed for history view
- Desktop builds unchanged (still use history_v1)
- 21 unit and integration tests (all passing)

Memory optimization:
- Old: 300-600KB per history item (full outputs)
- New: 100-200 bytes per history item (preview only)
- Reduction: 99.998%

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Richard Yu
2025-11-15 17:25:19 -08:00
parent e9d5ce7f3f
commit b733b88628
38 changed files with 1335 additions and 3047 deletions

View File

@@ -58,12 +58,21 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => workflowStoreMock
}))
const fetchJobDetailMock = vi.fn()
const extractWorkflowMock = vi.fn()
vi.mock('@/platform/remote/comfyui/jobs', () => ({
fetchJobDetail: (...args: any[]) => fetchJobDetailMock(...args),
extractWorkflow: (...args: any[]) => extractWorkflowMock(...args)
}))
const interruptMock = vi.fn()
const deleteItemMock = vi.fn()
const fetchApiMock = vi.fn()
vi.mock('@/scripts/api', () => ({
api: {
interrupt: (...args: any[]) => interruptMock(...args),
deleteItem: (...args: any[]) => deleteItemMock(...args)
deleteItem: (...args: any[]) => deleteItemMock(...args),
fetchApi: (...args: any[]) => fetchApiMock(...args)
}
}))
@@ -73,7 +82,7 @@ vi.mock('@/scripts/utils', () => ({
}))
const dialogServiceMock = {
showExecutionErrorDialog: vi.fn(),
showErrorDialog: vi.fn(),
prompt: vi.fn()
}
vi.mock('@/services/dialogService', () => ({
@@ -170,6 +179,9 @@ describe('useJobMenu', () => {
LoadVideo: { id: 'LoadVideo' },
LoadAudio: { id: 'LoadAudio' }
}
// Default: no workflow available via lazy loading
fetchJobDetailMock.mockResolvedValue(undefined)
extractWorkflowMock.mockReturnValue(undefined)
})
const setCurrentItem = (item: JobListItem | null) => {
@@ -179,10 +191,14 @@ describe('useJobMenu', () => {
it('opens workflow when workflow data exists', async () => {
const { openJobWorkflow } = mountJobMenu()
const workflow = { nodes: [] }
setCurrentItem(createJobItem({ id: '55', taskRef: { workflow } }))
// Mock lazy loading via fetchJobDetail + extractWorkflow
fetchJobDetailMock.mockResolvedValue({ id: '55' })
extractWorkflowMock.mockReturnValue(workflow)
setCurrentItem(createJobItem({ id: '55' }))
await openJobWorkflow()
expect(fetchJobDetailMock).toHaveBeenCalledWith(expect.any(Function), '55')
expect(workflowStoreMock.createTemporary).toHaveBeenCalledWith(
'Job 55.json',
workflow
@@ -257,11 +273,10 @@ describe('useJobMenu', () => {
it('copies error message from failed job entry', async () => {
const { jobMenuEntries } = mountJobMenu()
const error = { exception_message: 'boom' }
setCurrentItem(
createJobItem({
state: 'failed',
taskRef: { status: { messages: [['execution_error', error]] } } as any
taskRef: { errorMessage: 'Something went wrong' } as any
})
)
@@ -269,16 +284,15 @@ describe('useJobMenu', () => {
const entry = findActionEntry(jobMenuEntries.value, 'copy-error')
await entry?.onClick?.()
expect(copyToClipboardMock).toHaveBeenCalledWith('boom')
expect(copyToClipboardMock).toHaveBeenCalledWith('Something went wrong')
})
it('reports error via dialog when entry triggered', async () => {
const { jobMenuEntries } = mountJobMenu()
const error = { exception_message: 'bad', extra: 1 }
setCurrentItem(
createJobItem({
state: 'failed',
taskRef: { status: { messages: [['execution_error', error]] } } as any
taskRef: { errorMessage: 'Job failed with error' } as any
})
)
@@ -286,14 +300,22 @@ describe('useJobMenu', () => {
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
entry?.onClick?.()
expect(dialogServiceMock.showExecutionErrorDialog).toHaveBeenCalledWith(
error
)
expect(dialogServiceMock.showErrorDialog).toHaveBeenCalledTimes(1)
const [errorArg, optionsArg] =
dialogServiceMock.showErrorDialog.mock.calls[0]
expect(errorArg).toBeInstanceOf(Error)
expect(errorArg.message).toBe('Job failed with error')
expect(optionsArg).toEqual({ reportType: 'queueJobError' })
})
it('ignores error actions when message missing', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'failed', taskRef: { status: {} } }))
setCurrentItem(
createJobItem({
state: 'failed',
taskRef: { errorMessage: undefined } as any
})
)
await nextTick()
const copyEntry = findActionEntry(jobMenuEntries.value, 'copy-error')
@@ -302,7 +324,7 @@ describe('useJobMenu', () => {
await reportEntry?.onClick?.()
expect(copyToClipboardMock).not.toHaveBeenCalled()
expect(dialogServiceMock.showExecutionErrorDialog).not.toHaveBeenCalled()
expect(dialogServiceMock.showErrorDialog).not.toHaveBeenCalled()
})
const previewCases = [
@@ -477,12 +499,14 @@ describe('useJobMenu', () => {
})
it('exports workflow with default filename when prompting disabled', async () => {
const workflow = { foo: 'bar' }
fetchJobDetailMock.mockResolvedValue({ id: '7' })
extractWorkflowMock.mockReturnValue(workflow)
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
id: '7',
state: 'completed',
taskRef: { workflow: { foo: 'bar' } }
state: 'completed'
})
)
@@ -502,11 +526,12 @@ describe('useJobMenu', () => {
it('prompts for filename when setting enabled', async () => {
settingStoreMock.get.mockReturnValue(true)
dialogServiceMock.prompt.mockResolvedValue('custom-name')
fetchJobDetailMock.mockResolvedValue({ id: 'job-1' })
extractWorkflowMock.mockReturnValue({})
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: { workflow: {} }
state: 'completed'
})
)
@@ -526,12 +551,13 @@ describe('useJobMenu', () => {
it('keeps existing json extension when exporting workflow', async () => {
settingStoreMock.get.mockReturnValue(true)
dialogServiceMock.prompt.mockResolvedValue('existing.json')
fetchJobDetailMock.mockResolvedValue({ id: '42' })
extractWorkflowMock.mockReturnValue({ foo: 'bar' })
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
id: '42',
state: 'completed',
taskRef: { workflow: { foo: 'bar' } }
state: 'completed'
})
)
@@ -547,11 +573,12 @@ describe('useJobMenu', () => {
it('abandons export when prompt cancelled', async () => {
settingStoreMock.get.mockReturnValue(true)
dialogServiceMock.prompt.mockResolvedValue('')
fetchJobDetailMock.mockResolvedValue({ id: 'job-1' })
extractWorkflowMock.mockReturnValue({})
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: { workflow: {} }
state: 'completed'
})
)
@@ -671,7 +698,12 @@ describe('useJobMenu', () => {
it('returns failed menu entries with error actions', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'failed', taskRef: { status: {} } }))
setCurrentItem(
createJobItem({
state: 'failed',
taskRef: { errorMessage: 'Some error' } as any
})
)
await nextTick()
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([