Files
olivv-cs-ComfyUI-FunPack/app/shell/updates.js
T
digital-garbageandClaude Sonnet 5 75e9e9f686 core, app: pipeline wiring, and Restart/auto-stash for the updater
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>
2026-09-07 19:25:06 +03:00

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_;
}