Make resolver workflow model selection survive analysis timing

Queue workflow model reference selection requests in the dialog, wait for
workflow analysis to complete before attempting selection, and handle
superseded or timed-out requests gracefully.

- Track pending selection requests on the dialog with status fields
- Apply queued selections from lifecycle graph methods after analysis
- Await loadWorkflowData/loadLoadedModels so the caller can proceed
  after tab content is actually ready
- Add timeout handling with user-visible warnings when selection cannot
  complete
- Cover queueing, superseding, waiting, and completion in JS tests
This commit is contained in:
Dariusz L
2026-07-24 16:00:10 +02:00
parent d368a6dffc
commit 668cecea2d
6 changed files with 138 additions and 21 deletions
@@ -237,6 +237,62 @@ test('node context integration preserves existing menu hooks and refreshes after
assert.deepEqual(calls, ['original-menu', 'original-widget:ckpt_name', 'refresh']);
});
test('queued workflow model selection survives missing data and completes after analysis', () => {
const queueWorkflowModelReferenceSelection = eval(`(${extractMethod(missingBrowserMethodsSource, 'queueWorkflowModelReferenceSelection')})`);
const applyPendingWorkflowModelSelection = eval(`(${extractMethod(missingBrowserMethodsSource, 'applyPendingWorkflowModelSelection')})`);
const selectedModel = {
node_id: 163,
widget_index: 1,
original_path: 'FLUX/ae.safetensors',
};
let analysisReady = false;
const dialog = {
pendingWorkflowModelSelection: null,
queueWorkflowModelReferenceSelection,
selectWorkflowModelReference() {
return analysisReady ? selectedModel : null;
},
};
const request = queueWorkflowModelReferenceSelection.call(dialog, selectedModel);
assert.equal(request.status, 'pending');
assert.equal(applyPendingWorkflowModelSelection.call(dialog, {}), null);
assert.equal(dialog.pendingWorkflowModelSelection, request);
analysisReady = true;
assert.equal(applyPendingWorkflowModelSelection.call(dialog, {}), selectedModel);
assert.equal(request.status, 'selected');
assert.equal(request.selected, selectedModel);
assert.equal(dialog.pendingWorkflowModelSelection, null);
});
test('a newer workflow model selection supersedes the previous pending request', () => {
const queueWorkflowModelReferenceSelection = eval(`(${extractMethod(missingBrowserMethodsSource, 'queueWorkflowModelReferenceSelection')})`);
const dialog = { pendingWorkflowModelSelection: null };
const first = queueWorkflowModelReferenceSelection.call(dialog, { node_id: 1 });
const second = queueWorkflowModelReferenceSelection.call(dialog, { node_id: 2 });
assert.equal(first.status, 'superseded');
assert.equal(second.status, 'pending');
assert.equal(dialog.pendingWorkflowModelSelection, second);
});
test('workflow model selection wait resolves only after the queued request completes', async () => {
const waitForWorkflowModelSelection = eval(`(${extractMethod(modelResolverSource, 'waitForWorkflowModelSelection')})`);
const request = { status: 'pending' };
const resolver = { dialog: { pendingWorkflowModelSelection: request } };
setTimeout(() => {
request.status = 'selected';
}, 20);
const completed = await waitForWorkflowModelSelection.call(resolver, request, 500);
assert.equal(completed, request);
assert.equal(completed.status, 'selected');
});
test('Local Database source ignores installed local model matches before search', () => {
const hasMissingSourceSearchAttempt = eval(`(${extractMethod(missingBrowserMethodsSource, 'hasMissingSourceSearchAttempt')})`);
const getMissingSourceResultStatus = eval(`(${extractMethod(missingBrowserMethodsSource, 'getMissingSourceResultStatus')})`);
+41 -17
View File
@@ -483,9 +483,33 @@ export class ModelResolver {
});
}
waitForWorkflowModelSelection(request, timeoutMs = 120000) {
const startedAt = Date.now();
return new Promise((resolve) => {
const check = () => {
if (!request || request.status !== 'pending') {
resolve(request || null);
return;
}
if (Date.now() - startedAt >= timeoutMs) {
request.status = 'timeout';
if (this.dialog?.pendingWorkflowModelSelection === request) {
this.dialog.pendingWorkflowModelSelection = null;
}
resolve(request);
return;
}
setTimeout(check, 50);
};
check();
});
}
async showResolvedNodeModelInResolver(reference) {
if (!this.dialog) return;
const wasVisible = Boolean(this.dialog.isVisible?.());
const wasOnMissingTab = this.dialog.activeTab === 'missing';
const { signature } = this.getNodeContextWorkflowState();
const analysisData = this.getCurrentNodeContextAnalysis(signature);
if (signature && analysisData) {
@@ -493,11 +517,11 @@ export class ModelResolver {
this.dialog.cachedAnalysisData = analysisData;
}
this.dialog.activeTab = 'missing';
this.dialog.persistActiveTab?.('missing');
this.dialog.showResolvedModels = true;
this.dialog.missingModelsTypeFilter = 'all';
this.dialog.missingModelsTypeFilterMenuOpen = false;
const selectionRequest = this.dialog.queueWorkflowModelReferenceSelection?.(reference);
try {
localStorage.setItem(this.dialog.showResolvedModelsStorageKey, '1');
@@ -505,30 +529,30 @@ export class ModelResolver {
log.debug('Model Resolver: failed to persist resolved-model visibility.', error);
}
if (!this.dialog.isVisible?.()) {
if (!wasVisible) {
this.dialog.activeTab = 'missing';
this.activateResolverButton();
}
if (!await this.waitForResolverDialogReady()) {
if (selectionRequest?.status === 'pending') {
selectionRequest.status = 'open-failed';
}
if (this.dialog.pendingWorkflowModelSelection === selectionRequest) {
this.dialog.pendingWorkflowModelSelection = null;
}
showNotification('Could not open Model Resolver.', 'error');
return;
}
this.dialog.switchTab?.('missing', { force: true });
await this.dialog.loadWorkflowData?.();
let selected = this.dialog.selectWorkflowModelReference?.(
reference,
this.dialog.cachedAnalysisData
);
if (!selected) {
await this.dialog.loadWorkflowData?.(null, { force: true, forceRescan: false });
selected = this.dialog.selectWorkflowModelReference?.(
reference,
this.dialog.cachedAnalysisData
);
if (wasVisible && !wasOnMissingTab) {
await this.dialog.switchTab?.('missing', { force: true });
} else if (wasVisible) {
await this.dialog.loadWorkflowData?.();
}
if (!selected) {
showNotification('The selected model is no longer present in the current workflow.', 'warning');
const completedRequest = await this.waitForWorkflowModelSelection(selectionRequest);
if (completedRequest?.status === 'timeout') {
showNotification('Could not select the model after workflow analysis completed.', 'warning');
}
}
+1
View File
@@ -54,6 +54,7 @@ export class ResolverManagerDialog extends ComfyDialog {
this.cachedLoadedModelsData = null;
this.cachedLoadedModelsSignature = null;
this.selectedMissingModelKey = null;
this.pendingWorkflowModelSelection = null;
this.batchSelectedMissingKeys = new Set();
this.lastBatchSelectedMissingKey = null;
this.activeFooterMenu = null;
@@ -157,9 +157,10 @@ export const lifecycleGraphMethods = {
if (shouldRenderMissingModels()) {
await this.ensureDownloadDirectoriesLoaded();
this.displayMissingModels(this.contentElement, this.cachedAnalysisData);
this.applyPendingWorkflowModelSelection?.(this.cachedAnalysisData);
this.reconnectActiveDownloads();
}
return;
return this.cachedAnalysisData;
}
if (!this.workflowHasNodes(workflow)) {
@@ -179,8 +180,9 @@ export const lifecycleGraphMethods = {
if (shouldRenderMissingModels()) {
await this.ensureDownloadDirectoriesLoaded();
this.displayMissingModels(this.contentElement, data);
this.applyPendingWorkflowModelSelection?.(data);
}
return;
return data;
}
const analysisId = `an-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -217,10 +219,12 @@ export const lifecycleGraphMethods = {
if (shouldRenderMissingModels()) {
await this.ensureDownloadDirectoriesLoaded();
this.displayMissingModels(this.contentElement, data);
this.applyPendingWorkflowModelSelection?.(data);
// Reconnect any active downloads to their new progress divs
this.reconnectActiveDownloads();
}
return data;
} catch (error) {
if (!loadToken || this._workflowDataLoadToken === loadToken) {
@@ -232,6 +236,7 @@ export const lifecycleGraphMethods = {
} else if (!loadToken && this.activeTab === 'missing' && this.contentElement) {
this.contentElement.innerHTML = `<p class="mr-error-text">Error: ${error.message}</p>`;
}
return null;
}
},
@@ -508,6 +508,36 @@ export const missingBrowserMethods = {
};
},
queueWorkflowModelReferenceSelection(reference = {}) {
if (this.pendingWorkflowModelSelection?.status === 'pending') {
this.pendingWorkflowModelSelection.status = 'superseded';
}
const request = {
id: `workflow-model-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
reference: { ...reference },
status: 'pending',
selected: null
};
this.pendingWorkflowModelSelection = request;
return request;
},
applyPendingWorkflowModelSelection(data = this.cachedAnalysisData || {}) {
const request = this.pendingWorkflowModelSelection;
if (!request || request.status !== 'pending') return null;
const selected = this.selectWorkflowModelReference(request.reference, data);
if (!selected) return null;
request.status = 'selected';
request.selected = selected;
if (this.pendingWorkflowModelSelection === request) {
this.pendingWorkflowModelSelection = null;
}
return selected;
},
selectWorkflowModelReference(reference = {}, data = this.cachedAnalysisData || {}) {
const selected = this.getResolvedWorkflowModels(data)
.find(model => matchesWorkflowModelReference(model, reference));
+3 -2
View File
@@ -59,13 +59,13 @@ export const tabsLoadedMethods = {
this.contentElement.style.overflowY = 'auto';
}
this.setMissingFooterControlsVisible(true);
this.loadWorkflowData();
return this.loadWorkflowData();
} else if (this.activeTab === 'loaded') {
if (this.contentElement) {
this.contentElement.style.overflowY = 'auto';
}
this.setMissingFooterControlsVisible(false);
this.loadLoadedModels();
return this.loadLoadedModels();
} else {
if (this.contentElement) {
this.contentElement.style.overflowY = 'hidden';
@@ -73,6 +73,7 @@ export const tabsLoadedMethods = {
this.setMissingFooterControlsVisible(false);
this.displayOptions();
}
return null;
},
async loadLoadedModels(workflow = null, { force = false } = {}) {