[refactor] Extract manager composables and execution utils (#9163)

## Summary
Extracts inline logic from manager components into dedicated composables
and utilities, and adds a cyclic subgraph fix.

## Changes
- **`usePackInstall`**: New composable extracted from
`PackInstallButton.vue` — handles conflict detection, payload
construction, and `Promise.allSettled`-based batch installation
- **`useApplyChanges`**: New shared composable extracted from
`ManagerProgressToast.vue` — manages ComfyUI restart flow with reconnect
timeout and post-reconnect refresh
- **`executionIdUtil`**: New utility (`getAncestorExecutionIds`,
`getParentExecutionIds`, `buildSubgraphExecutionPaths`) with unit tests;
fixes infinite recursion on cyclic subgraph definitions

## Review Focus
- `useApplyChanges` reconnect timeout (2 min) and setting restore logic
- `buildSubgraphExecutionPaths` visited-set guard for cyclic subgraph
defs

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9163-refactor-Extract-manager-composables-and-execution-utils-3116d73d365081f293d3d5484775ad48)
by [Unito](https://www.unito.io)
This commit is contained in:
jaeone94
2026-02-24 18:39:44 +09:00
committed by GitHub
parent 0f455c73bb
commit 09989b7aff
6 changed files with 417 additions and 139 deletions

View File

@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
import {
buildSubgraphExecutionPaths,
getAncestorExecutionIds,
getParentExecutionIds
} from '@/utils/executionIdUtil'
function node(id: number, type: string): ComfyNode {
return { id, type } as ComfyNode
}
function subgraphDef(id: string, nodes: ComfyNode[]) {
return { id, name: id, nodes, inputNode: {}, outputNode: {} }
}
describe('getAncestorExecutionIds', () => {
it('returns only itself for a root node', () => {
expect(getAncestorExecutionIds('65')).toEqual(['65'])
})
it('returns all ancestors including self for nested IDs', () => {
expect(getAncestorExecutionIds('65:70')).toEqual(['65', '65:70'])
expect(getAncestorExecutionIds('65:70:63')).toEqual([
'65',
'65:70',
'65:70:63'
])
})
})
describe('getParentExecutionIds', () => {
it('returns empty for a root node', () => {
expect(getParentExecutionIds('65')).toEqual([])
})
it('returns all ancestors excluding self for nested IDs', () => {
expect(getParentExecutionIds('65:70')).toEqual(['65'])
expect(getParentExecutionIds('65:70:63')).toEqual(['65', '65:70'])
})
})
describe('buildSubgraphExecutionPaths', () => {
it('returns empty map when there are no subgraph definitions', () => {
expect(buildSubgraphExecutionPaths([node(5, 'SomeNode')], [])).toEqual(
new Map()
)
})
it('returns empty map when no root node matches a subgraph type', () => {
const def = subgraphDef('def-A', [])
expect(
buildSubgraphExecutionPaths([node(5, 'UnrelatedNode')], [def])
).toEqual(new Map())
})
it('maps a single subgraph instance to its execution path', () => {
const def = subgraphDef('def-A', [])
const result = buildSubgraphExecutionPaths([node(5, 'def-A')], [def])
expect(result.get('def-A')).toEqual(['5'])
})
it('collects multiple instances of the same subgraph type', () => {
const def = subgraphDef('def-A', [])
const result = buildSubgraphExecutionPaths(
[node(5, 'def-A'), node(10, 'def-A')],
[def]
)
expect(result.get('def-A')).toEqual(['5', '10'])
})
it('builds nested execution paths for subgraphs within subgraphs', () => {
const innerDef = subgraphDef('def-B', [])
const outerDef = subgraphDef('def-A', [node(70, 'def-B')])
const result = buildSubgraphExecutionPaths(
[node(5, 'def-A')],
[outerDef, innerDef]
)
expect(result.get('def-A')).toEqual(['5'])
expect(result.get('def-B')).toEqual(['5:70'])
})
it('does not recurse infinitely on self-referential subgraph definitions', () => {
const cyclicDef = subgraphDef('def-A', [node(70, 'def-A')])
expect(() =>
buildSubgraphExecutionPaths([node(5, 'def-A')], [cyclicDef])
).not.toThrow()
})
it('does not recurse infinitely on mutually cyclic subgraph definitions', () => {
const defA = subgraphDef('def-A', [node(70, 'def-B')])
const defB = subgraphDef('def-B', [node(80, 'def-A')])
expect(() =>
buildSubgraphExecutionPaths([node(5, 'def-A')], [defA, defB])
).not.toThrow()
})
})

View File

@@ -0,0 +1,71 @@
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
import { isSubgraphDefinition } from '@/platform/workflow/validation/schemas/workflowSchema'
/**
* Returns all ancestor execution IDs for a given execution ID, including itself.
*
* Example: "65:70:63" → ["65", "65:70", "65:70:63"]
* @knipIgnoreUsedByStackedPR
*/
export function getAncestorExecutionIds(
executionId: string | NodeExecutionId
): NodeExecutionId[] {
const parts = executionId.split(':')
return Array.from({ length: parts.length }, (_, i) =>
parts.slice(0, i + 1).join(':')
)
}
/**
* Returns all ancestor execution IDs for a given execution ID, excluding itself.
*
* Example: "65:70:63" → ["65", "65:70"]
* @knipIgnoreUsedByStackedPR
*/
export function getParentExecutionIds(
executionId: string | NodeExecutionId
): NodeExecutionId[] {
return getAncestorExecutionIds(executionId).slice(0, -1)
}
/**
* "def-A" → ["5", "10"] for each container node instantiating that subgraph definition.
* @knipIgnoreUsedByStackedPR
*/
export function buildSubgraphExecutionPaths(
rootNodes: ComfyNode[],
allSubgraphDefs: unknown[]
): Map<string, string[]> {
const subgraphDefMap = new Map(
allSubgraphDefs.filter(isSubgraphDefinition).map((s) => [s.id, s])
)
const pathMap = new Map<string, string[]>()
const visited = new Set<string>()
const build = (nodes: ComfyNode[], parentPrefix: string) => {
for (const n of nodes ?? []) {
if (typeof n.type !== 'string' || !subgraphDefMap.has(n.type)) continue
const path = parentPrefix ? `${parentPrefix}:${n.id}` : String(n.id)
const existing = pathMap.get(n.type)
if (existing) {
existing.push(path)
} else {
pathMap.set(n.type, [path])
}
if (visited.has(n.type)) continue
visited.add(n.type)
const innerDef = subgraphDefMap.get(n.type)
if (innerDef) {
build(innerDef.nodes, path)
}
visited.delete(n.type)
}
}
build(rootNodes, '')
return pathMap
}

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useEventListener, useScroll, whenever } from '@vueuse/core'
import { useScroll, whenever } from '@vueuse/core'
import Panel from 'primevue/panel'
import TabMenu from 'primevue/tabmenu'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
@@ -8,18 +8,12 @@ import { useI18n } from 'vue-i18n'
import DotSpinner from '@/components/common/DotSpinner.vue'
import HoneyToast from '@/components/honeyToast/HoneyToast.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { api } from '@/scripts/api'
import { useCommandStore } from '@/stores/commandStore'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
import { useApplyChanges } from '@/workbench/extensions/manager/composables/useApplyChanges'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
const { t } = useI18n()
const comfyManagerStore = useComfyManagerStore()
const settingStore = useSettingStore()
const { runFullConflictAnalysis } = useConflictDetection()
const { isRestarting, isRestartCompleted, applyChanges } = useApplyChanges()
const isExpanded = ref(false)
const activeTabIndex = ref(0)
@@ -42,9 +36,6 @@ const focusedLogs = computed(() => {
const visible = computed(() => comfyManagerStore.taskLogs.length > 0)
const isRestarting = ref(false)
const isRestartCompleted = ref(false)
const isInProgress = computed(
() => comfyManagerStore.isProcessingTasks || isRestarting.value
)
@@ -148,47 +139,10 @@ function closeToast() {
}
async function handleRestart() {
const originalToastSetting = settingStore.get(
'Comfy.Toast.DisableReconnectingToast'
)
try {
await settingStore.set('Comfy.Toast.DisableReconnectingToast', true)
isRestarting.value = true
const onReconnect = async () => {
try {
comfyManagerStore.setStale()
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
await useWorkflowService().reloadCurrentWorkflow()
void runFullConflictAnalysis()
} finally {
await settingStore.set(
'Comfy.Toast.DisableReconnectingToast',
originalToastSetting
)
isRestarting.value = false
isRestartCompleted.value = true
setTimeout(() => {
closeToast()
}, 3000)
}
}
useEventListener(api, 'reconnected', onReconnect, { once: true })
await useComfyManagerService().rebootComfyUI()
} catch (error) {
await settingStore.set(
'Comfy.Toast.DisableReconnectingToast',
originalToastSetting
)
isRestarting.value = false
isRestartCompleted.value = false
closeToast()
throw error
await applyChanges(closeToast)
} catch (err) {
console.error('[ManagerProgressToast] Restart failed:', err)
}
}

View File

@@ -27,21 +27,15 @@ import DotSpinner from '@/components/common/DotSpinner.vue'
import Button from '@/components/ui/button/Button.vue'
import type { ButtonVariants } from '@/components/ui/button/button.variants'
import type { components } from '@/types/comfyRegistryTypes'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useNodeConflictDialog } from '@/workbench/extensions/manager/composables/useNodeConflictDialog'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import type {
ConflictDetail,
ConflictDetectionResult
} from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes'
import type { ConflictDetail } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
type NodePack = components['schemas']['Node']
const {
nodePacks,
isLoading = false,
label = 'Install',
label,
size = 'sm',
hasConflict,
conflictInfo
@@ -54,86 +48,13 @@ const {
conflictInfo?: ConflictDetail[]
}>()
const managerStore = useComfyManagerStore()
const { show: showNodeConflictDialog } = useNodeConflictDialog()
const { t } = useI18n()
// Check if any of the packs are currently being installed
const isInstalling = computed(() => {
if (!nodePacks?.length) return false
return nodePacks.some((pack) => managerStore.isPackInstalling(pack.id))
})
const createPayload = (installItem: NodePack) => {
if (!installItem.id) {
throw new Error('Node ID is required for installation')
}
const isUnclaimedPack = installItem.publisher?.name === 'Unclaimed'
const versionToInstall = isUnclaimedPack
? ('nightly' as ManagerComponents['schemas']['SelectedVersion'])
: (installItem.latest_version?.version ??
('latest' as ManagerComponents['schemas']['SelectedVersion']))
return {
id: installItem.id,
repository: installItem.repository ?? '',
channel: 'dev' as ManagerComponents['schemas']['ManagerChannel'],
mode: 'cache' as ManagerComponents['schemas']['ManagerDatabaseSource'],
selected_version: versionToInstall,
version: versionToInstall
}
}
const installPack = (item: NodePack) =>
managerStore.installPack.call(createPayload(item))
const installAllPacks = async () => {
if (!nodePacks?.length) return
if (hasConflict && conflictInfo) {
// Check each package individually for conflicts
const { checkNodeCompatibility } = useConflictDetection()
const conflictedPackages: ConflictDetectionResult[] = nodePacks
.map((pack) => {
const compatibilityCheck = checkNodeCompatibility(pack)
return {
package_id: pack.id || '',
package_name: pack.name || '',
has_conflict: compatibilityCheck.hasConflict,
conflicts: compatibilityCheck.conflicts,
is_compatible: !compatibilityCheck.hasConflict
}
})
.filter((result) => result.has_conflict) // Only show packages with conflicts
showNodeConflictDialog({
conflictedPackages,
buttonText: t('manager.conflicts.installAnyway'),
onButtonClick: async () => {
// Proceed with installation of uninstalled packages
const uninstalledPacks = nodePacks.filter(
(pack) => !managerStore.isPackInstalled(pack.id)
)
if (!uninstalledPacks.length) return
await performInstallation(uninstalledPacks)
}
})
return
}
// No conflicts or conflicts acknowledged - proceed with installation
const uninstalledPacks = nodePacks.filter(
(pack) => !managerStore.isPackInstalled(pack.id)
)
if (!uninstalledPacks.length) return
await performInstallation(uninstalledPacks)
}
const performInstallation = async (packs: NodePack[]) => {
await Promise.all(packs.map(installPack))
managerStore.installPack.clear()
}
const { isInstalling, installAllPacks } = usePackInstall(
() => nodePacks,
() => hasConflict,
() => conflictInfo
)
const computedLabel = computed(() =>
isInstalling.value

View File

@@ -0,0 +1,116 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { components } from '@/types/comfyRegistryTypes'
import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useNodeConflictDialog } from '@/workbench/extensions/manager/composables/useNodeConflictDialog'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import type { ConflictDetail } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
type NodePack = components['schemas']['Node']
export function usePackInstall(
getNodePacks: () => NodePack[],
getHasConflict?: () => boolean | undefined,
getConflictInfo?: () => ConflictDetail[] | undefined
) {
const managerStore = useComfyManagerStore()
const { show: showNodeConflictDialog } = useNodeConflictDialog()
const { checkNodeCompatibility } = useConflictDetection()
const { t } = useI18n()
// Check if any of the packs are currently being installed
const isInstalling = computed(() => {
const nodePacks = getNodePacks()
if (!nodePacks?.length) return false
return nodePacks.some((pack) => managerStore.isPackInstalling(pack.id))
})
const createPayload = (installItem: NodePack) => {
if (!installItem.id) {
throw new Error(t('manager.packInstall.nodeIdRequired'))
}
const isUnclaimedPack = installItem.publisher?.name === 'Unclaimed'
const versionToInstall = isUnclaimedPack
? ('nightly' as ManagerComponents['schemas']['SelectedVersion'])
: (installItem.latest_version?.version ??
('latest' as ManagerComponents['schemas']['SelectedVersion']))
return {
id: installItem.id,
repository: installItem.repository ?? '',
channel: 'dev' as ManagerComponents['schemas']['ManagerChannel'],
mode: 'cache' as ManagerComponents['schemas']['ManagerDatabaseSource'],
selected_version: versionToInstall,
version: versionToInstall
}
}
const installPack = (item: NodePack) =>
managerStore.installPack.call(createPayload(item))
const performInstallation = async (packs: NodePack[]) => {
try {
const results = await Promise.allSettled(packs.map(installPack))
const failures = results.filter(
(r): r is PromiseRejectedResult => r.status === 'rejected'
)
if (failures.length) {
console.error(
'[usePackInstall] Some installations failed:',
failures.map((f) => f.reason)
)
}
} finally {
managerStore.installPack.clear()
}
}
const installAllPacks = async () => {
const nodePacks = getNodePacks()
if (!nodePacks?.length) return
const hasConflict = getHasConflict?.()
const conflictInfo = getConflictInfo?.()
if (hasConflict) {
if (!conflictInfo) return
const conflictedPackages = nodePacks
.map((pack) => {
const compatibilityCheck = checkNodeCompatibility(pack)
return {
package_id: pack.id || '',
package_name: pack.name || '',
has_conflict: compatibilityCheck.hasConflict,
conflicts: compatibilityCheck.conflicts,
is_compatible: !compatibilityCheck.hasConflict
}
})
.filter((result) => result.has_conflict)
showNodeConflictDialog({
conflictedPackages,
buttonText: t('manager.conflicts.installAnyway'),
onButtonClick: async () => {
const uninstalledPacks = nodePacks.filter(
(pack) => !managerStore.isPackInstalled(pack.id)
)
if (!uninstalledPacks.length) return
await performInstallation(uninstalledPacks)
}
})
return
}
const uninstalledPacks = nodePacks.filter(
(pack) => !managerStore.isPackInstalled(pack.id)
)
if (!uninstalledPacks.length) return
await performInstallation(uninstalledPacks)
}
return { isInstalling, installAllPacks, performInstallation }
}

View File

@@ -0,0 +1,118 @@
import { createSharedComposable, useEventListener } from '@vueuse/core'
import { ref } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { api } from '@/scripts/api'
import { useCommandStore } from '@/stores/commandStore'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
export const useApplyChanges = createSharedComposable(() => {
const comfyManagerStore = useComfyManagerStore()
const settingStore = useSettingStore()
const { runFullConflictAnalysis } = useConflictDetection()
const isRestarting = ref(false)
const isRestartCompleted = ref(false)
async function applyChanges(onClose?: () => void) {
if (isRestarting.value) return
isRestarting.value = true
isRestartCompleted.value = false
const originalToastSetting = settingStore.get(
'Comfy.Toast.DisableReconnectingToast'
)
const onReconnect = async () => {
try {
comfyManagerStore.setStale()
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
await useWorkflowService().reloadCurrentWorkflow()
runFullConflictAnalysis().catch((err) => {
console.error('[useApplyChanges] Conflict analysis failed:', err)
})
} catch (err) {
console.error('[useApplyChanges] Post-reconnect tasks failed:', err)
} finally {
try {
await settingStore.set(
'Comfy.Toast.DisableReconnectingToast',
originalToastSetting
)
} catch (err) {
console.error(
'[useApplyChanges] Failed to restore reconnect toast setting:',
err
)
}
isRestarting.value = false
isRestartCompleted.value = true
setTimeout(() => {
onClose?.()
}, 3000)
}
}
let hasReconnected = false
const stopReconnectListener = useEventListener(
api,
'reconnected',
() => {
clearTimeout(reconnectTimeout)
hasReconnected = true
void onReconnect()
},
{ once: true }
)
const RECONNECT_TIMEOUT_MS = 120_000 // 2 minutes
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined
try {
await settingStore.set('Comfy.Toast.DisableReconnectingToast', true)
await useComfyManagerService().rebootComfyUI()
reconnectTimeout = setTimeout(async () => {
if (hasReconnected) return
stopReconnectListener()
try {
await settingStore.set(
'Comfy.Toast.DisableReconnectingToast',
originalToastSetting
)
} catch (err) {
console.error(
'[useApplyChanges] Failed to restore reconnect toast setting:',
err
)
}
isRestarting.value = false
isRestartCompleted.value = false
console.error('[useApplyChanges] Reconnect timed out')
}, RECONNECT_TIMEOUT_MS)
} catch (error) {
stopReconnectListener()
try {
await settingStore.set(
'Comfy.Toast.DisableReconnectingToast',
originalToastSetting
)
} catch (restoreErr) {
console.error(
'[useApplyChanges] Failed to restore reconnect toast setting:',
restoreErr
)
} finally {
isRestarting.value = false
isRestartCompleted.value = false
onClose?.()
}
throw error
}
}
return { isRestarting, isRestartCompleted, applyChanges }
})