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,012 lines • 45.6 kB
JavaScript
// Persistent catalog of local LoRA files with human/agent-readable metadata:
// descriptions, setup instructions, trigger keywords, strength hints, and
// preview images. Synced against listLocalModels('loras') so missing files are
// flagged without dropping curated notes.
import { closeSync, copyFileSync, existsSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
import { randomUUID } from "node:crypto";
import { homedir } from "node:os";
import { basename, dirname, extname, join } from "node:path";
import { getInstanceSlug } from "../config.js";
import { listLocalModels } from "./model-resolver.js";
import { writeFileDurable } from "../utils/durable-write.js";
import { logger } from "../utils/logger.js";
const CATALOG_VERSION = 1;
const PREVIEW_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif"]);
function dataBaseDir() {
return process.env.COMFYUI_MCP_DATA_DIR?.trim() || join(homedir(), ".comfyui-mcp");
}
/** Override for tests. */
export function loraCatalogPath() {
const override = process.env.COMFYUI_MCP_LORA_CATALOG?.trim();
if (override)
return override;
const slug = getInstanceSlug();
return join(dataBaseDir(), "instances", slug, "lora-catalog.json");
}
export function loraPreviewsDir() {
const override = process.env.COMFYUI_MCP_LORA_PREVIEWS?.trim();
if (override)
return override;
const slug = getInstanceSlug();
return join(dataBaseDir(), "instances", slug, "lora-previews");
}
export function loraIdFromPath(relPath) {
const base = relPath.replace(/\\/g, "/").replace(/^\/+/, "");
const slug = base
.toLowerCase()
.replace(/\.(safetensors|ckpt|pt|bin)$/i, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return slug || "lora";
}
function defaultDisplayName(relPath) {
const file = basename(relPath);
return file.replace(/\.(safetensors|ckpt|pt|bin)$/i, "").replace(/_/g, " ");
}
/** The catalog file's raw text, three ways: absent, unreadable, or the bytes. */
function readCatalogRaw(path) {
// NO existsSync pre-screen: it cannot tell "absent" from "cannot look" (an
// unsearchable ancestor answers false either way), and a false "absent"
// would let a later write overwrite a catalog we merely failed to SEE.
try {
return { raw: readFileSync(path, "utf-8") };
}
catch (err) {
if (err?.code === "ENOENT")
return {};
return { unreadable: err instanceof Error ? err.message : String(err) };
}
}
/** Parse raw catalog text, setting aside malformed entry VALUES. */
function parseCatalogRaw(raw) {
try {
const parsed = JSON.parse(raw);
const entries = parsed?.entries;
if (parsed &&
typeof parsed === "object" &&
parsed.version === CATALOG_VERSION &&
// null passes typeof "object"; an ARRAY passes it too but string-keyed
// upserts on it do not survive JSON.stringify — the write would report
// success and persist nothing. Only a plain record is a catalog.
entries !== null &&
entries !== undefined &&
typeof entries === "object" &&
!Array.isArray(entries)) {
// The record itself is shaped right, but individual VALUES may not be
// (a hand-edit or a partial write leaves "some-lora": null or {} behind
// — a null crashes list(), a missing displayName crashes the sort, a
// missing id makes remove() "delete" a key of `undefined`, a key that
// is not the entry's id makes remove() delete nothing while reporting
// the removal done, a string in an array field (or a non-string member)
// crashes .some(x => x.toLowerCase()), a non-string previewFile makes
// remove() throw AFTER the entry was durably deleted, and a null
// createdAt is silently coalesced on the next write). The bar is the
// shape every write path in this file produces. Keep the valid
// entries, and mark the file corrupt so the next write PRESERVES the
// original before replacing it.
const clean = {};
const dropped = [];
for (const [key, value] of Object.entries(entries)) {
const v = value;
const rec = value;
const stringArray = (f, required) => {
const arr = rec?.[f];
if (arr === undefined)
return !required;
return Array.isArray(arr) && arr.every((x) => typeof x === "string");
};
const optString = (f) => rec?.[f] === undefined || typeof rec?.[f] === "string";
const optNumber = (f) => rec?.[f] === undefined || typeof rec?.[f] === "number";
if (v &&
typeof v === "object" &&
!Array.isArray(v) &&
v.id === key &&
typeof v.relPath === "string" &&
typeof v.displayName === "string" &&
typeof rec.createdAt === "string" &&
typeof rec.updatedAt === "string" &&
typeof rec.description === "string" &&
typeof rec.setupInstructions === "string" &&
stringArray("keywords", true) &&
stringArray("baseModels", true) &&
stringArray("negativeKeywords", false) &&
stringArray("tags", false) &&
["previewFile", "sourceUrl", "notes", "modifiedAt"].every(optString) &&
["strengthMin", "strengthMax", "strengthDefault", "civitaiModelId", "civitaiVersionId", "fileSize"].every(optNumber) &&
// "missing": "false" is truthy — a present LoRA would list as missing.
(rec.missing === undefined || typeof rec.missing === "boolean")) {
clean[key] = v;
}
else {
dropped.push(key);
}
}
if (dropped.length === 0)
return { data: parsed };
const detail = `${dropped.length} malformed ${dropped.length === 1 ? "entry" : "entries"} ` +
`(${dropped.slice(0, 3).join(", ")}${dropped.length > 3 ? ", …" : ""})`;
return {
data: { ...parsed, entries: clean },
corrupt: detail,
};
}
return { data: { version: CATALOG_VERSION, entries: {} }, corrupt: "not a v1 catalog" };
}
catch (err) {
return {
data: { version: CATALOG_VERSION, entries: {} },
corrupt: err instanceof Error ? err.message : String(err),
};
}
}
function writeCatalogFile(data) {
const path = loraCatalogPath();
mkdirSync(dirname(path), { recursive: true });
// Temp + fsync + atomic rename (#798): a truncate-and-write can leave a TORN
// catalog after a crash, which every later read then correctly refuses to
// trust — the curation is not lost but it is unusable until someone repairs
// the file by hand.
writeFileDurable(path, JSON.stringify(data, null, 2));
}
/** Monotonic suffix so two backups in the same millisecond never collide. */
let backupSeq = 0;
// ---------------------------------------------------------------------------
// Cross-process write lock
//
// `persist()` is a read-modify-write, and two agents on one rig is ordinary
// here. Without exclusion both processes read the same healthy bytes, both pass
// the unchanged check, and the second write silently erases the first — and
// because BOTH probes were healthy neither takes the corrupt-file backup, so
// there is nothing to recover from either.
//
// Re-reading immediately before the write does NOT close that. It shrinks the
// window to microseconds and leaves the loss silent, which is the same fold one
// order of magnitude smaller: "we did not catch a concurrent writer" read as
// "there is no concurrent writer". Only an exclusive create ("wx" = O_EXCL,
// atomic across processes) actually excludes one.
//
// The lock covers writers running THIS code. A hand-edit lands wherever it
// lands — that is the user editing their own file, and the baseline check
// already catches the ones that complete before our read.
// ---------------------------------------------------------------------------
/** How long a write waits for another writer's turn before refusing. A held
* lock covers one read and one write — milliseconds — so this is already a
* long queue, and the caller gets an honest "not saved, retry" rather than an
* unbounded stall. */
const CATALOG_LOCK_WAIT_MS = 2_000;
/** Three orders of magnitude past how long a holder is ever inside the critical
* section, so no live writer can age its own lock into reclaimability. Only a
* crash leaves a lock this old — which is also what makes the release below
* safe (see there). */
const CATALOG_LOCK_STALE_MS = 30_000;
/** Cross-platform pid liveness. EPERM means a process with that number EXISTS
* and is simply not ours to signal — alive, not dead. Reading it as dead is
* what would let a live holder's lock be reclaimed out from under it. */
function catalogLockOwnerAlive(pid) {
try {
process.kill(pid, 0);
return true;
}
catch (err) {
return err?.code === "EPERM";
}
}
/** Block this thread briefly. persist() and every caller of it are synchronous
* all the way up, and a contended wait here is milliseconds long. */
function napSync(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
/** Whoever holds a lock or claim, as far as the file can say. An unreadable or
* truncated record names NO holder — which is not evidence of abandonment,
* only an absence of evidence, and nothing here may act on it as if it were
* the former. */
function readLockRecord(path) {
try {
const rec = JSON.parse(readFileSync(path, "utf-8"));
return {
pid: typeof rec.pid === "number" ? rec.pid : undefined,
token: typeof rec.token === "string" ? rec.token : undefined,
};
}
catch {
return {};
}
}
/** The bytes that make a lock or claim attributable. */
function lockRecordBytes(token) {
return JSON.stringify({ pid: process.pid, token, at: new Date().toISOString() });
}
/**
* Take the reclaim claim that serializes reclaimers, or answer undefined.
*
* A dead claimant's claim is removed by the SAME rule as the main lock — a
* recorded pid that is provably not running — and by no other. An age rule here
* would be the fold the main lock just stopped committing, one level down: a
* reclaimer paused inside its claim is not a dead one, and deleting its claim
* lets a second reclaimer run concurrently, which is the sequence the claim
* exists to prevent.
*
* WHERE THE REGRESS STOPS: two processes may still remove the same DEAD claim
* and reclaim concurrently. That is exactly the behaviour before this claim
* existed — the claim narrows a race, it never grants an authority — so it does
* not need a claim of its own.
*/
function takeReclaimClaim(claimPath) {
for (let attempt = 0; attempt < 2; attempt++) {
const token = randomUUID();
try {
const fd = openSync(claimPath, "wx");
try {
try {
writeFileSync(fd, lockRecordBytes(token));
}
finally {
closeSync(fd);
}
}
catch (initErr) {
// A claim nothing can attribute is one no later reclaimer can ever
// prove dead — remove our own husk rather than create that state.
try {
rmSync(claimPath, { force: true });
}
catch {
// The init failure is the one that matters.
}
throw initErr;
}
return token;
}
catch (err) {
if (err?.code !== "EEXIST")
return undefined;
if (!removeDeadReclaimClaim(claimPath))
return undefined;
}
}
return undefined;
}
/** Remove a reclaim claim only when its recorded owner is provably gone. */
function removeDeadReclaimClaim(claimPath) {
let observed;
try {
observed = statSync(claimPath).mtimeMs;
}
catch {
return true; // already gone
}
if (Date.now() - observed < CATALOG_LOCK_STALE_MS)
return false;
const { pid } = readLockRecord(claimPath);
if (pid === undefined || catalogLockOwnerAlive(pid))
return false;
try {
// Only the exact instance judged: a claim taken in the gap is brand new, so
// its mtime cannot match one already CATALOG_LOCK_STALE_MS old.
if (statSync(claimPath).mtimeMs !== observed)
return false;
rmSync(claimPath, { force: true });
return true;
}
catch {
return false;
}
}
/** Give up the reclaim claim this call took — and only that one. */
function releaseReclaimClaim(claimPath, token) {
const holder = readLockRecord(claimPath).token;
if (holder !== undefined && holder !== token)
return; // a later reclaimer's
try {
rmSync(claimPath, { force: true });
}
catch {
// It records this process's pid, so it clears when this process exits —
// and until then reclaim is merely serialized behind it, not blocked.
}
}
/**
* Free the lock path ONLY when nothing can still be holding it: old enough that
* no live holder could be inside its critical section, AND owned by a pid that
* is provably gone. Every other state waits — freeing a lock whose holder is
* still working reintroduces exactly the loss the lock prevents.
*
* PROVEN-DEAD OWNER, NOT AGE. Age cannot settle the two states a pid leaves
* open — a lock whose owner was never recorded, and one whose pid the OS has
* since handed to an unrelated live process — because a process can also be
* suspended or blocked in I/O for an arbitrarily long time while inside the
* critical section. Reclaiming on age alone would be "could not determine the
* holder is gone" acted on as "the holder is gone", with silent data loss as
* the payout. So those two states REFUSE, and the refusal names the file and
* its recorded owner so a person can clear it in one command (codex gate).
*
* SERIALIZED against other reclaimers by an exclusive claim, because the
* dangerous move is taking a lock OFF the path at all: two reclaimers judging
* the same stale lock can have the first free it, a third writer acquire the
* freed pathname, and the second lift THAT one off — leaving two writers inside
* the critical section. One reclaimer at a time makes that unreachable: the
* second re-reads the path under the claim, finds a FRESH lock, and never
* touches it.
*
* RENAME FIRST, then judge. A stat-then-unlink is TOCTOU: the file at the path
* when the unlink lands need not be the one that was judged. A rename
* atomically removes whatever is there and puts it somewhere only this call
* knows, so the file inspected afterwards is the file this call will act on.
* (Same technique, for the same reason, as the panel op lock in
* panel-pin-guard.)
*/
function reclaimAbandonedCatalogLock(lockPath) {
const claimPath = `${lockPath}.reclaim`;
// Another reclaimer is deciding right now, or the claim cannot be created at
// all. Either way, acting concurrently is the thing that breaks exclusion —
// wait instead.
const claim = takeReclaimClaim(claimPath);
if (claim === undefined)
return false;
try {
return reclaimUnderClaim(lockPath);
}
finally {
releaseReclaimClaim(claimPath, claim);
}
}
function reclaimUnderClaim(lockPath) {
let observed;
try {
observed = statSync(lockPath).mtimeMs;
}
catch {
return true; // already gone — the path is free, retry the create
}
const ageMs = Date.now() - observed;
if (ageMs < CATALOG_LOCK_STALE_MS)
return false;
const { pid } = readLockRecord(lockPath);
if (pid === undefined || catalogLockOwnerAlive(pid))
return false;
const aside = `${lockPath}.stale-${randomUUID()}`;
try {
renameSync(lockPath, aside);
}
catch {
return false; // gone, or not ours to move — wait rather than assume
}
// The path is free from here on, so the only question left is whether it was
// right to free it.
let takenMtime;
try {
takenMtime = statSync(aside).mtimeMs;
}
catch {
// Cannot confirm identity → treat it as somebody else's and restore.
}
if (takenMtime === observed) {
try {
rmSync(aside, { force: true });
}
catch {
// The path is already free; a leftover copy blocks nothing.
}
logger.warn(`[lora-catalog] reclaimed an abandoned write lock at ${lockPath} ` +
`(owner pid ${pid} is no longer running; ${Math.round(ageMs / 1000)}s old).`);
return true;
}
// NOT the lock that was judged — a fresh one landed in the gap and this call
// just took it off the path. Put it back with linkSync, NOT renameSync: link
// fails if the path is occupied, where a rename would silently clobber a lock
// that appeared in the meantime.
try {
linkSync(aside, lockPath);
rmSync(aside, { force: true });
}
catch {
logger.warn(`[lora-catalog] a write lock at ${lockPath} changed mid-reclaim and could not be ` +
`put back; the replaced lock is preserved at ${aside}. Catalog writes are not ` +
`blocked by it.`);
}
return false;
}
/**
* Locks THIS process created and could not remove — a transient rmSync failure
* (a scanner holding the handle open on Windows, say) after the write itself
* completed and was reported as saved.
*
* Nothing else can ever clear one: reclaim requires a provably dead owner, and
* the owner is this very process, alive. So without this the next write refuses,
* and so does every write after it, for the life of the server. The token is
* what makes clearing it provable rather than assumed — it identifies the file
* at the path as the lock this process already finished with.
*/
const unreleasedOwnLocks = new Map();
/** Retry removing a lock this process took and failed to release. */
function dropOwnUnreleasedLock(lockPath) {
const token = unreleasedOwnLocks.get(lockPath);
if (token === undefined)
return false;
if (readLockRecord(lockPath).token !== token) {
// Somebody else's lock is at the path now, so ours is long gone.
unreleasedOwnLocks.delete(lockPath);
return false;
}
try {
rmSync(lockPath, { force: true });
unreleasedOwnLocks.delete(lockPath);
logger.warn(`[lora-catalog] cleared this process's own unreleased write lock at ${lockPath}.`);
return true;
}
catch {
return false; // still stuck — the caller reports the refusal
}
}
/** Take the catalog write lock, or THROW. Refuse-vs-disclose: nothing has been
* written yet, so a failure here refuses the change outright and says so. */
function acquireCatalogLock(path) {
const lockPath = `${path}.lock`;
const token = randomUUID();
const deadline = Date.now() + CATALOG_LOCK_WAIT_MS;
for (;;) {
try {
mkdirSync(dirname(lockPath), { recursive: true });
const fd = openSync(lockPath, "wx");
try {
try {
writeFileSync(fd, lockRecordBytes(token));
}
finally {
closeSync(fd);
}
}
catch (initErr) {
// Created but not identified. A lock nothing can attribute is one no
// later writer can wait on intelligently, so remove our own husk rather
// than leave it for the staleness timer.
try {
rmSync(lockPath, { force: true });
}
catch {
// The init failure is the one worth reporting.
}
throw initErr;
}
return { lockPath, token };
}
catch (err) {
const code = err?.code;
// ONLY EEXIST is contention. A create that fails because the volume is
// full or read-only must surface now, not burn the whole budget first
// and then be reported as somebody else's lock.
if (code !== "EEXIST") {
throw new Error(`Could not take the LoRA catalog write lock at ${lockPath} ` +
`(${err instanceof Error ? err.message : String(err)}). This change was NOT saved.`);
}
// Ours from a failed release, before anything else: it is the one state
// reclaim can never resolve, because this process is alive.
if (dropOwnUnreleasedLock(lockPath))
continue;
if (reclaimAbandonedCatalogLock(lockPath))
continue;
if (Date.now() >= deadline) {
// Reclaim deliberately refuses the two states a pid cannot settle (no
// owner recorded, or a pid the OS may have reused), because guessing
// there means two writers and a silent loss. So the refusal has to hand
// over what it DOES know: a person can settle in one look what this
// process cannot settle at all.
const { pid } = readLockRecord(lockPath);
let ageNote = "";
try {
ageNote = ` (${Math.round((Date.now() - statSync(lockPath).mtimeMs) / 1000)}s old)`;
}
catch {
// The lock went away as this message was being built; the retry below
// is the right advice either way.
}
throw new Error(`Another writer has held the LoRA catalog lock at ${lockPath}${ageNote} for more ` +
`than ${Math.round(CATALOG_LOCK_WAIT_MS / 1000)}s. This change was NOT saved — ` +
`re-read and retry. ` +
(pid === undefined
? `The lock records no owner, so nothing here can prove it abandoned. If no ` +
`other comfyui-mcp process is running, delete that file.`
: `It records pid ${pid}, which is still running. If that process is not a ` +
`comfyui-mcp writing this catalog, delete that file.`));
}
napSync(25);
}
}
}
/** Release the lock this call took. */
function releaseCatalogLock(lockPath, token) {
// A lock is reclaimed only when it is BOTH older than CATALOG_LOCK_STALE_MS
// and owned by a pid that is gone — and this process is alive and has been
// inside the critical section for milliseconds, so ours cannot have been
// taken and replaced. The token check makes that reasoning checkable instead
// of assumed: if what is at the path is somehow not ours, leave it alone
// rather than free a path another writer owns.
const holder = readLockRecord(lockPath).token;
if (holder !== undefined && holder !== token) {
logger.warn(`[lora-catalog] the write lock at ${lockPath} is no longer the one this write took; ` +
`leaving it in place for its owner.`);
return;
}
try {
rmSync(lockPath, { force: true });
unreleasedOwnLocks.delete(lockPath);
}
catch (err) {
// NOT "it will age out": reclaim requires a provably dead owner and this
// process is alive, so nothing external will ever clear this. Remember it
// instead, and let the next write in this process clear it on the token —
// otherwise one transient failure refuses every catalog write from here on.
unreleasedOwnLocks.set(lockPath, token);
logger.warn(`[lora-catalog] could not release the write lock at ${lockPath} ` +
`(${err instanceof Error ? err.message : String(err)}). The write itself is saved; ` +
`the next catalog write will clear this lock.`);
}
}
/** Run `fn` with the catalog write lock held. */
function withCatalogWriteLock(path, fn) {
const { lockPath, token } = acquireCatalogLock(path);
try {
return fn();
}
finally {
releaseCatalogLock(lockPath, token);
}
}
function normalizeRelPath(path) {
const p = path.replace(/\\/g, "/").replace(/^\/+/, "");
if (p.startsWith("models/"))
return p.slice("models/".length);
if (!p.startsWith("loras/") && !p.includes("/"))
return `loras/${p}`;
return p;
}
function entryFromLocal(model, now) {
const relPath = normalizeRelPath(model.path.startsWith("loras/") ? model.path : `loras/${model.name}`);
const id = loraIdFromPath(relPath);
return {
id,
relPath,
displayName: defaultDisplayName(relPath),
description: "",
setupInstructions: "",
keywords: [],
baseModels: [],
fileSize: model.size || undefined,
modifiedAt: model.modified || undefined,
missing: false,
createdAt: now,
updatedAt: now,
};
}
function mergeDiskEntry(existing, disk) {
if (!existing)
return disk;
return {
...existing,
relPath: disk.relPath,
fileSize: disk.fileSize,
modifiedAt: disk.modifiedAt,
missing: false,
updatedAt: new Date().toISOString(),
};
}
function matchesQuery(entry, query) {
const q = query.toLowerCase().trim();
if (!q)
return true;
const hay = [
entry.displayName,
entry.relPath,
entry.description,
entry.setupInstructions,
entry.notes ?? "",
...(entry.keywords ?? []),
...(entry.tags ?? []),
...(entry.baseModels ?? []),
]
.join(" ")
.toLowerCase();
return hay.includes(q);
}
/** Public summary shape for panel UI and pickers. */
export function toLoraSummary(entry) {
const previewUrl = entry.previewFile
? join(loraPreviewsDir(), entry.previewFile)
: undefined;
return {
id: entry.id,
relPath: entry.relPath,
displayName: entry.displayName,
description: entry.description,
setupInstructions: entry.setupInstructions,
keywords: entry.keywords,
negativeKeywords: entry.negativeKeywords ?? [],
baseModels: entry.baseModels,
strengthDefault: entry.strengthDefault,
strengthMin: entry.strengthMin,
strengthMax: entry.strengthMax,
previewFile: entry.previewFile,
previewPath: previewUrl && existsSync(previewUrl) ? previewUrl : undefined,
tags: entry.tags ?? [],
missing: !!entry.missing,
sourceUrl: entry.sourceUrl,
};
}
/**
* Is this preview file still referenced by the catalog AS IT NOW IS ON DISK?
* Checked before deleting: another writer may have persisted a reference to
* the file after our own write, and deleting it would orphan THEIR entry.
* A failed re-read answers "referenced" — the safe side for a delete.
*/
function previewReferencedOnDisk(previewFile) {
const probe = readCatalogRaw(loraCatalogPath());
if (!probe.raw)
return true; // unreadable/absent → do not delete
const read = parseCatalogRaw(probe.raw);
// A corrupt catalog says NOTHING about what references the file — the safe
// answer for a delete is "referenced".
if (read.corrupt)
return true;
return Object.values(read.data.entries).some((e) => e.previewFile === previewFile);
}
/** Delete a preview file only once nothing on disk references it. */
function deletePreviewIfUnreferenced(previewsDirPath, previewFile) {
if (previewReferencedOnDisk(previewFile))
return;
const p = join(previewsDirPath, previewFile);
if (existsSync(p)) {
try {
unlinkSync(p);
}
catch {
/* best effort */
}
}
}
export class LoraCatalog {
// Immediately overwritten by reload() from the constructor.
data = { version: CATALOG_VERSION, entries: {} };
/**
* The on-disk state `this.data` was built from — the baseline every write
* is checked against. `{}` (neither field) means the file was absent.
*/
baseline = {};
constructor() {
this.reload();
}
reload() {
const path = loraCatalogPath();
const probe = readCatalogRaw(path);
this.baseline = probe;
if (probe.unreadable) {
logger.warn(`[lora-catalog] could not read ${path}: ${probe.unreadable}`);
this.data = { version: CATALOG_VERSION, entries: {} };
return;
}
if (probe.raw === undefined) {
this.data = { version: CATALOG_VERSION, entries: {} };
return;
}
const read = parseCatalogRaw(probe.raw);
if (read.corrupt) {
logger.warn(`[lora-catalog] ${path}: ${read.corrupt}; the original will be preserved before any write replaces it.`);
}
this.data = read.data;
}
/**
* Persist — but NEVER over a corrupt original without preserving it first.
* The corrupt file is the only copy of whatever curation it still holds;
* overwriting it unread is the rescue path deleting the thing it rescues.
* If it cannot even be copied, refuse the write outright: a loud failure
* beats silent data loss.
*
* All of it runs under the cross-process write lock, because every check
* below is a read and the write is a separate syscall: without exclusion two
* processes can each pass every check against the same healthy bytes and the
* second write erases the first, silently and with no backup taken (both
* probes were healthy, so neither branch preserves anything).
*
* THREE checks before anything is replaced:
* - STALE-INSTANCE: the file must still be the state this.data was built
* from. Another writer's COMPLETED, healthy update is refused (re-read
* and retry) rather than silently overwritten; a file that became
* CORRUPT since our read is preserved and then replaced (nothing
* readable is lost — our in-memory state is the newer good copy of it).
* - MID-PERSIST RACE: the bytes probed must still be the bytes on disk
* when the write begins. The lock excludes every writer running this
* code, so what is left for this to catch is a foreign one — a hand-edit
* landing between the two reads.
* - POST-WRITE: what is actually ON DISK afterwards, not what we intended
* to put there. A write that returned without throwing is not by itself
* proof the bytes arrived.
*
* And on ANY failure, re-sync from disk before rethrowing: the caller is
* about to see this mutation as FAILED, so it must not linger in memory —
* a later persist would silently commit what was reported as not applied.
*/
persist() {
try {
const path = loraCatalogPath();
withCatalogWriteLock(path, () => this.persistLocked(path));
}
catch (err) {
// reload() never throws (a failed read degrades to empty + corrupt).
this.reload();
throw err;
}
}
/** The persist itself, with the write lock already held. */
persistLocked(path) {
{
const probe = readCatalogRaw(path);
const now = readCatalogRaw(path);
if (now.raw !== probe.raw || now.unreadable !== probe.unreadable) {
throw new Error(`The LoRA catalog at ${path} changed on disk while this write was being ` +
`prepared — NOT overwriting another writer's update. This change was not ` +
`saved; re-read and retry.`);
}
const unchanged = probe.raw === this.baseline.raw && probe.unreadable === this.baseline.unreadable;
const probeParsed = probe.raw !== undefined ? parseCatalogRaw(probe.raw) : undefined;
const corrupt = probe.unreadable ?? probeParsed?.corrupt;
if (!unchanged && !corrupt) {
throw new Error(`The LoRA catalog at ${path} changed on disk since it was read — NOT ` +
`overwriting another writer's update. This change was not saved; re-read ` +
`and retry.`);
}
if (corrupt) {
const action = probe.unreadable ? "read" : "parsed";
// Unique per process AND per backup within it: two stale instances
// sharing a Date.now() tick must not have the second backup overwrite
// the first — that would erase the corrupt original after all.
const backup = `${path}.corrupt-${Date.now()}-${process.pid}-${backupSeq++}`;
try {
copyFileSync(path, backup);
logger.warn(`[lora-catalog] the catalog at ${path} could not be ${action} (${corrupt}); ` +
`the original was preserved at ${backup} before being replaced.`);
}
catch (err) {
throw new Error(`The LoRA catalog at ${path} could not be ${action} (${corrupt}) and ` +
`could not be preserved either (${err instanceof Error ? err.message : String(err)}). ` +
`Refusing to overwrite it — inspect or repair the file manually.`);
}
}
const intended = JSON.stringify(this.data, null, 2);
// The durable write can throw on EITHER side of its atomic rename: with
// nothing on the path, or with the new bytes already there and only their
// directory entry's durability unproven (writeFileDurable is strict about
// that by design, for callers whose next action depends on the record
// surviving a crash). Only the read-back can tell those apart, and
// reporting the second as an unapplied save is this file's own defect
// pointed the other way — the mutation DID happen.
let writeError;
try {
writeCatalogFile(this.data);
}
catch (err) {
writeError = err;
}
// The baseline is what is ACTUALLY on disk, read back — not what we meant
// to put there. Taking the intended bytes on faith is how a write that
// landed somewhere else, or landed short, becomes the state every later
// persist compares against: the next unchanged check would then pass on a
// file nobody ever wrote, and quietly overwrite whatever is really there.
const written = readCatalogRaw(path);
if (written.raw === intended) {
if (writeError) {
// Refuse-vs-disclose: the bytes are on the path, so this is a
// disclosure about durability, not a failed save.
logger.warn(`[lora-catalog] ${path} holds this write, but completing it durably failed ` +
`(${writeError instanceof Error ? writeError.message : String(writeError)}) — ` +
`the change is saved and readable; it may not survive a power loss.`);
}
this.baseline = { raw: written.raw };
return;
}
// Nothing of ours is on the path: the write genuinely failed, and its own
// error says why far better than a read-back mismatch would.
if (writeError)
throw writeError;
if (written.unreadable && probe.unreadable) {
// The catalog could not be read BEFORE this write either, so the
// read-back carries no new information — and refusing on it would
// refuse every write to a catalog this process can write but not read,
// permanently, for a write that most likely landed. That is the fold
// pointed the other way. DISCLOSE instead: the write has happened, only
// its confirmation is missing, and the baseline records what was
// actually observed rather than what we hoped for.
logger.warn(`[lora-catalog] wrote ${path}, but it still cannot be read back ` +
`(${written.unreadable}) — the write is UNCONFIRMED. Check the file's ` +
`permissions if entries appear to go missing.`);
this.baseline = { unreadable: written.unreadable };
return;
}
// The file was readable going in and does not hold what we just wrote, so
// something is genuinely wrong with the write path. Say only that — NOT
// that the change was discarded; persist()'s reload picks it up if it did
// in fact land.
throw new Error(`The LoRA catalog at ${path} does not read back as what was just written ` +
`(${written.unreadable
? `it could not be read: ${written.unreadable}`
: written.raw === undefined
? "it is not there"
: "its contents differ"}). This change is NOT confirmed saved — inspect the file, then retry.`);
}
}
list(opts = {}) {
let entries = Object.values(this.data.entries);
if (!opts.includeMissing)
entries = entries.filter((e) => !e.missing);
if (opts.tag) {
const t = opts.tag.toLowerCase();
entries = entries.filter((e) => (e.tags ?? []).some((x) => x.toLowerCase() === t));
}
if (opts.baseModel) {
const b = opts.baseModel.toLowerCase();
entries = entries.filter((e) => (e.baseModels ?? []).some((x) => x.toLowerCase().includes(b)));
}
if (opts.query)
entries = entries.filter((e) => matchesQuery(e, opts.query));
entries.sort((a, b) => a.displayName.localeCompare(b.displayName));
if (typeof opts.limit === "number" && opts.limit > 0) {
entries = entries.slice(0, opts.limit);
}
return entries;
}
get(idOrPath) {
const key = idOrPath.trim();
if (this.data.entries[key])
return this.data.entries[key];
const norm = normalizeRelPath(key);
const id = loraIdFromPath(norm);
if (this.data.entries[id])
return this.data.entries[id];
for (const e of Object.values(this.data.entries)) {
if (e.relPath === norm || e.relPath.endsWith(`/${basename(norm)}`))
return e;
}
return null;
}
async syncFromDisk() {
const models = await listLocalModels("loras");
const now = new Date().toISOString();
const seenIds = new Set();
let added = 0;
let updated = 0;
for (const model of models) {
const disk = entryFromLocal(model, now);
seenIds.add(disk.id);
const prev = this.data.entries[disk.id];
if (!prev) {
this.data.entries[disk.id] = disk;
added++;
}
else {
const wasMissing = !!prev.missing;
this.data.entries[disk.id] = mergeDiskEntry(prev, disk);
if (wasMissing)
updated++;
}
}
let markedMissing = 0;
for (const [id, entry] of Object.entries(this.data.entries)) {
if (!seenIds.has(id) && !entry.missing) {
entry.missing = true;
entry.updatedAt = now;
markedMissing++;
}
}
this.persist();
return {
scanned: models.length,
added,
updated: updated + markedMissing,
markedMissing,
total: Object.keys(this.data.entries).length,
};
}
upsert(partial) {
const now = new Date().toISOString();
let id = partial.id?.trim();
let relPath = partial.relPath ? normalizeRelPath(partial.relPath) : undefined;
if (!id && relPath)
id = loraIdFromPath(relPath);
if (id && !relPath)
relPath = this.data.entries[id]?.relPath;
if (!id || !relPath) {
throw new Error("upsert requires id or relPath");
}
const prev = this.data.entries[id];
const entry = {
id,
relPath,
displayName: partial.displayName?.trim() || prev?.displayName || defaultDisplayName(relPath),
description: partial.description ?? prev?.description ?? "",
setupInstructions: partial.setupInstructions ?? prev?.setupInstructions ?? "",
keywords: partial.keywords ?? prev?.keywords ?? [],
negativeKeywords: partial.negativeKeywords ?? prev?.negativeKeywords,
baseModels: partial.baseModels ?? prev?.baseModels ?? [],
strengthMin: partial.strengthMin ?? prev?.strengthMin,
strengthMax: partial.strengthMax ?? prev?.strengthMax,
strengthDefault: partial.strengthDefault ?? prev?.strengthDefault,
previewFile: partial.previewFile ?? prev?.previewFile,
civitaiModelId: partial.civitaiModelId ?? prev?.civitaiModelId,
civitaiVersionId: partial.civitaiVersionId ?? prev?.civitaiVersionId,
sourceUrl: partial.sourceUrl ?? prev?.sourceUrl,
tags: partial.tags ?? prev?.tags,
notes: partial.notes ?? prev?.notes,
missing: partial.missing ?? prev?.missing,
fileSize: partial.fileSize ?? prev?.fileSize,
modifiedAt: partial.modifiedAt ?? prev?.modifiedAt,
createdAt: prev?.createdAt ?? now,
updatedAt: now,
};
this.data.entries[id] = entry;
this.persist();
return entry;
}
setPreview(idOrPath, sourcePath) {
const entry = this.get(idOrPath);
if (!entry)
throw new Error(`No catalog entry for "${idOrPath}"`);
const src = sourcePath.trim();
if (!existsSync(src))
throw new Error(`Preview source not found: ${src}`);
const ext = extname(src).toLowerCase();
if (!PREVIEW_EXTS.has(ext)) {
throw new Error(`Preview must be an image (${[...PREVIEW_EXTS].join(", ")})`);
}
const dir = loraPreviewsDir();
mkdirSync(dir, { recursive: true });
const destName = `${entry.id}${ext}`;
const dest = join(dir, destName);
// Write the new preview and PERSIST the reference before deleting the old
// one: persist can refuse (a concurrently-changed catalog, an
// unpreservable corrupt original), and an old preview deleted ahead of a
// failed persist leaves the surviving entry pointing at a file that is
// gone. An orphaned NEW preview after a failed persist is the harmless
// direction.
copyFileSync(src, dest);
let updated;
try {
updated = this.upsert({ id: entry.id, previewFile: destName });
}
catch (err) {
// The file swap already happened; only its description failed. Disclose
// BOTH — refusing as though nothing changed would misreport applied
// work, and the retry reconciles the catalog with the file.
throw new Error(`The preview image was already written to ${dest}, but recording it in the ` +
`catalog FAILED: ${err instanceof Error ? err.message : String(err)}. ` +
`The file on disk is the NEW preview; the catalog may still describe the old one — retry to reconcile.`);
}
if (entry.previewFile && entry.previewFile !== destName) {
deletePreviewIfUnreferenced(dir, entry.previewFile);
}
return updated;
}
remove(idOrPath) {
const entry = this.get(idOrPath);
if (!entry)
return false;
delete this.data.entries[entry.id];
// Persist BEFORE deleting the preview: a refused persist reloads the
// entry (still referencing its preview), so the preview must still exist.
this.persist();
if (entry.previewFile) {
deletePreviewIfUnreferenced(loraPreviewsDir(), entry.previewFile);
}
return true;
}
}
let singleton = null;
export function getLoraCatalog() {
if (!singleton)
singleton = new LoraCatalog();
else
singleton.reload();
return singleton;
}
/** Reset singleton — tests only. */
export function resetLoraCatalog() {
singleton = null;
}
//# sourceMappingURL=lora-catalog.js.map