diff --git a/locales/de.json b/locales/de.json
index 5d57b808..f69d8044 100644
--- a/locales/de.json
+++ b/locales/de.json
@@ -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\")",
diff --git a/locales/en.json b/locales/en.json
index 4776b73f..94a1a499 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -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\")",
diff --git a/locales/es.json b/locales/es.json
index 58c44488..0daff35b 100644
--- a/locales/es.json
+++ b/locales/es.json
@@ -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\")",
diff --git a/locales/fr.json b/locales/fr.json
index 700a42d5..54d66827 100644
--- a/locales/fr.json
+++ b/locales/fr.json
@@ -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\")",
diff --git a/locales/he.json b/locales/he.json
index d9e34627..256b5ad4 100644
--- a/locales/he.json
+++ b/locales/he.json
@@ -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\")",
diff --git a/locales/ja.json b/locales/ja.json
index 3e3426f4..656398fc 100644
--- a/locales/ja.json
+++ b/locales/ja.json
@@ -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\")",
diff --git a/locales/ko.json b/locales/ko.json
index afe1a755..899750fc 100644
--- a/locales/ko.json
+++ b/locales/ko.json
@@ -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\")",
diff --git a/locales/ru.json b/locales/ru.json
index 5eecbf56..ae7380da 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -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\")",
diff --git a/locales/zh-CN.json b/locales/zh-CN.json
index 77753b80..7b39ac29 100644
--- a/locales/zh-CN.json
+++ b/locales/zh-CN.json
@@ -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”)",
diff --git a/locales/zh-TW.json b/locales/zh-TW.json
index 092a0aff..a5c67f04 100644
--- a/locales/zh-TW.json
+++ b/locales/zh-TW.json
@@ -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」)",
diff --git a/py/services/download_manager.py b/py/services/download_manager.py
index 6a7e06f7..c2d595bc 100644
--- a/py/services/download_manager.py
+++ b/py/services/download_manager.py
@@ -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:
diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py
index 132116cf..0e53ffc2 100644
--- a/py/services/settings_manager.py
+++ b/py/services/settings_manager.py
@@ -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
diff --git a/py/utils/constants.py b/py/utils/constants.py
index 9fecbad9..87d41f0f 100644
--- a/py/utils/constants.py
+++ b/py/utils/constants.py
@@ -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": "",
}
diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js
index 077939a0..a628f4f0 100644
--- a/static/js/managers/BulkManager.js
+++ b/static/js/managers/BulkManager.js
@@ -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,
diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js
index e4101651..10266136 100644
--- a/static/js/managers/SettingsManager.js
+++ b/static/js/managers/SettingsManager.js
@@ -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 || '';
diff --git a/static/js/utils/constants.js b/static/js/utils/constants.js
index b5e02713..d1b1c6c9 100644
--- a/static/js/utils/constants.js
+++ b/static/js/utils/constants.js
@@ -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.
diff --git a/templates/components/modals/settings/organization.html b/templates/components/modals/settings/organization.html
index 7edeb8d1..8b4b9993 100644
--- a/templates/components/modals/settings/organization.html
+++ b/templates/components/modals/settings/organization.html
@@ -93,6 +93,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('settings.downloadPathTemplates.workflowHint') }}
+
+
diff --git a/tests/frontend/managers/BulkManager.bulkDelete.test.js b/tests/frontend/managers/BulkManager.bulkDelete.test.js
index 62b3522e..34e242a6 100644
--- a/tests/frontend/managers/BulkManager.bulkDelete.test.js
+++ b/tests/frontend/managers/BulkManager.bulkDelete.test.js
@@ -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,
diff --git a/tests/frontend/managers/settingsManager.library.test.js b/tests/frontend/managers/settingsManager.library.test.js
index 7d9dea12..93a83b6a 100644
--- a/tests/frontend/managers/settingsManager.library.test.js
+++ b/tests/frontend/managers/settingsManager.library.test.js
@@ -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();
diff --git a/tests/routes/test_workflow_routes.py b/tests/routes/test_workflow_routes.py
index f9fafc83..cbf81ea1 100644
--- a/tests/routes/test_workflow_routes.py
+++ b/tests/routes/test_workflow_routes.py
@@ -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
diff --git a/tests/services/test_download_manager_workflows.py b/tests/services/test_download_manager_workflows.py
index 94047baa..93e16b92 100644
--- a/tests/services/test_download_manager_workflows.py
+++ b/tests/services/test_download_manager_workflows.py
@@ -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
diff --git a/tests/services/test_model_file_service.py b/tests/services/test_model_file_service.py
index e659f759..b0eb1b54 100644
--- a/tests/services/test_model_file_service.py
+++ b/tests/services/test_model_file_service.py
@@ -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, "/"))
+ ]
diff --git a/tests/services/test_settings_manager.py b/tests/services/test_settings_manager.py
index 81b62568..73e7dee2 100644
--- a/tests/services/test_settings_manager.py
+++ b/tests/services/test_settings_manager.py
@@ -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"] = {}
diff --git a/tests/services/test_workflow_auto_organize.py b/tests/services/test_workflow_auto_organize.py
new file mode 100644
index 00000000..3a9e5c46
--- /dev/null
+++ b/tests/services/test_workflow_auto_organize.py
@@ -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()