UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

782 lines • 45.2 kB
// Node-pack auto-sync: decide whether the installed sidebar panel pack // (comfyui-agent-panel) is behind what THIS orchestrator build needs, and — when // it is, and only when the user has not pinned it — run the sync through the // hardened, verified install_comfyui(action:'panel') path. // // WHY THIS EXISTS. The orchestrator ships on npm; the panel ships on the Comfy // Registry. Updating one does not update the other, so users end up running a // new orchestrator against an old panel. The failures that produces are // confusing and hard to diagnose (a bridge command the panel simply doesn't // implement), which is exactly the report we got from the community. // // TWO RULES GOVERN EVERYTHING HERE: // // 1. NEVER REPORT A SYNC THAT DID NOT HAPPEN. This feature sits on top of the // #639/#641 fabricate-success fixes, so it inherits their discipline: the // mutation runs through `runPanelAction`, which fails closed unless the pack // provably MOVED on disk, and the version we report afterwards is RE-READ // from disk, never the version we intended to install. // // 2. NEVER MOVE A PINNED USER. A pin is a promise. `evaluatePanelSync` refuses // to recommend a sync while a pin is in force, `performPanelSync` refuses to // execute one, and `runPanelAction` itself refuses at the choke point. Every // "can't tell" (an unreadable pin, an unreadable installed version, a shadow // copy) resolves to "don't touch it", never to "probably fine". import { assertPinnedTarget, panelStatus, pinPanelBase, runPanelActionInner, withPanelOpLock, defaultDeps, } from "./panel-installer.js"; import { reclaimAbandonedPanelLock } from "./panel-pin-guard.js"; import { describePanelPin, PANEL_PIN_ENV_VAR, } from "./panel-settings.js"; import { requiredPanelVersion as requiredBridgePanelVersion, SEMVER_RE } from "./ui-bridge.js"; import { describeInstallPanelAction } from "./panel-recovery.js"; import { compareSemver, detectInstallMode } from "./self-update.js"; export { requiredBridgePanelVersion as requiredPanelVersion }; /* * Version screening. `compareSemver` returns 0 for anything it cannot parse, so * an unscreened "nightly" / "dev" / "" would compare EQUAL to the required * version and be reported as up to date — a silent wrong answer. We reuse the * project's CANONICAL strict SemVer 2.0.0 grammar (ui-bridge's SEMVER_RE) rather * than a hand-rolled approximation: a looser local regex admitted `01.11.28` * (leading zero) and `0.11.28+.` (empty build identifier), which compareSemver * then read as satisfying 0.11.28. * * ONE DELIBERATE DEVIATION from the spec grammar: that regex allows an optional * leading `v` (`v0.11.28`). We KEEP it rather than reject it. A `v`-prefixed * version is unambiguous, `compareSemver` parses it correctly, and the panel's * pyproject version is the source here — so rejecting it would call a perfectly * meaningful version "unknown" and refuse a sync the user actually wants. The * screen is about telling a real version from `nightly`/`dev`/garbage, and `v` * does not blur that line. */ function isComparableVersion(v) { return typeof v === "string" && SEMVER_RE.test(v.trim()); } /** * How to clear a pin, phrased for THIS session. These summaries are pushed * straight into the embedded panel chat on every hello, and that surface does * not carry install_comfyui(action:'panel') (#784) — naming it there is the same dead end the * bridge refusal had. */ const UNPIN_INSTRUCTION = () => describeInstallPanelAction("unpin", "removing the pin on the ComfyUI host (its ~/.comfyui-mcp/panel-settings.json, " + "or the COMFYUI_MCP_PANEL_PIN variable there) and restarting the orchestrator " + "running on that machine"); /** * The one thing an `up-to-date` verdict was missing. * * "Nothing to do" is true and, to a user whose graph writes are being refused * for a too-old panel, completely useless — it is the last step of the loop that * made #771/#784 feel unfixable. The reason both can be true at once is that the * write gate reads what the browser TAB advertised, and the panel's module URLs * carry no cache-busting key: a tab holding the pre-0.11.35 copy of the file * that builds the `hello` announces the old capability set while the pack on * disk is current. So the honest completion of "nothing to do" is "…and if * writes are still refused, the tab is the stale part". * * Phrased conditionally on purpose: this function cannot see the tab's * handshake, so it must not assert that a skew IS happening. The bridge refusal * (which sees both) makes the definite call. (Cache-busting itself is * #584 / panel #596 and is not addressed here.) */ const STALE_BUNDLE_HINT = ` If graph WRITES are still being refused for a too-old panel, the install is not ` + `the problem — the open ComfyUI browser tab is running a CACHED older copy of the ` + `panel's JavaScript, and the capability check reads what the tab announced. ` + `Hard-refresh that tab with a cache-bypassing reload (Ctrl+Shift+R, or Cmd+Shift+R ` + // NOT "updating the pack will report nothing to do" — that was true only while // this verdict was believed to mean "you are on the newest panel". An update // CAN pull a newer panel (see FLOOR_IS_NOT_LATEST, #806); what it cannot do is // fix a stale TAB. Say the thing that stays true in both worlds. `on macOS). Updating the pack may pull a newer panel, but no update of any kind ` + `replaces the JavaScript an already-open tab is running.`; /** Where a human can read the newest published panel version. Deliberately the * pack's `pyproject.toml` and NOT the repo's GitHub releases: the panel ships to * the Comfy Registry on a pyproject version change, and its releases page lags * or omits versions entirely, so "latest release" is the wrong answer. */ const PANEL_PYPROJECT_URL = "https://github.com/artokun/comfyui-mcp-panel/blob/main/pyproject.toml"; /** * The sentence a floor check owes the reader (#806). * * Clearing the floor and being current are different claims, and the gap between * them is where a real user's bug lived for days. `PANEL_VERSION` is the Manager * channel name ("nightly"), not a version number, so this file cannot close that * gap by itself: the honest move is not to guess a latest version, it is to STOP * IMPLYING ONE — name what was compared, say the comparison's limit out loud, and * point at the two things that do move the user (the update action, and the file * where the real latest is published). * * #1983 — a caller CAN now close it, by resolving the published version and * passing `latestVersion`. `latestKnown` then drops the "does not know the newest * published panel" clause, because on that call it is no longer true. When no * lookup was made this text is unchanged, which is still the state on the hello * auto-sync path. * * A function, not a constant: the update instruction depends on whether * install_comfyui(action:'panel') can act in THIS session. */ const FLOOR_IS_NOT_LATEST = (latestKnown = false) => ` NOTE — that is a FLOOR check, not a latest-version check. It compares your panel ` + `against the MINIMUM this orchestrator requires; ` + // #1983 — the caller can now resolve the newest published panel and hand it // in. When it did, "it does not know the newest published panel" is no longer // true and must not be said: the concrete comparison follows in LATEST CHECK. (latestKnown ? `` : `it does not know the newest published panel, and `) + `most panel fixes ship WITHOUT raising the floor. So a newer panel carrying ` + `fixes you are missing can exist while this still reads "meets the minimum". ` + (latestKnown ? `That is what the LATEST CHECK below answers.` : `The newest version is published in the pack's pyproject.toml (${PANEL_PYPROJECT_URL}); ` + `to pull it, run ${describeInstallPanelAction("update", "the update on the ComfyUI host")}.`); /** * Classify what a pin WRITE achieved, given the pin state resolved immediately * afterwards. `savedVersion` is required so a concurrent write that replaced * ours is reported as `superseded` rather than as our pin being active — the * response must describe the pin that is really in force, not the one we asked * for. */ export function classifyPinWrite(resolved, savedVersion) { if (!resolved.pinned) return "not-in-force"; if (resolved.source === "env") return "env-overrides-with-pin"; return resolved.version === savedVersion ? "active" : "superseded"; } /** * Pure decision function over a `PanelStatus` snapshot. No I/O, no mutation — * every branch is directly testable, which is the point: this is the logic that * decides whether we are allowed to touch a user's install. * * #1983 — TWO comparisons, never folded into one. `behind` answers "is this * panel below the FLOOR this orchestrator needs" (too old to work with); * `behindLatest` answers "is a newer panel PUBLISHED" (merely stale). A * below-floor panel is unsupported and gets a sync decision; a stale one is * advisory and changes no decision at all. Collapsing them either way is the * defect: reporting a 16-versions-behind panel as `behind:false` (#1983), or * turning "a newer one exists" into a refusal. */ export function evaluatePanelSync(status, opts = {}) { const latest = compareAgainstLatest(status, opts.latestVersion); const floor = evaluateAgainstFloor(status, opts, latest.latestPublishedVersion !== undefined); return { ...floor, latestPublishedVersion: latest.latestPublishedVersion, behindLatest: latest.behindLatest, summary: floor.summary + latestNote(status, latest, opts.latestVersion), }; } /** * The LATEST comparison, kept away from the floor one on purpose. * * Every "can't tell" resolves to `null` — the module's own rule. `false` here * is a claim ("I compared you against the newest published panel and you are on * it") and may only be returned when that comparison actually happened. */ function compareAgainstLatest(status, latestVersion) { const latest = typeof latestVersion === "string" && isComparableVersion(latestVersion) ? latestVersion.trim() : undefined; if (!latest) return { behindLatest: null, unknownBecause: "no-latest" }; // No panel of ours here at all (remote / cloud / no local ComfyUI): there is // nothing on this machine for the published version to be newer THAN. if (!status.applicable) { return { latestPublishedVersion: latest, behindLatest: null, unknownBecause: "not-applicable" }; } // The states where THIS FILE already refuses to make a version claim, because // `installedVersion` does not describe what is really being served: an // unreliable custom_nodes enumeration, a failed shadow scan, or a shadow copy // that ComfyUI may be loading instead. Comparing an untrusted version against // the newest published one would launder that same untrusted number into a // fresh confident claim (`blocked` + "you are 16 behind"), which is the defect // in a new costume. Can't tell → don't answer. if (status.scanReliable === false || status.shadowInspectFailed === true || (status.shadows?.length ?? 0) > 0) { return { latestPublishedVersion: latest, behindLatest: null, unknownBecause: "untrusted-install", }; } if (!status.installed) { // Same discipline as `behind`: a PROVEN absence is definitionally behind // anything published; an unproven one is a failed observation. return status.absenceProven === false ? { latestPublishedVersion: latest, behindLatest: null, unknownBecause: "untrusted-install" } : { latestPublishedVersion: latest, behindLatest: true }; } if (!isComparableVersion(status.installedVersion)) { return { latestPublishedVersion: latest, behindLatest: null, unknownBecause: "incomparable" }; } return { latestPublishedVersion: latest, behindLatest: compareSemver(status.installedVersion, latest) < 0, }; } /** * The pin as `evaluatePanelSync` reads it. Shared so the verdict and the latest * advisory cannot disagree about whether a pin is in force — a MISSING pin field * resolves to indeterminate-PINNED, and an advisory that read it as unpinned * would send that user at an update the pin guard is going to refuse. */ function resolvePin(status) { return status.pin && typeof status.pin === "object" ? status.pin : { pinned: true, source: "settings", indeterminate: true }; } /** * The sentence the latest comparison owes the reader — appended to every * verdict, because the defect showed up on `dev-install` just as readily as on * `meets-floor` (#1983's own reproduction landed on the dev branch). * * Deliberately ADVISORY in every wording: being behind the newest published * panel blocks nothing, refuses nothing and changes no decision above it. */ function latestNote(status, latest, requested) { // Nothing was asked on this path — FLOOR_IS_NOT_LATEST already says the // latest is unknown here, and inventing a second sentence about a lookup that // never ran would be noise. if (requested === undefined) return ""; const published = latest.latestPublishedVersion; if (!published) { return ( // "did not answer USABLY" covers both shapes: no answer at all, and an // answer that is not a version we can compare (a "nightly" pyproject, a // body from the wrong pack). Saying "did not answer" would be wrong in the // second case, which is the same species of small lie as the rest of this. ` LATEST CHECK: the newest published panel could not be determined just now ` + `(the version lookup did not answer usably). Only the FLOOR comparison above was made — ` + `this is NOT evidence that you are on the newest panel. Check by hand at ` + `${PANEL_PYPROJECT_URL}, or re-run this once the network is back.`); } if (latest.behindLatest === null) { const why = latest.unknownBecause === "untrusted-install" ? `what is actually installed here could not be established (see above), so it cannot ` + `be compared against it` : latest.unknownBecause === "not-applicable" ? `no panel of this orchestrator's is managed on this machine, so there is nothing ` + `here for it to be newer than` : `the installed version (${status.installedVersion ?? "unreadable"}) is not a ` + `comparable version number, so it cannot be compared against it`; return ` LATEST CHECK: the newest published panel is ${published}, but ${why} — whether you are behind it is UNKNOWN.`; } if (latest.behindLatest === false) { // `false` was established as "NOT OLDER than the newest published panel", // and that includes being AHEAD of it — which a dev checkout tracking the // panel repo's main routinely is, and which is exactly the install shape // #1983 reproduced on. Saying "you are on it" there asserts a version // EQUALITY nothing here proved: it names a version the reader is not // running. That is the same unearned claim, one size smaller, that this // whole comparison exists to delete — so the two cases get two sentences. return isComparableVersion(status.installedVersion) && compareSemver(status.installedVersion, published) > 0 ? ` LATEST CHECK: your panel (${status.installedVersion}) is NEWER than the newest ` + `published one (${published}), so there is nothing to pull.` : ` LATEST CHECK: ${published} is the newest published panel, and you are on it.`; } const from = status.installedVersion ? `${status.installedVersion} → ` : ""; const pinned = resolvePin(status).pinned; /* * THE REMEDY TABLE — exhaustive on purpose. * * WHICH remedy is named is not cosmetic: a remedy that cannot succeed from * the state it is printed in is a bug (#771/#784, #1933), not a wording nit. * Two dead-end remedies have now been found in this one sentence — dev * symlinks and pins pointed at an update that refuses them, then an ABSENT * pack pointed at the same one — so every state that can reach here is * enumerated rather than falling through a default. Absence is the case * where the VERB changes entirely rather than merely being inapplicable, * which is exactly how it slipped past the first pass. * * Only `behindLatest === true` reaches this, which narrows it to: * * dev symlink → nothing here. The dev-install verdict above already * says "update it through its own git checkout", and * nothing in this process may touch that install. * NOT INSTALLED, → SYNC, never update. `runPanelActionCore` rethrows * unpinned the Manager's "not installed" for action:"update" * once the on-disk scan agrees the pack is absent — * there is genuinely nothing to update — while * `performPanelSync` picks `install` when * `before.installed` is false. The verdict above says * "Syncing will install it", so naming the same verb * keeps the reply speaking with one voice. * NOT INSTALLED, → clear the pin. Installing the nightly channel would * pinned land some version other than the pinned one, so the * pin is genuinely the first step (the `pinned-warn` * verdict says the same). * installed, pinned → clear the pin. * installed, plain → update. The one state that verb actually works in. */ const remedy = status.isDevSymlink ? `` : pinned ? ` You are pinned, so moving onto it means clearing the pin first (${UNPIN_INSTRUCTION()}).` : status.installed ? ` To pull it, run ${describeInstallPanelAction("update", "the update on the ComfyUI host")}.` : ` To install it, run ${describeInstallPanelAction("sync", "the panel sync on the ComfyUI host")}.`; // An absent pack has no version for a published one to be "newer" THAN, so // the behind-latest framing is wrong here even though `behindLatest` is // correctly true: say what an install would land instead. if (!status.installed) { return (` LATEST CHECK: the newest published panel is ${published} — that is what installing ` + `here would land. Nothing is blocked by this and nothing has been changed.${remedy}`); } return (` LATEST CHECK: a NEWER panel is published (${from}${published}). Nothing is blocked ` + `by this and nothing has been changed — it is an advisory.${remedy}`); } /** * The FLOOR half — the original decision body, unchanged in behaviour. It knows * whether a latest version was resolved only so its own note stops claiming the * newest panel is unknown when the caller just looked it up. */ function evaluateAgainstFloor(status, opts, latestKnown) { const required = opts.requiredVersion ?? requiredBridgePanelVersion(); const orchestratorVersion = opts.orchestratorVersion ?? detectInstallMode().currentVersion ?? undefined; // A PanelStatus without a `pin` is a caller that predates the pin (or built // the object by hand). Absence of the field is not evidence of absence of a // pin, so it resolves to indeterminate-pinned, not to unpinned. const pin = resolvePin(status); const base = { requiredPanelVersion: required, installedVersion: status.installedVersion, orchestratorVersion, pin, }; const orch = orchestratorVersion ? `comfyui-mcp ${orchestratorVersion}` : "this orchestrator"; // The skill PROMISES visible drift when a user sits on a pinned version — // that warning is owed in every state, including the early-return ones // (remote / dev install), not only on paths that could act. // Proven-behind is a VERSION fact, independent of whether we are allowed to // act on it. Kept separate from `driftNote` (which is about a PIN) so a branch // that declines to touch the install still reports the drift honestly. const provenBehind = !!status.installedVersion && isComparableVersion(status.installedVersion) && compareSemver(status.installedVersion, required) < 0; const driftNote = (() => { if (!pin.pinned || pin.indeterminate) return ""; if (!status.installedVersion || !isComparableVersion(status.installedVersion)) return ""; if (compareSemver(status.installedVersion, required) >= 0) return ""; return (` NOTE: the panel is ${describePanelPin(pin)} and BEHIND what ${orch} ` + `expects (${status.installedVersion} < ${required}) — left untouched; ` + `close the gap by ${UNPIN_INSTRUCTION()}.`); })(); if (!status.applicable) { return { ...base, decision: "not-applicable", behind: false, summary: `Panel sync does not apply here: ${status.note || "no local ComfyUI is configured"}. ` + `The panel pack is managed on the machine that runs ComfyUI.` + driftNote, }; } // A symlinked dev checkout belongs to whoever made it. Check this BEFORE the // pin: "we don't touch dev installs" is the stronger, older promise, and // telling a developer to unpin would be wrong advice. if (status.isDevSymlink) { return { ...base, decision: "dev-install", // A dev checkout is one we never TOUCH — that is not a reason to report it // as current. `behind` is documented as "we PROVED the panel is older than // requiredPanelVersion", and a comparable dev version below the floor is // exactly that proof. Hardcoding false here made a 0.15.3 checkout under a // 0.15.11 floor indistinguishable from an up-to-date one, so a panel // carrying already-fixed bugs read as healthy in every status call. behind: provenBehind, summary: `The panel at ${status.dir ?? "custom_nodes"} is a dev symlink — update it ` + `through its own git checkout. Nothing here will modify it.` + (provenBehind ? ` NOTE: it is BEHIND what ${orch} expects ` + `(${status.installedVersion} < ${required}) — pull its checkout to close the gap.` : "") + driftNote, }; } // Can't prove the scan saw everything → "absent" is unreliable (#639). A // sync that installs blind here could clobber a NEWER panel the enumeration // simply missed, exactly the loss the installer's own unreliable-scan guard // refuses. Can't-tell → don't touch. if (status.scanReliable === false) { return { ...base, decision: "blocked", behind: false, summary: `custom_nodes could not be enumerated, so whether the panel is present is ` + `UNRELIABLE — nothing will be changed (a blind install could clobber an ` + `existing, possibly newer, panel). Re-run once custom_nodes is readable.` + driftNote, }; } // Can't prove there is no pin → behave as if there is one. if (pin.indeterminate) { return { ...base, decision: "blocked", behind: false, summary: `Cannot determine whether the panel is version-pinned (${describePanelPin(pin)}), ` + `so nothing will be changed. Fix or remove the settings file, or set ` + `${PANEL_PIN_ENV_VAR}=off, then re-check.`, }; } // The shadow scan itself failed → `shadows: []` means "we didn't find any", // NOT "there are none". Reading an empty array as an all-clear would let a // served backup copy sit behind a "synced" claim, so an incomplete scan is // blocked exactly like a found shadow. if (status.shadowInspectFailed) { return { ...base, decision: "blocked", behind: false, summary: `Could not enumerate custom_nodes to check for shadow copies of the panel, so ` + `it cannot be confirmed that the panel in the browser is the one on disk. NOT ` + `syncing on an unverified install. Check custom_nodes is readable (a stray ` + `".comfyui-agent-panel.bak-*" there would shadow the real panel), then re-check.` + driftNote, }; } // A shadow copy means the SERVED panel is not the one on disk, so neither the // "you are behind" reading nor any post-sync version claim would be true. if (status.shadows.length > 0) { const names = status.shadows.map((s) => `"${s.name}"`).join(", "); return { ...base, decision: "blocked", behind: false, summary: `A shadow copy of the panel is present in custom_nodes (${names}). ComfyUI ` + `serves it too, and a dot-prefixed dir wins by sort order, so the browser may ` + `be loading it instead of the real panel — the installed version below cannot ` + `be trusted. Move the shadow copy OUT of custom_nodes and hard-refresh the ` + `ComfyUI tab before syncing anything.` + driftNote, }; } if (!status.installed) { // #766 — "not installed" is only actionable when the absence is PROVEN. // When the ComfyUI root came from configuration rather than from the running // server, a perfectly good panel may be sitting in the tree that is actually // served, and installing would drop a second copy into a custom_nodes // nothing loads — the user then sees no panel and no error. Refuse to act on // an unproven absence, exactly as we refuse on an unreliable scan above. if (status.absenceProven === false) { return { ...base, decision: "blocked", behind: false, summary: `No panel was found in ${status.comfyuiPath ?? "custom_nodes"}, but that root ` + `was NOT confirmed by the running ComfyUI — so this is not a proven absence ` + `and nothing will be installed. On a split install (Comfy Desktop's ` + `--base-directory, #766) the panel lives in a different tree and installing ` + `here would land in a custom_nodes that is never loaded. Make sure ComfyUI is ` + `running and reachable so its own install root can be read, or check ` + `install_comfyui (action:"environment")'s local.workspace_path, then re-check.` + driftNote, }; } // Not installed at all. That is definitionally behind, but a pin still wins: // installing the nightly channel would land some version other than the // pinned one. if (pin.pinned) { return { ...base, decision: "pinned-warn", behind: true, summary: `The panel pack is not installed, and ${orch} needs panel ${required}+. ` + `You are ${describePanelPin(pin)}, so nothing will be installed automatically. ` + `Clear the pin (${UNPIN_INSTRUCTION()}) to let the sync install it.`, }; } return { ...base, decision: "sync", behind: true, summary: `The panel pack is not installed and ${orch} needs panel ${required}+. ` + `Syncing will install it, then ComfyUI must be restarted.`, }; } if (!isComparableVersion(status.installedVersion)) { const cannotCompare = `The panel is installed at ${status.dir ?? "custom_nodes"} but its version ` + `(${status.installedVersion ?? "unreadable"}) is not a comparable version ` + `number, so it cannot be compared against the ${required} this orchestrator ` + `needs.`; // A pin outranks "we can't tell". Reporting `unknown` here would send the // agent off to suggest a deliberate update that the mutation guard is going // to refuse anyway — and would skip the warning the user is owed. if (pin.pinned) { return { ...base, decision: "pinned-warn", behind: false, summary: `${cannotCompare} You are also ${describePanelPin(pin)}, so nothing will ` + `be changed either way. Clear the pin (${UNPIN_INSTRUCTION()}) ` + `first if you want to move the panel at all.`, }; } return { ...base, decision: "unknown", behind: false, summary: `${cannotCompare} NOT syncing on a guess — check the pack's pyproject.toml, ` + `or run ${describeInstallPanelAction("update", "the update on the ComfyUI host")} ` + `deliberately.`, }; } const behind = compareSemver(status.installedVersion, required) < 0; if (pin.pinned) { if (!behind) { return { ...base, decision: "meets-floor", behind: false, summary: `Panel ${status.installedVersion} meets the minimum ${orch} needs ` + `(${required}+). You are ${describePanelPin(pin)}; nothing will be changed.` + // The pinned reader gets the same honesty, with the step that actually // applies to them: an update is gated behind clearing the pin, so // sending them straight at install_comfyui(action:'panel', panel_action:'update') would be the // dead-end shape this cluster exists to remove. ` NOTE — that is a FLOOR check, not a latest-version check: a newer panel with ` + `fixes you are missing can exist while this still reads "meets the minimum", ` + `because most panel fixes do not raise the floor. The newest version is published ` + `in the pack's pyproject.toml (${PANEL_PYPROJECT_URL}). To move onto it you must ` + `first clear the pin (${UNPIN_INSTRUCTION()}).`, }; } // THE WARN CASE. Say what exists, say they're pinned, change nothing. return { ...base, decision: "pinned-warn", behind: true, summary: `Heads up: ${orch} expects panel ${required}+, and you are on panel ` + `${status.installedVersion} — a newer panel that matches your orchestrator ` + `exists. You are ${describePanelPin(pin)}, so NOTHING has been changed and ` + `nothing will be. If you want the newer panel, clear the pin first ` + `(${UNPIN_INSTRUCTION()}` + (pin.source === "env" ? `, though this pin comes from ${PANEL_PIN_ENV_VAR} and must be unset in ` + `the environment` : ``) + `) and then sync.`, }; } if (!behind) { return { ...base, decision: "meets-floor", behind: false, summary: `Panel ${status.installedVersion} meets the minimum ${orch} needs ` + `(${required}+), so nothing will be synced.${FLOOR_IS_NOT_LATEST(latestKnown)}${STALE_BUNDLE_HINT}`, }; } return { ...base, decision: "sync", behind: true, summary: `Panel ${status.installedVersion} is older than the ${required}+ that ${orch} ` + `needs. Syncing will pull the latest panel via ComfyUI-Manager; ComfyUI must ` + `be restarted afterwards.`, }; } /** * #888 — the authoritative re-check behind the hello auto-sync FAILURE path. * * The sync's own pre-scan runs inside the reconnect/retarget window, where the * base it freezes can describe the tree ComfyUI is ABOUT to be retargeted away * from (or a tree read before a just-restarted server named its real root). A * failure minted from that scan — "the pack is not present in custom_nodes" — * can therefore be stale the moment it is thrown, while an immediate * install_comfyui(action:'panel', panel_action:'status') shows the panel * installed and compatible. * Pushing that detail to the user as-is warns about a state that does not exist. * * So before the failure is surfaced, the caller re-asks the ONE question the * whole sync exists to answer, against a FRESHLY resolved base: does the panel * on disk meet this orchestrator's floor? A proven `meets-floor` verdict means * the sync's goal is already met and the failure's warning would be false. * * Returns the fresh assessment, or null when the re-scan itself could not run * (base unresolvable, disk unreadable, …). A "can't tell" NEVER counts as * proof the warning was false — the caller keeps the original warning then. * * Note on the narrow opposite case: if the failed sync DID move the pack and * only its verification threw, a meets-floor re-scan means the update landed * and the floor is met — the warning's "no update was claimed" is equally * false, so suppressing is still the truthful outcome. The stale-RUNNING-panel * restart guidance that case loses is carried by the write gate's own refusal * (it reads what the tab advertises and names the skew), so the user is not * left without a path. */ export async function reassessPanelAfterSyncFailure(opts = {}) { const deps = opts.deps ?? defaultDeps; try { // Same fresh, frozen base resolution the sync itself used — forced, because // a hello can mean ComfyUI just restarted onto a different tree, and a cached // base is precisely how the stale reading this re-check exists to catch was // minted. panelStatus is a pure read and holds no lock, so this can run right // behind the failed sync without contending with the op lock it released. const pinnedDeps = await pinPanelBase(deps, { force: true }); const status = await panelStatus(pinnedDeps); return evaluatePanelSync(status, { orchestratorVersion: opts.orchestratorVersion, requiredVersion: opts.requiredVersion, }); } catch { // Every guard is itself an operation that can fail. A failed re-check is // "could not determine", never "determined fine" — report nothing. return null; } } /** * Run the sync, honouring every guard. * * Contract, in order: * - Re-evaluate the decision NOW (never act on a snapshot handed in from * earlier — the pin may have been set in between). * - Any decision other than `sync` returns `synced: false` WITHOUT queueing a * mutation. In particular `pinned-warn` returns the warning and stops; this * is a policy refusal, not an error. * - `sync` delegates to `runPanelAction`, which THROWS unless the pack provably * moved on disk (#639) and no shadow copy is serving (#641), or Manager * explicitly staged the update for the next startup (#1133). A staged * update is returned as pending, not as `synced: true`, because its bytes * have not moved on disk yet. * - On success the installed version is RE-READ from disk and reported. If the * re-read cannot confirm a version, we throw rather than claim a version we * did not observe. */ export async function performPanelSync(opts = {}) { const deps = opts.deps ?? defaultDeps; // #1828 / #1953 — hello auto-sync is unattended. An abandoned panel-op.lock // (recorded owner provably dead — including a fresh one left by a // self-restart) used to fail it every session for days, while the timeout // named panel_action:'unlock' as the recovery nobody was there to run. // Reclaim uses that same verified path (owner re-checked dead, rename-aside // + byte compare); a live or reuse-ambiguous lock still refuses and acquire // waits/times out as before. Explicit panel_action:'sync' shares this // function, so it unblocks the same way. reclaimAbandonedPanelLock(); // The status read, the DECISION, and the mutation all run inside the op // lock: a decision made outside could go stale before the lock is acquired, // and a newer panel installed in that gap would be blindly overwritten by // the stale decision (codex gate). runPanelAction's own wrapper would lock // only the mutation half, so this calls the inner action directly. return withPanelOpLock(async () => { // Freeze the ComfyUI base for the WHOLE sync, not per-call. This function // makes three separate reads of the install — the pre-decision status, the // mutation's own pre/post detection, and the post-status re-read — and the // honesty of every claim it makes depends on all three describing the same // directory. Pinning inside each callee is not enough: the live-base cache // has a short TTL and a Manager operation can outlive it, so two calls could // resolve different trees (see pinPanelBase). // A sync is what the orchestrator runs on every panel HELLO, which is also // the signal that ComfyUI may have just restarted — possibly with different // launch flags, and therefore a different custom_nodes. Force a fresh // resolution rather than serving a cached base that could describe the // previous process. (pinPanelBase only probes for the REAL dep set; an // injected one has already declared where ComfyUI is.) const pinnedDeps = await pinPanelBase(deps, { force: true }); const before = await panelStatus(pinnedDeps); const assessment = evaluatePanelSync(before, { orchestratorVersion: opts.orchestratorVersion, requiredVersion: opts.requiredVersion, }); if (assessment.decision !== "sync") { // EVERY return path, not just the one that mutated. This verdict was // computed from a status read of the FROZEN tree, and a retarget can land // while that read is awaited — handing target A's "already meets what this // orchestrator needs" to a tab that now belongs to B, which may be far // behind. A non-mutating answer about the wrong install is still a wrong // answer, and this one gets pushed straight into the panel chat. assertPinnedTarget(pinnedDeps, "sync", `before reporting "${assessment.decision}"`); return { synced: false, decision: assessment.decision, previousVersion: before.installedVersion, requiredPanelVersion: assessment.requiredPanelVersion, message: assessment.summary, }; } // `update` refreshes an existing pack; `install` is for a pack that isn't // there. Both go through the same verified path and both fail closed. const action = before.installed ? "update" : "install"; const result = await runPanelActionInner(action, pinnedDeps); // Re-read from disk. `runPanelAction` already proved movement, but the version // we hand back to the user must be one we OBSERVED after the fact, not the one // the installer intended or reported. const after = await panelStatus(pinnedDeps); if (!after.installed || !after.installedVersion) { throw new Error(`Panel ${action} reported success, but re-reading the pack afterwards could ` + `not confirm an installed version${after.dir ? ` at ${after.dir}` : ""}. NOT ` + `reporting a completed sync. Check custom_nodes and re-run ` + `${describeInstallPanelAction("status", "re-checking the pack on the ComfyUI host")}.`); } if (after.shadows.length > 0) { throw new Error(`Panel ${action} applied on disk, but ${after.shadows.length} shadow copy/copies ` + `(${after.shadows.map((s) => `"${s.name}"`).join(", ")}) are still served from ` + `custom_nodes, so the browser may keep loading the OLD panel. NOT reporting a ` + `completed sync: move them out of custom_nodes and hard-refresh the ComfyUI tab.`); } // A shadow scan that could not RUN is not a clean scan. `shadows: []` from a // failed enumeration would otherwise read as an all-clear here exactly as it // did in the pre-mutation assessment — the same fail-open, one step later. if (after.shadowInspectFailed) { throw new Error(`Panel ${action} applied on disk (now ${after.installedVersion}), but custom_nodes ` + `could not be enumerated afterwards to check for shadow copies, so it cannot be ` + `confirmed that the browser will load THIS panel rather than a stray ` + `".comfyui-agent-panel.bak-*". NOT reporting a completed sync — re-run ` + `${describeInstallPanelAction("status", "a re-check on the ComfyUI host")} once custom_nodes is readable.`); } // #639 recurrence: a Manager-success response can be a legitimate staged // update (#1133), leaving the old version on disk until the next startup. // That is safe to report as pending — and must retain restartRequired — but // it is not a completed sync and must not publish the unchanged version as // `verifiedVersion`. The installer marks this path explicitly so a same- // version nightly git movement cannot be confused with staging. if (result.staged) { assertPinnedTarget(pinnedDeps, "sync", "before reporting a staged update"); return { synced: false, staged: true, decision: "sync", previousVersion: result.previousVersion ?? before.installedVersion, requiredPanelVersion: assessment.requiredPanelVersion, restartRequired: true, message: result.message, }; } // Tri-state: `null` when the landed version can't be compared at all. const stillBehind = isComparableVersion(after.installedVersion) ? compareSemver(after.installedVersion, assessment.requiredPanelVersion) < 0 : null; const gapNote = stillBehind === true ? `NOTE: that is still below the ${assessment.requiredPanelVersion} this ` + `orchestrator expects — the update applied but did not close the gap. ` : stillBehind === null ? `NOTE: "${after.installedVersion}" is not a comparable version number, so ` + `whether it meets the ${assessment.requiredPanelVersion} this orchestrator ` + `expects could NOT be confirmed — the update applied, the version match did ` + `not. ` : ``; // The action checked the frozen target before IT returned, but this // function then does another awaited status read and publishes its own // success — which the orchestrator pushes straight into the panel chat. A // retarget in that last gap would announce a verified sync of tree A to a // tab that now belongs to B. Re-assert before the claim leaves here. assertPinnedTarget(pinnedDeps, "sync", "before reporting a completed sync"); return { synced: true, decision: "sync", previousVersion: result.previousVersion ?? before.installedVersion, verifiedVersion: after.installedVersion, requiredPanelVersion: assessment.requiredPanelVersion, stillBehind, restartRequired: true, message: `Panel synced: verified on disk as ${after.installedVersion}` + (result.previousVersion ? ` (was ${result.previousVersion})` : ``) + `. ` + gapNote + `RESTART ComfyUI to load it.`, }; }); } //# sourceMappingURL=panel-sync.js.map