diff --git a/comfy_cli/command/templates.py b/comfy_cli/command/templates.py index 578ff58f6..303f94b56 100644 --- a/comfy_cli/command/templates.py +++ b/comfy_cli/command/templates.py @@ -21,10 +21,12 @@ import os import subprocess import sys +import threading import time import urllib.error import urllib.parse import urllib.request +from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from typing import Annotated, Any @@ -575,7 +577,37 @@ def ls_cmd( ] = None, refresh: Annotated[ bool, - typer.Option("--refresh", help="Re-fetch index.json from GitHub before listing."), + typer.Option( + "--refresh", help="Re-fetch index.json (and, with --runnable, the template workflows) before listing." + ), + ] = False, + local_only: Annotated[ + bool, + typer.Option( + "--local-only", + help=( + "Index-only pre-filter: drop templates that need partner-API access " + "(name starts with `api_`, or an `API` tag). Zero network beyond the " + "index. NOTE: a known tail of community templates with unmarked API " + "nodes still leaks through until the workflow_templates tag backfill " + "lands — use --runnable to catch those via object_info." + ), + ), + ] = False, + runnable: Annotated[ + bool, + typer.Option( + "--runnable", + help=( + "Assess runnability on THIS install: applies the --local-only " + "pre-filter, then fetches each surviving template's workflow (cached " + "under gallery/templates/) and annotates every row with a `verdict` " + "(runnable / missing-models / api-required / unknown) and " + "`missing_count`. Needs a local ComfyUI server to list installed " + "models; the first run downloads ~300 small JSON files, later runs " + "hit the cache." + ), + ), ] = False, select: Annotated[ str | None, @@ -619,9 +651,49 @@ def ls_cmd( ) ] matched = len(rows) + + # `--runnable` implies the `--local-only` index pre-filter (drop templates + # that need partner-API access before spending any network on workflows). + if local_only or runnable: + rows = [r for r in rows if not _template_is_api_by_index(r["name"], r.get("tags") or [])] + + # `--runnable`: fetch + check every surviving row (batched), annotating each + # with a verdict. Runs on the full post-pre-filter set so the cache warms and + # the counts are stable; `--limit` is applied to the result below. + checked: dict[str, dict[str, Any]] = {} + if runnable: + checked = _batch_check_rows(renderer, rows, refresh=refresh) + + # `--limit` applies AFTER all filtering (flag filters + the local-only + # pre-filter) so a small limit never truncates ahead of the pre-filter and + # under-fills the result. if limit is not None: rows = rows[: max(0, limit)] + def _row_out(r: dict[str, Any]) -> dict[str, Any]: + out = { + "name": r["name"], + "title": r["title"], + "output_type": r["output_type"], + "category_title": r["category_title"], + "tags": r["tags"], + "models": r["models"], + "providers": r["providers"], + "description": r["description"][:120], + } + if runnable: + res = checked.get(r["name"]) + out["verdict"] = res["verdict"] if res else "unknown" + out["missing_count"] = len(res["models"]["missing"]) if res else 0 + return out + + ls_warnings: list[str] = [] + if runnable: + for r in rows: + res = checked.get(r["name"]) + if res: + ls_warnings.extend(res.get("warnings") or []) + payload = { "total_in_gallery": total, "matched": matched, @@ -634,20 +706,13 @@ def ls_cmd( "provider": provider, "name": name_sub, }, - "rows": [ - { - "name": r["name"], - "title": r["title"], - "output_type": r["output_type"], - "category_title": r["category_title"], - "tags": r["tags"], - "models": r["models"], - "providers": r["providers"], - "description": r["description"][:120], - } - for r in rows - ], + "rows": [_row_out(r) for r in rows], } + if local_only or runnable: + payload["local_only"] = True + if runnable: + payload["runnable"] = True + payload["warnings"] = ls_warnings if select is not None: from comfy_cli.selector import emit_selected @@ -665,16 +730,38 @@ def ls_cmd( tbl.add_column("type", style="dim") tbl.add_column("title") tbl.add_column("tags", style="dim") + if runnable: + tbl.add_column("verdict") + tbl.add_column("missing", style="dim") + _verdict_color = { + "runnable": "green", + "missing-models": "yellow", + "api-required": "red", + "unknown": "dim", + } for r in rows: - tbl.add_row( + cells = [ r["name"], r["output_type"], r["title"] or "(untitled)", ", ".join(r["tags"]), - ) + ] + if runnable: + res = checked.get(r["name"]) + verdict = res["verdict"] if res else "unknown" + mc = len(res["models"]["missing"]) if res else 0 + color = _verdict_color.get(verdict, "") + cells.append(f"[{color}]{verdict}[/{color}]" if color else verdict) + cells.append(str(mc)) + tbl.add_row(*cells) renderer.console().print(tbl) tail = f" (of {matched} matched, {total} in gallery)" if (matched != len(rows) or matched != total) else "" rprint(f"[dim]{len(rows)} template(s){tail}[/dim]") + if runnable and ls_warnings: + from rich.markup import escape + + for w in ls_warnings: + rprint(f" [yellow]⚠[/yellow] {escape(w)}") knowledge.attach( payload, command="templates ls", @@ -1365,6 +1452,300 @@ def _compute_verdict(*, api_dependent: bool, missing: list, required_count: int, return "unknown" if loaderish else "runnable" +def _persist_template_workflow(cache_path: Path, body: bytes) -> None: + """Atomically write a fetched per-template workflow JSON into its cache slot. + + Best-effort: a non-writable cache dir (read-only image, full disk) must never + fail the caller once we already hold valid data. The temp filename carries + both the pid *and* the thread id so the ``ls --runnable`` ThreadPool can't + collide two concurrent writers, and a partial temp file is unlinked on error + so the read path never trusts a truncated body on the next run. + """ + try: + cache_path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = cache_path.parent / f"{cache_path.name}.{os.getpid()}.{threading.get_ident()}.tmp" + try: + tmp_path.write_bytes(body) + os.replace(tmp_path, cache_path) + except OSError: + tmp_path.unlink(missing_ok=True) + raise + except OSError: + pass + + +def _parse_workflow_body(body: bytes) -> tuple[dict[str, Any] | None, str | None]: + """Decode a per-template workflow body into ``(workflow, error)``. + + ``error`` is a human string when the body is not a JSON object (malformed, + non-UTF-8, or a JSON non-object) — the batch ``ls --runnable`` path turns + that into a per-template ``unknown`` verdict rather than aborting the listing. + """ + try: + wf = json.loads(body) + except (json.JSONDecodeError, UnicodeDecodeError) as e: + return None, f"workflow is not valid JSON: {e}" + if not isinstance(wf, dict): + return None, "workflow is not a JSON object" + return wf, None + + +def _fetch_and_cache_workflow(name: str) -> tuple[dict[str, Any] | None, str | None]: + """Fetch one template's workflow, cache it, and parse it — for the batch path. + + Returns ``(workflow, error)``; every failure (network, non-200, over-cap + body, bad JSON) is caught and returned as an ``error`` string so a single + template's failure degrades to ``unknown`` and never aborts the whole + ``ls --runnable`` run (or, in the ThreadPool, propagates out of a worker). + """ + try: + body = _fetch_template_workflow(name) + except (urllib.error.HTTPError, urllib.error.URLError, OSError, RuntimeError, ResponseTooLarge) as e: + return None, f"failed to fetch workflow: {e}" + _persist_template_workflow(_template_workflow_cache_path(name), body) + return _parse_workflow_body(body) + + +def _batch_load_workflows(names: list[str], *, refresh: bool) -> dict[str, tuple[dict[str, Any] | None, str | None]]: + """Resolve each template's workflow, reusing the phase-1 per-template cache and + fetching misses concurrently. + + Returns ``{name: (workflow, error)}``. A cache hit (unless ``refresh``) is a + plain synchronous read; misses are fetched through a bounded + ``ThreadPoolExecutor`` (``max_workers=8``) so a cold first run over ~300 small + JSON files is tolerable while a warmed cache is all local reads. ``refresh`` + bypasses the cache read so ``--refresh`` re-fetches workflows as well as the + index. + """ + results: dict[str, tuple[dict[str, Any] | None, str | None]] = {} + to_fetch: list[str] = [] + for name in names: + cache_path = _template_workflow_cache_path(name) + body: bytes | None = None + if not refresh and cache_path.exists(): + try: + body = cache_path.read_bytes() + except OSError: + body = None + if body is not None: + results[name] = _parse_workflow_body(body) + else: + to_fetch.append(name) + + if to_fetch: + with ThreadPoolExecutor(max_workers=8) as pool: + futures = {pool.submit(_fetch_and_cache_workflow, name): name for name in to_fetch} + for fut in as_completed(futures): + name = futures[fut] + try: + results[name] = fut.result() + except Exception as e: + # A worker crash is one template's `unknown`, never a run abort. + results[name] = (None, f"failed to fetch workflow: {e}") + return results + + +def _list_model_folders(target, directories: list[str]) -> dict[str, list[str] | None]: + """List each distinct model directory ONCE on the local server. + + ``directories`` is deduped by the caller; unaddressable names (empty, or + containing ``..`` / a path separator) are skipped here and left out of the + returned mapping — :func:`_check_template` re-derives the "isn't a valid model + folder" warning for them from the directory string, so listing them would only + waste a request. A folder that 404s maps to ``None`` (custom-node folder). + Server-unreachable errors propagate to the caller. + """ + listings: dict[str, list[str] | None] = {} + for directory in directories: + if not directory or ".." in directory or "/" in directory or "\\" in directory: + continue + listings[directory] = _list_local_folder(target, directory) + return listings + + +def _check_template( + row: dict[str, Any], + wf: dict[str, Any], + listings: dict[str, list[str] | None], + *, + graph: Any = None, +) -> dict[str, Any]: + """Phase-1 runnable verdict for ONE template — the core shared by ``check`` + and ``ls --runnable``. + + * ``row`` is the flattened gallery row (for ``name``/``title``/``tags``/ + ``requires_custom_nodes``). + * ``wf`` is the parsed workflow JSON object. + * ``listings`` maps a model directory name to the local server's file listing + for it (``None`` = the folder 404s / wasn't listable), pre-fetched ONCE per + distinct directory by the caller so a batch run lists each folder a single + time rather than once per template. + * ``graph`` is an optional pre-loaded object_info ``Graph`` for the + object_info API-node tier; ``None`` restricts the API signal to the index + heuristic (name / ``API`` tag). + + Returns the verdict dict the payload/table are built from. It never raises for + a well-formed workflow: an unlistable directory surfaces as ``missing`` plus a + warning, exactly as the single-template ``check`` does. + """ + name = row.get("name") or "" + required = _collect_model_requirements(wf) + node_types = _collect_node_class_types(wf) + + # API dependence: index heuristic, upgraded by object_info when a graph is + # available. Mirrors `check`'s two-tier logic verbatim. + api_dependent = _template_is_api_by_index(name, row.get("tags") or []) + api_source = "index" + api_nodes: list[str] = [] + if graph is not None: + try: + api_nodes = [cls for cls in node_types if (m := graph.node(cls)) is not None and m.is_api_node] + except Exception: + # A partial/failed object_info scan must leave `source` reflecting the + # index heuristic, not claim object_info for a verdict it didn't make. + api_nodes = [] + if api_nodes: + api_source = "object_info" + api_dependent = True + + # Installed intersection against the pre-fetched listings. Warnings are + # per-directory (deduped) and re-derived from the directory string so this is + # a pure function of (row, wf, listings) — no second network call. + warnings: list[str] = [] + present: list[str] = [] + missing: list[dict[str, str]] = [] + warned_dirs: set[str] = set() + for req in required: + directory = req["directory"] + if not directory or ".." in directory or "/" in directory or "\\" in directory: + if directory not in warned_dirs: + warnings.append( + f"model directory {directory!r} isn't a valid model folder — its files are reported missing" + ) + warned_dirs.add(directory) + missing.append(dict(req)) + continue + listing = listings.get(directory) + if listing is None: + if directory not in warned_dirs: + warnings.append( + f"model folder {directory!r} not found on the local server " + f"(custom-node folder?) — its files are reported missing" + ) + warned_dirs.add(directory) + missing.append(dict(req)) + continue + # Normalize BOTH sides: a model ref may itself carry a subfolder. + req_base = _basename(req["name"]) + if any(_basename(entry) == req_base for entry in listing): + present.append(req["name"]) + else: + missing.append(dict(req)) + + custom_nodes_required = list(row.get("requires_custom_nodes") or []) + verdict = _compute_verdict( + api_dependent=api_dependent, + missing=missing, + required_count=len(required), + node_types=node_types, + ) + return { + "name": name, + "title": row.get("title") or "", + "verdict": verdict, + "models": {"required": len(required), "present": present, "missing": missing}, + "api": {"dependent": api_dependent, "source": api_source, "api_nodes": api_nodes}, + "custom_nodes_required": custom_nodes_required, + "warnings": warnings, + } + + +def _unknown_verdict(row: dict[str, Any], reason: str) -> dict[str, Any]: + """A synthetic ``unknown`` verdict for a template whose workflow could not be + fetched or parsed — so a single template's failure degrades to a warning row + in ``ls --runnable`` instead of aborting the whole listing.""" + name = row.get("name") or "" + return { + "name": name, + "title": row.get("title") or "", + "verdict": "unknown", + "models": {"required": 0, "present": [], "missing": []}, + "api": { + "dependent": _template_is_api_by_index(name, row.get("tags") or []), + "source": "index", + "api_nodes": [], + }, + "custom_nodes_required": list(row.get("requires_custom_nodes") or []), + "warnings": [f"could not determine runnability for {name!r}: {reason}"], + } + + +def _batch_check_rows(renderer, rows: list[dict[str, Any]], *, refresh: bool) -> dict[str, dict[str, Any]]: + """Run the phase-1 check over every row, batching the two network fan-outs. + + Returns ``{name: verdict_dict}``. Two batching wins the per-template ``check`` + can't get: workflows are fetched through one bounded ThreadPool with the + phase-1 cache reused, and every distinct model directory across the whole run + is listed on the local server exactly ONCE. + + A single template's fetch/parse failure degrades to a synthetic ``unknown`` + verdict (a warning row), never aborting the listing. An unreachable local + server, by contrast, IS fatal when any surviving template declares model + requirements: ``--runnable`` means "runnable on THIS install", which can't be + answered without the install (``--local-only`` is the index-only escape hatch). + """ + from comfy_cli.target import resolve_target + + row_by_name = {r["name"]: r for r in rows} + names = list(row_by_name) # de-dupes any repeated name, preserves order + loaded = _batch_load_workflows(names, refresh=refresh) + + # Distinct model directories across every successfully-parsed workflow. + distinct_dirs: list[str] = [] + seen_dirs: set[str] = set() + for name in names: + wf, _err = loaded.get(name, (None, "not fetched")) + if wf is None: + continue + for req in _collect_model_requirements(wf): + directory = req["directory"] + if directory not in seen_dirs: + seen_dirs.add(directory) + distinct_dirs.append(directory) + + listings: dict[str, list[str] | None] = {} + if distinct_dirs: + target = resolve_target(where="local") + try: + listings = _list_model_folders(target, distinct_dirs) + except (urllib.error.URLError, OSError, ValueError, json.JSONDecodeError) as e: + renderer.error( + code="server_not_running", + message=f"local ComfyUI server is unreachable, cannot check installed models: {e}", + hint="run `comfy launch` to start a local server, or use --local-only for an index-only filter", + ) + raise typer.Exit(code=1) from e + + # object_info graph once (best-effort) for the API-node tier. + graph = None + try: + from comfy_cli.cql.engine import Graph + + graph = Graph.load(mode="local") + except Exception: + graph = None + + results: dict[str, dict[str, Any]] = {} + for name in names: + wf, err = loaded.get(name, (None, "not fetched")) + row = row_by_name[name] + if wf is None: + results[name] = _unknown_verdict(row, err or "unknown error") + else: + results[name] = _check_template(row, wf, listings, graph=graph) + return results + + @app.command( "check", help=( @@ -1440,17 +1821,7 @@ def check_cmd( # Write atomically: a truncated file (interrupted write / full disk) would # otherwise be trusted by the read path above on the next non-refresh run # and fail `template_workflow_invalid_json` until the user passed --refresh. - try: - cache_path.parent.mkdir(parents=True, exist_ok=True) - tmp_path = cache_path.parent / f"{cache_path.name}.{os.getpid()}.tmp" - try: - tmp_path.write_bytes(body) - os.replace(tmp_path, cache_path) - except OSError: - tmp_path.unlink(missing_ok=True) - raise - except OSError: - pass # a non-writable cache dir must not fail the check + _persist_template_workflow(cache_path, body) try: wf = json.loads(body) @@ -1469,58 +1840,28 @@ def check_cmd( ) raise typer.Exit(code=1) - # 3. Model requirements (top-level + subgraph walk) and node class types. - required = _collect_model_requirements(wf) - node_types = _collect_node_class_types(wf) - - # 4. API dependence. Tier (a) is the index heuristic; tier (b) upgrades it with - # object_info when a local server is reachable (best-effort — a down server - # just leaves the index answer in place). - api_dependent = _template_is_api_by_index(name, match.get("tags") or []) - api_source = "index" - api_nodes: list[str] = [] + # 3. Load the object_info graph once for the API-node tier (best-effort — a + # down server leaves the index heuristic to decide inside _check_template). + graph = None try: from comfy_cli.cql.engine import Graph graph = Graph.load(mode="local") - api_nodes = [cls for cls in node_types if (m := graph.node(cls)) is not None and m.is_api_node] - # Only attribute the signal to object_info when it actually found API nodes; - # an empty scan must leave `source` reflecting the index heuristic, not claim - # object_info as the source of an api-required verdict it didn't produce. - if api_nodes: - api_source = "object_info" - api_dependent = True except Exception: - # Server down / object_info unavailable → index heuristic alone decides. - # Discard any partial object_info result so `source` and `api_nodes` agree. - api_nodes = [] - api_source = "index" + graph = None - # 5. Installed intersection: list each distinct folder once on the local server - # and match required files by basename. - warnings: list[str] = [] - present: list[str] = [] - missing: list[dict[str, str]] = [] + # 4. List each distinct model folder ONCE on the local server so the verdict + # core can intersect required files against what's installed. Unreachable + # server is a hard error for `check` (it can't answer "runnable on THIS + # install" without one) — `ls --runnable` shares _list_model_folders but + # decides its own batch-level handling. + required = _collect_model_requirements(wf) distinct_dirs = list(dict.fromkeys(req["directory"] for req in required)) + listings: dict[str, list[str] | None] = {} if required: target = resolve_target(where="local") - listings: dict[str, list[str] | None] = {} try: - for directory in distinct_dirs: - if not directory or ".." in directory or "/" in directory or "\\" in directory: - # Not addressable as a `/models/` segment — treat as absent. - listings[directory] = None - warnings.append( - f"model directory {directory!r} isn't a valid model folder — its files are reported missing" - ) - continue - folder_files = _list_local_folder(target, directory) - listings[directory] = folder_files - if folder_files is None: - warnings.append( - f"model folder {directory!r} not found on the local server " - f"(custom-node folder?) — its files are reported missing" - ) + listings = _list_model_folders(target, distinct_dirs) except (urllib.error.URLError, OSError, ValueError, json.JSONDecodeError) as e: renderer.error( code="server_not_running", @@ -1529,39 +1870,19 @@ def check_cmd( ) raise typer.Exit(code=1) from e - for req in required: - listing = listings.get(req["directory"]) - # Normalize BOTH sides: a model ref may itself carry a subfolder - # (e.g. ``SDXL/model.safetensors``, as ComfyUI loader widgets emit), - # so compare basenames on the required side too. - req_base = _basename(req["name"]) - if listing and any(_basename(entry) == req_base for entry in listing): - present.append(req["name"]) - else: - missing.append(dict(req)) - - # 6. Custom nodes are report-only in v1 (surfaced verbatim, not verified). - custom_nodes_required = list(match.get("requires_custom_nodes") or []) - - # 7. Verdict. - verdict = _compute_verdict( - api_dependent=api_dependent, - missing=missing, - required_count=len(required), - node_types=node_types, - ) - - # 8. Emit. - payload = { - "name": name, - "title": match["title"], - "verdict": verdict, - "models": {"required": len(required), "present": present, "missing": missing}, - "api": {"dependent": api_dependent, "source": api_source, "api_nodes": api_nodes}, - "custom_nodes_required": custom_nodes_required, - "warnings": warnings, - } - + # 5. Verdict (models intersection + api tier + custom nodes), shared with + # `ls --runnable`. + payload = _check_template(match, wf, listings, graph=graph) + verdict = payload["verdict"] + api_dependent = payload["api"]["dependent"] + api_source = payload["api"]["source"] + api_nodes = payload["api"]["api_nodes"] + present = payload["models"]["present"] + missing = payload["models"]["missing"] + custom_nodes_required = payload["custom_nodes_required"] + warnings = payload["warnings"] + + # 6. Emit. if renderer.is_pretty(): # `name`, node-class names, model names/urls and warnings are all untrusted # (they come from the gallery index / workflow JSON), so escape them before diff --git a/tests/comfy_cli/command/test_templates.py b/tests/comfy_cli/command/test_templates.py index 0fe34eba3..d60784b69 100644 --- a/tests/comfy_cli/command/test_templates.py +++ b/tests/comfy_cli/command/test_templates.py @@ -1517,3 +1517,201 @@ def test_ls_self_heals_from_a_cache_poisoned_by_an_older_build(cache_file, monke assert result.exit_code == 0, result.output assert _envelope(result.output)["data"]["total_in_gallery"] > 0 assert json.loads(cache_file.read_bytes()) == FIXTURE # healed on disk too + + +# --------------------------------------------------------------------------- +# templates ls --local-only / --runnable (BE-3377) +# --------------------------------------------------------------------------- + + +def _count_workflow_fetches(monkeypatch, body_by_name=None, fail_names=None): + """Stub `_fetch_template_workflow` with a call counter. + + `body_by_name` maps name -> bytes (default: a trivial zero-node workflow); + `fail_names` are names whose fetch raises an upstream 404. + """ + import urllib.error + + calls = {"count": 0, "names": []} + fail_names = set(fail_names or []) + + def _impl(name, *args, **kwargs): + calls["count"] += 1 + calls["names"].append(name) + if name in fail_names: + raise urllib.error.HTTPError(url="x", code=404, msg="Not Found", hdrs=None, fp=None) + if body_by_name and name in body_by_name: + return body_by_name[name] + return json.dumps({"nodes": []}).encode() + + monkeypatch.setattr(templates_cmd, "_fetch_template_workflow", _impl) + return calls + + +def _count_folder_listings(monkeypatch, mapping): + """Stub `_list_local_folder` with a per-folder call counter.""" + calls = {"count": 0, "folders": []} + + def _impl(target, folder): + calls["count"] += 1 + calls["folders"].append(folder) + return mapping.get(folder) + + monkeypatch.setattr(templates_cmd, "_list_local_folder", _impl) + return calls + + +def _run_ls(gallery_file, tmp_path, monkeypatch, extra): + # Isolate the on-disk per-template workflow cache so tests never read/write real cache. + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache")) + return CliRunner().invoke(templates_cmd.app, ["ls", "--gallery", gallery_file, *extra]) + + +def test_ls_local_only_drops_api_rows_without_any_fetch(gallery_file, tmp_path, monkeypatch): + _force_json_renderer() + fetches = _count_workflow_fetches(monkeypatch) + + result = _run_ls(gallery_file, tmp_path, monkeypatch, ["--local-only"]) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + names = [r["name"] for r in env["data"]["rows"]] + # image_flux2 + gsc_starter_1 both carry the API tag → dropped; image_z_image stays. + assert names == ["image_z_image"] + # `matched` counts the flag-filter survivors, unchanged by the pre-filter. + assert env["data"]["matched"] == 3 + assert env["data"]["shown"] == 1 + assert env["data"]["local_only"] is True + # Index-only pre-filter: zero network beyond the index. + assert fetches["count"] == 0 + + +def test_ls_runnable_implies_local_only_prefilter(gallery_file, tmp_path, monkeypatch): + _force_json_renderer() + _no_local_server(monkeypatch) # object_info unavailable → index-only API tier + fetches = _count_workflow_fetches(monkeypatch) + _count_folder_listings(monkeypatch, {}) + + result = _run_ls(gallery_file, tmp_path, monkeypatch, ["--runnable"]) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + assert [r["name"] for r in env["data"]["rows"]] == ["image_z_image"] + # Only the non-API survivor's workflow was fetched (API rows never reach the check). + assert fetches["names"] == ["image_z_image"] + row = env["data"]["rows"][0] + assert row["verdict"] == "runnable" # zero models, no loader node → runnable + assert row["missing_count"] == 0 + assert env["data"]["runnable"] is True + + +def test_ls_runnable_cache_hit_skips_fetch(gallery_file, tmp_path, monkeypatch): + _force_json_renderer() + _no_local_server(monkeypatch) + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache")) + # Pre-seed the phase-1 per-template cache for the surviving template. + cache_path = templates_cmd._template_workflow_cache_path("image_z_image") + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_bytes(json.dumps({"nodes": []}).encode()) + fetches = _count_workflow_fetches(monkeypatch) + _count_folder_listings(monkeypatch, {}) + + result = CliRunner().invoke(templates_cmd.app, ["ls", "--gallery", gallery_file, "--runnable"]) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + assert [r["name"] for r in env["data"]["rows"]] == ["image_z_image"] + assert env["data"]["rows"][0]["verdict"] == "runnable" + # Cache hit → no network fetch at all. + assert fetches["count"] == 0 + + +def test_ls_runnable_single_fetch_failure_degrades_to_unknown(gallery_file, tmp_path, monkeypatch): + _force_json_renderer() + _no_local_server(monkeypatch) + _count_workflow_fetches(monkeypatch, fail_names={"image_z_image"}) + _count_folder_listings(monkeypatch, {}) + + result = _run_ls(gallery_file, tmp_path, monkeypatch, ["--runnable"]) + # A single template's fetch failure must NOT abort the listing. + assert result.exit_code == 0, result.output + env = _envelope(result.output) + row = env["data"]["rows"][0] + assert row["name"] == "image_z_image" + assert row["verdict"] == "unknown" + assert row["missing_count"] == 0 + assert any("image_z_image" in w for w in env["data"]["warnings"]) + + +def test_ls_runnable_lists_each_directory_once(tmp_path, monkeypatch): + # Two surviving templates that BOTH reference the `checkpoints` folder: it must + # be listed exactly once for the whole run, not once per template. + fixture = [ + { + "moduleName": "default", + "category": "GEN", + "title": "Image", + "type": "image", + "templates": [ + {"name": "local_a", "title": "A", "tags": ["Local"], "models": []}, + {"name": "local_b", "title": "B", "tags": ["Local"], "models": []}, + ], + } + ] + gf = tmp_path / "idx.json" + gf.write_text(json.dumps(fixture)) + _force_json_renderer() + _no_local_server(monkeypatch) + wf = json.dumps(_TOP_LEVEL_WF).encode() # references checkpoints/v1-5-pruned-emaonly + _count_workflow_fetches(monkeypatch, body_by_name={"local_a": wf, "local_b": wf}) + listings = _count_folder_listings(monkeypatch, {"checkpoints": ["v1-5-pruned-emaonly.safetensors"]}) + + result = _run_ls(str(gf), tmp_path, monkeypatch, ["--runnable"]) + assert result.exit_code == 0, result.output + # checkpoints listed exactly ONCE despite two templates needing it. + assert listings["folders"] == ["checkpoints"] + env = _envelope(result.output) + verdicts = {r["name"]: r["verdict"] for r in env["data"]["rows"]} + assert verdicts == {"local_a": "runnable", "local_b": "runnable"} + + +def test_ls_limit_applied_after_prefilter_not_before(gallery_file, tmp_path, monkeypatch): + _force_json_renderer() + fetches = _count_workflow_fetches(monkeypatch) + # image_flux2 (API) is FIRST in the fixture. A naive pre-filter `rows[:1]` would + # grab it and the pre-filter would then drop it → zero rows. Correct ordering + # filters first, so the single local template survives the limit. + result = _run_ls(gallery_file, tmp_path, monkeypatch, ["--local-only", "--limit", "1"]) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + assert [r["name"] for r in env["data"]["rows"]] == ["image_z_image"] + assert fetches["count"] == 0 + + +def test_ls_runnable_server_unreachable_is_fatal_when_models_required(gallery_file, tmp_path, monkeypatch): + import urllib.error + + _force_json_renderer() + _no_local_server(monkeypatch) + _count_workflow_fetches(monkeypatch, body_by_name={"image_z_image": json.dumps(_TOP_LEVEL_WF).encode()}) + # Folder listing raises (server down) — fatal, because --runnable means + # "runnable on THIS install" and the install can't be inspected. + _stub_folder_listing(monkeypatch, urllib.error.URLError("connection refused")) + + result = _run_ls(gallery_file, tmp_path, monkeypatch, ["--runnable"]) + assert result.exit_code != 0 + env = _envelope(result.output) + assert env["error"]["code"] == "server_not_running" + + +def test_check_template_helper_is_pure_over_shared_listings(): + # _check_template intersects against a caller-provided listings map with no + # network of its own — the batching contract ls --runnable relies on. + row = {"name": "image_z_image", "title": "Z", "tags": ["Local"], "requires_custom_nodes": []} + listings = {"checkpoints": ["v1-5-pruned-emaonly.safetensors"]} + res = templates_cmd._check_template(row, _TOP_LEVEL_WF, listings) + assert res["verdict"] == "runnable" + assert res["models"]["present"] == ["v1-5-pruned-emaonly.safetensors"] + + # A 404/absent folder (None in the map) → missing, with a warning, no raise. + res_missing = templates_cmd._check_template(row, _TOP_LEVEL_WF, {"checkpoints": None}) + assert res_missing["verdict"] == "missing-models" + assert res_missing["models"]["missing"][0]["name"] == "v1-5-pruned-emaonly.safetensors" + assert any("checkpoints" in w for w in res_missing["warnings"])