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),

1,492 lines • 78.8 kB
// The panel version pin, enforced where the PANEL PACK IS IDENTIFIED AS THE
// TARGET — not where the panel-specific entry point happens to be.
//
// WHY THIS FILE EXISTS. The first cut of the pin put its guard inside
// `runPanelAction` and called that "the mutation choke point". It isn't one. The
// panel IS an ordinary custom node pack, so the generic node tools are a second,
// wider door into exactly the same ComfyUI-Manager mutation:
//
//     install_custom_node(action:"update", id="comfyui-agent-panel")
//                                                     → updateCustomNode(...)
//     install_custom_node(action:"update", id="all")  → updateCustomNode(...)
//     install_custom_node(action:"reinstall", id="comfyui-mcp-panel")
//                                                     → reinstallCustomNode(...)
//     update-all                                      → updateAllCustomNodes()
//
// None of those go through `runPanelAction`, so none of them saw the pin. A
// pinned user was one `id="all"` away from being moved. The guard therefore
// lives at the SERVICE layer of the generic mutations, where every caller —
// tools, apply_manifest, dependency installers, anything future — must pass.
//
// This module deliberately imports NOTHING from panel-installer or
// node-management: panel-installer already imports node-management's mutations,
// so a guard that reached back into either would create an import cycle. It
// depends only on the pin store (and panel-recovery, which is itself a leaf over
// config + panel-workspace and reaches back into neither).
import { execFileSync } from "node:child_process";
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
import { closeSync, existsSync, fstatSync, fsyncSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync, } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { writeFileDurable } from "../utils/durable-write.js";
import { logger } from "../utils/logger.js";
import { describePanelManagementRedirect, panelRecoveryContext, } from "./panel-recovery.js";
import { describePanelPin, getPanelPinState, PANEL_PIN_ENV_VAR, } from "./panel-settings.js";
import { assertNotWritingRealHomeInTests } from "./test-isolation-guard.js";
/** Thrown when a pin forbids a mutation. Distinct so callers can recognise it. */
export class PanelPinnedError extends Error {
    constructor(message) {
        super(message);
        this.name = "PanelPinnedError";
    }
}
/**
 * Every spelling of "the sidebar panel pack" a caller might pass as an id. The
 * Comfy Registry name and the repo/dir name differ, and both are accepted by
 * ComfyUI-Manager, so both must match here.
 */
export const PANEL_PACK_ALIASES = [
    "comfyui-agent-panel", // Comfy Registry id / pyproject [project].name
    "comfyui-mcp-panel", // repo name, and the custom_nodes dir it installs to
];
/** A bulk target that necessarily includes the panel. */
function isBulkTarget(id) {
    return id === "all" || id === "*";
}
/**
 * Reduce any target spelling to a bare repo/pack name.
 *
 * This MUST cover every ref-carrying form `parseGitUrl` accepts, because those
 * are the forms a caller can actually pass: naively taking the last path segment
 * turned `…/comfyui-mcp-panel.git@v0.11.28` into `comfyui-mcp-panel.git@v0.11.28`
 * and `…/comfyui-mcp-panel/tree/main` into `main`, so BOTH slipped past the
 * matcher and moved a pinned panel. Kept deliberately independent of
 * node-management's parser: panel-installer already imports node-management, so
 * reaching back into it from here would be an import cycle.
 */
export function normalizePackTarget(id) {
    let s = (id ?? "").trim().toLowerCase();
    if (!s)
        return "";
    // Drop query strings / fragments first.
    s = s.split(/[?#]/)[0] ?? "";
    // Forge "browse at a ref" forms (GitHub/GitLab/Gitea/Bitbucket), mirroring
    // parseGitUrl's list.
    s = s.replace(/\/-\/(tree|commit)\/.*$/, "");
    s = s.replace(/\/(tree|commit|commits|src)\/.*$/, "");
    s = s.replace(/\/releases\/tag\/.*$/, "");
    s = s.replace(/\/+$/, "");
    // Last path segment (handles bare ids, "author/repo", and full URLs).
    let seg = s.split(/[/\\]/).filter(Boolean).pop() ?? "";
    // npm/pip style `repo@ref` / `repo.git@ref`. The segment can no longer contain
    // an scp-style `git@host:` user part, since that lives before the ":".
    seg = seg.split("@")[0] ?? "";
    return seg.replace(/\.git$/, "");
}
/**
 * Does this mutation target cover the panel pack?
 *
 * Matches the bare aliases and every git-URL spelling that resolves to them,
 * case-insensitively — ComfyUI-Manager resolves all of these to the same pack,
 * so treating any of them as "not the panel" reopens the door this guard closes.
 * `"all"` is included because a bulk update moves the panel along with
 * everything else.
 */
export function targetsPanelPack(id) {
    const raw = (id ?? "").trim().toLowerCase();
    if (!raw)
        return false;
    if (isBulkTarget(raw))
        return true;
    if (PANEL_PACK_ALIASES.includes(raw))
        return true;
    return PANEL_PACK_ALIASES.includes(normalizePackTarget(raw));
}
/** Is this an exact single-pack panel target (i.e. NOT a bulk "all")? */
export function targetsPanelPackExactly(id) {
    const raw = (id ?? "").trim().toLowerCase();
    return !isBulkTarget(raw) && targetsPanelPack(id);
}
/**
 * Read the pin so that NO failure mode reads as "unpinned" — a reader that
 * throws is reported as an indeterminate pin, which counts as pinned.
 */
function readPin() {
    try {
        return getPanelPinState();
    }
    catch (err) {
        logger.warn(`[panel] could not read the panel version pin: ${err instanceof Error ? err.message : String(err)} — treating the panel as PINNED (refusing to move it).`);
        return { pinned: true, source: "settings", indeterminate: true };
    }
}
/**
 * Refuse a generic node mutation that would move a PINNED panel.
 *
 * `action` and `id` are only used to build the message; the decision is the
 * pin's. A bulk target gets its own wording because there is no way to update
 * everything-except-the-panel through ComfyUI-Manager — the user has to unpin or
 * update packs individually, and saying so is more useful than a generic refusal.
 */
export function assertPanelPinAllows(action, id) {
    if (!targetsPanelPack(id))
        return;
    const pin = readPin();
    if (!pin.pinned)
        return;
    const bulk = !targetsPanelPackExactly(id);
    const envNote = pin.source === "env"
        ? ` (this pin comes from the ${PANEL_PIN_ENV_VAR} environment variable, so it ` +
            `must be unset/changed in the environment — unpin cannot remove it)`
        : ``;
    // #774/#784 — the way out of this refusal must be a way the caller can
    // actually take. `install_comfyui(action:'panel', panel_action:'unpin')` is the right instruction in a
    // local session and a dead end in a remote/cloud one, where install_comfyui(action:'panel')
    // cannot act at all. So the LEAD instruction switches with the session rather
    // than being appended to. An env pin already carries its own instruction (unset
    // the variable), which is host-side either way and needs no substitution.
    const usable = panelRecoveryContext().installPanelUsable;
    const clearIt = usable
        ? `clear the pin with install_comfyui(action:'panel', panel_action:'unpin')${envNote}`
        : pin.source === "env"
            ? // An env pin is not cleared by any tool anywhere, so naming one would be
                // noise on top of a dead end — say plainly where the variable lives.
                `clear the pin ON THE COMFYUI HOST: unset ${PANEL_PIN_ENV_VAR} in that ` +
                    `machine's environment (or ~/.comfyui-mcp/.env) and restart the ` +
                    `orchestrator running there`
            : `clear the pin ON THE COMFYUI HOST — install_comfyui(action:'panel') cannot act in this ` +
                `session, so remove it from that machine's ` +
                `~/.comfyui-mcp/panel-settings.json and restart the orchestrator ` +
                `running there`;
    throw new PanelPinnedError(bulk
        ? `Refusing to ${action} "${id}": that would also move the sidebar panel pack, ` +
            `which is ${describePanelPin(pin)}. ComfyUI-Manager cannot update ` +
            `everything-except-one-pack, so either ${clearIt} and re-run, or update the ` +
            `other packs individually by id.`
        : `Refusing to ${action} the sidebar panel pack ("${id}"): it is ` +
            `${describePanelPin(pin)}. A pin is honoured even when a newer panel exists — ` +
            `${clearIt}, then re-run.`);
}
/**
 * Refuse a panel-pack mutation on a path that has NO on-disk verification —
 * currently the sidebar `panel_install_node` / `panel_update_node` tools (which
 * drive the user's built-in ComfyUI Manager through the browser) and
 * `install_custom_node` action:"fix".
 *
 * These cannot be redirected into the verified path: `panel_*` acts on the
 * panel's own host through the browser Manager (which may not even be the
 * filesystem this process can read), and `fix` has no verified equivalent. Both
 * report success from the Manager queue alone — precisely the #639 signal that
 * proves nothing. Rather than let the panel be moved unverifiably, or pretend we
 * checked, they refuse and name the tool that does verify.
 *
 * The pin is reported FIRST when set, because that is the more specific reason.
 *
 * #774/#784 — the REDIRECT is resolved from the session context rather than
 * hardcoded to "use install_comfyui(action:'panel')". Pairing this refusal with a pointer at a
 * tool that is a no-op here (remote/cloud) or absent here (the embedded
 * `panel_*` surface) is what closed the loop into a deadlock: every door the
 * user tried named another door that was also shut. Refusing remains correct —
 * this path genuinely cannot verify the move — but the way out must be real.
 */
export function assertPanelNotTargetedUnverifiable(toolName, 
// `unknown` on purpose: panel-tool handlers receive loosely-typed args, and a
// guard that forced a cast at every call site would eventually be skipped.
id) {
    if (typeof id !== "string" || !targetsPanelPack(id))
        return;
    assertPanelPinAllows(toolName, id); // pinned → the pin message wins
    throw new PanelPinnedError(`${toolName} cannot manage the comfyui-mcp sidebar panel pack ("${id}"). That ` +
        `path reports success as soon as the ComfyUI-Manager queue drains, which a ` +
        `stale Manager does WITHOUT doing any work (#639) and which cannot see a ` +
        `".bak" shadow copy shadowing the real panel (#641) — so it could tell you the ` +
        `panel updated when it did not. ${describePanelManagementRedirect()}`);
}
// ---------------------------------------------------------------------------
// Cross-process mutation lock
//
// The first cut serialized panel mutations with a module-global promise chain.
// That is not enough and the claim built on it was wrong: running more than one
// orchestrator process is ordinary here (one per MCP client). Process A could
// pass its final pin check and begin an awaited Manager update while process B
// wrote a pin against its own, entirely separate chain — and A would then move a
// now-pinned panel.
//
// So the lock is a FILE under ~/.comfyui-mcp/, which is the only thing both
// processes share. In-process we still chain (a file lock alone would spin), and
// the whole thing is re-entrant so `runPanelAction` can call a guarded service
// function while already holding it.
// ---------------------------------------------------------------------------
/**
 * An explicitly-set-but-EMPTY redirect is a mistake, not an intent to use the
 * default — and treating it as unset hid exactly that mistake once already:
 * node-snapshots.test.ts assigned `"\0"` intending an fs-rejected path, Node
 * truncated the assignment to `""`, and the write silently fell through to the
 * developer's real home (#866). Fail loudly at resolution time instead.
 */
function redirectedStatePath(envVar, fallback) {
    const value = process.env[envVar];
    if (value !== undefined && value.trim() === "") {
        throw new Error(`${envVar} is set but EMPTY. An empty override does not mean "use the default" — ` +
            `it falls through to the default (${fallback}) with no sign anything was wrong, ` +
            `which is how a test run came to write the developer's real home directory (#866). ` +
            `Set ${envVar} to a real path or unset it entirely.`);
    }
    return value ?? fallback;
}
/** Lock file path. Overridable so tests never touch the real home directory. */
export function panelLockPath() {
    return redirectedStatePath("COMFYUI_MCP_PANEL_LOCK", join(homedir(), ".comfyui-mcp", "panel-op.lock"));
}
/**
 * Default acquisition budget for a guarded mutation. This lock has NO expiry:
 * the owner holds it until its callback returns, and only a proven dead owner
 * can take the abandoned-lock recovery path. The budget is for a WAITER.
 *
 * The longest current local custom-node writer can spend 1,260s in git command
 * ceilings (clone plus the version/ref probes and checkout), another 1,200s
 * in the sequential requirements.txt and install.py ceilings, and 120s in the
 * in-lock /system_stats request used while resolving the install interpreter:
 * 2,580s total. Give a valid concurrent writer 60s for final filesystem work
 * and polling.
 * Callers that must not block (the fire-and-forget on-load ensure) pass a much
 * shorter explicit budget.
 */
const DEFAULT_ACQUIRE_MS = 44 * 60_000;
const POLL_MS = 100;
let inProcessChain = Promise.resolve();
/**
 * Marks the async context of the current lock HOLDER, so re-entrancy is scoped
 * to work actually running inside it.
 *
 * A process-global "held" flag was wrong and dangerous: while an update held the
 * lock, an UNRELATED concurrent request (a pin write, say) saw the flag set and
 * sailed straight through — landing precisely in the window between the update's
 * final pin check and its Manager call. AsyncLocalStorage makes the exemption
 * apply only to callers nested within the holder's own execution.
 */
const lockHolderContext = new AsyncLocalStorage();
function sleep(ms) {
    return new Promise((r) => setTimeout(r, ms));
}
/** Is the process that wrote a lock still running? */
/**
 * Whether the process that took a lock is still running — as far as a PID can
 * say, which is not very far.
 *
 * `process.kill(pid, 0)` answers "a process with that NUMBER exists". That is not
 * "our holder is alive" (codex gate P1): PIDs are recycled, so after a crashed
 * holder's number is reused by an unrelated program, the old answer was a
 * confident "STILL RUNNING" that refused the reclaim FOREVER — leaving #760's
 * wedge in place through the very fix meant to clear it. An existence check
 * standing in for an identity check is this repo's dominant fold pointed at a
 * positive.
 *
 * So this reports THREE states, and the caller must not collapse them:
 *   - `false`   — no such process. Provably not our holder.
 *   - `true`    — a process with that pid exists AND the lock is young enough
 *                 that recycling is not a plausible explanation.
 *   - `"unsure"` — a process exists, but the lock is old enough that the number
 *                 may since have been reused. Liveness is UNDETERMINED.
 *
 * The `startedAt` on the record is the LOCK's creation time. Identity of the
 * owner is `ownerStartedMs` (the process start time recorded at lock take).
 * This probe stays a cheap existence check for the acquire poll; reclaim
 * consults `ownerStartedMs` and the OS process table to resolve reuse / the
 * Windows kill(0) false-positive (#1953).
 */
function pidLiveness(pid, ageMs) {
    if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
        return false;
    let exists;
    try {
        // Signal 0 performs the permission/existence check without delivering.
        process.kill(pid, 0);
        exists = true;
    }
    catch (err) {
        // EPERM = the process exists but belongs to someone else — still there.
        exists = err?.code === "EPERM";
    }
    if (!exists)
        return false;
    // The boundary is STALE_LOCK_MS, not an invented interval (codex gate). Six
    // hours was indefensible in both directions: PIDs can be recycled far sooner,
    // and a genuine operation running longer would have been called undetermined.
    //
    // STALE_LOCK_MS is the line this module already draws for "no legitimate panel
    // operation is still running", and it is exactly the right one here. Inside it,
    // an existing pid IS evidence about our holder — the operation could still be
    // going. Outside it, the holder should have finished, so an existing pid is
    // equally well explained by a crash plus reuse, and claiming either is a guess.
    //
    // Note the reclaim path refuses on age before it ever consults liveness, so
    // "unsure" only ever describes a lock old enough for the doubt to be real.
    return ageMs > STALE_LOCK_MS ? "unsure" : true;
}
/**
 * Age beyond which a live-looking pid is equally well explained by reuse.
 * The grace window applies ONLY when the recorded owner is NOT proven dead
 * (#1953): a self-restart leaves a fresh lock whose owner is already gone,
 * and protecting that for ten minutes is how panel sync wedged indefinitely.
 */
const STALE_LOCK_MS = 10 * 60_000;
/** Clock skew / tick rounding when comparing recorded vs live process start. */
const OWNER_START_TOLERANCE_MS = 2_000;
/** This process's start time (uptime-derived). */
function processStartMs() {
    return Date.now() - process.uptime() * 1000;
}
/**
 * Does the OS process table list this pid? Independent of `process.kill(pid, 0)`,
 * which on Windows has claimed a pid Get-Process could not find (#1953).
 *
 * `true` / `false` are answers; `undefined` means the probe could not tell
 * (missing tool, permission, timeout) and must not be spent as either.
 */
function processTableHasPid(pid) {
    if (!Number.isInteger(pid) || pid <= 0)
        return false;
    if (process.platform === "linux") {
        try {
            return existsSync(`/proc/${pid}`);
        }
        catch {
            return undefined;
        }
    }
    if (process.platform === "win32") {
        try {
            const out = execFileSync("tasklist.exe", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], {
                encoding: "utf8",
                timeout: 3000,
                windowsHide: true,
                stdio: ["ignore", "pipe", "ignore"],
            });
            // CSV row is `"image","pid","session",...`. The "no match" INFO line does
            // not quote the pid as a field.
            return out.includes(`","${pid}",`);
        }
        catch {
            return undefined;
        }
    }
    try {
        const out = execFileSync("ps", ["-p", String(pid), "-o", "pid="], {
            encoding: "utf8",
            timeout: 2000,
            stdio: ["ignore", "pipe", "ignore"],
        });
        return new RegExp(`(?:^|\\s)${pid}(?:\\s|$)`).test(out);
    }
    catch (err) {
        const e = err;
        if (e.code === "ENOENT")
            return undefined;
        if (e.status === 1)
            return false;
        return undefined;
    }
}
function startTimesMatch(obs) {
    if (obs.ownerStartedMs === undefined)
        return false;
    return Math.abs(obs.ownerStartedMs - processStartMs()) <= OWNER_START_TOLERANCE_MS;
}
/**
 * Is the recorded owner gone, without pid-reuse ambiguity?
 *
 *   - kill(0) says no such process.
 *   - kill(0) says yes, but the OS process table does not list it (Windows
 *     kill(0) false-positive, #1953).
 *   - the pid is ours, but the recorded process start predates this life
 *     (the number was recycled onto us) AND the lock is past the stale window.
 */
function ownerProvablyDead(obs, 
/**
 * May this call spawn the PROCESS-TABLE probe? `processTableHasPid` runs
 * `tasklist.exe` (or `ps`) through `execFileSync` — synchronously, on the
 * event loop, with a 3s timeout. It is only ever reached when `kill(0)` already
 * said the owner is alive or is EPERM-unsure, i.e. in the cases where reclaim
 * refuses anyway; a genuinely dead owner is caught by `alive === false` with no
 * subprocess at all. So the waiter can poll cheaply every second and pay for the
 * table lookup far less often without slowing dead-owner reclaim down.
 */
allowProcessTableProbe = true) {
    if (obs.pid === undefined)
        return false;
    if (obs.pid === process.pid) {
        if (obs.ownerStartedMs !== undefined) {
            // The age floor is load-bearing, not belt-and-braces (review of #1955).
            // `processStartMs()` is `Date.now() - uptime*1000`: the recorded value is
            // written once at lock take, the live one is recomputed here, and only
            // Date.now() absorbs a wall-clock step. A forward step of more than the
            // tolerance (a resume from sleep, an NTP correction) therefore makes THIS
            // process read its OWN live lock as a recycled pid and delete the lock
            // guarding its own in-flight op — the concurrent-mutation case this
            // module exists to prevent, reachable because hello auto-sync calls
            // reclaim outside `lockHolderContext`.
            //
            // Requiring the stale window puts the floor exactly where the identity
            // signal is weakest. It costs nothing real: for this to matter our pid
            // must have been recycled onto us AND the dead predecessor's lock be
            // under ten minutes old. Past that window the branch still fires, which
            // is the wedge it was added for.
            if (obs.ageMs > STALE_LOCK_MS &&
                obs.ownerStartedMs < processStartMs() - OWNER_START_TOLERANCE_MS) {
                return true;
            }
            if (startTimesMatch(obs))
                return false;
        }
        // The number is ours. Without a recorded start we cannot tell this life
        // from a previous one, and we already know we exist — do not spawn a
        // process-table probe to re-learn that.
        return false;
    }
    if (obs.alive === false)
        return true;
    if (obs.alive === true || obs.alive === "unsure") {
        if (!allowProcessTableProbe)
            return false;
        return processTableHasPid(obs.pid) === false;
    }
    return false;
}
/**
 * Observe the lock WITHOUT acting on it. Returns undefined when the path
 * cannot be opened (usually: vanished) — the caller's next create attempt or
 * re-observation settles that.
 *
 * Stat and read go through ONE descriptor, so the age and the owner bytes
 * always describe the SAME instance: with separate path-based calls, a lock
 * replaced between the stat and the read would pair the OLD lock's mtime with
 * the FRESH lock's pid, and a young lock could be judged old (codex gate). A
 * lock file is written once at creation, so an mtime/size change between the
 * two fstats means in-place interference — indeterminate, not stale.
 *
 * Deliberately conservative: any content that cannot yield a valid pid is a
 * `contentProblem`, NOT a dead owner. An unreadable/corrupt lock cannot prove
 * abandonment, and treating "we couldn't tell" as "the owner is gone" is how a
 * live holder's lock gets deleted (#796's fold, pointed at a positive).
 */
function observePanelLock(path) {
    let fd;
    try {
        fd = openSync(path, "r");
    }
    catch (err) {
        // ONLY ENOENT proves absence. Any other open failure (permissions, AV
        // interference, I/O) means the lock is present but uninspectable — that
        // must read as indeterminate, never as "no lock" (codex gate).
        if (err?.code === "ENOENT")
            return undefined;
        let ageMs = 0;
        try {
            ageMs = Math.max(0, Date.now() - statSync(path).mtimeMs);
        }
        catch {
            // display-only
        }
        return {
            ageMs,
            contentProblem: `could not be opened (${err instanceof Error ? err.message : String(err)})`,
        };
    }
    try {
        const before = fstatSync(fd);
        const raw = readFileSync(fd, "utf-8");
        const after = fstatSync(fd);
        const ageMs = Math.max(0, Date.now() - after.mtimeMs);
        if (before.mtimeMs !== after.mtimeMs || before.size !== after.size) {
            return { ageMs, contentProblem: "changed while being read" };
        }
        let record;
        try {
            record = JSON.parse(raw);
        }
        catch {
            return { ageMs, raw, contentProblem: "is not valid JSON" };
        }
        const startedAt = typeof record?.startedAt === "string" ? record.startedAt : undefined;
        const ownerStartedMs = typeof record?.ownerStartedMs === "number" && Number.isFinite(record.ownerStartedMs)
            ? record.ownerStartedMs
            : undefined;
        const pid = record?.pid;
        if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
            return { ageMs, raw, startedAt, contentProblem: "records no valid owner pid" };
        }
        return { ageMs, raw, pid, startedAt, ownerStartedMs, alive: pidLiveness(pid, ageMs) };
    }
    catch (err) {
        // A mid-read failure (e.g. the fd turned out to be a directory). The age
        // here is display-only — the contentProblem is what the decision reads.
        let ageMs = 0;
        try {
            ageMs = Math.max(0, Date.now() - statSync(path).mtimeMs);
        }
        catch {
            // vanished mid-observation; the message just says what it can
        }
        return {
            ageMs,
            contentProblem: `unreadable (${err instanceof Error ? err.message : String(err)})`,
        };
    }
    finally {
        try {
            closeSync(fd);
        }
        catch {
            // nothing to do with a close failure on a read-only observation
        }
    }
}
/** "12 minutes" / "3 hours" — for human-facing lock messages. */
function describeLockAge(ageMs) {
    const minutes = Math.round(ageMs / 60_000);
    if (minutes < 1)
        return "under a minute";
    if (minutes < 120)
        return `${minutes} minutes`;
    return `${Math.round(minutes / 60)} hours`;
}
/** Compose the observed lock state as one sentence for error/report text. */
function describeObservedLock(path, obs) {
    const age = describeLockAge(obs.ageMs);
    if (obs.pid === undefined) {
        return (`The lock at ${path} was taken ${age} ago, but its content ${obs.contentProblem}, ` +
            `so its owner cannot be identified.`);
    }
    return (`The lock at ${path} was taken ${age} ago by pid ${obs.pid}` +
        `${obs.startedAt ? ` (started ${obs.startedAt})` : ""}, and ` +
        (obs.alive === "unsure"
            ? `a process with that pid exists — but the lock is old enough that the number may ` +
                `since have been reused, so whether the ORIGINAL owner is still running cannot be ` +
                `determined from the pid alone.`
            : obs.alive
                ? `that process is STILL RUNNING.`
                : `that process is no longer running.`));
}
/**
 * Reclaim the panel operation lock — ONLY when its recorded owner is
 * provably dead (#760, #1953). Age alone is not abandonment, and a live
 * (or reuse-ambiguous) pid is never deleted. A FRESH lock whose owner is
 * already gone is abandoned: the 10-minute window protects a slow
 * ComfyUI-Manager op, not a process that has already exited.
 *
 * Acquire uses this same function when the recorded owner is gone (#2788),
 * so a crashed orchestrator does not wedge every later panel mutation behind
 * `panel_action:"unlock"`. A living owner is never stolen; an unproven owner
 * is left in place. Node has no atomic compare-and-rename, so between a
 * stale observation and a delete a concurrent orchestrator could replace the
 * path with a fresh lock. That race is bounded here by RENAMING the file
 * aside first and comparing its bytes against the observed ones: a lock that
 * was replaced between observation and reclaim does not match and is never
 * deleted. The restore is a HARD LINK, not a rename — rename would silently
 * overwrite a fresh lock that appeared at the path in the meantime, while
 * linkSync fails with EEXIST and both files are reported, nothing destroyed.
 */
export function reclaimAbandonedPanelLock({ allowProcessTableProbe = true } = {}) {
    const path = panelLockPath();
    // A test pointed at the real home must never move or delete a lock a live
    // orchestrator is holding — reclaim is exactly the operation that does.
    assertNotWritingRealHomeInTests(path, "the panel operation lock");
    const obs = observePanelLock(path);
    if (!obs) {
        return {
            outcome: "no-lock",
            detail: `No panel operation lock is present at ${path} — nothing to reclaim. ` +
                `Whatever was blocking panel operations is already gone.`,
        };
    }
    const manual = `To clear it by hand instead: stop or restart every comfyui-mcp orchestrator, ` +
        `verify none remain, delete this exact lock file, then retry.`;
    const refuse = (why) => ({
        outcome: "refused",
        detail: `Refusing to reclaim the panel operation lock: ${why} ${manual}`,
    });
    // No valid pid → abandonment is unprovable at ANY age; say what the content
    // actually showed rather than hiding behind the age gate.
    if (obs.pid === undefined) {
        return refuse(`it is ${describeLockAge(obs.ageMs)} old, but its content ${obs.contentProblem}, ` +
            `so there is no owner whose death could prove it abandoned.`);
    }
    // Owner-aware: a dead owner is abandoned even inside the grace window. A
    // self-restart (#1953) leaves a lock seconds old whose holder is already gone.
    if (!ownerProvablyDead(obs, allowProcessTableProbe)) {
        if (obs.pid === process.pid && startTimesMatch(obs)) {
            return refuse(`it is held by this process (pid ${obs.pid}), which is still running, ` +
                `so it cannot be called abandoned.`);
        }
        if (obs.ageMs <= STALE_LOCK_MS) {
            return refuse(`it was taken only ${describeLockAge(obs.ageMs)} ago — inside the ` +
                `${STALE_LOCK_MS / 60_000}-minute window in which a slow ComfyUI-Manager ` +
                `operation is still legitimate, so it cannot be called abandoned.`);
        }
        if (obs.alive === "unsure" || obs.alive === true) {
            // NOT "STILL RUNNING" unless identity matched above. A recycled PID used
            // to refuse this reclaim indefinitely — #760's wedge surviving its own
            // fix. Still refusing is right when identity cannot be proven, but the
            // reason has to be true.
            return refuse(`it is ${describeLockAge(obs.ageMs)} old and a process with pid ${obs.pid} exists, ` +
                `but at that age the pid may have been REUSED by an unrelated program — so whether ` +
                `the original owner is still running cannot be determined, and abandonment cannot ` +
                `be proven. Nothing was deleted. If you are certain no panel operation is running ` +
                `(check that pid: if it is not a comfyui-mcp orchestrator, it is not the owner), ` +
                `delete ${path} by hand.`);
        }
        return refuse(`its owner could not be proven dead (${describeObservedLock(path, obs)}).`);
    }
    // Provably abandoned. Rename aside, verify the moved file is the EXACT bytes
    // that were judged abandoned, and only then delete.
    const claim = `${path}.reclaim-${randomUUID()}`;
    try {
        renameSync(path, claim);
    }
    catch (err) {
        if (err?.code === "ENOENT") {
            return {
                outcome: "no-lock",
                detail: `The abandoned lock at ${path} disappeared before it could be reclaimed — ` +
                    `another process removed it first. This call deleted nothing.`,
            };
        }
        return refuse(`moving it aside failed (${err instanceof Error ? err.message : String(err)}), ` +
            `so its current state could not be verified.`);
    }
    let moved;
    try {
        moved = readFileSync(claim, "utf-8");
    }
    catch {
        moved = undefined;
    }
    if (moved === undefined || moved !== obs.raw) {
        // The path was REPLACED between the observation and the rename: what was
        // moved aside is a different (possibly live) holder's lock. Put it back
        // with a HARD LINK, never a rename — a rename would silently OVERWRITE a
        // fresh lock that landed at the path in the meantime (codex gate), while
        // linkSync fails with EEXIST and leaves both files intact.
        try {
            linkSync(claim, path);
        }
        catch (err) {
            if (existsSync(path)) {
                return {
                    outcome: "refused",
                    detail: `The lock at ${path} changed mid-reclaim, and yet another lock appeared at ` +
                        `the path before the replaced one could be restored. Nothing was deleted or ` +
                        `overwritten: the replaced lock is preserved at ${claim}. Stop every ` +
                        `comfyui-mcp orchestrator, verify none remain, then reconcile both files ` +
                        `by hand.`,
                };
            }
            return {
                outcome: "refused",
                detail: `The lock at ${path} changed mid-reclaim and restoring it failed ` +
                    `(${err instanceof Error ? err.message : String(err)}). Nothing was deleted: ` +
                    `the replaced lock is preserved at ${claim} and the path is currently free. ` +
                    `Stop every comfyui-mcp orchestrator, verify none remain, then reconcile ` +
                    `both files by hand.`,
            };
        }
        // The restore succeeded. The claim name is now a duplicate (hard link) of
        // the restored lock, so removing it is cosmetic — a failure here must NOT
        // be reported as a restore failure (codex gate).
        const restored = refuse(`the lock changed between inspection and reclaim — a DIFFERENT holder's lock ` +
            `is now at ${path}. It was put back untouched; nothing was deleted.`);
        try {
            rmSync(claim, { force: true });
            return restored;
        }
        catch {
            return {
                ...restored,
                detail: `${restored.detail} (A duplicate name for it remains at ${claim} — a hard ` +
                    `link to the same restored lock; delete it by hand, it blocks nothing.)`,
            };
        }
    }
    // The abandoned lock is verified and moved aside; the lock path is already
    // free, so the reclaim HAS succeeded — a failure removing the aside copy is
    // cleanup, and must be reported as such, never as a reclaim failure (codex).
    const reclaimed = {
        outcome: "reclaimed",
        detail: `Reclaimed the abandoned panel operation lock at ${path}: it was ` +
            `${describeLockAge(obs.ageMs)} old and its owner, pid ${obs.pid}` +
            `${obs.startedAt ? ` (started ${obs.startedAt})` : ""}, is no longer running. ` +
            `That abandoned lock no longer blocks panel operations; if another operation ` +
            `has since taken a fresh lock there, the usual wait/timeout applies to it.`,
    };
    try {
        rmSync(claim, { force: true });
        return reclaimed;
    }
    catch (err) {
        return {
            ...reclaimed,
            detail: `${reclaimed.detail} (Removing the renamed-aside copy at ${claim} failed: ` +
                `${err instanceof Error ? err.message : String(err)} — delete that file by hand; it blocks nothing.)`,
        };
    }
}
/**
 * Drop a panel-op.lock THIS process owns. The self-restart path (and any other
 * clean exit) knows it is about to die; leaving the file behind is how a
 * successor then waits forever behind a pid that is gone (#1953).
 *
 * Same rename-aside + byte check as reclaim: we only delete the instance we
 * observed as ours. A lock naming any other pid is left untouched so a
 * replacement that already took the path is not stolen.
 */
export function releaseOwnedPanelLock() {
    const path = panelLockPath();
    assertNotWritingRealHomeInTests(path, "the panel operation lock");
    const obs = observePanelLock(path);
    if (!obs) {
        return {
            outcome: "no-lock",
            detail: `No panel operation lock is present at ${path}.`,
        };
    }
    if (obs.pid !== process.pid) {
        return {
            outcome: "not-ours",
            detail: obs.pid === undefined
                ? `The lock at ${path} records no valid owner pid; not deleting it on the way out.`
                : `The lock at ${path} is owned by pid ${obs.pid}, not this process (${process.pid}); left in place.`,
        };
    }
    const claim = `${path}.release-${randomUUID()}`;
    try {
        renameSync(path, claim);
    }
    catch (err) {
        if (err?.code === "ENOENT") {
            return {
                outcome: "no-lock",
                detail: `The lock at ${path} disappeared before it could be released.`,
            };
        }
        logger.warn(`[panel] could not take the panel op lock at ${path} aside to release it (${err instanceof Error ? err.message : String(err)}); it may still be held.`);
        return {
            outcome: "not-ours",
            detail: `Could not move the lock aside to release it; left in place.`,
        };
    }
    let moved;
    try {
        moved = readFileSync(claim, "utf-8");
    }
    catch {
        moved = undefined;
    }
    let movedPid;
    if (moved !== undefined) {
        try {
            movedPid = JSON.parse(moved).pid;
        }
        catch {
            movedPid = undefined;
        }
    }
    if (movedPid === process.pid) {
        try {
            rmSync(claim, { force: true });
        }
        catch (err) {
            logger.warn(`[panel] released the panel op lock but could not delete ${claim}: ${err instanceof Error ? err.message : String(err)}. It is no longer at ${path}, so it blocks nothing.`);
        }
        return {
            outcome: "released",
            detail: `Released the panel operation lock at ${path} (owned by this process, pid ${process.pid}).`,
        };
    }
    try {
        linkSync(claim, path);
        rmSync(claim, { force: true });
    }
    catch {
        logger.warn(`[panel] the panel op lock at ${path} was no longer this process's at release; ` +
            `the file taken aside is preserved at ${claim}.`);
    }
    return {
        outcome: "not-ours",
        detail: `The lock at ${path} was no longer this process's when released; it was restored rather than deleted.`,
    };
}
async function acquireFileLock(timeoutMs) {
    const path = panelLockPath();
    assertNotWritingRealHomeInTests(path, "the panel operation lock");
    mkdirSync(dirname(path), { recursive: true });
    const deadline = Date.now() + timeoutMs;
    // Throttle the dead-owner reclaim probe. The poll runs every 100 ms, and
    // reclaim re-opens the file (and may consult the process table). First
    // probe fires immediately: the common case is a lock left by an
    // orchestrator that already died, and that is answerable at once (#2788).
    const LIVENESS_PROBE_MS = 1_000;
    /** How often the reclaim poll may spawn `tasklist.exe` / `ps`. See the call site. */
    const PROCESS_TABLE_PROBE_MS = 15_000;
    let nextLivenessProbe = 0;
    // 0 so the FIRST poll still pays for the full probe -- an owner that died just
    // before this waiter arrived is reclaimed immediately, not 15s later.
    let nextProcessTableProbe = 0;
    for (;;) {
        try {
            // Hoisted above the open so the RELEASE closure can close over it: the
            // release must be able to prove the file it is about to delete is the one
            // this iteration created (see the closure below).
            const token = randomUUID();
            // "wx" = create-exclusive: atomic across processes, which is the whole point.
            const fd = openSync(path, "wx");
            try {
                // A short writeSync is NOT a failure return — it reports the bytes
                // actually written. Loop to completion; a zero-byte progress means the
                // record would be truncated (an unreadable owner pid that reclaim can
                // never prove abandoned — the #760 wedge again), so fail the init and
                // let the cleanup below remove the husk (codex gate). The payload is
                // ASCII (pid + ISO timestamp + ownerStartedMs + uuid), so string offsets
                // track byte offsets. `token` is what makes this lock IDENTIFIABLE, not
                // merely attributable (codex gate P0). pid+startedAt cannot distinguish
                // two locks taken by the same process, and more importantly they cannot
                // tell the release closure whether the file still at the path is the one
                // it created — which is the question that matters once a lock can be
                // RECLAIMED out from under a holder. `ownerStartedMs` is the PROCESS
                // start time, so reclaim can tell our life from a recycled pid (#1953).
                const payload = JSON.stringify({
                    pid: process.pid,
                    startedAt: new Date().toISOString(),
                    ownerStartedMs: Math.round(processStartMs()),
                    token,
                });
                let written = 0;
                while (written < payload.length) {
                    const n = writeSync(fd, payload.slice(written), null, "utf-8");
                    if (n <= 0) {
                        throw new Error(`short write on the panel operation lock (${written}/${payload.length} bytes)`);
                    }
                    written += n;
                }
                // Durable (#798): the recorded pid is the ONLY thing a post-crash
                // observation (the timeout report / reclaimAbandonedPanelLock) can prove
                // abandonment from — a buffered loss would leave an ownerless lock that
                // nothing can verify or reclaim.
                fsyncSync(fd);
                closeSync(fd);
            }
            catch (initErr) {
                // A failed init must not leave the husk behind: this call created the
                // file (exclusive create) and no mutation has run under it, so a
                // leftover would wedge every later acquire behind a lock whose recorded
                // owner is alive but holds nothing — and reclaim would rightly refuse
                // it (codex gate). Only the file THIS call created can be removed here:
                // no other process can have created or reclaimed the path in between
                // (reclaim requires an old lock with a dead owner; this one is fresh
                // and this process is alive).
                try {
                    closeSync(fd); // may already be closed if closeSync itself threw
                }
                catch {
                    // the init failure is the one to report
                }
                try {
                    rmSync(path, { force: true });
                }
                catch (cleanupErr) {
                    throw new Error(`Could not initialise the panel operation lock at ${path} (${initErr instanceof Error ? initErr.message : String(initErr)}), and removing the just-created file also failed (${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}). The leftover lock is NOT held by any operation — delete that file ` +
                        `by hand, then retry.`);
                }
                throw initErr;
            }
            return () => {
                // PROVE OWNERSHIP BEFORE DELETING — and do it in a way a concurrent
                // reclaim cannot invalidate (codex gate P0, twice).
                //
                // A bare `rmSync(path)` deletes whatever is at the path, including a lock
                // another process took after ours was reclaimed. Reading the token first
                // is not enough either: read-then-unlink is TOCTOU, because a reclaimer
                // can rename the verified file aside and a third agent can take the freed
                // pathname in the gap — so the unlink still lands on a stranger.
                //
                // RENAME FIRST. That atomically removes whatever is at the path and puts
                // it somewhere only we know, so the thing we then inspect is the thing we
                // will act on. It is the same technique `reclaimAbandonedPanelLock` uses
                // for the same reason, including the hard-link restore that can never
                // overwrite a lock that landed in the gap.
                const claim = `${path}.release-${randomUUID()}`;
                try {
                    renameSync(path, claim);
                }
                catch (err) {
                    if (err?.code === "ENOENT")
                        return; // already gone
                    logger.warn(`[panel] could not take the panel op lock at ${path} aside to release it (${err instanceof Error ? err.message : String(err)}); it may still be held. If no panel operation is running, delete that file.`);
                    return;
                }
                // The path is free from here on, whatever we decide — so the only
                // remaining question is what to do with the file in hand.
                let held;
                for (let attempt = 0; attempt < 2 && held === undefined; attempt++) {
                    try {
                        held = readFileSync(claim, "utf-8");
                    }
                    catch {
                        held = undefined; // one retry: a transient read must not decide this
                    }
                }
                let heldToken;
                if (held !== undefined) {
                    try {
                        heldToken = JSON.parse(held).token;
                    }
                    catch {
                        heldToken = undefined;
                    }
                }
                if (held !== undefined && heldToken === token) {
                    // Ours. Deleting the file we are holding aside cannot touch anyone else.
                    try {
                        rmSync(claim, { force: true });
                    }
                    catch (err) {
                        logger.warn(`[panel] released the panel op lock but could not delete ${claim}: ${err instanceof Error ? err.message : String(err)}. It is no longer at ${path}, so it blocks nothing; delete it at your leisure.`);
                    }
                    return;
                }
                // NOT ours, or unidentifiable. Either way we must not destroy it — put it
                // back. `linkSync` (never `renameSync`) so a lock that landed at the path
                // in the meantime is not overwritten.
                try {
                    linkSync(claim, path);
                    rmSync(claim, { force: true });
                    logger.warn(`[panel] the panel op lock at ${path} was ${held === undefined
                        ? "unreadable at release"
                        : "no longer the one this operation took (it was reclaimed or replaced while the operation ran)"}, so it was restored rather than deleted. Another operation may hold it.`);
                }
                catch {
                    logger.warn(`[panel] the panel op lock at ${path} was ${held === undefined ? "unreadable" : "not the one this operation took"} at release, and restoring it failed — most likely because another lock ` +
                        `already occupies the path, which is the outcome that matters. Nothing was ` +
                        `deleted; the file taken aside is preserved at ${claim}.`);
                }
            };
        }
        catch (err) {
            if (err?.code !== "EEXIST") {
                // Anything other than "already held" is a real failure. FAIL CLOSED:
                // proceeding without the lock is how a pinned user gets moved.
                throw new Error(`Could not take the panel operation lock at ${path}: ${err instanceof Error ? err.message : String(err)}. Refusing to mutate the panel without it.`);
            }
            // #2788 — a proven-dead owner is abandoned: take the lock via the same
            // rename-aside + byte-compare path as `panel_action:"unlock"`. A living
            // owner is never stolen. An unreadable / reuse-ambiguous record cannot
            // prove death, so it stays until timeout (fail closed).
            if (Date.now() >= nextLivenessProbe) {
                nextLivenessProbe = Date.now() + LIVENESS_PROBE_MS;
                // Cheap by default. `kill(0)` alone already proves a DEAD owner
                // (`alive === false`), which is the case this poll exists to notice, so
                // withholding the process-table probe costs no reclaim latency. The
                // expensive lookup only matters for an EPERM-unsure pid or a Windows
                // handle-kept-open zombie, and those stay refused either way — so it runs
                // on its own much slower clock instead of once a second, and always once
                // more at the deadline below.
                const allowProcessTableProbe = Date.now() >= nextProcessTableProbe;
                if (allowProcessTableProbe) {
                    nextProcessTableProbe = Date.now() + PROCESS_TABLE_PROBE_MS;
                }
                const reclaim = reclaimAbandonedPanelLock({ allowProcessTableProbe });
                if (reclaim.outcome === "reclaimed" || reclaim.outcome === "no-lock") {
                    continue;
                }
            }
            if (Date.now() >= deadline) {
                // Last chance: the owner may have died after the previous probe.
                const lastChance = reclaimAbandonedPanelLock();
                if (lastChance.outcome === "reclaimed" || lastChance.outcome === "no-lock") {
                    continue;
                }
                // Report the OBSERVED state of the lock that blocked us — the remedy
                // differs by whether its owner is alive, and "another operation is in
                // progress" is a claim we could not verify without looking.
                const obs = observePanelLock(path);
                if (!obs) {
                    if (!existsSync(path)) {
                        // It vanished between EEXIST and the deadline: the next create
                        // attempt settles it, so keep looping rather than report a timeout
                        // against a lock that is no longer there.
                        continue;
                    }
                    // Present but not inspectable (a permissions/IO failure on stat is
                    // NOT proof the holder is gone) — fail closed with the manual path.
                    throw new Error(`Timed out after ${timeoutMs}ms waiting for the panel operation lock ` +
                        `(${path}). The lock file exists but could not be inspected, so its ` +
                        `owner is unknown and it cannot be auto-reclaimed. To recover it by ` +
                        `hand: stop or restart every comfyui-mcp orchestrator, verify none ` +
                        `remain, delete this exact lock file, then retry.`);
                }
                throw new Error(`Timed out after ${timeoutMs}ms waiting for the panel operation lock. ` +
                    `${describeObservedLock(path, obs)} It was not auto-reclaimed: a living ` +
                    `owner is never stolen, and an unproven owner is left in place. ` +
                    `install_comfyui(action:'panel', panel_action:'unlock') RE-CHECKS and reports: it applies this ` +
                    `same proof, so it clears the lock only if the recorded owner can be ` +
                    `shown dead, and REFUSES for exactly the cases that reach this timeout ` +
                    `(an unreadable record, or a pid whose identity cannot be told apart ` +
                    `from a reused one). When it refuses, the recovery is by hand: stop or ` +
                    `restart every comfyui-mcp orchestrator, verify none remain, delete ` +
                    `this exact lock file, then retry.`);
            }
            await sleep(POLL_MS);
        }
    }
}
/**
 * Run `fn` with exclusive rights to mutate the panel or its pin, across BOTH
 * async callers in this process and other orchestrator processes.
 *
 * Re-entrant: a nested call while this process already holds the lock runs
 * immediately (the in-process chain guarantees only one holder is executing, so
 * a nested acquisition can only come from the holder itself). Rejections never
 * wedge the chain.
 */
export function withPanelMutationLock(fn, opts = {}) {
    // Only work running INSIDE the current holder is exempt — not everything that
    // happens to overlap it in this process.
    if (lockHolderContext.getStore())
        return fn();
    // `run` is the chain's settle CALLBACK — deliberately a function, NOT an
    // invoked IIFE (note: no trailing `()`). Passing an already-started promise
    // to .then() would resolve callers immediately with the chain's value while
    // the guarded work floated unfinished. Written without the `(async () =>
    // {...})` grouping parens because that shape reads as an IIFE and has been
    // mis-reviewed as one; the semantics were and are: callers resolve only
    // after the guarded action AND the release complete.
    const run = async () => {
        const release = await acquireFileLock(opts.timeoutMs ?? DEFAULT_ACQUIRE_MS);
        try {
            return await lockHolderContext.run(true, fn);
        }
        finally {
            release();
        }
    };
    const started = inProcessChain.then(run, run);
    inProcessChain = started.then(() => undefined, () => undefined);
    return started;
}
/**
 * Run a panel-moving mutation atomically with its pin check.
 *
 * assertPanelPinAllows alone is a TOCTOU race: the check passes, and THEN a pin
 * can be written before/while the ComfyUI-Manager operation runs (an update-all
 * drain takes seconds), so the update lands on a by-then-pinned panel. The pin
 * WRITE path takes this same lock, so checking inside it and holding it across
 * the whole mutation means a pin either lands before the op starts (and blocks
 * it) or after it finishes (and blocks the next one) — never in the middle.
 *
 * Targets that cannot move the panel skip the lock entirely: there is no pin
 * decision to make for them, and serializing every unrelated pack mutation
 * behind panel ops would be pointless contention.
 *
 * Re-entrant via withPanelMutationLock: runPanelAction already holds the lock
 * when it calls the guarded node-management mutations, so nesting is immediate.
 */
export function withPanelPinGuard(action, id, op) {
    if (!targetsPanelPack(id))
        return op();
    return withPanelMutationLock(() => {
        assertPanelPinAllows(action, id);
        return op();
    });
}
/** update-all drains in minutes on the Manager's worker; an hour is far beyond
 *  a legitimate drain, so reclaiming then cannot cut a live op short. */
export const UPDATE_ALL_PENDING_MS = 60 * 60_000;
/** A snapshot restore is applied at the next ComfyUI restart, which this
 *  process cannot observe — that could be days away. The marker cannot outlive
 *  restarts forever, so it expires after a week and says so in its detail. */
export const SNAPSHOT_RESTORE_PENDING_MS = 7 * 24 * 60 * 60_000;
/** Marker file path. Overridable so tests never touch the real home directory. */
export function panelPendingOpsPath() {
    return redirectedStatePath("COMFYUI_MCP_PANEL_PENDING", join(homedir(), ".comfyui-mcp", "panel-pending-ops.json"));
}
function isPanelPendingOp(value) {
    if (!value || typeof value !== "object" || Array.isArray(value))
        return false;
    const op = value;
    return (typeof op.kind === "string" &&
        typeof op.queuedAt === "string" &&
        typeof op.expiresAt === "string" &&
        typeof op.detail === "string" &&
        (op.base === undefined || typeof op.base === "string") &&
        (op.uiId === undefined || typeof op.uiId === "string"));
}
/**
 * Replace the marker atomically: write a uniquely-named temp beside it, fsync,
 * then rename over the target.
 *
 * `writeFileSync` TRUNCATES in place, so a crash after truncation and before the
 * content lands leaves a ZERO-BYTE file that previously held real operations.
 * That is what made "an empty file records nothing" untrue for this writer, and
 * it is the hole the independent gate found in the first cut of #847 — the write
 * path would have superseded such a file and silently dropped a queued
 * operation's warning. A rename is atomic: a reader sees the whole old content or
 * the whole new content, never nothing.
 *
 * The temp name carries a uuid because two agents share this rig, and a fixed
 * `.tmp` would let concurrent writers clobber each other's staging file.
 */
function writePanelPendingOpsAtomic(path, body) {
    // The third time the suite wrote the developer's real state it was THIS
    // file (#866): the orchestrator reads these markers, so leftovers make every
    // pin write warn about operations that never happened. Refuse at the write —
    // per-test scoping of COMFYUI_MCP_PANEL_PENDING kept eroding exactly the way
    // the issue predicted.
    assertNotWritingRealHomeInTests(path, "the pending panel-operation record");
    // Rebase reconciliation (#847 + #798). Both branches grew a temp+fsync+rename
    // writer for this file. `writeFileDurable` is the stronger one: it does
    // everything this function used to, and then fsyncs the CONTAINING DIRECTORY so
    // the rename's directory entry is itself durable — tolerating the one platform
    // that cannot do that (Windows EPERM) while still failing on a real I/O error.
    // Without that last step a crash can lose the rename even though the file's own
    // fsync succeeded, which is exactly the window this marker exists to cover.
    //
    // Keeping two near-identical writers would have meant one of them silently
    // losing the directory fsync, so this delegates rather than duplicating.
    mkdirSync(dirname(path), { recursive: true });
    writeFileDurable(path, body);
}
function readPanelPendingOpsFile() {
    const path = panelPendingOpsPath();
    let raw;
    try {
        raw = readFileSync(path, "utf-8");
    }
    catch (err) {
        // Only ENOENT proves there is no marker. Permission/I/O errors are
        // indeterminate and must keep a later pin from claiming clean protection.
        if (err?.code === "ENOENT") {
            return { ops: [], unreadable: false, state: "absent" };
        }
        return { ops: [], unreadable: true, state: "unparseable" };
    }
    // A zero-byte (or whitespace-only) file: `JSON.parse("")` throws, so this used
    // to land in `unparseable` and refuse forever. It stays `unreadable` for the
    // READ question — an interrupted write is indistinguishable from a file that
    // never got content, so a pin must still warn — but it is separately marked so
    // the WRITE path can supersede it. See the type comment above.
    if (raw.trim() === "") {
        return { ops: [], unreadable: true, state: "empty" };
    }
    // A zero-byte file is the signature of a torn pre-#798 write (crash between
    // truncate and write). It provably contains NO record — reading it as
    // "unreadable" would refuse every later update-all / warn on every pin
    // forever, with no recovery path, over a file we can read perfectly well.
    // (Non-empty unparseable content stays unreadable: it COULD have held a
    // record, and failing closed there is the whole point.)
    // Rebase resolution: `main` (#847) settled this as `unreadable: true` with an
    // explicit `state`, NOT a clean empty read. Both facts are load-bearing and they
    // are not in tension: `unreadable` keeps a later pin WARNING (an interrupted
    // write cannot be told from a file that never got content), while `state:
    // "empty"` is what lets a writer supersede it instead of refusing forever. This
    // branch had been rewritten to `unreadable: false`, which silently dropped the
    // warning half.
    if (raw.length === 0)
        return { ops: [], unreadable: true, state: "empty" };
    try {
        const parsed = JSON.parse(raw);
        const ops = parsed && typeof parsed === "object" && !Array.isArray(parsed)
            ? parsed.ops
            : undefined;
        if (Array.isArray(ops) && ops.every(isPanelPendingOp)) {
            return { ops, unreadable: false, state: "readable" };
        }
    }
    catch {
        // fall through
    }
    return { ops: [], unreadable: true, state: "unparseable" };
}
/**
 * Record that a panel-affecting operation was handed to ComfyUI-Manager and may
 * still land out-of-band. Replaces any prior marker of the same kind.
 *
 * Call this BEFORE handing the operation to ComfyUI-Manager. A mutation whose
 * out-of-band window cannot be durably recorded must not start: otherwise a
 * user can set a pin immediately afterwards and be told it protects a panel
 * which the already-queued Manager operation is still free to move. The write
 * is fsync'd (so the record cannot be lost while the action it describes
 * survives, #798) and read back before returning, so a successful syscall
 * alone is not treated as proof that the warning is durable.
 *
 * Once this succeeds, callers deliberately leave the marker in place even if
 * their Manager request errors. A transport error cannot prove the remote
 * Manager did not accept the request, and retaining a conservative warning is
 * safer than letting a later pin claim clean protection.
 *
 * On FAILURE the file is rolled back to the PRE-CALL record set — not merely
 * "without this call's record": the write REPLACES a same-kind predecessor,
 * and that predecessor may describe an operation still pending with the
 * Manager (codex gate). The rollback only fires when the file still holds
 * exactly what this call wrote, so a concurrent recorder's work is never
 * erased, and it is skipped entirely when the caller passes
 * `keepRecordOnFailure: true` — a call that merely RE-RECORDS an operation
 * already handed to the Manager (the update-all base/uiId enrichment), where
 * the operation IS pending and the marker must survive a failed enrichment.
 */
export function recordPanelPendingOp(kind, detail, ttlMs, extra = {}) {
    const now = Date.now();
    const op = {
        kind,
        id: randomUUID(),
        queuedAt: new Date(now).toISOString(),
        expiresAt: new Date(now + ttlMs).toISOString(),
        detail,
        ...(extra.base ? { base: extra.base } : {}),
        ...(extra.uiId ? { uiId: extra.uiId } : {}),
    };
    let priorOps;
    try {
        const path = panelPendingOpsPath();
        const prior = readPanelPendingOpsFile();
        // Refuse only when the prior file has CONTENT we cannot decode: that content
        // may describe a real queued operation, and overwriting it destroys a warning
        // we were never able to read.
        //
        // A zero-byte file is NOT that. It records no operation, so superseding it
        // loses nothing — and refusing on it was self-perpetuating (#847): this write
        // is gated behind the check, so the only thing that could replace the bad file
        // was the thing the bad file blocked. Because `recordPanelPendingOp` runs
        // BEFORE the Manager handoff, that wedged `update-all` permanently, until a
        // human deleted the file by hand.
        //
        // The path is named either way. A refusal a user cannot act on from where they
        // are is the other half of what made this a dead end.
        if (prior.unreadable && prior.state !== "empty") {
            throw new Error(`existing pending-operation record at ${path} has content that could not be ` +
                `decoded, so it may describe a queued operation whose warning would be lost. ` +
                `Inspect it, then delete it to clear this.`);
        }
        // Captured for #798's rollback: if the durable write lands but the directory
        // fsync fails, the caller restores exactly what was here before.
        priorOps = prior.ops;
        const kept = prior.ops.filter((o) => o.kind !== kind);
        mkdirSync(dirname(path), { recursive: true });
        // Superseding an EMPTY marker unwedges the operation, but it must not also
        // erase the possibility that the empty file masked a real queued op. A
        // pre-existing zero-byte file (written by the old truncating writer, before
        // writePanelPendingOpsAtomic) genuinely CAN be an interrupted write. So carry
        // an explicit indeterminate record forward: the block is lifted, the warning
        // is not. It warns rather than blocks, and clearPanelPendingOp can retire it.
        const carried = prior.state === "empty"
            ? [
                {
                    kind: "unknown",
                    queuedAt: new Date(now).toISOString(),
                    expiresAt: new Date(now + ttlMs).toISOString(),
                    detail: `an EMPTY pending-operation marker was found at ${path} and superseded. It recorded ` +
                        "no operation, but an interrupted write cannot be told from a file that never got " +
                        "content, so a previously queued update or deferred restore may still be outstanding.",
                },
            ]
            : [];
        writePanelPendingOpsAtomic(path, JSON.stringify({ ops: [...carried, ...kept, op] }, null, 2));
        // Verify the exact replacement record, not merely that JSON still parses.
        // A partial/redirected write that drops this operation would recreate the
        // same false-protection window as a write failure.
        const confirmed = readPanelPendingOpsFile();
        if (confirmed.unreadable ||
            !confirmed.ops.some((candidate) => candidate.kind === op.kind &&
                candidate.queuedAt === op.queuedAt &&
                candidate.expiresAt === op.expiresAt &&
                candidate.detail === op.detail &&
                candidate.base === op.base &&
                candidate.uiId === op.uiId)) {
            throw new Error("pending-operation record did not survive a read-back verification");
        }
    }
    catch (err) {
        // The record MAY have landed before the failure (the atomic rename precedes
        // the directory sync, and the read-back can fail after a successful write).
        // This throw means the guarded operation will NOT start — so restore the
        // PRE-CALL record set: our write REPLACED a same-kind predecessor that may
        // still describe a live pending operation, and leaving OUR record instead
        // would warn every later pin about a phantom one (codex gate rounds 4+7).
        // Restore only when the file still holds exactly what this call wrote, so
        // a concurrent recorder's work is never erased; a failed restore leaves
        // the conservative over-warning, which is the safe direction.
        // A CATCH THAT CAN THROW IS NOT A GUARD (codex gate P0). The rollback below
        // lives inside the failure path, so its OWN failure had nowhere to go — it was
        // swallowed, and the refusal was then reported as if the disk were clean. What
        // actually remained was a phantom marker that a later pin reads as a real
        // queued operation: false pending, false cancellation reporting, and no hint
        // that anything was left behind.
        //
        // The rollback still cannot be made infallible here (the record must precede
        // the handoff, so there is no ordering that removes the window). What CAN be
        // made reliable is saying so: the outcome is tracked and disclosed.
        let leftover;
        if (!extra.keepRecordOnFailure && priorOps !== undefined) {
            const recordKey = (o) => o.id ?? `${o.kind}:${o.queuedAt}`;
            try {
                const path = panelPendingOpsPath();
                const current = readPanelPendingOpsFile();
                if (current.unreadable) {
                    leftover =
                        `the pending-operation file could not be read back, so whether this call's ` +
                            `record is still on disk is UNKNOWN`;
                }
                else {
                    const expected = new Set([...priorOps.filter((o) => o.kind !== kind), op].map(recordKey));
                    const unchangedSinceOurWrite = current.ops.length === expected.size &&
                        current.ops.every((c) => expected.has(recordKey(c)));
                    if (unchangedSinceOurWrite) {
                        writeFileDurable(path, JSON.stringify({ ops: priorOps }, null, 2));
                    }
                    else if (current.ops.some((c) => recordKey(c) === recordKey(op))) {
                        // Another recorder changed the file, so rolling back would erase their
                        // work. Ours is still in there, and that is a phantom.
                        leftover =
                            `another operation wrote to the pending-operation file meanwhile, so this ` +
                                `call's record was left in place rather than erasing theirs`;
                    }
                }
            }
            catch (rollbackErr) {
                // DO NOT WARN ON THE FAILURE ALONE (codex gate). `writeFileDurable`
                // renames before it fsyncs the directory, so a throw from that last step
                // means the rollback may ALREADY have restored the prior records. Warning
                // regardless produced a false alarm that told the user to delete a
                // "phantom" — which, with a real same-kind predecessor restored, would
                // have been someone's live record. Ask the disk what actually happened.
                let ourRecordStillThere;
                try {
                    const after = readPanelPendingOpsFile();
                    // No "impossible value" sentinel here: an op with no id cannot be
                    // matched by id at all, so that is its own answer — undetermined.
                    // Reaching for a placeholder is how a literal NUL got into this file
                    // once already, which made the whole source read as git-binary.
                    ourRecordStillThere = after.unreadable
                        ? undefined
                        : op.id === undefined
                            ? undefined
                            : after.ops.some((o) => o.id === op.id);
                }
                catch {
                    ourRecordStillThere = undefined;
                }
                if (ourRecordStillThere === true) {
                    leftover =
                        `rolling it back ALSO failed (${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)})`;
                }
                else if (ourRecordStillThere === undefined) {
                    leftover =
                        `rolling it back also failed (${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}) and the file could not be read back, so whether this call's record ` +
                            `remains is UNKNOWN`;
                }
                // ourRecordStillThere === false: the rollback landed before the failure.
                // Nothing was left behind, so nothing to warn about.
            }
        }
        throw new Error(`Could not persist the pending ${kind} marker: ${err instanceof Error ? err.message : String(err)}. Refusing to start an operation that could move the panel after a later pin.` +
            (leftover
                ? ` NOTE: ${leftover}, so a pending-${kind} record may remain at ` +
                    `${panelPendingOpsPath()} describing an operation that never started. Later ` +
                    `panel operations will warn about it until it expires or is cleared; if that ` +
                    `warning is wrong, delete that record.`
                : ""));
    }
    return op;
}
/**
 * Remove a pending-op marker — ONLY the exact record handed in (matched by
 * kind + queuedAt), so a NEWER marker of the same kind recorded after it is
 * never silently dropped.
 *
 * Call this only for an op that was PROVABLY dealt with by the pin-write
 * cancellation path (#689): cancelled before it could run, or proven to be no
 * longer pending. Clearing anything else would let a later pin write report
 * clean protection while queued work is still out there.
 *
 * Read-back verified like recordPanelPendingOp. A WRITE failure does not
 * settle the answer by itself — writeFileDurable's atomic rename precedes the
 * directory sync, so a directory-sync failure can mean the clear DID land —
 * so the post-state is always READ BACK and the observed state reported
 * (codex gate): claiming "the marker remains" when it is gone would attach a
 * phantom warning to the pin result. Only a READ failure (or a marker still
 * present) returns false, leaving the warning in place. Returns true when the
 * exact record is gone afterwards — including when it was already absent
 * (that is the goal state, not a success claim about work this call did).
 */
export function clearPanelPendingOp(op) {
    // Identity by unique id when the record has one; only legacy records fall
    // back to kind+queuedAt (collision-prone in the same millisecond).
    const matches = (candidate) => op.id !== undefined
        ? candidate.id === op.id
        : candidate.kind === op.kind && candidate.queuedAt === op.queuedAt;
    try {
        const path = panelPendingOpsPath();
        const prior = readPanelPendingOpsFile();
        if (prior.unreadable)
            return false;
        if (!prior.ops.some(matches))
            return true; // already gone
        const kept = prior.ops.filter((candidate) => !matches(candidate));
        // Rebase resolution (#847 + #798). Both branches wanted a durable rewrite of
        // this crash-recovery record; they differed in how. `writePanelPendingOpsAtomic`
        // is the stronger of the two — temp file + fsync + rename, so a crash mid-write
        // cannot leave a truncated marker where `writeFileSync` would — and #798's
        // warn-and-verify is kept around it, because a write error here does not prove
        // the clear failed: the read-back below is what settles it.
        try {
            writePanelPendingOpsAtomic(path, JSON.stringify({ ops: kept }, null, 2));
        }
        catch (writeErr) {
            logger.warn(`[panel] the pending ${op.kind} marker clear hit a write error (${writeErr instanceof Error ? writeErr.message : String(writeErr)}) — verifying the on-disk state before reporting.`);
        }
        const confirmed = readPanelPendingOpsFile();
        return !confirmed.unreadable && !confirmed.ops.some(matches);
    }
    catch (err) {
        logger.warn(`[panel] could not clear the pending ${op.kind} marker: ${err instanceof Error ? err.message : String(err)} — leaving it (and its warning) in place.`);
        return false;
    }
}
/**
 * The pending panel-affecting operations whose warning window is still open.
 *
 * Reads fail CLOSED, mirroring the pin store: a present-but-unreadable marker
 * file means we cannot prove nothing is pending, so it yields a synthetic
 * indeterminate op and the pin write still warns. Entries with an unparseable
 * expiry are kept (indeterminate = still warn), never dropped.
 */
export function activePanelPendingOps(now = Date.now()) {
    const { ops, unreadable, state } = readPanelPendingOpsFile();
    if (unreadable) {
        // Both branches still WARN — for this question an empty file is genuinely
        // indeterminate, because an interrupted write cannot be told from a file that
        // never got content, and claiming nothing is pending would be the fabrication.
        // They differ only in what the user is told to do, which is the part that was
        // missing: the empty case clears itself, the other needs a decision (#847).
        return [
            {
                kind: "unknown",
                queuedAt: new Date(0).toISOString(),
                expiresAt: new Date(0).toISOString(),
                detail: state === "empty"
                    ? `the pending panel-operation record at ${panelPendingOpsPath()} is EMPTY, so a queued ` +
                        "update or deferred restore may still be outstanding and cannot be confirmed either way. " +
                        "It records no operation, so the next panel operation replaces it and this clears on its own — " +
                        "deleting the file also clears it immediately."
                    : `the pending panel-operation record at ${panelPendingOpsPath()} could not be read, so a queued ` +
                        "update or deferred restore may still be outstanding. Its content could not be decoded, so it " +
                        "is NOT replaced automatically — inspect it, then delete it to clear this.",
            },
        ];
    }
    // Expiry is informational only. We have no completion signal for a Manager
    // worker/deferred restore, so auto-expiry would falsely green a later pin.
    void now;
    return ops;
}
//# sourceMappingURL=panel-pin-guard.js.map