fix: swap keeps only roles the new node can take, role-fed inputs are not offered for wiring, an unwire and a group move survive reopen and in-flight saves, swap asks first

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-03 00:25:30 +03:00
co-authored by Claude Sonnet 5.5
parent a9cf49d97f
commit b037201fa0
6 changed files with 86 additions and 13 deletions
+7 -1
View File
@@ -135,7 +135,10 @@
const actions = el("div", "eng-structure-actions");
const swap = el("button", "btn ghost tiny", "Swap node…");
swap.title = "Use a different node here. It must still produce what the nodes after it read.";
swap.onclick = () => pickNode(`Swap ${slotDisplayLabel(slot)} for…`, (cls) => structural({ action: "replace", slot: slot.id, node: cls }));
swap.onclick = () => pickNode(`Swap ${slotDisplayLabel(slot)} for…`, (cls) => {
if (!confirm(`Swap ${slotDisplayLabel(slot)} for ${cls}?\n\nEverything set on this node (files, values, connections into it) is cleared, and the new node starts empty.`)) return;
structural({ action: "replace", slot: slot.id, node: cls });
});
const rm = el("button", "btn ghost tiny danger", "Remove");
rm.title = "Take this node out; what it fed is rewired to what fed it, when that is unambiguous.";
rm.onclick = () => { if (confirm(`Remove ${slotDisplayLabel(slot)} from the pipeline?`)) structural({ action: "remove", slot: slot.id }); };
@@ -185,6 +188,9 @@
"Fed by another node: change it there, or pick “A value” to type one here.");
}
const row = controlForPlainWidget(slot, widget);
// An input the app itself writes (the prompt box, the source picture, the length) is the app's:
// wiring it elsewhere would make every Generate refuse.
if ((slot.roles || []).some((r) => r.input === widget.name)) return row;
// Another node can drive this value (one Primitive shared by several inputs is how a "link" is made).
if (widget.type !== "COMBO" || (widget.choices || []).length) {
const sources = sourcesFor(slot, widget.type);
+29 -10
View File
@@ -67,6 +67,7 @@
let loadError = null;
let removed = new Set(); // default slots the person took out of this project's pipeline
let adoptGate = null; // a promise while a project's pipeline is being put in
let deferredRemoved;
let deferred = null; // a project's saved pipeline waiting for the first successful load
let epoch = 0; // bumped whenever a project's pipeline replaces the live one
let saving = false;
@@ -130,7 +131,7 @@
}
loading = false;
loadPromise = null;
if (slots !== null && deferred) { const d = deferred; deferred = null; await adopt(d); }
if (slots !== null && deferred) { const d = deferred, r = deferredRemoved; deferred = null; deferredRemoved = undefined; await adopt(d, r); }
})();
return loadPromise;
}
@@ -178,6 +179,7 @@
saving = true;
do {
pending = false;
applyGroups();
const toSend = { slots, ...pendingBody };
pendingBody = null;
const mine = epoch;
@@ -187,6 +189,7 @@
// this answer is about the old one and must not overwrite the new.
if (mine !== epoch) continue;
if (res && res.slots) slots = res.slots;
applyGroups();
incomplete = (res && res.incomplete) || [];
refused = (res && res.refused) || [];
queueable = !!(res && res.queueable);
@@ -300,10 +303,11 @@
saving = true;
let refusedNow = [];
try {
applyGroups();
const res = await API.editPipeline({ slots, ...body });
if (mine !== epoch) return { refused: ["The project was changed while this was being sent."] };
refusedNow = (res && res.refused) || [];
if (res && res.slots && !refusedNow.length) slots = res.slots;
if (res && res.slots && !refusedNow.length) { slots = res.slots; applyGroups(); }
incomplete = (res && res.incomplete) || [];
refused = refusedNow;
queueable = !!(res && res.queueable);
@@ -324,12 +328,20 @@
}
// A slot's group is the person's own to name (any node in any group).
// 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 = {};
function applyGroups() {
(slots || []).forEach((s) => {
if (!(s.id in groupEdits)) return;
if (groupEdits[s.id]) s.group = groupEdits[s.id]; else delete s.group;
});
}
function setGroup(slotId, group) {
const slot = (slots || []).find((s) => s.id === slotId);
if (!slot) return Promise.resolve();
const name = String(group || "").trim();
if (name) slot.group = name; else delete slot.group;
return save({});
if (!(slots || []).some((s) => s.id === slotId)) return Promise.resolve();
groupEdits[slotId] = String(group || "").trim();
applyGroups();
return save({}).then(() => { if (!saving) groupEdits = {}; });
}
// Whoever keeps the pipeline with the project hears every landed edit.
@@ -352,12 +364,14 @@
async function _adopt(saved, removedIds) {
// Not loaded (ComfyUI unreachable): remember what the project holds and put it in the moment a
// load succeeds, so the default is never what the next edit is built on.
if (slots === null) { if (Array.isArray(saved) && saved.length) deferred = saved; return !(Array.isArray(saved) && saved.length); }
if (slots === null) { if (Array.isArray(saved) && saved.length) { deferred = saved; deferredRemoved = removedIds; } return !(Array.isArray(saved) && saved.length); }
if (!Array.isArray(saved) || !saved.length) return true;
// A saved slot is only what the server would accept: a project file outlives the code that
// wrote it (v4 files name the node differently), and one bad slot refuses every later edit.
const sound = (s) => s && typeof s.id === "string" && typeof s.node === "string"
&& s.inputs && typeof s.inputs === "object" && !Array.isArray(s.inputs);
&& s.inputs && typeof s.inputs === "object" && !Array.isArray(s.inputs)
&& (s.group === undefined || (typeof s.group === "string" && s.group.trim()))
&& (s.roles === undefined || Array.isArray(s.roles));
const byId = new Map(saved.filter(sound).map((s) => [s.id, s]));
const have = new Set(offered.map((s) => s.id));
// Slots the person took out stay out; ones whose node they swapped keep their swap.
@@ -366,7 +380,11 @@
const mine = byId.get(def.id);
if (!mine) return JSON.parse(JSON.stringify(def));
if (mine.node !== def.node) return JSON.parse(JSON.stringify(mine));
const out = { ...def, inputs: { ...(def.inputs || {}), ...mine.inputs } };
// A default LINK the saved copy lacks was unwired by the person; a default VALUE it lacks is
// one an update added, and fills in.
const inputs = { ...(def.inputs || {}) };
Object.keys(inputs).forEach((k) => { if (Array.isArray(inputs[k]) && !(k in mine.inputs)) delete inputs[k]; });
const out = { ...def, inputs: { ...inputs, ...mine.inputs } };
if (typeof mine.group === "string" && mine.group) out.group = mine.group;
if (typeof mine.bypassed === "boolean") out.bypassed = mine.bypassed;
return out;
@@ -374,6 +392,7 @@
// Replaces whatever pipeline was live: nothing queued for the old one may land on the new.
const mine = ++epoch;
removed = new Set(gone);
groupEdits = {};
pendingBody = null; pending = false;
for (let waited = 0; saving && waited < 10000; waited += 20) await new Promise((r) => setTimeout(r, 20));
for (const extras of [true, false]) {
+17
View File
@@ -154,3 +154,20 @@ test("a structural edit is sent on its own and its refusal is handed back", asyn
assert.strictEqual(posts.at(-1).action, "remove");
assert.deepStrictEqual(PS.removedIds(), []); // refused: nothing is remembered as removed
});
test("a default link the person unwired stays unwired when the project is opened again", async () => {
const PS = load([]);
await PS.adopt([{ id: "model", node: "Loader", inputs: { file: "x" } },
{ id: "gen", node: "Gen", inputs: { steps: 8 } }]); // gen.model (a link) was unwired
assert.strictEqual("model" in PS.slots().find((s) => s.id === "gen").inputs, false);
});
test("a group moved while a save is in flight is still moved when it lands", 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.setGroup("gen", "Mine");
await inflight;
assert.strictEqual(PS.slots().find((s) => s.id === "gen").group, "Mine");
});
+14 -2
View File
@@ -604,8 +604,20 @@ def replace(slots: Sequence[dict], slot_id: str, class_type: str,
# Inputs are NOT carried across: a different node has different inputs, and
# silently keeping the ones whose names happen to match is how a value ends
# up meaning something else.
changed = [dict(slot, node=class_type, inputs={}) if slot["id"] == slot_id else dict(slot)
for slot in slots]
# The slot's roles say which of its inputs the app writes to (the prompt box, the source image,
# the length). Only the ones the new node actually has can still be written: a role left
# pointing at an input that is gone would make every run refuse.
have = schemas.inputs(class_type)
def fits(slot):
roles = [r for r in (slot.get("roles") or []) if isinstance(r, dict) and r.get("input") in have]
out = dict(slot, node=class_type, inputs={})
out.pop("roles", None)
if roles:
out["roles"] = roles
return out
changed = [fits(slot) if slot["id"] == slot_id else dict(slot) for slot in slots]
return changed, []
+8
View File
@@ -577,3 +577,11 @@ def test_a_new_node_can_be_wired_into_several_inputs_and_later_ones_added_to_the
slots, _ = graph.add(slots, "Empty", None, SCHEMAS)
slots, problems = graph.wire(slots, "empty_2", "width", "primitiveint", 0, SCHEMAS)
assert problems == [] and graph.slots_by_id(slots)["empty_2"]["inputs"]["width"] == ["primitiveint", 0]
def test_a_swap_keeps_only_the_roles_the_new_node_can_still_take():
slots = [{"id": "empty", "node": "Empty", "inputs": {"width": 8},
"roles": [{"at": "project.video", "input": "width"}, {"at": "project.video", "input": "length"}]},
{"id": "save", "node": "Save", "inputs": {"latent": ["empty", 0]}}]
swapped, problems = graph.replace(slots, "empty", "Empty", SCHEMAS)
assert problems == []
assert graph.slots_by_id(swapped)["empty"]["roles"] == [{"at": "project.video", "input": "width"}]
+11
View File
@@ -119,6 +119,17 @@ def test_a_built_in_slot_can_be_removed_over_http(server):
assert sampler["inputs"]["model"] == ["model", 0], "the sampler was not rewired"
def test_a_node_can_be_added_over_http_and_a_missing_one_comes_back_as_a_reason(server):
status, body = _request(server, "POST", "/funpack/api/pipeline",
{"action": "add", "node": "PrimitiveInt", "group": "Mine"})
assert status == 200 and body["refused"] == [], body
added = body["slots"][-1]
assert added["node"] == "PrimitiveInt" and added["group"] == "Mine" and added["inputs"] == {}
status, body = _request(server, "POST", "/funpack/api/pipeline", {"action": "add", "node": "NoSuchNode"})
assert status == 200 and any("NoSuchNode" in r for r in body["refused"])
assert _request(server, "POST", "/funpack/api/pipeline", {"action": "add", "node": 5})[0] == 400
def test_a_removal_that_cannot_work_comes_back_as_a_reason(server):
status, body = _request(server, "POST", "/funpack/api/pipeline",
{"action": "remove", "slot": "latent"})