comfyui-mcp 0.49.2 → 0.49.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/orchestrator/agent-backend.js +8 -0
- package/dist/orchestrator/agent-backend.js.map +1 -1
- package/dist/orchestrator/codex-backend.js +49 -0
- package/dist/orchestrator/codex-backend.js.map +1 -1
- package/dist/orchestrator/grok-backend.js +5 -0
- package/dist/orchestrator/grok-backend.js.map +1 -1
- package/dist/orchestrator/index.js +317 -22
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/ollama-backend.js +11 -2
- package/dist/orchestrator/ollama-backend.js.map +1 -1
- package/dist/orchestrator/panel-agent.js +591 -67
- package/dist/orchestrator/panel-agent.js.map +1 -1
- package/dist/orchestrator/panel-tools.js +518 -65
- package/dist/orchestrator/panel-tools.js.map +1 -1
- package/dist/orchestrator/run-completion-journal.js +914 -0
- package/dist/orchestrator/run-completion-journal.js.map +1 -0
- package/dist/orchestrator/session-store.js +11 -3
- package/dist/orchestrator/session-store.js.map +1 -1
- package/dist/services/asset-reconcile.js +83 -0
- package/dist/services/asset-reconcile.js.map +1 -0
- package/dist/services/asset-registry.js +9 -2
- package/dist/services/asset-registry.js.map +1 -1
- package/dist/services/download-jobs.js +178 -14
- package/dist/services/download-jobs.js.map +1 -1
- package/dist/services/download-progress.js +16 -18
- package/dist/services/download-progress.js.map +1 -1
- package/dist/services/extra-paths.js +61 -9
- package/dist/services/extra-paths.js.map +1 -1
- package/dist/services/hello-retarget.js +165 -0
- package/dist/services/hello-retarget.js.map +1 -0
- package/dist/services/job-history.js +50 -0
- package/dist/services/job-history.js.map +1 -1
- package/dist/services/job-watcher.js +38 -7
- package/dist/services/job-watcher.js.map +1 -1
- package/dist/services/manifest.js +92 -9
- package/dist/services/manifest.js.map +1 -1
- package/dist/services/model-resolver.js +616 -17
- package/dist/services/model-resolver.js.map +1 -1
- package/dist/services/output-dir.js +63 -15
- package/dist/services/output-dir.js.map +1 -1
- package/dist/services/panel-pin-guard.js +6 -62
- package/dist/services/panel-pin-guard.js.map +1 -1
- package/dist/services/ui-bridge.js +111 -14
- package/dist/services/ui-bridge.js.map +1 -1
- package/dist/services/workspace-env.js +179 -3
- package/dist/services/workspace-env.js.map +1 -1
- package/dist/tools/assets.js +34 -2
- package/dist/tools/assets.js.map +1 -1
- package/dist/tools/extra-paths.js +6 -4
- package/dist/tools/extra-paths.js.map +1 -1
- package/dist/tools/model-extras.js +26 -7
- package/dist/tools/model-extras.js.map +1 -1
- package/dist/tools/model-management.js +36 -8
- package/dist/tools/model-management.js.map +1 -1
- package/dist/tools/report-issue.js +12 -2
- package/dist/tools/report-issue.js.map +1 -1
- package/dist/tools/vocabulary.js +21 -0
- package/dist/tools/vocabulary.js.map +1 -1
- package/package.json +1 -1
- package/scripts/gen-tool-docs.ts +138 -4
- package/scripts/tool-doc-examples.ts +794 -0
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
+
import { platform } from "node:os";
|
|
3
4
|
import { readdir, stat, mkdir, readFile, lstat, realpath } from "node:fs/promises";
|
|
4
5
|
import { dirname, join, basename, resolve, relative, sep, isAbsolute, extname } from "node:path";
|
|
5
|
-
import { config, isRemoteMode } from "../config.js";
|
|
6
|
+
import { config, getComfyUIBaseUrl, isRemoteMode } from "../config.js";
|
|
6
7
|
import { getClient, getSystemStats } from "../comfyui/client.js";
|
|
7
8
|
import { getExtraModelRoots, getLiveExtraModelRoots } from "./extra-paths.js";
|
|
8
|
-
import { resolveEffectiveComfyUIBase,
|
|
9
|
+
import { resolveEffectiveComfyUIBase, resolveLiveServerRoot } from "./workspace-env.js";
|
|
9
10
|
import { installModelViaManager } from "./node-management.js";
|
|
10
11
|
import { ModelError, ValidationError } from "../utils/errors.js";
|
|
11
12
|
import { logger } from "../utils/logger.js";
|
|
12
13
|
import { downloadWithCache, probeRemoteModelPayload } from "./download-cache.js";
|
|
13
14
|
import { reportDownloadProgress } from "./download-progress.js";
|
|
14
|
-
import { resolveModelsDirWithBases, parseModelsDirFromArgv, hasUnresolvableRelativeModelDirFlag, } from "./output-dir.js";
|
|
15
|
+
import { resolveModelsDir, resolveModelsDirWithBases, parseModelsDirFromArgv, hasUnresolvableRelativeModelDirFlag, isLiveAuthoritativeModelsDir, } from "./output-dir.js";
|
|
15
16
|
import { applyDownloadAuth, redactUrlForLogs, } from "./download-auth.js";
|
|
16
17
|
export const MODEL_SUBDIRS = [
|
|
17
18
|
"checkpoints",
|
|
@@ -503,6 +504,548 @@ export function resolveModelSubfolder(targetSubfolder) {
|
|
|
503
504
|
}
|
|
504
505
|
return targetDir;
|
|
505
506
|
}
|
|
507
|
+
// ---------------------------------------------------------------------------
|
|
508
|
+
// Live-server visibility (#369): does the CONNECTED ComfyUI actually read here?
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
/**
|
|
511
|
+
* The file extensions ComfyUI core registers for its model folders
|
|
512
|
+
* (`folder_paths.supported_pt_extensions`). Deliberately EXCLUDES `.gguf` and
|
|
513
|
+
* other custom-node-registered types: this set is used to decide whether a
|
|
514
|
+
* candidate directory's contents SHOULD appear in the live server's listing, and
|
|
515
|
+
* over-including would turn "core doesn't list this type" into a false refusal.
|
|
516
|
+
*/
|
|
517
|
+
const CORE_MODEL_EXTENSIONS = new Set([
|
|
518
|
+
".ckpt",
|
|
519
|
+
".pt",
|
|
520
|
+
".pt2",
|
|
521
|
+
".bin",
|
|
522
|
+
".pth",
|
|
523
|
+
".safetensors",
|
|
524
|
+
".sft",
|
|
525
|
+
]);
|
|
526
|
+
/** The models/ CATEGORY a target subfolder belongs to — its first path segment
|
|
527
|
+
* (`"loras/sdxl"` → `"loras"`), which is the folder name ComfyUI's `/models/<cat>`
|
|
528
|
+
* endpoint is keyed by. */
|
|
529
|
+
function categoryOf(targetSubfolder) {
|
|
530
|
+
return (targetSubfolder ?? "").trim().split(/[\\/]+/).filter(Boolean)[0] ?? "";
|
|
531
|
+
}
|
|
532
|
+
/** The part of a target subfolder BELOW its category (`"loras/sdxl/x"` → `"sdxl/x"`,
|
|
533
|
+
* `"loras"` → `""`). ComfyUI's `/models/<category>` listing is relative to the
|
|
534
|
+
* category, so this is what a landed file's entry must be prefixed with. */
|
|
535
|
+
function subfolderRemainder(targetSubfolder) {
|
|
536
|
+
const parts = (targetSubfolder ?? "").trim().split(/[\\/]+/).filter(Boolean);
|
|
537
|
+
return parts.slice(1).join("/");
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* What the LIVE server reports for a model category via its own `/models/<cat>`
|
|
541
|
+
* endpoint — the SAME source of truth `list_local_models` reads, which is exactly
|
|
542
|
+
* why #369 was so confusing: the reader asked the server and the writer asked
|
|
543
|
+
* COMFYUI_PATH, and the two disagreed silently.
|
|
544
|
+
*
|
|
545
|
+
* Returns `undefined` (INCONCLUSIVE — never an empty array) when the server is
|
|
546
|
+
* unreachable, the endpoint is absent/errors, or the body is not a string array.
|
|
547
|
+
* Callers must treat inconclusive as "no evidence", never as "no files".
|
|
548
|
+
*/
|
|
549
|
+
export async function liveCategoryListing(category) {
|
|
550
|
+
if (!category)
|
|
551
|
+
return undefined;
|
|
552
|
+
try {
|
|
553
|
+
const client = getClient();
|
|
554
|
+
const res = await client.fetchApi(`/models/${encodeURIComponent(category)}`);
|
|
555
|
+
if (!res.ok)
|
|
556
|
+
return undefined;
|
|
557
|
+
const json = (await res.json());
|
|
558
|
+
if (!Array.isArray(json))
|
|
559
|
+
return undefined;
|
|
560
|
+
return json.filter((n) => typeof n === "string");
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
return undefined;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Model files under a category directory, as CATEGORY-RELATIVE paths — the exact
|
|
568
|
+
* form ComfyUI's `/models/<category>` listing uses (`"sdxl/x.safetensors"`). Bare
|
|
569
|
+
* basenames would make `loras/a/shared.safetensors` look accounted for by a live
|
|
570
|
+
* `loras/b/shared.safetensors` from a different install (codex gate, round 9).
|
|
571
|
+
*/
|
|
572
|
+
async function coreModelFilesUnder(categoryDir) {
|
|
573
|
+
let entries;
|
|
574
|
+
try {
|
|
575
|
+
entries = await readdir(categoryDir, { recursive: true });
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
return [];
|
|
579
|
+
}
|
|
580
|
+
// Not just tidiness: this feeds a REFUSAL, so an unreadable/odd listing must
|
|
581
|
+
// become "no evidence", never a crash inside the download path.
|
|
582
|
+
if (!Array.isArray(entries))
|
|
583
|
+
return [];
|
|
584
|
+
return entries
|
|
585
|
+
.filter((e) => typeof e === "string" && CORE_MODEL_EXTENSIONS.has(extname(e).toLowerCase()))
|
|
586
|
+
.map((e) => normRel(e));
|
|
587
|
+
}
|
|
588
|
+
/** How many populated categories of the candidate models root we cross-check.
|
|
589
|
+
* Bounded so a large models tree costs a handful of cheap HTTP calls, not one
|
|
590
|
+
* per folder. */
|
|
591
|
+
const DISAGREEMENT_PROBE_CATEGORIES = 6;
|
|
592
|
+
/** Immediate subdirectories of the candidate models root, in a stable order. */
|
|
593
|
+
async function categoriesUnder(modelsRoot) {
|
|
594
|
+
try {
|
|
595
|
+
const entries = await readdir(modelsRoot, { withFileTypes: true });
|
|
596
|
+
if (!Array.isArray(entries))
|
|
597
|
+
return [];
|
|
598
|
+
const names = [];
|
|
599
|
+
for (const e of entries) {
|
|
600
|
+
const entry = e;
|
|
601
|
+
if (!entry || typeof entry.isDirectory !== "function")
|
|
602
|
+
continue;
|
|
603
|
+
if (typeof entry.name !== "string")
|
|
604
|
+
continue;
|
|
605
|
+
if (entry.isDirectory())
|
|
606
|
+
names.push(entry.name);
|
|
607
|
+
}
|
|
608
|
+
return names.sort();
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
return [];
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* REFUSE a destination the connected ComfyUI demonstrably does not read from.
|
|
616
|
+
*
|
|
617
|
+
* Runs ONLY when the models root came from LOCAL CONFIGURATION that the running
|
|
618
|
+
* server never vouched for (`configured-base` / `base-anchored`) — a live-resolved
|
|
619
|
+
* root is already authoritative and needs no second opinion. The test is a pure
|
|
620
|
+
* disagreement check against the server's own `/models/<category>` listing: if a
|
|
621
|
+
* category directory in the candidate root holds core-extension model files and the
|
|
622
|
+
* live server lists NONE of them, that root is not part of its search path. That is
|
|
623
|
+
* the #369 signature exactly (3 files on disk, 24 unrelated files in the live
|
|
624
|
+
* listing), and catching it BEFORE the transfer saves the user the multi-GB download
|
|
625
|
+
* that would otherwise be reported as a success and then be invisible.
|
|
626
|
+
*
|
|
627
|
+
* The evidence is gathered ROOT-WIDE, not just from the target category: a stale
|
|
628
|
+
* install commonly has an EMPTY `diffusion_models/` while its `checkpoints/` is full
|
|
629
|
+
* of files the live server has never heard of (codex gate, round 1 — the target
|
|
630
|
+
* category alone made an empty-but-wrong destination look evidence-free).
|
|
631
|
+
*
|
|
632
|
+
* Agreement means CONTAINMENT, not overlap (codex gate, round 5): a directory the
|
|
633
|
+
* server scans has ALL of its model files in that server's listing. A single shared
|
|
634
|
+
* filename proves nothing — two unrelated installs routinely hold the same popular
|
|
635
|
+
* checkpoint — so overlap alone must never suppress the refusal.
|
|
636
|
+
*
|
|
637
|
+
* POSITIVE EVIDENCE ONLY. There is deliberately no "the live server lists models
|
|
638
|
+
* this tree cannot account for" rule: being unable to EXPLAIN the server's models is
|
|
639
|
+
* absence of evidence, not proof that this destination belongs to another install
|
|
640
|
+
* (maintainer ruling). Unaccountable is a proceed-unverified state — see the note at
|
|
641
|
+
* the end of the body.
|
|
642
|
+
*
|
|
643
|
+
* Fails OPEN on anything inconclusive — server unreachable, endpoint missing, an
|
|
644
|
+
* empty candidate directory, or full containment. It must never block a legitimate
|
|
645
|
+
* download into a fresh or shared models tree.
|
|
646
|
+
*/
|
|
647
|
+
async function assertDestinationVisibleToLiveServer(modelsRoot, targetSubfolder, source, snapshot) {
|
|
648
|
+
// A live-resolved root that EXISTS on this filesystem is authoritative and needs
|
|
649
|
+
// no second opinion. A live-resolved root that does NOT exist locally is a
|
|
650
|
+
// different animal: a loopback ComfyUI inside Docker / behind an SSH forward
|
|
651
|
+
// reports its CONTAINER-side `--models-directory`, and writing that path here
|
|
652
|
+
// silently creates a host directory the server never reads (codex gate, round 3).
|
|
653
|
+
// The check below is cheap and fails open, so run it for that case too.
|
|
654
|
+
if (isLiveAuthoritativeModelsDir(source) && existsSync(modelsRoot))
|
|
655
|
+
return;
|
|
656
|
+
if (!snapshot.reachable || isRemoteMode())
|
|
657
|
+
return;
|
|
658
|
+
// The target category first (it is the one that matters), then the rest of the
|
|
659
|
+
// root — so a populated sibling can still expose a wrong install when the target
|
|
660
|
+
// folder happens to be empty.
|
|
661
|
+
const target = categoryOf(targetSubfolder);
|
|
662
|
+
const present = await categoriesUnder(modelsRoot);
|
|
663
|
+
const primary = [...(target ? [target] : []), ...present.filter((c) => c !== target)];
|
|
664
|
+
let probed = 0;
|
|
665
|
+
let worst;
|
|
666
|
+
for (const category of primary) {
|
|
667
|
+
if (probed >= DISAGREEMENT_PROBE_CATEGORIES)
|
|
668
|
+
break;
|
|
669
|
+
const onDisk = await coreModelFilesUnder(join(modelsRoot, category));
|
|
670
|
+
if (onDisk.length === 0)
|
|
671
|
+
continue; // nothing here to contradict anything
|
|
672
|
+
const listing = await liveCategoryListing(category);
|
|
673
|
+
if (listing === undefined)
|
|
674
|
+
continue; // the server can't speak for it — no evidence
|
|
675
|
+
probed += 1;
|
|
676
|
+
// CONTAINMENT, not overlap. If this directory really is one the server reads, it
|
|
677
|
+
// SCANS it — so EVERY core-extension file in it must appear in the listing. A
|
|
678
|
+
// merely-overlapping name proves nothing: two unrelated installs routinely share
|
|
679
|
+
// `sd_xl_base_1.0.safetensors`, and treating that as agreement let a download
|
|
680
|
+
// proceed into a stale tree (codex gate, round 5). Compared as CATEGORY-RELATIVE
|
|
681
|
+
// paths, so `a/shared.safetensors` is not accounted for by a live
|
|
682
|
+
// `b/shared.safetensors` (codex gate, round 9).
|
|
683
|
+
const liveNames = new Set(listing.map((n) => normRel(n)));
|
|
684
|
+
const missing = onDisk.filter((n) => !liveNames.has(n));
|
|
685
|
+
if (missing.length === 0)
|
|
686
|
+
continue; // this folder is fully accounted for — agrees
|
|
687
|
+
worst = { category, missing, onDisk: onDisk.length, live: listing.length };
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
if (worst) {
|
|
691
|
+
const sample = worst.missing.slice(0, 3).join(", ");
|
|
692
|
+
throw new ModelError(`Refusing to download into "${modelsRoot}": the connected ComfyUI ` +
|
|
693
|
+
`(${getComfyUIBaseUrl()}) does not read from it. Its "${worst.category}" folder holds ` +
|
|
694
|
+
`${worst.onDisk} model file(s), ${worst.missing.length} of which the running server does ` +
|
|
695
|
+
`NOT list (e.g. ${sample}) — if it scanned this directory it would see all of them, so ` +
|
|
696
|
+
"this is a DIFFERENT install than the one serving you. This destination came from local " +
|
|
697
|
+
"configuration (COMFYUI_PATH / the saved default workspace), not from the running server, " +
|
|
698
|
+
"which could not tell us its own install root. Point COMFYUI_PATH at the ComfyUI that is " +
|
|
699
|
+
"actually running, or launch it with an absolute --base-directory, then retry.");
|
|
700
|
+
}
|
|
701
|
+
// NOTE — there is deliberately NO 'the server lists models this tree does not contain'
|
|
702
|
+
// refusal. That inference is itself absence of evidence: not being able to ACCOUNT
|
|
703
|
+
// for the server's models is not proof the destination belongs to a different
|
|
704
|
+
// install. The roots could be registered by a config we cannot read, one deleted
|
|
705
|
+
// after the server loaded it, or a path shape nobody has enumerated yet — and each
|
|
706
|
+
// time that rule was tightened it produced another FALSE REFUSAL of a legitimate
|
|
707
|
+
// setup (an external drive, a moved YAML). Per the maintainer ruling, unaccountable
|
|
708
|
+
// is a PROCEED-UNVERIFIED state, not a refusal: the download goes through and the
|
|
709
|
+
// post-write check reports honestly. This guard refuses only on POSITIVE evidence —
|
|
710
|
+
// files sitting in this tree that the running server demonstrably does not read.
|
|
711
|
+
}
|
|
712
|
+
/** Normalize a listing entry / relative path for comparison (ComfyUI reports
|
|
713
|
+
* OS-native separators). */
|
|
714
|
+
function normRel(s) {
|
|
715
|
+
return s.replace(/\\/g, "/").replace(/^\.?\//, "");
|
|
716
|
+
}
|
|
717
|
+
/** The category-relative entry a file at `<models>/<targetSubfolder>/<filename>`
|
|
718
|
+
* would appear as in ComfyUI's `/models/<category>` listing. */
|
|
719
|
+
function listingEntryFor(targetSubfolder, filename) {
|
|
720
|
+
const rest = subfolderRemainder(targetSubfolder);
|
|
721
|
+
return normRel(rest ? `${rest}/${filename}` : filename);
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Was this exact entry ALREADY in the live server's listing before we wrote?
|
|
725
|
+
*
|
|
726
|
+
* Captured BEFORE the transfer so the post-write check can tell "the server now
|
|
727
|
+
* sees the file BECAUSE we wrote it" from "the server already had a file of that
|
|
728
|
+
* name somewhere else". Without that distinction a download into a stale tree that
|
|
729
|
+
* happens to share a filename with the live install verifies as `visible` and is
|
|
730
|
+
* reported as a success (codex gate, round 3). `undefined` = the server could not
|
|
731
|
+
* answer, so nothing is known either way.
|
|
732
|
+
*/
|
|
733
|
+
export async function liveListingHasEntry(targetSubfolder, filename) {
|
|
734
|
+
const listing = await liveCategoryListing(categoryOf(targetSubfolder));
|
|
735
|
+
if (listing === undefined)
|
|
736
|
+
return undefined;
|
|
737
|
+
const wanted = listingEntryFor(targetSubfolder, filename);
|
|
738
|
+
return listing.some((n) => normRel(n) === wanted);
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Does the live server list a file with this BASENAME anywhere in the category?
|
|
742
|
+
*
|
|
743
|
+
* Deliberately looser than `liveListingHasEntry`: the manifest's existing-file
|
|
744
|
+
* lookup itself matches a basename anywhere in the served category (a `checkpoints`
|
|
745
|
+
* target satisfied by `checkpoints/sdxl/big.safetensors`), so confirming it with an
|
|
746
|
+
* exact-path check would FAIL legitimate nested installs. This is used only as an
|
|
747
|
+
* ADDITIONAL requirement on the skip path — it can rule a skip out, never in on its
|
|
748
|
+
* own. `undefined` = the server could not answer.
|
|
749
|
+
*/
|
|
750
|
+
export async function liveListingHasBasename(category, filename) {
|
|
751
|
+
const listing = await liveCategoryListing(categoryOf(category));
|
|
752
|
+
if (listing === undefined)
|
|
753
|
+
return undefined;
|
|
754
|
+
const wanted = basename(filename);
|
|
755
|
+
return listing.some((n) => basename(normRel(n)) === wanted);
|
|
756
|
+
}
|
|
757
|
+
export async function isUnderLiveModelRoots(absPath,
|
|
758
|
+
/** The models CATEGORY the path belongs to. Live extra roots are category-scoped
|
|
759
|
+
* (`{ category, dir }`), so a `checkpoints` root must never vouch for a LoRA
|
|
760
|
+
* (codex gate, round 6). Omitted = consider every root. */
|
|
761
|
+
category) {
|
|
762
|
+
/** Canonicalize so a junctioned/symlinked root is compared by where it REALLY
|
|
763
|
+
* lives. `C:\ComfyUI\models` being a junction to `D:\Models` is a legitimate,
|
|
764
|
+
* supported layout; a lexical-only compare would call a correctly-placed file
|
|
765
|
+
* "outside the live roots" and fabricate a failure (codex gate, round 6). */
|
|
766
|
+
const canon = async (p) => {
|
|
767
|
+
const lex = resolve(p);
|
|
768
|
+
try {
|
|
769
|
+
return { real: resolve(await realpath(p)), lex, ok: true };
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
return { real: lex, lex, ok: false }; // not created yet / unreadable
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
const target = await canon(absPath);
|
|
776
|
+
const under = (root) => {
|
|
777
|
+
const hit = (r, t) => t === r || t.startsWith(r + sep);
|
|
778
|
+
// Either canonical form matching is enough — only ONE side may be resolvable.
|
|
779
|
+
return (hit(root.real, target.real) ||
|
|
780
|
+
hit(root.lex, target.lex) ||
|
|
781
|
+
hit(root.real, target.lex) ||
|
|
782
|
+
hit(root.lex, target.real));
|
|
783
|
+
};
|
|
784
|
+
let dest;
|
|
785
|
+
try {
|
|
786
|
+
dest = await resolveModelsDirWithBases();
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
789
|
+
return { inRoots: undefined };
|
|
790
|
+
}
|
|
791
|
+
if (!isLiveAuthoritativeModelsDir(dest.source))
|
|
792
|
+
return { inRoots: undefined };
|
|
793
|
+
const liveRoot = resolve(dest.modelsDir);
|
|
794
|
+
// A negative answer is only honest when everything it rests on could actually be
|
|
795
|
+
// canonicalized; otherwise say UNKNOWN rather than accuse a correct placement.
|
|
796
|
+
let fullyCanonical = target.ok;
|
|
797
|
+
const primary = await canon(dest.modelsDir);
|
|
798
|
+
fullyCanonical = fullyCanonical && primary.ok;
|
|
799
|
+
if (under(primary))
|
|
800
|
+
return { inRoots: true, liveRoot };
|
|
801
|
+
let extra;
|
|
802
|
+
try {
|
|
803
|
+
extra = await getLiveExtraModelRoots(dest.snapshot);
|
|
804
|
+
}
|
|
805
|
+
catch {
|
|
806
|
+
return { inRoots: undefined, liveRoot };
|
|
807
|
+
}
|
|
808
|
+
const wantCat = (category ?? "").trim().toLowerCase();
|
|
809
|
+
for (const r of extra.roots) {
|
|
810
|
+
if (wantCat && String(r.category ?? "").trim().toLowerCase() !== wantCat)
|
|
811
|
+
continue;
|
|
812
|
+
const rc = await canon(r.dir);
|
|
813
|
+
fullyCanonical = fullyCanonical && rc.ok;
|
|
814
|
+
if (under(rc))
|
|
815
|
+
return { inRoots: true, liveRoot };
|
|
816
|
+
}
|
|
817
|
+
// The live roots are known and this path is in none of them.
|
|
818
|
+
if (!extra.authoritative)
|
|
819
|
+
return { inRoots: undefined, liveRoot };
|
|
820
|
+
return { inRoots: fullyCanonical ? false : undefined, liveRoot };
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* The models root the connected server reads RIGHT NOW — and ONLY when that answer
|
|
824
|
+
* is LIVE-AUTHORITATIVE. A transient `/system_stats` outage makes the resolver fall
|
|
825
|
+
* back to COMFYUI_PATH, and reporting THAT as "what the server reads now" would
|
|
826
|
+
* downgrade a correctly verified download with a false "a DIFFERENT install"
|
|
827
|
+
* warning (codex gate, round 12). Unknown is the honest answer there. Never throws.
|
|
828
|
+
*/
|
|
829
|
+
/** Do two absolute models roots name the same directory? Windows paths are
|
|
830
|
+
* case-insensitive and mix separators, so comparing them raw would report a
|
|
831
|
+
* server "change" that never happened. */
|
|
832
|
+
export function sameModelsRoot(a, b) {
|
|
833
|
+
const norm = (s) => {
|
|
834
|
+
const slashed = s.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
835
|
+
return platform() === "win32" ? slashed.toLowerCase() : slashed;
|
|
836
|
+
};
|
|
837
|
+
return norm(a) === norm(b);
|
|
838
|
+
}
|
|
839
|
+
export async function currentLiveModelsRoot() {
|
|
840
|
+
try {
|
|
841
|
+
const dest = await resolveModelsDirWithBases();
|
|
842
|
+
if (!isLiveAuthoritativeModelsDir(dest.source))
|
|
843
|
+
return undefined;
|
|
844
|
+
return resolve(dest.modelsDir);
|
|
845
|
+
}
|
|
846
|
+
catch {
|
|
847
|
+
return undefined;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Why a destination could not be confirmed, and how the USER can upgrade themselves
|
|
852
|
+
* into a verified answer.
|
|
853
|
+
*
|
|
854
|
+
* Saying "unconfirmed" without saying WHY leaves the user with nothing to act on.
|
|
855
|
+
* There are exactly two reasons the live root stays unpinnable: the running server
|
|
856
|
+
* reported a relative `main.py` with no working directory (so its own report is not
|
|
857
|
+
* enough), and the OS process table could not be read to identify the process on our
|
|
858
|
+
* port. The second is a TOOLING gap on POSIX — `lsof` is simply not installed on
|
|
859
|
+
* many minimal images — and installing it converts this host into a verifiable one.
|
|
860
|
+
*/
|
|
861
|
+
function unverifiableDestinationRemedy() {
|
|
862
|
+
const probe = platform() === "win32"
|
|
863
|
+
? "the process listening on the ComfyUI port could not be identified"
|
|
864
|
+
: "the process listening on the ComfyUI port could not be identified (this needs `lsof`, " +
|
|
865
|
+
"which is missing on many minimal images — installing it makes future downloads verifiable)";
|
|
866
|
+
return ("This happens when the running ComfyUI reports a RELATIVE main.py with no working " +
|
|
867
|
+
`directory AND ${probe}. To get a definite answer: point COMFYUI_PATH at the ComfyUI ` +
|
|
868
|
+
"that is actually running, launch it with an absolute --base-directory, or make its " +
|
|
869
|
+
"process observable. Meanwhile, confirm with list_local_models.");
|
|
870
|
+
}
|
|
871
|
+
/** How many times we re-ask the live server before concluding a landed file is
|
|
872
|
+
* invisible to it. ComfyUI caches its folder listings and invalidates on the
|
|
873
|
+
* directory mtime, so the first re-read normally already sees the new file; the
|
|
874
|
+
* retries only cover a filesystem whose mtime lands a beat late. */
|
|
875
|
+
const LIVE_VISIBILITY_ATTEMPTS = 3;
|
|
876
|
+
const LIVE_VISIBILITY_RETRY_MS = 1000;
|
|
877
|
+
/**
|
|
878
|
+
* Confirm, AFTER a download lands, that the bytes are (a) really on disk and
|
|
879
|
+
* (b) somewhere the LIVE server reads from — then report the path we actually
|
|
880
|
+
* confirmed. Reporting the INTENDED path is what made #369 a fabricated success:
|
|
881
|
+
* `download_status` said "done … landed at <stale install>" while the running
|
|
882
|
+
* ComfyUI could not see the file at all.
|
|
883
|
+
*
|
|
884
|
+
* Never throws: a completed transfer must not be turned into a failure by a
|
|
885
|
+
* verification hiccup. An inconclusive answer is reported as `unknown` with the
|
|
886
|
+
* reason, which callers surface instead of an unqualified success.
|
|
887
|
+
*/
|
|
888
|
+
export async function verifyLandedModel(targetPath, targetSubfolder, opts) {
|
|
889
|
+
let verifiedPath;
|
|
890
|
+
try {
|
|
891
|
+
const info = await stat(targetPath);
|
|
892
|
+
if (!info.isFile()) {
|
|
893
|
+
return {
|
|
894
|
+
liveVisible: "unknown",
|
|
895
|
+
note: `${targetPath} exists but is not a file.`,
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
try {
|
|
899
|
+
verifiedPath = resolve(await realpath(targetPath));
|
|
900
|
+
}
|
|
901
|
+
catch {
|
|
902
|
+
verifiedPath = resolve(targetPath);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
catch {
|
|
906
|
+
return {
|
|
907
|
+
liveVisible: "unknown",
|
|
908
|
+
note: `The file could not be confirmed on disk at ${targetPath}.`,
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
if (isRemoteMode()) {
|
|
912
|
+
return {
|
|
913
|
+
verifiedPath,
|
|
914
|
+
liveVisible: "unknown",
|
|
915
|
+
note: "The connected ComfyUI is remote, so local on-disk placement cannot be checked against it.",
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
const category = categoryOf(targetSubfolder);
|
|
919
|
+
// Match the FULL path the server would report relative to the category, not the
|
|
920
|
+
// bare filename: `/models/loras` lists nested entries as "a/foo.safetensors", so a
|
|
921
|
+
// basename comparison would call a download into `loras/a` "visible" because the
|
|
922
|
+
// LIVE tree happens to hold an unrelated `loras/b/foo.safetensors` — a direct
|
|
923
|
+
// false success (codex gate, round 2). Separators are normalized because ComfyUI
|
|
924
|
+
// reports OS-native ones.
|
|
925
|
+
const wanted = listingEntryFor(targetSubfolder, basename(targetPath));
|
|
926
|
+
const attempts = opts?.attempts ?? LIVE_VISIBILITY_ATTEMPTS;
|
|
927
|
+
const retryMs = opts?.retryMs ?? LIVE_VISIBILITY_RETRY_MS;
|
|
928
|
+
// Is the directory we wrote into one the RUNNING server told us about? Only then
|
|
929
|
+
// does "the server lists this name" prove that the bytes we just wrote are the
|
|
930
|
+
// ones it will load. On a locally-configured root a pre-existing entry of the
|
|
931
|
+
// same name in the LIVE tree would otherwise verify our write into a STALE tree
|
|
932
|
+
// as a success (codex gate, round 3) — so a name that was already listed BEFORE
|
|
933
|
+
// the download proves nothing there.
|
|
934
|
+
// Is the file inside a tree the RUNNING server actually reads? `true` makes the
|
|
935
|
+
// listing decisive; `false` means it demonstrably is not (a ComfyUI restart onto
|
|
936
|
+
// a DIFFERENT install between the write and this check produces exactly that —
|
|
937
|
+
// codex gate, round 4); `undefined` means only local configuration could answer,
|
|
938
|
+
// so a pre-existing listing entry proves nothing about OUR bytes.
|
|
939
|
+
const landed = verifiedPath ?? resolve(targetPath);
|
|
940
|
+
const membership = await isUnderLiveModelRoots(landed, category);
|
|
941
|
+
const destinationIsLiveAuthoritative = membership.inRoots === true;
|
|
942
|
+
if (membership.inRoots === false) {
|
|
943
|
+
const liveRoot = membership.liveRoot;
|
|
944
|
+
return {
|
|
945
|
+
verifiedPath,
|
|
946
|
+
verifiedAgainstRoot: liveRoot,
|
|
947
|
+
liveVisible: "not-visible",
|
|
948
|
+
note: `The file IS on disk at ${landed}, but that is OUTSIDE every directory the ` +
|
|
949
|
+
`connected ComfyUI (${getComfyUIBaseUrl()}) reads models from` +
|
|
950
|
+
(liveRoot ? ` (its models directory is ${liveRoot})` : "") +
|
|
951
|
+
". This happens when the running server is replaced by a different install " +
|
|
952
|
+
"while a download is in flight. Move the file into the running server's models " +
|
|
953
|
+
"tree, or re-download now that the correct server is connected.",
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
// A listing hit only PROVES something about the bytes we just wrote when the
|
|
957
|
+
// directory we wrote to is one the RUNNING server told us it reads. On any other
|
|
958
|
+
// destination the listing describes a tree we cannot tie to this write:
|
|
959
|
+
// - the name may have been there all along (round 3);
|
|
960
|
+
// - the pre-write listing may have been unavailable, so "it appeared" is not
|
|
961
|
+
// established (round 14);
|
|
962
|
+
// - the server answering AFTER the write may not even be the one that answered
|
|
963
|
+
// BEFORE it, and its own same-named model then masquerades as ours (round 17).
|
|
964
|
+
// Each of those was a separate route to a fabricated success, and every fix that
|
|
965
|
+
// stopped short of this rule left another. So: a non-authoritative destination is
|
|
966
|
+
// reported HONESTLY as unconfirmed. It costs a noisier message on hosts where the
|
|
967
|
+
// live root cannot be pinned; it never refuses, never fails, and never lies.
|
|
968
|
+
const ambiguous = !destinationIsLiveAuthoritative;
|
|
969
|
+
let sawListing = false;
|
|
970
|
+
for (let i = 0; i < attempts; i++) {
|
|
971
|
+
const listing = await liveCategoryListing(category);
|
|
972
|
+
if (listing !== undefined) {
|
|
973
|
+
sawListing = true;
|
|
974
|
+
if (listing.some((n) => normRel(n) === wanted)) {
|
|
975
|
+
// RE-CHECK membership AFTER the listing. The root check and the listing are
|
|
976
|
+
// two separate observations; a ComfyUI restart onto a DIFFERENT install
|
|
977
|
+
// between them would otherwise let the NEW server's own same-named model
|
|
978
|
+
// confirm OUR file, which that server cannot read (codex gate, round 11).
|
|
979
|
+
const still = await isUnderLiveModelRoots(landed, category);
|
|
980
|
+
if (still.inRoots === false) {
|
|
981
|
+
return {
|
|
982
|
+
verifiedPath,
|
|
983
|
+
// Stamp the root THIS check ran against — never a third, later
|
|
984
|
+
// observation, which could name yet another server (codex gate, r12).
|
|
985
|
+
verifiedAgainstRoot: still.liveRoot,
|
|
986
|
+
liveVisible: "not-visible",
|
|
987
|
+
note: `The file IS on disk at ${landed}, but the connected ComfyUI ` +
|
|
988
|
+
`(${getComfyUIBaseUrl()}) changed while this was being checked and no longer ` +
|
|
989
|
+
"reads from that location — the entry it lists is its OWN file of the same name. " +
|
|
990
|
+
"Re-download now that the correct server is connected.",
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
if (!ambiguous) {
|
|
994
|
+
return {
|
|
995
|
+
verifiedPath,
|
|
996
|
+
// The root the POST-listing membership check just validated against —
|
|
997
|
+
// taking a fresh observation here could stamp a server that replaced it
|
|
998
|
+
// between the two awaits (codex gate, round 12).
|
|
999
|
+
verifiedAgainstRoot: still.liveRoot ?? membership.liveRoot,
|
|
1000
|
+
liveVisible: "visible",
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
return {
|
|
1004
|
+
verifiedPath,
|
|
1005
|
+
liveVisible: "unknown",
|
|
1006
|
+
note: `The connected ComfyUI (${getComfyUIBaseUrl()}) lists "${wanted}" under "${category}", ` +
|
|
1007
|
+
"but this destination came from local configuration rather than from the running " +
|
|
1008
|
+
"server, so that listing cannot be tied to the file just written to " +
|
|
1009
|
+
`${verifiedPath} — it may be the server's own copy elsewhere` +
|
|
1010
|
+
(opts?.listedBefore === true
|
|
1011
|
+
? " (it already listed that name before this download)"
|
|
1012
|
+
: opts?.listedBefore === undefined
|
|
1013
|
+
? " (whether it listed that name before this download could not be checked)"
|
|
1014
|
+
: "") +
|
|
1015
|
+
". " +
|
|
1016
|
+
unverifiableDestinationRemedy(),
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
if (i < attempts - 1 && retryMs > 0) {
|
|
1021
|
+
await new Promise((r) => setTimeout(r, retryMs));
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
if (!sawListing) {
|
|
1025
|
+
return {
|
|
1026
|
+
verifiedPath,
|
|
1027
|
+
liveVisible: "unknown",
|
|
1028
|
+
note: `The connected ComfyUI (${getComfyUIBaseUrl()}) did not answer for the ` +
|
|
1029
|
+
`"${category}" model folder, so it could not be confirmed that it reads from this location.`,
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
let liveModelsDir;
|
|
1033
|
+
try {
|
|
1034
|
+
liveModelsDir = await resolveModelsDir();
|
|
1035
|
+
}
|
|
1036
|
+
catch {
|
|
1037
|
+
liveModelsDir = undefined;
|
|
1038
|
+
}
|
|
1039
|
+
return {
|
|
1040
|
+
verifiedPath,
|
|
1041
|
+
liveVisible: "not-visible",
|
|
1042
|
+
note: `The file IS on disk at ${verifiedPath}, but the connected ComfyUI ` +
|
|
1043
|
+
`(${getComfyUIBaseUrl()}) does NOT list "${wanted}" under "${category}" — it will not be ` +
|
|
1044
|
+
`usable in a workflow from there.` +
|
|
1045
|
+
(liveModelsDir ? ` The models directory that server reads is ${liveModelsDir}.` : "") +
|
|
1046
|
+
" Move the file into the running server's models tree (or point COMFYUI_PATH at that install and re-download).",
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
506
1049
|
/**
|
|
507
1050
|
* Like resolveModelSubfolder, but roots the destination at the CONNECTED
|
|
508
1051
|
* server's real models directory (its `--base-directory`/models, read from
|
|
@@ -514,6 +1057,19 @@ export function resolveModelSubfolder(targetSubfolder) {
|
|
|
514
1057
|
* same containment guard as the sync variant.
|
|
515
1058
|
*/
|
|
516
1059
|
export async function resolveModelSubfolderPreferServer(targetSubfolder) {
|
|
1060
|
+
return (await resolveModelSubfolderWithLiveRoot(targetSubfolder)).targetDir;
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* As `resolveModelSubfolderPreferServer`, but also returns the LIVE models root that
|
|
1064
|
+
* THIS resolution used — when it was live-authoritative.
|
|
1065
|
+
*
|
|
1066
|
+
* The writer must bind its destination to the server the destination was computed
|
|
1067
|
+
* for. Taking a separate observation afterwards leaves a window in which the server
|
|
1068
|
+
* is replaced BETWEEN the resolution and the capture, so both later observations
|
|
1069
|
+
* agree with each other while the target still points into the previous install
|
|
1070
|
+
* (codex gate, round 18). Returning it from the same call closes that window.
|
|
1071
|
+
*/
|
|
1072
|
+
export async function resolveModelSubfolderWithLiveRoot(targetSubfolder) {
|
|
517
1073
|
const raw = (targetSubfolder ?? "").trim();
|
|
518
1074
|
if (!raw) {
|
|
519
1075
|
throw new ModelError("target_subfolder is required (e.g. 'loras', 'checkpoints').");
|
|
@@ -525,12 +1081,18 @@ export async function resolveModelSubfolderPreferServer(targetSubfolder) {
|
|
|
525
1081
|
// code-root veto needs, AND the snapshot the escape-authorizer uses — so they can
|
|
526
1082
|
// never disagree (a second stats call could straddle a server restart and combine
|
|
527
1083
|
// model roots from server B with code bases from server A; #633 codex).
|
|
528
|
-
const { modelsDir, baseDirs, snapshot } = await resolveModelsDirWithBases();
|
|
1084
|
+
const { modelsDir, baseDirs, snapshot, source } = await resolveModelsDirWithBases();
|
|
529
1085
|
const modelsRoot = resolve(modelsDir);
|
|
530
1086
|
const targetDir = resolve(modelsRoot, raw);
|
|
531
1087
|
if (targetDir !== modelsRoot && !targetDir.startsWith(modelsRoot + sep)) {
|
|
532
1088
|
throw new ModelError(`Refusing to write outside the models directory: ${raw}`);
|
|
533
1089
|
}
|
|
1090
|
+
// The root did NOT come from the running server (it could not tell us where it
|
|
1091
|
+
// lives, so we fell back to local config). Before writing, check the one thing
|
|
1092
|
+
// that can still expose a stale-install destination: the server's own listing for
|
|
1093
|
+
// this category. #369 shipped 4.88 GB into a directory the live ComfyUI had never
|
|
1094
|
+
// heard of; refusing here costs nothing and saves the transfer.
|
|
1095
|
+
await assertDestinationVisibleToLiveServer(modelsRoot, raw, source, snapshot);
|
|
534
1096
|
// Path-string containment (above) is not enough: an EXISTING symlink somewhere
|
|
535
1097
|
// between modelsRoot and targetDir could redirect the real write OUTSIDE the
|
|
536
1098
|
// models dir. Because THIS resolver is the single canonical write-target for
|
|
@@ -539,7 +1101,10 @@ export async function resolveModelSubfolderPreferServer(targetSubfolder) {
|
|
|
539
1101
|
// so it can never diverge from a caller's separate pre-validation (e.g.
|
|
540
1102
|
// apply_manifest resolving the root a second time; #490 codex review).
|
|
541
1103
|
await assertNoEscapingSymlinkAncestor(modelsRoot, targetDir, raw, baseDirs, snapshot);
|
|
542
|
-
return
|
|
1104
|
+
return {
|
|
1105
|
+
targetDir,
|
|
1106
|
+
liveRootAtResolve: isLiveAuthoritativeModelsDir(source) ? modelsRoot : undefined,
|
|
1107
|
+
};
|
|
543
1108
|
}
|
|
544
1109
|
/**
|
|
545
1110
|
* Enforce that a local model download can ONLY land where the running ComfyUI
|
|
@@ -762,7 +1327,7 @@ liveSnapshot = { reachable: false }) {
|
|
|
762
1327
|
* "..". Throws ModelError on an invalid subfolder or filename.
|
|
763
1328
|
*/
|
|
764
1329
|
export async function resolveDownloadTarget(url, targetSubfolder, filename) {
|
|
765
|
-
const targetDir = await
|
|
1330
|
+
const { targetDir, liveRootAtResolve } = await resolveModelSubfolderWithLiveRoot(targetSubfolder);
|
|
766
1331
|
const rawFilename = filename ?? (basename(new URL(url).pathname) || "model.safetensors");
|
|
767
1332
|
const resolvedFilename = basename(rawFilename);
|
|
768
1333
|
if (resolvedFilename !== rawFilename ||
|
|
@@ -775,7 +1340,7 @@ export async function resolveDownloadTarget(url, targetSubfolder, filename) {
|
|
|
775
1340
|
if (!resolve(targetPath).startsWith(resolve(targetDir) + sep)) {
|
|
776
1341
|
throw new ModelError("Refusing to write outside the target model directory.", { filename: rawFilename });
|
|
777
1342
|
}
|
|
778
|
-
return { targetDir, filename: resolvedFilename, targetPath };
|
|
1343
|
+
return { targetDir, filename: resolvedFilename, targetPath, liveRootAtResolve };
|
|
779
1344
|
}
|
|
780
1345
|
/**
|
|
781
1346
|
* Resolve a relative-to-models path against a known root, keeping the result
|
|
@@ -1123,14 +1688,18 @@ export async function shouldDispatchDownloadToManager() {
|
|
|
1123
1688
|
// opaque/relative argv we can't resolve).
|
|
1124
1689
|
if (parseModelsDirFromArgv(argv, cwd))
|
|
1125
1690
|
return false;
|
|
1126
|
-
// Derive the server's own install root
|
|
1127
|
-
//
|
|
1128
|
-
//
|
|
1129
|
-
//
|
|
1130
|
-
//
|
|
1131
|
-
// isn't
|
|
1132
|
-
//
|
|
1133
|
-
|
|
1691
|
+
// Derive the server's own install root through the ONE canonical resolver — the
|
|
1692
|
+
// SAME notion resolveModelsDirWithBases writes into, so this predicate can never
|
|
1693
|
+
// disagree with the destination (#369). It covers the relative-`main.py`-no-cwd
|
|
1694
|
+
// shape (Desktop / Windows portable) that the old argv-only check could not
|
|
1695
|
+
// resolve, which needlessly bounced those installs through the Manager (and then
|
|
1696
|
+
// failed outright when Manager isn't installed). Only treat the root as a LOCAL
|
|
1697
|
+
// stream target when it ACTUALLY EXISTS on this filesystem: a loopback ComfyUI
|
|
1698
|
+
// inside Docker / behind an SSH port-forward reports a container-side path that
|
|
1699
|
+
// is NOT the host's, so writing there would create a bogus host directory
|
|
1700
|
+
// instead of reaching the server. When it isn't locally present, hand the fetch
|
|
1701
|
+
// to the connected Manager (server-side write), which lands correctly regardless.
|
|
1702
|
+
const liveRoot = resolveLiveServerRoot(argv, cwd, { remote: false }).root;
|
|
1134
1703
|
if (liveRoot && existsSync(liveRoot))
|
|
1135
1704
|
return false;
|
|
1136
1705
|
return true;
|
|
@@ -1204,10 +1773,40 @@ onLanded) {
|
|
|
1204
1773
|
// not blindly at COMFYUI_PATH/models — otherwise a Desktop install downloads
|
|
1205
1774
|
// into a stale checkout the running server never reads (#346/#369). Resolution
|
|
1206
1775
|
// + filename validation go through the SHARED resolveDownloadTarget so the
|
|
1207
|
-
// background job registry keys jobs by the exact same targetPath
|
|
1208
|
-
|
|
1776
|
+
// background job registry keys jobs by the exact same targetPath.
|
|
1777
|
+
//
|
|
1778
|
+
// DELIBERATE: this RE-RESOLVES rather than reusing the target startDownloadJob
|
|
1779
|
+
// keyed on. If the live server was replaced in between (a restart onto the same
|
|
1780
|
+
// port serving a DIFFERENT install), the freshly resolved root is the one the
|
|
1781
|
+
// server now reads, and the stale key is the one that would put the file where
|
|
1782
|
+
// nobody looks — so the CURRENT destination wins over a stable key. `onLanded`
|
|
1783
|
+
// reports the real path back to the job, and verifyLandedModel confirms it, so
|
|
1784
|
+
// nothing is ever reported at the stale path. Accepted residual (disclosed):
|
|
1785
|
+
// across such a restart the job's serialization key can name the previous
|
|
1786
|
+
// destination, so a concurrent same-file request may run a duplicate transfer.
|
|
1787
|
+
// That is bounded by the existing per-writer O_EXCL temp + atomic rename (#467) —
|
|
1788
|
+
// a duplicate download, never a corrupt file — and is strictly preferable to
|
|
1789
|
+
// writing into an install the server no longer serves.
|
|
1790
|
+
const { targetDir, targetPath, filename: resolvedFilename,
|
|
1791
|
+
// BIND the destination to the server it was resolved for. Re-resolving is right,
|
|
1792
|
+
// but the answer must not go stale between resolving it and writing: a ComfyUI
|
|
1793
|
+
// replaced during the mkdir would otherwise take the bytes into the root the
|
|
1794
|
+
// PREVIOUS server read. This root comes from the SAME resolution that produced
|
|
1795
|
+
// targetPath — capturing it separately afterwards would leave a window in which
|
|
1796
|
+
// the swap happens BEFORE the capture, so both later observations agree while the
|
|
1797
|
+
// target still points into the old install (codex gate, round 18). Both roots
|
|
1798
|
+
// must be live-authoritative to compare; when either is unknown there is nothing
|
|
1799
|
+
// to bind, and the (already never-confirmed) non-authoritative path applies.
|
|
1800
|
+
liveRootAtResolve: rootAtResolve, } = await resolveDownloadTarget(url, targetSubfolder, filename);
|
|
1209
1801
|
// Ensure target directory exists
|
|
1210
1802
|
await mkdir(targetDir, { recursive: true });
|
|
1803
|
+
const rootBeforeWrite = await currentLiveModelsRoot();
|
|
1804
|
+
if (rootAtResolve && rootBeforeWrite && !sameModelsRoot(rootAtResolve, rootBeforeWrite)) {
|
|
1805
|
+
throw new ModelError(`Refusing to start this download: the connected ComfyUI changed while the destination was ` +
|
|
1806
|
+
`being prepared. It read "${rootAtResolve}" when the destination was resolved and reads ` +
|
|
1807
|
+
`"${rootBeforeWrite}" now, so "${targetPath}" is no longer where the running server ` +
|
|
1808
|
+
"looks. Re-issue the download now that the current server is connected.");
|
|
1809
|
+
}
|
|
1211
1810
|
const request = applyDownloadAuth(url, auth);
|
|
1212
1811
|
const headers = { ...request.headers };
|
|
1213
1812
|
// HF token: attach ONLY when the url's PARSED hostname is huggingface.co (or a subdomain),
|