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),
265 lines • 15.6 kB
JavaScript
import { panelStatus, repairInterruptedPanelSwap, runPanelAction, withPanelOpLock, } from "../services/panel-installer.js";
import { classifyPinWrite, evaluatePanelSync, performPanelSync, requiredPanelVersion, } from "../services/panel-sync.js";
import { clearPanelVersionPin, describePanelPin, getPanelPinState, PANEL_PIN_ENV_VAR, setPanelVersionPin, } from "../services/panel-settings.js";
import { activePanelPendingOps, reclaimAbandonedPanelLock } from "../services/panel-pin-guard.js";
import { cancelPanelPendingOps } from "../services/panel-pending-cancel.js";
import { getLatestPublishedPanelVersion } from "../services/panel-auto-update.js";
import { logger } from "../utils/logger.js";
function json(value) {
return {
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
};
}
/** The sidebar-panel operations, unchanged from the retired `install_comfyui(action:'panel')`
* tool's own `action` enum (0.50.0 slice 13). It moved to `panel_action`
* because the folded tool's own `action` selects the family member — two
* fields cannot share one name in a flat schema. */
export const PANEL_ACTIONS = [
"status",
"install",
"update",
"reinstall",
"sync",
"pin",
"unpin",
"unlock",
];
/**
* #1983 — the newest published panel, for the STATUS reply.
*
* Three outcomes, and the middle one is the whole point:
* - `string` → resolved.
* - `null` → the lookup ran and could not answer (offline, GitHub down,
* a body that is not this pack). The status call still
* succeeds and still reports the floor verdict; what it must
* NOT do is report "not behind", which would assert a negative
* nothing observed.
* - `undefined` → no lookup was made, because none could mean anything here
* (remote / cloud / no local ComfyUI: there is no panel of
* ours on this machine for a published version to be newer
* than). Skipping it also keeps `action:"panel"` off the
* network entirely in remote mode.
*
* REPORTING ONLY. Nothing here installs, stages or applies anything — #1559
* calls auto-applying a non-goal and the staged-update-then-restart flow stays
* explicit.
*/
async function probeLatestPanelVersion(applicable) {
if (!applicable)
return undefined;
try {
// Bounded by the probe itself (5s AbortSignal.timeout) and documented as
// never-throwing. The try/catch is belt-and-braces: a status call is how an
// operator diagnoses a broken panel, and it must not start failing because
// a version lookup did something unexpected.
return (await getLatestPublishedPanelVersion()) ?? null;
}
catch (err) {
// NOT an unknown-collapse: `null` here IS the unknown state, and the
// assessment renders it as "could not be determined", never as "up to date".
logger.debug(`[install_comfyui panel status] latest-version probe threw: ${err instanceof Error ? err.message : String(err)}`);
return null;
}
}
/**
* `install_comfyui (action:"panel")` — everything the retired `install_comfyui(action:'panel')`
* tool did, selected by `panel_action` (0.50.0 slice 13). Every panel-installer
* / panel-sync / panel-settings call below, its arguments, its locking and its
* JSON return shape are byte-for-byte what the standalone tool did.
*/
export async function panelAction(action, version, reason) {
if (action === "status") {
// A crash between the swap's two renames leaves custom_nodes with NO
// panel. Repair it here — asking "what's going on?" is exactly when a
// user hits that state, and requiring them to know to run a MUTATION
// to get their panel back is not a recovery path. Takes the op lock
// itself and no-ops when there is nothing to repair.
const repaired = await repairInterruptedPanelSwap();
const status = await panelStatus();
// #1983 — THE CALL SITE. `evaluatePanelSync` compares against the FLOOR, so
// on its own it reported a panel sixteen releases behind as `behind:false`.
// #1971 shipped the published-version probe but only the periodic
// background loop called it; the tool an operator actually invokes never
// reached it. Resolve latest HERE, in the async caller, and hand it to the
// (still synchronous, still pure) evaluator.
//
// `null` — not `undefined` — when the lookup ran and failed: that is the
// difference between "could not determine the latest" and "no lookup was
// made", and the assessment says which. Never `false`/"up to date".
const latestVersion = await probeLatestPanelVersion(status.applicable);
return json({
...status,
note: repaired ? `${status.note}${repaired}` : status.note,
sync: evaluatePanelSync(status, { latestVersion }),
});
}
if (action === "sync") {
return json(await performPanelSync());
}
if (action === "pin") {
const target = (version ?? "").trim();
if (!target) {
// Refuse rather than guess a version for them — a pin the user did
// not choose is worse than no pin.
throw new Error("install_comfyui(action:'panel', panel_action:'pin') needs a `version` (e.g. '0.11.20'). Run " +
"install_comfyui(action:'panel', panel_action:'status') and pass its installedVersion to pin " +
"where you are now.");
}
// Serialized with panel mutations: a pin must not commit halfway
// through an in-flight install/update (see withPanelOpLock). The
// RESOLVED state is read INSIDE the same critical section — reading it
// after releasing the lock let a concurrent pin/unpin land in between,
// so the response could describe a pin that was no longer the real one.
// The pending-op read is inside too: a marker recorded by an update-all
// or snapshot restore (both hold this lock while recording) must be
// seen by the very pin write that follows them — and so must its
// CANCELLATION (#689): each marker is dealt with here, in the same
// critical section, before the pin result is reported.
const { pin, resolved, pending, cancels } = await withPanelOpLock(async () => {
const written = setPanelVersionPin(target, reason);
const ops = activePanelPendingOps();
return {
pin: written,
resolved: getPanelPinState(),
pending: ops,
cancels: ops.length > 0 ? await cancelPanelPendingOps(ops) : [],
};
});
// What actually governs is NOT necessarily what we just wrote:
// COMFYUI_MCP_PANEL_PIN takes precedence (so the saved pin can be inert,
// or the panel left unpinned outright by `=off`), and a concurrent write
// may have superseded ours. Reporting the write as protection without
// checking is the fabricated-success failure this feature exists to
// prevent, so each outcome is named distinctly.
const outcome = classifyPinWrite(resolved, pin.version);
// A pin written during a pending update-all / snapshot-restore window
// now CANCELS what it provably can (#689): each op's report says what
// happened — cancelled (with before/after pending counts), already
// drained (nothing left to cancel; the panel may ALREADY have moved),
// already running (in-flight work CANNOT be cancelled), or
// could-not-verify — and the marker is cleared only for the first two.
// What remains (residue) keeps the warning below. WARN rather than
// refuse for the residue: the pin is valid and governs every FUTURE
// operation — refusing it would leave the user LESS protected against
// the next sync, to punish them for a race they could not see.
const residue = pending.filter((_, i) => !cancels[i]?.markerCleared);
const pendingWarning = residue.length
? ` WARNING — ${residue.length === 1
? "a panel-affecting operation is"
: `${residue.length} panel-affecting operations are`} still pending and may move the panel AFTER this pin: ` +
`${residue.map((op) => op.detail).join("; ")}. The pin governs every ` +
`install/update/reinstall/sync from now on, but it cannot stop work ` +
`already running — check install_comfyui(action:'panel', panel_action:'status') once it has landed.`
: "";
const cancelNote = cancels.length
? ` Pending-op handling: ${cancels.map((c) => c.detail).join(" ")}`
: "";
// panelStatus is advisory here (it only supplies installedVersion for
// the message); the authoritative pin is `resolved`, captured above.
const status = await panelStatus();
return json({
action: "pin",
pin: resolved,
outcome,
/** Is the pin we just SAVED the one actually in force? */
active: outcome === "active",
requestedVersion: pin.version,
installedVersion: status.installedVersion,
requiredPanelVersion: requiredPanelVersion(),
/** Panel-affecting operations already handed to ComfyUI-Manager that
* may STILL land out-of-band — the residue after cancellation
* (#689): what was provably cancelled or proven drained is absent. */
pendingPanelOps: residue.length ? residue : undefined,
/** One report per pending op found at pin time (#689): what was
* attempted, the before/after pending counts, and the outcome —
* cancelled / already-drained / partially-cancelled /
* already-running / could-not-verify / cannot-cancel-remote. */
pendingOpCancellations: cancels.length
? cancels.map((c) => ({
kind: c.op.kind,
outcome: c.outcome,
markerCleared: c.markerCleared,
...(c.pendingBefore !== undefined
? {
pendingBefore: c.pendingBefore,
pendingAfter: c.pendingAfter,
inProgress: c.inProgress,
}
: {}),
detail: c.detail,
}))
: undefined,
// A pin records intent; it does NOT move the panel. Saying so
// prevents "pinned to 0.11.20" being read as "now on 0.11.20".
note: outcome === "active"
? `Pinned to ${pin.version}. This records intent only — it does NOT change ` +
`what is installed (currently ${status.installedVersion ?? "unknown"}). ` +
`install/update/reinstall/sync and the on-load auto-install will now ` +
`refuse until the pin is cleared with install_comfyui(action:'panel', panel_action:'unpin').` +
pendingWarning +
cancelNote
: outcome === "env-overrides-with-pin"
? `Saved a pin at ${pin.version}, but it is NOT the pin in force: the ` +
`${PANEL_PIN_ENV_VAR} environment variable takes precedence and the ` +
`panel is ${describePanelPin(resolved)}. The panel IS protected — ` +
`just at the env pin's version, not yours. Unset ${PANEL_PIN_ENV_VAR} ` +
`and restart the orchestrator for the saved ${pin.version} to govern.` +
pendingWarning +
cancelNote
: outcome === "superseded"
? `Saved a pin at ${pin.version}, but another pin was written at the ` +
`same time and won: the panel is ${describePanelPin(resolved)}. The ` +
`panel IS pinned, just not at ${pin.version} — re-run the pin if you ` +
`meant yours to stand.` +
pendingWarning +
cancelNote
: `WARNING — the pin was saved to disk but is NOT IN FORCE, and the ` +
`panel is NOT protected: ${PANEL_PIN_ENV_VAR} is set to an explicit ` +
`"no pin" value, which overrides the saved pin. ` +
`install/update/reinstall/sync will still proceed. Unset ` +
`${PANEL_PIN_ENV_VAR} in the environment / ~/.comfyui-mcp/.env and ` +
`restart the orchestrator for the saved pin to take effect.` +
pendingWarning +
cancelNote,
});
}
if (action === "unpin") {
// Same critical section as the pin path: the post-state is captured
// WITH the write, so a concurrent pin can't be misreported as ours.
const { removed, after } = await withPanelOpLock(async () => {
const gone = clearPanelVersionPin();
return { removed: gone, after: getPanelPinState() };
});
return json({
action: "unpin",
removed: removed ?? null,
pin: after,
note: after.pinned
? after.source === "env"
? `The persisted pin was ${removed ? "removed" : "already absent"}, but a ` +
`pin is STILL in force via the ${PANEL_PIN_ENV_VAR} environment variable. ` +
`Unset ${PANEL_PIN_ENV_VAR} (or set it to 'off') in the environment / ` +
`~/.comfyui-mcp/.env and restart the orchestrator before syncing.`
: `The persisted pin was ${removed ? "removed" : "already absent"}, but the ` +
`panel is pinned again: ${describePanelPin(after)}. Something wrote a new ` +
`pin at the same time — re-run unpin if you still want it cleared.`
: removed
? `Pin removed (was ${removed.version}). install_comfyui(action:'panel', panel_action:'sync') can ` +
`now proceed.`
: `No pin was set; nothing to clear. install_comfyui(action:'panel', panel_action:'sync') can ` +
`proceed.`,
});
}
if (action === "unlock") {
// An explicit RE-CHECK and report, not a force. It runs the same proof as
// acquire, so a lock whose owner cannot be proven dead (unreadable /
// reuse-ambiguous) is REFUSED here too -- that case is recovered by hand,
// after confirming no orchestrator still runs. What this adds over acquire
// is an on-demand verdict: acquire reclaims a proven-dead owner on its own
// (#2788), while this re-verifies on request (#1953: a fresh lock whose
// owner already exited is abandoned) and does NOT take the lock itself.
return json({ action: "unlock", ...reclaimAbandonedPanelLock() });
}
return json(await runPanelAction(action));
}
//# sourceMappingURL=install-panel.js.map