feat(builder): load workflow sweep sessions

Load Session restores Workflow Sweep arrays from meta.configs_json (workflowConfigToArray) and
tells the user when an older sweep session has no saved configs. Builder Start shows 'attached'.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jason Hoku
2026-11-04 00:56:03 -08:00
co-authored by Claude Fable 5.1
parent 13422104f4
commit 3eae054c11
4 changed files with 112 additions and 1 deletions
+55
View File
@@ -0,0 +1,55 @@
// workflowConfigToArray lives in an ES module that imports ComfyUI's frontend, so this test slices the
// pure helper functions out of the source and evaluates them (same trick as other tests that avoid the DOM).
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const src = fs.readFileSync(path.join(__dirname, '../../web/conf_builder/conf-builder-workflow-sweep.js'), 'utf8');
function grab(name) {
const re = new RegExp('(?:export )?function ' + name + '\\(');
const m = re.exec(src);
assert.ok(m, 'function ' + name + ' not found');
let i = src.indexOf('{', m.index), depth = 0, j = i;
for (; j < src.length; j++) {
if (src[j] === '{') depth++;
else if (src[j] === '}') { depth--; if (depth === 0) break; }
}
return src.slice(m.index, j + 1).replace(/^export /, '');
}
const code = ['defaultWorkflowState', 'ensureWorkflowState', 'newWorkflowArray', 'workflowConfigToArray'].map(grab).join('\n');
const { workflowConfigToArray } = new Function(code + '\nreturn { workflowConfigToArray };')();
const cfg = {
model_type: 'workflow', name: 'My sweep', workflow: { '3': { class_type: 'KSampler', inputs: { seed: 1 } } },
workflow_source: 'wf.json', workflow_output: { node_id: '9', output_index: 0 }, workflow_run_other_outputs: true,
workflow_bindings: [{ id: 'b1', kind: 'literal', targets: [{ node_id: '3', input: 'seed' }], values: [1, 2] }],
positive: [['a cat']], negative: 'bad', _prompt_source: 'custom',
};
test('restores workflow, bindings, output and custom prompts', () => {
const a = workflowConfigToArray(cfg, 0);
assert.strictEqual(a.kind, 'workflow');
assert.strictEqual(a.name, 'My sweep');
assert.deepStrictEqual(a.workflow.api_prompt, cfg.workflow);
assert.strictEqual(a.workflow.source_name, 'wf.json');
assert.deepStrictEqual(a.workflow.output, { node_id: '9', output_index: 0 });
assert.strictEqual(a.workflow.run_other_outputs, true);
assert.deepStrictEqual(a.workflow.bindings, cfg.workflow_bindings);
assert.strictEqual(a.use_custom_prompts, true);
assert.deepStrictEqual(a.positive_prompt_groups, [['a cat']]);
assert.strictEqual(a.negative_prompt, 'bad');
});
test('global prompts are not copied into the array; odd input never throws', () => {
const a = workflowConfigToArray({ ...cfg, _prompt_source: 'global' }, 2);
assert.strictEqual(a.use_custom_prompts, false);
assert.deepStrictEqual(a.positive_prompt_groups, []);
const b = workflowConfigToArray(null, 1);
assert.strictEqual(b.kind, 'workflow');
assert.strictEqual(b.name, 'Workflow 2');
assert.strictEqual(b.workflow.api_prompt, null);
assert.deepStrictEqual(b.workflow.bindings, []);
});
+31 -1
View File
@@ -8,6 +8,17 @@ import { app } from "../../../scripts/app.js";
// Cache-busting timestamp (regenerated on every page load)
const CACHE_BUST = Date.now();
// Small info toast (falls back to alert when the frontend has no toast extension)
function notify(detail) {
try {
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({ severity: "info", summary: "USCG", detail, life: 8000 });
return;
}
} catch (e) { /* fall through */ }
alert(detail);
}
// Module storage
let utilities, uiComponents, configManagement, distributionModule;
@@ -418,7 +429,12 @@ app.registerExtension({
if (configs && !Array.isArray(configs) && configs.configs) {
configs = configs.configs;
}
let loadedArrays = utilities.convertConfigsToConfigArrays(configs);
// Workflow Sweep entries (model_type "workflow") restore through workflowConfigToArray;
// standard entries keep the existing path. Original order is preserved.
const isWfCfg = (c) => c && c.model_type === "workflow";
const wfCfgs = configs.filter(isWfCfg);
const stdCfgs = configs.filter(c => !isWfCfg(c));
let loadedArrays = stdCfgs.length ? utilities.convertConfigsToConfigArrays(stdCfgs) : [];
const normalize = (str) => str.replace(/\\/g, "/");
loadedArrays.forEach(arr => {
@@ -486,6 +502,15 @@ app.registerExtension({
if (arr.kind === undefined) arr.kind = "standard";
});
if (wfCfgs.length) {
const { workflowConfigToArray } = await import("./conf-builder-workflow-sweep.js");
const wfArrays = wfCfgs.map((c, i) => workflowConfigToArray(c, i));
const merged = [];
let si = 0, wi = 0;
configs.forEach(c => merged.push(isWfCfg(c) ? wfArrays[wi++] : loadedArrays[si++]));
loadedArrays = merged.filter(Boolean);
}
this.state.config_arrays = loadedArrays;
// Restore global prompts from loaded configs
@@ -515,6 +540,11 @@ app.registerExtension({
}
}
// A sweep session saved before its configs were stored: nothing to restore, say so
if (meta.workflow_sweep && !meta.configs_json) {
notify("This session was made by a Workflow Sweep before its configs were saved. Load the workflow JSON again into a Workflow Sweep array; finished jobs are skipped by fingerprint.");
}
this.state.session_name = sessionName;
this.saveState();
this.renderUI();
@@ -656,6 +656,8 @@ export function convertConfigsToConfigArrays(configs) {
};
configs.forEach((config, idx) => {
// Workflow Sweep entries are restored by workflowConfigToArray (loadSession); never build a standard array from one
if (config && config.model_type === "workflow") return;
const loraValue = config.lora;
let loras = [];
let hasCombined = false;
@@ -53,6 +53,30 @@ export function newWorkflowArray(index) {
};
}
/**
* Inverse of config_builder_node._workflow_array_to_config: a saved workflow config entry (manifest
* meta.configs_json) back into a Builder array of kind "workflow". Never throws on odd input.
*/
export function workflowConfigToArray(cfg, idx = 0) {
const arr = newWorkflowArray(idx + 1);
cfg = (cfg && typeof cfg === "object") ? cfg : {};
if (cfg.name) arr.name = String(cfg.name);
arr.workflow = {
source_name: String(cfg.workflow_source || ""),
api_prompt: (cfg.workflow && typeof cfg.workflow === "object" && Object.keys(cfg.workflow).length) ? cfg.workflow : null,
output: (cfg.workflow_output && typeof cfg.workflow_output === "object") ? cfg.workflow_output : {},
run_other_outputs: !!cfg.workflow_run_other_outputs,
bindings: Array.isArray(cfg.workflow_bindings) ? cfg.workflow_bindings : [],
};
if (cfg._prompt_source === "custom" && Array.isArray(cfg.positive)) {
arr.positive_prompt_groups = cfg.positive;
arr.negative_prompt = typeof cfg.negative === "string" ? cfg.negative : "";
arr.use_custom_prompts = true;
}
ensureWorkflowState(arr);
return arr;
}
function el(tag, props = {}, css = "") {
const e = document.createElement(tag);
Object.assign(e, props);