Pipeline wiring: core/graph.py gains wire()/unwire(), and the "Models and
pipeline" editor can now actually connect one node's output to another's
input -- sockets and widgets alike, which is what makes "linked inputs"
(several widgets sharing one Primitive node's value) buildable at all,
without a second mechanism. Three adversarial review rounds found and fixed
real bugs along the way: check()'s request builder silently dropped the
wire-specific fields; the widgets loop had no wiring UI at all despite the
backend supporting it, and a widget already holding a link rendered as an
editable box with the wrong (default) value; wiring an input that already
had an unsaved drafted value let the stale value silently win on Save. Also
fixed, found live rather than by review: the Add-node picker stacked two
disagreeing search boxes (an outer server search and filterList's own
redundant client-side one).
Updates window: "Restart ComfyUI" is now a plain, always-available button,
not just something offered when an update already landed. Rolling back or
switching branch on a dirty tree now auto-stashes instead of refusing --
updating still refuses, unchanged, since pulling new commits onto changes
in progress is not something to do without asking. Three review rounds
here too: stashing before reading the rollback target shifted what HEAD@{1}
meant (a real reflog hazard, not just reasoned about); a stash's disclosure
was lost if the step right after it failed; that fix was too narrow and
missed pull()'s own failures inside checkout(pull_after=True) -- the actual
default path -- and rollback()'s own reset failing; and finally, the
broadened fix still only caught GitUpdateError, missing subprocess-level
failures like a hung fetch timing out, which is the realistic failure mode
on a rented GPU box's network.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
250 lines
10 KiB
JavaScript
250 lines
10 KiB
JavaScript
// Updating FunPack, switching branch, and getting back afterwards.
|
|
//
|
|
// The most-used feature in a pack whose value is experimental work shipped
|
|
// continuously -- so it is a window in the app rather than a terminal command,
|
|
// and it is honest about the one thing that makes it frightening: every one of
|
|
// these restarts ComfyUI. The window says so before it does it, and then stays
|
|
// up, polling, until the server answers again. A restart with nothing on screen
|
|
// is indistinguishable from a crash.
|
|
|
|
import { composer } from "../composer/composer.js";
|
|
|
|
const BASE = "/funpack/api/git";
|
|
|
|
async function ask(method, path, body) {
|
|
const res = await fetch(`${BASE}${path}`, {
|
|
method,
|
|
headers: body === undefined ? {} : { "Content-Type": "application/json" },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
const payload = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(payload.detail || `HTTP ${res.status}`);
|
|
return payload;
|
|
}
|
|
|
|
export const status = ({ remote = true } = {}) =>
|
|
ask("GET", `/status${remote ? "" : "?remote=0"}`);
|
|
|
|
/** How far behind, in words. The number alone does not say whether to care. */
|
|
function summarise(s) {
|
|
if (!s.ok) return s.detail || "FunPack cannot read its own git checkout.";
|
|
if (!s.checked_remote) return "Checking for updates…";
|
|
if (!s.fetch_ok) return "Could not reach the remote, so this may be out of date.";
|
|
if (s.behind) return `${s.behind} update${s.behind === 1 ? "" : "s"} available.`;
|
|
if (s.ahead) return `Up to date, with ${s.ahead} local commit${s.ahead === 1 ? "" : "s"} of your own.`;
|
|
return "Up to date.";
|
|
}
|
|
|
|
/**
|
|
* The overlay that stays up across a restart.
|
|
*
|
|
* Polls /api/health rather than guessing a duration: an update that installs
|
|
* requirements takes as long as it takes, and a fixed wait either lies or gives
|
|
* up early. Reloads once the server answers, because the code that just changed
|
|
* is the code the page is running.
|
|
*/
|
|
export function waitForRestart({ poll = 1500, give_up = 180000 } = {}) {
|
|
const overlay = composer.overlay.blocking({
|
|
message: "Restarting ComfyUI. This page comes back on its own.",
|
|
});
|
|
const started = Date.now();
|
|
|
|
const tick = async () => {
|
|
try {
|
|
const res = await fetch("/funpack/api/health", { cache: "no-store" });
|
|
if (res.ok) { window.location.reload(); return; }
|
|
} catch { /* still down, which is the expected half of this */ }
|
|
if (Date.now() - started > give_up) {
|
|
overlay.setMessage("ComfyUI has not come back. Check the terminal it was started from.");
|
|
return;
|
|
}
|
|
setTimeout(tick, poll);
|
|
};
|
|
setTimeout(tick, poll);
|
|
return overlay;
|
|
}
|
|
|
|
/**
|
|
* mount({ onRestart, running, setFooter, close }) -> a composer handle, no
|
|
* modal chrome. See tempfiles.js's mount() for why setFooter/close are the
|
|
* only things asked for. Built from a live status the moment it is mounted:
|
|
* what branch you are on and how far behind are exactly the facts that go
|
|
* stale while this was not on screen.
|
|
*/
|
|
export function mount({ onRestart = waitForRestart, running = () => false,
|
|
setFooter = () => {}, close = () => {} } = {}) {
|
|
const body = composer.region.stack({ gap: "sm", label: "Updates" });
|
|
|
|
const act = async (run) => {
|
|
body.set([composer.hint.default({ text: "Working. This can take a few minutes if "
|
|
+ "requirements changed." })]);
|
|
try {
|
|
const result = await run();
|
|
// The button is already disabled while a run is in flight, but that check
|
|
// goes stale if the dialog was left open across one starting -- the server
|
|
// re-checks and refuses the restart itself; this is what tells the user why
|
|
// nothing happened instead of it looking like the click did nothing.
|
|
if (result && result.blocked) { load(); return; }
|
|
// The server only restarts when the checkout actually moved. Pressing
|
|
// Update while already up to date is a normal thing to do, and waiting for
|
|
// a restart that is not coming would hang on an overlay for three minutes.
|
|
if (!result || result.restarting === false) { load(); return; }
|
|
// A stash that happened without being asked for by name has to be said
|
|
// loudly -- this is the only place it can be, since the restart about
|
|
// to happen replaces everything on screen.
|
|
if (result.stashed) {
|
|
composer.toast.warn({ text: `Local changes were stashed first: "${result.stashed}" `
|
|
+ "(git stash pop to get them back).", duration: 8000 });
|
|
}
|
|
close();
|
|
onRestart();
|
|
} catch (err) {
|
|
// Said in the window, where the button was pressed. Every refusal this can
|
|
// give names what to do: commit or stash, install git, pick another branch.
|
|
draw(null, err.message);
|
|
}
|
|
};
|
|
|
|
function draw(s, problem) {
|
|
if (problem) {
|
|
body.set([composer.banner.danger({ text: problem }),
|
|
composer.button.md({ label: "Try again", onClick: () => load() })]);
|
|
return;
|
|
}
|
|
if (!s) { body.set([composer.hint.default({ text: "Reading the checkout…" })]); return; }
|
|
|
|
const rows = [
|
|
composer.settingsRow.default({ label: "Version", hint: s.codename || undefined,
|
|
control: composer.text.sm({ text: s.version || "unknown" }) }),
|
|
composer.settingsRow.default({ label: "Branch", hint: summarise(s),
|
|
control: composer.text.sm({ text: s.branch || "?" }) }),
|
|
];
|
|
|
|
// A run in flight is killed by the restart, without warning and with the GPU
|
|
// time already spent. The window will not do that behind someone's back.
|
|
const busy = running();
|
|
if (busy) {
|
|
rows.push(composer.banner.warn({
|
|
text: "A generation is running. Updating or switching branch restarts ComfyUI "
|
|
+ "and stops it. Wait for it to finish, or cancel it first.",
|
|
}));
|
|
}
|
|
|
|
// The change already landed last time this was tried; only the restart is
|
|
// still owed. Nothing else in this window works until it happens -- the
|
|
// server refuses any further git action while one is pending.
|
|
if (s.restart_pending) {
|
|
rows.push(composer.banner.warn({
|
|
text: "An update already landed and is waiting to restart ComfyUI.",
|
|
}));
|
|
rows.push(composer.settingsRow.default({
|
|
label: "Restart",
|
|
hint: busy ? "Waiting for the generation to finish." : "Ready.",
|
|
control: composer.button.md({ label: "Restart now", tone: "primary", disabled: busy,
|
|
onClick: () => act(() => ask("POST", "/restart")) }),
|
|
}));
|
|
body.set([composer.group.default({ rows })]);
|
|
setFooter({ actions: [
|
|
composer.button.md({ label: "Close", tone: "ghost", onClick: () => close() }),
|
|
] });
|
|
return;
|
|
}
|
|
|
|
if (s.dirty) {
|
|
// Update alone still refuses on a dirty tree -- pulling new commits
|
|
// onto changes the user is in the middle of is not something to do
|
|
// without asking. Switching or rolling back auto-stash instead: what
|
|
// was left uncommitted on the branch (or commit) being LEFT is almost
|
|
// never the point of either action, and `git stash pop` gets it back.
|
|
rows.push(composer.banner.warn({
|
|
text: "This checkout has local changes, including any new files not yet tracked "
|
|
+ "by git. Switching branch or rolling back will stash all of it automatically "
|
|
+ "(recoverable with git stash pop). Updating still needs it committed or "
|
|
+ "stashed by hand first.",
|
|
}));
|
|
}
|
|
|
|
rows.push(composer.settingsRow.default({
|
|
label: "Switch to",
|
|
hint: "Switching pulls that branch and restarts ComfyUI.",
|
|
control: composer.select.md({
|
|
label: "Branch",
|
|
value: s.branch,
|
|
options: (s.branches || []).map((b) => ({ value: b, label: b })),
|
|
onChange: (branch) => { if (branch !== s.branch) act(() => ask("POST", "/checkout", { branch })); },
|
|
disabled: busy,
|
|
}),
|
|
}));
|
|
|
|
if (s.rollback_target) {
|
|
rows.push(composer.settingsRow.default({
|
|
label: "Roll back",
|
|
hint: `Back to ${String(s.rollback_target.commit || "").slice(0, 8)} — the version before the last update.`,
|
|
control: composer.button.md({ label: "Roll back", tone: "danger", disabled: busy,
|
|
onClick: () => act(() => ask("POST", "/rollback")) }),
|
|
}));
|
|
}
|
|
|
|
rows.push(composer.settingsRow.default({
|
|
label: "Restart ComfyUI",
|
|
hint: busy ? "Waiting for the generation to finish." : "Nothing else changes.",
|
|
control: composer.button.md({ label: "Restart", disabled: busy,
|
|
onClick: () => act(() => ask("POST", "/restart")) }),
|
|
}));
|
|
|
|
body.set([composer.group.default({ rows })]);
|
|
setFooter({
|
|
note: s.ok ? "" : "FunPack is not a git checkout, so it cannot update itself.",
|
|
actions: [
|
|
composer.button.md({ label: "Close", tone: "ghost", onClick: () => close() }),
|
|
composer.button.md({
|
|
label: s.behind ? `Update (${s.behind})` : "Update", tone: "primary",
|
|
disabled: !s.ok || Boolean(s.dirty) || busy,
|
|
onClick: () => act(() => ask("POST", "/update", {})),
|
|
}),
|
|
],
|
|
});
|
|
}
|
|
|
|
async function load() {
|
|
draw(null);
|
|
// Twice: the checkout alone first, so there is something to read instantly,
|
|
// then again with the remote for ahead and behind. A `git fetch` takes
|
|
// seconds on a good day and blocks until it times out on a machine that
|
|
// cannot reach the network -- and neither is a reason to show nothing.
|
|
let local = null;
|
|
try {
|
|
local = await status({ remote: false });
|
|
draw(local);
|
|
} catch (err) {
|
|
draw(null, err.message);
|
|
return;
|
|
}
|
|
try { draw(await status()); }
|
|
catch { /* the local answer stands, and says it has not checked */ }
|
|
}
|
|
|
|
load();
|
|
return body;
|
|
}
|
|
|
|
/**
|
|
* open() -> the window. One at a time.
|
|
*/
|
|
export function open({ onRestart, running } = {}) {
|
|
let window_ = null;
|
|
const body = mount({
|
|
onRestart, running,
|
|
setFooter: (f) => window_ && window_.setFooter(f),
|
|
close: () => window_ && window_.close("done"),
|
|
});
|
|
window_ = composer.modal.generic({
|
|
title: "Updates",
|
|
subtitle: "FunPack updates itself from git, then restarts ComfyUI.",
|
|
size: "md",
|
|
body,
|
|
onClose: () => { window_ = null; },
|
|
});
|
|
return window_;
|
|
}
|