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),
538 lines • 28.4 kB
JavaScript
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { logger } from "../utils/logger.js";
import { SHARED_SESSION_SCOPE } from "../services/session-scope.js";
import { runningUnderTestRunner } from "../services/panel-secrets.js";
const WORKFLOW_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** The validated + canonicalized (origin, uuid) identity a panel hello/command
* claims, or undefined when either half is missing/untrusted. The origin MUST
* be the caller's SERVER-OBSERVED (unspoofable) handshake origin — never the
* client-supplied hello.comfyui_url. Still used for the per-command workflow
* STAMP (routing job: a command dispatched for workflow A must not mutate
* workflow B after a switch) — deliberately NOT for session identity, which is
* orchestrator-scoped (#884). */
export function workflowIdentityParts(opts) {
const raw = typeof opts.workflowUuid === "string" ? opts.workflowUuid.trim() : "";
const uuid = WORKFLOW_UUID_RE.test(raw) ? raw.toLowerCase() : "";
if (!uuid)
return undefined;
const origin = typeof opts.origin === "string" ? opts.origin.trim().replace(/\/+$/, "").toLowerCase() : "";
if (!origin)
return undefined;
return { origin, uuid };
}
/** Legacy-adoption screen: tab-id halves that must never seed the shared
* conversation (test/spike keys from prior runs). */
const LEGACY_ADOPT_EXCLUDE = /^(e2e-|spike-)/;
export class SessionStore {
/** Entries untouched for longer than this are pruned on load. Resumes are
* same-day / few-days in practice; three weeks is a generous ceiling that
* still bounds growth. */
static GC_TTL_MS = 21 * 24 * 60 * 60 * 1000;
path;
/** The pre-#884 tmpdir location — read once for the location migration. */
legacyPath;
sessions;
/** #884 P1 (codex confirming gate 2) — TRUE when the in-memory state is known
* NOT to be on disk, i.e. a write failed and was reported. Every path that
* would otherwise take an in-memory shortcut and answer `true` consults this
* first, so a durability claim always describes the DISK, never just RAM. */
undurable = false;
/** Set when the on-disk store EXISTED but could not be READ (an I/O error, not
* a parse error). Its contents are unknown and presumed good, so flush() must
* move it aside before overwriting — see read() and flush(). Cleared once it
* has been preserved. */
loadUnreadable;
/** #884 P1 (confirming gate 3) — the (size, mtime) of the store file as last
* READ or WRITTEN by this instance. `undurable === false` alone proved only
* that the LAST write succeeded, not that the file is still there: deleted
* or replaced externally, a later in-memory shortcut still answered "durable"
* while a restart would lose the session. The shortcut now re-stats the file
* and compares against this fingerprint — a cheap, honest "the state I put
* on disk is observably still in place" check (a same-size, same-mtime
* external replacement is not detectable without a full read and is accepted
* as the documented limit of the claim). Undefined = nothing verifiable on
* disk; the shortcut then re-writes instead of asserting. */
diskFingerprint;
constructor(port, opts = {}) {
// #866 — guard at the WRITE, not per-test: this store lives in the user's
// real ~/.comfyui-mcp, so a test that forgets to pass { dir } must refuse
// loudly instead of silently polluting (and then reading back) real state.
// Detection keys off the runner's own globals; when uncertain, the write is
// allowed — a real user must never be refused their own store.
if (!opts.dir && runningUnderTestRunner()) {
throw new Error("Refusing to open the real ~/.comfyui-mcp/sessions store from a test run: " +
"pass { dir: <temp dir> } to SessionStore (see #866 — guard at the write, not per-test).");
}
const dir = opts.dir ?? join(homedir(), ".comfyui-mcp", "sessions");
this.path = join(dir, `panel-sessions-${port}.json`);
this.legacyPath = join(tmpdir(), `comfyui-mcp-panel-sessions-${port}.json`);
try {
mkdirSync(dir, { recursive: true });
}
catch (err) {
logger.warn(`[session-store] could not create ${dir}: ${String(err)}`);
}
const loaded = this.read();
this.sessions = loaded.sessions;
// If load had to SANITIZE (migrate the legacy format/location, GC an expired
// entry, clamp a corrupt future timestamp), persist the cleaned version NOW —
// otherwise a clamped-in-memory-only value survives on disk and is re-clamped
// on every load, immortal.
if (loaded.dirty)
this.flush();
}
now() {
return Date.now();
}
/** Parse one store file's bytes into a sessions map, dropping malformed and
* expired rows. Handles the v2 shape and the ancient flat v1 map. */
parse(text) {
const empty = { sessions: {}, dirty: false };
const parsed = JSON.parse(text);
if (!parsed || typeof parsed !== "object")
return empty;
const obj = parsed;
const cutoff = this.now() - SessionStore.GC_TTL_MS;
let dirty = false;
const coerce = (raw) => {
const out = {};
if (raw === undefined)
return out;
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
dirty = true;
return out;
}
const nowMs = this.now();
for (const [k, v] of Object.entries(raw)) {
if (!v || typeof v !== "object") {
dirty = true;
continue;
}
const e = v;
if (typeof e.s !== "string") {
dirty = true;
continue;
}
// Sanitize the timestamp: non-finite → epoch 0 (pruned as too old);
// a finite FUTURE value is clamped to now so the TTL applies to it.
const rawT = typeof e.t === "number" && Number.isFinite(e.t) ? e.t : 0;
let t = rawT;
if (t > nowMs)
t = nowMs;
if (t !== rawT)
dirty = true;
if (t < cutoff) {
dirty = true;
continue;
}
const entry = { s: e.s, t };
if (typeof e.u === "string")
entry.u = e.u;
// #1516 — keep a well-formed preview ledger; a malformed one is dropped
// (the budget then starts un-spent, the safe direction: under-bounding
// a conversation is the failure mode this ledger exists to prevent,
// and a fresh-zero ledger can never over-claim delivered media).
if (e.pl && typeof e.pl === "object" && !Array.isArray(e.pl)) {
const pl = e.pl;
if (typeof pl.i === "number" && Number.isFinite(pl.i) && pl.i >= 0 &&
typeof pl.b === "number" && Number.isFinite(pl.b) && pl.b >= 0) {
entry.pl = { i: pl.i, b: pl.b };
}
else {
dirty = true;
}
}
out[k] = entry;
}
return out;
};
if (obj.v === 2) {
const sessions = coerce(obj.sessions);
// A pre-#884 v2 file also carried a `stable` index — obsolete under the
// shared key (see the class docstring). Dropping it must be persisted.
if (obj.stable !== undefined)
dirty = true;
return { sessions, dirty };
}
// ANCIENT flat format (Record<string,string>) — migrate. Stamp `now` so the
// migration itself never trips GC.
const sessions = {};
for (const [k, v] of Object.entries(obj)) {
if (typeof v === "string")
sessions[k] = { s: v, t: this.now() };
}
return { sessions, dirty: true };
}
read() {
// RECOVERY ORDER. A leftover `.tmp` is always the NEWEST state when it
// parses: a successful flush renames it away, so its presence means the
// last rename failed or crashed mid-flush — prefer it over the main file
// and re-persist (dirty). The legacy tmpdir file is consulted ONLY when
// the new-location file does not EXIST (the one-shot location migration).
// An existing-but-corrupt home file with no usable `.tmp` starts EMPTY,
// never falls back to the stale pre-migration store — that would silently
// resurrect sessions the user has since replaced or cleared (a wrong
// resume; codex round 1, P1). Missing ≠ corrupt.
const tmpPath = `${this.path}.tmp`;
try {
if (existsSync(tmpPath)) {
const recovered = this.parse(readFileSync(tmpPath, "utf8"));
logger.warn(`[session-store] recovered the newest state from ${tmpPath} (a previous persist did not complete)`);
return { sessions: recovered.sessions, dirty: true }; // re-persist properly
}
}
catch {
// tmp unreadable/corrupt (a crash mid-tmp-write) — fall through to main.
}
if (existsSync(this.path)) {
// COULD NOT READ IT and IT IS CORRUPT are different states (#796), and this
// catch used to serve both. The difference decides whether the file on disk
// is worthless or precious:
//
// parse threw → the bytes really are unusable; starting empty loses
// nothing that was recoverable.
// readFileSync threw → EACCES / EBUSY / EMFILE. The sessions are INTACT
// and readable later; we simply could not open the file
// this instant (antivirus, a backup process, another
// orchestrator mid-write — all routine on Windows).
//
// Starting empty on the second one is silent data loss, because flush()
// renames over this.path unconditionally: the first new session — or even a
// touch() — replaces an intact store with a one-entry one, and every prior
// resume id is gone. This file already argues "failing to persist is
// strictly better than destroying what's there"; that reasoning simply never
// covered a failed LOAD.
let text;
try {
text = readFileSync(this.path, "utf8");
}
catch (err) {
// Do NOT let the next flush() overwrite a file we never managed to read.
this.loadUnreadable = String(err);
logger.warn(`[session-store] ${this.path} could NOT be READ (it is not known to be corrupt) — ` +
`starting empty for now, and the next persist will move it aside rather than ` +
`overwrite it, so nothing in it is lost: ${String(err)}`);
return { sessions: {}, dirty: false };
}
try {
const parsed = this.parse(text);
// The in-memory state now equals this on-disk file — fingerprint it so
// a later durability shortcut can verify the file is still in place.
this.captureDiskFingerprint();
return parsed;
}
catch (err) {
logger.warn(`[session-store] ${this.path} is unreadable/corrupt — starting empty (NOT falling back to the pre-migration tmpdir store): ${String(err)}`);
return { sessions: {}, dirty: false };
}
}
try {
if (existsSync(this.legacyPath)) {
const migrated = this.parse(readFileSync(this.legacyPath, "utf8"));
if (Object.keys(migrated.sessions).length) {
logger.info(`[session-store] migrated ${Object.keys(migrated.sessions).length} session(s) from ${this.legacyPath} to ${this.path}`);
}
return { sessions: migrated.sessions, dirty: true }; // persist at the new location
}
}
catch {
// Legacy unreadable/corrupt — start empty; resume falls back to fresh.
}
return { sessions: {}, dirty: false };
}
/** Persist the store. Atomic (temp + rename); returns whether the state is
* durably on disk. On ANY failure the previous on-disk store is left
* INTACT — there is deliberately NO truncate-in-place fallback (codex
* confirming-gate P0: a truncated write surviving a crash reads as corrupt
* next boot, and corrupt refuses legacy recovery, so every resume id would
* be lost; failing to persist is strictly better than destroying what's
* there). When the temp write itself succeeded, the newest state survives
* in `.tmp` and read() recovers it on the next load. */
flush() {
const payload = JSON.stringify({ v: 2, sessions: this.sessions });
const tmp = `${this.path}.tmp`;
// The store we are about to replace was never read (an I/O failure at load,
// not a parse failure), so its contents are unknown and presumed GOOD. Move it
// aside before the rename below destroys it. If it cannot even be moved,
// REFUSE to persist — the same ruling this method already makes for a failed
// write: failing to persist is strictly better than destroying what's there.
if (this.loadUnreadable !== undefined) {
const aside = `${this.path}.unreadable-${this.now()}`;
try {
if (existsSync(this.path)) {
renameSync(this.path, aside);
logger.warn(`[session-store] preserved the unreadable store as ${aside} before writing a fresh one ` +
`(it could not be read at startup: ${this.loadUnreadable}). If sessions went missing, ` +
`that file holds them.`);
}
this.loadUnreadable = undefined; // preserved (or already gone) — safe from here
}
catch (err) {
this.undurable = true;
logger.warn(`[session-store] REFUSING to persist: ${this.path} could not be read at startup and cannot ` +
`be moved aside either, so overwriting it would destroy sessions that are probably intact ` +
`(${String(err)}). State is kept in memory for this run.`);
return false;
}
}
try {
writeFileSync(tmp, payload);
renameSync(tmp, this.path);
this.undurable = false;
this.captureDiskFingerprint();
return true;
}
catch (err) {
this.undurable = true;
this.diskFingerprint = undefined; // nothing of ours verifiably on disk
const tmpHoldsState = (() => {
try {
return existsSync(tmp) && readFileSync(tmp, "utf8") === payload;
}
catch {
return false;
}
})();
logger.warn(`[session-store] persist FAILED — the previous on-disk store was left intact; the latest state ${tmpHoldsState ? `survives in ${tmp} and will be recovered on the next load` : "was NOT captured on disk"}: ${String(err)}`);
return false;
}
}
/** Touch an entry's timestamp on a resume hit so an actively-used session
* never ages out. Debounced (1h) so the per-spawn read doesn't churn disk. */
touch(key) {
const e = this.sessions[key];
if (!e)
return;
const now = this.now();
if (now - e.t < 60 * 60 * 1000)
return;
e.t = now;
this.flush();
}
/**
* KEYING migration (#884): the shared key for a backend has no entry yet, but
* per-workflow entries from the pre-#884 era may. Adopt the most recently
* used one for this backend as the shared conversation, persisting it under
* the shared key, so upgrading users keep their newest conversation's memory.
*
* Adoption CONSUMES every legacy entry for the backend (they are deleted).
* This is what makes it one-shot: after a deliberate New chat clears the
* shared key, a later get() finds no legacy entry to re-adopt — otherwise the
* cleared conversation would silently resurrect. The archived conversations
* themselves are untouched (the panel's history picker resumes them by
* explicit session id via resume_session, never through these keys).
*/
adoptLegacyForSharedKey(sharedKey) {
const sep = sharedKey.indexOf("::");
if (sep < 0)
return undefined;
const backend = sharedKey.slice(sep + 2);
if (!backend)
return undefined;
const suffix = `::${backend}`;
let bestKey;
let best;
const legacyKeys = [];
for (const [k, e] of Object.entries(this.sessions)) {
if (!k.endsWith(suffix))
continue;
const tabHalf = k.slice(0, k.length - suffix.length);
if (tabHalf === SHARED_SESSION_SCOPE || tabHalf.includes("::"))
continue;
if (LEGACY_ADOPT_EXCLUDE.test(tabHalf))
continue;
legacyKeys.push(k);
if (!best || e.t > best.t) {
best = e;
bestKey = k;
}
}
if (!best || !bestKey)
return undefined;
const adopted = { s: best.s, t: this.now() };
for (const k of legacyKeys)
delete this.sessions[k]; // consumed — see docstring
this.sessions[sharedKey] = adopted;
const persisted = this.flush();
logger.info(`[session-store] adopted legacy per-workflow session ${bestKey.slice(0, 24)}… as the shared ${backend} conversation (${legacyKeys.length} legacy ${backend} entr${legacyKeys.length === 1 ? "y" : "ies"} consumed, #884)${persisted ? "" : " — WARNING: the adoption could NOT be persisted and will re-run from disk on the next boot"}`);
return adopted;
}
/** The persisted session id to resume for a key, if any. For a shared-scope
* key with no entry, falls back to adopting the newest legacy per-workflow
* entry for that backend (see the class docstring). */
get(key) {
let e = this.sessions[key];
if (!e && key.startsWith(`${SHARED_SESSION_SCOPE}::`)) {
e = this.adoptLegacyForSharedKey(key);
}
if (!e)
return undefined;
this.touch(key);
return e.s;
}
/** The workflow-identity binding a legacy entry carries, if any. Retained for
* the manager's rebind plumbing; shared-scope entries never carry one. */
identityOf(key) {
return this.sessions[key]?.u;
}
/** Record (and persist) a key's current session id. `identityUuid` is the
* legacy per-workflow binding — accepted for API compatibility, unused for
* shared-scope keys. No-op if both the id and binding are unchanged.
* Returns whether the state is DURABLY persisted (a swallowed write failure
* here is the false-success class — codex confirming-gate P1). */
set(key, sessionId, identityUuid) {
const u = typeof identityUuid === "string" && identityUuid ? identityUuid : undefined;
const existing = this.sessions[key];
if (existing?.s === sessionId && existing.u === u) {
// Same id — refresh the timestamp so an active session never GCs out, but
// skip the disk write when the timestamp is already recent. NOT a blanket
// `true`: if an earlier write failed, this state has never reached disk,
// and the shortcut would report a durability we never achieved (codex
// confirming-gate 2, P1). Retry the write instead and answer honestly.
if (this.now() - existing.t < 60 * 60 * 1000)
return this.settled();
}
const entry = { s: sessionId, t: this.now() };
if (u)
entry.u = u;
// #1516 — the preview ledger is a property OF THE CONVERSATION: it carries
// over only when the session id is unchanged, and a fresh conversation
// provably starts at zero (its rollout holds no previews yet).
if (existing?.s === sessionId && existing.pl)
entry.pl = existing.pl;
this.sessions[key] = entry;
return this.flush();
}
/** #1516 — the durable automatic-preview ledger for a key's current session,
* joined with the session id it was recorded against so a caller can adopt
* it only for that exact conversation. */
previewLedger(key) {
const e = this.sessions[key];
if (!e?.pl)
return undefined;
return { sid: e.s, images: e.pl.i, bytes: e.pl.b };
}
/** #1516 — record (and persist) the automatic-preview ledger for a session.
* REFUSES when the stored session id is not the one the ledger describes:
* writing it anyway would either un-bound the conversation actually on disk
* or saddle a fresh one with media cost it never incurred — both silent
* corruptions of a safety bound. Returns whether the state reached disk. */
setPreviewLedger(key, sessionId, ledger) {
const e = this.sessions[key];
if (!e || e.s !== sessionId)
return false;
if (e.pl?.i === ledger.images && e.pl?.b === ledger.bytes)
return this.settled();
e.pl = { i: ledger.images, b: ledger.bytes };
e.t = this.now();
return this.flush();
}
/** Forget a key's session — called on a deliberate NEW chat, so the disk
* fallback never resurrects a conversation the user reset. Returns whether
* the clear is DURABLE: false means the in-memory entry is gone (this
* process starts fresh) but the on-disk copy could not be updated, so an
* orchestrator restart could resume the cleared conversation — callers must
* disclose that instead of reporting a clean New chat (codex
* confirming-gate P1). */
clear(key) {
// Absent in memory does NOT mean absent on disk. After a failed clear the
// entry is already gone from memory, so a naive `true` here let the SECOND
// New chat report a clean durable clear while the stale on-disk entry sat
// there waiting to resurrect the conversation on the next restart (codex
// confirming-gate 2, P1). Retry the write and report what actually happened.
if (!(key in this.sessions))
return this.settled();
delete this.sessions[key];
return this.flush();
}
/** Record the store file's current (size, mtime) after a successful read or
* write, for the drift check in {@link settled}. A failed stat clears the
* fingerprint: what cannot be fingerprinted cannot later be verified, so the
* shortcut re-writes instead of asserting. */
captureDiskFingerprint() {
try {
const st = statSync(this.path);
this.diskFingerprint = { size: st.size, mtimeMs: st.mtimeMs };
}
catch {
this.diskFingerprint = undefined;
}
}
/** Durability of the CURRENT in-memory state when a caller took an in-memory
* shortcut (nothing to change). `true` is claimed only on EVIDENCE: no prior
* write failure AND the store file is observably still the one this instance
* last read/wrote (same size+mtime — confirming gate 3, P1: after external
* deletion/replacement the old shortcut kept answering "durable" while a
* restart would lose the session). Anything less re-runs the write, which
* both repairs the store the moment the filesystem recovers and keeps the
* answer honest while it hasn't. */
settled() {
if (this.undurable)
return this.flush();
if (this.diskFingerprint) {
try {
const st = statSync(this.path);
if (st.size === this.diskFingerprint.size && st.mtimeMs === this.diskFingerprint.mtimeMs) {
return true;
}
}
catch {
// missing/unreadable — fall through to the repair write
}
}
return this.flush();
}
}
/**
* Move a tab's trusted workflow command stamp onto the id that replaced it (#1331).
*
* RESTORES A FUNCTION THIS REPO ALREADY HAD. #436 shipped `carryWorkflowCommandStamp` for
* exactly this, with a note that deleting the stamp "flapped sessions"; the #884
* orchestrator-scoped-sessions refactor rewrote the migration block and left a bare
* `delete` behind. The argument survived in the comments around it — including a paragraph
* naming this precise scenario — while the code implementing it did not.
*
* A same-socket re-hello mints a new tab id on a workflow switch, a save/rename
* (tmp: → wf:), or an id-scheme change. Deleting the stamp there and re-setting it only
* when THIS hello resolves an identity means a reconnect hello that lands before the canvas
* identity is readable leaves the tab with no stamp at all, and every fenced command is
* refused for the rest of the session.
*
* CARRYING CANNOT WIDEN AUTHORIZATION: the panel authorizes a fenced command iff the stamp
* equals the live active workflow uuid, so a carried-but-stale stamp naming workflow A on a
* canvas showing B is refused exactly as an absent one would be. What it avoids is the
* unrecoverable half — an absent stamp makes the bridge send frames with no workflow_uuid,
* which the panel also counts as a mismatch, and the panel's re-advertise repair is capped
* at 3 attempts per identity.
*
* Extracted and CALLED by the hello handler rather than inlined, so the tests exercise the
* function production runs instead of a re-implementation of what it ought to do.
*
* RETURNS whether it actually carried (#1656). "CARRYING CANNOT WIDEN AUTHORIZATION" was
* true of the ONE authorizer that existed when it was written: the panel's
* `stamp === live active uuid` test, which judges the carried value against the canvas
* actually mounted. #1682 then added a SECOND authorizer -- the orchestrator's
* dispatch-time agreement gate -- which reads this same map as "the identity the routed
* tab last ADVERTISED". A carried entry was advertised by the tab that was RETIRED, so
* for that gate the sentence stops holding: the conversation's issue-time stamp is
* captured from this very map (`turnOrigins.recordForMid(mid, stamps.get(tab), tab)`),
* so a carry makes the gate's two sides equal BY CONSTRUCTION exactly in the window
* where the canvas changed. Callers therefore have to be able to tell a CARRIED stamp
* from a PROVEN one, and only a proven one may satisfy an agreement check. The carry
* itself is unchanged -- #1331 still needs it, so the frame keeps its stamp and the
* panel's own fence keeps judging it.
*/
export function carryWorkflowCommandStamp(stamps, migratedFrom, panelTab) {
const carried = stamps.get(migratedFrom);
// An entry already recorded for the new id is NEWER evidence than anything held under
// the old one, so it wins.
const didCarry = carried !== undefined && !stamps.has(panelTab);
if (didCarry)
stamps.set(panelTab, carried);
// The old id is still retired: a straggler command addressed to it must not resolve.
stamps.delete(migratedFrom);
return didCarry;
}
//# sourceMappingURL=session-store.js.map