log: a severity, a source, and a message

[Log] Info: FunPack Diffusion Model Loader | flux.safetensors loaded, attention sage
    [Log] Alert: MiniMaxH3ImageToVideo node | no "first_image" input is connected
    [Log] Warning: FunPack LoRA Loader | style.safetensors is empty and will not load
    [Log] Critical: torch | cannot allocate 14 GB (3 GB free); the run will not start

The difference between the middle two is the one that matters. Alert is a
choice you may not have made on purpose. Warning is something that will NOT
do what its name says -- the level v4 needed and did not have, which is how
560 handlers came to swallow a failure and carry on, so a feature could be
switched on, do nothing, and never say so.

Info is not decoration either: "loaded on GPU using sage attention" is how
an attention setting that did nothing gets noticed months before the
pictures look wrong enough to investigate.

Every call site moved -- there is no note() left to be vague through -- and
the loaders now say what they actually loaded rather than only what they
were asked for. Records are kept in a bounded ring as well as printed, so a
long rental session cannot turn the log into a memory leak, and served at
/funpack/api/log so a person can read it somewhere other than a terminal
full of ComfyUI's own output. There is a page for it.

Also dropped the preflight this was traded for: min and max are already
declared and already enforced at both doors, and a second protection layer
around a check that exists is ceremony.

Two mistakes worth keeping: a log line reached into the model's shape to
describe it and could have failed the load it was only narrating; and both
new pages passed `children` to an element that takes `rows`, which the kit
accepted and silently dropped. It throws on an unknown element and not on an
unknown prop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-08-29 17:09:33 +03:00
co-authored by Claude Opus 5
parent def940d9d5
commit 74671329f9
20 changed files with 359 additions and 60 deletions
+6
View File
@@ -0,0 +1,6 @@
<link rel="stylesheet" href="/funpack/app/composer/composer.css">
<link rel="stylesheet" href="/funpack/app/catalogue/catalogue.css">
<title>FunPack log</title>
<div id="app"></div>
<script src="/funpack/app/shell/theme.js"></script>
<script type="module" src="/funpack/app/catalogue/log.js"></script>
+70
View File
@@ -0,0 +1,70 @@
// The log, where a person can read it.
//
// The terminal is where this ends up by default, buried among ComfyUI's own
// output. The whole reason for a severity and a source is that a line can be
// found later, so there has to be somewhere to look.
import { composer } from "../composer/composer.js";
const root = document.querySelector("#app");
// Severity -> the chip tone that already exists in the kit. Info is deliberately
// quiet: most lines are Info, and a wall of coloured chips says nothing.
const TONE = { Info: "neutral", Alert: "info", Warning: "warn", Critical: "danger" };
let filter = null;
async function load() {
const url = "/funpack/api/log" + (filter ? `?level=${encodeURIComponent(filter)}` : "");
const response = await fetch(url);
if (!response.ok) throw new Error(`${url} answered ${response.status}`);
return response.json();
}
function render(data) {
const counts = {};
for (const level of data.levels) counts[level] = 0;
for (const record of data.records) counts[record.level] = (counts[record.level] || 0) + 1;
const filters = composer.segmented.md({
label: "Show",
value: filter || "all",
options: [{ value: "all", label: "Everything" },
...data.levels.map((l) => ({ value: l, label: `${l} (${counts[l] || 0})` }))],
onChange: (value) => { filter = value === "all" ? null : value; start(); },
});
// Newest last, like a terminal: the eye expects the end to be the present.
const items = data.records.map((record, i) => ({
id: `r${i}`,
label: record.message,
hint: `${record.level} · ${record.source || "FunPack"}`,
}));
root.replaceChildren(composer.panel.default({
title: "Log",
body: composer.group.default({
rows: [
filters,
items.length
? composer.list.rows({ items, reorder: false })
: composer.emptyState.default({
icon: "○", title: "Nothing logged yet",
hint: filter ? `No ${filter} messages.` : "Run a generation and come back.",
}),
],
}),
}).node);
}
async function start() {
try {
render(await load());
} catch (err) {
root.replaceChildren(composer.emptyState.default({
icon: "▲", title: "Could not read the log", hint: err.message,
}).node);
}
}
start();
+4 -4
View File
@@ -75,10 +75,10 @@ async function start() {
function composal(title, hint, items) {
return composer.panel.default({
title,
body: items.length
? composer.group.default({ children: [
composer.hint.default({ text: hint }), rows(items)] })
: composer.hint.default({ text: "None." }),
body: composer.group.default({
hint,
rows: items.length ? [rows(items)] : [composer.hint.default({ text: "None." })],
}),
});
}
+101 -8
View File
@@ -1,17 +1,110 @@
"""One-line logging with a fixed prefix.
"""Saying what happened, at a severity, from somewhere.
`failed()` exists so a module that silently no-ops is impossible: every swallowed
exception says what stopped working, once.
[Log] Info: FunPack Diffusion Model Loader | flux.safetensors loaded, attention sage
[Log] Alert: FunPack Sampler | no image is connected, starting from noise
[Log] Warning: FunPack LoRA Loader | style.safetensors is empty and will not load
[Log] Critical: torch | cannot allocate 14 GB (3 GB free); the run will not start
Four levels, and the difference between the middle two is the one that matters:
* **Info** -- it worked, and here is what it actually did. Not decoration: "loaded
on GPU using sage attention" is how you find out the attention setting did
nothing, months before the pictures look wrong enough to investigate.
* **Alert** -- something is unset or unconnected and the run continues without
it. A choice you may not have made on purpose.
* **Warning** -- something will NOT do what its name says. This is the level v4
needed and did not have: 560 handlers swallowed a failure and carried on, so a
feature could be switched on, do nothing, and never say so.
* **Critical** -- the run cannot proceed.
Records are kept in a small ring as well as printed, so the app can show them.
The buffer is bounded because a long rental session must not turn a log into a
memory leak.
"""
import sys
import time
from collections import deque
from typing import Optional
PREFIX = "[FunPack]"
LABEL = "[Log]"
INFO = "Info"
ALERT = "Alert"
WARNING = "Warning"
CRITICAL = "Critical"
LEVELS = (INFO, ALERT, WARNING, CRITICAL)
# Enough to cover a long generation and its aftermath, small enough to be free.
HISTORY = 500
_records: deque = deque(maxlen=HISTORY)
# Keys already said once. Cleared per run rather than per process: v4's dedup
# lasted the life of the interpreter, so a session reported the first generation
# that went inert and stayed quiet for every one after it.
_said: set = set()
def note(message: str) -> None:
print(f"{PREFIX} {message}", file=sys.stderr, flush=True)
def line(level: str, source: str, message: str) -> str:
"""The one place the format is decided."""
where = f"{source} | " if source else ""
return f"{LABEL} {level}: {where}{message}"
def failed(what: str, exc: BaseException) -> None:
note(f"{what} failed to load: {type(exc).__name__}: {exc}")
def record(level: str, source: str, message: str) -> dict:
entry = {"at": time.time(), "level": level, "source": source, "message": message}
_records.append(entry)
print(line(level, source, message), file=sys.stderr, flush=True)
return entry
def info(source: str, message: str) -> dict:
return record(INFO, source, message)
def alert(source: str, message: str) -> dict:
return record(ALERT, source, message)
def warning(source: str, message: str) -> dict:
return record(WARNING, source, message)
def critical(source: str, message: str) -> dict:
return record(CRITICAL, source, message)
def once(key: str, level: str, source: str, message: str) -> Optional[dict]:
"""Say it the first time only, until the next run.
For anything that would otherwise repeat every sampling step. Thirty copies
of one line say nothing the first did not, and burying the console is its own
way of hiding a failure.
"""
if key in _said:
return None
_said.add(key)
return record(level, source, message)
def failed(what: str, exc: BaseException) -> dict:
"""Something did not load. Always a Warning: it means a feature is absent."""
return warning(what, f"did not load -- {type(exc).__name__}: {exc}")
def new_run() -> None:
"""A generation is starting: whatever was said once may be said again."""
_said.clear()
def history(level: Optional[str] = None, limit: int = HISTORY) -> list:
"""Recent records, newest last. For the app, and for the modules page."""
items = [r for r in _records if level is None or r["level"] == level]
return items[-limit:]
def _reset() -> None:
"""Tests only."""
_records.clear()
_said.clear()
+8 -8
View File
@@ -56,14 +56,14 @@ def collect(registry=None) -> Tuple[List[type], List[Tuple[str, str]]]:
if schema is None:
rejected.append((where, "is not a comfy_api io.ComfyNode (no GET_SCHEMA)"))
log.note(f"{where} is not a ComfyUI V3 node; not registered.")
log.warning(where, "is not a ComfyUI V3 node and was not registered")
continue
node_id = getattr(schema, "node_id", None)
if not isinstance(node_id, str) or not node_id.startswith(PREFIX):
rejected.append((where, f"node_id {node_id!r} does not start with {PREFIX!r}"))
log.note(f"{where} declares node_id {node_id!r}, which is not "
f"prefixed {PREFIX!r}; not registered.")
log.warning(where, f"declares node_id {node_id!r}, which is not prefixed "
f"{PREFIX!r}, and was not registered")
continue
if node_id in claimed:
@@ -71,8 +71,8 @@ def collect(registry=None) -> Tuple[List[type], List[Tuple[str, str]]]:
# and which one wins would depend on import order.
rejected.append((where, f"duplicate node_id {node_id!r}, already "
f"declared by {claimed[node_id]}"))
log.note(f"{where} reuses node_id {node_id!r}, already declared by "
f"{claimed[node_id]}; not registered.")
log.warning(where, f"reuses node_id {node_id!r}, already declared by "
f"{claimed[node_id]}, and was not registered")
continue
claimed[node_id] = where
@@ -96,7 +96,7 @@ def extension():
return list(nodes)
if rejected:
log.note(f"{len(rejected)} node(s) were not registered; "
+ "; ".join(f"{where}: {why}" for where, why in rejected))
log.note(f"registered {len(nodes)} node(s)")
log.warning("nodes", f"{len(rejected)} node(s) were not registered; "
+ "; ".join(f"{where}: {why}" for where, why in rejected))
log.info("nodes", f"registered {len(nodes)} node(s)")
return FunPackExtension()
+4 -3
View File
@@ -136,9 +136,10 @@ def guard(fn, key: str, neutral, dropped: Dropped):
except Exception as exc: # noqa: BLE001 -- the whole point
if dropped.record(key, exc):
from . import log
log.note(
f"{key} failed during sampling and is now OFF for the rest of "
f"this run. The run continues without it.\n"
log.warning(
key,
"failed during sampling and is now OFF for the rest of this run; "
"the run continues without it\n"
+ "".join(traceback.format_exception(exc)).rstrip())
return neutral(*args, **kwargs)
+1 -1
View File
@@ -122,7 +122,7 @@ def scan(root: Optional[Path] = None, package: Optional[str] = None) -> Registry
try:
registry.add(validate(raw, source=dotted))
except SchemaError as exc:
log.note(f"{dotted} declares an invalid module: {exc}")
log.warning(dotted, f"is not a valid module and was not loaded -- {exc}")
registry.failed.append((dotted, str(exc)))
return registry
+2 -1
View File
@@ -39,7 +39,8 @@ def order(specs: Iterable[ModuleSpec]) -> Tuple[List[ModuleSpec], List[Tuple[Mod
# that would put a whole stage out of sequence to satisfy one module.
# The relation is dropped and said out loud rather than silently obeyed.
if stage_of[later] < stage_of[earlier]:
log.note(
log.alert(
"module ordering",
f"ignoring {later!r} after {earlier!r}: {later!r} is in stage "
f"{STAGES[stage_of[later]]!r}, which runs before {STAGES[stage_of[earlier]]!r}"
)
+11 -1
View File
@@ -92,6 +92,16 @@ def register(routes, prefix=None):
traits = [t for t in raw.split(",") if t] if raw is not None else None
return web.json_response(manifest(traits))
@routes.get(P + "/api/log")
async def _log(req):
level = req.query.get("level") or None
try:
limit = int(req.query.get("limit", log.HISTORY))
except ValueError:
limit = log.HISTORY
return web.json_response({"levels": list(log.LEVELS),
"records": log.history(level, limit)})
@routes.get(P + "/app/{tail:.*}")
async def _app_asset(req):
return _serve_under(req, config.APP_DIR, config.APP_EXTS)
@@ -121,4 +131,4 @@ if web is not None and PromptServer is not None:
except Exception as exc: # noqa: BLE001
log.failed("route registration", exc)
else:
log.note(f"serving the app at {config.UI_PREFIX}/")
log.info("routes", f"serving the app at {config.UI_PREFIX}/")
+86
View File
@@ -0,0 +1,86 @@
"""The log: a severity, a source, and a message.
The format is the contract -- the app will parse it and a person will scan it --
so it is pinned rather than left to drift line by line.
"""
import pytest
from core import log
@pytest.fixture(autouse=True)
def clean():
log._reset()
yield
log._reset()
def test_the_shape_is_label_severity_source_message():
assert log.line("Warning", "FunPack LoRA Loader", "style.safetensors is empty") == (
"[Log] Warning: FunPack LoRA Loader | style.safetensors is empty")
def test_a_message_with_no_source_does_not_print_an_empty_one():
assert log.line("Info", "", "ready") == "[Log] Info: ready"
@pytest.mark.parametrize("fn,level", [
(log.info, "Info"), (log.alert, "Alert"),
(log.warning, "Warning"), (log.critical, "Critical"),
])
def test_each_level_records_itself(fn, level):
fn("somewhere", "something")
assert log.history()[-1] == {
"at": log.history()[-1]["at"], "level": level,
"source": "somewhere", "message": "something"}
def test_it_prints_as_well_as_records(capsys):
log.warning("loader", "a file went missing")
assert "[Log] Warning: loader | a file went missing" in capsys.readouterr().err
def test_history_can_be_filtered_by_level():
log.info("a", "one")
log.warning("b", "two")
log.info("c", "three")
assert [r["source"] for r in log.history(log.INFO)] == ["a", "c"]
def test_the_history_is_bounded():
"""A long rental session must not turn the log into a memory leak."""
for i in range(log.HISTORY + 50):
log.info("loop", str(i))
assert len(log.history()) == log.HISTORY
assert log.history()[-1]["message"] == str(log.HISTORY + 49)
def test_failed_is_a_warning_because_it_means_a_feature_is_absent():
log.failed("some.module", ValueError("bad"))
entry = log.history()[-1]
assert entry["level"] == log.WARNING
assert "ValueError" in entry["message"] and "bad" in entry["message"]
# --- once, and what "once" means -------------------------------------------
def test_once_says_it_a_single_time():
for _ in range(10):
log.once("k", log.ALERT, "src", "inert")
assert len(log.history()) == 1
def test_once_is_per_run_not_per_process():
"""v4 deduped for the life of the interpreter, so a session reported the
first generation that went inert and stayed quiet for every one after."""
log.once("k", log.ALERT, "src", "inert")
log.new_run()
log.once("k", log.ALERT, "src", "inert")
assert len(log.history()) == 2
def test_different_keys_are_independent():
log.once("a", log.ALERT, "src", "one")
log.once("b", log.ALERT, "src", "two")
assert len(log.history()) == 2
+5
View File
@@ -28,6 +28,8 @@ import folder_paths
import torch
from comfy_api.latest import io
from ..._core import log
MAX_ENCODERS = 4
@@ -118,5 +120,8 @@ class FunPackCLIPLoader(io.ComfyNode):
raise RuntimeError(
f"Could not load a text encoder from {', '.join(names)} as type {type!r}.")
log.info("FunPack CLIP Loader",
f"{len(names)} encoder file(s) loaded as type {type}, device {device}: "
+ ", ".join(names))
listing = "\n".join(f" {i + 1}. {name}" for i, name in enumerate(names))
return io.NodeOutput(clip, f"FunPack CLIP Loader | type={type} device={device}\n{listing}")
+9 -7
View File
@@ -10,10 +10,10 @@ which the loader cannot be handed by anyone else. Feature knobs -- strengths,
sigma windows, anything a modifier owns -- are settings, and never appear here.
"""
import logging
import torch
from .._core import log
# Named exactly as core's UNETLoader names them where they overlap, so a value
# copied from a stock workflow means the same thing here.
WEIGHT_DTYPES = ["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2", "fp16", "bf16", "fp32"]
@@ -80,11 +80,13 @@ def attention_override(name):
try:
from comfy.ldm.modules.attention import get_attention_function
except ImportError:
logging.warning("[FunPack] this ComfyUI has no attention registry; leaving attention alone")
log.warning("attention", "this ComfyUI has no attention registry, so the "
"chosen backend cannot be applied")
return None
chosen = get_attention_function(name, None)
if chosen is None:
logging.warning("[FunPack] attention backend %r is not available; leaving attention alone", name)
log.warning("attention", f"backend {name!r} is not available on this machine, "
f"so it was not applied")
return None
inner = getattr(chosen, "__wrapped__", chosen)
@@ -124,7 +126,7 @@ def set_fp16_accumulation(enabled):
before = bool(getattr(matmul, "allow_fp16_accumulation", False))
matmul.allow_fp16_accumulation = wanted
if before != wanted:
logging.warning(
"[FunPack] fp16 accumulation is now %s for EVERY model in this process, "
"not just this one -- torch has a single process-wide flag.", wanted)
log.alert("fp16 accumulation",
f"now {wanted} for EVERY model in this process, not just this one -- "
f"torch has a single process-wide flag")
return wanted
+8
View File
@@ -10,6 +10,7 @@ import comfy.utils
import folder_paths
from comfy_api.latest import io
from ..._core import log
from ..common import (COMPUTE_DTYPES, WEIGHT_DTYPES, attention_choices,
attention_override, dtype_of, set_fp16_accumulation,
weight_model_options)
@@ -90,4 +91,11 @@ class FunPackDiffusionModelLoader(io.ComfyNode):
else:
notes.append("attention: default (as launched)")
# Never reach into the model's shape to describe it: a log line that
# assumes structure can fail the load it was only meant to narrate.
kind = type(getattr(model, "model", model)).__name__
log.info("FunPack Diffusion Model Loader",
f"{model_name} loaded as {kind}, weights "
f"{weight_dtype}, compute {compute_dtype}, attention "
f"{attention if override is not None else 'default (as launched)'}")
return io.NodeOutput(model, "\n".join(notes))
+7
View File
@@ -12,6 +12,8 @@ import comfy.utils
import folder_paths
from comfy_api.latest import io
from ..._core import log
class FunPackLoraLoader(io.ComfyNode):
@classmethod
@@ -45,6 +47,10 @@ class FunPackLoraLoader(io.ComfyNode):
# rather than clones: a clone here would silently drop patches a later
# loader in the chain applied to the original.
if strength_model == 0 and (clip is None or strength_clip == 0):
# Loud, because "applied a LoRA at strength 0" is indistinguishable
# from "forgot to set the strength" by looking at the picture.
log.alert("FunPack LoRA Loader",
f"{lora_name} has both strengths at 0 and was not applied")
return io.NodeOutput(model, clip, f"{lora_name}: both strengths are 0, not applied")
path = folder_paths.get_full_path_or_raise("loras", lora_name)
@@ -54,5 +60,6 @@ class FunPackLoraLoader(io.ComfyNode):
applied = [f"model={strength_model}"]
applied.append(f"clip={strength_clip}" if clip is not None else "clip=not wired")
log.info("FunPack LoRA Loader", f"{lora_name} applied ({', '.join(applied)})")
return io.NodeOutput(patched_model, patched_clip,
f"FunPack LoRA Loader | {lora_name} ({', '.join(applied)})")
+11 -13
View File
@@ -164,12 +164,12 @@ def test_an_unknown_encoder_family_is_refused_not_silently_downgraded(monkeypatc
nodes.FunPackCLIPLoader.execute(clip_name1="e.safetensors", type="not_a_family")
def test_changing_the_process_wide_fp16_flag_is_never_silent(monkeypatch, caplog):
def test_changing_the_process_wide_fp16_flag_is_never_silent(monkeypatch):
"""torch has ONE flag for the interpreter, so this cannot mean "for this
model". A second loader setting it differently changes the first model's
maths, and ComfyUI's loader caching means the last value outlives its run."""
import logging
import torch
from core import log
from modules.loaders import common
class FakeMatmul:
@@ -178,23 +178,21 @@ def test_changing_the_process_wide_fp16_flag_is_never_silent(monkeypatch, caplog
fake = FakeMatmul()
monkeypatch.setattr(torch.backends, "cuda", type("C", (), {"matmul": fake}))
with caplog.at_level(logging.WARNING):
assert common.set_fp16_accumulation(True) is True
log._reset()
assert common.set_fp16_accumulation(True) is True
assert fake.allow_fp16_accumulation is True
assert any("EVERY model in this process" in r.getMessage() for r in caplog.records)
assert any("EVERY model in this process" in r["message"] for r in log.history())
# No transition, nothing said.
caplog.clear()
with caplog.at_level(logging.WARNING):
common.set_fp16_accumulation(True)
assert not caplog.records
log._reset()
common.set_fp16_accumulation(True)
assert log.history() == []
# And turning it back off is a transition too: it is another model's setting
# being undone.
caplog.clear()
with caplog.at_level(logging.WARNING):
common.set_fp16_accumulation(False)
assert caplog.records
log._reset()
common.set_fp16_accumulation(False)
assert log.history()
def test_a_torch_build_without_the_flag_reports_that_rather_than_pretending():
+3
View File
@@ -12,6 +12,8 @@ import folder_paths
import torch
from comfy_api.latest import io
from ..._core import log
# Chosen, not inferred. bf16 is the default because fp16 is a known failure on
# the AV VAEs, and fp32 decodes slowly for no visible gain.
DTYPES = {
@@ -53,4 +55,5 @@ class FunPackVAELoader(io.ComfyNode):
state_dict = comfy.utils.load_torch_file(path)
vae = comfy.sd.VAE(sd=state_dict, dtype=DTYPES[dtype])
vae.throw_exception_if_invalid()
log.info("FunPack VAE Loader", f"{vae_name} loaded as {dtype}")
return io.NodeOutput(vae, f"{vae_name} loaded as {dtype}")
+7 -10
View File
@@ -61,14 +61,11 @@ SETTINGS = {
},
}
_said = set()
def _once(message: str) -> None:
"""Say an inert reason once per process, not once per step."""
if message not in _said:
_said.add(message)
log.note(message)
"""Say an inert reason once per RUN, not once per step and not once per
process -- a session that deduped for the life of the interpreter reported
the first generation that went inert and stayed quiet for every one after."""
log.once(f"alg:{message}", log.ALERT, "FunPack ALG", message)
def install(patcher, values, key):
@@ -87,14 +84,14 @@ def install(patcher, values, key):
# No anchor pinned means nothing to de-static; a sampler that is not
# KSAMPLER-shaped has no loop to stand between.
if inner_fn is None or latent_image is None or denoise_mask is None:
_once("[FunPack] ALG is off this run: no pinned anchor to loosen.")
_once("off this run: there is no pinned anchor to loosen")
return executor(model_wrap, sigmas, extra_args, callback, noise,
latent_image, denoise_mask, disable_pbar)
blurred = blur_frames(latent_image, strength, frame_indices=(0,))
if blurred is None:
_once("[FunPack] ALG is off this run: this model's anchor is not a "
"plain video latent, and reshaping it would be guesswork.")
_once("off this run: this model's anchor is not a plain video latent, "
"and reshaping it would be guesswork")
return executor(model_wrap, sigmas, extra_args, callback, noise,
latent_image, denoise_mask, disable_pbar)
+8 -2
View File
@@ -149,8 +149,14 @@ class FunPackLoadModifiers(io.ComfyNode):
stripped = patching.strip(patched, KEY_PREFIX)
applied, notes = [], []
# One per run, so a long session reports every generation that went
# inert rather than only the first one after a restart.
# A run starts here: whatever was said once may be said again, and the
# dropped-modifier record starts empty. Both are per-run state and they
# begin together so they cannot disagree about when a run began.
#
# The limit, stated: a graph without this node never marks a run, so
# once-per-run reverts to once-per-process there. That is the honest
# cost of ComfyUI having no notion of "a run started" a node can hook.
log.new_run()
dropped = patching.Dropped()
patched.funpack_dropped = dropped
+1 -1
View File
@@ -71,7 +71,7 @@ def install(patcher, values, key):
if amount == 0.0:
# Zero is a request for nothing, and saying so beats reporting that a
# modifier ran when it could not have changed a single pixel.
log.note("[FunPack] detail is enabled at amount 0, which changes nothing")
log.alert("FunPack Detail", "enabled at amount 0, which changes nothing")
return None
radius = int(values.get("radius", 5))
+7 -1
View File
@@ -78,7 +78,13 @@ class Handler(BaseHTTPRequestHandler):
# The same manifest the aiohttp route serves, so what you see in the
# browser here is what ComfyUI would send.
if path == P + "/api/modules":
if path == P + "/api/log":
query = parse_qs(parsed.query)
level = (query.get("level") or [None])[0]
body = json.dumps({"levels": list(routes.log.LEVELS),
"records": routes.log.history(level)}).encode()
served = static.Served(200, body, "application/json")
elif path == P + "/api/modules":
raw = parse_qs(parsed.query).get("traits")
traits = [t for t in raw[0].split(",") if t] if raw else None
body = json.dumps(routes.manifest(traits)).encode()