fix: missing-asset correctness follow-ups from #10856 (#11233)

Follow-up to #10856. Four correctness issues and their regression tests.

## Bugs fixed

### 1. ErrorOverlay model count reflected node selection

`useErrorGroups` exposed `filteredMissingModelGroups` under the public
name `missingModelGroups`. `ErrorOverlay.vue` read that alias to compute
its model count label, so selecting a node shrank the overlay total. The
overlay must always show the whole workflow's errors.

Exposed both shapes explicitly: `missingModelGroups` /
`missingMediaGroups` (unfiltered totals) and
`filteredMissingModelGroups` / `filteredMissingMediaGroups`
(selection-scoped). `TabErrors.vue` destructures the filtered variant
with an alias.


Before 


https://github.com/user-attachments/assets/eb848c5f-d092-4a4f-b86f-d22bb4408003

After 


https://github.com/user-attachments/assets/75e67819-c9f2-45ec-9241-74023eca6120



### 2. Bypass → un-bypass dropped url/hash metadata

Realtime `scanNodeModelCandidates` only reads widget values, so
un-bypass produced a fresh candidate without the url that
`enrichWithEmbeddedMetadata` had previously attached from
`graphData.models`. `MissingModelRow`'s download/copy-url buttons
disappeared after a bypass/un-bypass cycle.

Added `enrichCandidateFromNodeProperties` that copies
`url`/`hash`/`directory` from the node's own `properties.models` — which
persists across mode toggles — into each scanned candidate. Applied to
every call site of the per-node scan. A later fix in the same branch
also enforces directory agreement to prevent a same-name /
different-directory collision from stamping the wrong metadata.

Before 


https://github.com/user-attachments/assets/39039d83-4d55-41a9-9d01-dec40843741b

After 


https://github.com/user-attachments/assets/047a603b-fb52-4320-886d-dfeed457d833



### 3. Initial full scan surfaced interior errors of a muted/bypassed
subgraph container

`scanAllModelCandidates`, `scanAllMediaCandidates`, and the JSON-based
missing-node scan only check each node's own mode. Interior nodes whose
parent container was bypassed passed the filter.

Added `isAncestorPathActive(rootGraph, executionId)` to
`graphTraversalUtil` and post-filter the three pipelines in `app.ts`
after the live rootGraph is configured. The filter uses the execution-ID
path (`"65:63"` → check node 65's mode) so it handles both
live-scan-produced and JSON-enrichment-produced candidates.

Before


https://github.com/user-attachments/assets/3032d46b-81cd-420e-ab8e-f58392267602

After 


https://github.com/user-attachments/assets/02a01931-951d-4a48-986c-06424044fbf8




### 4. Bypassed subgraph entry re-surfaced interior errors

`useGraphNodeManager` replays `graph.onNodeAdded` for each existing
interior node when the Vue node manager initializes on subgraph entry.
That chain reached `scanSingleNodeErrors` via
`installErrorClearingHooks`' `onNodeAdded` override. Each interior
node's own mode was active, so the caller guards passed and the scan
re-introduced the error that the initial pipeline had correctly
suppressed.

Added an ancestor-activity gate at the top of `scanSingleNodeErrors`,
the single entry point shared by paste, un-bypass, subgraph entry, and
subgraph container activation. A later commit also hardens this guard
against detached nodes (null execution ID → skip) and applies the same
ancestor check to `isCandidateStillActive` in the realtime verification
callback.

Before


https://github.com/user-attachments/assets/fe44862d-f1d6-41ed-982d-614a7e83d441

After


https://github.com/user-attachments/assets/497a76ce-3caa-479f-9024-4cd0f7bd20a4



## Tests

- 6 unit tests for `isAncestorPathActive` (root, active,
immediate-bypass, deep-nested mute, unresolvable ancestor, null
rootGraph)
- 4 unit tests for `enrichCandidateFromNodeProperties` (enrichment,
no-overwrite, name mismatch, directory mismatch)
- 1 unit test for `scanSingleNodeErrors` ancestor guard (subgraph entry
replaying onNodeAdded)
- 2 unit tests for `useErrorGroups` dual export + ErrorOverlay contract
- 4 E2E tests:
- ErrorOverlay model count stays constant when a node is selected (new
fixture `missing_models_distinct.json`)
- Bypass/un-bypass cycle preserves Copy URL button (uses
`missing_models_from_node_properties`)
- Loading a workflow with bypassed subgraph suppresses interior missing
model error (new fixture `missing_models_in_bypassed_subgraph.json`)
- Entering a bypassed subgraph does not resurface interior missing model
error (shares the above fixture)

`pnpm typecheck`, `pnpm lint`, 206 related unit tests passing.

## Follow-up

Several items raised by code review are deferred as pre-existing tech
debt or scope-avoided refactors. Tracked via comments on #11215 and
#11216.

---
Follows up on #10856.
This commit is contained in:
jaeone94
2026-04-15 19:58:24 +09:00
committed by GitHub
parent 033b3dad3a
commit 693b8383d6
17 changed files with 1199 additions and 20 deletions

View File

@@ -15,6 +15,8 @@ import {
verifyAssetSupportedCandidates,
MODEL_FILE_EXTENSIONS
} from '@/platform/missingModel/missingModelScan'
import activeSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/activeSubgraphUnmatchedModel.json' with { type: 'json' }
import bypassedSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/bypassedSubgraphUnmatchedModel.json' with { type: 'json' }
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
@@ -156,6 +158,134 @@ describe('scanNodeModelCandidates', () => {
expect(result).toEqual([])
})
it('enriches candidates with url/hash/directory from node.properties.models', () => {
// Regression: bypass/un-bypass cycle previously lost url metadata
// because realtime scan only reads widget values. Per-node embedded
// metadata in `properties.models` persists across mode toggles, so
// the scan now enriches candidates from that source.
const graph = makeGraph([])
const node = fromAny<LGraphNode, unknown>({
id: 1,
type: 'CheckpointLoaderSimple',
widgets: [
makeComboWidget('ckpt_name', 'missing_model.safetensors', [
'other_model.safetensors'
])
],
properties: {
models: [
{
name: 'missing_model.safetensors',
url: 'https://example.com/missing_model',
directory: 'checkpoints',
hash: 'abc123',
hash_type: 'sha256'
}
]
}
})
const result = scanNodeModelCandidates(graph, node, noAssetSupport)
expect(result).toHaveLength(1)
expect(result[0].url).toBe('https://example.com/missing_model')
expect(result[0].directory).toBe('checkpoints')
expect(result[0].hash).toBe('abc123')
expect(result[0].hashType).toBe('sha256')
})
it('preserves existing candidate fields when enriching (no overwrite)', () => {
const graph = makeGraph([])
const node = fromAny<LGraphNode, unknown>({
id: 1,
type: 'CheckpointLoaderSimple',
widgets: [makeComboWidget('ckpt_name', 'missing_model.safetensors', [])],
properties: {
models: [
{
name: 'missing_model.safetensors',
url: 'https://example.com/new_url',
directory: 'checkpoints'
}
]
}
})
const result = scanNodeModelCandidates(
graph,
node,
noAssetSupport,
() => 'checkpoints'
)
expect(result).toHaveLength(1)
// scanComboWidget already sets directory via getDirectory; enrichment
// does not overwrite it.
expect(result[0].directory).toBe('checkpoints')
// url was not set by scan, so enrichment fills it in.
expect(result[0].url).toBe('https://example.com/new_url')
})
it('skips enrichment when candidate and embedded model directories differ', () => {
// A node can list the same model name under multiple directories
// (e.g. a LoRA present in both `loras` and `loras/subdir`). Name-only
// matching would stamp the wrong url/hash onto the candidate, so
// enrichment must agree on directory when the candidate already has
// one.
const graph = makeGraph([])
const node = fromAny<LGraphNode, unknown>({
id: 1,
type: 'CheckpointLoaderSimple',
widgets: [
makeComboWidget('ckpt_name', 'collision_model.safetensors', [])
],
properties: {
models: [
{
name: 'collision_model.safetensors',
url: 'https://example.com/wrong_dir_url',
directory: 'wrong_dir'
}
]
}
})
const result = scanNodeModelCandidates(
graph,
node,
noAssetSupport,
() => 'checkpoints'
)
expect(result).toHaveLength(1)
expect(result[0].directory).toBe('checkpoints')
// Directory mismatch — enrichment should not stamp the wrong url.
expect(result[0].url).toBeUndefined()
})
it('does not enrich candidates with mismatched model names', () => {
const graph = makeGraph([])
const node = fromAny<LGraphNode, unknown>({
id: 1,
type: 'CheckpointLoaderSimple',
widgets: [makeComboWidget('ckpt_name', 'missing_model.safetensors', [])],
properties: {
models: [
{
name: 'different_model.safetensors',
url: 'https://example.com/different',
directory: 'checkpoints'
}
]
}
})
const result = scanNodeModelCandidates(graph, node, noAssetSupport)
expect(result).toHaveLength(1)
expect(result[0].url).toBeUndefined()
})
})
describe('scanAllModelCandidates', () => {
@@ -925,6 +1055,86 @@ describe('enrichWithEmbeddedMetadata', () => {
expect(result).toHaveLength(0)
})
it('drops workflow-level entries when only reference is in a bypassed subgraph interior', async () => {
// Interior properties.models references the workflow-level model
// but its widget value does not — forcing the workflow-level entry
// down the unmatched path where isModelReferencedByActiveNode
// decides. Previously the helper ignored the bypassed container.
const result = await enrichWithEmbeddedMetadata(
[],
fromAny<ComfyWorkflowJSON, unknown>(bypassedSubgraphUnmatchedModel),
alwaysMissing
)
expect(result).toHaveLength(0)
})
it('keeps workflow-level entries when reference is in an active subgraph interior', async () => {
// Positive control for the bypassed case above: identical fixture
// with container mode=0 must still surface the unmatched workflow-
// level model. Guards against a regression where the ancestor gate
// drops every workflow-level entry regardless of context.
const result = await enrichWithEmbeddedMetadata(
[],
fromAny<ComfyWorkflowJSON, unknown>(activeSubgraphUnmatchedModel),
alwaysMissing
)
expect(result).toHaveLength(1)
expect(result[0].name).toBe('rare_model.safetensors')
})
it('drops workflow-level entries when interior reference is under a different directory', async () => {
// Same name, different directory: the interior's properties.models
// entry is not the same asset as the workflow-level entry, so the
// fallback helper must not treat it as a reference that keeps the
// workflow-level model alive.
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
last_link_id: 0,
nodes: [
{
id: 1,
type: 'CheckpointLoaderSimple',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0,
properties: {
models: [
{
name: 'collide_model.safetensors',
directory: 'loras'
}
]
},
widgets_values: ['unrelated_widget.safetensors']
}
],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4,
models: [
{
name: 'collide_model.safetensors',
url: 'https://example.com/collide',
directory: 'checkpoints'
}
]
})
const result = await enrichWithEmbeddedMetadata(
[],
graphData,
alwaysMissing
)
expect(result).toHaveLength(0)
})
})
describe('OSS missing model detection (non-Cloud path)', () => {