diff --git a/invokeai/frontend/web/src/features/queue/components/CancelAllExceptCurrentButton.tsx b/invokeai/frontend/web/src/features/queue/components/CancelAllExceptCurrentButton.tsx
new file mode 100644
index 0000000000..e804176511
--- /dev/null
+++ b/invokeai/frontend/web/src/features/queue/components/CancelAllExceptCurrentButton.tsx
@@ -0,0 +1,28 @@
+import type { ButtonProps } from '@invoke-ai/ui-library';
+import { Button } from '@invoke-ai/ui-library';
+import { useCancelAllExceptCurrentQueueItemDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog';
+import { memo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiXCircle } from 'react-icons/pi';
+
+export const CancelAllExceptCurrentButton = memo((props: ButtonProps) => {
+ const { t } = useTranslation();
+ const api = useCancelAllExceptCurrentQueueItemDialog();
+
+ return (
+ }
+ colorScheme="error"
+ onClick={api.openDialog}
+ {...props}
+ >
+ {t('queue.clear')}
+
+ );
+});
+
+CancelAllExceptCurrentButton.displayName = 'CancelAllExceptCurrentButton';
diff --git a/invokeai/frontend/web/src/features/queue/components/DeleteAllExceptCurrentButton.tsx b/invokeai/frontend/web/src/features/queue/components/DeleteAllExceptCurrentButton.tsx
deleted file mode 100644
index a3ffd7d519..0000000000
--- a/invokeai/frontend/web/src/features/queue/components/DeleteAllExceptCurrentButton.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import type { ButtonProps } from '@invoke-ai/ui-library';
-import { Button } from '@invoke-ai/ui-library';
-import { useDeleteAllExceptCurrentQueueItemDialog } from 'features/queue/components/DeleteAllExceptCurrentQueueItemConfirmationAlertDialog';
-import { memo } from 'react';
-import { useTranslation } from 'react-i18next';
-import { PiXCircle } from 'react-icons/pi';
-
-type Props = ButtonProps;
-
-export const DeleteAllExceptCurrentButton = memo((props: Props) => {
- const { t } = useTranslation();
- const deleteAllExceptCurrent = useDeleteAllExceptCurrentQueueItemDialog();
-
- return (
- <>
- }
- colorScheme="error"
- data-testid={t('queue.clear')}
- {...props}
- >
- {t('queue.clear')}
-
- >
- );
-});
-
-DeleteAllExceptCurrentButton.displayName = 'DeleteAllExceptCurrentButton';
diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx
index 1f6cea985a..3f39c5e8be 100644
--- a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx
+++ b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx
@@ -3,7 +3,7 @@ import { Badge, ButtonGroup, Collapse, Flex, IconButton, Text } from '@invoke-ai
import QueueStatusBadge from 'features/queue/components/common/QueueStatusBadge';
import { useDestinationText } from 'features/queue/components/QueueList/useDestinationText';
import { useOriginText } from 'features/queue/components/QueueList/useOriginText';
-import { useDeleteQueueItem } from 'features/queue/hooks/useDeleteQueueItem';
+import { useCancelQueueItem } from 'features/queue/hooks/useCancelQueueItem';
import { useRetryQueueItem } from 'features/queue/hooks/useRetryQueueItem';
import { getSecondsFromTimestamps } from 'features/queue/util/getSecondsFromTimestamps';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
@@ -38,13 +38,13 @@ const QueueItemComponent = ({ index, item, context }: InnerItemProps) => {
const handleToggle = useCallback(() => {
context.toggleQueueItem(item.item_id);
}, [context, item.item_id]);
- const deleteQueueItem = useDeleteQueueItem();
- const onClickDeleteQueueItem = useCallback(
+ const cancelQueueItem = useCancelQueueItem();
+ const onClickCancelQueueItem = useCallback(
(e: MouseEvent) => {
e.stopPropagation();
- deleteQueueItem.trigger(item.item_id);
+ cancelQueueItem.trigger(item.item_id);
},
- [deleteQueueItem, item.item_id]
+ [cancelQueueItem, item.item_id]
);
const retryQueueItem = useRetryQueueItem();
const onClickRetryQueueItem = useCallback(
@@ -135,9 +135,9 @@ const QueueItemComponent = ({ index, item, context }: InnerItemProps) => {
{(!isFailed || !isRetryEnabled || isValidationRun) && (
}
/>
diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemDetail.tsx b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemDetail.tsx
index c8a8d6992e..329fe1d4eb 100644
--- a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemDetail.tsx
+++ b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemDetail.tsx
@@ -5,7 +5,7 @@ import { useDestinationText } from 'features/queue/components/QueueList/useDesti
import { useOriginText } from 'features/queue/components/QueueList/useOriginText';
import { useBatchIsCanceled } from 'features/queue/hooks/useBatchIsCanceled';
import { useCancelBatch } from 'features/queue/hooks/useCancelBatch';
-import { useDeleteQueueItem } from 'features/queue/hooks/useDeleteQueueItem';
+import { useCancelQueueItem } from 'features/queue/hooks/useCancelQueueItem';
import { useRetryQueueItem } from 'features/queue/hooks/useRetryQueueItem';
import { getSecondsFromTimestamps } from 'features/queue/util/getSecondsFromTimestamps';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
@@ -13,20 +13,22 @@ import type { ReactNode } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold, PiXBold } from 'react-icons/pi';
+import { useGetQueueItemQuery } from 'services/api/endpoints/queue';
import type { S } from 'services/api/types';
type Props = {
queueItem: S['SessionQueueItem'];
};
-const QueueItemComponent = ({ queueItem }: Props) => {
- const { session_id, batch_id, item_id, origin, destination } = queueItem;
+const QueueItemComponent = ({ queueItem: queueItemDTO }: Props) => {
+ const { session_id, batch_id, item_id, origin, destination } = queueItemDTO;
const { t } = useTranslation();
const isRetryEnabled = useFeatureStatus('retryQueueItem');
const isBatchCanceled = useBatchIsCanceled(batch_id);
const cancelBatch = useCancelBatch();
- const deleteQueueItem = useDeleteQueueItem();
+ const cancelQueueItem = useCancelQueueItem();
const retryQueueItem = useRetryQueueItem();
+ const { data: queueItem } = useGetQueueItemQuery(item_id);
const originText = useOriginText(origin);
const destinationText = useDestinationText(destination);
@@ -57,8 +59,8 @@ const QueueItemComponent = ({ queueItem }: Props) => {
}, [cancelBatch, batch_id]);
const onCancelQueueItem = useCallback(() => {
- deleteQueueItem.trigger(item_id);
- }, [deleteQueueItem, item_id]);
+ cancelQueueItem.trigger(item_id);
+ }, [cancelQueueItem, item_id]);
const onRetryQueueItem = useCallback(() => {
retryQueueItem.trigger(item_id);
@@ -85,8 +87,8 @@ const QueueItemComponent = ({ queueItem }: Props) => {
{(!isFailed || !isRetryEnabled) && (
}
colorScheme="error"
diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueList.tsx b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueList.tsx
index e812324335..06137aa409 100644
--- a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueList.tsx
+++ b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueList.tsx
@@ -13,7 +13,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { Components, ItemContent } from 'react-virtuoso';
import { Virtuoso } from 'react-virtuoso';
-import { useListQueueItemsQuery } from 'services/api/endpoints/queue';
+import { queueItemsAdapterSelectors, useListQueueItemsQuery } from 'services/api/endpoints/queue';
import type { S } from 'services/api/types';
import QueueItemComponent from './QueueItemComponent';
@@ -70,7 +70,7 @@ const QueueList = () => {
if (!listQueueItemsData) {
return [];
}
- return listQueueItemsData.items;
+ return queueItemsAdapterSelectors.selectAll(listQueueItemsData);
}, [listQueueItemsData]);
const handleLoadMore = useCallback(() => {
diff --git a/invokeai/frontend/web/src/features/queue/components/QueueTabQueueControls.tsx b/invokeai/frontend/web/src/features/queue/components/QueueTabQueueControls.tsx
index 303687007e..849ad59a6d 100644
--- a/invokeai/frontend/web/src/features/queue/components/QueueTabQueueControls.tsx
+++ b/invokeai/frontend/web/src/features/queue/components/QueueTabQueueControls.tsx
@@ -1,8 +1,8 @@
import { ButtonGroup, Flex } from '@invoke-ai/ui-library';
-import { DeleteAllExceptCurrentButton } from 'features/queue/components/DeleteAllExceptCurrentButton';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo } from 'react';
+import { CancelAllExceptCurrentButton } from './CancelAllExceptCurrentButton';
import ClearModelCacheButton from './ClearModelCacheButton';
import PauseProcessorButton from './PauseProcessorButton';
import PruneQueueButton from './PruneQueueButton';
@@ -23,7 +23,7 @@ const QueueTabQueueControls = () => {
)}
-
+
diff --git a/invokeai/frontend/web/src/features/queue/store/queueSlice.ts b/invokeai/frontend/web/src/features/queue/store/queueSlice.ts
index cb815bb210..9f0225149d 100644
--- a/invokeai/frontend/web/src/features/queue/store/queueSlice.ts
+++ b/invokeai/frontend/web/src/features/queue/store/queueSlice.ts
@@ -33,7 +33,7 @@ export const queueSlice = createSlice({
},
});
-export const { listCursorChanged, listPriorityChanged } = queueSlice.actions;
+export const { listCursorChanged, listPriorityChanged, listParamsReset } = queueSlice.actions;
const selectQueueSlice = (state: RootState) => state.queue;
const createQueueSelector = (selector: Selector) => createSelector(selectQueueSlice, selector);
diff --git a/invokeai/frontend/web/src/services/api/endpoints/queue.ts b/invokeai/frontend/web/src/services/api/endpoints/queue.ts
index d5630acef1..671ddb8390 100644
--- a/invokeai/frontend/web/src/services/api/endpoints/queue.ts
+++ b/invokeai/frontend/web/src/services/api/endpoints/queue.ts
@@ -1,4 +1,8 @@
+import type { EntityState, ThunkDispatch, UnknownAction } from '@reduxjs/toolkit';
+import { createEntityAdapter } from '@reduxjs/toolkit';
+import { getSelectorsOptions } from 'app/store/createMemoizedSelector';
import { $queueId } from 'app/store/nanostores/queueId';
+import { listParamsReset } from 'features/queue/store/queueSlice';
import queryString from 'query-string';
import type { components, paths } from 'services/api/schema';
@@ -31,6 +35,30 @@ export type SessionQueueItemStatus = NonNullable<
NonNullable['status']
>;
+export const queueItemsAdapter = createEntityAdapter({
+ selectId: (queueItem) => String(queueItem.item_id),
+ sortComparer: (a, b) => {
+ // Sort by priority in descending order
+ if (a.priority > b.priority) {
+ return -1;
+ }
+ if (a.priority < b.priority) {
+ return 1;
+ }
+
+ // If priority is the same, sort by id in ascending order
+ if (a.item_id < b.item_id) {
+ return -1;
+ }
+ if (a.item_id > b.item_id) {
+ return 1;
+ }
+
+ return 0;
+ },
+});
+export const queueItemsAdapterSelectors = queueItemsAdapter.getSelectors(undefined, getSelectorsOptions);
+
export const queueApi = api.injectEndpoints({
endpoints: (build) => ({
enqueueBatch: build.mutation<
@@ -50,6 +78,57 @@ export const queueApi = api.injectEndpoints({
{ type: 'SessionQueueItem', id: LIST_TAG },
{ type: 'SessionQueueItem', id: LIST_ALL_TAG },
],
+ onQueryStarted: async (arg, api) => {
+ const { dispatch, queryFulfilled } = api;
+ try {
+ const { data } = await queryFulfilled;
+ resetListQueryData(dispatch);
+ /**
+ * When a batch is enqueued, we need to update the queue status. While it might be templting to invalidate the
+ * `SessionQueueStatus` tag here, this can introduce a race condition when the queue item executes quickly:
+ *
+ * - Enqueue via this query
+ * - On success, we invalidate `SessionQueueStatus` tag - network request sent to server
+ * - The server gets the queue status request and responds, but this takes some time... in the meantime:
+ * - The new queue item starts executing, and we receive a socket queue item status changed event
+ * - We optimistically update the queue status in the queue item status changed socket handler
+ * - At this point, the queue status is correct
+ * - Finally, we get the queue status from the tag invalidation request - but it's reporting the queue status
+ * from _before_ the last queue event
+ * - The queue status is now incorrect!
+ *
+ * Ok, what if we just never did optimistic updates and invalidated the tag in the queue event handlers instead?
+ * It's much simpler that way, but it causes a lot of network requests - 3 per queue item, as it moves from
+ * pending -> in_progress -> completed/failed/canceled.
+ *
+ * We can do a bit of extra work here, incrementing the pending and total counts in the queue status, and do
+ * similar optimistic updates in the socket handler. Because this optimistic update runs immediately after the
+ * enqueue network request, it should always occur _before_ the next queue event, so no race condition:
+ *
+ * - Enqueue batch via this query
+ * - On success, optimistically update - this happens immediately on the HTTP OK - before the next queue event
+ * - At this point, the queue status is correct
+ * - A queue item status changes and we receive a socket event w/ updated status
+ * - Update status optimistically in socket handler
+ * - Queue status is still correct
+ *
+ * This problem occurs most commonly with canvas filters like Canny edge detection, which are single-node
+ * graphs that execute very quickly. Image generation graphs take long enough to not trigger this race
+ * condition - even when all nodes are cached on the server.
+ */
+ dispatch(
+ queueApi.util.updateQueryData('getQueueStatus', undefined, (draft) => {
+ if (!draft) {
+ return;
+ }
+ draft.queue.pending += data.enqueued;
+ draft.queue.total += data.enqueued;
+ })
+ );
+ } catch {
+ // no-op
+ }
+ },
}),
resumeProcessor: build.mutation<
paths['/api/v1/queue/{queue_id}/processor/resume']['put']['responses']['200']['content']['application/json'],
@@ -85,6 +164,15 @@ export const queueApi = api.injectEndpoints({
{ type: 'SessionQueueItem', id: LIST_TAG },
{ type: 'SessionQueueItem', id: LIST_ALL_TAG },
],
+ onQueryStarted: async (arg, api) => {
+ const { dispatch, queryFulfilled } = api;
+ try {
+ await queryFulfilled;
+ resetListQueryData(dispatch);
+ } catch {
+ // no-op
+ }
+ },
}),
clearQueue: build.mutation<
paths['/api/v1/queue/{queue_id}/clear']['put']['responses']['200']['content']['application/json'],
@@ -104,6 +192,15 @@ export const queueApi = api.injectEndpoints({
{ type: 'SessionQueueItem', id: LIST_TAG },
{ type: 'SessionQueueItem', id: LIST_ALL_TAG },
],
+ onQueryStarted: async (arg, api) => {
+ const { dispatch, queryFulfilled } = api;
+ try {
+ await queryFulfilled;
+ resetListQueryData(dispatch);
+ } catch {
+ // no-op
+ }
+ },
}),
getCurrentQueueItem: build.query<
paths['/api/v1/queue/{queue_id}/current']['get']['responses']['200']['content']['application/json'],
@@ -187,6 +284,25 @@ export const queueApi = api.injectEndpoints({
url: buildQueueUrl(`i/${item_id}/cancel`),
method: 'PUT',
}),
+ onQueryStarted: async (item_id, { dispatch, queryFulfilled }) => {
+ try {
+ const { data } = await queryFulfilled;
+ dispatch(
+ queueApi.util.updateQueryData('listQueueItems', undefined, (draft) => {
+ queueItemsAdapter.updateOne(draft, {
+ id: String(item_id),
+ changes: {
+ status: data.status,
+ completed_at: data.completed_at,
+ updated_at: data.updated_at,
+ },
+ });
+ })
+ );
+ } catch {
+ // no-op
+ }
+ },
invalidatesTags: (result) => {
if (!result) {
return [];
@@ -210,6 +326,15 @@ export const queueApi = api.injectEndpoints({
method: 'PUT',
body,
}),
+ onQueryStarted: async (arg, api) => {
+ const { dispatch, queryFulfilled } = api;
+ try {
+ await queryFulfilled;
+ resetListQueryData(dispatch);
+ } catch {
+ // no-op
+ }
+ },
invalidatesTags: (result, error, { batch_ids }) => {
if (!result) {
return [];
@@ -256,16 +381,6 @@ export const queueApi = api.injectEndpoints({
}),
invalidatesTags: ['SessionQueueStatus', 'BatchStatus', 'QueueCountsByDestination', 'SessionQueueItem'],
}),
- deleteAllExceptCurrent: build.mutation<
- paths['/api/v1/queue/{queue_id}/delete_all_except_current']['put']['responses']['200']['content']['application/json'],
- void
- >({
- query: () => ({
- url: buildQueueUrl('delete_all_except_current'),
- method: 'PUT',
- }),
- invalidatesTags: ['SessionQueueStatus', 'BatchStatus', 'QueueCountsByDestination', 'SessionQueueItem'],
- }),
retryItemsById: build.mutation<
paths['/api/v1/queue/{queue_id}/retry_items_by_id']['put']['responses']['200']['content']['application/json'],
paths['/api/v1/queue/{queue_id}/retry_items_by_id']['put']['requestBody']['content']['application/json']
@@ -275,6 +390,15 @@ export const queueApi = api.injectEndpoints({
method: 'PUT',
body,
}),
+ onQueryStarted: async (arg, api) => {
+ const { dispatch, queryFulfilled } = api;
+ try {
+ await queryFulfilled;
+ resetListQueryData(dispatch);
+ } catch {
+ // no-op
+ }
+ },
invalidatesTags: (result, error, item_ids) => {
if (!result) {
return [];
@@ -290,24 +414,31 @@ export const queueApi = api.injectEndpoints({
},
}),
listQueueItems: build.query<
- components['schemas']['CursorPaginatedResults_SessionQueueItem_'],
- { cursor?: number; priority?: number; destination?: string } | undefined
+ EntityState & {
+ has_more: boolean;
+ },
+ { cursor?: number; priority?: number } | undefined
>({
query: (queryArgs) => ({
url: getListQueueItemsUrl(queryArgs),
method: 'GET',
}),
- keepUnusedDataFor: 60 * 5, // 5 minutes
- providesTags: (result, _error, _args) => {
- if (!result) {
- return [];
- }
- return [
- 'FetchOnReconnect',
- { type: 'SessionQueueItem', id: LIST_TAG },
- ...result.items.map(({ item_id }) => ({ type: 'SessionQueueItem', id: item_id }) satisfies ApiTagDescription),
- ];
+ serializeQueryArgs: () => {
+ return buildQueueUrl('list');
},
+ transformResponse: (response: components['schemas']['CursorPaginatedResults_SessionQueueItem_']) =>
+ queueItemsAdapter.addMany(
+ queueItemsAdapter.getInitialState({
+ has_more: response.has_more,
+ }),
+ response.items
+ ),
+ merge: (cache, response) => {
+ queueItemsAdapter.addMany(cache, queueItemsAdapterSelectors.selectAll(response));
+ cache.has_more = response.has_more;
+ },
+ forceRefetch: ({ currentArg, previousArg }) => currentArg !== previousArg,
+ keepUnusedDataFor: 60 * 5, // 5 minutes
}),
listAllQueueItems: build.query<
paths['/api/v1/queue/{queue_id}/list_all']['get']['responses']['200']['content']['application/json'],
@@ -356,6 +487,16 @@ export const queueApi = api.injectEndpoints({
{ type: 'SessionQueueItem', id: LIST_ALL_TAG },
],
}),
+ deleteAllExceptCurrent: build.mutation<
+ paths['/api/v1/queue/{queue_id}/delete_all_except_current']['put']['responses']['200']['content']['application/json'],
+ void
+ >({
+ query: () => ({
+ url: buildQueueUrl('delete_all_except_current'),
+ method: 'PUT',
+ }),
+ invalidatesTags: ['SessionQueueStatus', 'BatchStatus', 'QueueCountsByDestination', 'SessionQueueItem'],
+ }),
getQueueCountsByDestination: build.query<
paths['/api/v1/queue/{queue_id}/counts_by_destination']['get']['responses']['200']['content']['application/json'],
paths['/api/v1/queue/{queue_id}/counts_by_destination']['get']['parameters']['query']
@@ -378,6 +519,7 @@ export const {
useClearQueueMutation,
usePruneQueueMutation,
useGetQueueStatusQuery,
+ useGetQueueItemQuery,
useListQueueItemsQuery,
useCancelQueueItemMutation,
useCancelQueueItemsByDestinationMutation,
@@ -392,6 +534,24 @@ export const {
export const selectQueueStatus = queueApi.endpoints.getQueueStatus.select();
+const resetListQueryData = (
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ dispatch: ThunkDispatch
+) => {
+ dispatch(
+ queueApi.util.updateQueryData('listQueueItems', undefined, (draft) => {
+ // remove all items from the list
+ queueItemsAdapter.removeAll(draft);
+ // reset the has_more flag
+ draft.has_more = false;
+ })
+ );
+ // set the list cursor and priority to undefined
+ dispatch(listParamsReset());
+ // we have to manually kick off another query to get the first page and re-initialize the list
+ dispatch(queueApi.endpoints.listQueueItems.initiate(undefined));
+};
+
export const enqueueMutationFixedCacheKeyOptions = {
fixedCacheKey: 'enqueueBatch',
} as const;
diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.tsx b/invokeai/frontend/web/src/services/events/setEventListeners.tsx
index 96a24b6b47..b3d599fcc8 100644
--- a/invokeai/frontend/web/src/services/events/setEventListeners.tsx
+++ b/invokeai/frontend/web/src/services/events/setEventListeners.tsx
@@ -22,7 +22,7 @@ import { t } from 'i18next';
import type { ApiTagDescription } from 'services/api';
import { api, LIST_ALL_TAG, LIST_TAG } from 'services/api';
import { modelsApi } from 'services/api/endpoints/models';
-import { queueApi } from 'services/api/endpoints/queue';
+import { queueApi, queueItemsAdapter } from 'services/api/endpoints/queue';
import { workflowsApi } from 'services/api/endpoints/workflows';
import { buildOnInvocationComplete } from 'services/events/onInvocationComplete';
import { buildOnModelInstallError } from 'services/events/onModelInstallError';
@@ -343,10 +343,42 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis
socket.on('queue_item_status_changed', (data) => {
// we've got new status for the queue item, batch and queue
- const { item_id, session_id, status, batch_status, error_type, error_message, destination } = data;
+ const {
+ item_id,
+ session_id,
+ status,
+ batch_status,
+ error_type,
+ error_message,
+ destination,
+ started_at,
+ updated_at,
+ completed_at,
+ error_traceback,
+ credits,
+ } = data;
log.debug({ data }, `Queue item ${item_id} status updated: ${status}`);
+ // // Update this specific queue item in the list of queue items
+ dispatch(
+ queueApi.util.updateQueryData('listQueueItems', undefined, (draft) => {
+ queueItemsAdapter.updateOne(draft, {
+ id: String(item_id),
+ changes: {
+ status,
+ started_at,
+ updated_at: updated_at ?? undefined,
+ completed_at: completed_at ?? undefined,
+ error_type,
+ error_message,
+ error_traceback,
+ credits,
+ },
+ });
+ })
+ );
+
// Invalidate caches for things we cannot easily update
const tagsToInvalidate: ApiTagDescription[] = [
'SessionQueueStatus',