feat: bypass toggle in Models & Pipeline -- a node is skipped for the run, keeps its values, and refuses with a reason when it cannot be passed through

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-17 02:19:00 +03:00
co-authored by Claude Sonnet 5.5
parent 4852d50609
commit 4fd3b94424
5 changed files with 66 additions and 6 deletions
+3 -2
View File
@@ -88,7 +88,8 @@ export const models = (app) => function mount() {
const groups = () => [...new Set(slotsNow().map((s) => s.group || "Other"))];
const structure = (slot) => {
const here = slot.group || "Other";
return c.field.default({ label: "Group", hint: "Which tab this node sits under. Any node can live in any group.", control: c.toolbar.default({
const bypass = c.toggle.default({ label: "Bypass", hint: "Skip this node for the run: what it feeds reads what fed it. Its settings stay, switch it back on any time.", checked: Boolean(slot.bypassed), onChange: async (v) => { await ps.setBypass(slot.id, v); draw(); } });
return c.region.stack({ gap: "sm", children: [bypass, c.field.default({ label: "Group", hint: "Which tab this node sits under. Any node can live in any group.", control: c.toolbar.default({
items: [c.select.md({ label: "Group", value: here, options: [...groups().map((g) => ({ value: g, label: g })), { value: NEW_GROUP, label: "New group…" }], onChange: async (v) => {
let name = v;
if (v === NEW_GROUP) { name = ((await c.modal.prompt({ title: "New group", label: "Name", confirmLabel: "Create" }).result) || "").trim(); if (!name) return draw(); }
@@ -101,7 +102,7 @@ export const models = (app) => function mount() {
if (await confirm("Swap node", `Swap ${label(slot)} for ${cls}? Everything set on this node (files, values, connections into it) is cleared, and the new node starts empty.${driven.length ? ` The main window drives this node's ${driven.join(", ")}: those controls keep working only for inputs the new node also has, by the same name.` : ""}`, "danger")) structural({ action: "replace", slot: slot.id, node: cls });
} }), c.button.sm({ label: "Remove", tone: "danger", title: "Take this node out; what it fed is rewired to what fed it, when that is unambiguous.", onClick: async () => {
if (await confirm("Remove node", `Remove ${label(slot)} from the pipeline?`, "danger")) structural({ action: "remove", slot: slot.id });
} })] }) });
} })] }) })] });
};
// Which pack gives a missing node (from ComfyUI-Manager's map, when it is installed), and a button to clone it.
+14 -4
View File
@@ -480,7 +480,7 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) {
unwired[body.slot] = unwired[body.slot].filter((k) => k !== body.input);
if (!unwired[body.slot].length) delete unwired[body.slot];
}
if (body.action === "remove") { delete unwired[body.slot]; delete groupEdits[body.slot]; }
if (body.action === "remove") { delete unwired[body.slot]; delete groupEdits[body.slot]; delete bypassEdits[body.slot]; }
// A default slot that was swapped for the same node, or taken out and put back, starts empty:
// none of the default's links may come back over it when the project is opened again.
const fresh = body.action === "replace" ? [body.slot] : body.action === "add" ? (res.slots || []).map((s) => s.id) : [];
@@ -536,7 +536,7 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) {
removed = new Set([...(snap.removed || []), ...(offered || []).map((s) => s.id).filter((id) => !kept.has(id))]);
whole = snap.whole !== false; // only a snapshot of an edited default says otherwise
unwired = JSON.parse(JSON.stringify(snap.unwired || {}));
groupEdits = {}; pendingValues = {}; pendingBody = null; pending = false;
groupEdits = {}; bypassEdits = {}; pendingValues = {}; pendingBody = null; pending = false;
incomplete = res.incomplete || []; refused = []; queueable = !!res.queueable;
skipped = skipMap(res);
saveNotes = res.notes || [];
@@ -557,12 +557,22 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) {
// Kept until it has landed: a save already in flight answers with the slots as they were when
// it left, and would otherwise put the old group back.
let groupEdits = {};
let bypassEdits = {}; // same life as a group edit: kept until its save has landed
function applyGroups() {
(slots || []).forEach((s) => {
if (s.id in bypassEdits) { if (bypassEdits[s.id]) s.bypassed = true; else delete s.bypassed; }
if (!(s.id in groupEdits)) return;
if (groupEdits[s.id]) s.group = groupEdits[s.id]; else delete s.group;
});
}
/** Switch a node off for the run (what it feeds reads what fed it) without losing a single value on it. */
function setBypass(slotId, on) {
if (!(slots || []).some((s) => s.id === slotId)) return Promise.resolve();
bypassEdits[slotId] = Boolean(on);
applyGroups();
const mine = bypassEdits[slotId];
return save({}).then(() => settled()).then(() => { if (bypassEdits[slotId] === mine) delete bypassEdits[slotId]; });
}
function setGroup(slotId, group) {
if (!(slots || []).some((s) => s.id === slotId)) return Promise.resolve();
groupEdits[slotId] = String(group || "").trim();
@@ -660,7 +670,7 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) {
Object.entries(unwiredMap && typeof unwiredMap === "object" ? unwiredMap : {}).forEach(([id, list]) => {
if (Array.isArray(list)) unwired[id] = list.filter((x) => typeof x === "string");
});
groupEdits = {};
groupEdits = {}; bypassEdits = {};
pendingBody = null; pending = false;
for (let waited = 0; saving && waited < 10000; waited += 20) await new Promise((r) => setTimeout(r, 20));
let unreachable = false, reasons = [];
@@ -698,7 +708,7 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) {
}
return {
ensureLoaded, save, edit, restore, snapshot, setGroup, adopt, settled, readyToRun, subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); }, valuesAlreadyPlaced, currentValues, setModuleValue,
ensureLoaded, save, edit, restore, snapshot, setGroup, setBypass, adopt, settled, readyToRun, subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); }, valuesAlreadyPlaced, currentValues, setModuleValue,
modulesById: () => modulesById,
setEditGuard: (fn) => { editGuard = fn; }, adoptedOk: () => adoptedOk, unreachable: () => slots === null || unreachableLast, refusal: () => refusalLast, activeModules, useful, usefulness, isOff, setOff, allOff, setAllOff, refreshControl, control: () => controlState,
removedIds: () => [...removed],
+15
View File
@@ -178,6 +178,21 @@ test("a group moved while a save is in flight is still moved when it lands", asy
assert.strictEqual(PS.slots().find((s) => s.id === "gen").group, "Mine");
});
test("a bypass switched while a save is in flight still holds when it lands, and travels with the saved pipeline", async () => {
const PS = load([], { delay: 30 });
await PS.ensureLoaded();
const inflight = PS.save({ inputs: { model: { file: "z" } } });
await new Promise((r) => setTimeout(r, 5));
await PS.setBypass("gen", true);
await inflight;
assert.strictEqual(PS.slots().find((s) => s.id === "gen").bypassed, true);
const again = load([]);
await again.adopt(JSON.parse(JSON.stringify(PS.slots())), [], {});
assert.strictEqual(again.slots().find((s) => s.id === "gen").bypassed, true);
await PS.setBypass("gen", false);
assert.strictEqual("bypassed" in PS.slots().find((s) => s.id === "gen"), false, "off leaves no flag behind");
});
test("a default slot swapped for the same node, or removed and added back, does not get its links back on reopen", async () => {
const PS = load([]);
await PS.ensureLoaded();
+9
View File
@@ -212,6 +212,8 @@ def shape_problems(slots: Any) -> List[str]:
elif not group.strip():
problems.append(f"{where}'s group is blank; leave it out to leave the "
f"slot ungrouped")
if slot.get("bypassed") is not None and not isinstance(slot["bypassed"], bool):
problems.append(f"{where}'s bypassed must be true or false, not {type(slot['bypassed']).__name__}")
problems.extend(_role_problems(slot.get("roles"), where))
problems.extend(_optional_problems(slot.get("optional"), where))
return problems
@@ -360,6 +362,13 @@ def build(slots: Sequence[dict], schemas: Optional[Schemas] = None,
if problems:
return {}, problems
# A bypassed slot stays in the saved pipeline with all its values; the run is built as if it were removed.
# Refused, with the reason, when what it fed has nothing to inherit: never quietly left running.
for slot_id in [s["id"] for s in slots if s.get("bypassed") is True]:
slots, bad = remove(slots, slot_id, schemas)
problems.extend(f"bypass on {slot_id!r}: {why}" for why in bad)
if problems:
return {}, problems
slots, _skipped = bypass_missing(slots, schemas, optional)
known = {s["id"] for s in slots}
# Built once. Rebuilding it per link made validating a chain of N slots do N
+25
View File
@@ -822,3 +822,28 @@ def test_a_malformed_optional_table_entry_is_logged_not_swallowed(monkeypatch):
slot.pop("optional", None)
graph.build(slots, _without("Rewrite", "Markup"), {"Rewrite": {"pass": "bad"}})
assert any("Rewrite" in m and "cannot be skipped" in m for m in seen)
# --- bypassing ---------------------------------------------------------------
def test_a_bypassed_slot_is_left_out_of_the_run_but_kept_with_its_values():
"""The point of bypass over remove: switch a node off to compare, switch it back, nothing retyped."""
slots = [dict(s, bypassed=True) if s["id"] == "lora" else s for s in pipeline()]
prompt, problems = graph.build(slots, SCHEMAS)
assert problems == []
assert "lora" not in prompt and prompt["sample"]["inputs"]["model"] == ["model", 0]
kept = graph.slots_by_id(slots)["lora"]
assert kept["bypassed"] is True and kept["inputs"]["strength"] == graph.slots_by_id(pipeline())["lora"]["inputs"]["strength"]
back, problems = graph.build([dict(s, bypassed=False) if s["id"] == "lora" else s for s in slots], SCHEMAS)
assert problems == [] and "lora" in back
def test_bypassing_something_with_nothing_to_pass_through_is_refused_and_said():
slots = [dict(s, bypassed=True) if s["id"] == "latent" else s for s in pipeline()]
prompt, problems = graph.build(slots, SCHEMAS)
assert prompt == {} and any("bypass on 'latent'" in p and "no wired LATENT input" in p for p in problems)
def test_bypassed_must_be_true_or_false():
slots = [dict(s, bypassed="yes") if s["id"] == "lora" else s for s in pipeline()]
assert any("bypassed must be true or false" in p for p in graph.shape_problems(slots))