mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 09:48:09 +00:00
Compare commits
29 Commits
split/node
...
matt/be-34
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d711e2b72 | ||
|
|
2f6b4a7ba1 | ||
|
|
4d9f89a40f | ||
|
|
8e9baaad71 | ||
|
|
9b9b458e4f | ||
|
|
c9c0083834 | ||
|
|
60fa89d6cf | ||
|
|
e4d56565c1 | ||
|
|
007a5cce8e | ||
|
|
e471b64e68 | ||
|
|
8b40f6a161 | ||
|
|
ea0f8a9040 | ||
|
|
6c3ead5b81 | ||
|
|
6f7686b952 | ||
|
|
c23814551a | ||
|
|
89fdbcd913 | ||
|
|
c207f5699d | ||
|
|
4290619af0 | ||
|
|
753b0b4c9b | ||
|
|
f318718521 | ||
|
|
69a4d78cba | ||
|
|
1f810a1373 | ||
|
|
5ef89c73e6 | ||
|
|
1fee4490d4 | ||
|
|
8d23daa33d | ||
|
|
880582ab5d | ||
|
|
e498c4ae0d | ||
|
|
4cadbb8af9 | ||
|
|
cc048464fa |
@@ -245,7 +245,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
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ async function fetchGeneratedHistoryAssets(
|
||||
const historyPage = await fetchHistoryPage(
|
||||
api.fetchApi.bind(api),
|
||||
HISTORY_MEDIA_ASSETS_PAGE_SIZE,
|
||||
requestedOffset
|
||||
{ offset: requestedOffset }
|
||||
)
|
||||
|
||||
signal?.throwIfAborted()
|
||||
|
||||
@@ -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]',
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
JobsApiError,
|
||||
extractWorkflow,
|
||||
fetchHistory,
|
||||
fetchHistoryPage,
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,23 +137,57 @@ 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')
|
||||
})
|
||||
})
|
||||
|
||||
expect(result).toEqual([])
|
||||
it('truncates oversized error bodies to 200 chars in the thrown message', async () => {
|
||||
const oversized = 'x'.repeat(500)
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve(oversized)
|
||||
})
|
||||
|
||||
const err = await fetchHistory(mockFetch).catch((e) => e)
|
||||
expect(err).toBeInstanceOf(JobsApiError)
|
||||
expect(err.message.length).toBeLessThanOrEqual(
|
||||
'[Jobs API] Failed to fetch jobs: 500 '.length + 200 + 1 // +1 for the ellipsis
|
||||
)
|
||||
expect(err.message).toContain('…')
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const result = await fetchHistoryPage(mockFetch, 200, { offset: 0 })
|
||||
|
||||
expect(result.nextCursor).toBeUndefined()
|
||||
})
|
||||
|
||||
it('parses batch containing text-only preview outputs', async () => {
|
||||
@@ -205,7 +241,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'
|
||||
@@ -218,6 +254,79 @@ 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<(url: string) => Promise<Response>>()
|
||||
.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
createMockResponse([createMockJob('job1', 'completed')], 10, {
|
||||
has_more: true,
|
||||
next_cursor: 'cursor-page-2'
|
||||
})
|
||||
),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
|
||||
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', () => {
|
||||
@@ -268,12 +377,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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)}…`
|
||||
: 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 {
|
||||
@@ -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(() => ''))
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +125,7 @@ export async function fetchHistory(
|
||||
maxItems: number = 200,
|
||||
offset: number = 0
|
||||
): Promise<JobListItem[]> {
|
||||
const { jobs } = await fetchHistoryPage(fetchApi, maxItems, offset)
|
||||
const { jobs } = await fetchHistoryPage(fetchApi, maxItems, { offset })
|
||||
return jobs
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,14 @@ import {
|
||||
} from '@/platform/assets/services/assetService'
|
||||
import type { PaginationOptions } from '@/platform/assets/services/assetService'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import {
|
||||
JobsApiError,
|
||||
fetchHistoryPage
|
||||
} from '@/platform/remote/comfyui/jobs/fetchJobs'
|
||||
import type {
|
||||
FetchHistoryPageResult,
|
||||
JobsPageRequest
|
||||
} from '@/platform/remote/comfyui/jobs/fetchJobs'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
@@ -95,6 +103,54 @@ const BATCH_SIZE = 200
|
||||
const MAX_HISTORY_ITEMS = 1000 // Maximum items to keep in memory
|
||||
const FLAT_OUTPUT_PAGE_SIZE = 200
|
||||
|
||||
/**
|
||||
* Coalesce concurrent calls to an async `run` into a single leading run plus
|
||||
* at most one trailing run. Calls arriving while a run is in flight share that
|
||||
* run; the first such call schedules exactly one trailing run to pick up any
|
||||
* state the in-flight run was dispatched too early to observe.
|
||||
*
|
||||
* The leading rejection is swallowed before scheduling the trailing run so a
|
||||
* failed run can never latch a settled-rejected promise in the trailing slot,
|
||||
* which would freeze every future call. The leading caller still observes the
|
||||
* rejection via the returned promise.
|
||||
*
|
||||
* A queued trailing run is returned before anything else so that a call landing
|
||||
* in the microtask gap after the leading run clears `inFlight` but before the
|
||||
* trailing run clears its slot coalesces into that trailing run rather than
|
||||
* starting a second leading run, preserving the at-most-one-trailing guarantee.
|
||||
*/
|
||||
export function createTrailingRefreshCoalescer(
|
||||
run: () => Promise<void>
|
||||
): () => Promise<void> {
|
||||
let inFlight: Promise<void> | null = null
|
||||
let trailing: Promise<void> | null = null
|
||||
|
||||
const invoke = (): Promise<void> => {
|
||||
if (trailing) return trailing
|
||||
if (!inFlight) {
|
||||
let started: Promise<void>
|
||||
try {
|
||||
started = run()
|
||||
} catch (error) {
|
||||
started = Promise.reject(error)
|
||||
}
|
||||
inFlight = started.finally(() => {
|
||||
inFlight = null
|
||||
})
|
||||
return inFlight
|
||||
}
|
||||
trailing = inFlight
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
trailing = null
|
||||
return invoke()
|
||||
})
|
||||
return trailing
|
||||
}
|
||||
|
||||
return invoke
|
||||
}
|
||||
|
||||
export const useAssetsStore = defineStore('assets', () => {
|
||||
const assetDownloadStore = useAssetDownloadStore()
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
@@ -114,8 +170,9 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
return deletingAssetIds.has(assetId)
|
||||
}
|
||||
|
||||
// Pagination state
|
||||
// History pagination state
|
||||
const historyOffset = ref(0)
|
||||
const historyNextCursor = ref<string | null>(null)
|
||||
const hasMoreHistory = ref(true)
|
||||
const isLoadingMore = ref(false)
|
||||
|
||||
@@ -123,6 +180,12 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
|
||||
const loadedIds = shallowReactive(new Set<string>())
|
||||
|
||||
// Ids of every raw job walked so far, including ones that map to no
|
||||
// displayable asset (failed, cancelled, preview-less). Head-refresh gap
|
||||
// detection needs the full set: a burst of non-asset jobs at the top would
|
||||
// otherwise never overlap `loadedIds` and trigger a needless full reload.
|
||||
const loadedJobIds = new Set<string>()
|
||||
|
||||
const fetchInputFiles = isCloud
|
||||
? fetchInputFilesFromCloud
|
||||
: fetchInputFilesFromAPI
|
||||
@@ -147,65 +210,139 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch history assets with pagination support
|
||||
* @param loadMore - true for pagination (append), false for initial load (replace)
|
||||
* Insert assets in sorted order (newest first), skipping already-loaded ids
|
||||
*/
|
||||
const mergeHistoryAssets = (newAssets: AssetItem[]) => {
|
||||
for (const asset of newAssets) {
|
||||
if (loadedIds.has(asset.id)) {
|
||||
continue
|
||||
}
|
||||
loadedIds.add(asset.id)
|
||||
|
||||
const assetTime = new Date(asset.created_at ?? 0).getTime()
|
||||
const insertIndex = allHistoryItems.value.findIndex(
|
||||
(item) => new Date(item.created_at ?? 0).getTime() < assetTime
|
||||
)
|
||||
|
||||
if (insertIndex === -1) {
|
||||
allHistoryItems.value.push(asset)
|
||||
} else {
|
||||
allHistoryItems.value.splice(insertIndex, 0, asset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const trimHistoryToLimit = () => {
|
||||
if (allHistoryItems.value.length <= MAX_HISTORY_ITEMS) return
|
||||
|
||||
const removed = allHistoryItems.value.slice(MAX_HISTORY_ITEMS)
|
||||
allHistoryItems.value = allHistoryItems.value.slice(0, MAX_HISTORY_ITEMS)
|
||||
removed.forEach((item) => loadedIds.delete(item.id))
|
||||
}
|
||||
|
||||
const fetchHistoryJobsPage = (page: JobsPageRequest) =>
|
||||
fetchHistoryPage(api.fetchApi.bind(api), BATCH_SIZE, page)
|
||||
|
||||
// Invalidates in-flight history fetches whenever the list is replaced, so
|
||||
// a stale continuation can't merge into (or move the cursor of) the new walk.
|
||||
let historyFetchEpoch = 0
|
||||
|
||||
// Tracks whether the walk is keyset-paginated, independent of the current
|
||||
// cursor value: once a cursor has been minted the walk stays in cursor mode
|
||||
// even after it exhausts (`historyNextCursor` back to null), so head-refresh
|
||||
// merges keep preserving scroll-loaded items instead of replacing them.
|
||||
let historyCursorMode = false
|
||||
|
||||
const isRejectedCursorError = (err: unknown): boolean =>
|
||||
err instanceof JobsApiError && err.status === 400
|
||||
|
||||
const fetchHistoryPageWithCursorRecovery = async (
|
||||
after: string | null,
|
||||
epoch: number
|
||||
): Promise<FetchHistoryPageResult> => {
|
||||
if (after == null)
|
||||
return fetchHistoryJobsPage({ offset: historyOffset.value })
|
||||
try {
|
||||
return await fetchHistoryJobsPage({ after })
|
||||
} catch (err) {
|
||||
// Drop only a rejected cursor (e.g. stale across a restart) to the
|
||||
// offset fallback; transient failures and superseded-walk
|
||||
// continuations must propagate so a valid/newer cursor isn't lost.
|
||||
if (!isRejectedCursorError(err) || epoch !== historyFetchEpoch) throw err
|
||||
console.warn('Stale history cursor rejected, resuming via offset:', err)
|
||||
historyNextCursor.value = null
|
||||
historyCursorMode = false
|
||||
historyOffset.value = 0
|
||||
allHistoryItems.value = []
|
||||
loadedIds.clear()
|
||||
loadedJobIds.clear()
|
||||
return fetchHistoryJobsPage({ offset: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one page of history assets and update reactive state.
|
||||
*
|
||||
* Pagination model: the server starts in offset mode and mints a
|
||||
* `next_cursor` on any page that has one; subsequent requests pass that
|
||||
* cursor (keyset mode). The walk upgrades automatically — offset paging is
|
||||
* only used until the first cursor is received.
|
||||
*
|
||||
* An empty page with no cursor is treated as terminal regardless of
|
||||
* `has_more`, because offset paging would refetch the same page forever.
|
||||
* A cursor that hasn't advanced (the server echoed back the value it was
|
||||
* given) is also treated as terminal to prevent an infinite dedup loop.
|
||||
*
|
||||
* @param loadMore - When `true`, appends the next page to the existing list
|
||||
* (infinite-scroll continuation). When `false` (default), resets all
|
||||
* pagination state and replaces the list with the first page.
|
||||
* @returns The current accumulated list of history asset items.
|
||||
*/
|
||||
const fetchHistoryAssets = async (loadMore = false): Promise<AssetItem[]> => {
|
||||
// Reset state for initial load
|
||||
if (!loadMore) {
|
||||
historyFetchEpoch += 1
|
||||
historyOffset.value = 0
|
||||
historyNextCursor.value = null
|
||||
historyCursorMode = false
|
||||
hasMoreHistory.value = true
|
||||
allHistoryItems.value = []
|
||||
loadedIds.clear()
|
||||
loadedJobIds.clear()
|
||||
}
|
||||
|
||||
// Fetch from server with offset
|
||||
const history = await api.getHistory(BATCH_SIZE, {
|
||||
offset: historyOffset.value
|
||||
})
|
||||
const epoch = historyFetchEpoch
|
||||
const requestedAfter = loadMore ? historyNextCursor.value : null
|
||||
const page = await fetchHistoryPageWithCursorRecovery(requestedAfter, epoch)
|
||||
if (epoch !== historyFetchEpoch) return allHistoryItems.value
|
||||
|
||||
// Convert JobListItems to AssetItems
|
||||
const newAssets = mapHistoryToAssets(history)
|
||||
page.jobs.forEach((job) => loadedJobIds.add(job.id))
|
||||
const newAssets = mapHistoryToAssets(page.jobs)
|
||||
|
||||
if (loadMore) {
|
||||
// Filter out duplicates and insert in sorted order
|
||||
for (const asset of newAssets) {
|
||||
if (loadedIds.has(asset.id)) {
|
||||
continue // Skip duplicates
|
||||
}
|
||||
loadedIds.add(asset.id)
|
||||
|
||||
// Find insertion index to maintain sorted order (newest first)
|
||||
const assetTime = new Date(asset.created_at ?? 0).getTime()
|
||||
const insertIndex = allHistoryItems.value.findIndex(
|
||||
(item) => new Date(item.created_at ?? 0).getTime() < assetTime
|
||||
)
|
||||
|
||||
if (insertIndex === -1) {
|
||||
// Asset is oldest, append to end
|
||||
allHistoryItems.value.push(asset)
|
||||
} else {
|
||||
// Insert at the correct position
|
||||
allHistoryItems.value.splice(insertIndex, 0, asset)
|
||||
}
|
||||
}
|
||||
mergeHistoryAssets(newAssets)
|
||||
} else {
|
||||
// Initial load: replace all
|
||||
allHistoryItems.value = newAssets
|
||||
newAssets.forEach((asset) => loadedIds.add(asset.id))
|
||||
}
|
||||
|
||||
// Update pagination state
|
||||
historyOffset.value += BATCH_SIZE
|
||||
hasMoreHistory.value = history.length === BATCH_SIZE
|
||||
const cursorStuck =
|
||||
page.nextCursor != null && page.nextCursor === requestedAfter
|
||||
if (page.nextCursor != null) historyCursorMode = true
|
||||
// The server ignores `offset` once the walk is keyset-paginated, so only
|
||||
// advance it while still in offset mode; otherwise the offset used by the
|
||||
// recovery fallback would drift past valid rows.
|
||||
if (!historyCursorMode) historyOffset.value += page.jobs.length
|
||||
hasMoreHistory.value =
|
||||
page.hasMore &&
|
||||
!cursorStuck &&
|
||||
(page.jobs.length > 0 || page.nextCursor != null)
|
||||
// Drop the cursor once paging terminates so state never carries a live
|
||||
// cursor alongside `hasMoreHistory === false`.
|
||||
historyNextCursor.value = hasMoreHistory.value
|
||||
? (page.nextCursor ?? null)
|
||||
: null
|
||||
|
||||
if (allHistoryItems.value.length > MAX_HISTORY_ITEMS) {
|
||||
const removed = allHistoryItems.value.slice(MAX_HISTORY_ITEMS)
|
||||
allHistoryItems.value = allHistoryItems.value.slice(0, MAX_HISTORY_ITEMS)
|
||||
|
||||
// Clean up Set
|
||||
removed.forEach((item) => loadedIds.delete(item.id))
|
||||
}
|
||||
trimHistoryToLimit()
|
||||
|
||||
return allHistoryItems.value
|
||||
}
|
||||
@@ -245,13 +382,15 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
isLoadingMore.value = true
|
||||
historyError.value = null
|
||||
|
||||
const epoch = historyFetchEpoch
|
||||
try {
|
||||
await fetchHistoryAssets(true)
|
||||
if (epoch !== historyFetchEpoch) return
|
||||
historyAssets.value = allHistoryItems.value
|
||||
} catch (err) {
|
||||
if (epoch !== historyFetchEpoch) return
|
||||
console.error('Error loading more history:', err)
|
||||
historyError.value = err
|
||||
// Keep existing data when error occurs (consistent with updateHistory)
|
||||
if (!historyAssets.value.length) {
|
||||
historyAssets.value = []
|
||||
}
|
||||
@@ -260,6 +399,86 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A head page with no further rows spans the whole timeline, so replacing
|
||||
* local state with it also prunes jobs deleted server-side (e.g. after the
|
||||
* queue history is cleared from another surface).
|
||||
*
|
||||
* Bumps `historyFetchEpoch`, which cancels any concurrent
|
||||
* `loadMoreHistory`/`fetchHistoryAssets` continuation.
|
||||
*/
|
||||
const replaceHistoryWithHeadPage = (page: FetchHistoryPageResult) => {
|
||||
historyFetchEpoch += 1
|
||||
const newAssets = mapHistoryToAssets(page.jobs)
|
||||
allHistoryItems.value = newAssets
|
||||
loadedIds.clear()
|
||||
newAssets.forEach((asset) => loadedIds.add(asset.id))
|
||||
loadedJobIds.clear()
|
||||
page.jobs.forEach((job) => loadedJobIds.add(job.id))
|
||||
historyOffset.value = page.jobs.length
|
||||
historyNextCursor.value = page.nextCursor ?? null
|
||||
historyCursorMode = page.nextCursor != null
|
||||
hasMoreHistory.value = page.hasMore
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge newly completed jobs into the top of the list without resetting
|
||||
* pagination state, so items loaded via infinite scroll survive the refresh.
|
||||
* Cursors only walk toward older items, so new completions are picked up by
|
||||
* re-fetching the head page and deduplicating. Bursts of status events share
|
||||
* the in-flight refresh, and a call arriving mid-flight schedules exactly one
|
||||
* trailing refresh — the shared response was dispatched before that caller's
|
||||
* event, so it could miss the very completion the caller is reacting to.
|
||||
*/
|
||||
const refreshHistoryHead = createTrailingRefreshCoalescer(() =>
|
||||
doRefreshHistoryHead()
|
||||
)
|
||||
|
||||
const doRefreshHistoryHead = async () => {
|
||||
historyError.value = null
|
||||
if (!allHistoryItems.value.length) {
|
||||
await updateHistory()
|
||||
return
|
||||
}
|
||||
|
||||
let epoch = historyFetchEpoch
|
||||
try {
|
||||
const page = await fetchHistoryJobsPage({ offset: 0 })
|
||||
if (epoch !== historyFetchEpoch) return
|
||||
|
||||
const reachesLoadedItems = page.jobs.some((job) =>
|
||||
loadedJobIds.has(job.id)
|
||||
)
|
||||
if (page.hasMore && !reachesLoadedItems) {
|
||||
await updateHistory()
|
||||
return
|
||||
}
|
||||
|
||||
// Merging only preserves scroll-loaded items safely in cursor mode,
|
||||
// including once the cursor has exhausted (historyNextCursor is null but
|
||||
// the loaded terminal pages must survive). In offset fallback mode,
|
||||
// prepending new head rows without advancing historyOffset would drift
|
||||
// the next offset request (the server timeline shifted down by the new
|
||||
// completions), so rebuild from the head page — which resets
|
||||
// historyOffset to a position consistent with that page.
|
||||
if (page.hasMore && historyCursorMode) {
|
||||
page.jobs.forEach((job) => loadedJobIds.add(job.id))
|
||||
mergeHistoryAssets(mapHistoryToAssets(page.jobs))
|
||||
trimHistoryToLimit()
|
||||
} else {
|
||||
replaceHistoryWithHeadPage(page)
|
||||
// replaceHistoryWithHeadPage bumps the epoch; re-sync so the catch
|
||||
// guard below suppresses stale continuations, not genuine errors.
|
||||
epoch = historyFetchEpoch
|
||||
}
|
||||
historyAssets.value = allHistoryItems.value
|
||||
} catch (err) {
|
||||
if (epoch !== historyFetchEpoch) return
|
||||
console.error('Error refreshing history:', err)
|
||||
historyError.value = err
|
||||
}
|
||||
}
|
||||
|
||||
const flatOutputAssets = ref<AssetItem[]>([])
|
||||
const flatOutputLoading = ref(false)
|
||||
const flatOutputError = ref<unknown>(null)
|
||||
@@ -884,6 +1103,7 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
updateInputs,
|
||||
updateHistory,
|
||||
loadMoreHistory,
|
||||
refreshHistoryHead,
|
||||
setAssetPreview,
|
||||
|
||||
// Flat output assets (cloud-only, tag-based)
|
||||
|
||||
@@ -100,7 +100,10 @@ vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ isBuilderMode: ref(false) })
|
||||
}))
|
||||
vi.mock('@/stores/assetsStore', () => ({
|
||||
useAssetsStore: () => ({ updateHistory: vi.fn() })
|
||||
useAssetsStore: () => ({
|
||||
updateHistory: vi.fn(),
|
||||
refreshHistoryHead: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ registerCommands: vi.fn() })
|
||||
|
||||
@@ -238,7 +238,7 @@ const onStatus = async (e: CustomEvent<StatusWsMessageStatus>) => {
|
||||
// Only update assets if the assets sidebar is currently open
|
||||
// When sidebar is closed, AssetsSidebarTab.vue will refresh on mount
|
||||
if (sidebarTabStore.activeSidebarTabId === 'assets' || linearMode.value) {
|
||||
await assetsStore.updateHistory()
|
||||
await assetsStore.refreshHistoryHead()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ const onExecutionSuccess = async () => {
|
||||
// Only update assets if the assets sidebar is currently open
|
||||
// When sidebar is closed, AssetsSidebarTab.vue will refresh on mount
|
||||
if (sidebarTabStore.activeSidebarTabId === 'assets' || linearMode.value) {
|
||||
await assetsStore.updateHistory()
|
||||
await assetsStore.refreshHistoryHead()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user