From 668cecea2db74ca1d87828663f49c932449fc442 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Fri, 24 Jul 2026 16:00:10 +0200 Subject: [PATCH] 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 --- tests/test_downloads_tab_workflow_route.mjs | 56 ++++++++++++++++++ web/resolver/model_resolver.js | 58 +++++++++++++------ web/resolver/resolver_dialog.js | 1 + web/resolver/shell/lifecycle_graph_methods.js | 9 ++- web/resolver/views/missing_browser_methods.js | 30 ++++++++++ web/resolver/views/tabs_loaded_methods.js | 5 +- 6 files changed, 138 insertions(+), 21 deletions(-) diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 32a9769..c648a2b 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -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')})`); diff --git a/web/resolver/model_resolver.js b/web/resolver/model_resolver.js index b56f58a..2d92a83 100644 --- a/web/resolver/model_resolver.js +++ b/web/resolver/model_resolver.js @@ -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'); } } diff --git a/web/resolver/resolver_dialog.js b/web/resolver/resolver_dialog.js index 0043ade..df6bbda 100644 --- a/web/resolver/resolver_dialog.js +++ b/web/resolver/resolver_dialog.js @@ -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; diff --git a/web/resolver/shell/lifecycle_graph_methods.js b/web/resolver/shell/lifecycle_graph_methods.js index 2db1ee6..fc0d618 100644 --- a/web/resolver/shell/lifecycle_graph_methods.js +++ b/web/resolver/shell/lifecycle_graph_methods.js @@ -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 = `

Error: ${error.message}

`; } + return null; } }, diff --git a/web/resolver/views/missing_browser_methods.js b/web/resolver/views/missing_browser_methods.js index c8792c3..ec30d68 100644 --- a/web/resolver/views/missing_browser_methods.js +++ b/web/resolver/views/missing_browser_methods.js @@ -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)); diff --git a/web/resolver/views/tabs_loaded_methods.js b/web/resolver/views/tabs_loaded_methods.js index 9aeea39..48ca0e7 100644 --- a/web/resolver/views/tabs_loaded_methods.js +++ b/web/resolver/views/tabs_loaded_methods.js @@ -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 } = {}) {