Compare commits

...

3 Commits

Author SHA1 Message Date
Jedrzej Kosinski
0fb4d3bd34 fix: address review feedback on Manager offline/security error handling
- Scope backend-message preference to 403 so curated route/404 strings stay
  authoritative (was overriding every status); keep the security goal intact.
- Handle timeout/offline (no-response) errors explicitly so they no longer
  render "failed with status undefined".
- Check isTabLoading before short-circuiting on searchError in ManagerDialog so
  a stale search error can't hide the tab loading skeleton.
- Use a generous shared timeout ceiling for long sync POSTs and a short
  per-request timeout for reads/polls.
- Model the empty-state error as a discriminated value so a non-retryable
  manager error no longer shows a search retry.
- Treat only status_str === 'error' as a failed task ('skip' is not a failure).
- Extract a shared createApiClient factory (JSON header + timeout), fixing the
  latent missing timeout on customerApiClient.
- Add unit tests (timeout, route-string precedence, skipped task) and Playwright
  regression coverage for the Manager offline/retry flow.

Amp-Thread-ID: https://ampcode.com/threads/T-019efced-7233-707c-bccf-7a4218671fdd
Co-authored-by: Amp <amp@ampcode.com>
2026-06-25 18:08:50 -07:00
Jedrzej Kosinski
c7ccafc75b fix: surface failed manager install reasons in the progress toast
When an install is rejected (e.g. ComfyUI-Manager blocks it due to the
security_level/--listen restriction), the reason is captured in task
history but the progress toast only rendered the streamed server logs --
which stay empty for a request rejected before the task runs. The failed
task also misleadingly showed 'Completed'.

- Expose isTaskFailed and getTaskErrorMessages from the manager store
- Render a task's error messages in its failed panel
- Label failed tasks 'Failed' (in danger color) instead of 'Completed'

Amp-Thread-ID: https://ampcode.com/threads/T-019eafaf-ac38-734b-8fa1-1422ed378e78
Co-authored-by: Amp <amp@ampcode.com>
2026-06-09 22:30:45 -07:00
Jedrzej Kosinski
d349677767 fix: prevent infinite manager loading offline and surface backend security messages
- Wrap registry search in try/catch/finally so a failed/offline search clears the loading spinner instead of hanging forever, and expose error + retry
- Add request timeouts to the registry and manager axios clients so hung sockets reject
- Surface ComfyUI-Manager's actionable backend error message (e.g. required security_level/--listen) instead of a generic 403 fallback
- Show a retryable connection-error empty state in the manager dialog

Amp-Thread-ID: https://ampcode.com/threads/T-019eafaf-ac38-734b-8fa1-1422ed378e78
Co-authored-by: Amp <amp@ampcode.com>
2026-06-09 22:11:28 -07:00
13 changed files with 480 additions and 69 deletions

View File

@@ -1,3 +1,4 @@
import type { Route } from '@playwright/test'
import { expect } from '@playwright/test'
import type { AlgoliaNodePack } from '@/types/algoliaTypes'
@@ -428,4 +429,73 @@ test.describe('ManagerDialog', { tag: '@ui' }, () => {
await expect(nodesOption).toBeVisible()
await nodesOption.click()
})
test('Offline search shows a retryable connection error, not an endless spinner', async ({
comfyPage
}) => {
const failSearch = async (route: Route) => route.abort()
await comfyPage.page.route('**/*.algolia.net/**', failSearch)
await comfyPage.page.route('**/*.algolianet.com/**', failSearch)
await comfyPage.page.route('**/api.comfy.org/nodes/search**', failSearch)
await comfyPage.page.route(
(url) => url.hostname === 'api.comfy.org' && url.pathname === '/nodes',
failSearch
)
await openManagerDialog(comfyPage)
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
// The error placeholder only renders once isLoading is false, so its
// visibility proves the spinner did not run forever.
await expect(
dialog.getByText('Error connecting to the Comfy Node Registry.')
).toBeVisible()
await expect(
dialog.getByRole('button', { name: 'Try Again' })
).toBeVisible()
})
test('Retrying after the registry recovers loads results', async ({
comfyPage
}) => {
let online = false
const registryListResponse = {
total: 3,
nodes: [MOCK_PACK_A, MOCK_PACK_B, MOCK_PACK_C],
page: 1,
limit: 64,
totalPages: 1
}
const algoliaRoute = async (route: Route) => {
if (online) await route.fulfill({ json: MOCK_ALGOLIA_RESPONSE })
else await route.abort()
}
const registryRoute = async (route: Route) => {
if (online) await route.fulfill({ json: registryListResponse })
else await route.abort()
}
await comfyPage.page.route('**/*.algolia.net/**', algoliaRoute)
await comfyPage.page.route('**/*.algolianet.com/**', algoliaRoute)
await comfyPage.page.route('**/api.comfy.org/nodes/search**', registryRoute)
await comfyPage.page.route(
(url) => url.hostname === 'api.comfy.org' && url.pathname === '/nodes',
registryRoute
)
await openManagerDialog(comfyPage)
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
const retryButton = dialog.getByRole('button', { name: 'Try Again' })
await expect(retryButton).toBeVisible()
online = true
await retryButton.click()
await expect(dialog.getByText('Test Pack A')).toBeVisible()
})
})

View File

@@ -453,6 +453,7 @@
"totalNodes": "Total Nodes",
"discoverCommunityContent": "Discover community-made Node Packs, Extensions, and more...",
"errorConnecting": "Error connecting to the Comfy Node Registry.",
"retry": "Try Again",
"noResultsFound": "No results found matching your search.",
"tryDifferentSearch": "Please try a different search query.",
"emptyState": {

25
src/services/apiClient.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { AxiosInstance, AxiosRequestConfig } from 'axios'
import axios from 'axios'
// A hung socket (e.g. no internet, captive portal) never rejects without a
// timeout, leaving callers stuck in their loading state. This is the single
// home for that policy; callers override `timeout` when they need a different
// ceiling.
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000
/**
* Create an axios client with the shared defaults (JSON content type and a
* request timeout). Each service still supplies its own `baseURL` and may
* override the `timeout` or add other axios options (e.g. `paramsSerializer`).
*/
export function createApiClient(
config: Omit<AxiosRequestConfig, 'headers'> = {}
): AxiosInstance {
return axios.create({
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
...config,
headers: {
'Content-Type': 'application/json'
}
})
}

View File

@@ -2,16 +2,14 @@ import type { AxiosError, AxiosResponse } from 'axios'
import axios from 'axios'
import { ref } from 'vue'
import { createApiClient } from '@/services/apiClient'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
const API_BASE_URL = 'https://api.comfy.org'
const registryApiClient = axios.create({
const registryApiClient = createApiClient({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json'
},
paramsSerializer: {
// Disables PHP-style notation (e.g. param[]=value) in favor of repeated params (e.g. param=value1&param=value2)
indexes: null

View File

@@ -4,6 +4,7 @@ import { ref, watch } from 'vue'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { d } from '@/i18n'
import { createApiClient } from '@/services/apiClient'
import { useAuthStore } from '@/stores/authStore'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
@@ -23,11 +24,8 @@ type CustomerEventsResponseQuery =
export type AuditLog = components['schemas']['AuditLog']
const customerApiClient = axios.create({
baseURL: getComfyApiBaseUrl(),
headers: {
'Content-Type': 'application/json'
}
const customerApiClient = createApiClient({
baseURL: getComfyApiBaseUrl()
})
export const useCustomerEventsService = () => {

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useScroll, whenever } from '@vueuse/core'
import Panel from 'primevue/panel'
import TabMenu from 'primevue/tabmenu'
@@ -40,10 +41,7 @@ const isInProgress = computed(
() => comfyManagerStore.isProcessingTasks || isRestarting.value
)
const isTaskInProgress = (index: number) => {
const log = focusedLogs.value[index]
if (!log) return false
const isTaskInProgress = (taskId: string) => {
const taskQueue = comfyManagerStore.taskQueue
if (!taskQueue) return false
@@ -52,7 +50,13 @@ const isTaskInProgress = (index: number) => {
...(taskQueue.pending_queue || [])
]
return allQueueTasks.some((task) => task.ui_id === log.taskId)
return allQueueTasks.some((task) => task.ui_id === taskId)
}
function taskStatusLabel(taskId: string): string {
if (isTaskInProgress(taskId)) return t('g.inProgress')
if (comfyManagerStore.isTaskFailed(taskId)) return t('manager.failed')
return t('g.completedWithCheckmark')
}
const completedTasksCount = computed(() => {
@@ -190,12 +194,16 @@ onBeforeUnmount(() => {
<div class="flex w-full items-center justify-between py-2">
<div class="flex flex-col text-sm/normal font-medium">
<span>{{ log.taskName }}</span>
<span class="text-muted">
{{
isTaskInProgress(index)
? t('g.inProgress')
: t('g.completedWithCheckmark')
}}
<span
:class="
cn(
'text-muted',
comfyManagerStore.isTaskFailed(log.taskId) &&
'text-danger'
)
"
>
{{ taskStatusLabel(log.taskId) }}
</span>
</div>
</div>
@@ -229,6 +237,17 @@ onBeforeUnmount(() => {
@scroll="handleScroll"
>
<div class="h-full">
<div
v-for="(
errorMessage, errorIndex
) in comfyManagerStore.getTaskErrorMessages(log.taskId)"
:key="`error-${errorIndex}`"
class="text-danger"
>
<pre class="wrap-break-word whitespace-pre-wrap">{{
errorMessage
}}</pre>
</div>
<div
v-for="(logLine, logIndex) in log.logs"
:key="logIndex"

View File

@@ -114,6 +114,10 @@
v-else-if="displayPacks.length === 0"
:title="emptyStateTitle"
:message="emptyStateMessage"
:button-label="
connectionError?.kind === 'search' ? $t('manager.retry') : undefined
"
@action="onEmptyStateAction"
/>
<div v-else class="size-full" @click="handleGridContainerClick">
<VirtualGrid
@@ -344,6 +348,8 @@ const {
searchQuery,
pageNumber,
isLoading: isSearchLoading,
error: searchError,
retry: retrySearch,
searchResults,
searchMode,
sortField,
@@ -434,9 +440,24 @@ const isManagerErrorRelevant = computed(() => {
)
})
// One source of truth for the empty-state error so the title/message/button and
// retry action can never disagree. A search error is retryable in place; a
// manager-store error is not, so only the former exposes a retry.
type ConnectionError = { kind: 'search' } | { kind: 'manager' }
const connectionError = computed<ConnectionError | null>(() => {
if (searchError.value) return { kind: 'search' }
if (isManagerErrorRelevant.value) return { kind: 'manager' }
return null
})
const hasConnectionError = computed(() => connectionError.value !== null)
const onEmptyStateAction = () => {
if (connectionError.value?.kind === 'search') void retrySearch()
}
// Empty state messages based on current tab and search state
const emptyStateTitle = computed(() => {
if (isManagerErrorRelevant.value) return t('manager.errorConnecting')
if (hasConnectionError.value) return t('manager.errorConnecting')
if (searchQuery.value) return t('manager.noResultsFound')
const tabId = selectedTab.value?.id
@@ -448,7 +469,7 @@ const emptyStateTitle = computed(() => {
})
const emptyStateMessage = computed(() => {
if (isManagerErrorRelevant.value) return t('manager.tryAgainLater')
if (hasConnectionError.value) return t('manager.tryAgainLater')
if (searchQuery.value) {
const baseMessage = t('manager.tryDifferentSearch')
if (isLegacyManagerSearch.value) {
@@ -475,8 +496,9 @@ const onClickWarningLink = () => {
}
const isLoading = computed(() => {
if (isSearchLoading.value) return searchResults.value.length === 0
if (isTabLoading.value) return true
if (searchError.value) return false
if (isSearchLoading.value) return searchResults.value.length === 0
return isInitialLoad.value
})

View File

@@ -0,0 +1,60 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
import type { NodePackSearchProvider } from '@/types/searchServiceTypes'
import { useRegistrySearch } from '@/workbench/extensions/manager/composables/useRegistrySearch'
vi.mock('@/services/gateway/registrySearchGateway')
function mockGateway(searchPacks: NodePackSearchProvider['searchPacks']) {
vi.mocked(useRegistrySearchGateway).mockReturnValue({
searchPacks,
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
})
}
describe('useRegistrySearch', () => {
beforeEach(() => {
// Suppress the immediate debounced search so each test drives the search
// explicitly via retry(); pending timers stay queued and never fire.
vi.useFakeTimers()
vi.clearAllMocks()
})
afterEach(() => {
vi.useRealTimers()
})
it('clears loading and records the error when the search fails', async () => {
const searchPacks = vi
.fn()
.mockRejectedValue(new Error('All search providers failed'))
mockGateway(searchPacks)
const { isLoading, error, retry } = useRegistrySearch()
await retry()
expect(isLoading.value).toBe(false)
expect(error.value).toBe('All search providers failed')
})
it('recovers and clears the error on a successful retry', async () => {
const searchPacks = vi.fn().mockRejectedValue(new Error('offline'))
mockGateway(searchPacks)
const { error, searchResults, retry } = useRegistrySearch()
await retry()
expect(error.value).toBe('offline')
searchPacks.mockResolvedValue({
nodePacks: [{ id: 'a', name: 'Pack A' }],
querySuggestions: []
})
await retry()
expect(error.value).toBeNull()
expect(searchResults.value).toHaveLength(1)
})
})

View File

@@ -33,6 +33,7 @@ export function useRegistrySearch(
} = options
const isLoading = ref(false)
const error = ref<string | null>(null)
const sortField = ref<string>(initialSortField)
const searchMode = ref<SearchMode>(initialSearchMode)
const pageSize = ref(DEFAULT_PAGE_SIZE)
@@ -52,43 +53,51 @@ export function useRegistrySearch(
const updateSearchResults = async (options: { append?: boolean }) => {
isLoading.value = true
error.value = null
if (!options.append) {
pageNumber.value = 0
}
const { nodePacks, querySuggestions } = await searchPacks(
searchQuery.value,
{
pageSize: pageSize.value,
pageNumber: pageNumber.value,
restrictSearchableAttributes: searchAttributes.value
}
)
let sortedPacks = nodePacks
// Results are sorted by the default field to begin with -- so don't manually sort again
if (sortField.value && sortField.value !== DEFAULT_SORT_FIELD) {
// Get the sort direction from the provider's sortable fields
const sortableFields = getSortableFields()
const fieldConfig = sortableFields.find((f) => f.id === sortField.value)
const direction = fieldConfig?.direction || 'desc'
sortedPacks = orderBy(
nodePacks,
[(pack) => getSortValue(pack, sortField.value)],
[direction]
try {
const { nodePacks, querySuggestions } = await searchPacks(
searchQuery.value,
{
pageSize: pageSize.value,
pageNumber: pageNumber.value,
restrictSearchableAttributes: searchAttributes.value
}
)
}
if (options.append && searchResults.value?.length) {
searchResults.value = searchResults.value.concat(sortedPacks)
} else {
searchResults.value = sortedPacks
let sortedPacks = nodePacks
// Results are sorted by the default field to begin with -- so don't manually sort again
if (sortField.value && sortField.value !== DEFAULT_SORT_FIELD) {
// Get the sort direction from the provider's sortable fields
const sortableFields = getSortableFields()
const fieldConfig = sortableFields.find((f) => f.id === sortField.value)
const direction = fieldConfig?.direction || 'desc'
sortedPacks = orderBy(
nodePacks,
[(pack) => getSortValue(pack, sortField.value)],
[direction]
)
}
if (options.append && searchResults.value?.length) {
searchResults.value = searchResults.value.concat(sortedPacks)
} else {
searchResults.value = sortedPacks
}
suggestions.value = querySuggestions
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
} finally {
isLoading.value = false
}
suggestions.value = querySuggestions
isLoading.value = false
}
const retry = () => updateSearchResults({ append: false })
const onQueryChange = () => void updateSearchResults({ append: false })
const onPageChange = () => {
if (pageNumber.value === 0) return
@@ -108,6 +117,8 @@ export function useRegistrySearch(
return {
isLoading,
error,
retry,
pageNumber,
pageSize,
sortField,

View File

@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
const { mockClient } = vi.hoisted(() => ({
mockClient: { get: vi.fn(), post: vi.fn() }
}))
vi.mock('axios', () => ({
default: {
create: () => mockClient,
isAxiosError: (e: unknown): boolean =>
!!e &&
typeof e === 'object' &&
(e as { isAxiosError?: boolean }).isAxiosError === true
}
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (p: string) => p,
clientId: 'test-client',
initialClientId: 'test-client'
}
}))
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({ isNewManagerUI: ref(true) })
}))
function axiosError(status: number, data?: { message: string }) {
return { isAxiosError: true, response: { status, data } }
}
function axiosNoResponse(code = 'ECONNABORTED') {
return { isAxiosError: true, code, message: 'timeout of 10000ms exceeded' }
}
function installSomePack(service: ReturnType<typeof useComfyManagerService>) {
return service.installPack({
id: 'some-pack',
version: '1.0.0',
selected_version: '1.0.0',
mode: 'remote',
channel: 'default'
})
}
describe('useComfyManagerService error messages', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('surfaces the backend security message on a 403 instead of the generic fallback', async () => {
const backendMessage =
"ERROR: To use this action, '--listen' must be set to a local IP and security_level must be 'normal-' or lower."
mockClient.post.mockRejectedValue(
axiosError(403, { message: backendMessage })
)
const service = useComfyManagerService()
await service.installPack({
id: 'some-pack',
version: '1.0.0',
selected_version: '1.0.0',
mode: 'remote',
channel: 'default'
})
expect(service.error.value).toBe(backendMessage)
})
it('falls back to the generic security message when the 403 has no body', async () => {
mockClient.post.mockRejectedValue(axiosError(403))
const service = useComfyManagerService()
await installSomePack(service)
expect(service.error.value).toContain('security error has occurred')
})
it('reports a connection error on a timeout instead of "status undefined"', async () => {
mockClient.post.mockRejectedValue(axiosNoResponse())
const service = useComfyManagerService()
await installSomePack(service)
expect(service.error.value).toBe('Could not connect to ComfyUI-Manager')
expect(service.error.value).not.toContain('undefined')
})
it('keeps the curated route error over a backend body on non-security statuses', async () => {
mockClient.post.mockRejectedValue(
axiosError(401, { message: 'raw backend text' })
)
const service = useComfyManagerService()
await service.updateAllPacks()
expect(service.error.value).toBe(
'Unauthorized: ComfyUI-Manager job queue is busy'
)
})
})

View File

@@ -3,6 +3,7 @@ import axios from 'axios'
import { v4 as uuidv4 } from 'uuid'
import { ref } from 'vue'
import { createApiClient } from '@/services/apiClient'
import { api } from '@/scripts/api'
import { isAbortError } from '@/utils/typeGuardUtil'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
@@ -19,6 +20,8 @@ type QueueTaskItem = components['schemas']['QueueTaskItem']
const GENERIC_SECURITY_ERR_MSG =
'Forbidden: A security error has occurred. Please check the terminal logs'
const CONNECTION_ERR_MSG = 'Could not connect to ComfyUI-Manager'
/**
* API routes for ComfyUI Manager
*/
@@ -38,11 +41,16 @@ enum ManagerRoute {
QUEUE_TASK = 'manager/queue/task'
}
const managerApiClient = axios.create({
// Reads/polls should fail fast so the UI can show an error instead of hanging.
const READ_TIMEOUT_MS = 10_000
// Synchronous POSTs (reboot, update_comfyui, update_all, bulk) can legitimately
// run long, so the client uses a generous ceiling and reads opt into the
// shorter timeout per-request.
const REQUEST_TIMEOUT_MS = 60_000
const managerApiClient = createApiClient({
baseURL: api.apiURL('/v2/'),
headers: {
'Content-Type': 'application/json'
}
timeout: REQUEST_TIMEOUT_MS
})
/**
@@ -74,14 +82,21 @@ export const useComfyManagerService = () => {
} else {
const axiosError = err as AxiosError<{ message: string }>
const status = axiosError.response?.status
if (status && routeSpecificErrors?.[status]) {
const backendMessage = axiosError.response?.data?.message
if (!axiosError.response) {
// Timeout/offline has no status; avoid "failed with status undefined".
message = CONNECTION_ERR_MSG
} else if (status === 403 && backendMessage) {
// Backend security text is more actionable than our curated 403 strings.
message = backendMessage
} else if (status && routeSpecificErrors?.[status]) {
message = routeSpecificErrors[status]
} else if (status === 404) {
message = 'Could not connect to ComfyUI-Manager'
message = CONNECTION_ERR_MSG
} else if (backendMessage) {
message = backendMessage
} else {
message =
axiosError.response?.data?.message ??
`${context} failed with status ${status}`
message = `${context} failed with status ${status}`
}
}
@@ -138,7 +153,8 @@ export const useComfyManagerService = () => {
() =>
managerApiClient.get(ManagerRoute.QUEUE_STATUS, {
params: client_id ? { client_id } : undefined,
signal
signal,
timeout: READ_TIMEOUT_MS
}),
{ errorContext }
)
@@ -148,7 +164,11 @@ export const useComfyManagerService = () => {
const errorContext = 'Fetching installed packs'
return executeRequest<InstalledPacksResponse>(
() => managerApiClient.get(ManagerRoute.LIST_INSTALLED, { signal }),
() =>
managerApiClient.get(ManagerRoute.LIST_INSTALLED, {
signal,
timeout: READ_TIMEOUT_MS
}),
{ errorContext }
)
}
@@ -157,7 +177,11 @@ export const useComfyManagerService = () => {
const errorContext = 'Fetching import failure information'
return executeRequest<Record<string, unknown>>(
() => managerApiClient.get(ManagerRoute.IMPORT_FAIL_INFO, { signal }),
() =>
managerApiClient.get(ManagerRoute.IMPORT_FAIL_INFO, {
signal,
timeout: READ_TIMEOUT_MS
}),
{ errorContext }
)
}
@@ -316,7 +340,11 @@ export const useComfyManagerService = () => {
const errorContext = 'Checking if user set Manager to use the legacy UI'
return executeRequest<{ is_legacy_manager_ui: boolean }>(
() => managerApiClient.get(ManagerRoute.IS_LEGACY_MANAGER_UI, { signal }),
() =>
managerApiClient.get(ManagerRoute.IS_LEGACY_MANAGER_UI, {
signal,
timeout: READ_TIMEOUT_MS
}),
{ errorContext }
)
}
@@ -336,7 +364,8 @@ export const useComfyManagerService = () => {
() =>
managerApiClient.get(ManagerRoute.TASK_HISTORY, {
params: options,
signal
signal,
timeout: READ_TIMEOUT_MS
}),
{ errorContext }
)

View File

@@ -409,6 +409,64 @@ describe('useComfyManagerStore', () => {
})
})
describe('task failure surfacing', () => {
type TaskHistoryItem = ManagerComponents['schemas']['TaskHistoryItem']
const historyItem = (
uiId: string,
statusStr: 'success' | 'error' | 'skip',
messages: string[]
): TaskHistoryItem => ({
ui_id: uiId,
client_id: 'client',
kind: 'install',
result: statusStr === 'success' ? 'success' : 'failed',
timestamp: '2024-01-01T00:00:00Z',
status: {
status_str: statusStr,
completed: statusStr === 'success',
messages
}
})
it('flags an errored task as failed and surfaces its messages', async () => {
const store = useComfyManagerStore()
const reason =
"ERROR: To use this action, '--listen' must be set and security_level must be 'normal-' or lower."
store.taskHistory = { 'task-1': historyItem('task-1', 'error', [reason]) }
await nextTick()
expect(store.isTaskFailed('task-1')).toBe(true)
expect(store.getTaskErrorMessages('task-1')).toEqual([reason])
})
it('does not surface messages for a successful task', async () => {
const store = useComfyManagerStore()
store.taskHistory = {
'task-2': historyItem('task-2', 'success', ['Installed successfully'])
}
await nextTick()
expect(store.isTaskFailed('task-2')).toBe(false)
expect(store.getTaskErrorMessages('task-2')).toEqual([])
})
it('does not treat a skipped task as a failure', async () => {
const store = useComfyManagerStore()
store.taskHistory = {
'task-3': historyItem('task-3', 'skip', ['Already installed'])
}
await nextTick()
expect(store.isTaskFailed('task-3')).toBe(false)
expect(store.failedTasksIds).not.toContain('task-3')
expect(store.getTaskErrorMessages('task-3')).toEqual([])
})
})
describe('refreshInstalledList with pack ID normalization', () => {
it('normalizes pack IDs by removing version suffixes', async () => {
const mockPacks = {

View File

@@ -96,10 +96,11 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
const successTasksIds = []
const failTasksIds = []
for (const task of Object.values(taskHistory.value)) {
if (task.status?.status_str === 'success') {
successTasksIds.push(task.ui_id)
} else {
// Only 'error' is a failure; 'skip' (and 'success') are not.
if (task.status?.status_str === 'error') {
failTasksIds.push(task.ui_id)
} else {
successTasksIds.push(task.ui_id)
}
}
succeededTasksIds.value = successTasksIds
@@ -115,6 +116,18 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
{ deep: true }
)
const isTaskFailed = (taskId: string): boolean =>
failedTasksIds.value.includes(taskId)
// The actionable reason a task failed (e.g. the security_level/--listen
// restriction ComfyUI-Manager reports on a blocked install) lives in task
// history, not the streamed server logs -- which stay empty when the request
// is rejected before the task ever runs. Surface it so the failure isn't silent.
const getTaskErrorMessages = (taskId: string): string[] =>
isTaskFailed(taskId)
? (taskHistory.value[taskId]?.status?.messages ?? [])
: []
const getPackId = (pack: ManagerPackInstalled) => pack.cnr_id || pack.aux_id
const isInstalledPackId = (packName: NodePackId | undefined): boolean =>
@@ -384,6 +397,8 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
failedTasksIds,
succeededTasksLogs,
failedTasksLogs,
isTaskFailed,
getTaskErrorMessages,
managerQueue,
// Pack actions