Merge remote-tracking branch 'origin/feat/workflow-auto-organize'

This commit is contained in:
Will Miao
2026-11-06 12:54:34 +08:00
24 changed files with 445 additions and 17 deletions
+3 -1
View File
@@ -759,10 +759,12 @@
"customTemplate": "Benutzerdefinierte Vorlage"
},
"customTemplatePlaceholder": "Benutzerdefinierte Vorlage eingeben (z.B. {base_model}/{author}/{first_tag})",
"workflowHint": "[TODO: Translate] Applies to workflow downloads and to Auto-Organize on the Workflows page.",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
"embedding": "Embedding",
"workflow": "[TODO: Translate] Workflow"
},
"baseModelPathMappings": "Basismodell-Pfad-Zuordnungen",
"baseModelPathMappingsHelp": "Passen Sie Ordnernamen für spezifische Basismodelle an (z.B. \"Flux.1 D\" → \"flux\")",
+3 -1
View File
@@ -759,10 +759,12 @@
"customTemplate": "Custom Template"
},
"customTemplatePlaceholder": "Enter custom template (e.g., {base_model}/{author}/{first_tag})",
"workflowHint": "Applies to workflow downloads and to Auto-Organize on the Workflows page.",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
"embedding": "Embedding",
"workflow": "Workflow"
},
"baseModelPathMappings": "Base Model Path Mappings",
"baseModelPathMappingsHelp": "Customize folder names for specific base models (e.g., \"Flux.1 D\" → \"flux\")",
+3 -1
View File
@@ -759,10 +759,12 @@
"customTemplate": "Plantilla personalizada"
},
"customTemplatePlaceholder": "Introduce plantilla personalizada (ej., {base_model}/{author}/{first_tag})",
"workflowHint": "[TODO: Translate] Applies to workflow downloads and to Auto-Organize on the Workflows page.",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
"embedding": "Embedding",
"workflow": "[TODO: Translate] Workflow"
},
"baseModelPathMappings": "Mapeos de rutas de modelo base",
"baseModelPathMappingsHelp": "Personalizar nombres de carpetas para modelos base específicos (ej., \"Flux.1 D\" → \"flux\")",
+3 -1
View File
@@ -759,10 +759,12 @@
"customTemplate": "Modèle personnalisé"
},
"customTemplatePlaceholder": "Entrez un modèle personnalisé (ex: {base_model}/{author}/{first_tag})",
"workflowHint": "[TODO: Translate] Applies to workflow downloads and to Auto-Organize on the Workflows page.",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
"embedding": "Embedding",
"workflow": "[TODO: Translate] Workflow"
},
"baseModelPathMappings": "Mappages de chemin de modèle de base",
"baseModelPathMappingsHelp": "Personnaliser les noms de dossiers pour des modèles de base spécifiques (ex: \"Flux.1 D\" → \"flux\")",
+3 -1
View File
@@ -759,10 +759,12 @@
"customTemplate": "תבנית מותאמת אישית"
},
"customTemplatePlaceholder": "הזן תבנית מותאמת אישית (למשל, {base_model}/{author}/{first_tag})",
"workflowHint": "[TODO: Translate] Applies to workflow downloads and to Auto-Organize on the Workflows page.",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
"embedding": "Embedding",
"workflow": "[TODO: Translate] Workflow"
},
"baseModelPathMappings": "מיפוי נתיבי מודל בסיס",
"baseModelPathMappingsHelp": "התאם אישית שמות תיקיות עבור מודלי בסיס ספציפיים (למשל, \"Flux.1 D\" → \"flux\")",
+3 -1
View File
@@ -759,10 +759,12 @@
"customTemplate": "カスタムテンプレート"
},
"customTemplatePlaceholder": "カスタムテンプレートを入力(例:{base_model}/{author}/{first_tag})",
"workflowHint": "[TODO: Translate] Applies to workflow downloads and to Auto-Organize on the Workflows page.",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
"embedding": "Embedding",
"workflow": "[TODO: Translate] Workflow"
},
"baseModelPathMappings": "ベースモデルパスマッピング",
"baseModelPathMappingsHelp": "特定のベースモデルのフォルダ名をカスタマイズ(例:\"Flux.1 D\" → \"flux\")",
+3 -1
View File
@@ -759,10 +759,12 @@
"customTemplate": "사용자 정의 템플릿"
},
"customTemplatePlaceholder": "사용자 정의 템플릿 입력 (예: {base_model}/{author}/{first_tag})",
"workflowHint": "[TODO: Translate] Applies to workflow downloads and to Auto-Organize on the Workflows page.",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
"embedding": "Embedding",
"workflow": "[TODO: Translate] Workflow"
},
"baseModelPathMappings": "베이스 모델 경로 매핑",
"baseModelPathMappingsHelp": "특정 베이스 모델의 폴더 이름을 사용자 정의합니다 (예: \"Flux.1 D\" → \"flux\")",
+3 -1
View File
@@ -759,10 +759,12 @@
"customTemplate": "Пользовательский шаблон"
},
"customTemplatePlaceholder": "Введите пользовательский шаблон (например, {base_model}/{author}/{first_tag})",
"workflowHint": "[TODO: Translate] Applies to workflow downloads and to Auto-Organize on the Workflows page.",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
"embedding": "Embedding",
"workflow": "[TODO: Translate] Workflow"
},
"baseModelPathMappings": "Сопоставления путей базовых моделей",
"baseModelPathMappingsHelp": "Настройте имена папок для конкретных базовых моделей (например, \"Flux.1 D\" → \"flux\")",
+3 -1
View File
@@ -759,10 +759,12 @@
"customTemplate": "自定义模板"
},
"customTemplatePlaceholder": "输入自定义模板(如:{base_model}/{author}/{first_tag})",
"workflowHint": "[TODO: Translate] Applies to workflow downloads and to Auto-Organize on the Workflows page.",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
"embedding": "Embedding",
"workflow": "[TODO: Translate] Workflow"
},
"baseModelPathMappings": "基础模型路径映射",
"baseModelPathMappingsHelp": "为特定基础模型自定义文件夹名称(如“Flux.1 D”→“flux”)",
+3 -1
View File
@@ -759,10 +759,12 @@
"customTemplate": "自訂範本"
},
"customTemplatePlaceholder": "輸入自訂範本(例如:{base_model}/{author}/{first_tag})",
"workflowHint": "[TODO: Translate] Applies to workflow downloads and to Auto-Organize on the Workflows page.",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
"embedding": "Embedding",
"workflow": "[TODO: Translate] Workflow"
},
"baseModelPathMappings": "基礎模型路徑對應",
"baseModelPathMappingsHelp": "自訂特定基礎模型的資料夾名稱(例如:「Flux.1 D」→「flux」)",
+2
View File
@@ -2177,6 +2177,8 @@ class DownloadManager:
elif model_type == "workflow":
# Workflows have no default-root setting: the first
# configured workflow root is the download target.
# The workflow path template (Settings > Organization)
# is applied below, like every other model type.
workflow_scanner = await ServiceRegistry.get_workflow_scanner()
workflow_roots = workflow_scanner.get_model_roots()
if not workflow_roots:
+1 -1
View File
@@ -2795,7 +2795,7 @@ class SettingsManager:
Args:
model_type: The type of model ('lora', 'checkpoint', 'embedding',
'other')
'workflow', 'other')
Returns:
Template string for the model type. Falls back to the per-type
+7
View File
@@ -309,10 +309,17 @@ DEFAULT_PRIORITY_TAG_CONFIG = {
# "other" entry, so {first_tag} would resolve to an arbitrary CivitAI tag and
# scatter files into unstable folders. Users can still opt in to a template by
# writing "other" into download_path_templates in settings.json.
#
# Workflows get a single level on purpose: workflow management is opt-in and
# existing libraries are flat, so the first enabled download organizes by the
# resolved base model without scattering files over CivitAI tags, which local
# (non-CivitAI) workflows usually do not have. The same template drives
# Auto-Organize on the Workflows page.
DEFAULT_DOWNLOAD_PATH_TEMPLATES: Dict[str, str] = {
"lora": "{base_model}/{first_tag}",
"checkpoint": "{base_model}/{first_tag}",
"embedding": "{base_model}/{first_tag}",
"workflow": "{base_model}",
"other": "",
}
+3 -2
View File
@@ -130,8 +130,9 @@ export class BulkManager {
refreshAll: true,
checkUpdates: true,
moveAll: true,
// Workflows have no base-model/tag folder routing.
autoOrganize: false,
// Workflow routing is the workflow download path template
// (Settings > Organization), the same one downloads use.
autoOrganize: true,
deleteAll: true,
setContentRating: true,
skipMetadataRefresh: true,
+15 -3
View File
@@ -3010,15 +3010,20 @@ export class SettingsManager {
const previewElement = document.getElementById(`${modelType}Preview`);
if (!previewElement) return;
// Workflow artifacts are JSON/zip documents, not weight files.
const exampleFile = modelType === 'workflow'
? 'workflow-name.json'
: 'model-name.safetensors';
if (!template) {
previewElement.textContent = 'model-name.safetensors';
previewElement.textContent = exampleFile;
} else {
// Generate example preview
const exampleTemplate = template
.replace('{base_model}', 'Flux.1 D')
.replace('{author}', 'authorname')
.replace('{first_tag}', 'style');
previewElement.textContent = `${exampleTemplate}/model-name.safetensors`;
previewElement.textContent = `${exampleTemplate}/${exampleFile}`;
}
previewElement.style.display = 'block';
}
@@ -3411,7 +3416,9 @@ export class SettingsManager {
* Reflect the opt-in Workflow management state in the settings UI: the
* master toggle gates the single workflow library folder input
* (workflow_path, LoRA-Manager-owned in both plugin and standalone mode,
* applied live without a restart).
* applied live without a restart) and the workflow download path template
* in Settings > Organization. The template row stays hidden while the
* feature is off because no workflow download or organization can use it.
*/
updateWorkflowsControls() {
const enabled = !!state.global.settings.enable_workflows;
@@ -3426,6 +3433,11 @@ export class SettingsManager {
group.style.display = enabled ? '' : 'none';
}
const templateSetting = document.getElementById('workflowPathTemplateSetting');
if (templateSetting) {
templateSetting.style.display = enabled ? '' : 'none';
}
const input = document.getElementById('workflowPath');
if (input) {
input.value = state.global.settings.workflow_path || '';
+4
View File
@@ -357,6 +357,10 @@ export const DEFAULT_PATH_TEMPLATES = {
checkpoint: '{base_model}',
unet: '{base_model}',
embedding: '{first_tag}',
// Workflows group by the base model resolved from their referenced
// models. Local workflows rarely carry CivitAI tags, so {first_tag}
// would mostly resolve to the "no tags" fallback folder.
workflow: '{base_model}',
// Other models (VAE/upscaler/...) default to a flat layout: their root is
// already split per sub_type, and priority_tags has no "other" entry, so
// {first_tag} would resolve to an arbitrary CivitAI tag.
@@ -93,6 +93,29 @@
<div class="template-preview" id="embeddingPreview"></div>
</div>
<!-- Workflow routing exists only while Workflow management is enabled;
SettingsManager.updateWorkflowsControls() gates this row. -->
<div class="setting-item" id="workflowPathTemplateSetting" style="display: none;">
<div class="setting-row">
<div class="setting-info">
<label for="workflowTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.workflow') }}</label>
</div>
<div class="setting-control select-control">
<select id="workflowTemplatePreset" onchange="settingsManager.updateTemplatePreset('workflow', this.value)">
{% for value, option_label in template_preset_options %}
<option value="{{ value }}">{{ t(option_label) }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="template-custom-row" id="workflowCustomRow" style="display: none;">
<input type="text" id="workflowCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
<div class="template-validation" id="workflowValidation"></div>
</div>
<div class="template-preview" id="workflowPreview"></div>
<div class="input-help">{{ t('settings.downloadPathTemplates.workflowHint') }}</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
@@ -329,6 +329,8 @@ describe('BulkManager.confirmBulkDelete undo flows', () => {
const config = bulk.actionConfig.workflows;
// Model-only actions stay hidden; workflow-capable actions are enabled.
// Auto-Organize is enabled: workflows use the workflow download path
// template from Settings > Organization for folder routing.
expect(config).toEqual(expect.objectContaining({
addTags: true,
sendToWorkflow: false,
@@ -336,7 +338,7 @@ describe('BulkManager.confirmBulkDelete undo flows', () => {
refreshAll: true,
checkUpdates: true,
moveAll: true,
autoOrganize: false,
autoOrganize: true,
deleteAll: true,
setContentRating: true,
skipMetadataRefresh: true,
@@ -833,6 +833,63 @@ describe('SettingsManager Other Models nav and banner sync', () => {
});
});
describe('SettingsManager workflow template visibility', () => {
const appendWorkflowControls = () => {
const toggle = document.createElement('input');
toggle.id = 'enableWorkflows';
toggle.type = 'checkbox';
const pathGroup = document.createElement('div');
pathGroup.id = 'workflowPathSetting';
// Settings > Organization row for the workflow download path template.
const templateGroup = document.createElement('div');
templateGroup.id = 'workflowPathTemplateSetting';
const input = document.createElement('input');
input.id = 'workflowPath';
document.body.append(toggle, pathGroup, templateGroup, input);
};
it('keeps the workflow download template hidden while workflow management is off', () => {
const manager = createManager();
appendWorkflowControls();
state.global.settings.enable_workflows = false;
manager.updateWorkflowsControls();
expect(document.getElementById('workflowPathTemplateSetting').style.display).toBe('none');
expect(document.getElementById('workflowPathSetting').style.display).toBe('none');
expect(document.getElementById('enableWorkflows').checked).toBe(false);
});
it('shows the workflow download template once workflow management is enabled', () => {
const manager = createManager();
appendWorkflowControls();
state.global.settings.enable_workflows = true;
state.global.settings.workflow_path = '/library/workflows';
manager.updateWorkflowsControls();
expect(document.getElementById('workflowPathTemplateSetting').style.display).toBe('');
expect(document.getElementById('workflowPathSetting').style.display).toBe('');
expect(document.getElementById('enableWorkflows').checked).toBe(true);
expect(document.getElementById('workflowPath').value).toBe('/library/workflows');
});
it('previews workflow templates with a workflow artifact name', () => {
const manager = createManager();
const preview = document.createElement('div');
preview.id = 'workflowPreview';
document.body.appendChild(preview);
manager.updateTemplatePreview('workflow', '{base_model}');
expect(preview.textContent).toBe('Flux.1 D/workflow-name.json');
});
});
describe('SettingsManager recipes layout switch', () => {
it('dispatches lm:recipes-layout-changed without recalculating the old scroller', async () => {
const manager = createManager();
+3
View File
@@ -106,6 +106,9 @@ def test_common_and_specific_routes_registered():
assert ("POST", "/api/lm/workflows/fetch-civitai") in registered
assert ("POST", "/api/lm/workflows/delete") in registered
assert ("POST", "/api/lm/workflows/move_model") in registered
# Auto-Organize selected (shared model route; workflow path template).
assert ("POST", "/api/lm/workflows/auto-organize") in registered
assert ("GET", "/api/lm/workflows/auto-organize-progress") in registered
assert ("GET", "/api/lm/workflows/info/{name}") in registered
# The companion browser extension is GET-only (see AGENTS.md).
assert ("POST", "/api/lm/workflows/send-to-comfy") in registered
@@ -268,10 +268,60 @@ async def test_default_paths_use_first_workflow_root(
model_version_id=99, use_default_paths=True
)
assert result["success"] is True
# The default workflow template ({base_model}) is applied below the root,
# exactly like the other model types.
assert captured["save_dir"] == str(workflow_root / "SDXL 1.0")
@pytest.mark.asyncio
async def test_default_paths_keep_flat_workflow_root_when_template_is_empty(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""An explicitly flat workflow template preserves the old layout."""
get_settings_manager().settings["download_path_templates"] = {"workflow": ""}
workflow_root = tmp_path / "workflows"
workflow_root.mkdir()
scanners.workflow._roots = [str(workflow_root)]
captured = {}
_capture_execute(monkeypatch, captured)
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=99, use_default_paths=True
)
assert result["success"] is True
assert captured["save_dir"] == str(workflow_root)
@pytest.mark.asyncio
async def test_default_paths_apply_workflow_first_tag_template(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""CivitAI workflow tags feed {first_tag} like they do for models."""
get_settings_manager().settings["download_path_templates"] = {
"workflow": "{base_model}/{first_tag}"
}
workflow_root = tmp_path / "workflows"
workflow_root.mkdir()
scanners.workflow._roots = [str(workflow_root)]
captured = {}
_capture_execute(monkeypatch, captured)
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=99, use_default_paths=True
)
assert result["success"] is True
assert captured["save_dir"] == str(workflow_root / "SDXL 1.0" / "utility")
@pytest.mark.asyncio
async def test_default_paths_error_without_workflow_root(
monkeypatch, scanners, metadata_provider
+77
View File
@@ -905,3 +905,80 @@ async def test_auto_organize_skips_model_when_target_path_exceeds_budget(
assert result.skipped_count == 1
assert scanner.moves == []
assert "path length limit" in result.results[0]["message"]
def _workflow_organize_setup(
tmp_path: Path, *, template: str | None = None
) -> tuple:
"""Build a workflow ModelFileService over a fake scanner.
``template=None`` leaves the workflow entry out of the settings so the
default in DEFAULT_DOWNLOAD_PATH_TEMPLATES applies; pass a string to pin
the template.
"""
from py.services.model_file_service import ModelFileService
from py.services.settings_manager import get_settings_manager
manager = get_settings_manager()
templates = dict(manager.settings.get("download_path_templates") or {})
if template is None:
templates.pop("workflow", None)
else:
templates["workflow"] = template
manager.settings["download_path_templates"] = templates
nested_dir = tmp_path / "old"
nested_dir.mkdir()
workflow_path = nested_dir / "portrait.workflow.json"
workflow_path.write_text("{}", encoding="utf-8")
models = [
{
"file_path": workflow_path.as_posix(),
"model_name": "portrait",
"file_name": "portrait",
"base_model": "SDXL 1.0",
"tags": [],
"civitai": {},
}
]
scanner = AutoOrganizeScanner(tmp_path, models)
return ModelFileService(scanner, "workflow"), scanner, workflow_path
@pytest.mark.asyncio
async def test_auto_organize_workflows_uses_the_workflow_template(tmp_path: Path):
"""Auto-Organize on the Workflows page routes by the workflow template."""
service, scanner, workflow_path = _workflow_organize_setup(tmp_path)
result = await service.auto_organize_models(
file_paths=[workflow_path.as_posix()]
)
assert result.operation_type == "bulk"
assert result.success_count == 1
assert scanner.moves == [
(
workflow_path.as_posix(),
str(tmp_path / "SDXL 1.0").replace(os.sep, "/"),
)
]
@pytest.mark.asyncio
async def test_auto_organize_workflows_flattens_when_template_is_empty(
tmp_path: Path,
):
"""An empty workflow template keeps the historical flat layout: selected
files are moved back to the workflow root, not into a tag folder."""
service, scanner, workflow_path = _workflow_organize_setup(
tmp_path, template=""
)
result = await service.auto_organize_models(
file_paths=[workflow_path.as_posix()]
)
assert result.success_count == 1
assert scanner.moves == [
(workflow_path.as_posix(), str(tmp_path).replace(os.sep, "/"))
]
+13
View File
@@ -412,6 +412,19 @@ def test_download_path_template_defaults_flat_for_other(manager):
assert manager.get_download_path_template("other") == "{base_model}"
def test_download_path_template_defaults_to_base_model_for_workflow(manager):
"""Workflow downloads and Auto-Organize share this template. Local
workflows rarely carry CivitAI tags, so the default stops at the base
model instead of adding a "no tags" folder."""
manager.settings["download_path_templates"] = {}
assert manager.get_download_path_template("workflow") == "{base_model}"
# An explicit user configuration still wins.
manager.settings["download_path_templates"] = {"workflow": "{first_tag}"}
assert manager.get_download_path_template("workflow") == "{first_tag}"
def test_download_path_template_unknown_type_is_flat(manager):
manager.settings["download_path_templates"] = {}
@@ -0,0 +1,157 @@
"""Auto-Organize for the workflow library (issue #1177).
The workflow page reuses the shared auto-organize endpoint; these tests pin
the workflow-specific pieces: the default path template and the fact that a
move carries the workflow sidecars (derived document, metadata, preview)
along with the artifact itself.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from py import config as config_module
from py.services.model_cache import ModelCache
from py.services.model_file_service import ModelFileService
from py.services.model_hash_index import ModelHashIndex
from py.services.settings_manager import get_settings_manager
from py.services.workflow_scanner import WorkflowScanner
from py.utils.models import WorkflowMetadata
def _normalize(path) -> str:
return str(path).replace(os.sep, "/")
def _make_workflow_scanner(root: Path) -> WorkflowScanner:
"""Wire a WorkflowScanner for a move without async initialization."""
scanner = object.__new__(WorkflowScanner)
scanner.model_type = "workflow"
scanner.model_class = WorkflowMetadata
scanner.file_extensions = {".json", ".zip", ".png", ".webp"}
scanner._cache = None
scanner._cache_version = 0
scanner._hash_index = ModelHashIndex()
scanner._tags_count = {}
scanner._excluded_models = []
scanner._is_initializing = False
scanner._persistent_cache = MagicMock()
scanner._name_display_mode = "model_name"
scanner._cancel_requested = False
scanner._move_locks = {}
scanner._all_folders_backfill_running = False
scanner.get_model_roots = lambda: [_normalize(root)]
return scanner
def _cache_entry(file_path: str, folder: str) -> dict:
return {
"file_path": _normalize(file_path),
"file_name": "portrait",
"model_name": "portrait",
"folder": folder,
"sha256": "abc123",
"base_model": "SDXL 1.0",
"tags": [],
"civitai": {},
}
@pytest.fixture
def workflow_library(tmp_path, monkeypatch):
root = tmp_path / "workflows"
(root / "old").mkdir(parents=True)
monkeypatch.setattr(config_module.config, "workflows_roots", [_normalize(root)])
monkeypatch.setattr(
get_settings_manager(), "is_workflows_enabled", lambda: True
)
# Pin the workflow template so the test does not depend on the default.
monkeypatch.setitem(
get_settings_manager().settings,
"download_path_templates",
{"workflow": "{base_model}"},
)
return root
@pytest.mark.asyncio
async def test_auto_organize_moves_workflow_with_its_sidecars(workflow_library: Path):
old_dir = workflow_library / "old"
workflow_path = old_dir / "portrait.json"
workflow_path.write_text(
json.dumps({"1": {"class_type": "CheckpointLoaderSimple", "inputs": {}}}),
encoding="utf-8",
)
(old_dir / "portrait.workflow.json").write_text(
json.dumps({"1": {"class_type": "CheckpointLoaderSimple"}}),
encoding="utf-8",
)
(old_dir / "portrait.preview.webp").write_bytes(b"preview")
(old_dir / "portrait.metadata.json").write_text(
json.dumps(
{
"file_path": _normalize(workflow_path),
"file_name": "portrait",
"model_name": "portrait",
"sha256": "abc123",
"sub_type": "workflow",
"base_model": "SDXL 1.0",
"tags": [],
}
),
encoding="utf-8",
)
scanner = _make_workflow_scanner(workflow_library)
scanner._cache = ModelCache(
raw_data=[_cache_entry(workflow_path, "old")], folders=["old"]
)
service = ModelFileService(scanner, "workflow")
result = await service.auto_organize_models(
file_paths=[_normalize(workflow_path)]
)
target_dir = workflow_library / "SDXL 1.0"
assert result.operation_type == "bulk"
assert result.success_count == 1
assert (target_dir / "portrait.json").is_file()
assert (target_dir / "portrait.workflow.json").is_file()
assert (target_dir / "portrait.preview.webp").read_bytes() == b"preview"
# The metadata sidecar follows the artifact and records the new path.
moved_metadata = json.loads(
(target_dir / "portrait.metadata.json").read_text(encoding="utf-8")
)
assert moved_metadata["file_path"] == _normalize(target_dir / "portrait.json")
# The empty source folder is cleaned up after a bulk run.
assert not (old_dir / "portrait.json").exists()
assert not old_dir.exists()
@pytest.mark.asyncio
async def test_auto_organize_skips_workflow_already_in_place(workflow_library: Path):
target_dir = workflow_library / "SDXL 1.0"
target_dir.mkdir()
workflow_path = target_dir / "portrait.json"
workflow_path.write_text("{}", encoding="utf-8")
scanner = _make_workflow_scanner(workflow_library)
scanner._cache = ModelCache(
raw_data=[_cache_entry(workflow_path, "SDXL 1.0")],
folders=["SDXL 1.0"],
)
service = ModelFileService(scanner, "workflow")
result = await service.auto_organize_models(
file_paths=[_normalize(workflow_path)]
)
assert result.success_count == 0
assert result.skipped_count == 1
assert (target_dir / "portrait.json").is_file()