diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5ace4600..cbd920f6 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,3 +4,7 @@ updates: directory: "/" schedule: interval: "weekly" + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index e0534ab2..62778ce6 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -153,7 +153,7 @@ jobs: pip install .[dev] - name: Run pytest - run: python -m pytest -q --timeout=300 --cov=mjr_am_backend --cov=mjr_am_shared --cov-report=xml --cov-report=term-missing:skip-covered + run: python -m pytest -q --timeout=300 --cov=mjr_am_backend --cov=mjr_am_shared --cov-fail-under=60 --cov-report=xml --cov-report=term-missing:skip-covered - name: Upload coverage report if: matrix.python-version == '3.12' diff --git a/.gitignore b/.gitignore index b74b99b4..05cab401 100644 --- a/.gitignore +++ b/.gitignore @@ -196,3 +196,5 @@ output_metadata.json CLAUDE.md docs/QUALITY_IMPROVEMENT_PLAN.md docs/COMFYUI_CORE_ANALYSIS.md +.github/workflows/pr-review.yml +scripts/pr_reviewer.py diff --git a/README.md b/README.md index 9eea4539..ea41ade7 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,9 @@ git clone https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager ComfyUI-Ma cd ComfyUI-Majoor-AssetsManager pip install -r requirements.txt +# Optional: install AI/vector features +# pip install -r requirements.txt -r requirements-vector.txt + # Restart ComfyUI ``` @@ -582,7 +585,8 @@ See [`docs/SETTINGS_CONFIGURATION.md`](docs/SETTINGS_CONFIGURATION.md) and [`doc ### Extension Not Appearing - Verify installation in `custom_nodes/ComfyUI-Majoor-AssetsManager` - Check ComfyUI console for import errors -- Ensure all dependencies are installed: `pip install -r requirements.txt` +- Ensure core dependencies are installed: `pip install -r requirements.txt` +- If you use AI/vector features, also install: `pip install -r requirements.txt -r requirements-vector.txt` - Restart ComfyUI completely ### Database Corruption diff --git a/docs/AI_FEATURES.md b/docs/AI_FEATURES.md index 06df7a80..8023f45b 100644 --- a/docs/AI_FEATURES.md +++ b/docs/AI_FEATURES.md @@ -1009,7 +1009,7 @@ source venv/bin/activate # Linux/macOS venv\Scripts\activate # Windows # Install dependencies -pip install -r requirements.txt +pip install -r requirements.txt -r requirements-vector.txt ``` --- diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index d8f37d85..543386c8 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -54,12 +54,23 @@ cd ComfyUI-Majoor-AssetsManager pip install -r requirements.txt ``` +If you want the optional AI/vector features as well, install the extra vector stack too: + +```bash +pip install -r requirements.txt -r requirements-vector.txt +``` + > **Faster installs with `uv`**: If you have [uv](https://github.com/astral-sh/uv) installed, you can use it as a drop-in replacement for significantly faster dependency resolution: > ```bash > uv pip install -r requirements.txt > ``` > Install `uv` with `pip install uv` or see the [uv documentation](https://docs.astral.sh/uv/) for other methods. +> For AI/vector features with `uv`: +> ```bash +> uv pip install -r requirements.txt -r requirements-vector.txt +> ``` + ### Step 3: Restart ComfyUI Stop your ComfyUI server completely and restart it to load the new extension. @@ -213,6 +224,12 @@ cd ComfyUI-Majoor-AssetsManager pip install -r requirements.txt ``` +If the error is related to AI/vector search features, install the optional vector stack too: + +```bash +pip install -r requirements.txt -r requirements-vector.txt +``` + ### External Tools Not Found If metadata extraction isn't working: 1. Verify tools are installed: `exiftool -ver` and `ffprobe -version` @@ -262,6 +279,12 @@ git pull origin main pip install -r requirements.txt --upgrade ``` +If you use AI/vector features, also upgrade the optional vector requirements: + +```bash +pip install -r requirements.txt -r requirements-vector.txt --upgrade +``` + Remember to restart ComfyUI after updating. ## Uninstallation diff --git a/docs/TESTING.md b/docs/TESTING.md index f28caf4f..c498d09f 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -82,7 +82,9 @@ python scripts/run_quality_gate.py --python-only --skip-tests tox -e quality ``` -The canonical gate runs encoding/BOM checks, `ruff`, `mypy`, `bandit`, `pip-audit`, xenon/radon complexity checks, backend tests, frontend tests, and `npm audit`. CI uses the same script for the Python quality job so local and CI behavior stay aligned. +The canonical gate runs encoding/BOM checks, `ruff`, `mypy`, `bandit`, `pip-audit`, xenon/radon complexity checks, backend tests, frontend tests, and `npm audit`. The `pip-audit` step audits `requirements.txt` directly so the local package itself does not need to be published on PyPI for the gate to pass. CI uses the same script for the Python quality job so local and CI behavior stay aligned. + +The Python coverage gate currently enforces a minimum combined backend/shared coverage threshold of `60%`, which gives the CI pipeline a regression floor without making legacy cleanup block unrelated work. During the migration to stricter quality thresholds, `ruff` is enforced on changed Python files while repository-wide hygiene, security, and complexity checks continue to run across the repo. This keeps the gate hard for new work without turning legacy cleanup into a single big-bang change. diff --git a/js/api/client.js b/js/api/client.js index 20a081d9..9466da5c 100644 --- a/js/api/client.js +++ b/js/api/client.js @@ -696,17 +696,16 @@ export async function updateAssetRating(assetId, rating, options = {}) { const enabled = _readRatingTagsSyncEnabled(); const asset = assetId && typeof assetId === "object" ? assetId : null; const resolvedId = asset ? asset.id : assetId; + const normalizedId = normalizeAssetId(resolvedId); const payload = { rating: Math.max(0, Math.min(5, Number(rating) || 0)), }; - if (resolvedId != null) { - payload.asset_id = normalizeAssetId(resolvedId); + if (normalizedId) { + payload.asset_id = normalizedId; } else if (asset) { payload.filepath = asset.filepath || asset.path || asset?.file_info?.filepath || ""; payload.type = asset.type || "output"; payload.root_id = pickRootId(asset); - } else { - payload.asset_id = normalizeAssetId(resolvedId); } return fetchAPI("/mjr/am/asset/rating", { ...options, @@ -726,17 +725,16 @@ export async function updateAssetTags(assetId, tags, options = {}) { const enabled = _readRatingTagsSyncEnabled(); const asset = assetId && typeof assetId === "object" ? assetId : null; const resolvedId = asset ? asset.id : assetId; + const normalizedId = normalizeAssetId(resolvedId); const payload = { tags: Array.isArray(tags) ? tags : [], }; - if (resolvedId != null) { - payload.asset_id = normalizeAssetId(resolvedId); + if (normalizedId) { + payload.asset_id = normalizedId; } else if (asset) { payload.filepath = asset.filepath || asset.path || asset?.file_info?.filepath || ""; payload.type = asset.type || "output"; payload.root_id = pickRootId(asset); - } else { - payload.asset_id = normalizeAssetId(resolvedId); } const result = await fetchAPI("/mjr/am/asset/tags", { ...options, diff --git a/js/api/endpoints.js b/js/api/endpoints.js index a6c2d6cd..6ab57037 100644 --- a/js/api/endpoints.js +++ b/js/api/endpoints.js @@ -2,7 +2,7 @@ * API Endpoints - Centralized URL constants */ -import { pickRootId } from "../utils/ids.js"; +import { normalizeAssetId, pickRootId } from "../utils/ids.js"; import { hasWindowsDrivePrefix, splitFilenameAndSubfolder, toPosixPath } from "../utils/path.js"; const DEFAULT_QUERY_LIMIT = 100; @@ -309,11 +309,10 @@ export function buildViewerResourceURL(assetOrContext, relpath = "") { const rel = String(relpath || "").trim(); if (!rel) return ""; - const rawId = ctx?.asset_id ?? ctx?.id ?? null; - const assetId = Number(rawId) || 0; + const assetId = normalizeAssetId(ctx?.asset_id ?? ctx?.id ?? null); let url = `${ENDPOINTS.VIEWER_RESOURCE}?relpath=${encodeURIComponent(rel)}`; - if (assetId > 0) { - return `${url}&asset_id=${encodeURIComponent(String(assetId))}`; + if (assetId) { + return `${url}&asset_id=${encodeURIComponent(assetId)}`; } const filepath = toPosixPath( diff --git a/js/entry.js b/js/entry.js index 241b1f6a..cb69c92d 100644 --- a/js/entry.js +++ b/js/entry.js @@ -38,6 +38,7 @@ import { pushGeneratedAsset, refreshGeneratedFeedHosts, } from "./features/bottomPanel/GeneratedFeedTab.js"; +import { createJobFinalizeQueue } from "./features/stacks/finalizeQueue.js"; import { extractOutputFiles } from "./utils/extractOutputFiles.js"; import { post } from "./api/client.js"; import { ENDPOINTS } from "./api/endpoints.js"; @@ -374,10 +375,29 @@ const executionStarts = createTTLCache({ ttlMs: EXECUTION_START_TTL_MS, maxSize: EXECUTION_STARTS_MAX, }); -let _stackFinalizeTimer = null; -let _stackFinalizeInFlight = null; let _lastStacksUpdateSignature = ""; let _lastStacksUpdateAt = 0; +const _stackFinalizeQueue = createJobFinalizeQueue({ + defaultDelayMs: 900, + async postJob(targetJobId) { + try { + const res = await post(ENDPOINTS.STACKS_AUTO_STACK, { + mode: "job_id", + job_id: targetJobId, + }); + if (!res?.ok) { + reportError( + new Error(String(res?.error || "Auto stack failed")), + "entry.execution_end.auto_stack", + ); + return; + } + notifyStacksUpdated(res?.data || { targeted_job_id: targetJobId }); + } catch (error) { + reportError(error, "entry.execution_end.auto_stack"); + } + }, +}); function notifyStacksUpdated(detail = {}) { try { @@ -501,38 +521,7 @@ function scheduleGenerationIndex(files, attempt = 1, meta = {}) { } function scheduleFinalizeExecutionStacks(jobId, delayMs = 900) { - const targetJobId = String(jobId || "").trim(); - if (!targetJobId) return; - try { - if (_stackFinalizeTimer) clearTimeout(_stackFinalizeTimer); - } catch (e) { - console.debug?.(e); - } - _stackFinalizeTimer = setTimeout( - () => { - _stackFinalizeTimer = null; - if (_stackFinalizeInFlight) return; - _stackFinalizeInFlight = post(ENDPOINTS.STACKS_AUTO_STACK, { - mode: "job_id", - job_id: targetJobId, - }) - .then((res) => { - if (!res?.ok) { - reportError( - new Error(String(res?.error || "Auto stack failed")), - "entry.execution_end.auto_stack", - ); - return; - } - notifyStacksUpdated(res?.data || { targeted_job_id: targetJobId }); - }) - .catch((error) => reportError(error, "entry.execution_end.auto_stack")) - .finally(() => { - _stackFinalizeInFlight = null; - }); - }, - Math.max(0, Number(delayMs) || 0), - ); + _stackFinalizeQueue.schedule(jobId, delayMs); } app.registerExtension({ diff --git a/js/features/bottomPanel/GeneratedFeedTab.js b/js/features/bottomPanel/GeneratedFeedTab.js index e2a83b5c..0b6675fa 100644 --- a/js/features/bottomPanel/GeneratedFeedTab.js +++ b/js/features/bottomPanel/GeneratedFeedTab.js @@ -124,6 +124,8 @@ const FEED_STATE = { function _getAssetGroupKey(asset) { const stackId = String(asset?.stack_id || "").trim(); if (stackId) return `stack:${stackId}`; + const jobId = String(asset?.job_id || "").trim(); + if (jobId) return `job:${jobId}`; return _assetKey(asset); } diff --git a/js/features/stacks/finalizeQueue.js b/js/features/stacks/finalizeQueue.js new file mode 100644 index 00000000..0b41417e --- /dev/null +++ b/js/features/stacks/finalizeQueue.js @@ -0,0 +1,52 @@ +export function createJobFinalizeQueue({ defaultDelayMs = 900, postJob } = {}) { + const timers = new Map(); + const pending = new Set(); + let inFlight = false; + + async function drain() { + if (inFlight) return; + inFlight = true; + try { + while (pending.size > 0) { + const next = pending.values().next().value; + pending.delete(next); + if (!next) continue; + await postJob?.(next); + } + } finally { + inFlight = false; + if (pending.size > 0) { + void drain(); + } + } + } + + function schedule(jobId, delayMs = defaultDelayMs) { + const id = String(jobId || "").trim(); + if (!id) return; + const existing = timers.get(id); + if (existing) clearTimeout(existing); + const timer = setTimeout(() => { + timers.delete(id); + pending.add(id); + void drain(); + }, Math.max(0, Number(delayMs) || 0)); + timers.set(id, timer); + } + + function dispose() { + for (const timer of timers.values()) { + clearTimeout(timer); + } + timers.clear(); + pending.clear(); + inFlight = false; + } + + return { + schedule, + dispose, + _pendingJobIds: pending, + _timers: timers, + }; +} diff --git a/js/tests/endpoints_model3d.vitest.mjs b/js/tests/endpoints_model3d.vitest.mjs index 64ae5615..e2d24400 100644 --- a/js/tests/endpoints_model3d.vitest.mjs +++ b/js/tests/endpoints_model3d.vitest.mjs @@ -23,4 +23,13 @@ describe("buildViewerResourceURL", () => { expect(url).toContain("root_id=root-7"); expect(url).not.toContain("asset_id="); }); + + it("ignores malformed asset ids and falls back to filepath context", () => { + const url = buildViewerResourceURL( + { id: "not-an-id", filepath: "C:/assets/models/robot.gltf" }, + "../textures/albedo.png", + ); + expect(url).toContain("filepath=C%3A%2Fassets%2Fmodels%2Frobot.gltf"); + expect(url).not.toContain("asset_id="); + }); }); diff --git a/js/tests/finalize_queue.vitest.mjs b/js/tests/finalize_queue.vitest.mjs new file mode 100644 index 00000000..beddadcd --- /dev/null +++ b/js/tests/finalize_queue.vitest.mjs @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createJobFinalizeQueue } from "../features/stacks/finalizeQueue.js"; + +describe("createJobFinalizeQueue", () => { + it("processes multiple job ids instead of dropping overlaps", async () => { + vi.useFakeTimers(); + const calls = []; + const queue = createJobFinalizeQueue({ + defaultDelayMs: 25, + postJob: async (jobId) => { + calls.push(jobId); + }, + }); + + queue.schedule("job-a"); + queue.schedule("job-b"); + + await vi.advanceTimersByTimeAsync(25); + + expect(calls).toEqual(["job-a", "job-b"]); + queue.dispose(); + vi.useRealTimers(); + }); + + it("coalesces duplicate schedules for the same job id", async () => { + vi.useFakeTimers(); + const calls = []; + const queue = createJobFinalizeQueue({ + defaultDelayMs: 50, + postJob: async (jobId) => { + calls.push(jobId); + }, + }); + + queue.schedule("job-a"); + queue.schedule("job-a"); + + await vi.advanceTimersByTimeAsync(50); + + expect(calls).toEqual(["job-a"]); + queue.dispose(); + vi.useRealTimers(); + }); +}); diff --git a/js/tests/ids.vitest.mjs b/js/tests/ids.vitest.mjs new file mode 100644 index 00000000..33350a66 --- /dev/null +++ b/js/tests/ids.vitest.mjs @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeAssetId } from "../utils/ids.js"; + +describe("normalizeAssetId", () => { + it("accepts positive integer ids", () => { + expect(normalizeAssetId(42)).toBe("42"); + expect(normalizeAssetId(" 42 ")).toBe("42"); + }); + + it("rejects empty, non-numeric, and non-positive ids", () => { + expect(normalizeAssetId("")).toBe(""); + expect(normalizeAssetId("abc")).toBe(""); + expect(normalizeAssetId("0")).toBe(""); + expect(normalizeAssetId("-7")).toBe(""); + }); +}); diff --git a/js/utils/ids.js b/js/utils/ids.js index e94c7fa6..b84af4ad 100644 --- a/js/utils/ids.js +++ b/js/utils/ids.js @@ -6,8 +6,21 @@ function normalizeId(value) { } } +export function normalizePositiveIntId(value) { + const raw = normalizeId(value); + if (!raw) return ""; + if (!/^\d+$/.test(raw)) return ""; + try { + const parsed = Number(raw); + if (!Number.isSafeInteger(parsed) || parsed <= 0) return ""; + return String(parsed); + } catch { + return ""; + } +} + export function normalizeAssetId(assetId) { - return normalizeId(assetId); + return normalizePositiveIntId(assetId); } export function pickRootId(obj) { diff --git a/mjr_am_backend/features/stacks/service.py b/mjr_am_backend/features/stacks/service.py index c8d0c061..5df13052 100644 --- a/mjr_am_backend/features/stacks/service.py +++ b/mjr_am_backend/features/stacks/service.py @@ -73,6 +73,12 @@ class StacksService: (job_id, name, now, now), ) if not insert.ok: + # Another worker may have created the row after our existence check. + existing = await self.db.aquery( + "SELECT id FROM asset_stacks WHERE job_id = ?", (job_id,) + ) + if existing.ok and existing.data: + return Result.Ok(int(existing.data[0]["id"])) return Result.Err("DB_ERROR", insert.error or "Failed to create stack") # Fetch the new id diff --git a/requirements-vector.txt b/requirements-vector.txt new file mode 100644 index 00000000..f65b5b3a --- /dev/null +++ b/requirements-vector.txt @@ -0,0 +1,35 @@ +# Optional AI / vector feature dependencies for ComfyUI-Majoor-AssetsManager +# Install with: +# pip install -r requirements.txt -r requirements-vector.txt + +-r requirements.txt + +# --- CLIP / Semantic Search (optional, enable with MJR_ENABLE_VECTOR_SEARCH=1) --- +# sentence-transformers wraps HuggingFace models including CLIP ViT-L/14 +sentence-transformers>=2.2.0,<4.0.0 + +# Explicit HF stack for SigLIP2 / X-CLIP / Florence-2 runtime compatibility +transformers>=4.44.0,<5.0.0 +huggingface-hub>=0.24.0,<1.0.0 +safetensors>=0.4.0,<1.0.0 + +# Protobuf is required by SigLIP2/SiglipConfig for model config deserialization +protobuf>=3.20.0,<6.0.0 + +# timm is required by Florence-2 model implementation +timm>=0.9.0,<2.0.0 + +# einops is required by Florence-2 vision encoder +einops>=0.7.0,<1.0.0 + +# sentencepiece is required by the SigLIP2 tokenizer (SiglipTokenizer) +sentencepiece>=0.1.99,<1.0.0 + +# OpenCV headless for video frame extraction fallback when ffprobe/ffmpeg are unavailable +opencv-python-headless>=4.8.0,<5.0.0 + +# Efficient approximate nearest-neighbour search (CPU-only build) +faiss-cpu>=1.7.0,<2.0.0 + +# scikit-learn is used for K-Means clustering in the auto-collections utility +scikit-learn>=1.3.0,<2.0.0 diff --git a/requirements.txt b/requirements.txt index 5efce825..52be76c0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,39 +21,9 @@ watchdog>=3.0.0,<7.0.0 # Windows-specific: Windows Property System metadata support # Only required on Windows; gracefully skipped on other platforms -pywin32>=300,<312; sys_platform == 'win32' +pywin32>=300,<311; sys_platform == 'win32' # Note: tkinter is required for native folder browser functionality # tkinter is included in standard Python distribution but may be missing in minimal installs -# --- CLIP / Semantic Search (optional, enable with MJR_ENABLE_VECTOR_SEARCH=1) --- -# sentence-transformers wraps HuggingFace models including CLIP ViT-L/14 -sentence-transformers>=2.2.0,<4.0.0 - -# Explicit HF stack for SigLIP2 / X-CLIP / Florence-2 runtime compatibility -transformers>=4.44.0,<5.0.0 -huggingface-hub>=0.24.0,<1.0.0 -safetensors>=0.4.0,<1.0.0 - -# Protobuf is required by SigLIP2/SiglipConfig for model config deserialization -protobuf>=3.20.0,<6.0.0 - -# timm is required by Florence-2 model implementation -timm>=0.9.0,<2.0.0 - -# einops is required by Florence-2 vision encoder -einops>=0.7.0,<1.0.0 - -# sentencepiece is required by the SigLIP2 tokenizer (SiglipTokenizer) -sentencepiece>=0.1.99,<1.0.0 - -# OpenCV headless for video frame extraction fallback when ffprobe/ffmpeg are unavailable -opencv-python-headless>=4.8.0,<5.0.0 - -# Efficient approximate nearest-neighbour search (CPU-only build) -faiss-cpu>=1.7.0,<2.0.0 - -# scikit-learn is used for K-Means clustering in the auto-collections utility -scikit-learn>=1.3.0,<2.0.0 - # Dev/test tools are intentionally kept out of runtime requirements. diff --git a/scripts/run_quality_gate.py b/scripts/run_quality_gate.py index 8e0a856f..bd2bd16a 100644 --- a/scripts/run_quality_gate.py +++ b/scripts/run_quality_gate.py @@ -40,9 +40,11 @@ DEFAULT_PYTEST_ARGS = [ "-q", "--cov=mjr_am_backend", "--cov=mjr_am_shared", + "--cov-fail-under=60", "--cov-report=xml", "--cov-report=term-missing:skip-covered", ] +PIP_AUDIT_REQUIREMENTS = ["requirements.txt"] def _parse_args() -> argparse.Namespace: @@ -123,6 +125,16 @@ def _existing_paths(values: list[str]) -> list[str]: return existing +def _pip_audit_cmd() -> list[str]: + requirement_files = _existing_paths(PIP_AUDIT_REQUIREMENTS) + cmd = _python_cmd("pip_audit", "--strict", "--skip-editable") + if requirement_files: + for requirement_file in requirement_files: + cmd.extend(["-r", requirement_file]) + return cmd + return cmd + + def _npm_available() -> bool: return shutil.which("npm") is not None @@ -166,7 +178,10 @@ def main() -> int: _run(_python_cmd("bandit", "-r", *bandit_targets, "-ll", "-ii", "-x", "tests"), label="Bandit") if not args.skip_pip_audit: - _run(_python_cmd("pip_audit", "--strict", "--skip-editable"), label="pip-audit") + _run( + _pip_audit_cmd(), + label="pip-audit", + ) if not args.skip_complexity: _run( diff --git a/tests/features/test_stacks_service.py b/tests/features/test_stacks_service.py new file mode 100644 index 00000000..e8c6edaa --- /dev/null +++ b/tests/features/test_stacks_service.py @@ -0,0 +1,63 @@ +from mjr_am_backend.features.stacks.service import StacksService + + +class _DbStub: + def __init__(self, *, query_results=None, execute_results=None): + self.query_results = list(query_results or []) + self.execute_results = list(execute_results or []) + + async def aquery(self, _sql, _params=()): + if self.query_results: + return self.query_results.pop(0) + raise AssertionError("Unexpected aquery call") + + async def aexecute(self, _sql, _params=()): + if self.execute_results: + return self.execute_results.pop(0) + raise AssertionError("Unexpected aexecute call") + + +def _ok(data): + return type("Res", (), {"ok": True, "data": data, "error": None})() + + +def _err(message): + return type("Res", (), {"ok": False, "data": None, "error": message})() + + +async def test_create_or_get_stack_returns_existing_row_after_insert_race(): + db = _DbStub( + query_results=[ + _ok([]), + _ok([{"id": 17}]), + ], + execute_results=[ + _err("UNIQUE constraint failed: asset_stacks.job_id"), + ], + ) + + service = StacksService(db) + + result = await service.create_or_get_stack("job-123") + + assert result.ok is True + assert result.data == 17 + + +async def test_create_or_get_stack_still_errors_when_insert_and_requery_fail(): + db = _DbStub( + query_results=[ + _ok([]), + _ok([]), + ], + execute_results=[ + _err("database is locked"), + ], + ) + + service = StacksService(db) + + result = await service.create_or_get_stack("job-123") + + assert result.ok is False + assert result.code == "DB_ERROR" diff --git a/user_guide.html b/user_guide.html index a45a16d5..76cc129d 100644 --- a/user_guide.html +++ b/user_guide.html @@ -314,6 +314,9 @@ git clone https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager ComfyUI-Ma cd ComfyUI-Majoor-AssetsManager pip install -r requirements.txt +# Optional: install AI/vector features +# pip install -r requirements.txt -r requirements-vector.txt + # Restart ComfyUI
custom_nodes/ComfyUI-Majoor-AssetsManagerpip install -r requirements.txtpip install -r requirements.txtpip install -r requirements.txt -r requirements-vector.txt