mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 09:18:26 +00:00
Compare commits
1 Commits
feat/load3
...
feat/parti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cb19adde3 |
@@ -8,6 +8,7 @@ import type {
|
||||
JobStatus
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import {
|
||||
TaskItemImpl,
|
||||
useQueueSettingsStore,
|
||||
@@ -53,6 +54,8 @@ const i18n = createI18n({
|
||||
en: {
|
||||
menu: {
|
||||
run: 'Run',
|
||||
continueIndependentBranches:
|
||||
'Continue independent branches if a node fails',
|
||||
disabledTooltip: 'Disabled tooltip',
|
||||
onChange: 'On Change',
|
||||
onChangeTooltip: 'On change tooltip',
|
||||
@@ -85,7 +88,9 @@ const stubs = {
|
||||
DropdownMenuTrigger: { template: '<div><slot /></div>' },
|
||||
DropdownMenuPortal: { template: '<div><slot /></div>' },
|
||||
DropdownMenuContent: { template: '<div><slot /></div>' },
|
||||
DropdownMenuItem: { template: '<div><slot /></div>' }
|
||||
DropdownMenuItem: { template: '<div><slot /></div>' },
|
||||
DropdownMenuCheckboxItem: { template: '<div><slot /></div>' },
|
||||
DropdownMenuItemIndicator: { template: '<span><slot /></span>' }
|
||||
}
|
||||
|
||||
function renderQueueButton() {
|
||||
@@ -106,6 +111,19 @@ function renderQueueButton() {
|
||||
}
|
||||
|
||||
describe('ComfyQueueButton', () => {
|
||||
it('only offers continuing independent branches when supported', async () => {
|
||||
renderQueueButton()
|
||||
expect(
|
||||
screen.queryByTestId('continue-independent-branches')
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
api.serverFeatureFlags.value = { supports_node_failure_policy: true }
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
screen.getByTestId('continue-independent-branches')
|
||||
).toHaveTextContent('Continue independent branches if a node fails')
|
||||
})
|
||||
it('renders the batch count control before the run button', () => {
|
||||
renderQueueButton()
|
||||
const controls = screen.getAllByTestId(/batch-count-edit|queue-button/)
|
||||
|
||||
@@ -56,6 +56,20 @@
|
||||
{{ item.label }}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
v-if="supportsNodeFailurePolicy"
|
||||
v-model:checked="continueIndependentBranches"
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary outline-none hover:bg-secondary-background-hover"
|
||||
data-testid="continue-independent-branches"
|
||||
@select.prevent
|
||||
>
|
||||
<span class="flex size-4 items-center justify-center">
|
||||
<DropdownMenuItemIndicator>
|
||||
<i class="icon-[lucide--check] size-4" />
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
{{ t('menu.continueIndependentBranches') }}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
@@ -64,8 +78,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuItemIndicator,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
@@ -78,6 +94,7 @@ import BatchCountEdit from '@/components/actionbar/BatchCountEdit.vue'
|
||||
import TinyChevronIcon from '@/components/actionbar/TinyChevronIcon.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import ButtonGroup from '@/components/ui/button-group/ButtonGroup.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -93,7 +110,15 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
const {
|
||||
mode: queueMode,
|
||||
batchCount,
|
||||
continueIndependentBranches
|
||||
} = storeToRefs(useQueueSettingsStore())
|
||||
const { flags } = useFeatureFlags()
|
||||
const supportsNodeFailurePolicy = computed(
|
||||
() => flags.supportsNodeFailurePolicy
|
||||
)
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
|
||||
@@ -160,6 +160,8 @@ type TestPreviewOutput = {
|
||||
type TestTaskRef = {
|
||||
workflowId?: string
|
||||
previewOutput?: TestPreviewOutput
|
||||
isPartialSuccess?: boolean
|
||||
executionErrorCount?: number
|
||||
}
|
||||
|
||||
type TestJobListItem = Omit<JobListItem, 'taskRef'> & {
|
||||
@@ -310,6 +312,23 @@ describe('JobAssetsList', () => {
|
||||
expect(stubRoot.getAttribute('data-preview-url')).not.toBe(preview.url)
|
||||
})
|
||||
|
||||
it('shows a warning badge for completed jobs with node errors', () => {
|
||||
const job = buildJob({
|
||||
taskRef: {
|
||||
...createTaskRef(createPreviewOutput('job-1.png')),
|
||||
isPartialSuccess: true,
|
||||
executionErrorCount: 2
|
||||
}
|
||||
})
|
||||
|
||||
renderJobAssetsList({ jobs: [job] })
|
||||
|
||||
expect(screen.getByTestId('partial-success-badge')).toHaveAttribute(
|
||||
'aria-label',
|
||||
'queue.jobDetails.completedWithErrors'
|
||||
)
|
||||
})
|
||||
|
||||
it('emits viewItem on double-click for completed jobs with preview', async () => {
|
||||
const job = buildJob()
|
||||
const onViewItem = vi.fn()
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
>
|
||||
<div
|
||||
:data-job-id="row.job.id"
|
||||
class="h-12"
|
||||
class="relative h-12"
|
||||
@mouseenter="onJobEnter(row.job, $event)"
|
||||
@mouseleave="onJobLeave(row.job.id)"
|
||||
>
|
||||
@@ -84,6 +84,18 @@
|
||||
</Button>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
<span
|
||||
v-if="row.job.taskRef?.isPartialSuccess"
|
||||
data-testid="partial-success-badge"
|
||||
class="pointer-events-none absolute bottom-1 left-7 z-2 flex size-4 items-center justify-center rounded-full bg-interface-panel-surface"
|
||||
:aria-label="getPartialSuccessLabel(row.job)"
|
||||
:title="getPartialSuccessLabel(row.job)"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--triangle-alert] size-3 text-warning-background"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -394,6 +406,11 @@ function getJobIconClass(job: JobListItem): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getPartialSuccessLabel(job: JobListItem): string {
|
||||
const count = job.taskRef?.executionErrorCount ?? 0
|
||||
return t('queue.jobDetails.completedWithErrors', { count }, count)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => displayedJobGroups,
|
||||
() => {
|
||||
|
||||
@@ -83,12 +83,54 @@
|
||||
{{ errorMessageValue }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="taskForJob?.isPartialSuccess"
|
||||
class="rounded-sm border border-warning-background/30 bg-warning-background/10 p-3 text-xs/normal text-text-primary"
|
||||
>
|
||||
<div class="flex items-center gap-2 font-medium">
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
|
||||
/>
|
||||
{{
|
||||
t(
|
||||
'queue.jobDetails.completedWithErrors',
|
||||
{ count: taskForJob.executionErrorCount },
|
||||
taskForJob.executionErrorCount
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
v-if="recoverableExecutionErrors.length"
|
||||
class="mt-3 flex max-h-64 flex-col gap-2 overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
v-for="(error, index) in recoverableExecutionErrors"
|
||||
:key="`${error.node_id}-${index}`"
|
||||
class="rounded-sm bg-interface-panel-hover-surface px-3 py-2"
|
||||
>
|
||||
<div class="font-medium text-text-primary">
|
||||
{{
|
||||
t('queue.jobDetails.nodeError', {
|
||||
nodeType: error.node_type,
|
||||
nodeId: error.node_id
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
class="mt-1 wrap-break-word whitespace-pre-wrap text-text-secondary"
|
||||
>
|
||||
{{ error.exception_message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -96,6 +138,7 @@ import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { getJobDetail } from '@/services/jobOutputCache'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
@@ -151,6 +194,33 @@ const jobState = computed(() => {
|
||||
return jobStateFromTask(task, isInitializing)
|
||||
})
|
||||
|
||||
type RecoverableExecutionError = {
|
||||
node_id: string | number
|
||||
node_type: string
|
||||
exception_message: string
|
||||
}
|
||||
|
||||
const storedExecutionErrors = ref<RecoverableExecutionError[]>([])
|
||||
const recoverableExecutionErrors = computed<RecoverableExecutionError[]>(() =>
|
||||
storedExecutionErrors.value.length
|
||||
? storedExecutionErrors.value
|
||||
: (executionStore.executionErrorsByJob[props.jobId] ?? [])
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.jobId, taskForJob.value?.isPartialSuccess] as const,
|
||||
async ([jobId, isPartialSuccess]) => {
|
||||
storedExecutionErrors.value = []
|
||||
if (!isPartialSuccess) return
|
||||
|
||||
const detail = await getJobDetail(jobId)
|
||||
if (props.jobId === jobId) {
|
||||
storedExecutionErrors.value = detail?.execution_errors ?? []
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const firstSeenTs = computed<number | undefined>(() => {
|
||||
const task = taskForJob.value
|
||||
return task?.createTime
|
||||
|
||||
@@ -55,6 +55,23 @@ describe('useFeatureFlags', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should access supportsNodeFailurePolicy', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.SUPPORTS_NODE_FAILURE_POLICY)
|
||||
return true
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.supportsNodeFailurePolicy).toBe(true)
|
||||
expect(api.getServerFeature).toHaveBeenCalledWith(
|
||||
ServerFeatureFlag.SUPPORTS_NODE_FAILURE_POLICY,
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('should access maxUploadSize', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getDevOverride } from '@/utils/devFeatureFlagOverride'
|
||||
*/
|
||||
export enum ServerFeatureFlag {
|
||||
SUPPORTS_PREVIEW_METADATA = 'supports_preview_metadata',
|
||||
SUPPORTS_NODE_FAILURE_POLICY = 'supports_node_failure_policy',
|
||||
MAX_UPLOAD_SIZE = 'max_upload_size',
|
||||
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
|
||||
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
|
||||
@@ -77,6 +78,12 @@ export function useFeatureFlags() {
|
||||
get supportsPreviewMetadata() {
|
||||
return api.getServerFeature(ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
|
||||
},
|
||||
get supportsNodeFailurePolicy() {
|
||||
return api.getServerFeature(
|
||||
ServerFeatureFlag.SUPPORTS_NODE_FAILURE_POLICY,
|
||||
false
|
||||
)
|
||||
},
|
||||
get maxUploadSize() {
|
||||
return api.getServerFeature(ServerFeatureFlag.MAX_UPLOAD_SIZE)
|
||||
},
|
||||
|
||||
@@ -1092,6 +1092,7 @@
|
||||
"runWorkflowFront": "Run workflow (Queue at front)",
|
||||
"runWorkflowDisabled": "Workflow contains unsupported nodes (highlighted red). Remove these to run the workflow.",
|
||||
"run": "Run",
|
||||
"continueIndependentBranches": "Continue independent branches if a node fails",
|
||||
"stopRunInstant": "Stop Run (Instant)",
|
||||
"stopRunInstantTooltip": "Stop running",
|
||||
"execute": "Execute",
|
||||
@@ -1366,6 +1367,8 @@
|
||||
"failedAfter": "Failed after",
|
||||
"errorMessage": "Error message",
|
||||
"report": "Report",
|
||||
"completedWithErrors": "Completed with {count} node error | Completed with {count} node errors",
|
||||
"nodeError": "{nodeType} (node {nodeId})",
|
||||
"queuePositionValue": "~{count} job ahead of yours | ~{count} jobs ahead of yours",
|
||||
"eta": {
|
||||
"seconds": "~{count} second | ~{count} seconds",
|
||||
|
||||
@@ -68,6 +68,31 @@ describe('fetchJobs', () => {
|
||||
expect(result[1].id).toBe('job2')
|
||||
})
|
||||
|
||||
it('preserves partial success metadata on completed jobs', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve(
|
||||
createMockResponse([
|
||||
createMockJob('job1', 'completed', {
|
||||
completion_status: 'partial_success',
|
||||
has_errors: true,
|
||||
execution_error_count: 2
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
const [job] = await fetchHistory(mockFetch)
|
||||
|
||||
expect(job).toMatchObject({
|
||||
status: 'completed',
|
||||
completion_status: 'partial_success',
|
||||
has_errors: true,
|
||||
execution_error_count: 2
|
||||
})
|
||||
})
|
||||
|
||||
it('assigns synthetic priorities', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
@@ -64,6 +64,9 @@ const zRawJobListItem = z
|
||||
preview_output: zPreviewOutput.nullable().optional(),
|
||||
outputs_count: z.number().nullable().optional(),
|
||||
execution_error: zExecutionError.nullable().optional(),
|
||||
completion_status: z.enum(['success', 'partial_success']).optional(),
|
||||
has_errors: z.boolean().optional(),
|
||||
execution_error_count: z.number().int().optional(),
|
||||
workflow_id: z.string().nullable().optional(),
|
||||
priority: z.number().optional()
|
||||
})
|
||||
@@ -79,7 +82,8 @@ export const zJobDetail = zRawJobListItem
|
||||
outputs: zTaskOutput.optional(),
|
||||
update_time: z.number().optional(),
|
||||
execution_status: z.unknown().optional(),
|
||||
execution_meta: z.unknown().optional()
|
||||
execution_meta: z.unknown().optional(),
|
||||
execution_errors: z.array(zExecutionError).optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ import { toLinkId } from '@/types/linkId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const mockUseColorPaletteStore = vi.hoisted(() => vi.fn())
|
||||
const mockExecutionState = vi.hoisted(() => ({
|
||||
nodes: {} as Record<string, { state: string }>
|
||||
}))
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: mockUseColorPaletteStore
|
||||
}))
|
||||
@@ -25,9 +28,9 @@ vi.mock('@/utils/colorUtil', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: vi.fn().mockReturnValue({
|
||||
nodeProgressStates: {}
|
||||
})
|
||||
useExecutionStore: vi.fn(() => ({
|
||||
nodeProgressStates: mockExecutionState.nodes
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('minimapCanvasRenderer', () => {
|
||||
@@ -37,6 +40,7 @@ describe('minimapCanvasRenderer', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockExecutionState.nodes = {}
|
||||
|
||||
mockContext = {
|
||||
clearRect: vi.fn(),
|
||||
@@ -221,6 +225,31 @@ describe('minimapCanvasRenderer', () => {
|
||||
expect(mockContext.strokeRect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render blocked execution state with amber outline', () => {
|
||||
mockGraph._nodes = [mockGraph._nodes[0]]
|
||||
mockExecutionState.nodes = {
|
||||
'1': { state: 'blocked' }
|
||||
}
|
||||
const context: MinimapRenderContext = {
|
||||
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
|
||||
scale: 0.5,
|
||||
settings: {
|
||||
nodeColors: true,
|
||||
showLinks: false,
|
||||
showGroups: false,
|
||||
renderBypass: false,
|
||||
renderError: true
|
||||
},
|
||||
width: 250,
|
||||
height: 200
|
||||
}
|
||||
|
||||
renderMinimapToCanvas(mockCanvas, mockGraph, context)
|
||||
|
||||
expect(mockContext.strokeStyle).toBe('#F59E0B')
|
||||
expect(mockContext.strokeRect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render groups when enabled', () => {
|
||||
mockGraph._groups = [
|
||||
{
|
||||
|
||||
@@ -28,6 +28,7 @@ function getMinimapColors() {
|
||||
errorColor: '#FF0000',
|
||||
runningColor: '#00FF00',
|
||||
successColor: '#239B23',
|
||||
blockedColor: '#F59E0B',
|
||||
isLightTheme
|
||||
}
|
||||
}
|
||||
@@ -116,7 +117,13 @@ function renderNodes(
|
||||
w: number
|
||||
h: number
|
||||
hasErrors?: boolean
|
||||
executionState?: 'pending' | 'running' | 'finished' | 'error' | null
|
||||
executionState?:
|
||||
| 'pending'
|
||||
| 'running'
|
||||
| 'finished'
|
||||
| 'error'
|
||||
| 'blocked'
|
||||
| null
|
||||
}>
|
||||
>()
|
||||
|
||||
@@ -162,6 +169,9 @@ function renderNodes(
|
||||
} else if (node.executionState === 'finished') {
|
||||
ctx.strokeStyle = colors.successColor
|
||||
ctx.strokeRect(node.x, node.y, node.w, node.h)
|
||||
} else if (node.executionState === 'blocked') {
|
||||
ctx.strokeStyle = colors.blockedColor
|
||||
ctx.strokeRect(node.x, node.y, node.w, node.h)
|
||||
} else if (
|
||||
node.executionState === 'error' &&
|
||||
context.settings.renderError
|
||||
|
||||
@@ -80,7 +80,13 @@ export interface MinimapNodeData {
|
||||
bgcolor?: string
|
||||
mode?: number
|
||||
hasErrors?: boolean
|
||||
executionState?: 'pending' | 'running' | 'finished' | 'error' | null
|
||||
executionState?:
|
||||
| 'pending'
|
||||
| 'running'
|
||||
| 'finished'
|
||||
| 'error'
|
||||
| 'blocked'
|
||||
| null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,6 +18,7 @@ import { app } from '@/scripts/app'
|
||||
|
||||
const mockData = vi.hoisted(() => ({
|
||||
mockExecuting: false,
|
||||
mockExecutionState: 'idle' as 'idle' | 'error' | 'blocked',
|
||||
mockLgraphNode: null as Record<string, unknown> | null
|
||||
}))
|
||||
|
||||
@@ -91,7 +92,7 @@ vi.mock(
|
||||
progress: computed(() => undefined),
|
||||
progressPercentage: computed(() => undefined),
|
||||
progressState: computed(() => undefined),
|
||||
executionState: computed(() => 'idle' as const)
|
||||
executionState: computed(() => mockData.mockExecutionState)
|
||||
}))
|
||||
})
|
||||
)
|
||||
@@ -178,6 +179,7 @@ describe('LGraphNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockData.mockExecuting = false
|
||||
mockData.mockExecutionState = 'idle'
|
||||
|
||||
setActivePinia(pinia)
|
||||
const canvasStore = useCanvasStore()
|
||||
@@ -247,6 +249,34 @@ describe('LGraphNode', () => {
|
||||
expect(overlay).toHaveClass('border-node-stroke-executing')
|
||||
})
|
||||
|
||||
it('should render failed execution state separately from validation errors', () => {
|
||||
mockData.mockExecutionState = 'error'
|
||||
|
||||
const { container } = renderLGraphNode({ nodeData: mockNodeData })
|
||||
|
||||
expect(getNodeRoot(container)).toHaveAttribute(
|
||||
'data-execution-state',
|
||||
'error'
|
||||
)
|
||||
expect(screen.getByTestId('node-inner-wrapper')).toHaveClass(
|
||||
'ring-destructive-background'
|
||||
)
|
||||
})
|
||||
|
||||
it('should render blocked execution state as a warning', () => {
|
||||
mockData.mockExecutionState = 'blocked'
|
||||
|
||||
const { container } = renderLGraphNode({ nodeData: mockNodeData })
|
||||
|
||||
expect(getNodeRoot(container)).toHaveAttribute(
|
||||
'data-execution-state',
|
||||
'blocked'
|
||||
)
|
||||
expect(screen.getByTestId('node-inner-wrapper')).toHaveClass(
|
||||
'ring-warning-background/70'
|
||||
)
|
||||
})
|
||||
|
||||
it('should initialize height CSS vars for collapsed nodes', () => {
|
||||
const { container } = renderLGraphNode({
|
||||
nodeData: {
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
:data-node-id="nodeData.id"
|
||||
:data-collapsed="isCollapsed || undefined"
|
||||
:data-ghost="nodeData.flags?.ghost || undefined"
|
||||
:data-execution-state="
|
||||
executionState === 'idle' ? undefined : executionState
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
'group/node lg-node absolute isolate text-xs',
|
||||
@@ -75,7 +78,9 @@
|
||||
'w-(--node-width)',
|
||||
!isRerouteNode && 'min-w-(--min-node-width)',
|
||||
shapeClass,
|
||||
hasAnyError && 'ring-4 ring-destructive-background',
|
||||
(hasAnyError || executionState === 'error') &&
|
||||
'ring-4 ring-destructive-background',
|
||||
executionState === 'blocked' && 'ring-4 ring-warning-background/70',
|
||||
bypassed && bypassOverlayClass,
|
||||
muted && mutedOverlayClass,
|
||||
isDraggingOver && 'bg-primary-500/10 ring-4 ring-primary-500'
|
||||
@@ -352,7 +357,8 @@ const isSelected = computed(() => {
|
||||
const nodeLocatorId = computed(
|
||||
() => getLocatorIdFromNodeData(nodeData) ?? undefined
|
||||
)
|
||||
const { executing, progress } = useNodeExecutionState(nodeLocatorId)
|
||||
const { executing, progress, executionState } =
|
||||
useNodeExecutionState(nodeLocatorId)
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const missingNodesErrorStore = useMissingNodesErrorStore()
|
||||
|
||||
@@ -64,7 +64,7 @@ const zProgressWsMessage = z.object({
|
||||
const zNodeProgressState = z.object({
|
||||
value: z.number(),
|
||||
max: z.number(),
|
||||
state: z.enum(['pending', 'running', 'finished', 'error']),
|
||||
state: z.enum(['pending', 'running', 'finished', 'error', 'blocked']),
|
||||
node_id: zNodeId,
|
||||
prompt_id: zJobId,
|
||||
display_node_id: zNodeId.optional(),
|
||||
@@ -94,7 +94,15 @@ const zExecutionWsMessageBase = z.object({
|
||||
})
|
||||
|
||||
const zExecutionStartWsMessage = zExecutionWsMessageBase
|
||||
const zExecutionSuccessWsMessage = zExecutionWsMessageBase
|
||||
const zExecutionSuccessWsMessage = zExecutionWsMessageBase.extend({
|
||||
completion_status: z.enum(['success', 'partial_success']).optional(),
|
||||
has_errors: z.boolean().optional(),
|
||||
execution_error_count: z.number().int().optional(),
|
||||
failed_node_ids: z.array(zNodeId).optional(),
|
||||
blocked_node_ids: z.array(zNodeId).optional(),
|
||||
blocked_output_node_ids: z.array(zNodeId).optional(),
|
||||
successful_output_node_ids: z.array(zNodeId).optional()
|
||||
})
|
||||
const zExecutionCachedWsMessage = zExecutionWsMessageBase.extend({
|
||||
nodes: z.array(zNodeId)
|
||||
})
|
||||
@@ -181,6 +189,7 @@ export type ExecutionInterruptedWsMessage = z.infer<
|
||||
typeof zExecutionInterruptedWsMessage
|
||||
>
|
||||
export type ExecutionErrorWsMessage = z.infer<typeof zExecutionErrorWsMessage>
|
||||
export type ExecutionNodeErrorWsMessage = ExecutionErrorWsMessage
|
||||
export type LogsWsMessage = z.infer<typeof zLogsWsMessage>
|
||||
export type ProgressTextWsMessage = z.infer<typeof zProgressTextWsMessage>
|
||||
export type NodeProgressState = z.infer<typeof zNodeProgressState>
|
||||
|
||||
@@ -37,6 +37,7 @@ import type {
|
||||
ExecutingWsMessage,
|
||||
ExecutionCachedWsMessage,
|
||||
ExecutionErrorWsMessage,
|
||||
ExecutionNodeErrorWsMessage,
|
||||
ExecutionInterruptedWsMessage,
|
||||
ExecutionStartWsMessage,
|
||||
ExecutionSuccessWsMessage,
|
||||
@@ -75,6 +76,7 @@ interface QueuePromptRequestBody {
|
||||
client_id: string
|
||||
prompt: ComfyApiWorkflow
|
||||
partial_execution_targets?: NodeExecutionId[]
|
||||
node_failure_policy?: 'continue_independent'
|
||||
extra_data: {
|
||||
extra_pnginfo: {
|
||||
workflow: ComfyWorkflowJSON
|
||||
@@ -135,6 +137,7 @@ interface QueuePromptOptions {
|
||||
* Format: Colon-separated path of node IDs (e.g., "123:456:789")
|
||||
*/
|
||||
partialExecutionTargets?: NodeExecutionId[]
|
||||
nodeFailurePolicy?: 'continue_independent'
|
||||
/**
|
||||
* Override the preview method for this prompt execution.
|
||||
* 'default' uses the server's CLI setting and is not sent to backend.
|
||||
@@ -165,6 +168,7 @@ interface BackendApiCalls {
|
||||
execution_start: ExecutionStartWsMessage
|
||||
execution_success: ExecutionSuccessWsMessage
|
||||
execution_error: ExecutionErrorWsMessage
|
||||
execution_node_error: ExecutionNodeErrorWsMessage
|
||||
execution_interrupted: ExecutionInterruptedWsMessage
|
||||
execution_cached: ExecutionCachedWsMessage
|
||||
logs: LogsWsMessage
|
||||
@@ -831,6 +835,7 @@ export class ComfyApi extends EventTarget {
|
||||
break
|
||||
case 'execution_start':
|
||||
case 'execution_error':
|
||||
case 'execution_node_error':
|
||||
case 'execution_interrupted':
|
||||
case 'execution_cached':
|
||||
case 'execution_success':
|
||||
@@ -960,6 +965,10 @@ export class ComfyApi extends EventTarget {
|
||||
...(options?.partialExecutionTargets && {
|
||||
partial_execution_targets: options.partialExecutionTargets
|
||||
}),
|
||||
...(options?.nodeFailurePolicy &&
|
||||
this.serverSupportsFeature('supports_node_failure_policy') && {
|
||||
node_failure_policy: options.nodeFailurePolicy
|
||||
}),
|
||||
extra_data: {
|
||||
auth_token_comfy_org: this.authToken,
|
||||
api_key_comfy_org: this.apiKey,
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import {
|
||||
@@ -229,9 +230,19 @@ describe('ComfyApp', () => {
|
||||
node_errors: nodeErrors,
|
||||
error: ''
|
||||
})
|
||||
api.serverFeatureFlags.value = { supports_node_failure_policy: true }
|
||||
useQueueSettingsStore().continueIndependentBranches = true
|
||||
|
||||
await expect(app.queuePrompt(0)).resolves.toBe(false)
|
||||
|
||||
expect(api.queuePrompt).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
nodeFailurePolicy: 'continue_independent'
|
||||
})
|
||||
)
|
||||
|
||||
const errorStore = useExecutionErrorStore()
|
||||
const executionStore = useExecutionStore()
|
||||
expect(errorStore.lastNodeErrors).toEqual(nodeErrors)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { reactive, unref } from 'vue'
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import { ServerFeatureFlag } from '@/composables/useFeatureFlags'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { syncLayoutStoreNodeBoundsFromGraph } from '@/renderer/core/layout/sync/syncLayoutStoreFromGraph'
|
||||
import { flushScheduledSlotLayoutSync } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
@@ -72,6 +73,7 @@ import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
@@ -1669,6 +1671,13 @@ export class ComfyApp {
|
||||
api.apiKey = comfyOrgApiKey ?? undefined
|
||||
const res = await api.queuePrompt(number, p, {
|
||||
partialExecutionTargets: queueNodeIds,
|
||||
nodeFailurePolicy:
|
||||
useQueueSettingsStore().continueIndependentBranches &&
|
||||
api.serverSupportsFeature(
|
||||
ServerFeatureFlag.SUPPORTS_NODE_FAILURE_POLICY
|
||||
)
|
||||
? 'continue_independent'
|
||||
: undefined,
|
||||
previewMethod
|
||||
})
|
||||
delete api.authToken
|
||||
|
||||
@@ -1461,6 +1461,26 @@ describe('useExecutionStore - WebSocket event handlers', () => {
|
||||
expect(store.queuedJobs['job-1']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps partial failure states visible until the next execution starts', () => {
|
||||
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
|
||||
|
||||
fire('execution_success', {
|
||||
prompt_id: 'job-1',
|
||||
timestamp: 1,
|
||||
completion_status: 'partial_success',
|
||||
failed_node_ids: ['failed'],
|
||||
blocked_node_ids: ['blocked']
|
||||
})
|
||||
|
||||
expect(store.activeJobId).toBeNull()
|
||||
expect(store.nodeProgressStates['failed'].state).toBe('error')
|
||||
expect(store.nodeProgressStates['blocked'].state).toBe('blocked')
|
||||
|
||||
fire('execution_start', { prompt_id: 'job-2', timestamp: 2 })
|
||||
|
||||
expect(store.nodeProgressStates).toEqual({})
|
||||
})
|
||||
|
||||
it('does not track success for jobs this client did not queue', () => {
|
||||
fire('execution_success', { prompt_id: 'foreign-job', timestamp: 0 })
|
||||
|
||||
@@ -1613,6 +1633,28 @@ describe('useExecutionStore - WebSocket event handlers', () => {
|
||||
})
|
||||
|
||||
describe('execution_error', () => {
|
||||
it('collects execution_node_error without terminating the job', () => {
|
||||
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
|
||||
const detail = {
|
||||
prompt_id: 'job-1',
|
||||
timestamp: 1,
|
||||
node_id: 'n1',
|
||||
node_type: 'SeeDanceVideo',
|
||||
executed: [],
|
||||
exception_type: 'RuntimeError',
|
||||
exception_message: 'Content filtered',
|
||||
traceback: [],
|
||||
current_inputs: {},
|
||||
current_outputs: {}
|
||||
}
|
||||
|
||||
fire('execution_node_error', detail)
|
||||
|
||||
expect(store.executionErrorsByJob['job-1']).toEqual([detail])
|
||||
expect(store.activeJobId).toBe('job-1')
|
||||
expect(useExecutionErrorStore().lastExecutionError).toBeNull()
|
||||
})
|
||||
|
||||
it('routes a service-level error (no node_id) to the prompt error store', () => {
|
||||
const errorStore = useExecutionErrorStore()
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
ExecutedWsMessage,
|
||||
ExecutionCachedWsMessage,
|
||||
ExecutionErrorWsMessage,
|
||||
ExecutionNodeErrorWsMessage,
|
||||
ExecutionInterruptedWsMessage,
|
||||
ExecutionStartWsMessage,
|
||||
ExecutionSuccessWsMessage,
|
||||
@@ -119,6 +120,9 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
const nodeProgressStatesByJob = ref<
|
||||
Record<JobId, Record<string, NodeProgressState>>
|
||||
>({})
|
||||
const executionErrorsByJob = ref<
|
||||
Record<JobId, ExecutionNodeErrorWsMessage[]>
|
||||
>({})
|
||||
|
||||
/**
|
||||
* Map of job ID to workflow ID for quick lookup across the app.
|
||||
@@ -349,6 +353,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
api.addEventListener('progress_state', handleProgressState)
|
||||
api.addEventListener('status', handleStatus)
|
||||
api.addEventListener('execution_error', handleExecutionError)
|
||||
api.addEventListener('execution_node_error', handleExecutionNodeError)
|
||||
api.addEventListener('progress_text', handleProgressText)
|
||||
}
|
||||
|
||||
@@ -364,6 +369,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
api.removeEventListener('progress_state', handleProgressState)
|
||||
api.removeEventListener('status', handleStatus)
|
||||
api.removeEventListener('execution_error', handleExecutionError)
|
||||
api.removeEventListener('execution_node_error', handleExecutionNodeError)
|
||||
api.removeEventListener('progress_text', handleProgressText)
|
||||
|
||||
if (workflowStatus.value.size > 0) workflowStatus.value = new Map()
|
||||
@@ -373,6 +379,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
|
||||
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
|
||||
executionIdToLocatorCache.clear()
|
||||
nodeProgressStates.value = {}
|
||||
executionErrorStore.clearExecutionStartErrors()
|
||||
activeJobId.value = e.detail.prompt_id
|
||||
queuedJobs.value[activeJobId.value] ??= { nodes: {} }
|
||||
@@ -413,6 +420,36 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
|
||||
function handleExecutionSuccess(e: CustomEvent<ExecutionSuccessWsMessage>) {
|
||||
const jobId = e.detail.prompt_id
|
||||
const partialProgressStates: Record<string, NodeProgressState> = {}
|
||||
if (e.detail.completion_status === 'partial_success') {
|
||||
for (const [nodeId, state] of Object.entries(nodeProgressStates.value)) {
|
||||
if (state.state === 'error' || state.state === 'blocked') {
|
||||
partialProgressStates[nodeId] = state
|
||||
}
|
||||
}
|
||||
for (const nodeId of e.detail.failed_node_ids ?? []) {
|
||||
partialProgressStates[nodeId] ??= {
|
||||
state: 'error',
|
||||
value: 1,
|
||||
max: 1,
|
||||
node_id: nodeId,
|
||||
display_node_id: nodeId,
|
||||
real_node_id: nodeId,
|
||||
prompt_id: jobId
|
||||
}
|
||||
}
|
||||
for (const nodeId of e.detail.blocked_node_ids ?? []) {
|
||||
partialProgressStates[nodeId] ??= {
|
||||
state: 'blocked',
|
||||
value: 1,
|
||||
max: 1,
|
||||
node_id: nodeId,
|
||||
display_node_id: nodeId,
|
||||
real_node_id: nodeId,
|
||||
prompt_id: jobId
|
||||
}
|
||||
}
|
||||
}
|
||||
setWorkflowStatus(jobId, 'completed')
|
||||
const queuedJob = queuedJobs.value[jobId]
|
||||
const telemetry = useTelemetry()
|
||||
@@ -430,6 +467,9 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
}
|
||||
resetExecutionState(jobId)
|
||||
if (Object.keys(partialProgressStates).length > 0) {
|
||||
nodeProgressStates.value = partialProgressStates
|
||||
}
|
||||
}
|
||||
|
||||
function handleExecuting(e: CustomEvent<string | number | null>): void {
|
||||
@@ -559,6 +599,23 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
resetExecutionState(e.detail.prompt_id)
|
||||
}
|
||||
|
||||
function handleExecutionNodeError(
|
||||
e: CustomEvent<ExecutionNodeErrorWsMessage>
|
||||
) {
|
||||
const jobId = e.detail.prompt_id
|
||||
const next = { ...executionErrorsByJob.value }
|
||||
delete next[jobId]
|
||||
next[jobId] = [
|
||||
...(executionErrorsByJob.value[jobId] ?? []),
|
||||
e.detail
|
||||
].slice(-100)
|
||||
const jobIds = Object.keys(next)
|
||||
for (const oldJobId of jobIds.slice(0, -MAX_PROGRESS_JOBS)) {
|
||||
delete next[oldJobId]
|
||||
}
|
||||
executionErrorsByJob.value = next
|
||||
}
|
||||
|
||||
function handleAccountPreconditionError(
|
||||
detail: ExecutionErrorWsMessage
|
||||
): boolean {
|
||||
@@ -835,6 +892,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
nodeProgressStates,
|
||||
nodeLocationProgressStates,
|
||||
nodeProgressStatesByJob,
|
||||
executionErrorsByJob,
|
||||
runningJobIds,
|
||||
runningWorkflowCount,
|
||||
initializingJobIds,
|
||||
|
||||
@@ -677,6 +677,32 @@ describe('useQueueStore', () => {
|
||||
expect(updatedTask).not.toBe(initialTask)
|
||||
})
|
||||
|
||||
it('should recreate TaskItemImpl when completion metadata changes', async () => {
|
||||
const job = createHistoryJob(10, 'job-1')
|
||||
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue([job])
|
||||
|
||||
await store.update()
|
||||
const initialTask = store.historyTasks[0]
|
||||
expect(initialTask.isPartialSuccess).toBe(false)
|
||||
|
||||
mockGetHistory.mockResolvedValue([
|
||||
{
|
||||
...job,
|
||||
completion_status: 'partial_success',
|
||||
has_errors: true,
|
||||
execution_error_count: 2
|
||||
}
|
||||
])
|
||||
|
||||
await store.update()
|
||||
|
||||
expect(store.historyTasks[0]).not.toBe(initialTask)
|
||||
expect(store.historyTasks[0].isPartialSuccess).toBe(true)
|
||||
expect(store.historyTasks[0].executionErrorCount).toBe(2)
|
||||
})
|
||||
|
||||
it('should reuse TaskItemImpl when outputs_count unchanged', async () => {
|
||||
const job = {
|
||||
...createHistoryJob(10, 'job-1'),
|
||||
|
||||
@@ -342,6 +342,14 @@ export class TaskItemImpl {
|
||||
return this.job.execution_error ?? undefined
|
||||
}
|
||||
|
||||
get executionErrorCount(): number {
|
||||
return this.job.execution_error_count ?? 0
|
||||
}
|
||||
|
||||
get isPartialSuccess(): boolean {
|
||||
return this.job.completion_status === 'partial_success'
|
||||
}
|
||||
|
||||
get workflowId(): string | undefined {
|
||||
return this.job.workflow_id ?? undefined
|
||||
}
|
||||
@@ -571,7 +579,6 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
.slice(0, toValue(maxHistoryItems))
|
||||
|
||||
// Reuse existing TaskItemImpl instances or create new
|
||||
// Must recreate if outputs_count changed (e.g., API started returning it)
|
||||
const existingByJobId = new Map(
|
||||
currentHistory.map((impl) => [impl.jobId, impl])
|
||||
)
|
||||
@@ -579,8 +586,12 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
const nextHistoryTasks = sortedHistory.map((job) => {
|
||||
const existing = existingByJobId.get(job.id)
|
||||
if (!existing) return new TaskItemImpl(job)
|
||||
// Recreate if outputs_count changed to ensure lazy loading works
|
||||
if (existing.outputsCount !== (job.outputs_count ?? undefined)) {
|
||||
if (
|
||||
existing.outputsCount !== (job.outputs_count ?? undefined) ||
|
||||
existing.job.completion_status !== job.completion_status ||
|
||||
existing.job.has_errors !== job.has_errors ||
|
||||
existing.job.execution_error_count !== job.execution_error_count
|
||||
) {
|
||||
return new TaskItemImpl(job)
|
||||
}
|
||||
return existing
|
||||
@@ -675,7 +686,8 @@ export const isInstantRunningMode = (
|
||||
export const useQueueSettingsStore = defineStore('queueSettingsStore', {
|
||||
state: () => ({
|
||||
mode: 'disabled' as AutoQueueMode,
|
||||
batchCount: 1
|
||||
batchCount: 1,
|
||||
continueIndependentBranches: false
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// the real composable's auth/remote-config dependency graph into the bundle.
|
||||
export enum ServerFeatureFlag {
|
||||
SUPPORTS_PREVIEW_METADATA = 'supports_preview_metadata',
|
||||
SUPPORTS_NODE_FAILURE_POLICY = 'supports_node_failure_policy',
|
||||
MAX_UPLOAD_SIZE = 'max_upload_size',
|
||||
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
|
||||
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
|
||||
|
||||
Reference in New Issue
Block a user