Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
cc04846
ci: add team-gated Cursor review (thin caller for github-workflows)
mattmillerai Jun 15, 2026
4cadbb8
fix: resolve review feedback
mattmillerai Jun 15, 2026
e498c4a
ci: bump cursor-review SHA to github-workflows#9, drop judge_model ov…
mattmillerai Jun 17, 2026
880582a
[automated] Apply ESLint and Oxfmt fixes
actions-user Jun 17, 2026
8d23daa
fix: resolve review feedback
mattmillerai Jun 17, 2026
1fee449
Merge branch 'main' into ci/cursor-review-workflow
mattmillerai Jun 17, 2026
5ef89c7
feat(assets): adopt cursor pagination in the Generated tab jobs walk
mattmillerai Jun 10, 2026
1f810a1
fix(assets): harden cursor pagination against review findings
mattmillerai Jun 10, 2026
69a4d78
fix(assets): guard the history jobs walk against a non-advancing cursor
mattmillerai Jun 16, 2026
f318718
docs(assets): add JSDoc to assetsStore per review
mattmillerai Jun 17, 2026
753b0b4
fix(assets): harden epoch guards and truncate JobsApiError body
mattmillerai Jun 19, 2026
4290619
[automated] Apply ESLint and Oxfmt fixes
actions-user Jun 19, 2026
c207f56
Merge branch 'main' into matt/fe-962-fe-adopt-cursor-pagination-in-th…
mattmillerai Jun 19, 2026
89fdbcd
Update src/stores/assetsStore.ts
mattmillerai Jun 22, 2026
c238145
Update src/platform/remote/comfyui/jobs/jobTypes.ts
mattmillerai Jun 22, 2026
6f7686b
Update src/platform/remote/comfyui/jobs/fetchJobs.ts
mattmillerai Jun 22, 2026
6c3ead5
Update src/stores/assetsStore.ts
mattmillerai Jun 22, 2026
ea0f8a9
test: add body-truncation coverage and historyError-null assertion fo…
mattmillerai Jun 22, 2026
8b40f6a
fix: reset offset to 0 on cursor-recovery fallback to prevent page drift
mattmillerai Jun 22, 2026
e471b64
Merge remote-tracking branch 'origin/main' into HEAD
mattmillerai Jun 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/queue/QueueProgressOverlay.vue
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ const focusAssetInSidebar = async (item: JobListItem) => {
const assetId = String(jobId)
openAssetsSidebar()
await nextTick()
await assetsStore.updateHistory()
await assetsStore.refreshHistoryHead()
const asset = assetsStore.historyAssets.find(
(existingAsset) => existingAsset.id === assetId
)
Expand Down
4 changes: 2 additions & 2 deletions src/platform/missingMedia/missingMediaAssetResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,13 +255,13 @@ describe('resolveMissingMediaAssetSources', () => {
1,
expect.any(Function),
200,
0
{ offset: 0 }
)
expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
2,
expect.any(Function),
200,
200
{ offset: 200 }
)
})

Expand Down
2 changes: 1 addition & 1 deletion src/platform/missingMedia/missingMediaAssetResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ async function fetchGeneratedHistoryAssets(
const historyPage = await fetchHistoryPage(
api.fetchApi.bind(api),
HISTORY_MEDIA_ASSETS_PAGE_SIZE,
requestedOffset
{ offset: requestedOffset }
Comment thread
mattmillerai marked this conversation as resolved.
)

signal?.throwIfAborted()
Expand Down
6 changes: 3 additions & 3 deletions src/platform/missingMedia/missingMediaScan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,7 @@ describe('verifyMediaCandidates', () => {
expect(mockFetchHistoryPage).toHaveBeenCalledWith(
expect.any(Function),
200,
0
{ offset: 0 }
)
expect(candidates[0]).toMatchObject({
name: 'subfolder/photo.png [output]',
Expand Down Expand Up @@ -843,13 +843,13 @@ describe('verifyMediaCandidates', () => {
1,
expect.any(Function),
200,
0
{ offset: 0 }
)
expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
2,
expect.any(Function),
200,
200
{ offset: 200 }
)
expect(candidates[0].isMissing).toBe(false)
})
Expand Down
116 changes: 102 additions & 14 deletions src/platform/remote/comfyui/jobs/fetchJobs.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'

import {
JobsApiError,
extractWorkflow,
fetchHistory,
fetchHistoryPage,
Expand Down Expand Up @@ -39,7 +40,8 @@ function createMockResponse(
offset: pagination.offset ?? 0,
limit: pagination.limit ?? 200,
total,
has_more: pagination.has_more ?? false
has_more: pagination.has_more ?? false,
next_cursor: pagination.next_cursor
}
}
}
Expand Down Expand Up @@ -135,23 +137,41 @@ describe('fetchJobs', () => {
expect(result[0].priority).toBe(999)
})

it('returns empty array on error', async () => {
it('propagates fetch errors', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'))

const result = await fetchHistory(mockFetch)

expect(result).toEqual([])
await expect(fetchHistory(mockFetch)).rejects.toThrow('Network error')
})

it('returns empty array on non-ok response', async () => {
it('throws a JobsApiError carrying status and body on non-ok response', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 500
status: 400,
text: () =>
Promise.resolve('{"error":"Invalid cursor","code":"INVALID_CURSOR"}')
})

const result = await fetchHistory(mockFetch)
await expect(fetchHistory(mockFetch)).rejects.toBeInstanceOf(JobsApiError)
await expect(fetchHistory(mockFetch)).rejects.toMatchObject({
status: 400,
message: expect.stringContaining('INVALID_CURSOR')
})
})

it('parses a null next_cursor as absent', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve(
createMockResponse([createMockJob('job1', 'completed')], 1, {
next_cursor: null
})
)
})

expect(result).toEqual([])
const result = await fetchHistoryPage(mockFetch, 200, { offset: 0 })

expect(result.nextCursor).toBeUndefined()
})

it('parses batch containing text-only preview outputs', async () => {
Expand Down Expand Up @@ -205,7 +225,7 @@ describe('fetchJobs', () => {
)
})

const result = await fetchHistoryPage(mockFetch, 2, 5)
const result = await fetchHistoryPage(mockFetch, 2, { offset: 5 })

expect(mockFetch).toHaveBeenCalledWith(
'/jobs?status=completed,failed,cancelled&limit=2&offset=5'
Expand All @@ -218,6 +238,76 @@ describe('fetchJobs', () => {
expect(result.jobs[0].priority).toBe(5)
expect(result.jobs[1].priority).toBe(4)
})

it('sends the cursor instead of offset and returns next_cursor', async () => {
const mockFetch = vi.fn().mockResolvedValue({
Comment thread
mattmillerai marked this conversation as resolved.
Outdated
ok: true,
json: () =>
Promise.resolve(
createMockResponse([createMockJob('job1', 'completed')], 10, {
has_more: true,
next_cursor: 'cursor-page-2'
})
)
})

const result = await fetchHistoryPage(mockFetch, 200, {
after: 'cursor-page-1'
})

expect(mockFetch).toHaveBeenCalledWith(
'/jobs?status=completed,failed,cancelled&limit=200&after=cursor-page-1'
)
expect(result.nextCursor).toBe('cursor-page-2')
expect(result.hasMore).toBe(true)
})

it('uri-encodes the cursor', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(createMockResponse([]))
})

await fetchHistoryPage(mockFetch, 200, { after: 'a+b/c=' })

expect(mockFetch).toHaveBeenCalledWith(
'/jobs?status=completed,failed,cancelled&limit=200&after=a%2Bb%2Fc%3D'
)
})

it('returns next_cursor from offset-mode responses for cursor bootstrap', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve(
createMockResponse([createMockJob('job1', 'completed')], 10, {
has_more: true,
next_cursor: 'minted-in-offset-mode'
})
)
})

const result = await fetchHistoryPage(mockFetch, 200, { offset: 0 })

expect(mockFetch).toHaveBeenCalledWith(
'/jobs?status=completed,failed,cancelled&limit=200&offset=0'
)
expect(result.nextCursor).toBe('minted-in-offset-mode')
})

it('omits nextCursor when the server does not mint one', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve(
createMockResponse([createMockJob('job1', 'completed')])
)
})

const result = await fetchHistoryPage(mockFetch, 200, { offset: 0 })

expect(result.nextCursor).toBeUndefined()
})
})

describe('fetchQueue', () => {
Expand Down Expand Up @@ -268,12 +358,10 @@ describe('fetchJobs', () => {
)
})

it('returns empty arrays on error', async () => {
it('propagates fetch errors', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'))

const result = await fetchQueue(mockFetch)

expect(result).toEqual({ Running: [], Pending: [] })
await expect(fetchQueue(mockFetch)).rejects.toThrow('Network error')
})
})

Expand Down
95 changes: 59 additions & 36 deletions src/platform/remote/comfyui/jobs/fetchJobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,43 @@ import type {
} from './jobTypes'
import { zJobDetail, zJobsListResponse, zWorkflowContainer } from './jobTypes'

/**
* Position of the page to fetch. `after` is an opaque keyset cursor from a
* prior response's `nextCursor` and takes precedence over `offset`; `offset`
* remains as the fallback for random access and for backends that don't mint
* cursors.
*/
export type JobsPageRequest =
| { after: string; offset?: never }
| { offset?: number; after?: never }

/**
* Non-ok response from the jobs API. Carries the HTTP status so callers can
* tell a rejected cursor (400 INVALID_CURSOR) apart from transient failures.
*/
const MAX_ERROR_BODY_LENGTH = 200

export class JobsApiError extends Error {
constructor(
readonly status: number,
body: string
) {
const truncated =
body.length > MAX_ERROR_BODY_LENGTH
? `${body.slice(0, MAX_ERROR_BODY_LENGTH)}…`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): the delta's hardening fixes shipped without direct test coverage — this 200-char body truncation, and the if (epoch !== historyFetchEpoch) return catch guards in loadMoreHistory/doRefreshHistoryHead. The existing stale-continuation test (does not let a stale rejected continuation drop the new walk cursor) asserts call counts and the resumed cursor but never asserts historyError stays null on the superseded walk, so a regression that re-introduces the spurious-error-on-healthy-walk behavior (the exact bug the catch guard fixes) would still pass. A small unit test for truncation (oversized body → message ends with and is length-bounded) plus a historyError-null assertion on the superseded-loadMore case would lock in the delta's intent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in ea0f8a9.

  • Body-truncation test added to fetchJobs.test.ts: builds a 500-char body, asserts the error message is at most (prefix + 200 + 1 for ) chars long, and that is present.
  • historyError-null assertion added to the existing stale-rejected-continuation test in assetsStore.test.ts (the "does not let a stale rejected continuation drop the new walk cursor" case): after the superseded walk throws JobsApiError(400, INVALID_CURSOR), asserts store.historyError is still null.

Both tests fail if the catch guard is removed, which is the regression lock you asked for.

: body
super(`[Jobs API] Failed to fetch jobs: ${status} ${truncated}`.trim())
this.name = 'JobsApiError'
}
}

interface FetchJobsRawResult {
jobs: RawJobListItem[]
total: number
offset: number
limit: number
hasMore: boolean
nextCursor?: string
}

export interface FetchHistoryPageResult {
Expand All @@ -32,43 +63,39 @@ export interface FetchHistoryPageResult {
offset: number
limit: number
hasMore: boolean
nextCursor?: string
}

/**
* Fetches raw jobs from /jobs endpoint
* Fetches raw jobs from /jobs endpoint.
* Throws on failure so callers can tell a failed page apart from an empty
* last page (e.g. a stale cursor rejected with 400 INVALID_CURSOR).
* @internal
*/
async function fetchJobsRaw(
fetchApi: (url: string) => Promise<Response>,
statuses: JobStatus[],
maxItems: number = 200,
offset: number = 0
page: JobsPageRequest = {}
): Promise<FetchJobsRawResult> {
const statusParam = statuses.join(',')
const url = `/jobs?status=${statusParam}&limit=${maxItems}&offset=${offset}`
try {
const res = await fetchApi(url)
if (!res.ok) {
console.error(`[Jobs API] Failed to fetch jobs: ${res.status}`)
return {
jobs: [],
total: 0,
offset,
limit: maxItems,
hasMore: false
}
}
const data = zJobsListResponse.parse(await res.json())
return {
jobs: data.jobs,
total: data.pagination.total,
offset: data.pagination.offset,
limit: data.pagination.limit,
hasMore: data.pagination.has_more
}
} catch (error) {
console.error('[Jobs API] Error fetching jobs:', error)
return { jobs: [], total: 0, offset, limit: maxItems, hasMore: false }
const pageParam =
page.after != null
? `after=${encodeURIComponent(page.after)}`
: `offset=${page.offset ?? 0}`
const url = `/jobs?status=${statusParam}&limit=${maxItems}&${pageParam}`
const res = await fetchApi(url)
if (!res.ok) {
throw new JobsApiError(res.status, await res.text().catch(() => ''))
Comment thread
mattmillerai marked this conversation as resolved.
}
const data = zJobsListResponse.parse(await res.json())
return {
jobs: data.jobs,
total: data.pagination.total,
offset: data.pagination.offset,
limit: data.pagination.limit,
hasMore: data.pagination.has_more,
nextCursor: data.pagination.next_cursor ?? undefined
}
}

Expand Down Expand Up @@ -98,7 +125,7 @@ export async function fetchHistory(
maxItems: number = 200,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (non-blocking): fetchHistory keeps a positional offset param while its sibling fetchHistoryPage moved to { offset?, after? }. The two now have divergent, easily-confused pagination signatures. Optionally align fetchHistory to the same options object.

offset: number = 0
): Promise<JobListItem[]> {
const { jobs } = await fetchHistoryPage(fetchApi, maxItems, offset)
const { jobs } = await fetchHistoryPage(fetchApi, maxItems, { offset })
return jobs
}

Expand All @@ -108,13 +135,13 @@ export async function fetchHistory(
export async function fetchHistoryPage(
fetchApi: (url: string) => Promise<Response>,
maxItems: number = 200,
offset: number = 0
page: JobsPageRequest = {}
): Promise<FetchHistoryPageResult> {
const result = await fetchJobsRaw(
fetchApi,
['completed', 'failed', 'cancelled'],
maxItems,
offset
page
)

// History gets priority based on total count (lower than queue)
Expand All @@ -123,7 +150,8 @@ export async function fetchHistoryPage(
total: result.total,
offset: result.offset,
limit: result.limit,
hasMore: result.hasMore
hasMore: result.hasMore,
nextCursor: result.nextCursor
}
}

Expand All @@ -134,12 +162,7 @@ export async function fetchHistoryPage(
export async function fetchQueue(
fetchApi: (url: string) => Promise<Response>
): Promise<{ Running: JobListItem[]; Pending: JobListItem[] }> {
const { jobs } = await fetchJobsRaw(
fetchApi,
['in_progress', 'pending'],
200,
0
)
const { jobs } = await fetchJobsRaw(fetchApi, ['in_progress', 'pending'])

const running = jobs.filter((j) => j.status === 'in_progress')
const pending = jobs.filter((j) => j.status === 'pending')
Expand Down
3 changes: 2 additions & 1 deletion src/platform/remote/comfyui/jobs/jobTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ const zPaginationInfo = z.object({
offset: z.number(),
limit: z.number(),
total: z.number(),
has_more: z.boolean()
has_more: z.boolean(),
next_cursor: z.string().min(1).nullish()
})

export const zJobsListResponse = z.object({
Expand Down
Loading
Loading