fix: a node change holds runs back from the moment it is asked; values typed for a swapped node are dropped, not misplaced; problems naming no slot first stay visible in the simple view
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4f6c6284fd
commit
feabbaaa4b
@@ -95,7 +95,7 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) {
|
||||
let deferred = null; // a project's saved pipeline ([slots, removed, unwired, whole]) waiting for the first successful load
|
||||
let epoch = 0; // bumped whenever a project's pipeline replaces the live one
|
||||
let saving = false;
|
||||
let shaping = false; // a structural edit (node swapped, added, removed) waiting for the server
|
||||
let shaping = 0; // node changes (swap, add, remove, preset, import) asked for and not yet answered: counted from the ask
|
||||
let pending = false;
|
||||
let pendingBody = null;
|
||||
let saveNotes = [];
|
||||
@@ -220,8 +220,16 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) {
|
||||
}
|
||||
// An answer replaces the slots with the server's copy of what was SENT: whatever was typed while it was on its way
|
||||
// (node values still queued, module values) goes back on, or the screen and the next run lose it.
|
||||
function keepTyped() {
|
||||
placeInputs(pendingBody && pendingBody.inputs);
|
||||
// A value typed for a node that the answer swapped for another belongs to nothing now: it is dropped, not put on the
|
||||
// new node (which has no such input, and would be refused for it).
|
||||
function keepTyped(before) {
|
||||
const was = new Map((before || []).map((x) => [x.id, x.node]));
|
||||
const typed = pendingBody && pendingBody.inputs;
|
||||
if (typed && slots) for (const id of Object.keys(typed)) {
|
||||
const now = slots.find((x) => x.id === id);
|
||||
if (!now || (was.has(id) && was.get(id) !== now.node)) delete typed[id];
|
||||
}
|
||||
placeInputs(typed);
|
||||
if (Object.keys(pendingValues).length) mirrorValues(currentValues());
|
||||
}
|
||||
async function save(body) {
|
||||
@@ -242,8 +250,9 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) {
|
||||
// A different pipeline was put in while this was in flight (another project opened):
|
||||
// this answer is about the old one and must not overwrite the new.
|
||||
if (mine !== epoch) continue;
|
||||
const before = slots;
|
||||
if (res && res.slots) slots = res.slots;
|
||||
keepTyped(); // an edit queued behind this one stays in the slots a run reads
|
||||
keepTyped(before); // an edit queued behind this one stays in the slots a run reads
|
||||
applyGroups();
|
||||
incomplete = (res && res.incomplete) || [];
|
||||
refused = (res && res.refused) || [];
|
||||
@@ -431,7 +440,7 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) {
|
||||
// A change to the pipeline's SHAPE (add / replace / remove / wire / unwire), sent on its own: two
|
||||
// of these folded into one request would lose the first. Waits for any value edit in flight and
|
||||
// for a project's pipeline going in. -> {refused: [...]}; empty when it happened.
|
||||
async function edit(body) {
|
||||
async function editNow(body) {
|
||||
await ensureLoaded();
|
||||
if (slots === null) return { refused: ["The pipeline has not loaded: is ComfyUI reachable?"] };
|
||||
if (adoptGate) await adoptGate;
|
||||
@@ -441,14 +450,14 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) {
|
||||
const keep = currentValues(); // a settings node added or swapped in starts empty: it gets these
|
||||
let refill = false;
|
||||
const mine = epoch;
|
||||
saving = true; shaping = true;
|
||||
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; keepTyped(); applyGroups(); }
|
||||
if (res && res.slots && !refusedNow.length) { const before = slots; slots = res.slots; keepTyped(before); applyGroups(); }
|
||||
incomplete = (res && res.incomplete) || [];
|
||||
refused = refusedNow;
|
||||
queueable = !!(res && res.queueable);
|
||||
@@ -480,7 +489,7 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) {
|
||||
} catch (e) {
|
||||
refusedNow = [e && e.message ? e.message : String(e)];
|
||||
} finally {
|
||||
saving = false; shaping = false;
|
||||
saving = false;
|
||||
}
|
||||
if (refill || pending) await save({}); // the filled settings, or a value edit queued behind this one
|
||||
return { refused: refusedNow };
|
||||
@@ -492,7 +501,7 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) {
|
||||
return { slots: JSON.parse(JSON.stringify(slots)), removed: [...removed], unwired: JSON.parse(JSON.stringify(unwired)), whole };
|
||||
}
|
||||
// -> {refused: [...]}; empty when the snapshot is back. Announced as a change, so the project follows.
|
||||
async function restore(snap) {
|
||||
async function restoreNow(snap) {
|
||||
if (!snap || !Array.isArray(snap.slots)) return { refused: ["Nothing to go back to."] };
|
||||
// Already what is live: nothing to do. Above all not a change of shape, which would let Revert replace a refused saved pipeline.
|
||||
if (JSON.stringify(snap.slots) === JSON.stringify(slots)) return { refused: [] };
|
||||
@@ -503,15 +512,16 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) {
|
||||
const keep = currentValues();
|
||||
let refill = false;
|
||||
const mine = epoch;
|
||||
saving = true; shaping = true;
|
||||
saving = true;
|
||||
let refusedNow = [];
|
||||
try {
|
||||
const res = await API.editPipeline({ slots: JSON.parse(JSON.stringify(snap.slots)) });
|
||||
if (mine !== epoch) return { refused: ["The project was changed while this was being sent."] };
|
||||
refusedNow = (res && res.refused) || [];
|
||||
if (res && res.slots && !refusedNow.length) {
|
||||
const before = slots;
|
||||
slots = res.slots;
|
||||
keepTyped();
|
||||
keepTyped(before);
|
||||
// A default slot the new pipeline lacks is removed, whatever the caller said: otherwise the next
|
||||
// open lays the default back over it (a preset, an import or the wizard would regain the default's loaders).
|
||||
const kept = new Set(res.slots.map((s) => s.id));
|
||||
@@ -528,7 +538,7 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) {
|
||||
} catch (e) {
|
||||
refusedNow = [e && e.message ? e.message : String(e)];
|
||||
} finally {
|
||||
saving = false; shaping = false;
|
||||
saving = false;
|
||||
}
|
||||
if (refill) await save({});
|
||||
return { refused: refusedNow };
|
||||
@@ -580,6 +590,10 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) {
|
||||
|
||||
/** Resolves true once no edit is on its way and no project's pipeline is going in; false if that took over
|
||||
* three minutes (every request gives up after ANSWER_MS, so only a server failing again and again gets there). */
|
||||
// Counted from the moment one is asked for, not from when it gets its turn behind a save: a run started in between
|
||||
// would freeze the pipeline as it was before the change.
|
||||
const shape = (fn) => async (...args) => { shaping += 1; try { return await fn(...args); } finally { shaping -= 1; } };
|
||||
const edit = shape(editNow), restore = shape(restoreNow);
|
||||
// What a run needs finished: a project's pipeline going in, or a node swapped/added/removed (that shape exists only once
|
||||
// the server answers). A value save is not waited on: the value is already in the slots (placeInputs, mirrorValues).
|
||||
async function readyToRun() {
|
||||
|
||||
@@ -13,6 +13,7 @@ function load(posts, opts = {}) {
|
||||
editPipeline: async (body) => {
|
||||
body = JSON.parse(JSON.stringify(body)); // sent over HTTP: the server never shares the page's objects
|
||||
posts.push(body);
|
||||
if (body.action === "replace") { const sl = body.slots.find((x) => x.id === body.slot); if (sl) { sl.node = body.node; sl.inputs = {}; } }
|
||||
if (opts.delay) await new Promise((r) => setTimeout(r, opts.delay));
|
||||
const bad = (body.slots || []).findIndex((s) => typeof s.node !== "string");
|
||||
if (bad >= 0) { const e = new Error(`slot ${bad} has no node`); throw e; } for (const [id, edits] of Object.entries(body.inputs || {})) { const sl = body.slots.find((x) => x.id === id); if (sl) sl.inputs = { ...sl.inputs, ...edits }; }
|
||||
@@ -686,3 +687,27 @@ test("after a preset or import goes in, a run is ready at once (the node-change
|
||||
assert.strictEqual(await PS.readyToRun(), true);
|
||||
assert.ok(Date.now() - t0 < 100);
|
||||
});
|
||||
|
||||
test("a value typed for a node while it is being swapped is dropped, not put on the node that replaces it", async () => {
|
||||
const posts = [];
|
||||
const PS = load(posts, { delay: 40 });
|
||||
await PS.ensureLoaded();
|
||||
const swap = PS.edit({ action: "replace", slot: "gen", node: "Other" });
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const typed = PS.save({ inputs: { gen: { steps: 33 } } });
|
||||
await swap; await typed; await PS.settled();
|
||||
const gen = PS.slots().find((x) => x.id === "gen");
|
||||
assert.strictEqual(gen.node, "Other");
|
||||
assert.strictEqual(gen.inputs.steps, undefined, "no stray input the new node cannot take");
|
||||
assert.ok(posts.every((b) => !(b.inputs && b.inputs.gen && "steps" in b.inputs.gen) || !b.slots.some((x) => x.id === "gen" && x.node === "Other")));
|
||||
});
|
||||
|
||||
test("a node change asked for while a value save is on its way holds a run back until it lands", async () => {
|
||||
const PS = load([], { delay: 60 });
|
||||
await PS.ensureLoaded();
|
||||
PS.save({ inputs: { gen: { steps: 12 } } });
|
||||
const swap = PS.edit({ action: "replace", slot: "gen", node: "Other" }); // queued behind the save
|
||||
await PS.readyToRun();
|
||||
assert.strictEqual(PS.slots().find((x) => x.id === "gen").node, "Other", "the run freezes the pipeline after the swap");
|
||||
await swap;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user