feat(assets): add --disable-assets flag to turn the assets system off

With --enable-assets removed and the assets system always on, there is no
supported way to run without it. Add an explicit opt-out for the rollout
bake period: cloud multi-tenant sidecars need a guaranteed off-switch, and
QA needs a clean-fallback configuration.

When --disable-assets is passed:
- asset API routes are registered but disabled, returning a structured 503
- database initialization is skipped entirely (no sqlite file is created;
  the database currently has no non-asset users)
- the background seeder is disabled, covering startup, /object_info and
  post-execution enrich scans, and output registration
- workflow output enrichment and /upload/image asset registration are
  skipped
- the assets feature flag reports false, and supports_model_type_tags
  follows it since model_type tags are an assets-API capability

The hidden --enable-assets no-op remains accepted for launcher
compatibility; --disable-assets takes precedence since the former gates
nothing.
This commit is contained in:
Simon Pinfold
2026-07-28 05:44:53 +12:00
parent c78d436b4e
commit d1014a5f92
9 changed files with 197 additions and 25 deletions

View File

@@ -243,6 +243,7 @@ parser.add_argument("--database-url", type=str, default=f"sqlite:///{database_de
# Deprecated no-op: the asset system is now always enabled. Kept (hidden) so that
# existing launchers/containers still passing --enable-assets don't fail argparse.
parser.add_argument("--enable-assets", action="store_true", help=argparse.SUPPRESS)
parser.add_argument("--disable-assets", action="store_true", help="Disable the assets system: asset API routes return 503, the asset database is not initialized, no background scanning runs, and uploads and workflow outputs are not registered as assets.")
parser.add_argument("--enable-asset-hashing", action="store_true", help="Compute blake3 content hashes when scanning assets. Hashing enables future asset-portability features (deduplication, cross-machine model resolution) but adds startup cost and per-output cost on large models directories. Off by default; enable to opt in.")
parser.add_argument("--feature-flag", type=str, action='append', default=[], metavar="KEY[=VALUE]", help="Set a server feature flag. Use KEY=VALUE to set an explicit value, or bare KEY to set it to true. Can be specified multiple times. Boolean values (true/false) and numbers are auto-converted. Examples: --feature-flag show_signin_button=true or --feature-flag show_signin_button")
parser.add_argument("--list-feature-flags", action="store_true", help="Print the registry of known CLI-settable feature flags as JSON and exit.")

View File

@@ -172,4 +172,8 @@ def get_server_features() -> dict[str, Any]:
features["assets"] = assets_enabled()
except Exception:
features["assets"] = False
if not features["assets"]:
# model_type tags are an assets-API capability; don't advertise them
# when the assets backend is disabled or unavailable.
features["supports_model_type_tags"] = False
return features

View File

@@ -6,11 +6,15 @@ import os
def enrich_output_with_assets(output_ui: dict) -> dict:
"""Register file-type output entries as assets and inject their ``id``.
Runs at output-processing time, once per produced output. Returns a new
dict; entries without a resolvable on-disk file path are left unchanged.
Errors are caught per-entry so a failure never blocks execution or the
other entries.
Runs at output-processing time, once per produced output, unless
--disable-assets is set. Returns a new dict; entries without a resolvable
on-disk file path are left unchanged. Errors are caught per-entry so a
failure never blocks execution or the other entries.
"""
from comfy.cli_args import args
if args.disable_assets:
return output_ui
import folder_paths
from app.assets.services.ingest import register_file_in_place, DependencyMissingError

View File

@@ -459,6 +459,10 @@ def cleanup_temp():
def setup_database():
if args.disable_assets:
logging.info("Assets system disabled via --disable-assets; skipping database initialization and asset scanning.")
asset_seeder.disable()
return
try:
if dependencies_available():
init_db()

View File

@@ -253,7 +253,12 @@ class PromptServer():
else args.front_end_root
)
logging.info(f"[Prompt Server] web root: {self.web_root}")
register_assets_routes(self.app, self.user_manager)
if args.disable_assets:
# Register the routes without enabling them so /api/assets/* returns
# a structured 503 rather than a 404.
register_assets_routes(self.app)
else:
register_assets_routes(self.app, self.user_manager)
routes = web.RouteTableDef()
self.routes = routes
self.last_node_id = None
@@ -435,21 +440,22 @@ class PromptServer():
resp = {"name" : filename, "subfolder": subfolder, "type": image_upload_type}
try:
tag = image_upload_type if image_upload_type in ("input", "output") else "input"
tags = [tag]
tags.extend(get_known_subfolder_tags(subfolder))
result = register_file_in_place(abs_path=filepath, name=filename, tags=tags)
resp["asset"] = {
"id": result.ref.id,
"name": result.ref.name,
"asset_hash": result.asset.hash,
"size": result.asset.size_bytes,
"mime_type": result.asset.mime_type,
"tags": result.tags,
}
except Exception:
logging.warning("Failed to register uploaded image as asset", exc_info=True)
if not args.disable_assets:
try:
tag = image_upload_type if image_upload_type in ("input", "output") else "input"
tags = [tag]
tags.extend(get_known_subfolder_tags(subfolder))
result = register_file_in_place(abs_path=filepath, name=filename, tags=tags)
resp["asset"] = {
"id": result.ref.id,
"name": result.ref.name,
"asset_hash": result.asset.hash,
"size": result.asset.size_bytes,
"mime_type": result.asset.mime_type,
"tags": result.tags,
}
except Exception:
logging.warning("Failed to register uploaded image as asset", exc_info=True)
return web.json_response(resp)
else:

View File

@@ -0,0 +1,118 @@
"""Boot ComfyUI with --disable-assets and verify the assets system is inert."""
import contextlib
import socket
import subprocess
import sys
import time
from pathlib import Path
import pytest
import requests
@pytest.fixture(autouse=True)
def autoclean_unit_test_assets():
"""Override the package-level autouse cleaner: it boots the regular
(assets-enabled) server, which this module does not need."""
yield
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _wait_assets_disabled(base: str, timeout: float = 90.0) -> None:
start = time.time()
last_err = None
while time.time() - start < timeout:
try:
r = requests.get(base + "/api/assets", timeout=5)
if r.status_code == 503:
return
last_err = RuntimeError(f"unexpected status {r.status_code}")
except Exception as e:
last_err = e
time.sleep(0.25)
raise RuntimeError(f"ComfyUI HTTP did not become ready: {last_err}")
@pytest.fixture(scope="module")
def disabled_comfy(tmp_path_factory: pytest.TempPathFactory):
"""Boot a ComfyUI subprocess with --disable-assets.
Yields (base_url, db_path) where db_path is where the sqlite database
would be created if the assets system initialized it.
"""
base_dir = tmp_path_factory.mktemp("comfyui-assets-disabled")
for sub in ("models", "custom_nodes", "input", "output", "temp", "user"):
(base_dir / sub).mkdir(parents=True, exist_ok=True)
db_path = base_dir / "assets-disabled.sqlite3"
logs_dir = base_dir / "logs"
logs_dir.mkdir(exist_ok=True)
out_log = open(logs_dir / "stdout.log", "w", buffering=1)
err_log = open(logs_dir / "stderr.log", "w", buffering=1)
comfy_root = Path(__file__).resolve().parent.parent.parent
port = _free_port()
proc = subprocess.Popen(
args=[
sys.executable,
"main.py",
f"--base-directory={str(base_dir)}",
f"--database-url=sqlite:///{db_path}",
"--disable-assets",
"--listen",
"127.0.0.1",
"--port",
str(port),
"--cpu",
],
stdout=out_log,
stderr=err_log,
cwd=str(comfy_root),
)
base_url = f"http://127.0.0.1:{port}"
try:
_wait_assets_disabled(base_url)
yield base_url, db_path
finally:
if proc.poll() is None:
with contextlib.suppress(Exception):
proc.terminate()
proc.wait(timeout=15)
out_log.close()
err_log.close()
def test_assets_routes_return_503(disabled_comfy):
base_url, _ = disabled_comfy
for path in ("/api/assets", "/api/tags", "/api/assets/seed/status"):
r = requests.get(base_url + path, timeout=30)
assert r.status_code == 503, (path, r.text)
assert r.json()["error"]["code"] == "SERVICE_DISABLED"
def test_database_not_created(disabled_comfy):
_, db_path = disabled_comfy
assert not db_path.exists()
def test_seed_scan_rejected(disabled_comfy):
base_url, _ = disabled_comfy
r = requests.post(base_url + "/api/assets/seed", json={"roots": ["models"]}, timeout=30)
assert r.status_code == 503
def test_upload_image_skips_asset_registration(disabled_comfy):
base_url, db_path = disabled_comfy
files = {"image": ("disabled-mode-test.png", b"\x89PNG fake bytes", "image/png")}
r = requests.post(base_url + "/upload/image", files=files, timeout=30)
assert r.status_code == 200, r.text
body = r.json()
assert body["name"] == "disabled-mode-test.png"
assert "asset" not in body
assert not db_path.exists()

View File

@@ -1,9 +1,16 @@
"""Tests for enrich_output_with_assets in comfy_execution/asset_enrichment.py."""
import os
import types
import unittest
from unittest.mock import MagicMock, patch
def _make_args(disable_assets: bool):
a = types.SimpleNamespace()
a.disable_assets = disable_assets
return a
def _make_register_result(ref_id="ref-id-2"):
result = MagicMock()
result.ref.id = ref_id
@@ -15,8 +22,9 @@ def _make_register_result(ref_id="ref-id-2"):
_DEFAULT_BASE = os.path.join(__import__("tempfile").gettempdir(), "asset-enrichment-test-base")
def _mocked_modules(*, register_file_in_place=None, directory=_DEFAULT_BASE):
def _mocked_modules(*, disable_assets=False, register_file_in_place=None, directory=_DEFAULT_BASE):
return {
"comfy.cli_args": MagicMock(args=_make_args(disable_assets)),
"folder_paths": MagicMock(get_directory_by_type=MagicMock(return_value=directory)),
"app.assets.services.ingest": MagicMock(
register_file_in_place=register_file_in_place or MagicMock(return_value=_make_register_result()),
@@ -25,9 +33,10 @@ def _mocked_modules(*, register_file_in_place=None, directory=_DEFAULT_BASE):
}
def _call(output_ui, *, file_exists=True, register_result=None, directory=_DEFAULT_BASE):
def _call(output_ui, *, disable_assets=False, file_exists=True, register_result=None, directory=_DEFAULT_BASE):
register_mock = MagicMock(return_value=register_result or _make_register_result())
mocked = _mocked_modules(
disable_assets=disable_assets,
register_file_in_place=register_mock,
directory=directory,
)
@@ -44,6 +53,11 @@ def _call(output_ui, *, file_exists=True, register_result=None, directory=_DEFAU
class TestEnrichOutputWithAssets(unittest.TestCase):
def test_disabled_returns_unchanged(self):
output = {"images": [{"filename": "a.png", "subfolder": "", "type": "output"}]}
result = _call(output, disable_assets=True)
self.assertNotIn("id", result["images"][0])
def test_non_list_value_passed_through(self):
output = {"text": "hello"}
result = _call(output)

View File

@@ -1,5 +1,7 @@
"""Tests for feature flags functionality."""
from unittest.mock import patch
import pytest
from comfy_api.feature_flags import (
@@ -26,7 +28,8 @@ class TestFeatureFlags:
def test_get_server_features_contains_expected_flags(self):
"""Test that server features contain expected flags."""
features = get_server_features()
with patch("app.assets.api.routes.assets_enabled", return_value=True):
features = get_server_features()
assert "supports_preview_metadata" in features
assert features["supports_preview_metadata"] is True
assert "supports_model_type_tags" in features
@@ -34,6 +37,20 @@ class TestFeatureFlags:
assert "max_upload_size" in features
assert isinstance(features["max_upload_size"], (int, float))
def test_assets_flag_reflects_live_availability(self):
"""The assets capability mirrors assets_enabled() rather than a static default."""
with patch("app.assets.api.routes.assets_enabled", return_value=True):
assert get_server_features()["assets"] is True
with patch("app.assets.api.routes.assets_enabled", return_value=False):
assert get_server_features()["assets"] is False
def test_model_type_tags_follow_assets_availability(self):
"""supports_model_type_tags is an assets capability: off when assets are off."""
with patch("app.assets.api.routes.assets_enabled", return_value=False):
features = get_server_features()
assert features["assets"] is False
assert features["supports_model_type_tags"] is False
def test_get_connection_feature_with_missing_sid(self):
"""Test getting feature for non-existent session ID."""
sockets_metadata = {}

View File

@@ -1,4 +1,6 @@
"""Simplified tests for WebSocket feature flags functionality."""
from unittest.mock import patch
from comfy_api import feature_flags
@@ -7,7 +9,8 @@ class TestWebSocketFeatureFlags:
def test_server_feature_flags_response(self):
"""Test server feature flags are properly formatted."""
features = feature_flags.get_server_features()
with patch("app.assets.api.routes.assets_enabled", return_value=True):
features = feature_flags.get_server_features()
# Check expected server features
assert "supports_preview_metadata" in features
@@ -67,7 +70,8 @@ class TestWebSocketFeatureFlags:
assert "supports_preview_metadata" in client_message["data"]
# Server response format (what would be sent)
server_features = feature_flags.get_server_features()
with patch("app.assets.api.routes.assets_enabled", return_value=True):
server_features = feature_flags.get_server_features()
server_message = {
"type": "feature_flags",
"data": server_features