Compare commits

...

5 Commits

Author SHA1 Message Date
Hunter Senft-Grupp
a7da951b90 feat: support parallel job execution in executionStore
- handleExecutionStart: don't clobber activeJobId when another job is running,
  only switch if idle or if the new job matches the current canvas workflow
- handleProgressState: only update nodeProgressStates for the active job,
  always update per-job state
- resetExecutionState: only clear/promote when the active job finishes,
  promote next running job if available
2026-02-22 02:47:06 -05:00
Christian Byrne
16ddcfdbaf feat: add toolkit node tracking to execution telemetry (#9073)
## Summary

Add toolkit (Essentials) node tracking to execution telemetry, enabling
measurement of toolkit node adoption and popularity.

## Changes

- **What**: Add `has_toolkit_nodes`, `toolkit_node_names`, and
`toolkit_node_count` fields to `ExecutionContext` and
`RunButtonProperties`. Toolkit nodes are identified via a hardcoded set
of node type names (10 novel Essentials nodes) and by `python_module ===
'comfy_essentials'` for blueprint nodes. Detection runs inside the
existing `reduceAllNodes()` traversal — no additional graph walks.

## Review Focus

- Toolkit node identification is frontend-only (no backend flag) — uses
two mechanisms: hardcoded `TOOLKIT_NODE_NAMES` set and
`TOOLKIT_BLUEPRINT_MODULES` for blueprints
- API node overlap is intentional — a node can appear in both
`api_node_names` and `toolkit_node_names`
- Blueprint detection via `python_module` automatically picks up new
essentials blueprints without code changes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9073-feat-add-toolkit-node-tracking-to-execution-telemetry-30f6d73d365081b3ac91e697889c58b6)
by [Unito](https://www.unito.io)
2026-02-21 21:31:09 -08:00
Christian Byrne
ef5198be25 fix: invalidate loader node dropdown cache after model asset deletion (#8434)
## Summary

When deleting a model asset (checkpoint, lora, etc.), the loader node
dropdowns now update correctly by invalidating the category-keyed cache.

## Problem

After deleting a model asset in the asset browser, the loader node
dropdowns (e.g., CheckpointLoaderSimple, LoraLoader) still showed the
deleted model. Users had to refresh or re-open the dropdown to see the
updated list.

## Solution

After successful asset deletion, check each deleted asset's tags for
model categories (checkpoints, loras, etc.) and call
`assetsStore.invalidateCategory()` for each affected category. This
triggers a refetch when the dropdown is next accessed.

## Changes

- In `useMediaAssetActions.ts`:
  - After deletion, iterate through deleted assets' tags
- Check if each tag corresponds to a model category using
`modelToNodeStore.getAllNodeProviders()`
  - Call `invalidateCategory()` for each affected category

- In `useMediaAssetActions.test.ts`:
  - Added mocks for `useAssetsStore` and `useModelToNodeStore`
  - Added tests for deletion invalidation behavior

## Testing

- Added unit tests verifying:
  - Model cache is invalidated when deleting model assets
  - Multiple categories are invalidated when deleting multiple assets
  - Non-model assets (input, output) don't trigger invalidation

## Part of Stack

This is **PR 2 of 2** in a stacked PR series:
1. **[PR 1](https://github.com/Comfy-Org/ComfyUI_frontend/pull/8433)**:
Refactor asset cache to category-keyed (architectural improvement)
2. **This PR**: Fix deletion invalidation using the clean architecture

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8434-fix-invalidate-loader-node-dropdown-cache-after-model-asset-deletion-2f76d73d3650813181aedc373d9799c6)
by [Unito](https://www.unito.io)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved model cache invalidation after asset deletions — only
relevant model categories are invalidated and non-model assets are
ignored.
* Fixed edge-rendering behavior so reroutes are cleared correctly in the
canvas.

* **Chores**
* Added category-aware cache management and targeted refreshes for model
assets.

* **Tests**
* Expanded tests for cache invalidation, category handling, workflow
interactions, and related mocks.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-21 20:55:32 -08:00
Dante
38675e658f feat: add dev-time feature flag overrides via localStorage (#9075)
## Summary
- Adds `localStorage`-based dev-time override for feature flags, with
`ff:` key prefix (e.g.
`localStorage.setItem('ff:team_workspaces_enabled', 'true')`)
- Override priority: dev localStorage > remoteConfig >
serverFeatureFlags
- Guarded by `import.meta.env.DEV` — tree-shaken to empty function in
production builds
- Extracts `resolveFlag` helper in `useFeatureFlags` to eliminate
repeated fallback pattern

Fixes #9054

## Test plan
- [x] `getDevOverride` unit tests: boolean/number/string/object parsing,
prefix isolation, invalid JSON warning
- [x] `api.getServerFeature` / `serverSupportsFeature` override tests
- [x] `useFeatureFlags` override priority tests, including
`teamWorkspacesEnabled` bypassing guards
- [x] Production build verified: `getDevOverride` compiles to empty
function body, localStorage never accessed
- [x] `pnpm typecheck`, `pnpm lint` clean

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9075-feat-add-dev-time-feature-flag-overrides-via-localStorage-30f6d73d365081b394d3ccc461987b1a)
by [Unito](https://www.unito.io)
2026-02-21 20:36:09 -08:00
Dante
bd95150f82 test: remove unused DebugHelper from browser_tests (#9017)
## Summary

- Remove unused `DebugHelper` class and its import from browser test
fixtures

Fixes #8581

## Test plan

- [x] Verify browser tests still pass without `DebugHelper`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9017-test-remove-unused-DebugHelper-from-browser_tests-30d6d73d36508158b8c9fc83670df4f3)
by [Unito](https://www.unito.io)
2026-02-22 11:52:54 +09:00
17 changed files with 813 additions and 218 deletions

View File

@@ -26,7 +26,6 @@ import { Topbar } from './components/Topbar'
import { CanvasHelper } from './helpers/CanvasHelper'
import { ClipboardHelper } from './helpers/ClipboardHelper'
import { CommandHelper } from './helpers/CommandHelper'
import { DebugHelper } from './helpers/DebugHelper'
import { DragDropHelper } from './helpers/DragDropHelper'
import { KeyboardHelper } from './helpers/KeyboardHelper'
import { NodeOperationsHelper } from './helpers/NodeOperationsHelper'
@@ -174,7 +173,6 @@ export class ComfyPage {
public readonly settingDialog: SettingDialog
public readonly confirmDialog: ConfirmDialog
public readonly vueNodes: VueNodeHelpers
public readonly debug: DebugHelper
public readonly subgraph: SubgraphHelper
public readonly canvasOps: CanvasHelper
public readonly nodeOps: NodeOperationsHelper
@@ -219,7 +217,6 @@ export class ComfyPage {
this.settingDialog = new SettingDialog(page, this)
this.confirmDialog = new ConfirmDialog(page)
this.vueNodes = new VueNodeHelpers(page)
this.debug = new DebugHelper(page, this.canvas)
this.subgraph = new SubgraphHelper(this)
this.canvasOps = new CanvasHelper(page, this.canvas, this.resetViewButton)
this.nodeOps = new NodeOperationsHelper(this)

View File

@@ -1,167 +0,0 @@
import type { Locator, Page, TestInfo } from '@playwright/test'
import type { Position } from '../types'
export interface DebugScreenshotOptions {
fullPage?: boolean
element?: 'canvas' | 'page'
markers?: Array<{ position: Position; id?: string }>
}
export class DebugHelper {
constructor(
private page: Page,
private canvas: Locator
) {}
async addMarker(
position: Position,
id: string = 'debug-marker'
): Promise<void> {
await this.page.evaluate(
([pos, markerId]) => {
const existing = document.getElementById(markerId)
if (existing) existing.remove()
const marker = document.createElement('div')
marker.id = markerId
marker.style.position = 'fixed'
marker.style.left = `${pos.x - 10}px`
marker.style.top = `${pos.y - 10}px`
marker.style.width = '20px'
marker.style.height = '20px'
marker.style.border = '2px solid red'
marker.style.borderRadius = '50%'
marker.style.backgroundColor = 'rgba(255, 0, 0, 0.3)'
marker.style.pointerEvents = 'none'
marker.style.zIndex = '10000'
document.body.appendChild(marker)
},
[position, id] as const
)
}
async removeMarkers(): Promise<void> {
await this.page.evaluate(() => {
document
.querySelectorAll('[id^="debug-marker"]')
.forEach((el) => el.remove())
})
}
async attachScreenshot(
testInfo: TestInfo,
name: string,
options?: DebugScreenshotOptions
): Promise<void> {
if (options?.markers) {
for (const marker of options.markers) {
await this.addMarker(marker.position, marker.id)
}
}
let screenshot: Buffer
const targetElement = options?.element || 'page'
if (targetElement === 'canvas') {
screenshot = await this.canvas.screenshot()
} else if (options?.fullPage) {
screenshot = await this.page.screenshot({ fullPage: true })
} else {
screenshot = await this.page.screenshot()
}
await testInfo.attach(name, {
body: screenshot,
contentType: 'image/png'
})
if (options?.markers) {
await this.removeMarkers()
}
}
async saveCanvasScreenshot(filename: string): Promise<void> {
await this.page.evaluate(async (filename) => {
const canvas = document.getElementById(
'graph-canvas'
) as HTMLCanvasElement
if (!canvas) {
throw new Error('Canvas not found')
}
return new Promise<void>((resolve) => {
canvas.toBlob(async (blob) => {
if (!blob) {
throw new Error('Failed to create blob from canvas')
}
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
resolve()
}, 'image/png')
})
}, filename)
}
async getCanvasDataURL(): Promise<string> {
return await this.page.evaluate(() => {
const canvas = document.getElementById(
'graph-canvas'
) as HTMLCanvasElement
if (!canvas) {
throw new Error('Canvas not found')
}
return canvas.toDataURL('image/png')
})
}
async showCanvasOverlay(): Promise<void> {
await this.page.evaluate(() => {
const canvas = document.getElementById(
'graph-canvas'
) as HTMLCanvasElement
if (!canvas) {
throw new Error('Canvas not found')
}
const existingOverlay = document.getElementById('debug-canvas-overlay')
if (existingOverlay) {
existingOverlay.remove()
}
const overlay = document.createElement('div')
overlay.id = 'debug-canvas-overlay'
overlay.style.position = 'fixed'
overlay.style.top = '0'
overlay.style.left = '0'
overlay.style.zIndex = '9999'
overlay.style.backgroundColor = 'white'
overlay.style.padding = '10px'
overlay.style.border = '2px solid red'
const img = document.createElement('img')
img.src = canvas.toDataURL('image/png')
img.style.maxWidth = '800px'
img.style.maxHeight = '600px'
overlay.appendChild(img)
document.body.appendChild(overlay)
})
}
async hideCanvasOverlay(): Promise<void> {
await this.page.evaluate(() => {
const overlay = document.getElementById('debug-canvas-overlay')
if (overlay) {
overlay.remove()
}
})
}
}

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { isReactive, isReadonly } from 'vue'
import {
@@ -175,4 +175,49 @@ describe('useFeatureFlags', () => {
expect(flags.linearToggleEnabled).toBe(false)
})
})
describe('dev override via localStorage', () => {
afterEach(() => {
localStorage.clear()
})
it('resolveFlag returns localStorage override over remoteConfig and server value', () => {
vi.mocked(api.getServerFeature).mockReturnValue(false)
localStorage.setItem('ff:model_upload_button_enabled', 'true')
const { flags } = useFeatureFlags()
expect(flags.modelUploadButtonEnabled).toBe(true)
})
it('resolveFlag falls through to server when no override is set', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.ASSET_RENAME_ENABLED) return true
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.assetRenameEnabled).toBe(true)
})
it('direct server flags delegate override to api.getServerFeature', () => {
vi.mocked(api.getServerFeature).mockImplementation((path) => {
if (path === ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
return 'overridden'
return undefined
})
const { flags } = useFeatureFlags()
expect(flags.supportsPreviewMetadata).toBe('overridden')
})
it('teamWorkspacesEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
vi.mocked(distributionTypes).isCloud = false
localStorage.setItem('ff:team_workspaces_enabled', 'true')
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
})
})
})

View File

@@ -6,6 +6,7 @@ import {
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
/**
* Known server feature flags (top-level, not extensions)
@@ -24,6 +25,19 @@ export enum ServerFeatureFlag {
NODE_REPLACEMENTS = 'node_replacements'
}
/**
* Resolves a feature flag value with dev override > remoteConfig > serverFeature priority.
*/
function resolveFlag<T>(
flagKey: string,
remoteConfigValue: T | undefined,
defaultValue: T
): T {
const override = getDevOverride<T>(flagKey)
if (override !== undefined) return override
return remoteConfigValue ?? api.getServerFeature(flagKey, defaultValue)
}
/**
* Composable for reactive access to server-side feature flags
*/
@@ -39,38 +53,40 @@ export function useFeatureFlags() {
return api.getServerFeature(ServerFeatureFlag.MANAGER_SUPPORTS_V4)
},
get modelUploadButtonEnabled() {
return (
remoteConfig.value.model_upload_button_enabled ??
api.getServerFeature(
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
false
)
return resolveFlag(
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
remoteConfig.value.model_upload_button_enabled,
false
)
},
get assetRenameEnabled() {
return (
remoteConfig.value.asset_rename_enabled ??
api.getServerFeature(ServerFeatureFlag.ASSET_RENAME_ENABLED, false)
return resolveFlag(
ServerFeatureFlag.ASSET_RENAME_ENABLED,
remoteConfig.value.asset_rename_enabled,
false
)
},
get privateModelsEnabled() {
return (
remoteConfig.value.private_models_enabled ??
api.getServerFeature(ServerFeatureFlag.PRIVATE_MODELS_ENABLED, false)
return resolveFlag(
ServerFeatureFlag.PRIVATE_MODELS_ENABLED,
remoteConfig.value.private_models_enabled,
false
)
},
get onboardingSurveyEnabled() {
return (
remoteConfig.value.onboarding_survey_enabled ??
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, false)
return resolveFlag(
ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED,
remoteConfig.value.onboarding_survey_enabled,
false
)
},
get linearToggleEnabled() {
if (isNightly) return true
return (
remoteConfig.value.linear_toggle_enabled ??
api.getServerFeature(ServerFeatureFlag.LINEAR_TOGGLE_ENABLED, false)
return resolveFlag(
ServerFeatureFlag.LINEAR_TOGGLE_ENABLED,
remoteConfig.value.linear_toggle_enabled,
false
)
},
/**
@@ -80,11 +96,12 @@ export function useFeatureFlags() {
* and prevents race conditions during initialization.
*/
get teamWorkspacesEnabled() {
if (!isCloud) return false
const override = getDevOverride<boolean>(
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED
)
if (override !== undefined) return override
// Only return true if authenticated config has been loaded.
// This prevents race conditions where code checks this flag before
// WorkspaceAuthGate has refreshed the config with auth.
if (!isCloud) return false
if (!isAuthenticatedConfigLoaded.value) return false
return (
@@ -93,9 +110,10 @@ export function useFeatureFlags() {
)
},
get userSecretsEnabled() {
return (
remoteConfig.value.user_secrets_enabled ??
api.getServerFeature(ServerFeatureFlag.USER_SECRETS_ENABLED, false)
return resolveFlag(
ServerFeatureFlag.USER_SECRETS_ENABLED,
remoteConfig.value.user_secrets_enabled,
false
)
},
get nodeReplacementsEnabled() {

View File

@@ -0,0 +1,38 @@
/**
* Toolkit (Essentials) node detection constants.
*
* Used by telemetry to track toolkit node adoption and popularity.
* Only novel nodes — basic nodes (LoadImage, SaveImage, etc.) are excluded.
*
* Source: https://www.notion.so/comfy-org/2fe6d73d365080d0a951d14cdf540778
*/
/**
* Canonical node type names for individual toolkit nodes.
*/
export const TOOLKIT_NODE_NAMES: ReadonlySet<string> = new Set([
// Image Tools
'ImageCrop',
'ImageRotate',
'ImageBlur',
'ImageInvert',
'ImageCompare',
'Canny',
// Video Tools
'Video Slice',
// API Nodes
'RecraftRemoveBackgroundNode',
'RecraftVectorizeImageNode',
'KlingOmniProEditVideoNode'
])
/**
* python_module values that identify toolkit blueprint nodes.
* Essentials blueprints are registered with node_pack 'comfy_essentials',
* which maps to python_module on the node def.
*/
export const TOOLKIT_BLUEPRINT_MODULES: ReadonlySet<string> = new Set([
'comfy_essentials'
])

View File

@@ -1,4 +1,5 @@
import { createPinia, setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -35,12 +36,32 @@ vi.mock('vue-i18n', () => ({
})
}))
const mockShowDialog = vi.hoisted(() => vi.fn())
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
showDialog: vi.fn()
showDialog: mockShowDialog
})
}))
const mockInvalidateModelsForCategory = vi.hoisted(() => vi.fn())
const mockSetAssetDeleting = vi.hoisted(() => vi.fn())
const mockUpdateHistory = vi.hoisted(() => vi.fn())
const mockUpdateInputs = vi.hoisted(() => vi.fn())
const mockHasCategory = vi.hoisted(() => vi.fn())
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
setAssetDeleting: mockSetAssetDeleting,
updateHistory: mockUpdateHistory,
updateInputs: mockUpdateInputs,
invalidateModelsForCategory: mockInvalidateModelsForCategory,
hasCategory: mockHasCategory
})
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({})
}))
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({
copyToClipboard: vi.fn()
@@ -93,14 +114,33 @@ vi.mock('@/utils/typeGuardUtil', () => ({
isResultItemType: vi.fn().mockReturnValue(true)
}))
const mockGetAssetType = vi.hoisted(() => vi.fn())
vi.mock('@/platform/assets/utils/assetTypeUtil', () => ({
getAssetType: vi.fn().mockReturnValue('input')
getAssetType: mockGetAssetType
}))
vi.mock('../schemas/assetMetadataSchema', () => ({
getOutputAssetMetadata: vi.fn().mockReturnValue(null)
}))
const mockDeleteAsset = vi.hoisted(() => vi.fn())
vi.mock('../services/assetService', () => ({
assetService: {
deleteAsset: mockDeleteAsset
}
}))
vi.mock('@/scripts/api', () => ({
api: {
deleteItem: vi.fn(),
apiURL: vi.fn((path: string) => `http://localhost:8188/api${path}`),
internalURL: vi.fn((path: string) => `http://localhost:8188${path}`),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
user: 'test-user'
}
}))
function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'test-asset-id',
@@ -115,7 +155,7 @@ function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
describe('useMediaAssetActions', () => {
beforeEach(() => {
vi.resetModules()
setActivePinia(createPinia())
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
capturedFilenames.values = []
mockIsCloud.value = false
@@ -218,4 +258,114 @@ describe('useMediaAssetActions', () => {
})
})
})
describe('deleteAssets - model cache invalidation', () => {
beforeEach(() => {
mockIsCloud.value = true
mockGetAssetType.mockReturnValue('input')
mockDeleteAsset.mockResolvedValue(undefined)
mockInvalidateModelsForCategory.mockClear()
mockSetAssetDeleting.mockClear()
mockUpdateHistory.mockClear()
mockUpdateInputs.mockClear()
mockHasCategory.mockClear()
// By default, hasCategory returns true for model categories
mockHasCategory.mockImplementation(
(tag: string) => tag === 'checkpoints' || tag === 'loras'
)
})
it('should invalidate model cache when deleting a model asset', async () => {
const actions = useMediaAssetActions()
const modelAsset = createMockAsset({
id: 'checkpoint-1',
name: 'model.safetensors',
tags: ['models', 'checkpoints']
})
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
await actions.deleteAssets(modelAsset)
// Only 'checkpoints' exists in cache; 'models' is excluded
expect(mockInvalidateModelsForCategory).toHaveBeenCalledTimes(1)
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
'checkpoints'
)
})
it('should invalidate multiple categories for multiple assets', async () => {
const actions = useMediaAssetActions()
const assets = [
createMockAsset({ id: '1', tags: ['models', 'checkpoints'] }),
createMockAsset({ id: '2', tags: ['models', 'loras'] })
]
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
await actions.deleteAssets(assets)
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
'checkpoints'
)
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith('loras')
})
it('should not invalidate model cache for non-model assets', async () => {
const actions = useMediaAssetActions()
const inputAsset = createMockAsset({
id: 'input-1',
name: 'image.png',
tags: ['input']
})
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
await actions.deleteAssets(inputAsset)
// 'input' tag is excluded, so no cache invalidation
expect(mockInvalidateModelsForCategory).not.toHaveBeenCalled()
})
it('should only invalidate categories that exist in cache', async () => {
const actions = useMediaAssetActions()
// hasCategory returns false for 'unknown-category'
mockHasCategory.mockImplementation((tag: string) => tag === 'checkpoints')
const assets = [
createMockAsset({ id: '1', tags: ['models', 'checkpoints'] }),
createMockAsset({ id: '2', tags: ['models', 'unknown-category'] })
]
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
await actions.deleteAssets(assets)
// Only checkpoints should be invalidated (unknown-category not in cache)
expect(mockInvalidateModelsForCategory).toHaveBeenCalledTimes(1)
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
'checkpoints'
)
})
})
})

View File

@@ -26,6 +26,8 @@ import type { AssetItem } from '../schemas/assetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import { assetService } from '../services/assetService'
const EXCLUDED_TAGS = new Set(['models', 'input', 'output'])
export function useMediaAssetActions() {
const { t } = useI18n()
const toast = useToast()
@@ -639,6 +641,22 @@ export function useMediaAssetActions() {
await assetsStore.updateInputs()
}
// Invalidate model caches for affected categories
const modelCategories = new Set<string>()
for (const asset of assetArray) {
for (const tag of asset.tags ?? []) {
if (EXCLUDED_TAGS.has(tag)) continue
if (assetsStore.hasCategory(tag)) {
modelCategories.add(tag)
}
}
}
for (const category of modelCategories) {
assetsStore.invalidateModelsForCategory(category)
}
// Show appropriate feedback based on results
if (failed.length === 0) {
toast.add({

View File

@@ -0,0 +1,181 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
vi.mock('vue', async () => {
const actual = await vi.importActual('vue')
return {
...actual,
watch: vi.fn()
}
})
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({
onUserResolved: vi.fn()
})
}))
vi.mock('@/platform/telemetry/topupTracker', () => ({
checkForCompletedTopup: vi.fn(),
clearTopupTracking: vi.fn(),
startTopupTracking: vi.fn()
}))
const hoisted = vi.hoisted(() => ({
mockNodeDefsByName: {} as Record<string, unknown>,
mockNodes: [] as Pick<LGraphNode, 'type' | 'isSubgraphNode'>[]
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({
nodeDefsByName: hoisted.mockNodeDefsByName
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: null
})
}))
vi.mock(
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
() => ({
useWorkflowTemplatesStore: () => ({
knownTemplateNames: new Set()
})
})
)
function mockNode(
type: string,
isSubgraph = false
): Pick<LGraphNode, 'type' | 'isSubgraphNode'> {
return {
type,
isSubgraphNode: (() => isSubgraph) as LGraphNode['isSubgraphNode']
}
}
vi.mock('@/utils/graphTraversalUtil', () => ({
reduceAllNodes: vi.fn((_graph, reducer, initial) => {
let result = initial
for (const node of hoisted.mockNodes) {
result = reducer(result, node)
}
return result
})
}))
vi.mock('@/scripts/app', () => ({
app: { rootGraph: {} }
}))
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
remoteConfig: { value: null }
}))
import { MixpanelTelemetryProvider } from './MixpanelTelemetryProvider'
describe('MixpanelTelemetryProvider.getExecutionContext', () => {
let provider: MixpanelTelemetryProvider
beforeEach(() => {
vi.clearAllMocks()
hoisted.mockNodes.length = 0
for (const key of Object.keys(hoisted.mockNodeDefsByName)) {
delete hoisted.mockNodeDefsByName[key]
}
provider = new MixpanelTelemetryProvider()
})
it('returns has_toolkit_nodes false when no toolkit nodes are present', () => {
hoisted.mockNodes.push(mockNode('KSampler'), mockNode('LoadImage'))
hoisted.mockNodeDefsByName['KSampler'] = {
name: 'KSampler',
python_module: 'nodes'
}
hoisted.mockNodeDefsByName['LoadImage'] = {
name: 'LoadImage',
python_module: 'nodes'
}
const context = provider.getExecutionContext()
expect(context.has_toolkit_nodes).toBe(false)
expect(context.toolkit_node_names).toEqual([])
expect(context.toolkit_node_count).toBe(0)
})
it('detects individual toolkit nodes by type name', () => {
hoisted.mockNodes.push(mockNode('Canny'), mockNode('KSampler'))
hoisted.mockNodeDefsByName['Canny'] = {
name: 'Canny',
python_module: 'comfy_extras.nodes_canny'
}
hoisted.mockNodeDefsByName['KSampler'] = {
name: 'KSampler',
python_module: 'nodes'
}
const context = provider.getExecutionContext()
expect(context.has_toolkit_nodes).toBe(true)
expect(context.toolkit_node_names).toEqual(['Canny'])
expect(context.toolkit_node_count).toBe(1)
})
it('detects blueprint toolkit nodes via python_module', () => {
const blueprintType = 'SubgraphBlueprint.text_to_image'
hoisted.mockNodes.push(mockNode(blueprintType, true))
hoisted.mockNodeDefsByName[blueprintType] = {
name: blueprintType,
python_module: 'comfy_essentials'
}
const context = provider.getExecutionContext()
expect(context.has_toolkit_nodes).toBe(true)
expect(context.toolkit_node_names).toEqual([blueprintType])
expect(context.toolkit_node_count).toBe(1)
})
it('deduplicates toolkit_node_names when same type appears multiple times', () => {
hoisted.mockNodes.push(mockNode('Canny'), mockNode('Canny'))
hoisted.mockNodeDefsByName['Canny'] = {
name: 'Canny',
python_module: 'comfy_extras.nodes_canny'
}
const context = provider.getExecutionContext()
expect(context.toolkit_node_names).toEqual(['Canny'])
expect(context.toolkit_node_count).toBe(2)
})
it('allows a node to appear in both api_node_names and toolkit_node_names', () => {
hoisted.mockNodes.push(mockNode('RecraftRemoveBackgroundNode'))
hoisted.mockNodeDefsByName['RecraftRemoveBackgroundNode'] = {
name: 'RecraftRemoveBackgroundNode',
python_module: 'comfy_extras.nodes_api',
api_node: true
}
const context = provider.getExecutionContext()
expect(context.has_api_nodes).toBe(true)
expect(context.api_node_names).toEqual(['RecraftRemoveBackgroundNode'])
expect(context.has_toolkit_nodes).toBe(true)
expect(context.toolkit_node_names).toEqual(['RecraftRemoveBackgroundNode'])
})
it('uses node.type as tracking name when nodeDef is missing', () => {
hoisted.mockNodes.push(mockNode('ImageCrop'))
const context = provider.getExecutionContext()
expect(context.has_toolkit_nodes).toBe(true)
expect(context.toolkit_node_names).toEqual(['ImageCrop'])
})
})

View File

@@ -2,6 +2,10 @@ import type { OverridedMixpanel } from 'mixpanel-browser'
import { watch } from 'vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import {
TOOLKIT_BLUEPRINT_MODULES,
TOOLKIT_NODE_NAMES
} from '@/constants/toolkitNodes'
import {
checkForCompletedTopup as checkTopupUtil,
clearTopupTracking as clearTopupUtil,
@@ -285,6 +289,8 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
subgraph_count: executionContext.subgraph_count,
has_api_nodes: executionContext.has_api_nodes,
api_node_names: executionContext.api_node_names,
has_toolkit_nodes: executionContext.has_toolkit_nodes,
toolkit_node_names: executionContext.toolkit_node_names,
trigger_source: options?.trigger_source
}
@@ -432,10 +438,13 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
type NodeMetrics = {
custom_node_count: number
api_node_count: number
toolkit_node_count: number
subgraph_count: number
total_node_count: number
has_api_nodes: boolean
api_node_names: string[]
has_toolkit_nodes: boolean
toolkit_node_names: string[]
}
const nodeCounts = reduceAllNodes<NodeMetrics>(
@@ -458,8 +467,21 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
}
}
const isToolkitNode =
TOOLKIT_NODE_NAMES.has(node.type) ||
(nodeDef?.python_module !== undefined &&
TOOLKIT_BLUEPRINT_MODULES.has(nodeDef.python_module))
if (isToolkitNode) {
metrics.has_toolkit_nodes = true
const trackingName = nodeDef?.name ?? node.type
if (!metrics.toolkit_node_names.includes(trackingName)) {
metrics.toolkit_node_names.push(trackingName)
}
}
metrics.custom_node_count += isCustomNode ? 1 : 0
metrics.api_node_count += isApiNode ? 1 : 0
metrics.toolkit_node_count += isToolkitNode ? 1 : 0
metrics.subgraph_count += isSubgraph ? 1 : 0
metrics.total_node_count += 1
@@ -468,10 +490,13 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
{
custom_node_count: 0,
api_node_count: 0,
toolkit_node_count: 0,
subgraph_count: 0,
total_node_count: 0,
has_api_nodes: false,
api_node_names: []
api_node_names: [],
has_toolkit_nodes: false,
toolkit_node_names: []
}
)

View File

@@ -59,6 +59,8 @@ export interface RunButtonProperties {
subgraph_count: number
has_api_nodes: boolean
api_node_names: string[]
has_toolkit_nodes: boolean
toolkit_node_names: string[]
trigger_source?: ExecutionTriggerSource
}
@@ -82,6 +84,9 @@ export interface ExecutionContext {
total_node_count: number
has_api_nodes: boolean
api_node_names: string[]
has_toolkit_nodes: boolean
toolkit_node_names: string[]
toolkit_node_count: number
trigger_source?: ExecutionTriggerSource
}

View File

@@ -233,4 +233,37 @@ describe('API Feature Flags', () => {
expect(flag.value).toBe(true)
})
})
describe('Dev override via localStorage', () => {
afterEach(() => {
localStorage.clear()
})
it('getServerFeature returns localStorage override over server value', () => {
api.serverFeatureFlags.value = { some_flag: false }
localStorage.setItem('ff:some_flag', 'true')
expect(api.getServerFeature('some_flag')).toBe(true)
})
it('serverSupportsFeature returns localStorage override over server value', () => {
api.serverFeatureFlags.value = { some_flag: false }
localStorage.setItem('ff:some_flag', 'true')
expect(api.serverSupportsFeature('some_flag')).toBe(true)
})
it('getServerFeature falls through when no override is set', () => {
api.serverFeatureFlags.value = { some_flag: 'server_value' }
expect(api.getServerFeature('some_flag')).toBe('server_value')
})
it('getServerFeature override works with numeric values', () => {
api.serverFeatureFlags.value = { max_upload_size: 100 }
localStorage.setItem('ff:max_upload_size', '999')
expect(api.getServerFeature('max_upload_size')).toBe(999)
})
})
})

View File

@@ -5,6 +5,7 @@ import { trimEnd } from 'es-toolkit'
import { ref } from 'vue'
import defaultClientFeatureFlags from '@/config/clientFeatureFlags.json' with { type: 'json' }
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
import type {
ModelFile,
ModelFolderInfo
@@ -1299,6 +1300,8 @@ export class ComfyApi extends EventTarget {
* @returns true if the feature is supported, false otherwise
*/
serverSupportsFeature(featureName: string): boolean {
const override = getDevOverride<boolean>(featureName)
if (override !== undefined) return override
return get(this.serverFeatureFlags.value, featureName) === true
}
@@ -1309,6 +1312,8 @@ export class ComfyApi extends EventTarget {
* @returns The feature value or default
*/
getServerFeature<T = unknown>(featureName: string, defaultValue?: T): T {
const override = getDevOverride<T>(featureName)
if (override !== undefined) return override
return get(this.serverFeatureFlags.value, featureName, defaultValue) as T
}

View File

@@ -507,12 +507,12 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
mockIsCloud.value = false
})
const createMockAsset = (id: string) => ({
const createMockAsset = (id: string, tags: string[] = ['models']) => ({
id,
name: `asset-${id}`,
size: 100,
created_at: new Date().toISOString(),
tags: ['models'],
tags,
preview_url: `http://test.com/${id}`
})
@@ -751,4 +751,103 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
expect(store.getAssets('tag:models')).toEqual([])
})
})
describe('hasCategory', () => {
it('should return true for loaded categories', async () => {
const store = useAssetsStore()
const assets = [createMockAsset('asset-1')]
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValue(assets)
await store.updateModelsForNodeType('CheckpointLoaderSimple')
expect(store.hasCategory('checkpoints')).toBe(true)
})
it('should return true for tag-based category when tag: prefix is not used', async () => {
const store = useAssetsStore()
const assets = [createMockAsset('asset-1')]
vi.mocked(assetService.getAssetsByTag).mockResolvedValue(assets)
await store.updateModelsForTag('models')
// hasCategory('models') checks for both 'models' and 'tag:models'
expect(store.hasCategory('models')).toBe(true)
})
it('should return false for unloaded categories', () => {
const store = useAssetsStore()
expect(store.hasCategory('checkpoints')).toBe(false)
expect(store.hasCategory('unknown-category')).toBe(false)
})
it('should return false after category is invalidated', async () => {
const store = useAssetsStore()
const assets = [createMockAsset('asset-1')]
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValue(assets)
await store.updateModelsForNodeType('CheckpointLoaderSimple')
expect(store.hasCategory('checkpoints')).toBe(true)
store.invalidateCategory('checkpoints')
expect(store.hasCategory('checkpoints')).toBe(false)
})
})
describe('invalidateModelsForCategory', () => {
it('should clear cache for category and trigger refetch on next access', async () => {
const store = useAssetsStore()
const initialAssets = [createMockAsset('initial-1')]
const refreshedAssets = [
createMockAsset('refreshed-1'),
createMockAsset('refreshed-2')
]
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce(
initialAssets
)
await store.updateModelsForNodeType('CheckpointLoaderSimple')
expect(store.getAssets('CheckpointLoaderSimple')).toHaveLength(1)
store.invalidateModelsForCategory('checkpoints')
// Cache should be cleared
expect(store.hasCategory('checkpoints')).toBe(false)
expect(store.getAssets('CheckpointLoaderSimple')).toEqual([])
// Next fetch should get fresh data
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce(
refreshedAssets
)
await store.updateModelsForNodeType('CheckpointLoaderSimple')
expect(store.getAssets('CheckpointLoaderSimple')).toHaveLength(2)
})
it('should clear tag-based caches', async () => {
const store = useAssetsStore()
const tagAssets = [createMockAsset('tag-1'), createMockAsset('tag-2')]
vi.mocked(assetService.getAssetsByTag).mockResolvedValue(tagAssets)
await store.updateModelsForTag('checkpoints')
await store.updateModelsForTag('models')
expect(store.getAssets('tag:checkpoints')).toHaveLength(2)
expect(store.getAssets('tag:models')).toHaveLength(2)
store.invalidateModelsForCategory('checkpoints')
expect(store.getAssets('tag:checkpoints')).toEqual([])
expect(store.getAssets('tag:models')).toEqual([])
})
it('should handle unknown categories gracefully', () => {
const store = useAssetsStore()
expect(() =>
store.invalidateModelsForCategory('unknown-category')
).not.toThrow()
})
})
})

View File

@@ -375,6 +375,18 @@ export const useAssetsStore = defineStore('assets', () => {
return modelStateByCategory.value.has(category)
}
/**
* Check if a category exists in the cache.
* Checks both direct category keys and tag-prefixed keys.
* @param category The category to check (e.g., 'checkpoints', 'loras')
*/
function hasCategory(category: string): boolean {
return (
modelStateByCategory.value.has(category) ||
modelStateByCategory.value.has(`tag:${category}`)
)
}
/**
* Internal helper to fetch and cache assets for a category.
* Loads first batch immediately, then progressively loads remaining batches.
@@ -608,17 +620,30 @@ export const useAssetsStore = defineStore('assets', () => {
}
}
/**
* Invalidate model caches for a given category (e.g., 'checkpoints', 'loras')
* Clears the category cache and tag-based caches so next access triggers refetch
* @param category The model category to invalidate (e.g., 'checkpoints')
*/
function invalidateModelsForCategory(category: string): void {
invalidateCategory(category)
invalidateCategory(`tag:${category}`)
invalidateCategory('tag:models')
}
return {
getAssets,
isLoading,
getError,
hasMore,
hasAssetKey,
hasCategory,
updateModelsForNodeType,
updateModelsForTag,
invalidateCategory,
updateAssetMetadata,
updateAssetTags
updateAssetTags,
invalidateModelsForCategory
}
}
@@ -629,11 +654,13 @@ export const useAssetsStore = defineStore('assets', () => {
getError: () => undefined,
hasMore: () => false,
hasAssetKey: () => false,
hasCategory: () => false,
updateModelsForNodeType: async () => {},
invalidateCategory: () => {},
updateModelsForTag: async () => {},
updateAssetMetadata: async () => {},
updateAssetTags: async () => {}
updateAssetTags: async () => {},
invalidateModelsForCategory: () => {}
}
}
@@ -643,11 +670,13 @@ export const useAssetsStore = defineStore('assets', () => {
getError,
hasMore,
hasAssetKey,
hasCategory,
updateModelsForNodeType,
updateModelsForTag,
invalidateCategory,
updateAssetMetadata,
updateAssetTags
updateAssetTags,
invalidateModelsForCategory
} = getModelState()
// Watch for completed downloads and refresh model caches
@@ -718,12 +747,14 @@ export const useAssetsStore = defineStore('assets', () => {
getError,
hasMore,
hasAssetKey,
hasCategory,
// Model assets - actions
updateModelsForNodeType,
updateModelsForTag,
invalidateCategory,
updateAssetMetadata,
updateAssetTags
updateAssetTags,
invalidateModelsForCategory
}
})

View File

@@ -215,9 +215,26 @@ export const useExecutionStore = defineStore('execution', () => {
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
executionErrorStore.clearAllErrors()
activeJobId.value = e.detail.prompt_id
queuedJobs.value[activeJobId.value] ??= { nodes: {} }
clearInitializationByJobId(activeJobId.value)
const jobId = e.detail.prompt_id
queuedJobs.value[jobId] ??= { nodes: {} }
clearInitializationByJobId(jobId)
// Only set activeJobId if idle or if this job's workflow matches the current canvas
if (!activeJobId.value) {
activeJobId.value = jobId
} else {
const newJobWorkflowId = jobIdToWorkflowId.value.get(jobId)
const currentWorkflow = workflowStore.activeWorkflow
if (
newJobWorkflowId &&
currentWorkflow &&
String(
currentWorkflow.activeState?.id ?? currentWorkflow.initialState?.id
) === newJobWorkflowId
) {
activeJobId.value = jobId
}
}
}
function handleExecutionCached(e: CustomEvent<ExecutionCachedWsMessage>) {
@@ -282,12 +299,16 @@ export const useExecutionStore = defineStore('execution', () => {
}
}
// Update the progress states for all nodes
// Update per-job progress (always)
nodeProgressStatesByJob.value = {
...nodeProgressStatesByJob.value,
[jobId]: nodes
}
nodeProgressStates.value = nodes
// Only update the "active" progress states if this is the active job
if (jobId === activeJobId.value) {
nodeProgressStates.value = nodes
}
// If we have progress for the currently executing node, update it for backwards compatibility
if (executingNodeId.value && nodes[executingNodeId.value]) {
@@ -424,20 +445,30 @@ export const useExecutionStore = defineStore('execution', () => {
* Reset execution-related state after a run completes or is stopped.
*/
function resetExecutionState(jobIdParam?: string | null) {
nodeProgressStates.value = {}
const jobId = jobIdParam ?? activeJobId.value ?? null
if (jobId) {
const map = { ...nodeProgressStatesByJob.value }
delete map[jobId]
nodeProgressStatesByJob.value = map
useJobPreviewStore().clearPreview(jobId)
delete queuedJobs.value[jobId]
}
if (activeJobId.value) {
delete queuedJobs.value[activeJobId.value]
// Only clear active state if this was the active job
if (jobId && jobId === activeJobId.value) {
// Promote next running job if any
const nextRunning = runningJobIds.value.find((id) => id !== jobId)
if (nextRunning) {
activeJobId.value = nextRunning
nodeProgressStates.value =
nodeProgressStatesByJob.value[nextRunning] ?? {}
} else {
activeJobId.value = null
nodeProgressStates.value = {}
_executingNodeProgress.value = null
}
executionErrorStore.clearPromptError()
}
activeJobId.value = null
_executingNodeProgress.value = null
executionErrorStore.clearPromptError()
}
function getNodeIdIfExecuting(nodeId: string | number) {

View File

@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
describe('getDevOverride', () => {
afterEach(() => {
localStorage.clear()
vi.restoreAllMocks()
})
it('returns undefined when no override is set', () => {
expect(getDevOverride('some_flag')).toBeUndefined()
})
it('returns parsed boolean true', () => {
localStorage.setItem('ff:some_flag', 'true')
expect(getDevOverride<boolean>('some_flag')).toBe(true)
})
it('returns parsed boolean false', () => {
localStorage.setItem('ff:some_flag', 'false')
expect(getDevOverride<boolean>('some_flag')).toBe(false)
})
it('returns parsed number', () => {
localStorage.setItem('ff:max_upload_size', '209715200')
expect(getDevOverride<number>('max_upload_size')).toBe(209715200)
})
it('returns parsed string', () => {
localStorage.setItem('ff:some_flag', '"hello"')
expect(getDevOverride<string>('some_flag')).toBe('hello')
})
it('returns parsed object', () => {
localStorage.setItem('ff:complex', '{"nested": true}')
expect(getDevOverride<Record<string, boolean>>('complex')).toEqual({
nested: true
})
})
it('uses ff: prefix for localStorage keys', () => {
localStorage.setItem('some_flag', 'true')
expect(getDevOverride('some_flag')).toBeUndefined()
localStorage.setItem('ff:some_flag', 'true')
expect(getDevOverride('some_flag')).toBe(true)
})
it('returns undefined and warns on invalid JSON', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
localStorage.setItem('ff:bad', 'True')
expect(getDevOverride('bad')).toBeUndefined()
expect(warnSpy).toHaveBeenCalledWith(
'[ff] Invalid JSON for override "bad":',
'True'
)
})
})

View File

@@ -0,0 +1,26 @@
const FF_PREFIX = 'ff:'
/**
* Gets a dev-time feature flag override from localStorage.
* Stripped from production builds via import.meta.env.DEV tree-shaking.
*
* Returns undefined (not null) as the "no override" sentinel because
* null is a valid JSON value — JSON.parse('null') returns null.
* Using undefined avoids ambiguity between "no override set" and
* "override explicitly set to null".
*
* Usage in browser console:
* localStorage.setItem('ff:team_workspaces_enabled', 'true')
* localStorage.removeItem('ff:team_workspaces_enabled')
*/
export function getDevOverride<T>(flagKey: string): T | undefined {
if (!import.meta.env.DEV) return undefined
const raw = localStorage.getItem(`${FF_PREFIX}${flagKey}`)
if (raw === null) return undefined
try {
return JSON.parse(raw) as T
} catch {
console.warn(`[ff] Invalid JSON for override "${flagKey}":`, raw)
return undefined
}
}